Merge pull request #1169 from jwagenet/single_line
Some checks failed
benchmarks / benchmarks (macos-14, 3.12) (push) Has been cancelled
benchmarks / benchmarks (macos-15-intel, 3.12) (push) Has been cancelled
benchmarks / benchmarks (ubuntu-latest, 3.12) (push) Has been cancelled
benchmarks / benchmarks (windows-latest, 3.12) (push) Has been cancelled
Upload coverage reports to Codecov / run (push) Has been cancelled
pylint / lint (3.10) (push) Has been cancelled
Wheel building and publishing / Build wheel on ubuntu-latest (push) Has been cancelled
tests / tests (macos-14, 3.10) (push) Has been cancelled
tests / tests (macos-14, 3.14) (push) Has been cancelled
tests / tests (macos-15-intel, 3.10) (push) Has been cancelled
tests / tests (macos-15-intel, 3.14) (push) Has been cancelled
tests / tests (ubuntu-latest, 3.10) (push) Has been cancelled
tests / tests (ubuntu-latest, 3.14) (push) Has been cancelled
tests / tests (windows-latest, 3.10) (push) Has been cancelled
tests / tests (windows-latest, 3.14) (push) Has been cancelled
Run type checking / typecheck (3.10) (push) Has been cancelled
Run type checking / typecheck (3.14) (push) Has been cancelled
Wheel building and publishing / upload_pypi (push) Has been cancelled
Feature: Support single line fonts and add FontManager Class
BIN
docs/assets/objects/align.png
Normal file
|
After Width: | Height: | Size: 107 KiB |
BIN
docs/assets/objects/missing_glyph.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
docs/assets/objects/outline.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
docs/assets/objects/path.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
docs/assets/objects/singleline.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
docs/assets/objects/text.png
Normal file
|
After Width: | Height: | Size: 8.9 KiB |
BIN
docs/assets/objects/text_align.png
Normal file
|
After Width: | Height: | Size: 108 KiB |
|
|
@ -528,6 +528,13 @@ Reference
|
|||
.. autoclass:: Torus
|
||||
.. autoclass:: Wedge
|
||||
|
||||
|
||||
Text
|
||||
----
|
||||
|
||||
.. include:: objects/text.rst
|
||||
|
||||
|
||||
Custom Objects
|
||||
--------------
|
||||
|
||||
|
|
|
|||
17
docs/objects/examples/text.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
from build123d import Text, Pos, Compound, TextAlign, Align, Location, RadiusArc
|
||||
from tcv_screenshots import save_model
|
||||
|
||||
|
||||
text = "The quick brown fox"
|
||||
save_model(Text(text, 10), "text", {"reset_camera": "top"})
|
||||
path = RadiusArc((-50, 0), (50, 0), 100)
|
||||
save_model([path, Text(text, 10, path=path, position_on_path=.5, text_align=(TextAlign.CENTER, TextAlign.BOTTOM))], "path", {"reset_camera": "top"})
|
||||
save_model([Pos(Y=10) * Text(text, 10, "singleline"), Text(text, 10, "singleline", single_line_width=1)], "outline", {"reset_camera": "top"})
|
||||
save_model(Compound.make_text(text, 10, "singleline"), "singleline", {"reset_camera": "top"})
|
||||
|
||||
text = "The quick brown\nfox jumped over\nthe lazy dog."
|
||||
save_model([Location(), Text(text, 2, text_align=(TextAlign.LEFT, TextAlign.TOPFIRSTLINE))], "text_align", {"reset_camera": "top"})
|
||||
save_model([Location(), Text(text, 2, align=(Align.MIN, Align.MIN))], "align", {"reset_camera": "top"})
|
||||
|
||||
t = Text("The", 10, "Source Sans 3 Black")
|
||||
save_model([(Pos(Y=10) * t).wires(), t], "missing_glyph", {"reset_camera": "top"})
|
||||
358
docs/objects/text.rst
Normal file
|
|
@ -0,0 +1,358 @@
|
|||
Create Text Object
|
||||
^^^^^^^^^^^^^^^^^^
|
||||
|
||||
Create text object or add to ``BuildSketch`` using :class:`~objects_sketch.Text`:
|
||||
|
||||
.. image:: /assets/objects/text.png
|
||||
:width: 80%
|
||||
:align: center
|
||||
|
||||
|
|
||||
|
||||
.. code-block:: build123d
|
||||
|
||||
text = "The quick brown fox jumped over the lazy dog."
|
||||
Text(text, 10)
|
||||
|
||||
|
||||
Specify font and style. Fonts have up to 4 font styles: ``REGULAR``, ``BOLD``,
|
||||
``ITALIC``, ``BOLDITALIC``. All fonts can use ``ITALIC`` even if only
|
||||
``REGULAR`` is defined.
|
||||
|
||||
.. code-block:: build123d
|
||||
|
||||
Text(text, 10, "Arial", font_style=FontStyle.BOLD)
|
||||
|
||||
|
||||
Find available fonts on system and available styles:
|
||||
|
||||
.. code-block:: build123d
|
||||
|
||||
from pprint import pprint
|
||||
pprint(available_fonts())
|
||||
|
||||
.. code-block:: text
|
||||
|
||||
[
|
||||
...
|
||||
Font(name='Arial', styles=('REGULAR', 'BOLD', 'BOLDITALIC', 'ITALIC')),
|
||||
Font(name='Arial Black', styles=('REGULAR',)),
|
||||
Font(name='Arial Narrow', styles=('REGULAR', 'BOLD', 'BOLDITALIC', 'ITALIC')),
|
||||
Font(name='Arial Rounded MT Bold', styles=('REGULAR',)),
|
||||
...
|
||||
]
|
||||
|
||||
|
||||
Font faces like ``"Arial Black"`` or ``"Arial Narrow"`` must be specified
|
||||
by name rather than ``FontStyle``:
|
||||
|
||||
.. code-block:: build123d
|
||||
|
||||
Text(text, 10, "Arial Black")
|
||||
|
||||
|
||||
Specify a font file directly by filename:
|
||||
|
||||
.. code-block:: build123d
|
||||
|
||||
Text(text, 10, font_path="DejaVuSans.ttf")
|
||||
|
||||
|
||||
Fonts added via ``font_path`` persist in the font list:
|
||||
|
||||
.. code-block:: build123d
|
||||
|
||||
Text(text, 10, font_path="SourceSans3-VariableFont_wght.ttf")
|
||||
pprint([f.name for f in available_fonts() if "Source Sans" in f.name])
|
||||
Text(text, 10, "Source Sans 3 Medium")
|
||||
|
||||
.. code-block:: text
|
||||
|
||||
['Source Sans 3',
|
||||
'Source Sans 3 Black',
|
||||
'Source Sans 3 ExtraBold',
|
||||
'Source Sans 3 ExtraLight',
|
||||
...]
|
||||
|
||||
|
||||
Add a font file to ``FontManager`` if a font is reused in the script or
|
||||
contains multiple font faces:
|
||||
|
||||
.. code-block:: build123d
|
||||
|
||||
new_font_faces = FontManager().register_font("Roboto-VariableFont_wdth,wght.ttf")
|
||||
pprint(new_font_faces)
|
||||
Text(text, 10, "Roboto")
|
||||
Text(text, 10, "Roboto Black")
|
||||
|
||||
.. code-block:: text
|
||||
|
||||
['Roboto Thin',
|
||||
'Roboto ExtraLight',
|
||||
'Roboto Light',
|
||||
'Roboto',
|
||||
...]
|
||||
|
||||
|
||||
Placement
|
||||
^^^^^^^^^
|
||||
|
||||
Multiline text has two methods of alignment.
|
||||
``text_align`` aligns the text relative to its ``Location``:
|
||||
|
||||
.. image:: /assets/objects/text_align.png
|
||||
:width: 80%
|
||||
:align: center
|
||||
|
||||
|
|
||||
|
||||
.. code-block:: build123d
|
||||
|
||||
Text(text, 10, text_align=(TextAlign.LEFT, TextAlign.TOPFIRSTLINE))
|
||||
|
||||
|
||||
``align`` aligns the object bounding box relative to its ``Location`` *after*
|
||||
text alignment:
|
||||
|
||||
.. image:: /assets/objects/align.png
|
||||
:width: 80%
|
||||
:align: center
|
||||
|
||||
|
|
||||
|
||||
.. code-block:: build123d
|
||||
|
||||
text = "The quick brown\nfox jumped over\nthe lazy dog."
|
||||
Text(text, 10, align=(Align.MIN, Align.MIN))
|
||||
|
||||
|
||||
Place text along an ``Edge`` or ``Wire`` with ``path`` and ``position_on_path``:
|
||||
|
||||
.. image:: /assets/objects/path.png
|
||||
:width: 80%
|
||||
:align: center
|
||||
|
||||
|
|
||||
|
||||
.. code-block:: build123d
|
||||
|
||||
text = "The quick brown fox"
|
||||
path = RadiusArc((-50, 0), (50, 0), 100)
|
||||
Text(
|
||||
text,
|
||||
10,
|
||||
path=path,
|
||||
position_on_path=.5,
|
||||
text_align=(TextAlign.CENTER, TextAlign.BOTTOM)
|
||||
)
|
||||
|
||||
|
||||
Single Line Fonts
|
||||
^^^^^^^^^^^^^^^^^
|
||||
|
||||
``"singleline"`` is a special font referencing ``Relief SingleLine CAD``.
|
||||
Glyphs are represented as single lines rather than filled faces.
|
||||
|
||||
``Text`` creates an outlined face by default. The outline width is controlled
|
||||
by ``single_line_width``. This operation is slow with many glyphs.
|
||||
|
||||
.. image:: /assets/objects/outline.png
|
||||
:width: 80%
|
||||
:align: center
|
||||
|
||||
|
|
||||
|
||||
.. code-block:: build123d
|
||||
|
||||
Text(text, 10, "singleline")
|
||||
Text(text, 10, "singleline", single_line_width=1)
|
||||
|
||||
|
||||
Use ``Compound.make_text()`` to create *unoutlined* single-line text.
|
||||
Useful for routing, engraving, or drawing label paths.
|
||||
|
||||
.. image:: /assets/objects/singleline.png
|
||||
:width: 80%
|
||||
:align: center
|
||||
|
||||
|
|
||||
|
||||
.. code-block:: build123d
|
||||
|
||||
Compound.make_text(text, 10, "singleline")
|
||||
|
||||
|
||||
Common Issues
|
||||
^^^^^^^^^^^^^
|
||||
|
||||
Missing Glyphs or Invalid Geometry
|
||||
==================================
|
||||
|
||||
Modern variable-width fonts often contain glyphs with overlapping stroke
|
||||
outlines, which produce invalid geometry. ``ocp_vscode`` ignores invalid
|
||||
faces.
|
||||
|
||||
.. image:: /assets/objects/missing_glyph.png
|
||||
:align: center
|
||||
|
||||
|
|
||||
|
||||
.. code-block:: build123d
|
||||
|
||||
Text("The", 10, "Source Sans 3 Black")
|
||||
|
||||
|
||||
FileNotFoundError
|
||||
=================
|
||||
|
||||
Ensure relative ``font_path`` specifications are relative to the *current
|
||||
working directory*.
|
||||
|
||||
|
||||
.. Working With Text
|
||||
.. #################
|
||||
|
||||
.. Create text object or add to ``BuildSketch``:
|
||||
|
||||
.. .. code-block:: build123d
|
||||
.. text = "The quick brown fox jumped over the lazy dog."
|
||||
.. Text(text, 10)
|
||||
|
||||
.. Specify font and style. Fonts have up to 4 font styles: ``REGULAR``, ``BOLD``, ``ITALIC``, ``BOLDITALIC``.
|
||||
.. All fonts can use ``ITALIC`` even if only ``REGULAR`` is defined.
|
||||
|
||||
.. .. code-block:: build123d
|
||||
|
||||
.. Text(text, 10, "Arial", font_style=FontStyle.BOLD)
|
||||
|
||||
.. Find available fonts on system and available styles:
|
||||
|
||||
.. .. code-block:: build123d
|
||||
|
||||
.. from pprint import pprint
|
||||
.. pprint(available_fonts())
|
||||
|
||||
.. .. code-block:: text
|
||||
|
||||
.. [
|
||||
.. ...
|
||||
.. Font(name='Arial', styles=('REGULAR', 'BOLD', 'BOLDITALIC', 'ITALIC')),
|
||||
.. Font(name='Arial Black', styles=('REGULAR',)),
|
||||
.. Font(name='Arial Narrow', styles=('REGULAR', 'BOLD', 'BOLDITALIC', 'ITALIC')),
|
||||
.. Font(name='Arial Rounded MT Bold', styles=('REGULAR',)),
|
||||
.. ...
|
||||
.. ]
|
||||
|
||||
.. Font faces like ``"Arial Black"`` or ``"Arial Narrow"`` need to be specified by name rather than ``FontStyle``:
|
||||
|
||||
.. .. code-block:: build123d
|
||||
|
||||
.. Text(text, 10, "Arial Black")
|
||||
|
||||
|
||||
.. .. code-block:: build123d
|
||||
|
||||
.. Text(text, 10, font_path="DejaVuSans.ttf")
|
||||
|
||||
|
||||
.. .. code-block:: build123d
|
||||
|
||||
.. Text(text, 10, font_path="SourceSans3-VariableFont_wght.ttf")
|
||||
.. pprint([f.name for f in available_fonts() if "Source Sans" in f.name])
|
||||
.. Text(text, 10, "Source Sans 3 Medium")
|
||||
|
||||
.. .. code-block:: text
|
||||
|
||||
.. ['Source Sans 3',
|
||||
.. 'Source Sans 3 Black',
|
||||
.. 'Source Sans 3 ExtraBold',
|
||||
.. 'Source Sans 3 ExtraLight',
|
||||
.. ...]
|
||||
|
||||
|
||||
.. .. code-block:: build123d
|
||||
|
||||
.. new_font_faces = FontManager().register_font("Roboto-VariableFont_wdth,wght.ttf")
|
||||
.. pprint(new_font_faces)
|
||||
.. Text(text, 10, "Roboto")
|
||||
.. Text(text, 10, "Roboto Black")
|
||||
|
||||
.. .. code-block:: text
|
||||
|
||||
.. ['Roboto Thin',
|
||||
.. 'Roboto ExtraLight',
|
||||
.. 'Roboto Light',
|
||||
.. 'Roboto',
|
||||
.. ...]
|
||||
|
||||
.. Placement
|
||||
.. #########
|
||||
|
||||
.. Multiline text has two methods of alignment:
|
||||
.. ``text_align`` aligns the text relative to its ``Location``:
|
||||
|
||||
.. .. code-block:: build123d
|
||||
|
||||
.. Text(text, 10, text_align=(TextAlign.LEFT, TextAlign.TOPFIRSTLINE))
|
||||
|
||||
|
||||
.. ``align`` aligns the object bounding box relative to its ``Location`` *after* text alignment:
|
||||
|
||||
.. .. code-block:: build123d
|
||||
|
||||
.. text = "The quick brown\nfox jumped over\nthe lazy dog."
|
||||
.. Text(text, 10, align=(Align.MIN, Align.MIN))
|
||||
|
||||
.. Place text along an ``Edge`` or ``Wire`` with ``path`` and ``position_on_path``:
|
||||
|
||||
.. .. code-block:: build123d
|
||||
|
||||
.. text = "The quick brown fox"
|
||||
.. path = RadiusArc((-50, 0), (50, 0), 100)
|
||||
.. Text(
|
||||
.. text,
|
||||
.. 10,
|
||||
.. path=path,
|
||||
.. position_on_path=.5,
|
||||
.. text_align=(TextAlign.CENTER, TextAlign.BOTTOM)
|
||||
.. )
|
||||
|
||||
.. Single Line Fonts
|
||||
.. #################
|
||||
|
||||
.. ``"singleline"`` is a special font referencing ``Relief SingleLine CAD``.
|
||||
.. Glyphs are represented as single lines rather than filled faces.
|
||||
|
||||
.. ``Text`` creates an outlined face by default.
|
||||
.. The outline width is controlled by ``single_line_width``.
|
||||
.. This operation is slow with many glyphs.
|
||||
|
||||
.. .. code-block:: build123d
|
||||
|
||||
.. Text(text, 10, "singleline")
|
||||
.. Text(text, 10, "singleline", single_line_width=1)
|
||||
|
||||
.. Use ``Compound.make_text`` to create *unoutlined* single-line text.
|
||||
.. Useful for routing, engraving, or drawing label path.
|
||||
|
||||
.. .. code-block:: build123d
|
||||
|
||||
.. Compound.make_text(text, 10, "singleline")
|
||||
|
||||
.. Common Issues
|
||||
.. #############
|
||||
|
||||
.. Missing Glyphs or Invalid Geometry
|
||||
.. ##################################
|
||||
|
||||
.. Modern variable-width fonts often contain glyphs with overlapping stroke outlines, which produce
|
||||
.. invalid geometry. ``ocp_vscode`` ignores invalid faces.
|
||||
|
||||
.. .. code-block:: build123d
|
||||
|
||||
.. Text("The", 10, "Source Sans 3 Black")
|
||||
|
||||
.. FileNotFoundError
|
||||
.. #################
|
||||
|
||||
.. Ensure relative ``font_path`` specifications are relative to the *current working directory*
|
||||
|
|
@ -21,7 +21,7 @@ from build123d.topology import *
|
|||
from build123d.drafting import *
|
||||
from build123d.persistence import modify_copyreg
|
||||
from build123d.exporters3d import *
|
||||
from build123d.utils import available_fonts
|
||||
from build123d.text import available_fonts, FontManager
|
||||
|
||||
from .version import version as __version__
|
||||
|
||||
|
|
@ -173,6 +173,7 @@ __all__ = [
|
|||
"CylindricalJoint",
|
||||
"BallJoint",
|
||||
"DraftAngleError",
|
||||
"FontManager",
|
||||
# Exporter classes
|
||||
"Export2D",
|
||||
"ExportDXF",
|
||||
|
|
|
|||
44
src/build123d/data/fonts/reliefsingleline/OFL.txt
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
Copyright 2022 The Relief SingleLine Project Authors (https://github.com/isdat-type/Relief-SingleLine)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide development of collaborative font projects, to support the font creation efforts of academic and linguistic communities, and to provide a free and open framework in which fonts may be shared and improved in partnership with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The requirement for fonts to remain under this license does not apply to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright Holder(s) under this license and clearly marked as such. This may include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting, or substituting -- in part or in whole -- any of the components of the Original Version, by changing formats or by porting the Font Software to a new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of the Font Software, to use, study, copy, merge, embed, modify, redistribute, and sell modified and unmodified copies of the Font Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components, in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled, redistributed and/or sold with any software, provided that each copy contains the above copyright notice and this license. These can be included either as stand-alone text files, human-readable headers or in the appropriate machine-readable metadata fields within text or binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font Name(s) unless explicit written permission is granted by the corresponding Copyright Holder. This restriction only applies to the primary font name as presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software shall not be used to promote, endorse or advertise any Modified Version, except to acknowledge the contribution(s) of the Copyright Holder(s) and the Author(s) or with their explicit written permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole, must be distributed entirely under this license, and must not be distributed under any other license. The requirement for fonts to remain under this license does not apply to any document created using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -581,6 +581,8 @@ class Text(BaseSketchObject):
|
|||
path (Edge | Wire, optional): path for text to follow. 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
|
||||
single_line_width (float, optional): width of outlined single line font.
|
||||
Defaults to 4% of font_size
|
||||
rotation (float, optional): angle to rotate object. Defaults to 0
|
||||
mode (Mode, optional): combination mode. Defaults to Mode.ADD
|
||||
"""
|
||||
|
|
@ -599,12 +601,22 @@ class Text(BaseSketchObject):
|
|||
align: Align | tuple[Align, Align] | None = None,
|
||||
path: Edge | Wire | None = None,
|
||||
position_on_path: float = 0.0,
|
||||
single_line_width: float | None = None,
|
||||
rotation: float = 0.0,
|
||||
mode: Mode = Mode.ADD,
|
||||
):
|
||||
context: BuildSketch | None = BuildSketch._get_context(self)
|
||||
validate_inputs(context, self)
|
||||
|
||||
if single_line_width is None:
|
||||
# Ensure line width is passed for single line fonts to convert to faces
|
||||
# Default is 4% of font_size
|
||||
single_line_width = 0.04 * font_size
|
||||
elif single_line_width <= 0.0:
|
||||
raise ValueError(
|
||||
f"single_line_width ({single_line_width}) must be greater than 0"
|
||||
)
|
||||
|
||||
self.txt = txt
|
||||
self.font_size = font_size
|
||||
self.font = font
|
||||
|
|
@ -614,6 +626,7 @@ class Text(BaseSketchObject):
|
|||
self.align = align
|
||||
self.text_path = path
|
||||
self.position_on_path = position_on_path
|
||||
self.single_line_width = single_line_width
|
||||
self.rotation = rotation
|
||||
self.mode = mode
|
||||
|
||||
|
|
@ -627,6 +640,7 @@ class Text(BaseSketchObject):
|
|||
align=align,
|
||||
position_on_path=position_on_path,
|
||||
text_path=path,
|
||||
single_line_width=single_line_width,
|
||||
)
|
||||
super().__init__(text_string, rotation, None, mode)
|
||||
|
||||
|
|
|
|||
267
src/build123d/text.py
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
"""
|
||||
build123d font and text objects
|
||||
|
||||
name: text.py
|
||||
by: jwagenet
|
||||
date: July 28th 2025
|
||||
|
||||
desc:
|
||||
This python module contains font and text objects.
|
||||
|
||||
"""
|
||||
|
||||
import glob
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fontTools.ttLib import TTFont, ttCollection # type:ignore
|
||||
from OCP.Font import (
|
||||
Font_FA_Bold,
|
||||
Font_FA_BoldItalic,
|
||||
Font_FA_Italic,
|
||||
Font_FA_Regular,
|
||||
Font_FontMgr,
|
||||
Font_SystemFont,
|
||||
)
|
||||
from OCP.TCollection import TCollection_AsciiString
|
||||
from OCP.TColStd import TColStd_SequenceOfHAsciiString
|
||||
|
||||
from build123d.build_enums import FontStyle
|
||||
|
||||
|
||||
FONT_ASPECT = {
|
||||
FontStyle.REGULAR: Font_FA_Regular,
|
||||
FontStyle.BOLD: Font_FA_Bold,
|
||||
FontStyle.ITALIC: Font_FA_Italic,
|
||||
FontStyle.BOLDITALIC: Font_FA_BoldItalic,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FontInfo:
|
||||
"""Representation for registered font.
|
||||
|
||||
Not immediately compatible with Font_SystemFont, which only contains a single
|
||||
style/aspect.
|
||||
"""
|
||||
|
||||
name: str
|
||||
styles: tuple[FontStyle, ...]
|
||||
|
||||
def __repr__(self) -> str:
|
||||
style_names = tuple(s.name for s in self.styles)
|
||||
return f"Font(name={self.name!r}, styles={style_names})"
|
||||
|
||||
|
||||
class FontManager:
|
||||
"""Wrap OCP Font_FontMgr"""
|
||||
|
||||
bundled_path = "data/fonts"
|
||||
bundled_fonts = [
|
||||
(
|
||||
"Relief SingleLine CAD",
|
||||
"reliefsingleline/ReliefSingleLineCAD-Regular.ttf",
|
||||
True,
|
||||
)
|
||||
]
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize FontManager
|
||||
|
||||
Bundled fonts are added to global OCP instance if they haven't already
|
||||
"""
|
||||
# Should clarify if this is necessary
|
||||
if sys.platform.startswith("linux"):
|
||||
os.environ["FONTCONFIG_FILE"] = "/etc/fonts/fonts.conf"
|
||||
os.environ["FONTCONFIG_PATH"] = "/etc/fonts/"
|
||||
|
||||
self.manager = Font_FontMgr.GetInstance_s()
|
||||
|
||||
# Check if OCP manager is already initialized. "singleline" alias is canary
|
||||
aliases = TColStd_SequenceOfHAsciiString()
|
||||
self.manager.GetAllAliases(aliases)
|
||||
aliases = [aliases.Value(i).ToCString() for i in range(1, aliases.Length() + 1)]
|
||||
|
||||
if "singleline" not in aliases:
|
||||
if platform.system() == "Windows": # pragma: no cover
|
||||
# OCCT doesnt add user fonts on Windows
|
||||
self.register_system_fonts()
|
||||
|
||||
working_path = os.path.dirname(os.path.abspath(__file__))
|
||||
for font in self.bundled_fonts:
|
||||
font_path = os.path.normpath(
|
||||
os.path.join(working_path, self.bundled_path, font[1])
|
||||
)
|
||||
self.register_font(font_path, single_stroke=font[2])
|
||||
|
||||
self.manager.AddFontAlias(
|
||||
TCollection_AsciiString("singleline"),
|
||||
TCollection_AsciiString("Relief SingleLine CAD"),
|
||||
)
|
||||
|
||||
def available_fonts(self) -> list[FontInfo]:
|
||||
"""Get list of available fonts by name and available styles (also called aspects)"""
|
||||
|
||||
font_aspects = {
|
||||
"REGULAR": Font_FA_Regular,
|
||||
"BOLD": Font_FA_Bold,
|
||||
"BOLDITALIC": Font_FA_BoldItalic,
|
||||
"ITALIC": Font_FA_Italic,
|
||||
}
|
||||
|
||||
font_list = []
|
||||
for f in self.manager.GetAvailableFonts():
|
||||
avail_aspects = tuple(
|
||||
FontStyle[n] for n, a in font_aspects.items() if f.HasFontAspect(a)
|
||||
)
|
||||
font_list.append(FontInfo(f.FontName().ToCString(), avail_aspects))
|
||||
|
||||
font_list.sort(key=lambda x: x.name)
|
||||
|
||||
return font_list
|
||||
|
||||
def check_font(self, path: str) -> Font_SystemFont | None:
|
||||
"""Check if font exists at path and return system font"""
|
||||
return self.manager.CheckFont(path)
|
||||
|
||||
def find_font(self, name: str, style: FontStyle) -> Font_SystemFont:
|
||||
"""Find font in FontManager library by name and style"""
|
||||
return self.manager.FindFont(TCollection_AsciiString(name), FONT_ASPECT[style])
|
||||
|
||||
def register_font(
|
||||
self, path: str, override: bool = False, single_stroke=False
|
||||
) -> list[str]:
|
||||
"""Register all font faces in a font file and return font face names."""
|
||||
_, ext = os.path.splitext(path)
|
||||
if ext.strip(".") == "ttc": # pragma: no cover
|
||||
fonts = ttCollection.TTCollection(path)
|
||||
else:
|
||||
fonts = [TTFont(path)]
|
||||
|
||||
font_faces = []
|
||||
for font in fonts:
|
||||
fonts = self._get_font_faces(font, path)
|
||||
for f in fonts:
|
||||
font_faces.append(f.FontName().ToCString())
|
||||
f.SetSingleStrokeFont(single_stroke)
|
||||
self.manager.RegisterFont(f, override)
|
||||
|
||||
return font_faces
|
||||
|
||||
def register_folder(
|
||||
self, path: str, override: bool = False, single_stroke=False
|
||||
) -> list[str]:
|
||||
"""Register all fonts in a folder"""
|
||||
exts = ["ttf", "otf", "ttc"]
|
||||
font_faces = []
|
||||
for ext in exts:
|
||||
search = os.path.join(os.path.normpath(path), "*" + ext)
|
||||
results = glob.glob(search)
|
||||
for result in results:
|
||||
font_faces += self.register_font(result, override, single_stroke)
|
||||
|
||||
return list(set(font_faces))
|
||||
|
||||
def register_system_fonts(self):
|
||||
"""Runner to (re)inititalize the OCCT FontMgr font list since user folder is
|
||||
missing on Windows and some fonts may not be imported correctly."""
|
||||
|
||||
if platform.system() == "Windows": # pragma: no cover
|
||||
user = os.getlogin()
|
||||
paths = [
|
||||
"C:/Windows/Fonts",
|
||||
f"C:/Users/{user}/AppData/Local/Microsoft/Windows/Fonts",
|
||||
]
|
||||
elif platform.system() == "Darwin": # pragma: no cover
|
||||
# macOS
|
||||
paths = ["/System/Library/Fonts", "/Library/Fonts"]
|
||||
else:
|
||||
paths = [
|
||||
"/system/fonts", # Android
|
||||
"/usr/share/fonts",
|
||||
"/usr/local/share/fonts",
|
||||
]
|
||||
|
||||
for path in paths:
|
||||
self.register_folder(path)
|
||||
|
||||
def _get_font_faces(self, ft_font: TTFont, path: str) -> list[Font_SystemFont]: # pragma: no cover
|
||||
"""Extract font info from font files and return list of font object."""
|
||||
|
||||
family, sub, preferred = "", "", ""
|
||||
for record in ft_font["name"].names:
|
||||
try:
|
||||
value = record.toUnicode()
|
||||
except:
|
||||
continue
|
||||
|
||||
if record.nameID == 1 and family == "":
|
||||
family = value
|
||||
elif record.nameID == 2 and sub == "":
|
||||
sub = value
|
||||
elif record.nameID == 16 and preferred == "":
|
||||
preferred = value
|
||||
|
||||
family = preferred if preferred != "" else family
|
||||
|
||||
if "fvar" in ft_font:
|
||||
sub_ids = [i.subfamilyNameID for i in ft_font["fvar"].instances]
|
||||
subfamilies = []
|
||||
for record in ft_font["name"].names:
|
||||
if record.nameID in sub_ids:
|
||||
subfamilies.append(record.toUnicode())
|
||||
|
||||
else:
|
||||
subfamilies = [sub]
|
||||
|
||||
# Replicate OCCT font aspect substitution rules, but make them correct
|
||||
# - OCCT treats "Oblique" as "Italic", which seems fine
|
||||
# - OCCT treats "Book" as "Regular", which is wrong
|
||||
aspects = ["Regular", "Bold", "Italic", "Oblique"]
|
||||
fonts: list[Font_SystemFont] = []
|
||||
for i, subfamily in enumerate(subfamilies):
|
||||
labels = subfamily.split()
|
||||
matches = {aspect for aspect in aspects if aspect in labels}
|
||||
|
||||
if "Bold" in matches:
|
||||
labels = [
|
||||
label
|
||||
for label in labels
|
||||
if label not in ("Bold", "Italic", "Oblique")
|
||||
]
|
||||
if "Italic" in matches or "Oblique" in matches:
|
||||
aspect = Font_FA_BoldItalic
|
||||
else:
|
||||
aspect = Font_FA_Bold
|
||||
elif "Italic" in matches or "Oblique" in matches:
|
||||
labels = [
|
||||
label for label in labels if label not in ("Italic", "Oblique")
|
||||
]
|
||||
aspect = Font_FA_Italic
|
||||
else:
|
||||
labels = [] if "Regular" in matches else labels
|
||||
aspect = Font_FA_Regular
|
||||
|
||||
subfamily = " ".join(labels)
|
||||
font_name = " ".join([family, subfamily]) if subfamily != "" else family
|
||||
font_name = font_name.strip()
|
||||
|
||||
ocp_font = Font_SystemFont(TCollection_AsciiString(font_name))
|
||||
ocp_font.SetFontPath(aspect, TCollection_AsciiString(path), i << 16)
|
||||
try:
|
||||
# Some fonts have bad unicode characters in their name and I couldn't
|
||||
# figure out how to fix them. Skipping these fonts for now
|
||||
ocp_font.SetSingleStrokeFont(
|
||||
ocp_font.FontKey().ToCString().startswith("olf ")
|
||||
)
|
||||
except UnicodeDecodeError:
|
||||
return fonts
|
||||
|
||||
fonts.append(ocp_font)
|
||||
|
||||
return fonts
|
||||
|
||||
|
||||
available_fonts = FontManager().available_fonts
|
||||
|
|
@ -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 (
|
||||
|
|
@ -235,9 +225,7 @@ class Compound(Mixin3D[TopoDS_Compound]):
|
|||
Returns:
|
||||
Edge: extruded shape
|
||||
"""
|
||||
return Compound(
|
||||
TopoDS.Compound(_extrude_topods_shape(obj.wrapped, direction))
|
||||
)
|
||||
return Compound(TopoDS.Compound(_extrude_topods_shape(obj.wrapped, direction)))
|
||||
|
||||
@classmethod
|
||||
def make_text(
|
||||
|
|
@ -251,31 +239,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 +279,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): # pragma: no cover
|
||||
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,33 +338,27 @@ 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(
|
||||
builder.Perform(
|
||||
font_i,
|
||||
brep_font,
|
||||
NCollection_Utf8String(txt),
|
||||
gp_Ax3(),
|
||||
horiz_align,
|
||||
|
|
@ -393,9 +373,29 @@ 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
|
||||
# offset_2d distance is radius, treat single_line_width as diameter/overall height
|
||||
if system_font.IsSingleStrokeFont() and single_line_width > 0:
|
||||
outline = [e.offset_2d(single_line_width / 2) for e in text_flat.edges()]
|
||||
outline = [_make_face(o.edges()) for o in outline]
|
||||
text_flat = Compound([]) + outline
|
||||
if any([not f.is_valid for f in text_flat.get_top_level_shapes()]):
|
||||
raise ValueError(
|
||||
f"single_line_width ({single_line_width}) is too large for the text and produces invalid faces. Try a smaller width"
|
||||
)
|
||||
|
||||
return text_flat
|
||||
|
||||
|
|
@ -412,14 +412,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 +427,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)
|
||||
|
|
|
|||
|
|
@ -1,57 +0,0 @@
|
|||
"""
|
||||
Helper Utilities
|
||||
|
||||
name: utils.py
|
||||
by: jwagenet
|
||||
date: July 28th 2025
|
||||
|
||||
desc:
|
||||
This python module contains helper utilities not related to object creation.
|
||||
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from build123d.build_enums import FontStyle
|
||||
from OCP.Font import (
|
||||
Font_FA_Bold,
|
||||
Font_FA_BoldItalic,
|
||||
Font_FA_Italic,
|
||||
Font_FA_Regular,
|
||||
Font_FontMgr,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FontInfo:
|
||||
name: str
|
||||
styles: tuple[FontStyle, ...]
|
||||
|
||||
def __repr__(self) -> str:
|
||||
style_names = tuple(s.name for s in self.styles)
|
||||
return f"Font(name={self.name!r}, styles={style_names})"
|
||||
|
||||
|
||||
def available_fonts() -> list[FontInfo]:
|
||||
"""Get list of available fonts by name and available styles (also called aspects).
|
||||
Note: on Windows, fonts must be installed with "Install for all users" to be found.
|
||||
"""
|
||||
|
||||
font_aspects = {
|
||||
"REGULAR": Font_FA_Regular,
|
||||
"BOLD": Font_FA_Bold,
|
||||
"BOLDITALIC": Font_FA_BoldItalic,
|
||||
"ITALIC": Font_FA_Italic,
|
||||
}
|
||||
|
||||
manager = Font_FontMgr.GetInstance_s()
|
||||
font_list = []
|
||||
for f in manager.GetAvailableFonts():
|
||||
avail_aspects = tuple(
|
||||
FontStyle[n] for n, a in font_aspects.items() if f.HasFontAspect(a)
|
||||
)
|
||||
font_list.append(FontInfo(f.FontName().ToCString(), avail_aspects))
|
||||
|
||||
font_list.sort(key=lambda x: x.name)
|
||||
|
||||
return font_list
|
||||
|
|
@ -383,6 +383,22 @@ class TestBuildSketchObjects(unittest.TestCase):
|
|||
self.assertEqual(len(test.sketch.faces()), 4)
|
||||
self.assertEqual(t.faces()[0].normal_at(), Vector(0, 0, 1))
|
||||
|
||||
def test_text_singleline(self):
|
||||
font_size = 10
|
||||
singleline = Text("test", font_size, "singleline")
|
||||
self.assertTrue(all([isinstance(s, Face) for s in singleline.get_top_level_shapes()]))
|
||||
self.assertEqual(singleline.single_line_width, font_size * .04)
|
||||
|
||||
singlelinewidth = Text("test", font_size, "singleline", single_line_width=1)
|
||||
self.assertEqual(singlelinewidth.single_line_width, 1)
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
Text("test", font_size, "singleline", single_line_width=0)
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
Text("the quick brown fox", font_size, "singleline", single_line_width=6)
|
||||
|
||||
def test_text_exceptions(self):
|
||||
with self.assertRaises(ValueError):
|
||||
Text("test", 2, text_align=(TextAlign.BOTTOM, TextAlign.BOTTOM))
|
||||
|
||||
|
|
|
|||
|
|
@ -28,12 +28,14 @@ license:
|
|||
|
||||
import itertools
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from build123d.build_common import GridLocations, PolarLocations
|
||||
from build123d.build_enums import Align, CenterOf
|
||||
from build123d.geometry import Location, Plane
|
||||
from build123d.objects_part import Box
|
||||
from build123d.objects_sketch import Circle
|
||||
from build123d.text import FontManager
|
||||
from build123d.topology import Compound, Edge, Face, ShapeList, Solid, Sketch
|
||||
|
||||
|
||||
|
|
@ -47,6 +49,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)))
|
||||
|
|
|
|||
155
tests/test_text.py
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
"""
|
||||
build123d Font and Text Utilities tests
|
||||
|
||||
name: test_text.py
|
||||
by: jwagenet
|
||||
date: July 28th 2025
|
||||
|
||||
desc: Unit tests for the build123d font and text module
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from OCP.TCollection import TCollection_AsciiString
|
||||
|
||||
from build123d import available_fonts, FontStyle
|
||||
from build123d.text import FONT_ASPECT, FontInfo, FontManager
|
||||
|
||||
|
||||
class TestFontManager(unittest.TestCase):
|
||||
"""Tests for FontManager."""
|
||||
|
||||
def test_persistence(self):
|
||||
"""OCP FontMgr expected to persist db over multiple instances"""
|
||||
instance1 = FontManager()
|
||||
instance1.manager.ClearFontDataBase()
|
||||
|
||||
working_path = Path(__file__).resolve().parent
|
||||
src_path = Path("src/build123d")
|
||||
|
||||
font_name = instance1.bundled_fonts[0][1]
|
||||
font_path = (working_path.parent / src_path / instance1.bundled_path / font_name)
|
||||
|
||||
instance1.register_font(str(font_path))
|
||||
|
||||
instance2 = FontManager()
|
||||
self.assertEqual(instance1.available_fonts(), instance2.available_fonts())
|
||||
|
||||
def test_register_font(self):
|
||||
"""Expected to return system font with matching name if it exists"""
|
||||
manager = FontManager()
|
||||
manager.manager.ClearFontDataBase()
|
||||
|
||||
working_path = Path(__file__).resolve().parent
|
||||
src_path = Path("src/build123d")
|
||||
|
||||
font_name = manager.bundled_fonts[0][1]
|
||||
font_path = (working_path.parent / src_path / manager.bundled_path / font_name).resolve()
|
||||
|
||||
font_names = manager.register_font(str(font_path))
|
||||
|
||||
result = manager.find_font(font_names[0], FontStyle.REGULAR)
|
||||
self.assertEqual(font_names[0], result.FontName().ToCString())
|
||||
|
||||
def test_register_folder(self):
|
||||
"""Expected to register fonts in folder"""
|
||||
manager = FontManager()
|
||||
manager.manager.ClearFontDataBase()
|
||||
|
||||
working_path = Path(__file__).resolve().parent
|
||||
src_path = Path("src/build123d")
|
||||
|
||||
font_name = manager.bundled_fonts[0][0]
|
||||
font_file = Path(manager.bundled_fonts[0][1])
|
||||
font_folder = font_file.parent
|
||||
|
||||
folder_path = (working_path.parent / src_path / manager.bundled_path / font_folder).resolve()
|
||||
|
||||
font_names = manager.register_folder(str(folder_path))
|
||||
|
||||
result = manager.find_font(font_names[0], FontStyle.REGULAR)
|
||||
self.assertEqual(font_name, result.FontName().ToCString())
|
||||
|
||||
def test_register_system_fonts(self):
|
||||
"""Expected to register at least as many fonts from before.
|
||||
May find more on Windows
|
||||
"""
|
||||
manager = FontManager()
|
||||
available_before = manager.available_fonts()
|
||||
|
||||
manager.manager.RemoveFontAlias(
|
||||
TCollection_AsciiString("singleline"),
|
||||
TCollection_AsciiString("Relief SingleLine CAD"),
|
||||
)
|
||||
manager.manager.ClearFontDataBase()
|
||||
manager.register_system_fonts()
|
||||
|
||||
# add bundled fonts back in
|
||||
manager.__init__()
|
||||
|
||||
available_after = manager.available_fonts()
|
||||
self.assertGreaterEqual(len(available_after), len(available_before))
|
||||
|
||||
def test_check_font(self):
|
||||
"""Expected to return system font with matching path if it exists or None"""
|
||||
manager = FontManager()
|
||||
|
||||
working_path = Path(__file__).resolve().parent
|
||||
src_path = Path("src/build123d")
|
||||
|
||||
font_name = manager.bundled_fonts[0][1]
|
||||
good_path = (working_path.parent / src_path / manager.bundled_path / font_name).resolve()
|
||||
|
||||
good_font = manager.check_font(str(good_path))
|
||||
bad_font = manager.check_font(font_name)
|
||||
|
||||
aspect = FONT_ASPECT[FontStyle.REGULAR]
|
||||
|
||||
self.assertEqual(str(good_path), good_font.FontPath(aspect).ToCString())
|
||||
self.assertIsNone(bad_font)
|
||||
|
||||
def test_find_font(self):
|
||||
"""Expected to return font with matching name if it exists"""
|
||||
manager = FontManager()
|
||||
|
||||
good_name = manager.bundled_fonts[0][0]
|
||||
good_font = manager.find_font(good_name, FontStyle.REGULAR)
|
||||
bad_font = manager.find_font("build123d", FontStyle.REGULAR)
|
||||
|
||||
self.assertEqual(good_name, good_font.FontName().ToCString())
|
||||
self.assertNotEqual("build123d", bad_font.FontName().ToCString())
|
||||
|
||||
|
||||
class TestFontHelpers(unittest.TestCase):
|
||||
"""Tests for font helpers."""
|
||||
|
||||
def test_font_info(self):
|
||||
"""Test expected FontInfo repr."""
|
||||
name = "Arial"
|
||||
styles = tuple(member for member in FontStyle)
|
||||
font = FontInfo(name, styles)
|
||||
|
||||
self.assertEqual(
|
||||
repr(font),
|
||||
f"Font(name={name!r}, styles={tuple(s.name for s in styles)})",
|
||||
)
|
||||
|
||||
def test_available_fonts(self):
|
||||
"""Test expected output for available fonts."""
|
||||
fonts = available_fonts()
|
||||
self.assertIsInstance(fonts, list)
|
||||
|
||||
for font in fonts:
|
||||
self.assertIsInstance(font, FontInfo)
|
||||
self.assertIsInstance(font.name, str)
|
||||
self.assertIsInstance(font.styles, tuple)
|
||||
for style in font.styles:
|
||||
self.assertIsInstance(style, FontStyle)
|
||||
|
||||
names = [font.name for font in fonts]
|
||||
self.assertEqual(names, sorted(names))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
"""
|
||||
build123d Helper Utilities tests
|
||||
|
||||
name: test_utils.py
|
||||
by: jwagenet
|
||||
date: July 28th 2025
|
||||
|
||||
desc: Unit tests for the build123d helper utilities module
|
||||
"""
|
||||
|
||||
import unittest
|
||||
|
||||
from build123d import *
|
||||
from build123d.utils import FontInfo
|
||||
|
||||
|
||||
class TestFontHelpers(unittest.TestCase):
|
||||
"""Tests for font helpers."""
|
||||
|
||||
def test_font_info(self):
|
||||
"""Test expected FontInfo repr."""
|
||||
name = "Arial"
|
||||
styles = tuple(member for member in FontStyle)
|
||||
font = FontInfo(name, styles)
|
||||
|
||||
self.assertEqual(
|
||||
repr(font), f"Font(name={name!r}, styles={tuple(s.name for s in styles)})"
|
||||
)
|
||||
|
||||
def test_available_fonts(self):
|
||||
"""Test expected output for available fonts."""
|
||||
fonts = available_fonts()
|
||||
self.assertIsInstance(fonts, list)
|
||||
for font in fonts:
|
||||
self.assertIsInstance(font, FontInfo)
|
||||
self.assertIsInstance(font.name, str)
|
||||
self.assertIsInstance(font.styles, tuple)
|
||||
for style in font.styles:
|
||||
self.assertIsInstance(style, FontStyle)
|
||||
|
||||
names = [font.name for font in fonts]
|
||||
self.assertEqual(names, sorted(names))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||