Compound.make_text: add singleline font support, use FontManager, refactor position_face

This commit is contained in:
Jonathan Wagenet 2025-12-12 20:48:16 -05:00
parent aff4cc4b3a
commit c97b2b74f6
3 changed files with 84 additions and 81 deletions

View file

@ -720,13 +720,13 @@ class BallJoint(Joint):
circle_y,
circle_z,
Compound.make_text(
"X", radius / 5, align=(Align.CENTER, Align.CENTER)
"X", radius / 5, "singleline", align=(Align.CENTER, Align.CENTER)
).locate(circle_x.location_at(0.125) * Rotation(90, 0, 0)),
Compound.make_text(
"Y", radius / 5, align=(Align.CENTER, Align.CENTER)
"Y", radius / 5, "singleline", align=(Align.CENTER, Align.CENTER)
).locate(circle_y.location_at(0.625) * Rotation(90, 0, 0)),
Compound.make_text(
"Z", radius / 5, align=(Align.CENTER, Align.CENTER)
"Z", radius / 5, "singleline", align=(Align.CENTER, Align.CENTER)
).locate(circle_z.location_at(0.125) * Rotation(90, 0, 0)),
]
).move(self.location)

View file

@ -55,8 +55,6 @@ license:
from __future__ import annotations
import copy
import os
import sys
import warnings
from collections.abc import Iterable, Iterator, Sequence
from itertools import combinations
@ -65,14 +63,6 @@ from typing_extensions import Self
import OCP.TopAbs as ta
from OCP.BRepAlgoAPI import BRepAlgoAPI_Common, BRepAlgoAPI_Fuse, BRepAlgoAPI_Section
from OCP.Font import (
Font_FA_Bold,
Font_FA_BoldItalic,
Font_FA_Italic,
Font_FA_Regular,
Font_FontMgr,
Font_SystemFont,
)
from OCP.gp import gp_Ax3
from OCP.Graphic3d import (
Graphic3d_HTA_LEFT,
@ -86,7 +76,6 @@ from OCP.Graphic3d import (
from OCP.GProp import GProp_GProps
from OCP.NCollection import NCollection_Utf8String
from OCP.StdPrs import StdPrs_BRepTextBuilder as Font_BRepTextBuilder, StdPrs_BRepFont
from OCP.TCollection import TCollection_AsciiString
from OCP.TopAbs import TopAbs_ShapeEnum
from OCP.TopoDS import (
TopoDS,
@ -107,6 +96,7 @@ from build123d.geometry import (
VectorLike,
logger,
)
from build123d.text import FONT_ASPECT, FontManager
from .one_d import Edge, Wire, Mixin1D
from .shape_core import (
@ -251,31 +241,33 @@ class Compound(Mixin3D[TopoDS_Compound]):
align: Align | tuple[Align, Align] | None = None,
position_on_path: float = 0.0,
text_path: Edge | Wire | None = None,
single_line_width: float = 0.0,
) -> Compound:
"""2D Text that optionally follows a path.
"""Text that optionally follows a path.
The text that is created can be combined as with other sketch features by specifying
a mode or rotated by the given angle. In addition, edges have been previously created
a mode or rotated by the given angle. In addition, edges have been previously created
with arc or segment, the text will follow the path defined by these edges. The start
parameter can be used to shift the text along the path to achieve precise positioning.
Args:
txt: text to be rendered
font_size: size of the font in model units
font: font name
font_path: path to font file
font_style: text style. Defaults to FontStyle.REGULAR
txt (str): text to render
font_size (float): size of the font in model units
font (str, optional): font name. Defaults to "Arial"
font_path (str, optional): system path to font file. Defaults to None
font_style (Font_Style, optional): font style, REGULAR, BOLD, BOLDITALIC, or
ITALIC. Defaults to Font_Style.REGULAR
text_align (tuple[TextAlign, TextAlign], optional): horizontal text align
LEFT, CENTER, or RIGHT. Vertical text align BOTTOM, CENTER, TOP, or
TOPFIRSTLINE. Defaults to (TextAlign.CENTER, TextAlign.CENTER)
align (Union[Align, tuple[Align, Align]], optional): align min, center, or max
of object. Defaults to None
position_on_path: the relative location on path to position the text,
between 0.0 and 1.0. Defaults to 0.0
text_path: a path for the text to follows. Defaults to None (linear text)
Returns:
a Compound object containing multiple Faces representing the text
align (Align | tuple[Align, Align], optional): align MIN, CENTER, or MAX of
object. Defaults to None
position_on_path (float, optional): the relative location on path to position
the text, values must be between 0.0 and 1.0. Defaults to 0.0
text_path: (Edge | Wire, optional): path for text to follow. Defaults to None
Compound object containing multiple Shapes representing the text
single_line_width (float): width of outlined single line font.
Defaults to 0.0
Examples::
@ -289,39 +281,35 @@ class Compound(Mixin3D[TopoDS_Compound]):
"""
# pylint: disable=too-many-locals
def position_face(orig_face: Face) -> Face:
"""
Reposition a face to the provided path
def position_glyph(glyph: Shape, path: Edge | Wire, position: float) -> Shape:
"""Reposition a glyph shape on provided path
Local coordinates are used to calculate the position of the face
relative to the path. Global coordinates to position the face.
Local coordinates are used to calculate the position of the shape
relative to the path. Global coordinates to position the shape.
"""
assert text_path is not None
bbox = orig_face.bounding_box()
bbox = glyph.bounding_box()
face_bottom_center = Vector((bbox.min.X + bbox.max.X) / 2, 0, 0)
relative_position_on_wire = (
position_on_path + face_bottom_center.X / path_length
)
wire_tangent = text_path.tangent_at(relative_position_on_wire)
relative_position_on_wire = position + face_bottom_center.X / path.length
wire_tangent = path.tangent_at(relative_position_on_wire)
wire_angle = Vector(1, 0, 0).get_signed_angle(wire_tangent)
wire_position = text_path.position_at(relative_position_on_wire)
wire_position = path.position_at(relative_position_on_wire)
return orig_face.translate(wire_position - face_bottom_center).rotate(
return glyph.translate(wire_position - face_bottom_center).rotate(
Axis(wire_position, (0, 0, 1)),
-wire_angle,
)
if sys.platform.startswith("linux"):
os.environ["FONTCONFIG_FILE"] = "/etc/fonts/fonts.conf"
os.environ["FONTCONFIG_PATH"] = "/etc/fonts/"
font_kind = {
FontStyle.REGULAR: Font_FA_Regular,
FontStyle.BOLD: Font_FA_Bold,
FontStyle.ITALIC: Font_FA_Italic,
FontStyle.BOLDITALIC: Font_FA_BoldItalic,
}[font_style]
manager = FontManager()
if font_path and manager.check_font(font_path):
face_names = manager.register_font(font_path, True, False)
# Check if font (name) is in face names and not bad or default (Arial)
font_name = font if font in face_names else face_names[0]
system_font = manager.find_font(font_name, font_style)
else:
system_font = manager.find_font(font, font_style)
# Validate TextAlign parameters
if text_align[0] not in [TextAlign.LEFT, TextAlign.CENTER, TextAlign.RIGHT]:
raise ValueError(
"Horizontal TextAlign must be LEFT, CENTER, or RIGHT. "
@ -352,38 +340,30 @@ class Compound(Mixin3D[TopoDS_Compound]):
TextAlign.TOPFIRSTLINE: Graphic3d_VTA_TOPFIRSTLINE,
}[text_align[1]]
mgr = Font_FontMgr.GetInstance_s()
if font_path and mgr.CheckFont(TCollection_AsciiString(font_path).ToCString()):
font_t = Font_SystemFont(TCollection_AsciiString(font_path))
font_t.SetFontPath(font_kind, TCollection_AsciiString(font_path))
mgr.RegisterFont(font_t, True)
else:
font_t = mgr.FindFont(TCollection_AsciiString(font), font_kind)
logger.info(
"Creating text with font %s located at %s",
font_t.FontName().ToCString(),
font_t.FontPath(font_kind).ToCString(),
system_font.FontName().ToCString(),
system_font.FontPath(FONT_ASPECT[font_style]).ToCString(),
)
# Write text to shape
builder = Font_BRepTextBuilder()
font_i = StdPrs_BRepFont(
NCollection_Utf8String(font_t.FontName().ToCString()),
font_kind,
brep_font = StdPrs_BRepFont(
NCollection_Utf8String(system_font.FontName().ToCString()),
FONT_ASPECT[font_style],
float(font_size),
)
if system_font.IsSingleStrokeFont():
brep_font.SetCompositeCurveMode(False)
text_flat = Compound(
TopoDS.Compound_s(
builder.Perform(
font_i,
NCollection_Utf8String(txt),
gp_Ax3(),
horiz_align,
vert_align,
)
builder.Perform(
brep_font,
NCollection_Utf8String(txt),
gp_Ax3(),
horiz_align,
vert_align,
)
)
@ -393,9 +373,24 @@ class Compound(Mixin3D[TopoDS_Compound]):
Vector(*text_flat.bounding_box().to_align_offset(align_text))
)
if text_path is not None:
path_length = text_path.length
text_flat = Compound([position_face(f) for f in text_flat.faces()])
# Place text on path
if text_path:
glyphs = text_flat.get_top_level_shapes()
text_flat = Compound(
[position_glyph(g, text_path, position_on_path) for g in glyphs]
)
def _make_face(edges: Iterable[Edge]) -> Face:
face = Face(Wire.combine(edges)[0])
if face.normal_at().Z < 0: # flip up-side-down faces
face = -face # pylint: disable=E1130
return face
# Outline single line text
if system_font.IsSingleStrokeFont() and single_line_width > 0:
outline = [e.offset_2d(single_line_width) for e in text_flat.edges()]
outline = [_make_face(o.edges()) for o in outline]
text_flat = Compound([]) + outline
return text_flat
@ -412,14 +407,14 @@ class Compound(Mixin3D[TopoDS_Compound]):
arrow = Wire([arrow_arc, copy.copy(arrow_arc).mirror(Plane.XZ)])
x_label = (
Compound.make_text(
"X", font_size=axes_scale / 4, align=(Align.MIN, Align.CENTER)
"X", axes_scale / 4, "singleline", align=(Align.MIN, Align.CENTER)
)
.move(Location(x_axis @ 1))
.edges()
)
y_label = (
Compound.make_text(
"Y", font_size=axes_scale / 4, align=(Align.MIN, Align.CENTER)
"Y", axes_scale / 4, "singleline", align=(Align.MIN, Align.CENTER)
)
.rotate(Axis.Z, 90)
.move(Location(y_axis @ 1))
@ -427,7 +422,7 @@ class Compound(Mixin3D[TopoDS_Compound]):
)
z_label = (
Compound.make_text(
"Z", font_size=axes_scale / 4, align=(Align.CENTER, Align.MIN)
"Z", axes_scale / 4, "singleline", align=(Align.CENTER, Align.MIN)
)
.rotate(Axis.Y, 90)
.rotate(Axis.X, 90)

View file

@ -47,6 +47,14 @@ class TestCompound(unittest.TestCase):
)
self.assertEqual(len(text.faces()), 4)
singleline = Compound.make_text("test", 10, "singleline", text_path=arc)
outline = Compound.make_text(
"test", 10, "singleline", text_path=arc, single_line_width=0.2
)
self.assertEqual(len(singleline.faces()), 0)
self.assertGreaterEqual(len(singleline.wires()), 4)
self.assertEqual(len(outline.faces()), 4)
def test_fuse(self):
box1 = Solid.make_box(1, 1, 1)
box2 = Solid.make_box(1, 1, 1, Plane((1, 0, 0)))