From 9b18c727e87c966feb33a75083f28289d18142dd Mon Sep 17 00:00:00 2001 From: WJCarpenter Date: Thu, 8 Jan 2026 13:58:35 -0800 Subject: [PATCH 01/50] adds a sketch for T-slot nuts --- README.md | 1 + src/gflabel/fragments.py | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/README.md b/README.md index 36f7c9f..3e73eb1 100644 --- a/README.md +++ b/README.md @@ -258,6 +258,7 @@ A list of all the fragments currently recognised: | nut_profile | Rectangle with two horizontal lines, as the side view of a hex nut. | | locknut_profile | Rectangle with two horizontal lines, as the side view of a hex nut, with an added "top bump". | | lockwasher | Circular washer with a locking cutout. | +| tnut | T-slot nut, rectangular horizontal profile | | magnet | Horseshoe shaped magnet symbol. | | measure | Fills as much area as possible with a dimension line, and shows the length. Useful for debugging. | | sym, symbol | Render an electronic symbol. | diff --git a/src/gflabel/fragments.py b/src/gflabel/fragments.py index f65c817..440aafd 100644 --- a/src/gflabel/fragments.py +++ b/src/gflabel/fragments.py @@ -31,6 +31,7 @@ PolarLocations, Polyline, Rectangle, + RectangleRounded, RegularPolygon, Rot, Sketch, @@ -456,6 +457,16 @@ def _fragment_circle(height: float, _maxsize: float) -> Sketch: return sketch.sketch +@fragment("tnut", examples=["{tnut}"]) +def _fragment_tnut(height: float, _maxsize: float) -> Sketch: + """T-slot nut.""" + with BuildSketch(mode=Mode.PRIVATE) as sketch: + RectangleRounded(height*0.6, height, height/7) + Circle((height*0.4)/2, mode=Mode.SUBTRACT) + + return sketch.sketch + + class BoltBase(Fragment): """Base class for handling common bolt/screw configuration""" From ba276bd21f8577548a336c8afaafdae7d30e3d1a Mon Sep 17 00:00:00 2001 From: WJCarpenter Date: Thu, 8 Jan 2026 14:53:40 -0800 Subject: [PATCH 02/50] implement overall scale factors for when you need a little nudge to make things fit --- README.md | 5 ++++- src/gflabel/cli.py | 12 ++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 36f7c9f..8be9b54 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,7 @@ The full command parameter usage (as generate by `gflabel --help`): usage: gflabel [-h] [--vscode] [-w WIDTH] [--height HEIGHT] [--depth DEPTH_MM] [--no-overheight] [-d DIVISIONS] [--font FONT] [--font-size-maximum FONT_SIZE_MAXIMUM | --font-size FONT_SIZE] [--font-style {regular,bold,italic}] [--font-path FONT_PATH] [--margin MARGIN] [-o OUTPUT] [--style {embossed,debossed,embedded}] [--list-fragments] [--list-symbols] [--label-gap LABEL_GAP] - [--column-gap COLUMN_GAP] [-v] [--version VERSION] + [--column-gap COLUMN_GAP] [--xscale XSCALE] [--yscale YSCALE] [--zscale ZSCALE] [-v] [--version VERSION] BASE LABEL [LABEL ...] Generate gridfinity bin labels @@ -166,6 +166,9 @@ options: Vertical gap (in mm) between physical labels. Default: 2 mm --column-gap COLUMN_GAP Gap (in mm) between columns + --xscale,--yscale,--zscale + Scale factor for entire label along the corresponding axis. Useful when you need slight adjustments for proper fit. + [All default to 1.0] -v, --verbose Verbose output --version VERSION The version of geometry to use for a given label system (if a system has versions). [Default: latest] ``` diff --git a/src/gflabel/cli.py b/src/gflabel/cli.py index 4809c6f..2bbc706 100755 --- a/src/gflabel/cli.py +++ b/src/gflabel/cli.py @@ -34,6 +34,7 @@ add, export_step, extrude, + scale, ) from . import fragments @@ -284,6 +285,15 @@ def run(argv: list[str] | None = None): parser.add_argument( "--column-gap", help="Gap (in mm) between columns", default=0.4, type=float ) + parser.add_argument( + "--xscale", help="Scale factor for entire label on the X axis", default=1.0, type=float + ) + parser.add_argument( + "--yscale", help="Scale factor for entire label on the Y axis", default=1.0, type=float + ) + parser.add_argument( + "--zscale", help="Scale factor for entire label on the Z axis", default=1.0, type=float + ) parser.add_argument("--box", action="store_true", help=argparse.SUPPRESS) parser.add_argument("-v", "--verbose", help="Verbose output", action="store_true") parser.add_argument( @@ -424,6 +434,8 @@ def run(argv: list[str] | None = None): else: assembly = Compound(part.part) + assembly = scale(assembly, (args.xscale, args.yscale, args.zscale)) + for output in args.output: if output.endswith(".stl"): logger.info(f"Writing STL {output}") From 82556d7bffa27b00e7de7eade97a623cc18b3251 Mon Sep 17 00:00:00 2001 From: WJCarpenter Date: Mon, 12 Jan 2026 11:59:58 -0800 Subject: [PATCH 03/50] Adds colors for base and label components. This is useful not only for better visualizing, but it can help with selecting things when importing into a slicer or other tool for further manipulation. For example, if you create a STEP file and convert it to a OBJ file (with any convenient converter tool), you can add the OBJ file to Bambu Studio, at which point Bambu Studio prompts you to map the colors in the OBJ file to filament colors. (Unfortunately, Bambu Studio doesn't preserve colors on STL, STEP, nor 3MF files at the moment. Maybe someday.) --- README.md | 38 ++++++++++++++++++++++---------------- src/gflabel/cli.py | 40 +++++++++++++++++++++++++--------------- 2 files changed, 47 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 36f7c9f..7aff102 100644 --- a/README.md +++ b/README.md @@ -120,38 +120,39 @@ gflabel predbox -w 5 "HEX\n{head(hex)} {bolt(5)}{3|}{<}M2\nM3\nM4\nM5{2|2}{<}M6\ The full command parameter usage (as generate by `gflabel --help`): ``` -usage: gflabel [-h] [--vscode] [-w WIDTH] [--height HEIGHT] [--depth DEPTH_MM] [--no-overheight] [-d DIVISIONS] [--font FONT] - [--font-size-maximum FONT_SIZE_MAXIMUM | --font-size FONT_SIZE] [--font-style {regular,bold,italic}] [--font-path FONT_PATH] - [--margin MARGIN] [-o OUTPUT] [--style {embossed,debossed,embedded}] [--list-fragments] [--list-symbols] [--label-gap LABEL_GAP] - [--column-gap COLUMN_GAP] [-v] [--version VERSION] +usage: gflabel [-h] [--vscode] [-w WIDTH] [--height HEIGHT] [--label-depth DEPTH] [--depth DEPTH_MM] [--no-overheight] [-d DIVISIONS] [--font FONT] + [--font-size-maximum FONT_SIZE_MAXIMUM | --font-size FONT_SIZE] [--font-style {regular,bold,italic,bolditalic}] [--font-path FONT_PATH] + [--margin MARGIN] [-o OUTPUT] [--style {embossed,debossed,embedded}] [--base-color BASE_COLOR] [--label-color LABEL_COLOR] [--list-fragments] + [--list-symbols] [--label-gap LABEL_GAP] [--column-gap COLUMN_GAP] [-v] [--version VERSION] BASE LABEL [LABEL ...] Generate gridfinity bin labels positional arguments: - BASE Label base to generate onto (pred, plain, none, cullenect, predbox). + BASE Label base to generate onto (pred, plain, none, cullenect, predbox, modern). LABEL options: -h, --help show this help message and exit --vscode Run in vscode_ocp mode, and show the label afterwards. -w WIDTH, --width WIDTH - Label width. If using a gridfinity standard base, then this is width in U. Otherwise, width in mm. - --height HEIGHT Label height, in mm. Ignored for standardised label bases. + Label width. If using a gridfinity standard base, then this is width in U. Otherwise, width in mm. Specify units e.g. '3mm' to + override the default behaviour. + --height HEIGHT Label height, by default in mm. For bases with standard heights, this will overwrite the height, diverging from the standard. + --label-depth DEPTH Label depth, by default in mm. --depth DEPTH_MM How high (or deep) the label extrusion is. - --no-overheight Disable the 'Overheight' system. This allows some symbols to oversize, meaning that the rest of the line will first shrink - before they are shrunk. + --no-overheight Disable the 'Overheight' system. This allows some symbols to oversize, meaning that the rest of the line will first shrink before they + are shrunk. -d DIVISIONS, --divisions DIVISIONS - How many areas to divide a single label into. If more labels that this are requested, multiple labels will be generated. - Default: 1. - --font FONT The name of the system font to use for rendering. If unspecified, a bundled version of Open Sans will be used. Set GFLABEL_FONT - in your environment to change the default. + How many areas to divide a single label into. If more labels that this are requested, multiple labels will be generated. Default: 1. + --font FONT The name of the system font to use for rendering. If unspecified, a bundled version of Open Sans will be used. Set GFLABEL_FONT in + your environment to change the default. --font-size-maximum FONT_SIZE_MAXIMUM Specify a maximum font size (in mm) to use for rendering. The text may end up smaller than this if it needs to fit in the area. --font-size FONT_SIZE - The font size (in mm) to use for rendering. If unset, then the font will use as much vertical space as needed (that also fits - within the horizontal area). - --font-style {regular,bold,italic} + The font size (in mm) to use for rendering. If unset, then the font will use as much vertical space as needed (that also fits within + the horizontal area). + --font-style {regular,bold,italic,bolditalic} The font style use for rendering. [Default: regular] --font-path FONT_PATH Path to font file, if not using a system-level font. @@ -160,6 +161,11 @@ options: Output filename(s). [Default: []] --style {embossed,debossed,embedded} How the label contents are formed. + --base-color BASE_COLOR + The name of a color used for rendering the base. Can be any of the recognized OCCT color names. + --label-color LABEL_COLOR + The name of a color used for rendering the label contents. Can be any of the recognized OCCT color names. Ignored for style + 'debossed'. --list-fragments List all available fragments. --list-symbols List all available electronic symbols --label-gap LABEL_GAP diff --git a/src/gflabel/cli.py b/src/gflabel/cli.py index 4809c6f..2125936 100755 --- a/src/gflabel/cli.py +++ b/src/gflabel/cli.py @@ -20,6 +20,7 @@ from build123d import ( BuildPart, BuildSketch, + Color, ColorIndex, Compound, ExportSVG, @@ -155,7 +156,7 @@ def base_name_to_subclass(name: str) -> type[LabelBase]: def run(argv: list[str] | None = None): # Handle the old way of specifying base - if any(x.startswith("--base") for x in (argv or sys.argv)): + if any((x.startswith("--base") and x != "--base-color") for x in (argv or sys.argv)): sys.exit( "Error: --base is no longer the way to specify base geometry. Please pass in as a direct argument (gflabel )" ) @@ -265,6 +266,18 @@ def run(argv: list[str] | None = None): default=LabelStyle.EMBOSSED, type=LabelStyle, ) + parser.add_argument( + "--base-color", + help="The name of a color used for rendering the base. Can be any of the recognized OCCT color names.", + type=str, + default="orange", + ) + parser.add_argument( + "--label-color", + help="The name of a color used for rendering the label contents. Can be any of the recognized OCCT color names. Ignored for style 'debossed'.", + type=str, + default="blue", + ) parser.add_argument( "--list-fragments", help="List all available fragments.", @@ -406,23 +419,20 @@ def run(argv: list[str] | None = None): add(body.part) logger.debug("Extruding labels") - is_embossed = args.style == LabelStyle.EMBOSSED - extrude( - label_sketch.sketch, - amount=args.depth if is_embossed else -args.depth, - mode=(Mode.ADD if is_embossed else Mode.SUBTRACT), - ) + if args.style == LabelStyle.DEBOSSED: + extrude(label_sketch.sketch, amount=-args.depth, mode=Mode.SUBTRACT) if not is_2d: part.part.label = "Base" + part.part.color = Color(args.base_color) - if args.style == LabelStyle.EMBEDDED: - # We want to make new volumes for the label, making it flush - embedded_label = extrude(label_sketch.sketch, amount=-args.depth) - embedded_label.label = "Label" - assembly = Compound([part.part, embedded_label]) - else: - assembly = Compound(part.part) + if args.style == LabelStyle.DEBOSSED: + assembly = Compound(children=[part.part]) + else: + embedded_or_embossed_label = extrude(label_sketch.sketch, amount=(args.depth if args.style == LabelStyle.EMBOSSED else -args.depth)) + embedded_or_embossed_label.label = "Label" + embedded_or_embossed_label.color = Color(args.label_color) + assembly = Compound(children=[part.part, embedded_or_embossed_label]) for output in args.output: if output.endswith(".stl"): @@ -461,7 +471,7 @@ def run(argv: list[str] | None = None): if args.style == LabelStyle.EMBEDDED: show_parts.append(part.part) show_cols.append(None) - show_parts.append(embedded_label) + show_parts.append(embedded_or_embossed_label) show_cols.append((0.2, 0.2, 0.2)) else: # Split the base for display as two colours From 11c6fe45fd50e1369370b546f0fb850a915d8042 Mon Sep 17 00:00:00 2001 From: WJCarpenter Date: Mon, 12 Jan 2026 12:32:46 -0800 Subject: [PATCH 04/50] label color used for SVG output --- src/gflabel/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gflabel/cli.py b/src/gflabel/cli.py index 2125936..e889e28 100755 --- a/src/gflabel/cli.py +++ b/src/gflabel/cli.py @@ -446,7 +446,7 @@ def run(argv: list[str] | None = None): *label_sketch.sketch.bounding_box().size, label_area.X, label_area.Y ) exporter = ExportSVG(scale=100 / max_dimension) - exporter.add_layer("Shapes", fill_color=ColorIndex.BLACK, line_weight=0) + exporter.add_layer("Shapes", fill_color=Color(args.label_color), line_weight=0) if args.box and is_2d: exporter.add_layer("Box", line_weight=1) From eedb3db72a1435cf89502c881c6d5a4f8ac125ed Mon Sep 17 00:00:00 2001 From: WJCarpenter Date: Mon, 12 Jan 2026 16:40:20 -0800 Subject: [PATCH 05/50] catch up with vscode viewer (currently broken in upstream main) --- src/gflabel/cli.py | 38 +++++++++++++------------------------- 1 file changed, 13 insertions(+), 25 deletions(-) diff --git a/src/gflabel/cli.py b/src/gflabel/cli.py index f4dfb5f..cd9f990 100755 --- a/src/gflabel/cli.py +++ b/src/gflabel/cli.py @@ -442,9 +442,9 @@ def run(argv: list[str] | None = None): embedded_or_embossed_label = extrude(label_sketch.sketch, amount=(args.depth if args.style == LabelStyle.EMBOSSED else -args.depth)) embedded_or_embossed_label.label = "Label" embedded_or_embossed_label.color = Color(args.label_color) - assembly = Compound(children=[part.part, embedded_or_embossed_label]) + assembly = Compound([part.part, embedded_or_embossed_label]) - assembly = scale(assembly, (args.xscale, args.yscale, args.zscale)) + #assembly = scale(assembly, (args.xscale, args.yscale, args.zscale)) for output in args.output: if output.endswith(".stl"): @@ -470,43 +470,31 @@ def run(argv: list[str] | None = None): logger.error(f"Error: Do not understand output format '{args.output}'") if args.vscode: - show_parts = [] - show_cols: list[str | tuple[float, float, float] | None] = [] - # Export both step and stl in vscode_ocp mode if is_2d: show_parts.append(label_sketch.sketch) else: + # Export both step and stl in vscode_ocp mode logger.info("Writing SVG label.stl") bd.export_stl(assembly, "label.stl") logger.info("Writing STEP label.step") export_step(assembly, "label.step") - if args.style == LabelStyle.EMBEDDED: - show_parts.append(part.part) - show_cols.append(None) - show_parts.append(embedded_or_embossed_label) - show_cols.append((0.2, 0.2, 0.2)) + + if args.style != LabelStyle.DEBOSSED: + show(part.part, embedded_or_embossed_label, colors=[args.base_color, args.label_color]) else: # Split the base for display as two colours - top = part.part.split( - Plane.XY if is_embossed else Plane.XY.offset(-args.depth), - keep=Keep.TOP, - ) + show_parts = [] + show_cols = [] + top = part.part.split(Plane.XY.offset(-args.depth), keep=Keep.TOP) if top: show_parts.append(top) - show_cols.append((0.2, 0.2, 0.2)) + show_cols.append(args.base_color) if args.base != "none": bottom = part.part.split(Plane.XY, keep=Keep.BOTTOM) - if bottom.wrapped: + if bottom: show_parts.append(bottom) - show_cols.append(None) - - show( - *show_parts, - colors=show_cols, - # position=[0, -10, 10], - # target=[0, 0, 0], - ) - + show_cols.append(args.label_color) + show(top, bottom, colors=show_cols) if __name__ == "__main__": run() From 495b09c5f7d4fa0d4539d08f332c4d2622552073 Mon Sep 17 00:00:00 2001 From: WJCarpenter Date: Mon, 12 Jan 2026 17:16:34 -0800 Subject: [PATCH 06/50] restore scaling --- src/gflabel/cli.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/gflabel/cli.py b/src/gflabel/cli.py index cd9f990..e9528aa 100755 --- a/src/gflabel/cli.py +++ b/src/gflabel/cli.py @@ -269,13 +269,13 @@ def run(argv: list[str] | None = None): ) parser.add_argument( "--base-color", - help="The name of a color used for rendering the base. Can be any of the recognized OCCT color names.", + help="The name of a color used for rendering the base. Can be any of the recognized OCCT color names. Default: %(default)s.", type=str, default="orange", ) parser.add_argument( "--label-color", - help="The name of a color used for rendering the label contents. Can be any of the recognized OCCT color names. Ignored for style 'debossed'.", + help="The name of a color used for rendering the label contents. Can be any of the recognized OCCT color names. Ignored for style 'debossed' except for 'vscode' rendering. Default: %(default)s.", type=str, default="blue", ) @@ -444,7 +444,7 @@ def run(argv: list[str] | None = None): embedded_or_embossed_label.color = Color(args.label_color) assembly = Compound([part.part, embedded_or_embossed_label]) - #assembly = scale(assembly, (args.xscale, args.yscale, args.zscale)) + assembly = scale(assembly, (args.xscale, args.yscale, args.zscale)) for output in args.output: if output.endswith(".stl"): From e1c909e527edb42d4e3dec967795d459f80fdbca Mon Sep 17 00:00:00 2001 From: WJCarpenter Date: Tue, 13 Jan 2026 10:38:39 -0800 Subject: [PATCH 07/50] add option for base and label colors, with orange and blue defaults, respectively This is useful not only for better visualizing, but it can help with selecting things when importing into a slicer or other tool for further manipulation. For example, if you create a STEP file and convert it to a OBJ file (with any convenient converter tool), you can add the OBJ file to Bambu Studio, at which point Bambu Studio prompts you to map the colors in the OBJ file to filament colors. (Unfortunately, Bambu Studio doesn't preserve colors on STL, STEP, nor 3MF files at the moment. Maybe someday.) Partial help for issue #19 Solution for issue #9 I discovered that this online STEP converter creates 3MF files that Bambu Studio likes (and preserves colors if the STEP file has them): https://convert3d.org/step-to-3mf/app A workflow could be: - create STEP file via gflabel, either with the default colors or your own choices for base color and label color - use that converter to convert the STEP file to 3MF - add the 3MF into Bambu Studio; you'll see the colors in the prepare tab - slice the plate - when you print the plate, Bambu studio will try to pick filaments for each color, but you can also pick whichever filaments you prefer before actually sending it to the printer This workflow is less tedious than splitting the model to parts or objects and assigning filaments manually. ----------------- (Sorry about the extra commits on this branch and PR. I had a few independent changes in separate branches and accidentally combined all of them into this branch. I took the shortest path get thingws back to where they should have been.) --- README.md | 8 ++------ src/gflabel/cli.py | 18 +++--------------- src/gflabel/fragments.py | 11 ----------- 3 files changed, 5 insertions(+), 32 deletions(-) mode change 100755 => 100644 src/gflabel/cli.py diff --git a/README.md b/README.md index aaef8ee..907a81c 100644 --- a/README.md +++ b/README.md @@ -162,19 +162,16 @@ options: --style {embossed,debossed,embedded} How the label contents are formed. --base-color BASE_COLOR - The name of a color used for rendering the base. Can be any of the recognized OCCT color names. + The name of a color used for rendering the base. Can be any of the recognized OCCT color names. Default: %(default)s. --label-color LABEL_COLOR The name of a color used for rendering the label contents. Can be any of the recognized OCCT color names. Ignored for style - 'debossed'. + 'debossed' (except for 'vscode' rendering). Default: %(default)s. --list-fragments List all available fragments. --list-symbols List all available electronic symbols --label-gap LABEL_GAP Vertical gap (in mm) between physical labels. Default: 2 mm --column-gap COLUMN_GAP Gap (in mm) between columns - --xscale,--yscale,--zscale - Scale factor for entire label along the corresponding axis. Useful when you need slight adjustments for proper fit. - [All default to 1.0] -v, --verbose Verbose output --version VERSION The version of geometry to use for a given label system (if a system has versions). [Default: latest] ``` @@ -267,7 +264,6 @@ A list of all the fragments currently recognised: | nut_profile | Rectangle with two horizontal lines, as the side view of a hex nut. | | locknut_profile | Rectangle with two horizontal lines, as the side view of a hex nut, with an added "top bump". | | lockwasher | Circular washer with a locking cutout. | -| tnut | T-slot nut, rectangular horizontal profile | | magnet | Horseshoe shaped magnet symbol. | | measure | Fills as much area as possible with a dimension line, and shows the length. Useful for debugging. | | sym, symbol | Render an electronic symbol. | diff --git a/src/gflabel/cli.py b/src/gflabel/cli.py old mode 100755 new mode 100644 index e9528aa..73594f4 --- a/src/gflabel/cli.py +++ b/src/gflabel/cli.py @@ -35,7 +35,6 @@ add, export_step, extrude, - scale, ) from . import fragments @@ -269,13 +268,13 @@ def run(argv: list[str] | None = None): ) parser.add_argument( "--base-color", - help="The name of a color used for rendering the base. Can be any of the recognized OCCT color names. Default: %(default)s.", + help="The name of a color used for rendering the base. Can be any of the recognized OCCT color names.", type=str, default="orange", ) parser.add_argument( "--label-color", - help="The name of a color used for rendering the label contents. Can be any of the recognized OCCT color names. Ignored for style 'debossed' except for 'vscode' rendering. Default: %(default)s.", + help="The name of a color used for rendering the label contents. Can be any of the recognized OCCT color names. Ignored for style 'debossed'.", type=str, default="blue", ) @@ -298,15 +297,6 @@ def run(argv: list[str] | None = None): parser.add_argument( "--column-gap", help="Gap (in mm) between columns", default=0.4, type=float ) - parser.add_argument( - "--xscale", help="Scale factor for entire label on the X axis", default=1.0, type=float - ) - parser.add_argument( - "--yscale", help="Scale factor for entire label on the Y axis", default=1.0, type=float - ) - parser.add_argument( - "--zscale", help="Scale factor for entire label on the Z axis", default=1.0, type=float - ) parser.add_argument("--box", action="store_true", help=argparse.SUPPRESS) parser.add_argument("-v", "--verbose", help="Verbose output", action="store_true") parser.add_argument( @@ -442,9 +432,7 @@ def run(argv: list[str] | None = None): embedded_or_embossed_label = extrude(label_sketch.sketch, amount=(args.depth if args.style == LabelStyle.EMBOSSED else -args.depth)) embedded_or_embossed_label.label = "Label" embedded_or_embossed_label.color = Color(args.label_color) - assembly = Compound([part.part, embedded_or_embossed_label]) - - assembly = scale(assembly, (args.xscale, args.yscale, args.zscale)) + assembly = Compound(children=[part.part, embedded_or_embossed_label]) for output in args.output: if output.endswith(".stl"): diff --git a/src/gflabel/fragments.py b/src/gflabel/fragments.py index 440aafd..f65c817 100644 --- a/src/gflabel/fragments.py +++ b/src/gflabel/fragments.py @@ -31,7 +31,6 @@ PolarLocations, Polyline, Rectangle, - RectangleRounded, RegularPolygon, Rot, Sketch, @@ -457,16 +456,6 @@ def _fragment_circle(height: float, _maxsize: float) -> Sketch: return sketch.sketch -@fragment("tnut", examples=["{tnut}"]) -def _fragment_tnut(height: float, _maxsize: float) -> Sketch: - """T-slot nut.""" - with BuildSketch(mode=Mode.PRIVATE) as sketch: - RectangleRounded(height*0.6, height, height/7) - Circle((height*0.4)/2, mode=Mode.SUBTRACT) - - return sketch.sketch - - class BoltBase(Fragment): """Base class for handling common bolt/screw configuration""" From 900d10abe0f408637fa7cdaa9ea1dbbe5e4e0fa3 Mon Sep 17 00:00:00 2001 From: WJCarpenter Date: Mon, 19 Jan 2026 14:48:53 -0800 Subject: [PATCH 08/50] Provides the {color(xyz)} fragment type You can change colors with a label. See COLOR_NOTES.md for details. In this implementation, the colors are tracked "on the side" (that is, outside of the build123d hierarchy. As far as I have been able to figure out, build123d "forgets" the colors as the sketches are built up. The original top-level "label_sketch" is still created, but it's not used for output file export or for VScode rendering. Instead, the on-the-side bookkeeping is used. Perhaps someday we'll be able to track the colors directly in the build123d hierarchy, at which point the on-the-side bookkeeping can be dropped. --- COLOR_NOTES.md | 102 +++++++++++++++++++++++++++++++++++++++ src/gflabel/cli.py | 49 +++++++++++++++++-- src/gflabel/fragments.py | 19 ++++++++ src/gflabel/label.py | 91 +++++++++++++++++++++++----------- 4 files changed, 229 insertions(+), 32 deletions(-) create mode 100644 COLOR_NOTES.md diff --git a/COLOR_NOTES.md b/COLOR_NOTES.md new file mode 100644 index 0000000..18f40aa --- /dev/null +++ b/COLOR_NOTES.md @@ -0,0 +1,102 @@ +# Color Notes + +## Basics + +There are global default colors for the base and label, +set via `--base-color` and `--label-color`, respectively. +They default to `orange` and `blue`. +Colors can be any of the names standardized in CSS3. + +In addition, there is a label fragment type for changing colors within a label. +Each line of a label starts with the default label color. +When a color fragment is seen, +all fragments after that will be rendered in the named color +until another color fragment is seen or the end of the line is reached. + +Here are some examples. +They are all rendered in VScode OCP CAD Viewer. +For each example, a label with just the default colors +is shown along with the same label using colors. + +## Slicers + +`gflabel` can produce STL and STEP output files. +STL format is not color-aware. +STEP format can handle colors, +and the colors described here are part of the STEP file export from `gflabel`. +However, treatment of color information when a STEP file is imported into a slicer varies a bit. +In general, most slicers don't bother with STEP file colors on import. +(Most CAD tools do, which is not surprising since STEP is a CAD file format.) + +Most color testing was done with Bambu Studio. +It does not notice colors in STEP files. +However, Bambu Studio does notice colors in OBJ and 3MF files, +though it deals with them differently. +The file converter at +[convert3d.org](https://convert3d.org) +can convert a STEP file into an OBJ or 3MF file that has colors expressed in a way that Bambu Studio understands. + +If you open one of those 3MF files in Bambu Studio, +you will immediately see it rendered in the expected colors. +Since those are just color names and not specific filaments, +Bambu Studio will prompt you to map the colors to filaments +when you try to send the sliced model to the 3D printer. + +- bs 3MF + +If you open one of those OBJ files +(and its accompanying MTL file) +in Bambu Studio, you are immediately prompted to confirm or modify +the color mapping choices it has made. +But, again, you must map the colors to specific filaments +when you try to send the sliced model to the 3D printer. + +- bs OBJ + +## Examples + +Here is a very simple example: + +> gflabel --style embossed pred 'R{|}G{|}B' '{color(red)}R{|}{color(green)}G{|}{color(blue)}B' --vscode + +- RGB + +Nobody is likely to have more than a few colors when 3D printing labels, +but there is no enforced limit. +Here's a slightly more complicated example: + +> gflabel --style embossed pred '{washer} R O Y G B I V {nut} {color(chartreuse)}{washer}' '{color(red)}R {color(orange)}O {color(yellow)}Y {color(green)}G {color(blue)}B {color(indigo)}I {color(violet)}V {color(chartreuse)}{nut}' --vscode + +- ROYGBIV + +This is an example of a divided label: + +> gflabel --style embossed pred '{<}I used to\nbe an\nadventurer\nlike you,{|}{variable_resistor}{|}{<}but\nthen....' '{<}I used to\nbe an\nadventurer\nlike you,{|}{color(red)}{variable_resistor}{|}{<}but\nthen....' --vscode + +- adventurer + +Another example: + +> gflabel --style embossed pred 'Danger! {head(triangle)}' '{color(red)}Danger! {color(black)}{head(triangle)}' --vscode + +- danger + +The color fragment should work properly with all of the other fragment types since there is no nesting. +Here is one of the `{measure}` examples from the README: + +> gflabel predbox -w=5 'A\n{measure}{4|}B\n{measure}{1|2}C\n{measure}' 'A\n{color(white)}{measure}{4|}B\n{color(chartreuse)}{measure}{1|2}C\n{color(pink)}{measure}' --vscode + +- measure + +There is one side effect that you might not expect. +If you change the color inside a text fragment, +the spacing is likely to be affected. +It's because rendering an uninterrupted text fragment is down +with the assistance of low-level font handling code. +When that same piece of text is broken into two or more +pieces, the spacing between them is handled directly by +the `gflabel` code. + +> gflabel --style embossed pred 'WWW' 'W{color(blue)}W{color(blue)}W' --vscode + +- www diff --git a/src/gflabel/cli.py b/src/gflabel/cli.py index 73594f4..06c2c04 100644 --- a/src/gflabel/cli.py +++ b/src/gflabel/cli.py @@ -31,6 +31,7 @@ Mode, Plane, RectangleRounded, + Shell, Face, Vector, add, export_step, @@ -384,20 +385,44 @@ def run(argv: list[str] | None = None): X=args.width.to("mm").magnitude, Y=args.height.to("mm").magnitude ) + # build123d makes it really hard to preserve the color + # attribute of things, so we keep separate bookkeeping for it + # (alas), which negates a lot of the convenient stuff that + # build123d otherwise does for us w/r/t locations. We keep a + # separate list of Faces and their locations. The locations + # have to get adjusted at each level of the render logic so + # that they ultimately provide global coordinates instead of + # local coordinates. Location of faces is tracked as the + # non-standard attribute "lokation" because something in + # build123d automatically adjusts the "location" attribute + # somehow. + # + # The original all-in-one Sketch logic is preserved, which is + # more elegant if you don't care about {color()} fragments + # within a label spec. + body_locations = [] + colored_faces = [] with BuildSketch(mode=Mode.PRIVATE) as label_sketch: all_labels = [] for labels in batched(args.labels, args.divisions): body_locations.append((0, y)) try: + local_colored_faces = [] all_labels.append( render_divided_label( labels, label_area, + default_color=args.label_color, + colored_faces=local_colored_faces, divisions=args.divisions, options=options, ).locate(Location([0, y])) ) + for face in local_colored_faces: + face.lokation = Location((face.lokation.position.X, face.lokation.position.Y + y)) + colored_faces.append(face) + except fragments.InvalidFragmentSpecification as e: rich.print(f"\n[y][b]Could not proceed: {e}[/b][/y]\n") sys.exit(1) @@ -429,10 +454,18 @@ def run(argv: list[str] | None = None): if args.style == LabelStyle.DEBOSSED: assembly = Compound(children=[part.part]) else: - embedded_or_embossed_label = extrude(label_sketch.sketch, amount=(args.depth if args.style == LabelStyle.EMBOSSED else -args.depth)) - embedded_or_embossed_label.label = "Label" - embedded_or_embossed_label.color = Color(args.label_color) - assembly = Compound(children=[part.part, embedded_or_embossed_label]) + child_list = [part.part] + facedex = 0 + for face in colored_faces: + with BuildPart() as face_part: + with Locations(face.lokation): + extrude(face, amount=(args.depth if args.style == LabelStyle.EMBOSSED else -args.depth)) + face_part.part.locate(face.lokation) + facedex += 1 + face_part.part.label = "Label_" + str(facedex) + "_" + face.color_name + face_part.part.color = face.color + child_list.append(face_part.part) + assembly = Compound(children=child_list) for output in args.output: if output.endswith(".stl"): @@ -468,7 +501,13 @@ def run(argv: list[str] | None = None): export_step(assembly, "label.step") if args.style != LabelStyle.DEBOSSED: - show(part.part, embedded_or_embossed_label, colors=[args.base_color, args.label_color]) + part_list = [] + color_list = [] + for a in assembly.children: + part_list.append(a) + color_list.append(a.color) + show(*part_list, colors=color_list) + else: # Split the base for display as two colours show_parts = [] diff --git a/src/gflabel/fragments.py b/src/gflabel/fragments.py index f65c817..7748a19 100644 --- a/src/gflabel/fragments.py +++ b/src/gflabel/fragments.py @@ -21,6 +21,7 @@ BuildSketch, CenterArc, Circle, + Color, EllipticalCenterArc, GridLocations, Line, @@ -1241,6 +1242,24 @@ def __init__(self, *args): ) +@fragment("color") +class ColorFragment(Fragment): + """Changes the color to be used for subsequent label fragments on a line (left to right). Every line starts with the default label color.""" + + examples = ["{color(blue)}BLUE{color(green)}GREEN]"] + + def __init__(self, color_name: str): + self.color = color_name + + visible = False + # a tiny, tiny circle for a microscopic bounding box, which in any case is invisible + # this eliminates some tedious special cases in single line processing + def render(self, height: float, maxsize: float, options: RenderOptions) -> Sketch: + with BuildSketch() as sketch: + Circle(0.000000000001) + return sketch.sketch + + @fragment("magnet", examples=["{magnet}"]) def _fragment_magnet(height: float, _maxsize: float) -> Sketch: """Horseshoe shaped magnet symbol.""" diff --git a/src/gflabel/label.py b/src/gflabel/label.py index 5735301..304772a 100644 --- a/src/gflabel/label.py +++ b/src/gflabel/label.py @@ -9,9 +9,11 @@ from build123d import ( BuildSketch, + Circle, Location, Locations, Mode, + Select, Sketch, Vector, add, @@ -57,13 +59,14 @@ class LabelRenderer: def __init__(self, options: RenderOptions): self.opts = options - def render(self, spec: str, area: Vector) -> Sketch: + def render(self, spec: str, area: Vector, default_color: str, colored_faces: list(Face,str,Location)) -> Sketch: """ Given a specification string, render a single label. Args: spec: The string representing the label. area: The width and height the label should be confined to. + default_color: The starting color to use until a color fragment is seen. Returns: A rendered Sketch object with the label contents, centered on @@ -135,18 +138,23 @@ def _handle_spec_alignment(scoped_spec) -> tuple[str, str | None]: with BuildSketch(mode=Mode.PRIVATE) as sketch: x = -area.X / 2 for column_spec, width in zip(columns, column_widths): - add( - self._do_multiline_render( - column_spec, Vector(X=width, Y=area.Y) - ).locate(Location((x + (width / 2), 0))) - ) + local_colored_faces = [] + xy = Location(((x + (width / 2), 0))) + add(self._do_multiline_render( + column_spec, Vector(X=width, Y=area.Y), default_color=default_color, colored_faces=local_colored_faces + ).locate(xy) + ) x += width + self.opts.column_gap + for face in local_colored_faces: + fxy = Location(((face.lokation.position.X + xy.position.X), (face.lokation.position.Y + xy.position.Y))) + face.lokation = fxy + colored_faces.append(face) return sketch.sketch # return self._do_multiline_render(spec, area) def _do_multiline_render( - self, spec: str, area: Vector, is_rescaling: bool = False + self, spec: str, area: Vector, default_color: str, colored_faces: list(Face,str,Location), is_rescaling: bool = False ) -> Sketch: """Label render function, with ability to recurse.""" lines = spec.splitlines() @@ -156,10 +164,9 @@ def _do_multiline_render( if not lines: raise ValueError("Asked to render empty label") - row_height = (area.Y - (self.opts.line_spacing_mm * (len(lines) - 1))) / len( - lines - ) + row_height = (area.Y - (self.opts.line_spacing_mm * (len(lines) - 1))) / len(lines) + first_try_colored_faces = [] with BuildSketch() as sketch: # Render each line onto the sketch separately for n, line in enumerate(lines): @@ -174,14 +181,21 @@ def _do_multiline_render( ) logger.info(f'Rendering line {n+1} ("{line}")') IndentingRichHandler.indent() - with Locations([(0, render_y)]): - add( - self._render_single_line( - line, - Vector(X=area.X, Y=row_height), - self.opts.allow_overheight, - ) + local_colored_faces = [] + xy = Location((0, render_y)) + with Locations([xy]): + sl_sketch = self._render_single_line( + line, + Vector(X=area.X, Y=row_height), + default_color=default_color, + allow_overheight=self.opts.allow_overheight, + colored_faces=local_colored_faces, ) + add(sl_sketch) + for face in local_colored_faces: + fxy = Location(((face.lokation.position.X + xy.position.X), (face.lokation.position.Y + xy.position.Y))) + face.lokation = fxy + first_try_colored_faces.append(face) IndentingRichHandler.dedent() scale_to_maxwidth = area.X / sketch.sketch.bounding_box().size.X @@ -207,9 +221,14 @@ def _do_multiline_render( # to scale down THAT height, instead of the "total available" height height_to_scale = min(area.Y, sketch.sketch.bounding_box().size.Y) + # these locations don't need adjustment because that's handled within + # the recursive call to this method + second_try_colored_faces = [] second_try = self._do_multiline_render( spec, Vector(X=area.X, Y=height_to_scale * to_scale * 0.95), + default_color, + colored_faces=second_try_colored_faces, is_rescaling=True, ) # If this didn't help, then error @@ -224,15 +243,17 @@ def _do_multiline_render( print( f'Entry "{print_spec}" calculated width = {sketch.sketch.bounding_box().size.X:.1f} (max {area.X})' ) + colored_faces.extend(second_try_colored_faces) return second_try print( f'Entry "{spec}" calculated width = {sketch.sketch.bounding_box().size.X:.1f} (max {area.X})' ) + colored_faces.extend(first_try_colored_faces) return sketch.sketch def _render_single_line( - self, line: str, area: Vector, allow_overheight: bool + self, line: str, area: Vector, default_color: str, colored_faces: list(Face,str,Location), allow_overheight: bool ) -> Sketch: """ Render a single line of a labelspec. @@ -295,21 +316,31 @@ def _render_single_line( if total_width > area.X: logger.warning("Overfull Hbox: Label is wider than available area") + current_color = default_color # Assemble these onto the target with BuildSketch() as sketch: x = -total_width / 2 for fragment, frag_sketch in [(x, rendered[x]) for x in frags]: - fragment_width = frag_sketch.bounding_box().size.X - with Locations((x + fragment_width / 2, 0)): - if fragment.visible: - add(frag_sketch) - x += fragment_width + if isinstance(fragment, fragments.ColorFragment): + logger.info(f"Switching to color '{fragment.color}'") + current_color = fragment.color + else: + fragment_width = frag_sketch.bounding_box().size.X + fxy = Location(((x + fragment_width / 2, 0))) + with Locations(fxy): + if fragment.visible: + add(frag_sketch) + for face in frag_sketch.faces(): + face.color = current_color + face.color_name = current_color # can't get the name back out of a Color object + face.lokation = fxy + colored_faces.append(face) + x += fragment_width return sketch.sketch - def render_divided_label( - labels: str, area: Vector, divisions: int, options: RenderOptions + labels: str, area: Vector, divisions: int, options: RenderOptions, default_color: str, colored_faces: list[(Face,str,Location)] ) -> Sketch: """ Create a sketch for multiple labels fitted into a single area @@ -320,8 +351,14 @@ def render_divided_label( renderer = LabelRenderer(options) with BuildSketch() as sketch: for i, label in enumerate(labels): - with Locations([(leftmost_label_x + i * area_per_label.X, 0)]): + local_colored_faces = [] + xy = Location(((leftmost_label_x + i * area_per_label.X, 0))) + with Locations([xy]): if label.strip(): - add(renderer.render(label, area_per_label)) + add(renderer.render(label, area_per_label, default_color=default_color, colored_faces=local_colored_faces)) + for face in local_colored_faces: + fxy = Location(((face.lokation.position.X + xy.position.X), (face.lokation.position.Y + xy.position.Y))) + face.lokation = fxy + colored_faces.append(face) return sketch.sketch From b7e3f64b7f4735e790afd06caded531ba13fb812 Mon Sep 17 00:00:00 2001 From: WJCarpenter Date: Mon, 19 Jan 2026 15:03:50 -0800 Subject: [PATCH 09/50] Update COLOR_NOTES.md Add images for examples --- COLOR_NOTES.md | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/COLOR_NOTES.md b/COLOR_NOTES.md index 18f40aa..933e467 100644 --- a/COLOR_NOTES.md +++ b/COLOR_NOTES.md @@ -42,8 +42,6 @@ Since those are just color names and not specific filaments, Bambu Studio will prompt you to map the colors to filaments when you try to send the sliced model to the 3D printer. -- bs 3MF - If you open one of those OBJ files (and its accompanying MTL file) in Bambu Studio, you are immediately prompted to confirm or modify @@ -51,15 +49,13 @@ the color mapping choices it has made. But, again, you must map the colors to specific filaments when you try to send the sliced model to the 3D printer. -- bs OBJ - ## Examples Here is a very simple example: > gflabel --style embossed pred 'R{|}G{|}B' '{color(red)}R{|}{color(green)}G{|}{color(blue)}B' --vscode -- RGB +rgb Nobody is likely to have more than a few colors when 3D printing labels, but there is no enforced limit. @@ -67,26 +63,26 @@ Here's a slightly more complicated example: > gflabel --style embossed pred '{washer} R O Y G B I V {nut} {color(chartreuse)}{washer}' '{color(red)}R {color(orange)}O {color(yellow)}Y {color(green)}G {color(blue)}B {color(indigo)}I {color(violet)}V {color(chartreuse)}{nut}' --vscode -- ROYGBIV +roygbiv This is an example of a divided label: > gflabel --style embossed pred '{<}I used to\nbe an\nadventurer\nlike you,{|}{variable_resistor}{|}{<}but\nthen....' '{<}I used to\nbe an\nadventurer\nlike you,{|}{color(red)}{variable_resistor}{|}{<}but\nthen....' --vscode -- adventurer +adventurer Another example: > gflabel --style embossed pred 'Danger! {head(triangle)}' '{color(red)}Danger! {color(black)}{head(triangle)}' --vscode -- danger +danger The color fragment should work properly with all of the other fragment types since there is no nesting. Here is one of the `{measure}` examples from the README: > gflabel predbox -w=5 'A\n{measure}{4|}B\n{measure}{1|2}C\n{measure}' 'A\n{color(white)}{measure}{4|}B\n{color(chartreuse)}{measure}{1|2}C\n{color(pink)}{measure}' --vscode -- measure +measure There is one side effect that you might not expect. If you change the color inside a text fragment, @@ -99,4 +95,5 @@ the `gflabel` code. > gflabel --style embossed pred 'WWW' 'W{color(blue)}W{color(blue)}W' --vscode -- www +www + From bfbd4899c114c79b48881ef248a0d2d33d5c3ada Mon Sep 17 00:00:00 2001 From: WJCarpenter Date: Mon, 19 Jan 2026 15:05:49 -0800 Subject: [PATCH 10/50] typo --- COLOR_NOTES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/COLOR_NOTES.md b/COLOR_NOTES.md index 933e467..7cdca93 100644 --- a/COLOR_NOTES.md +++ b/COLOR_NOTES.md @@ -87,7 +87,7 @@ Here is one of the `{measure}` examples from the README: There is one side effect that you might not expect. If you change the color inside a text fragment, the spacing is likely to be affected. -It's because rendering an uninterrupted text fragment is down +It's because rendering an uninterrupted text fragment is done with the assistance of low-level font handling code. When that same piece of text is broken into two or more pieces, the spacing between them is handled directly by From 2b50ffbe448c5369dba3565e3a145f5c2200a342 Mon Sep 17 00:00:00 2001 From: WJCarpenter Date: Mon, 19 Jan 2026 15:18:19 -0800 Subject: [PATCH 11/50] Update README.md --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 907a81c..70040c7 100644 --- a/README.md +++ b/README.md @@ -162,10 +162,10 @@ options: --style {embossed,debossed,embedded} How the label contents are formed. --base-color BASE_COLOR - The name of a color used for rendering the base. Can be any of the recognized OCCT color names. Default: %(default)s. + The name of a color used for rendering the base. Can be any of the recognized OCCT color names. --label-color LABEL_COLOR The name of a color used for rendering the label contents. Can be any of the recognized OCCT color names. Ignored for style - 'debossed' (except for 'vscode' rendering). Default: %(default)s. + 'debossed'. --list-fragments List all available fragments. --list-symbols List all available electronic symbols --label-gap LABEL_GAP @@ -258,6 +258,7 @@ A list of all the fragments currently recognised: | bolt | Variable length bolt, in the style of Printables pred-box labels.

If the requested bolt is longer than the available space, then the
bolt will be as large as possible with a broken thread. | | box | Arbitrary width, height centered box. If height is not specified, will expand to row height. | | circle | A filled circle. | +| color | Changes the color to be used for subsequent label fragments on a line (left to right). Every line starts with the default label color. See COLOR_NOTES.md | head | Screw head with specifiable head-shape. | | hexhead | Hexagonal screw head. Will accept drives, but not compulsory. | | hexnut, nut | Hexagonal outer profile nut with circular cutout. | From 6e71cb8e308d28bbe8d44632f4b5021b9c193205 Mon Sep 17 00:00:00 2001 From: WJCarpenter Date: Mon, 19 Jan 2026 15:35:55 -0800 Subject: [PATCH 12/50] typo in example cut/paste glitch --- COLOR_NOTES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/COLOR_NOTES.md b/COLOR_NOTES.md index 7cdca93..15dd51d 100644 --- a/COLOR_NOTES.md +++ b/COLOR_NOTES.md @@ -61,7 +61,7 @@ Nobody is likely to have more than a few colors when 3D printing labels, but there is no enforced limit. Here's a slightly more complicated example: -> gflabel --style embossed pred '{washer} R O Y G B I V {nut} {color(chartreuse)}{washer}' '{color(red)}R {color(orange)}O {color(yellow)}Y {color(green)}G {color(blue)}B {color(indigo)}I {color(violet)}V {color(chartreuse)}{nut}' --vscode +> gflabel --style embossed pred '{washer} R O Y G B I V {nut}' '{color(chartreuse)}{washer} {color(red)}R {color(orange)}O {color(yellow)}Y {color(green)}G {color(blue)}B {color(indigo)}I {color(violet)}V {color(chartreuse)}{nut}' --vscode roygbiv From b664bb7cb8e15fc5c1cd59859d924490f23248f5 Mon Sep 17 00:00:00 2001 From: WJCarpenter Date: Mon, 19 Jan 2026 16:09:06 -0800 Subject: [PATCH 13/50] fix image for earlier typo --- COLOR_NOTES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/COLOR_NOTES.md b/COLOR_NOTES.md index 15dd51d..b306ab8 100644 --- a/COLOR_NOTES.md +++ b/COLOR_NOTES.md @@ -63,7 +63,7 @@ Here's a slightly more complicated example: > gflabel --style embossed pred '{washer} R O Y G B I V {nut}' '{color(chartreuse)}{washer} {color(red)}R {color(orange)}O {color(yellow)}Y {color(green)}G {color(blue)}B {color(indigo)}I {color(violet)}V {color(chartreuse)}{nut}' --vscode -roygbiv +image This is an example of a divided label: From ff9f6d09f3e7a9635dbf46fd34c2d8816ff2f48f Mon Sep 17 00:00:00 2001 From: WJCarpenter Date: Thu, 22 Jan 2026 12:04:38 -0800 Subject: [PATCH 14/50] allow inline color changes The new fragment type ColorFragment ({color(red)}) is introduced. It changes the color of subsequent fragments until the end of the line or another ColorFragment is seen. Exported STEP and SVG files, and VScode renders preserve those colorings (though there is a new --svg-mono command line option to preserve the old behavior). A couple of changes as a side effect of implementing the above: 1. (fairly major) Labels are now created as a hierarchy of Compound objects instead of sketches. Things are labelled at each level, so hypotherically a tool looking at a STEP, STL, or SVG could navigate through the layers. (My experiments find little support fot his in those other tools, though FreeCAD seems to grok it pretty well.) 2. (minor) ModifierFragment is a subclass of Fragment, meant for things like the ColorFragment class. These things act on other fragments instead of being rendered themselves. --- README.md | 10 +- src/gflabel/cli.py | 168 +++++++++++++++------------- src/gflabel/fragments.py | 7 +- src/gflabel/label.py | 234 +++++++++++++++++++++------------------ src/gflabel/options.py | 6 + 5 files changed, 231 insertions(+), 194 deletions(-) diff --git a/README.md b/README.md index 70040c7..9f2c10a 100644 --- a/README.md +++ b/README.md @@ -122,8 +122,8 @@ The full command parameter usage (as generate by `gflabel --help`): ``` usage: gflabel [-h] [--vscode] [-w WIDTH] [--height HEIGHT] [--label-depth DEPTH] [--depth DEPTH_MM] [--no-overheight] [-d DIVISIONS] [--font FONT] [--font-size-maximum FONT_SIZE_MAXIMUM | --font-size FONT_SIZE] [--font-style {regular,bold,italic,bolditalic}] [--font-path FONT_PATH] - [--margin MARGIN] [-o OUTPUT] [--style {embossed,debossed,embedded}] [--base-color BASE_COLOR] [--label-color LABEL_COLOR] [--list-fragments] - [--list-symbols] [--label-gap LABEL_GAP] [--column-gap COLUMN_GAP] [-v] [--version VERSION] + [--margin MARGIN] [-o OUTPUT] [--style {embossed,debossed,embedded}] [--base-color BASE_COLOR] [--label-color LABEL_COLOR] [--svg-mono] + [--list-fragments] [--list-symbols] [--label-gap LABEL_GAP] [--column-gap COLUMN_GAP] [-v] [--version VERSION] BASE LABEL [LABEL ...] Generate gridfinity bin labels @@ -162,10 +162,12 @@ options: --style {embossed,debossed,embedded} How the label contents are formed. --base-color BASE_COLOR - The name of a color used for rendering the base. Can be any of the recognized OCCT color names. + The name of a color used for rendering the base. Can be any of the recognized CSS3 color names. --label-color LABEL_COLOR - The name of a color used for rendering the label contents. Can be any of the recognized OCCT color names. Ignored for style + The name of a color used for rendering the label contents. Can be any of the recognized CSS3 color names. Ignored for style 'debossed'. + --svg-mono SVG files are normally produced with the same colors as the label contents. If you specify this argument, they are produced with label + contents in the default label color. --list-fragments List all available fragments. --list-symbols List all available electronic symbols --label-gap LABEL_GAP diff --git a/src/gflabel/cli.py b/src/gflabel/cli.py index 06c2c04..a734389 100644 --- a/src/gflabel/cli.py +++ b/src/gflabel/cli.py @@ -21,7 +21,6 @@ BuildPart, BuildSketch, Color, - ColorIndex, Compound, ExportSVG, FontStyle, @@ -29,12 +28,14 @@ Location, Locations, Mode, + Part, Plane, RectangleRounded, - Shell, Face, + Solid, Vector, add, export_step, + export_stl, extrude, ) @@ -155,6 +156,27 @@ def base_name_to_subclass(name: str) -> type[LabelBase]: return bases[name] +def colored_parts(comp: Compound) -> list(Part): + """Walk the tree of comp to get a list of individual Part objects. Adjust their local locatons to globals along the way.""" + part_list = [] + for child in comp.children: + if isinstance(child, Part): + # we clone the part so that the move() calls don't modify things in place + clone = Part(child) + clone.label = child.label + clone.color = child.color + clone.move(comp.location) + part_list.append(clone) + elif isinstance(child, Compound): + child_part_list = colored_parts(child) + for child_part in child_part_list: + clone_part = Part(child_part) + clone_part.label = child_part.label + clone_part.color = child_part.color + clone_part.move(comp.location) + part_list.append(clone_part) + return part_list + def run(argv: list[str] | None = None): # Handle the old way of specifying base if any((x.startswith("--base") and x != "--base-color") for x in (argv or sys.argv)): @@ -269,16 +291,22 @@ def run(argv: list[str] | None = None): ) parser.add_argument( "--base-color", - help="The name of a color used for rendering the base. Can be any of the recognized OCCT color names.", + help="The name of a color used for rendering the base. Can be any of the recognized CSS3 color names.", type=str, default="orange", ) parser.add_argument( "--label-color", - help="The name of a color used for rendering the label contents. Can be any of the recognized OCCT color names. Ignored for style 'debossed'.", + help="The name of a color used for rendering the label contents. Can be any of the recognized CSS3 color names. Ignored for style 'debossed'.", type=str, default="blue", ) + parser.add_argument( + "--svg-mono", + help="SVG files are normally produced with the same colors as the label contents. If you specify this argument, they are produced with label contents in the default label color.", + action="store_true", + default=False, + ) parser.add_argument( "--list-fragments", help="List all available fragments.", @@ -368,7 +396,7 @@ def run(argv: list[str] | None = None): options = RenderOptions.from_args(args) logger.debug("Got render options: %s", options) body: LabelBase | None = None - with BuildPart() as part: + with BuildPart() as base_bpart: y = 0 body = base_type(args) @@ -385,56 +413,33 @@ def run(argv: list[str] | None = None): X=args.width.to("mm").magnitude, Y=args.height.to("mm").magnitude ) - # build123d makes it really hard to preserve the color - # attribute of things, so we keep separate bookkeeping for it - # (alas), which negates a lot of the convenient stuff that - # build123d otherwise does for us w/r/t locations. We keep a - # separate list of Faces and their locations. The locations - # have to get adjusted at each level of the render logic so - # that they ultimately provide global coordinates instead of - # local coordinates. Location of faces is tracked as the - # non-standard attribute "lokation" because something in - # build123d automatically adjusts the "location" attribute - # somehow. - # - # The original all-in-one Sketch logic is preserved, which is - # more elegant if you don't care about {color()} fragments - # within a label spec. - body_locations = [] - colored_faces = [] - with BuildSketch(mode=Mode.PRIVATE) as label_sketch: - all_labels = [] - for labels in batched(args.labels, args.divisions): - body_locations.append((0, y)) + child_pcomps = [] + batch_iter = batched(args.labels, args.divisions) + for ba in batch_iter: + labels = ba + xy = Location([0, y]) + body_locations.append((0, y)) + with Locations([xy]): try: - local_colored_faces = [] - all_labels.append( - render_divided_label( + ch_pc = render_divided_label( labels, label_area, - default_color=args.label_color, - colored_faces=local_colored_faces, divisions=args.divisions, options=options, - ).locate(Location([0, y])) - ) - for face in local_colored_faces: - face.lokation = Location((face.lokation.position.X, face.lokation.position.Y + y)) - colored_faces.append(face) + ) + ch_pc.locate(xy) + ch_pc.label = "Label_" + str(len(child_pcomps)+1) + child_pcomps.append(ch_pc) except fragments.InvalidFragmentSpecification as e: rich.print(f"\n[y][b]Could not proceed: {e}[/b][/y]\n") sys.exit(1) - y -= y_offset_each_label - logger.debug("Combining all labels") - add(all_labels) + y -= y_offset_each_label - if args.box and is_2d: - logger.debug("Generating label outline for --box") - with BuildSketch(mode=Mode.PRIVATE) as body_box: - with Locations(body_locations): - add(RectangleRounded(label_area.X, label_area.Y, label_area.Y / 10)) + label_compound = Compound(children=child_pcomps) + label_compound.label = "Label" + logger.debug(f"LABEL COMPOUND {label_compound}\n{label_compound.show_topology()}") if not is_2d: # Create all of the bases @@ -443,81 +448,84 @@ def run(argv: list[str] | None = None): with Locations(body_locations): add(body.part) - logger.debug("Extruding labels") - if args.style == LabelStyle.DEBOSSED: - extrude(label_sketch.sketch, amount=-args.depth, mode=Mode.SUBTRACT) + if args.box and is_2d: + logger.debug("Generating label outline for --box") + with BuildSketch(mode=Mode.PRIVATE) as body_box_bsketch: + with Locations(body_locations): + RectangleRounded(label_area.X, label_area.Y, label_area.Y / 10) + body_box_sketch = body_box_bsketch.sketch - if not is_2d: - part.part.label = "Base" - part.part.color = Color(args.base_color) + base_part = base_bpart.part + if not is_2d: + logger.debug(f"BASE PART {base_part}\n{base_part.show_topology()}") if args.style == LabelStyle.DEBOSSED: - assembly = Compound(children=[part.part]) + # this produces "UserWarning: Unknown Compound type, color not set"; I don't know why + base_part -= label_compound + assembly = Compound(children=[base_part]) else: - child_list = [part.part] - facedex = 0 - for face in colored_faces: - with BuildPart() as face_part: - with Locations(face.lokation): - extrude(face, amount=(args.depth if args.style == LabelStyle.EMBOSSED else -args.depth)) - face_part.part.locate(face.lokation) - facedex += 1 - face_part.part.label = "Label_" + str(facedex) + "_" + face.color_name - face_part.part.color = face.color - child_list.append(face_part.part) - assembly = Compound(children=child_list) + assembly = Compound(children=[base_part, label_compound]) + base_part.label = "Base" + base_part.color = Color(args.base_color) for output in args.output: if output.endswith(".stl"): logger.info(f"Writing STL {output}") - bd.export_stl(assembly, output) + export_stl(assembly, output) elif output.endswith(".step"): logger.info(f"Writing STEP {output}") export_step(assembly, output) elif output.endswith(".svg"): max_dimension = max( - *label_sketch.sketch.bounding_box().size, label_area.X, label_area.Y + *label_compound.bounding_box().size, label_area.X, label_area.Y ) exporter = ExportSVG(scale=100 / max_dimension) - exporter.add_layer("Shapes", fill_color=Color(args.label_color), line_weight=0) if args.box and is_2d: - exporter.add_layer("Box", line_weight=1) - exporter.add_shape(body_box.sketch, layer="Box") + exporter.add_layer("Box", line_color=Color(args.base_color), line_weight=1) + exporter.add_shape(body_box_sketch, layer="Box") + if args.svg_mono: + exporter.add_layer("Shapes", fill_color=Color(args.label_color), line_weight=0) + compound_in_plane = label_compound.intersect(Plane.XY) + exporter.add_shape(compound_in_plane, layer="Shapes") + else: + layer_dict = {} + for pdex, part in enumerate(colored_parts(label_compound)): + color = part.color + color_str = str(color) + if not color_str in layer_dict: + exporter.add_layer(name=color_str, fill_color=color, line_weight=0) + layer_dict[color_str] = True + part_in_plane = part.intersect(Plane.XY) + exporter.add_shape(part_in_plane, layer=color_str) logger.info(f"Writing SVG {output}") - exporter.add_shape(label_sketch.sketch, layer="Shapes") exporter.write(output) else: logger.error(f"Error: Do not understand output format '{args.output}'") if args.vscode: if is_2d: - show_parts.append(label_sketch.sketch) + show(label_compound) else: # Export both step and stl in vscode_ocp mode - logger.info("Writing SVG label.stl") + logger.info("Writing STL label.stl") bd.export_stl(assembly, "label.stl") logger.info("Writing STEP label.step") export_step(assembly, "label.step") if args.style != LabelStyle.DEBOSSED: - part_list = [] - color_list = [] - for a in assembly.children: - part_list.append(a) - color_list.append(a.color) - show(*part_list, colors=color_list) - + # CAD viewer notices the Part colors + show(assembly) else: # Split the base for display as two colours show_parts = [] show_cols = [] - top = part.part.split(Plane.XY.offset(-args.depth), keep=Keep.TOP) + top = base_part.split(Plane.XY.offset(-args.depth), keep=Keep.TOP) if top: show_parts.append(top) show_cols.append(args.base_color) if args.base != "none": - bottom = part.part.split(Plane.XY, keep=Keep.BOTTOM) + bottom = base_part.split(Plane.XY, keep=Keep.BOTTOM) if bottom: show_parts.append(bottom) show_cols.append(args.label_color) diff --git a/src/gflabel/fragments.py b/src/gflabel/fragments.py index 7748a19..674440f 100644 --- a/src/gflabel/fragments.py +++ b/src/gflabel/fragments.py @@ -1242,9 +1242,12 @@ def __init__(self, *args): ) +class ModifierFragment(Fragment): + """This Fragment subclass is for fragments that make some kind of inline adjustment applicable to fragments that follow it. Each line of a label starts with defaults, as if no modifier fragment has yet been seen.""" + @fragment("color") -class ColorFragment(Fragment): - """Changes the color to be used for subsequent label fragments on a line (left to right). Every line starts with the default label color.""" +class ColorFragment(ModifierFragment): + """Changes the color to be used for subsequent fragments on a line.""" examples = ["{color(blue)}BLUE{color(green)}GREEN]"] diff --git a/src/gflabel/label.py b/src/gflabel/label.py index 304772a..b009401 100644 --- a/src/gflabel/label.py +++ b/src/gflabel/label.py @@ -8,26 +8,29 @@ import re from build123d import ( + BuildPart, BuildSketch, - Circle, + Compound, Location, Locations, Mode, - Select, Sketch, Vector, add, + extrude, ) from rich import print from . import fragments -from .options import RenderOptions +from .options import RenderOptions, LabelStyle from .util import IndentingRichHandler, batched logger = logging.getLogger(__name__) RE_FRAGMENT = re.compile(r"((? list[fragments.Fragment]: """Convert a single line spec string to a list of renderable fragments.""" @@ -59,17 +62,16 @@ class LabelRenderer: def __init__(self, options: RenderOptions): self.opts = options - def render(self, spec: str, area: Vector, default_color: str, colored_faces: list(Face,str,Location)) -> Sketch: + def render(self, spec: str, area: Vector) -> Compound: """ Given a specification string, render a single label. Args: spec: The string representing the label. area: The width and height the label should be confined to. - default_color: The starting color to use until a color fragment is seen. Returns: - A rendered Sketch object with the label contents, centered on + A rendered Compound object with the label contents, centered on the origin. """ # Area splitting @@ -135,27 +137,22 @@ def _handle_spec_alignment(scoped_spec) -> tuple[str, str | None]: logger.debug(f"{column_widths=}") logger.debug(f"{column_proportions=}") - with BuildSketch(mode=Mode.PRIVATE) as sketch: - x = -area.X / 2 - for column_spec, width in zip(columns, column_widths): - local_colored_faces = [] - xy = Location(((x + (width / 2), 0))) - add(self._do_multiline_render( - column_spec, Vector(X=width, Y=area.Y), default_color=default_color, colored_faces=local_colored_faces - ).locate(xy) - ) - x += width + self.opts.column_gap - for face in local_colored_faces: - fxy = Location(((face.lokation.position.X + xy.position.X), (face.lokation.position.Y + xy.position.Y))) - face.lokation = fxy - colored_faces.append(face) + child_pcomps = [] + x = -area.X / 2 + for column_spec, width in zip(columns, column_widths): + xy = Location(((x + (width / 2), 0))) + with Locations([xy]): + ch_pc = self._do_multiline_render(column_spec, Vector(X=width, Y=area.Y)) + ch_pc.locate(xy) + ch_pc.label = "Multiline_" + str(len(child_pcomps)+1) + child_pcomps.append(ch_pc) + x += width + self.opts.column_gap - return sketch.sketch - # return self._do_multiline_render(spec, area) + compound = Compound(children=child_pcomps) + return compound def _do_multiline_render( - self, spec: str, area: Vector, default_color: str, colored_faces: list(Face,str,Location), is_rescaling: bool = False - ) -> Sketch: + self, spec: str, area: Vector, is_rescaling: bool = False) -> Compound: """Label render function, with ability to recurse.""" lines = spec.splitlines() if spec.endswith("\n"): @@ -166,47 +163,43 @@ def _do_multiline_render( row_height = (area.Y - (self.opts.line_spacing_mm * (len(lines) - 1))) / len(lines) - first_try_colored_faces = [] - with BuildSketch() as sketch: - # Render each line onto the sketch separately - for n, line in enumerate(lines): - # Handle blank lines - if not line: - continue - # Calculate the y of the line center - render_y = ( - area.Y / 2 - - (row_height + self.opts.line_spacing_mm) * n - - row_height / 2 + child_pcomps = [] + # Render each line into the Compound separately + for n, line in enumerate(lines): + # Handle blank lines + if not line: + continue + # Calculate the y of the line center + render_y = ( + area.Y / 2 + - (row_height + self.opts.line_spacing_mm) * n + - row_height / 2 + ) + logger.info(f'Rendering line {n+1} ("{line}")') + IndentingRichHandler.indent() + xy = Location((0, render_y)) + with Locations([xy]): + ch_pc = self._render_single_line( + line, + Vector(X=area.X, Y=row_height), + allow_overheight=self.opts.allow_overheight, ) - logger.info(f'Rendering line {n+1} ("{line}")') - IndentingRichHandler.indent() - local_colored_faces = [] - xy = Location((0, render_y)) - with Locations([xy]): - sl_sketch = self._render_single_line( - line, - Vector(X=area.X, Y=row_height), - default_color=default_color, - allow_overheight=self.opts.allow_overheight, - colored_faces=local_colored_faces, - ) - add(sl_sketch) - for face in local_colored_faces: - fxy = Location(((face.lokation.position.X + xy.position.X), (face.lokation.position.Y + xy.position.Y))) - face.lokation = fxy - first_try_colored_faces.append(face) - IndentingRichHandler.dedent() + ch_pc.locate(xy) + ch_pc.label = "Line_" + str(len(child_pcomps)+1) + child_pcomps.append(ch_pc) + IndentingRichHandler.dedent() - scale_to_maxwidth = area.X / sketch.sketch.bounding_box().size.X - scale_to_maxheight = area.Y / sketch.sketch.bounding_box().size.Y + ml_compound = Compound(children=child_pcomps) + bbox = ml_compound.bounding_box() + scale_to_maxwidth = area.X / bbox.size.X + scale_to_maxheight = area.Y / bbox.size.Y if scale_to_maxheight < 1 - 1e3: print( f"Vertical scale is too high for area ({scale_to_maxheight}); downscaling" ) to_scale = min(scale_to_maxheight, scale_to_maxwidth, 1) - print(f"Got scale: {to_scale}") + print("Got scale: " + str(to_scale)) if to_scale < 0.99 and not is_rescaling: print(f"Rescaling as {scale_to_maxwidth}") # We need to scale this down. Resort to adjusting the height and re-requesting. @@ -219,16 +212,11 @@ def _do_multiline_render( # If we had an area that didn't fill the whole height, then we need # to scale down THAT height, instead of the "total available" height - height_to_scale = min(area.Y, sketch.sketch.bounding_box().size.Y) + height_to_scale = min(area.Y, bbox.size.Y) - # these locations don't need adjustment because that's handled within - # the recursive call to this method - second_try_colored_faces = [] second_try = self._do_multiline_render( spec, Vector(X=area.X, Y=height_to_scale * to_scale * 0.95), - default_color, - colored_faces=second_try_colored_faces, is_rescaling=True, ) # If this didn't help, then error @@ -241,26 +229,51 @@ def _do_multiline_render( ) print_spec = spec.replace("\n", "\\n") print( - f'Entry "{print_spec}" calculated width = {sketch.sketch.bounding_box().size.X:.1f} (max {area.X})' + f'Entry "{print_spec}" calculated width = {bbox.size.X:.1f} (max {area.X})' ) - colored_faces.extend(second_try_colored_faces) return second_try print( - f'Entry "{spec}" calculated width = {sketch.sketch.bounding_box().size.X:.1f} (max {area.X})' + f'Entry "{spec}" calculated width = {bbox.size.X:.1f} (max {area.X})' ) - colored_faces.extend(first_try_colored_faces) - return sketch.sketch + return ml_compound def _render_single_line( - self, line: str, area: Vector, default_color: str, colored_faces: list(Face,str,Location), allow_overheight: bool - ) -> Sketch: + self, line: str, area: Vector, allow_overheight: bool) -> Compound: """ Render a single line of a labelspec. """ # Firstly, split the line into a set of fragment objects frags = _spec_to_fragments(line) + # Now pre-process the modifier fragments and record stuff into + # a dictionary for later use; those modifier fragments are + # removed from the list of fragments + + # For modifier fragments, the change needs to happen + # in this loop so that fragment order is preserved. + # If you need to reference something later, when the + # Sketch is extruded into a Part (which is pretty + # likely!), a good technique is to store info as + # entries in the fragment_data dictionary that gets + # attached to the Fragment object + + current_color = self.opts.default_color + renderable_frags = [] + for frag in frags: + if isinstance(frag, fragments.ModifierFragment): + + if isinstance(frag, fragments.ColorFragment): + logger.info(f"Switching to color '{frag.color}'") + current_color = frag.color + + else: + fragment_data = {} + fragment_data["color"] = current_color + frag.fragment_data = fragment_data + renderable_frags.append(frag) + frags = renderable_frags + # Overheight fragments: Work out if we have any, so that we can # scale the total height such that they fit. # If this isn't turned on, then we will still allow the fragment @@ -316,49 +329,54 @@ def _render_single_line( if total_width > area.X: logger.warning("Overfull Hbox: Label is wider than available area") - current_color = default_color + child_parts = [] + label_dict = {} # Assemble these onto the target - with BuildSketch() as sketch: - x = -total_width / 2 - for fragment, frag_sketch in [(x, rendered[x]) for x in frags]: - if isinstance(fragment, fragments.ColorFragment): - logger.info(f"Switching to color '{fragment.color}'") - current_color = fragment.color - else: - fragment_width = frag_sketch.bounding_box().size.X - fxy = Location(((x + fragment_width / 2, 0))) - with Locations(fxy): - if fragment.visible: - add(frag_sketch) - for face in frag_sketch.faces(): - face.color = current_color - face.color_name = current_color # can't get the name back out of a Color object - face.lokation = fxy - colored_faces.append(face) - x += fragment_width - - return sketch.sketch - + x = -total_width / 2 + for fragment, frag_sketch in [(x, rendered[x]) for x in frags]: + fragment_width = frag_sketch.bounding_box().size.X + fxy = Location(((x + fragment_width / 2, 0))) + with Locations(fxy): + if fragment.visible: + with BuildPart(mode=Mode.PRIVATE) as child_bpart: + # EMBOSSED gets raised, DEBOSSED and EMBEDDED get lowered + extrude(frag_sketch, self.opts.depth if self.opts.label_style == LabelStyle.EMBOSSED else -self.opts.depth) + child_part = child_bpart.part + child_part.color = fragment.fragment_data["color"] + child_part.locate(fxy) + fragment_class_name = fragment.__class__.__name__ + child_part_label = fragment_class_name.removesuffix("Fragment").removesuffix("fragment") + label_count = label_dict[child_part_label] if child_part_label in label_dict else 0 + label_count += 1 + label_dict[child_part_label] = label_count + child_part_label += "_" + str(label_count) + if child_part.color != self.opts.default_color: + child_part_label += "__" + current_color + child_part.label = child_part_label + child_parts.append(child_part) + x += fragment_width + + sl_compound = Compound(children=child_parts) + return sl_compound + def render_divided_label( - labels: str, area: Vector, divisions: int, options: RenderOptions, default_color: str, colored_faces: list[(Face,str,Location)] -) -> Sketch: + labels: str, area: Vector, divisions: int, options: RenderOptions) -> Compound: """ - Create a sketch for multiple labels fitted into a single area + Create a Compound for multiple labels fitted into a single area """ area = Vector(X=area.X - options.margin_mm * 2, Y=area.Y - options.margin_mm * 2) area_per_label = Vector(area.X / divisions, area.Y) leftmost_label_x = -area.X / 2 + area_per_label.X / 2 renderer = LabelRenderer(options) - with BuildSketch() as sketch: - for i, label in enumerate(labels): - local_colored_faces = [] - xy = Location(((leftmost_label_x + i * area_per_label.X, 0))) - with Locations([xy]): - if label.strip(): - add(renderer.render(label, area_per_label, default_color=default_color, colored_faces=local_colored_faces)) - for face in local_colored_faces: - fxy = Location(((face.lokation.position.X + xy.position.X), (face.lokation.position.Y + xy.position.Y))) - face.lokation = fxy - colored_faces.append(face) - - return sketch.sketch + child_pcomps = [] + for i, label in enumerate(labels): + xy = Location(((leftmost_label_x + i * area_per_label.X, 0))) + with Locations([xy]): + if label.strip(): + ch_pc = renderer.render(label, area_per_label) + ch_pc.locate(xy) + ch_pc.label = "Division_" + str(len(child_pcomps)+1) + child_pcomps.append(ch_pc) + + div_compound = Compound(children=child_pcomps) + return div_compound diff --git a/src/gflabel/options.py b/src/gflabel/options.py index 110899d..110da55 100644 --- a/src/gflabel/options.py +++ b/src/gflabel/options.py @@ -91,6 +91,9 @@ class RenderOptions(NamedTuple): # like everything else? allow_overheight: bool = True column_gap: float = 0.4 + label_style: LabelStyle = LabelStyle.EMBOSSED + depth: float = 0.4 + default_color: str = "black" @classmethod def from_args(cls, args: argparse.Namespace) -> RenderOptions: @@ -118,4 +121,7 @@ def from_args(cls, args: argparse.Namespace) -> RenderOptions: ), allow_overheight=not args.no_overheight, column_gap=args.column_gap, + label_style=args.style, + depth=args.depth, + default_color=args.label_color, ) From 9a462c1dd813452d89689879328ff97fd3659df6 Mon Sep 17 00:00:00 2001 From: WJCarpenter Date: Thu, 22 Jan 2026 14:33:29 -0800 Subject: [PATCH 15/50] T-Nut fragment --- src/gflabel/fragments.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/gflabel/fragments.py b/src/gflabel/fragments.py index 674440f..f088de8 100644 --- a/src/gflabel/fragments.py +++ b/src/gflabel/fragments.py @@ -32,6 +32,7 @@ PolarLocations, Polyline, Rectangle, + RectangleRounded, RegularPolygon, Rot, Sketch, @@ -457,6 +458,16 @@ def _fragment_circle(height: float, _maxsize: float) -> Sketch: return sketch.sketch +@fragment("tnut", examples=["{tnut}"]) +def _fragment_tnut(height: float, _maxsize: float) -> Sketch: + """T-slot nut.""" + with BuildSketch(mode=Mode.PRIVATE) as sketch: + RectangleRounded(height*0.6, height, height/7) + Circle((height*0.4)/2, mode=Mode.SUBTRACT) + + return sketch.sketch + + class BoltBase(Fragment): """Base class for handling common bolt/screw configuration""" From 7acccf2bf505d8bfa851e77866a0fb2aa1385b41 Mon Sep 17 00:00:00 2001 From: WJCarpenter Date: Thu, 22 Jan 2026 14:34:33 -0800 Subject: [PATCH 16/50] scale --- src/gflabel/cli.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/gflabel/cli.py b/src/gflabel/cli.py index a734389..7e9fc55 100644 --- a/src/gflabel/cli.py +++ b/src/gflabel/cli.py @@ -37,6 +37,7 @@ export_step, export_stl, extrude, + scale, ) from . import fragments From d647fdac44f1118343ae05de263dcdf76ddb360b Mon Sep 17 00:00:00 2001 From: WJCarpenter Date: Thu, 22 Jan 2026 15:05:30 -0800 Subject: [PATCH 17/50] Update cli.py --- src/gflabel/cli.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/gflabel/cli.py b/src/gflabel/cli.py index 7e9fc55..fc6bbd8 100644 --- a/src/gflabel/cli.py +++ b/src/gflabel/cli.py @@ -327,6 +327,14 @@ def run(argv: list[str] | None = None): parser.add_argument( "--column-gap", help="Gap (in mm) between columns", default=0.4, type=float ) + parser.add_argument( + "--xscale", help="Scale factor for entire label on the X axis", default=1.0, type=float + ) + parser.add_argument( + "--yscale", help="Scale factor for entire label on the Y axis", default=1.0, type=float + ) + parser.add_argument( + "--zscale", help="Scale factor for entire label on the Z axis", default=1.0, type=float parser.add_argument("--box", action="store_true", help=argparse.SUPPRESS) parser.add_argument("-v", "--verbose", help="Verbose output", action="store_true") parser.add_argument( @@ -469,6 +477,10 @@ def run(argv: list[str] | None = None): base_part.label = "Base" base_part.color = Color(args.base_color) + if args.xscale != 1.0 or args.yscale != 1.0 or args.zscale != 1.0: + logger.info(f"Scaling overall label by ({args.xscale}, {args.yscale}, {args.zscale})") + assembly = scale(assembly, (args.xscale, args.yscale, args.zscale)) + for output in args.output: if output.endswith(".stl"): logger.info(f"Writing STL {output}") From 6f7d322f7ad907cd2b0f983ddefe9a00fcab2084 Mon Sep 17 00:00:00 2001 From: WJCarpenter Date: Thu, 22 Jan 2026 15:07:09 -0800 Subject: [PATCH 18/50] Update cli.py --- src/gflabel/cli.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/gflabel/cli.py b/src/gflabel/cli.py index fc6bbd8..f3d7d92 100644 --- a/src/gflabel/cli.py +++ b/src/gflabel/cli.py @@ -335,6 +335,7 @@ def run(argv: list[str] | None = None): ) parser.add_argument( "--zscale", help="Scale factor for entire label on the Z axis", default=1.0, type=float + ) parser.add_argument("--box", action="store_true", help=argparse.SUPPRESS) parser.add_argument("-v", "--verbose", help="Verbose output", action="store_true") parser.add_argument( From 12e155a6c7891bf1c76b384c41f7bd107e67cae7 Mon Sep 17 00:00:00 2001 From: WJCarpenter Date: Thu, 22 Jan 2026 16:37:36 -0800 Subject: [PATCH 19/50] revise scaling implementation The previous x/y/z scaling options were easily implemented. Just call a single build123d scale operation on the overall assembly. Unfortunately, build123d considers that a "CAD operation" and so creates all new objects in the assembly, losing the label and color attributes in the process. This revised implementation takes a different approach. It pre-scales the dimensions of the base (including margin) as well as any command line specs of width, height, depth, and margin. The excellent existing dynamic scaling of the gflabel code takes care of everything else. Note: If you use label divisions ({|}) with scaling, you may get some surprises. Each division is scaled and rendered independently. That was always the case, but x/y scaling can make it more obvious. My advice is to use trial and error to get what you want. --- src/gflabel/cli.py | 36 +++++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/src/gflabel/cli.py b/src/gflabel/cli.py index f3d7d92..9d720fa 100644 --- a/src/gflabel/cli.py +++ b/src/gflabel/cli.py @@ -292,13 +292,13 @@ def run(argv: list[str] | None = None): ) parser.add_argument( "--base-color", - help="The name of a color used for rendering the base. Can be any of the recognized CSS3 color names.", + help="The name of a color used for rendering the base. Can be any of the recognized CSS3 color names. Default: %(default)s", type=str, default="orange", ) parser.add_argument( "--label-color", - help="The name of a color used for rendering the label contents. Can be any of the recognized CSS3 color names. Ignored for style 'debossed'.", + help="The name of a color used for rendering the label contents. Can be any of the recognized CSS3 color names. Ignored for style 'debossed'. Default: %(default)s", type=str, default="blue", ) @@ -328,13 +328,13 @@ def run(argv: list[str] | None = None): "--column-gap", help="Gap (in mm) between columns", default=0.4, type=float ) parser.add_argument( - "--xscale", help="Scale factor for entire label on the X axis", default=1.0, type=float + "--xscale", help="Scale factor for entire label on the X axis. Default: %(default)s", default=1.0, type=float ) parser.add_argument( - "--yscale", help="Scale factor for entire label on the Y axis", default=1.0, type=float + "--yscale", help="Scale factor for entire label on the Y axis. Default: %(default)s", default=1.0, type=float ) parser.add_argument( - "--zscale", help="Scale factor for entire label on the Z axis", default=1.0, type=float + "--zscale", help="Scale factor for entire label on the Z axis. Default: %(default)s", default=1.0, type=float ) parser.add_argument("--box", action="store_true", help=argparse.SUPPRESS) parser.add_argument("-v", "--verbose", help="Verbose output", action="store_true") @@ -398,6 +398,25 @@ def run(argv: list[str] | None = None): elif args.margin is not pint.Quantity: args.margin = pint.Quantity(args.margin, unit_registry.mm) + body: LabelBase | None = None + body = base_type(args) + + if args.xscale != 1.0 or args.yscale != 1.0 or args.zscale != 1.0: + logger.info(f"Scaling overall label by ({args.xscale}, {args.yscale}, {args.zscale})") + if args.width: + args.width *= args.xscale + if args.height: + args.height *= args.yscale + if body: + if body.part: + body.part = scale(body.part, (args.xscale, args.yscale, args.zscale)) + if body.area: + body.area = Vector(args.xscale * body.area.X, args.yscale * body.area.Y) + if body.DEFAULT_WIDTH: + body.DEFAULT_WIDTH *= args.xscale + if body.DEFAULT_MARGIN: + body.DEFAULT_MARGIN *= args.xscale + logger.info(f"Rendering label with width: {args.width}") args.divisions = args.divisions or len(args.labels) @@ -405,14 +424,13 @@ def run(argv: list[str] | None = None): options = RenderOptions.from_args(args) logger.debug("Got render options: %s", options) - body: LabelBase | None = None with BuildPart() as base_bpart: y = 0 - body = base_type(args) if body.part: y_offset_each_label = body.part.bounding_box().size.Y + args.label_gap label_area = body.area + else: # Only occurs if label type has no body e.g. "None" if args.height is None: @@ -478,10 +496,6 @@ def run(argv: list[str] | None = None): base_part.label = "Base" base_part.color = Color(args.base_color) - if args.xscale != 1.0 or args.yscale != 1.0 or args.zscale != 1.0: - logger.info(f"Scaling overall label by ({args.xscale}, {args.yscale}, {args.zscale})") - assembly = scale(assembly, (args.xscale, args.yscale, args.zscale)) - for output in args.output: if output.endswith(".stl"): logger.info(f"Writing STL {output}") From b764479fd05cda540fe4284cd10cc9270dd86fa9 Mon Sep 17 00:00:00 2001 From: WJCarpenter Date: Thu, 22 Jan 2026 17:01:39 -0800 Subject: [PATCH 20/50] neglected to actually scale margin and depth :-( --- src/gflabel/cli.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/gflabel/cli.py b/src/gflabel/cli.py index 9d720fa..2431588 100644 --- a/src/gflabel/cli.py +++ b/src/gflabel/cli.py @@ -405,8 +405,12 @@ def run(argv: list[str] | None = None): logger.info(f"Scaling overall label by ({args.xscale}, {args.yscale}, {args.zscale})") if args.width: args.width *= args.xscale + if args.margin: + args.margin *= args.xscale if args.height: args.height *= args.yscale + if args.depth: + args.depth *= args.zscale if body: if body.part: body.part = scale(body.part, (args.xscale, args.yscale, args.zscale)) From 49ff7102ec0921bd3f0a7831d7fff8c3763168e3 Mon Sep 17 00:00:00 2001 From: WJCarpenter Date: Thu, 22 Jan 2026 17:07:28 -0800 Subject: [PATCH 21/50] Update README.md --- README.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 9f2c10a..f603238 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,8 @@ The full command parameter usage (as generate by `gflabel --help`): usage: gflabel [-h] [--vscode] [-w WIDTH] [--height HEIGHT] [--label-depth DEPTH] [--depth DEPTH_MM] [--no-overheight] [-d DIVISIONS] [--font FONT] [--font-size-maximum FONT_SIZE_MAXIMUM | --font-size FONT_SIZE] [--font-style {regular,bold,italic,bolditalic}] [--font-path FONT_PATH] [--margin MARGIN] [-o OUTPUT] [--style {embossed,debossed,embedded}] [--base-color BASE_COLOR] [--label-color LABEL_COLOR] [--svg-mono] - [--list-fragments] [--list-symbols] [--label-gap LABEL_GAP] [--column-gap COLUMN_GAP] [-v] [--version VERSION] + [--list-fragments] [--list-symbols] [--label-gap LABEL_GAP] [--column-gap COLUMN_GAP] [--xscale XSCALE] [--yscale YSCALE] [--zscale ZSCALE] + [-v] [--version VERSION] BASE LABEL [LABEL ...] Generate gridfinity bin labels @@ -162,10 +163,10 @@ options: --style {embossed,debossed,embedded} How the label contents are formed. --base-color BASE_COLOR - The name of a color used for rendering the base. Can be any of the recognized CSS3 color names. + The name of a color used for rendering the base. Can be any of the recognized CSS3 color names. Default: orange --label-color LABEL_COLOR The name of a color used for rendering the label contents. Can be any of the recognized CSS3 color names. Ignored for style - 'debossed'. + 'debossed'. Default: blue --svg-mono SVG files are normally produced with the same colors as the label contents. If you specify this argument, they are produced with label contents in the default label color. --list-fragments List all available fragments. @@ -174,6 +175,9 @@ options: Vertical gap (in mm) between physical labels. Default: 2 mm --column-gap COLUMN_GAP Gap (in mm) between columns + --xscale XSCALE Scale factor for entire label on the X axis. Default: 1.0 + --yscale YSCALE Scale factor for entire label on the Y axis. Default: 1.0 + --zscale ZSCALE Scale factor for entire label on the Z axis. Default: 1.0 -v, --verbose Verbose output --version VERSION The version of geometry to use for a given label system (if a system has versions). [Default: latest] ``` @@ -260,7 +264,7 @@ A list of all the fragments currently recognised: | bolt | Variable length bolt, in the style of Printables pred-box labels.

If the requested bolt is longer than the available space, then the
bolt will be as large as possible with a broken thread. | | box | Arbitrary width, height centered box. If height is not specified, will expand to row height. | | circle | A filled circle. | -| color | Changes the color to be used for subsequent label fragments on a line (left to right). Every line starts with the default label color. See COLOR_NOTES.md +| color | Changes the color to be used for subsequent fragments on a line. See COLOR_NOTES.md | head | Screw head with specifiable head-shape. | | hexhead | Hexagonal screw head. Will accept drives, but not compulsory. | | hexnut, nut | Hexagonal outer profile nut with circular cutout. | From 2adbb351e862381a8b096b89c0d380c939e4782a Mon Sep 17 00:00:00 2001 From: WJCarpenter Date: Thu, 22 Jan 2026 17:08:53 -0800 Subject: [PATCH 22/50] Update fragments.py --- src/gflabel/fragments.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gflabel/fragments.py b/src/gflabel/fragments.py index f088de8..2c42455 100644 --- a/src/gflabel/fragments.py +++ b/src/gflabel/fragments.py @@ -1258,7 +1258,7 @@ class ModifierFragment(Fragment): @fragment("color") class ColorFragment(ModifierFragment): - """Changes the color to be used for subsequent fragments on a line.""" + """Changes the color to be used for subsequent fragments on a line. See COLOR_NOTES.md""" examples = ["{color(blue)}BLUE{color(green)}GREEN]"] From d8db70123bce8b494e12ead100ab98cf5fb83195 Mon Sep 17 00:00:00 2001 From: WJCarpenter Date: Thu, 22 Jan 2026 19:05:32 -0800 Subject: [PATCH 23/50] better labels in SVG and STEP files by using fragment names (or the text for text fragments) --- src/gflabel/label.py | 41 +++++++++++++++++++++++++++++++---------- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/src/gflabel/label.py b/src/gflabel/label.py index b009401..61d2b3c 100644 --- a/src/gflabel/label.py +++ b/src/gflabel/label.py @@ -7,6 +7,7 @@ import logging import re +from collections.abc import Callable from build123d import ( BuildPart, BuildSketch, @@ -32,13 +33,22 @@ # We extrude fragments into Parts at the very lowest level. We # aggregate them into Compounds (with children) move up the stack. -def _spec_to_fragments(spec: str) -> list[fragments.Fragment]: +def _spec_to_fragments(spec: str) -> tuple[list[fragments.Fragment], list[str]]: """Convert a single line spec string to a list of renderable fragments.""" fragment_list = [] + fragment_name_list = [] for part in RE_FRAGMENT.split(spec): if part.startswith("{") and not part.startswith("{{") and part.endswith("}"): # We have a special fragment. Find and instantiate it. - fragment_list.append(fragments.fragment_from_spec(part[1:-1])) + fragment = fragments.fragment_from_spec(part[1:-1]) + fragment_list.append(fragment) + if isinstance(fragment, fragments.FunctionalFragment): + fun_frag = fragment.fn + if isinstance(fun_frag, Callable): + fragment_name_list.append(fun_frag.__name__.removeprefix("_fragment_")) + else: + fragment_name_list.append(fragment.__class__.__name__.removesuffix("Fragment").removesuffix("fragment")) + else: # We have text. Build123d Text object doesn't handle leading/ # trailing spaces, so let's split them out here and put in @@ -47,15 +57,19 @@ def _spec_to_fragments(spec: str) -> list[fragments.Fragment]: left_spaces = part[: len(part) - len(part.lstrip())] if left_spaces: fragment_list.append(fragments.WhitespaceFragment(left_spaces)) + fragment_name_list.append("whitespace") part = part.lstrip() part_stripped = part.strip() if part_stripped: fragment_list.append(fragments.TextFragment(part_stripped)) + fragment_name_list.append(part_stripped) if chars := len(part) - len(part_stripped): fragment_list.append(fragments.WhitespaceFragment(part[-chars:])) - return fragment_list + fragment_name_list.append("whitespace") + + return fragment_list, fragment_name_list class LabelRenderer: @@ -244,7 +258,7 @@ def _render_single_line( Render a single line of a labelspec. """ # Firstly, split the line into a set of fragment objects - frags = _spec_to_fragments(line) + frags, frag_names = _spec_to_fragments(line) # Now pre-process the modifier fragments and record stuff into # a dictionary for later use; those modifier fragments are @@ -260,7 +274,7 @@ def _render_single_line( current_color = self.opts.default_color renderable_frags = [] - for frag in frags: + for fragdex, frag in enumerate(frags): if isinstance(frag, fragments.ModifierFragment): if isinstance(frag, fragments.ColorFragment): @@ -269,7 +283,8 @@ def _render_single_line( else: fragment_data = {} - fragment_data["color"] = current_color + fragment_data["color_name"] = current_color + fragment_data["fragment_name"] = frag_names[fragdex] frag.fragment_data = fragment_data renderable_frags.append(frag) frags = renderable_frags @@ -342,16 +357,22 @@ def _render_single_line( # EMBOSSED gets raised, DEBOSSED and EMBEDDED get lowered extrude(frag_sketch, self.opts.depth if self.opts.label_style == LabelStyle.EMBOSSED else -self.opts.depth) child_part = child_bpart.part - child_part.color = fragment.fragment_data["color"] + child_part_color_name = fragment.fragment_data["color_name"] + child_part.color = child_part_color_name child_part.locate(fxy) - fragment_class_name = fragment.__class__.__name__ - child_part_label = fragment_class_name.removesuffix("Fragment").removesuffix("fragment") + clean_label = "" + # Sanitize the label. Not for security, but just to hope that + # any external tools don't freak out about labels they don't like. + for char in fragment.fragment_data["fragment_name"]: + if char.isalnum() or char == "_": + clean_label += char + child_part_label = clean_label if clean_label else "item" label_count = label_dict[child_part_label] if child_part_label in label_dict else 0 label_count += 1 label_dict[child_part_label] = label_count child_part_label += "_" + str(label_count) if child_part.color != self.opts.default_color: - child_part_label += "__" + current_color + child_part_label += "__" + child_part_color_name child_part.label = child_part_label child_parts.append(child_part) x += fragment_width From 522e457755d0170fdc6fcedd9c313563d1b1d02c Mon Sep 17 00:00:00 2001 From: WJCarpenter Date: Thu, 22 Jan 2026 19:11:54 -0800 Subject: [PATCH 24/50] tnut --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index f603238..dde03ad 100644 --- a/README.md +++ b/README.md @@ -275,6 +275,7 @@ A list of all the fragments currently recognised: | measure | Fills as much area as possible with a dimension line, and shows the length. Useful for debugging. | | sym, symbol | Render an electronic symbol. | | threaded_insert | Representation of a threaded insert. | +| tnut | T-slot nut. | | variable_resistor | Electrical symbol of a variable resistor. | | washer | Circular washer with a circular hole. | | cullbolt | Alternate bolt representation incorporating screw drive, with fixed length, as used by the [Cullenect][cullenect] system. | From 1741245f9965bf380340b2543ea9d8c6ed44815b Mon Sep 17 00:00:00 2001 From: WJCarpenter Date: Sat, 24 Jan 2026 14:48:03 -0800 Subject: [PATCH 25/50] improved node labels and elided unnecessary node levels --- COLOR_NOTES.md | 25 ++++--- src/gflabel/cli.py | 51 ++++--------- src/gflabel/label.py | 175 +++++++++++++++++++++++++++++++++++-------- 3 files changed, 172 insertions(+), 79 deletions(-) diff --git a/COLOR_NOTES.md b/COLOR_NOTES.md index b306ab8..3a6f1ea 100644 --- a/COLOR_NOTES.md +++ b/COLOR_NOTES.md @@ -13,7 +13,7 @@ When a color fragment is seen, all fragments after that will be rendered in the named color until another color fragment is seen or the end of the line is reached. -Here are some examples. +There are some examples below. They are all rendered in VScode OCP CAD Viewer. For each example, a label with just the default colors is shown along with the same label using colors. @@ -28,10 +28,10 @@ However, treatment of color information when a STEP file is imported into a slic In general, most slicers don't bother with STEP file colors on import. (Most CAD tools do, which is not surprising since STEP is a CAD file format.) -Most color testing was done with Bambu Studio. +Most slicer color testing was done with Bambu Studio. It does not notice colors in STEP files. -However, Bambu Studio does notice colors in OBJ and 3MF files, -though it deals with them differently. +Bambu Studio does notice colors in OBJ and 3MF files, +though it deals with them slightly differently. The file converter at [convert3d.org](https://convert3d.org) can convert a STEP file into an OBJ or 3MF file that has colors expressed in a way that Bambu Studio understands. @@ -53,7 +53,7 @@ when you try to send the sliced model to the 3D printer. Here is a very simple example: -> gflabel --style embossed pred 'R{|}G{|}B' '{color(red)}R{|}{color(green)}G{|}{color(blue)}B' --vscode +> gflabel --vscode pred '{washer} R O Y G B I V {nut}' '{color(chartreuse)}{washer} {color(red)}R {color(orange)}O {color(yellow)}Y {color(green)}G {color(blue)}B {color(indigo)}I {color(violet)}V {color(chartreuse)}{nut}' rgb @@ -61,26 +61,26 @@ Nobody is likely to have more than a few colors when 3D printing labels, but there is no enforced limit. Here's a slightly more complicated example: -> gflabel --style embossed pred '{washer} R O Y G B I V {nut}' '{color(chartreuse)}{washer} {color(red)}R {color(orange)}O {color(yellow)}Y {color(green)}G {color(blue)}B {color(indigo)}I {color(violet)}V {color(chartreuse)}{nut}' --vscode +> gflabel --vscode pred '{<}I used to\nbe an\nadventurer\nlike you,{|}{variable_resistor}{|}{<}but\nthen....' '{<}I used to\nbe an\nadventurer\nlike you,{|}{color(red)}{variable_resistor}{|}{<}but\nthen....' image This is an example of a divided label: -> gflabel --style embossed pred '{<}I used to\nbe an\nadventurer\nlike you,{|}{variable_resistor}{|}{<}but\nthen....' '{<}I used to\nbe an\nadventurer\nlike you,{|}{color(red)}{variable_resistor}{|}{<}but\nthen....' --vscode +> gflabel --vscode pred 'R{|}G{|}B' '{color(red)}R{|}{color(green)}G{|}{color(blue)}B' adventurer Another example: -> gflabel --style embossed pred 'Danger! {head(triangle)}' '{color(red)}Danger! {color(black)}{head(triangle)}' --vscode +> gflabel --vscode pred 'Danger! {head(triangle)}' '{color(red)}Danger! {color(black)}{head(triangle)}' danger The color fragment should work properly with all of the other fragment types since there is no nesting. Here is one of the `{measure}` examples from the README: -> gflabel predbox -w=5 'A\n{measure}{4|}B\n{measure}{1|2}C\n{measure}' 'A\n{color(white)}{measure}{4|}B\n{color(chartreuse)}{measure}{1|2}C\n{color(pink)}{measure}' --vscode +> gflabel --vscode predbox -w=5 'A\n{measure}{4|}B\n{measure}{1|2}C\n{measure}' 'A\n{color(white)}{measure}{4|}B\n{color(chartreuse)}{measure}{1|2}C\n{color(pink)}{measure}' measure @@ -89,11 +89,14 @@ If you change the color inside a text fragment, the spacing is likely to be affected. It's because rendering an uninterrupted text fragment is done with the assistance of low-level font handling code. +(It's the same reason you might see slight spacing differences on different platforms, +even though you're using the same font.) When that same piece of text is broken into two or more pieces, the spacing between them is handled directly by the `gflabel` code. -> gflabel --style embossed pred 'WWW' 'W{color(blue)}W{color(blue)}W' --vscode +Have a close look at the spacing between the tips of these letters: -www +> gflabel --vscode pred 'WWW' 'W{color(blue)}W{color(blue)}W' +www diff --git a/src/gflabel/cli.py b/src/gflabel/cli.py index 2431588..f5b8c4d 100644 --- a/src/gflabel/cli.py +++ b/src/gflabel/cli.py @@ -47,9 +47,9 @@ from .bases.none import NoneBase from .bases.plain import PlainBase from .bases.pred import PredBase, PredBoxBase -from .label import render_divided_label +from .label import render_collection_of_labels, clean_up_name from .options import LabelStyle, RenderOptions -from .util import IndentingRichHandler, batched, unit_registry +from .util import IndentingRichHandler, unit_registry logger = logging.getLogger(__name__) @@ -400,7 +400,6 @@ def run(argv: list[str] | None = None): body: LabelBase | None = None body = base_type(args) - if args.xscale != 1.0 or args.yscale != 1.0 or args.zscale != 1.0: logger.info(f"Scaling overall label by ({args.xscale}, {args.yscale}, {args.zscale})") if args.width: @@ -429,8 +428,6 @@ def run(argv: list[str] | None = None): options = RenderOptions.from_args(args) logger.debug("Got render options: %s", options) with BuildPart() as base_bpart: - y = 0 - if body.part: y_offset_each_label = body.part.bounding_box().size.Y + args.label_gap label_area = body.area @@ -445,34 +442,14 @@ def run(argv: list[str] | None = None): X=args.width.to("mm").magnitude, Y=args.height.to("mm").magnitude ) + labels_compound = render_collection_of_labels(args.labels, args.divisions, y_offset_each_label, options, label_area) + + y = 0 body_locations = [] - child_pcomps = [] - batch_iter = batched(args.labels, args.divisions) - for ba in batch_iter: - labels = ba - xy = Location([0, y]) + for boddex in range(len(labels_compound.children)): body_locations.append((0, y)) - with Locations([xy]): - try: - ch_pc = render_divided_label( - labels, - label_area, - divisions=args.divisions, - options=options, - ) - ch_pc.locate(xy) - ch_pc.label = "Label_" + str(len(child_pcomps)+1) - child_pcomps.append(ch_pc) - - except fragments.InvalidFragmentSpecification as e: - rich.print(f"\n[y][b]Could not proceed: {e}[/b][/y]\n") - sys.exit(1) y -= y_offset_each_label - - label_compound = Compound(children=child_pcomps) - label_compound.label = "Label" - logger.debug(f"LABEL COMPOUND {label_compound}\n{label_compound.show_topology()}") - + if not is_2d: # Create all of the bases if body.part: @@ -493,11 +470,11 @@ def run(argv: list[str] | None = None): logger.debug(f"BASE PART {base_part}\n{base_part.show_topology()}") if args.style == LabelStyle.DEBOSSED: # this produces "UserWarning: Unknown Compound type, color not set"; I don't know why - base_part -= label_compound + base_part -= labels_compound assembly = Compound(children=[base_part]) else: - assembly = Compound(children=[base_part, label_compound]) - base_part.label = "Base" + assembly = Compound(children=[base_part, labels_compound]) + base_part.label = clean_up_name("Base") base_part.color = Color(args.base_color) for output in args.output: @@ -509,7 +486,7 @@ def run(argv: list[str] | None = None): export_step(assembly, output) elif output.endswith(".svg"): max_dimension = max( - *label_compound.bounding_box().size, label_area.X, label_area.Y + *labels_compound.bounding_box().size, label_area.X, label_area.Y ) exporter = ExportSVG(scale=100 / max_dimension) @@ -518,11 +495,11 @@ def run(argv: list[str] | None = None): exporter.add_shape(body_box_sketch, layer="Box") if args.svg_mono: exporter.add_layer("Shapes", fill_color=Color(args.label_color), line_weight=0) - compound_in_plane = label_compound.intersect(Plane.XY) + compound_in_plane = labels_compound.intersect(Plane.XY) exporter.add_shape(compound_in_plane, layer="Shapes") else: layer_dict = {} - for pdex, part in enumerate(colored_parts(label_compound)): + for pdex, part in enumerate(colored_parts(labels_compound)): color = part.color color_str = str(color) if not color_str in layer_dict: @@ -537,7 +514,7 @@ def run(argv: list[str] | None = None): if args.vscode: if is_2d: - show(label_compound) + show(labels_compound) else: # Export both step and stl in vscode_ocp mode logger.info("Writing STL label.stl") diff --git a/src/gflabel/label.py b/src/gflabel/label.py index 61d2b3c..f879832 100644 --- a/src/gflabel/label.py +++ b/src/gflabel/label.py @@ -15,12 +15,14 @@ Location, Locations, Mode, + Part, Sketch, Vector, add, extrude, ) from rich import print +from enum import Enum, auto from . import fragments from .options import RenderOptions, LabelStyle @@ -30,9 +32,55 @@ RE_FRAGMENT = re.compile(r"((? tuple[list[fragments.Fragment], list[str]]: """Convert a single line spec string to a list of renderable fragments.""" fragment_list = [] @@ -45,9 +93,9 @@ def _spec_to_fragments(spec: str) -> tuple[list[fragments.Fragment], list[str]]: if isinstance(fragment, fragments.FunctionalFragment): fun_frag = fragment.fn if isinstance(fun_frag, Callable): - fragment_name_list.append(fun_frag.__name__.removeprefix("_fragment_")) + fragment_name_list.append(clean_up_name(fun_frag.__name__)) else: - fragment_name_list.append(fragment.__class__.__name__.removesuffix("Fragment").removesuffix("fragment")) + fragment_name_list.append(clean_up_name(fragment.__class__.__name__)) else: # We have text. Build123d Text object doesn't handle leading/ @@ -57,26 +105,24 @@ def _spec_to_fragments(spec: str) -> tuple[list[fragments.Fragment], list[str]]: left_spaces = part[: len(part) - len(part.lstrip())] if left_spaces: fragment_list.append(fragments.WhitespaceFragment(left_spaces)) - fragment_name_list.append("whitespace") + fragment_name_list.append(clean_up_name(fragments.WhitespaceFragment.__name__)) part = part.lstrip() part_stripped = part.strip() if part_stripped: fragment_list.append(fragments.TextFragment(part_stripped)) - fragment_name_list.append(part_stripped) + fragment_name_list.append(clean_up_name(part_stripped)) if chars := len(part) - len(part_stripped): fragment_list.append(fragments.WhitespaceFragment(part[-chars:])) - fragment_name_list.append("whitespace") - + fragment_name_list.append(clean_up_name(fragments.WhitespaceFragment.__name__)) return fragment_list, fragment_name_list - class LabelRenderer: def __init__(self, options: RenderOptions): self.opts = options - def render(self, spec: str, area: Vector) -> Compound: + def render_batch(self, spec: str, area: Vector) -> Compound: """ Given a specification string, render a single label. @@ -158,12 +204,12 @@ def _handle_spec_alignment(scoped_spec) -> tuple[str, str | None]: with Locations([xy]): ch_pc = self._do_multiline_render(column_spec, Vector(X=width, Y=area.Y)) ch_pc.locate(xy) - ch_pc.label = "Multiline_" + str(len(child_pcomps)+1) child_pcomps.append(ch_pc) x += width + self.opts.column_gap - compound = Compound(children=child_pcomps) - return compound + batch_compound = Compound(children=child_pcomps) + batch_compound.label = clean_up_name(get_global_label("Batch")) + return batch_compound def _do_multiline_render( self, spec: str, area: Vector, is_rescaling: bool = False) -> Compound: @@ -198,12 +244,14 @@ def _do_multiline_render( Vector(X=area.X, Y=row_height), allow_overheight=self.opts.allow_overheight, ) + ch_pc.label = clean_up_name(get_global_label("Line")) ch_pc.locate(xy) - ch_pc.label = "Line_" + str(len(child_pcomps)+1) child_pcomps.append(ch_pc) IndentingRichHandler.dedent() ml_compound = Compound(children=child_pcomps) + ml_compound.label = clean_up_name(get_global_label("Multiline")) + bbox = ml_compound.bounding_box() scale_to_maxwidth = area.X / bbox.size.X scale_to_maxheight = area.Y / bbox.size.Y @@ -283,8 +331,8 @@ def _render_single_line( else: fragment_data = {} - fragment_data["color_name"] = current_color - fragment_data["fragment_name"] = frag_names[fragdex] + fragment_data[FragmentDataItem.COLOR_NAME] = current_color + fragment_data[FragmentDataItem.FRAGMENT_NAME] = frag_names[fragdex] frag.fragment_data = fragment_data renderable_frags.append(frag) frags = renderable_frags @@ -345,7 +393,6 @@ def _render_single_line( logger.warning("Overfull Hbox: Label is wider than available area") child_parts = [] - label_dict = {} # Assemble these onto the target x = -total_width / 2 for fragment, frag_sketch in [(x, rendered[x]) for x in frags]: @@ -357,27 +404,19 @@ def _render_single_line( # EMBOSSED gets raised, DEBOSSED and EMBEDDED get lowered extrude(frag_sketch, self.opts.depth if self.opts.label_style == LabelStyle.EMBOSSED else -self.opts.depth) child_part = child_bpart.part - child_part_color_name = fragment.fragment_data["color_name"] + child_part_color_name = fragment.fragment_data[FragmentDataItem.COLOR_NAME] child_part.color = child_part_color_name child_part.locate(fxy) - clean_label = "" - # Sanitize the label. Not for security, but just to hope that - # any external tools don't freak out about labels they don't like. - for char in fragment.fragment_data["fragment_name"]: - if char.isalnum() or char == "_": - clean_label += char - child_part_label = clean_label if clean_label else "item" - label_count = label_dict[child_part_label] if child_part_label in label_dict else 0 - label_count += 1 - label_dict[child_part_label] = label_count - child_part_label += "_" + str(label_count) - if child_part.color != self.opts.default_color: + fragment_name = fragment.fragment_data[FragmentDataItem.FRAGMENT_NAME] + child_part_label = fragment_name if fragment_name else "item" + if child_part_color_name != self.opts.default_color: child_part_label += "__" + child_part_color_name - child_part.label = child_part_label + child_part.label = clean_up_name(get_global_label(child_part_label)) child_parts.append(child_part) x += fragment_width sl_compound = Compound(children=child_parts) + sl_compound.label = clean_up_name(get_global_label("Line")) return sl_compound def render_divided_label( @@ -394,10 +433,84 @@ def render_divided_label( xy = Location(((leftmost_label_x + i * area_per_label.X, 0))) with Locations([xy]): if label.strip(): - ch_pc = renderer.render(label, area_per_label) + ch_pc = renderer.render_batch(label, area_per_label) ch_pc.locate(xy) - ch_pc.label = "Division_" + str(len(child_pcomps)+1) child_pcomps.append(ch_pc) div_compound = Compound(children=child_pcomps) + div_compound.label = clean_up_name(get_global_label("Batches")) return div_compound + +def render_collection_of_labels(labels:list(str), divisions:int, y_offset_each_label:float, options:RenderOptions, label_area:Vector) -> Compound: + child_pcomps = [] + y = 0 + physical_label_count = 0 + batch_iter = batched(labels, divisions) + for ba in batch_iter: + labels = ba + physical_label_count +=1 + xy = Location([0, y]) + with Locations([xy]): + try: + ch_pc = render_divided_label( + labels, + label_area, + divisions=divisions, + options=options, + ) + ch_pc.label = clean_up_name(get_global_label("Label")) # a physical label + ch_pc.locate(xy) + child_pcomps.append(ch_pc) + + except fragments.InvalidFragmentSpecification as e: + rich.print(f"\n[y][b]Could not proceed: {e}[/b][/y]\n") + sys.exit(1) + y -= y_offset_each_label + + labels_compound = Compound(children=child_pcomps) + labels_compound.label = clean_up_name("Labels") + logger.debug(f"FULL COMPOUND {labels_compound}\n{labels_compound.show_topology()}") + simplify_the_tree(labels_compound) + logger.info(f"SIMPLIFIED topology\n{labels_compound.show_topology(limit_class=Part)}") + return labels_compound + +def simplify_the_tree(comp: Compound): + """Walk the tree of a Compound to eliminate unecessary nodes (those with only a single child)""" + # Sorry to all the middle managers we're laying off :-) + # other than Parts, all Compounds here have at least 1 child, which eliminated some + # cluttery defensive checking + parent = comp.parent + single_child_parent = None + adjustment = Vector(0,0,0) + while parent and len(parent.children) == 1: + single_child_parent = parent + adjustment += parent.location.position + parent = parent.parent + if single_child_parent: + # this relative adjustment is made to account for the locations of + # the eliminated single-child nodes in the original hierarchy + comp.move(Location(position=adjustment)) + # promote the hierarchical label upwards, with 2 exceptions: + # part labels stay where they are, and the very top of the tree is not replaced + if parent and not isinstance(comp, Part): + single_child_parent.label = comp.label + if parent: + # look for the child with the matching label and replace it with this comp + new_children = [] + for child in parent.children: + if child.label == single_child_parent.label: + new_children.append(comp) + else: + new_children.append(child) + parent_location = parent.location + # Child assignment tweaks Compound location, so restore it + parent.children = new_children + parent.location = parent_location + else: + parent_location = single_child_parent.location + single_child_parent.children = [comp] + single_child_parent.location = parent_location + # this is the recursion stopping condition (all leafs are Parts) + if not isinstance(comp, Part): + for child in comp.children: + simplify_the_tree(child) From 1fd6c88b80a760aff841d346186d869a5e52dceb Mon Sep 17 00:00:00 2001 From: WJCarpenter Date: Sat, 24 Jan 2026 15:09:05 -0800 Subject: [PATCH 26/50] revise graphics to show latest node label scheme --- COLOR_NOTES.md | 57 ++++++++++++++++++++++++++++---------------------- 1 file changed, 32 insertions(+), 25 deletions(-) diff --git a/COLOR_NOTES.md b/COLOR_NOTES.md index 3a6f1ea..9279d04 100644 --- a/COLOR_NOTES.md +++ b/COLOR_NOTES.md @@ -17,6 +17,7 @@ There are some examples below. They are all rendered in VScode OCP CAD Viewer. For each example, a label with just the default colors is shown along with the same label using colors. +The Viewer assemlby tree is expanded to show the node labels in the CAD model. ## Slicers @@ -51,38 +52,44 @@ when you try to send the sliced model to the 3D printer. ## Examples -Here is a very simple example: +Here is a very simple example showing a lot of colors: +``` +gflabel --vscode pred '{washer} R O Y G B I V {nut}' '{color(chartreuse)}{washer} {color(red)}R {color(orange)}O {color(yellow)}Y {color(green)}G {color(blue)}B {color(indigo)}I {color(violet)}V {color(chartreuse)}{nut}' +``` +image -> gflabel --vscode pred '{washer} R O Y G B I V {nut}' '{color(chartreuse)}{washer} {color(red)}R {color(orange)}O {color(yellow)}Y {color(green)}G {color(blue)}B {color(indigo)}I {color(violet)}V {color(chartreuse)}{nut}' - -rgb - -Nobody is likely to have more than a few colors when 3D printing labels, +Nobody is likely to have that many colors when 3D printing labels, but there is no enforced limit. Here's a slightly more complicated example: - -> gflabel --vscode pred '{<}I used to\nbe an\nadventurer\nlike you,{|}{variable_resistor}{|}{<}but\nthen....' '{<}I used to\nbe an\nadventurer\nlike you,{|}{color(red)}{variable_resistor}{|}{<}but\nthen....' - -image +``` +gflabel --vscode pred '{<}I used to\nbe an\nadventurer\nlike you,{|}{variable_resistor}{|}{<}but\nthen....' '{<}I used to\nbe an\nadventurer\nlike you,{|}{color(red)}{variable_resistor}{|}{<}but\nthen....' +``` +image This is an example of a divided label: - -> gflabel --vscode pred 'R{|}G{|}B' '{color(red)}R{|}{color(green)}G{|}{color(blue)}B' - -adventurer +``` +gflabel --vscode pred 'R{|}G{|}B' '{color(red)}R{|}{color(green)}G{|}{color(blue)}B' +``` +image Another example: +``` +gflabel --vscode pred 'Danger! {head(triangle)}' '{color(red)}Danger! {color(black)}{head(triangle)}' +``` +image -> gflabel --vscode pred 'Danger! {head(triangle)}' '{color(red)}Danger! {color(black)}{head(triangle)}' - -danger +And another: +``` +gflabel --vscode pred "{head(hex)} {bolt(50)}\nM5x50" "{color(tan)}{head(hex)} {color(red)}{bolt(50)}\n{color(blue)}M5x50" +``` +image The color fragment should work properly with all of the other fragment types since there is no nesting. Here is one of the `{measure}` examples from the README: - -> gflabel --vscode predbox -w=5 'A\n{measure}{4|}B\n{measure}{1|2}C\n{measure}' 'A\n{color(white)}{measure}{4|}B\n{color(chartreuse)}{measure}{1|2}C\n{color(pink)}{measure}' - -measure +``` +gflabel --vscode predbox -w=5 'A\n{measure}{4|}B\n{measure}{1|2}C\n{measure}' 'A\n{color(white)}{measure}{4|}B\n{color(chartreuse)}{measure}{1|2}C\n{color(pink)}{measure}' +``` +image There is one side effect that you might not expect. If you change the color inside a text fragment, @@ -96,7 +103,7 @@ pieces, the spacing between them is handled directly by the `gflabel` code. Have a close look at the spacing between the tips of these letters: - -> gflabel --vscode pred 'WWW' 'W{color(blue)}W{color(blue)}W' - -www +``` +gflabel --vscode pred 'WWW' 'W{color(blue)}W{color(blue)}W' +``` +image From b8bbc0fda9c44450d2c4bd650afbe6c052f2e8d6 Mon Sep 17 00:00:00 2001 From: WJCarpenter Date: Sun, 25 Jan 2026 10:14:17 -0800 Subject: [PATCH 27/50] move interpretation of depth direction from labels.py to cli.py --- src/gflabel/cli.py | 6 +++++- src/gflabel/label.py | 3 +-- src/gflabel/options.py | 2 -- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/gflabel/cli.py b/src/gflabel/cli.py index f5b8c4d..2b3bf10 100644 --- a/src/gflabel/cli.py +++ b/src/gflabel/cli.py @@ -425,6 +425,10 @@ def run(argv: list[str] | None = None): args.divisions = args.divisions or len(args.labels) args.labels = [x.replace("\\n", "\n") for x in args.labels] + # EMBOSSED gets raised, DEBOSSED and EMBEDDED get lowered + if args.style != LabelStyle.EMBOSSED: + args.depth = -args.depth + options = RenderOptions.from_args(args) logger.debug("Got render options: %s", options) with BuildPart() as base_bpart: @@ -529,7 +533,7 @@ def run(argv: list[str] | None = None): # Split the base for display as two colours show_parts = [] show_cols = [] - top = base_part.split(Plane.XY.offset(-args.depth), keep=Keep.TOP) + top = base_part.split(Plane.XY.offset(args.depth), keep=Keep.TOP) if top: show_parts.append(top) show_cols.append(args.base_color) diff --git a/src/gflabel/label.py b/src/gflabel/label.py index f879832..76c6b65 100644 --- a/src/gflabel/label.py +++ b/src/gflabel/label.py @@ -401,8 +401,7 @@ def _render_single_line( with Locations(fxy): if fragment.visible: with BuildPart(mode=Mode.PRIVATE) as child_bpart: - # EMBOSSED gets raised, DEBOSSED and EMBEDDED get lowered - extrude(frag_sketch, self.opts.depth if self.opts.label_style == LabelStyle.EMBOSSED else -self.opts.depth) + extrude(frag_sketch, self.opts.depth) child_part = child_bpart.part child_part_color_name = fragment.fragment_data[FragmentDataItem.COLOR_NAME] child_part.color = child_part_color_name diff --git a/src/gflabel/options.py b/src/gflabel/options.py index 110da55..99012a4 100644 --- a/src/gflabel/options.py +++ b/src/gflabel/options.py @@ -91,7 +91,6 @@ class RenderOptions(NamedTuple): # like everything else? allow_overheight: bool = True column_gap: float = 0.4 - label_style: LabelStyle = LabelStyle.EMBOSSED depth: float = 0.4 default_color: str = "black" @@ -121,7 +120,6 @@ def from_args(cls, args: argparse.Namespace) -> RenderOptions: ), allow_overheight=not args.no_overheight, column_gap=args.column_gap, - label_style=args.style, depth=args.depth, default_color=args.label_color, ) From 6da29596e7df874a6f02ccdc84f97cd754ec7de8 Mon Sep 17 00:00:00 2001 From: WJCarpenter Date: Sun, 25 Jan 2026 11:37:46 -0800 Subject: [PATCH 28/50] introduce --embedded-lift for better visualization of embedded labels --- README.md | 7 +++++-- src/gflabel/cli.py | 8 ++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index dde03ad..194d092 100644 --- a/README.md +++ b/README.md @@ -123,8 +123,8 @@ The full command parameter usage (as generate by `gflabel --help`): usage: gflabel [-h] [--vscode] [-w WIDTH] [--height HEIGHT] [--label-depth DEPTH] [--depth DEPTH_MM] [--no-overheight] [-d DIVISIONS] [--font FONT] [--font-size-maximum FONT_SIZE_MAXIMUM | --font-size FONT_SIZE] [--font-style {regular,bold,italic,bolditalic}] [--font-path FONT_PATH] [--margin MARGIN] [-o OUTPUT] [--style {embossed,debossed,embedded}] [--base-color BASE_COLOR] [--label-color LABEL_COLOR] [--svg-mono] - [--list-fragments] [--list-symbols] [--label-gap LABEL_GAP] [--column-gap COLUMN_GAP] [--xscale XSCALE] [--yscale YSCALE] [--zscale ZSCALE] - [-v] [--version VERSION] + [--embedded-lift EMBEDDED_LIFT] [--list-fragments] [--list-symbols] [--label-gap LABEL_GAP] [--column-gap COLUMN_GAP] [--xscale XSCALE] + [--yscale YSCALE] [--zscale ZSCALE] [-v] [--version VERSION] BASE LABEL [LABEL ...] Generate gridfinity bin labels @@ -169,6 +169,9 @@ options: 'debossed'. Default: blue --svg-mono SVG files are normally produced with the same colors as the label contents. If you specify this argument, they are produced with label contents in the default label color. + --embedded-lift EMBEDDED_LIFT + Visualization can have artifacts for embedded style, so lift the embedded labels on Z axis by this (small) amount (in mm). Use 0 to + ignore and get precise STEP/STL files. Default: 0.005 --list-fragments List all available fragments. --list-symbols List all available electronic symbols --label-gap LABEL_GAP diff --git a/src/gflabel/cli.py b/src/gflabel/cli.py index 2b3bf10..70a01b8 100644 --- a/src/gflabel/cli.py +++ b/src/gflabel/cli.py @@ -308,6 +308,12 @@ def run(argv: list[str] | None = None): action="store_true", default=False, ) + parser.add_argument( + "--embedded-lift", + help="Visualization can have artifacts for embedded style, so lift the embedded labels on Z axis by this (small) amount (in mm). Use 0 to ignore and get precise STEP/STL files. Default: %(default)s", + type=float, + default=0.005, + ) parser.add_argument( "--list-fragments", help="List all available fragments.", @@ -477,6 +483,8 @@ def run(argv: list[str] | None = None): base_part -= labels_compound assembly = Compound(children=[base_part]) else: + if args.style == LabelStyle.EMBEDDED and args.embedded_lift != 0: + labels_compound.locate(Location(position=Vector(0, 0, args.embedded_lift))) assembly = Compound(children=[base_part, labels_compound]) base_part.label = clean_up_name("Base") base_part.color = Color(args.base_color) From 1f26a02f6053723e0cb18d02a35f87dec895b478 Mon Sep 17 00:00:00 2001 From: WJCarpenter Date: Sun, 25 Jan 2026 12:15:32 -0800 Subject: [PATCH 29/50] manually adjust for several upstream commits --- README.md | 1 + src/gflabel/cli.py | 27 +++++++++++++++++++++++---- src/gflabel/fragments.py | 4 ++-- 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 194d092..7946585 100644 --- a/README.md +++ b/README.md @@ -212,6 +212,7 @@ The base (specified by `--base=TYPE`) defines the shape of what the label is gen | ---- | ----------- | ----- | | `pred` | For [Pred's parametric labelled bins][predlabel] labels. If specifying this style, then height is ignored and width is in gridfinity units (e.g. `--width=1` for a label for a single 42mm bin). | ![](https://github.com/ndevenish/gflabel/raw/refs/heads/readme_images/base_pred.png) | | `predbox` | For labels matching the style of [Pred's Parametric Storage Box][predbox]. These are larger (~25 mm) labels for slotting in the front of the parametric storage boxes. `--width` is for the storage bin width, and is 4, 5, 6, or 7 u. | ![](https://github.com/ndevenish/gflabel/raw/refs/heads/readme_images/base_predbox.png) +| `tailorbox` | For labels matching the style of [Tailor Glad's Storage Box][tailorbox]. These are even larger labels for slotting in the front of the storage boxes. `--width` is for the storage bin width, and currently only accepts 5u. | ![](https://github.com/ndevenish/gflabel/raw/refs/heads/readme_images/base_tailor.png) | `plain` | For a blank, square label with a chamfered top edge. The specified width and height will be the whole area of the label base. You must specify at least a width. | ![](https://github.com/ndevenish/gflabel/raw/refs/heads/readme_images/base_plain.png) | `cullenect` | For [Cullen J Webb's ](https://makerworld.com/en/models/446624) swappable label system. Label is a 36.4 mm x 11 mm rounded rectangle with snap-fit inserts on the back. Use without margins to match the author's style labels. | ![](https://github.com/ndevenish/gflabel/raw/refs/heads/readme_images/base_cullenect.png) | `modern` | For [Modern Gridfinity Case][modern] labels, ~22 mm high labels that slot into the front. `--width` is for the storage bin width, and can be 3, 4, 5, 6, 7 or 8 u. | ![](https://github.com/ndevenish/gflabel/raw/refs/heads/readme_images/base_modern.png) | diff --git a/src/gflabel/cli.py b/src/gflabel/cli.py index 70a01b8..5566745 100644 --- a/src/gflabel/cli.py +++ b/src/gflabel/cli.py @@ -47,6 +47,7 @@ from .bases.none import NoneBase from .bases.plain import PlainBase from .bases.pred import PredBase, PredBoxBase +from .bases.tailor import TailorBoxBase from .label import render_collection_of_labels, clean_up_name from .options import LabelStyle, RenderOptions from .util import IndentingRichHandler, unit_registry @@ -115,7 +116,15 @@ def __call__(self, parser, namespace, values, _option_string=None): if values in deprecated_choices: values = deprecated_choices[values] - choices = ["pred", "plain", "none", "cullenect", "predbox", "modern"] + choices = [ + "pred", + "plain", + "none", + "cullenect", + "predbox", + "tailorbox", + "modern", + ] if values not in choices: # Allow prefix-only of choice name, as long as unambiguous @@ -146,6 +155,7 @@ def base_name_to_subclass(name: str) -> type[LabelBase]: "modern": ModernBase, "pred": PredBase, "predbox": PredBoxBase, + "tailorbox": TailorBoxBase, "plain": PlainBase, "none": NoneBase, None: NoneBase, @@ -334,13 +344,22 @@ def run(argv: list[str] | None = None): "--column-gap", help="Gap (in mm) between columns", default=0.4, type=float ) parser.add_argument( - "--xscale", help="Scale factor for entire label on the X axis. Default: %(default)s", default=1.0, type=float + "--xscale", + help="Scale factor for entire label on the X axis. Default: %(default)s", + default=1.0, + type=float, ) parser.add_argument( - "--yscale", help="Scale factor for entire label on the Y axis. Default: %(default)s", default=1.0, type=float + "--yscale", + help="Scale factor for entire label on the Y axis. Default: %(default)s", + default=1.0, + type=float, ) parser.add_argument( - "--zscale", help="Scale factor for entire label on the Z axis. Default: %(default)s", default=1.0, type=float + "--zscale", + help="Scale factor for entire label on the Z axis. Default: %(default)s", + default=1.0, + type=float, ) parser.add_argument("--box", action="store_true", help=argparse.SUPPRESS) parser.add_argument("-v", "--verbose", help="Verbose output", action="store_true") diff --git a/src/gflabel/fragments.py b/src/gflabel/fragments.py index 2c42455..0bd2f72 100644 --- a/src/gflabel/fragments.py +++ b/src/gflabel/fragments.py @@ -462,8 +462,8 @@ def _fragment_circle(height: float, _maxsize: float) -> Sketch: def _fragment_tnut(height: float, _maxsize: float) -> Sketch: """T-slot nut.""" with BuildSketch(mode=Mode.PRIVATE) as sketch: - RectangleRounded(height*0.6, height, height/7) - Circle((height*0.4)/2, mode=Mode.SUBTRACT) + RectangleRounded(height * 0.6, height, height / 7) + Circle((height*0.4) / 2, mode=Mode.SUBTRACT) return sketch.sketch From 8c6c88eb5996d40f43f58c4621df9e2529950e26 Mon Sep 17 00:00:00 2001 From: WJCarpenter Date: Sun, 25 Jan 2026 12:27:29 -0800 Subject: [PATCH 30/50] make all ModifierFragment subclasses unrenderable and not visible --- src/gflabel/fragments.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/gflabel/fragments.py b/src/gflabel/fragments.py index 0bd2f72..432090d 100644 --- a/src/gflabel/fragments.py +++ b/src/gflabel/fragments.py @@ -1256,6 +1256,13 @@ def __init__(self, *args): class ModifierFragment(Fragment): """This Fragment subclass is for fragments that make some kind of inline adjustment applicable to fragments that follow it. Each line of a label starts with defaults, as if no modifier fragment has yet been seen.""" + visible = False + + # a tiny, tiny circle for a microscopic bounding box, which in any case is invisible + # this eliminates some tedious special cases in single line processing + def render(self, height: float, maxsize: float, options: RenderOptions) -> Sketch: + raise NotImplementedError(f"Modifier fragments should never be rendered: {self.__class__.__name__}") + @fragment("color") class ColorFragment(ModifierFragment): """Changes the color to be used for subsequent fragments on a line. See COLOR_NOTES.md""" @@ -1265,15 +1272,6 @@ class ColorFragment(ModifierFragment): def __init__(self, color_name: str): self.color = color_name - visible = False - # a tiny, tiny circle for a microscopic bounding box, which in any case is invisible - # this eliminates some tedious special cases in single line processing - def render(self, height: float, maxsize: float, options: RenderOptions) -> Sketch: - with BuildSketch() as sketch: - Circle(0.000000000001) - return sketch.sketch - - @fragment("magnet", examples=["{magnet}"]) def _fragment_magnet(height: float, _maxsize: float) -> Sketch: """Horseshoe shaped magnet symbol.""" From 7d0104bae8304ffb7b706af266cf0943de65a023 Mon Sep 17 00:00:00 2001 From: WJCarpenter Date: Tue, 27 Jan 2026 16:17:12 -0800 Subject: [PATCH 31/50] introduces modifier fragment {scale()} For inline scaling along any of the 3 axes. See SCALE_AND_OFFSET_NOTES.md for details. --- SCALE_AND_OFFSET_NOTES.md | 60 +++++++++++++++++++++++++++ src/gflabel/cli.py | 42 ++++++++++++------- src/gflabel/fragments.py | 40 ++++++++++++++++++ src/gflabel/label.py | 87 +++++++++++++++++++++++++++------------ 4 files changed, 186 insertions(+), 43 deletions(-) create mode 100644 SCALE_AND_OFFSET_NOTES.md diff --git a/SCALE_AND_OFFSET_NOTES.md b/SCALE_AND_OFFSET_NOTES.md new file mode 100644 index 0000000..206cc6f --- /dev/null +++ b/SCALE_AND_OFFSET_NOTES.md @@ -0,0 +1,60 @@ +There are two )OK, three) distinct types of scaling in gflabel. + +- The `--xscale`, `--yscale`, and `--zscale` command line options can be used to scale the entire created physical labels along any of the axes. +That scaling is done at the very end of things and is best used for small nudges when the pysical label is not quite the right size. +- The `{scale()}` fragment provides for inline scaling along any one or more of the x/y/z axes. +The scaling is applied for individual fragments in a line after the fragment has been rendered into a Sketch +and the Sketch is extruded into a Part. +It's best used when you want to distort the shape of a rendered fragment. +--gflabel code has many options, calculations, and heuristics for presenting label content pleasantly in a generally desirable layout. +Those factors are extremely handy for someone who just wants to make a nice-looking label without a lot of bother. + +Unfortunately, these things can interact in mysterious ways that are difficult to overcome. +It's easy to make fragments expand beyond the base's pysical boundaries, +have fragments overlap with each other, +or just generally show up in ways you don't expect. + +There is a brute force option when all else fails: the `{offset()}` fragment. +Fragments are positioned on the label base according to calculations done by `gflabel` code. +In the simplest case, a single item is placed at the (0,0,0) origin point, +with the X and Y dimensions centered and the Z dimension extruded from 0 according top the label style. +When there are mutliple fragments, the `gflabel` logic places each at a calculated location in the XCY plane. +The `{offset()}` fragment, as its name implies, +lets you apply a specific offset to any of the X/Y/Z axes. +For example, a negative X offset value will move the affected fragments to the left by that amount. + +Here are some examples: + +``` +gflabel --vscode pred "normal" "{scale(x,0.5)}thin" "{scale(x,2)}wide" +``` +``` +gflabel --vscode pred "normal{|}{scale(x,0.5)}thin{|}{scale(x,2)}wide" +``` + +``` +gflabel --vscode pred "normal" "{scale(y,0.5)}short" "{scale(y,2)}tall" +``` + +``` +gflabel --vscode pred "normal{|}{scale(y,0.5)}short{|}{scale(y,2)}tall" +``` + +``` +gflabel --vscode pred "{scale(x,0.5)}word" "{scale(y,2)}word" +``` +``` +gflabel --vscode pred "normal" "{scale(z,0.5)}thin" "{scale(z,2)}thick" +``` +``` +gflabel --vscode pred "grab here:{7|}{scale(z,8, x,0.2, y,0.2)}{color(red)}{head(hex)}" +``` +``` +gflabel --vscode pred "twirl here:{7|}{scale(z,5, x,0.3, y,0.3)}{offset(z,2)}{color(red)}{head(hex)}" --style embedded +``` +``` +gflabel --vscode pred "{scale(z,3)}stencil" --style debossed +``` +``` +gflabel --vscode pred "stencil" --style debossed --depth 3 +``` diff --git a/src/gflabel/cli.py b/src/gflabel/cli.py index 5566745..994dddf 100644 --- a/src/gflabel/cli.py +++ b/src/gflabel/cli.py @@ -453,8 +453,11 @@ def run(argv: list[str] | None = None): # EMBOSSED gets raised, DEBOSSED and EMBEDDED get lowered if args.style != LabelStyle.EMBOSSED: args.depth = -args.depth - options = RenderOptions.from_args(args) + # now put it back where it was + if args.style != LabelStyle.EMBOSSED: + args.depth = -args.depth + logger.debug("Got render options: %s", options) with BuildPart() as base_bpart: if body.part: @@ -498,8 +501,10 @@ def run(argv: list[str] | None = None): if not is_2d: logger.debug(f"BASE PART {base_part}\n{base_part.show_topology()}") if args.style == LabelStyle.DEBOSSED: - # this produces "UserWarning: Unknown Compound type, color not set"; I don't know why - base_part -= labels_compound + base_part -= labels_compound # algebra mode + # this cloning avoids "UserWarning: Unknown Compound type, color not set"; I don't know why + base_part = Part(base_part) + # this Compound wrapper doesn't seem to be needed, but doing it for consistency assembly = Compound(children=[base_part]) else: if args.style == LabelStyle.EMBEDDED and args.embedded_lift != 0: @@ -557,19 +562,24 @@ def run(argv: list[str] | None = None): # CAD viewer notices the Part colors show(assembly) else: - # Split the base for display as two colours - show_parts = [] - show_cols = [] - top = base_part.split(Plane.XY.offset(args.depth), keep=Keep.TOP) - if top: - show_parts.append(top) - show_cols.append(args.base_color) - if args.base != "none": - bottom = base_part.split(Plane.XY, keep=Keep.BOTTOM) - if bottom: - show_parts.append(bottom) - show_cols.append(args.label_color) - show(top, bottom, colors=show_cols) + # part min Z will be negative; args.depth is positive + if -args.depth <= base_part.bounding_box().min.Z: + # if the debossing goes all the way through the base, just show the base + show([base_part], colors=[base_part.color]) + else: + # Split the base for display as two colours + show_parts = [] + show_cols = [] + top = base_part.split(Plane.XY.offset(-args.depth), keep=Keep.TOP) + if top: + show_parts.append(top) + show_cols.append(args.base_color) + if args.base != "none": + bottom = base_part.split(Plane.XY, keep=Keep.BOTTOM) + if bottom: + show_parts.append(bottom) + show_cols.append(args.label_color) + show(*show_parts, colors=show_cols) if __name__ == "__main__": run() diff --git a/src/gflabel/fragments.py b/src/gflabel/fragments.py index 432090d..5139b7e 100644 --- a/src/gflabel/fragments.py +++ b/src/gflabel/fragments.py @@ -1272,6 +1272,46 @@ class ColorFragment(ModifierFragment): def __init__(self, color_name: str): self.color = color_name +@fragment("scale") +class ScaleFragment(ModifierFragment): + """Apply a scaling on one or more axes for subsequent fragments on a line.""" + + examples = ["normal{scale(x, 2.5, y, 0.5)}scaled"] + + def __init__(self, *args: list[Any]): + self.x = 1 + self.y = 1 + self.z = 1 + for axis, scale in zip(args[::2], args[1::2]): + if axis == 'x' or axis == 'X': + self.x = float(scale) + elif axis == 'y' or axis == 'Y': + self.y = float(scale) + elif axis == 'z' or axis == 'Z': + self.z = float(scale) + else: + raise InvalidFragmentSpecification(f"For scale fragments, axis must be 'x', 'y', or 'z'. Saw '{axis}'") + +@fragment("offset") +class OffsetFragment(ModifierFragment): + """Apply a placement offset on one or more axes for subsequent fragments on a line.""" + + examples = ["normal{offset(x, 2.5, y, 0.5)}shifted"] + + def __init__(self, *args: list[Any]): + self.x = 0 + self.y = 0 + self.z = 0 + for axis, offset in zip(args[::2], args[1::2]): + if axis == 'x' or axis == 'X': + self.x = float(offset) + elif axis == 'y' or axis == 'Y': + self.y = float(offset) + elif axis == 'z' or axis == 'Z': + self.z = float(offset) + else: + raise InvalidFragmentSpecification(f"For offset fragments, axis must be 'x', 'y', or 'z'. Saw '{axis}'") + @fragment("magnet", examples=["{magnet}"]) def _fragment_magnet(height: float, _maxsize: float) -> Sketch: """Horseshoe shaped magnet symbol.""" diff --git a/src/gflabel/label.py b/src/gflabel/label.py index 76c6b65..bb02900 100644 --- a/src/gflabel/label.py +++ b/src/gflabel/label.py @@ -6,6 +6,7 @@ import logging import re +import sys from collections.abc import Callable from build123d import ( @@ -20,6 +21,7 @@ Vector, add, extrude, + scale, ) from rich import print from enum import Enum, auto @@ -71,6 +73,10 @@ def clean_up_name(dirty_name: str): class FragmentDataItem(Enum): FRAGMENT_NAME = auto() COLOR_NAME = auto() + XSCALE = auto() + YSCALE = auto() + ZSCALE = auto() + OFFSET = auto() @classmethod def _missing_(cls, value): @@ -321,20 +327,36 @@ def _render_single_line( # attached to the Fragment object current_color = self.opts.default_color + current_xscale = 1 + current_yscale = 1 + current_zscale = 1 + current_offset = Location(position=(0,0,0)) renderable_frags = [] - for fragdex, frag in enumerate(frags): - if isinstance(frag, fragments.ModifierFragment): - - if isinstance(frag, fragments.ColorFragment): - logger.info(f"Switching to color '{frag.color}'") - current_color = frag.color + for fragdex, fragment in enumerate(frags): + if isinstance(fragment, fragments.ModifierFragment): + + if isinstance(fragment, fragments.ColorFragment): + logger.info(f"Switching to color '{fragment.color}'") + current_color = fragment.color + elif isinstance(fragment, fragments.ScaleFragment): + logger.info(f"Scaling factor(s) ({fragment.x}, {fragment.y}, {fragment.z}) applied") + current_xscale = fragment.x + current_yscale = fragment.y + current_zscale = fragment.z + elif isinstance(fragment, fragments.OffsetFragment): + logger.info(f"Offsets ({fragment.x}, {fragment.y}, {fragment.z}) applied") + current_offset = Location(position=(fragment.x, fragment.y, fragment.z)) else: fragment_data = {} - fragment_data[FragmentDataItem.COLOR_NAME] = current_color fragment_data[FragmentDataItem.FRAGMENT_NAME] = frag_names[fragdex] - frag.fragment_data = fragment_data - renderable_frags.append(frag) + fragment_data[FragmentDataItem.COLOR_NAME] = current_color + fragment_data[FragmentDataItem.XSCALE] = current_xscale + fragment_data[FragmentDataItem.YSCALE] = current_yscale + fragment_data[FragmentDataItem.ZSCALE] = current_zscale + fragment_data[FragmentDataItem.OFFSET] = current_offset + fragment.fragment_data = fragment_data + renderable_frags.append(fragment) frags = renderable_frags # Overheight fragments: Work out if we have any, so that we can @@ -353,13 +375,14 @@ def _render_single_line( ) rendered: dict[fragments.Fragment, Sketch] = {} - for frag in [x for x in frags if not x.variable_width]: + for fragment in [x for x in frags if not x.variable_width]: + fragment_name = fragment.fragment_data[FragmentDataItem.FRAGMENT_NAME] # Handle overheight if we have overheight turned off frag_available_y = Y_available / ( - 1 if allow_overheight else (frag.overheight or 1) + 1 if allow_overheight else (fragment.overheight or 1) ) - rendered[frag] = frag.render(frag_available_y, area.X, self.opts) - + rendered[fragment] = fragment.render(frag_available_y, area.X, self.opts) + # Work out what we have left to give to the variable labels remaining_area = area.X - sum( x.bounding_box().size.X for x in rendered.values() @@ -369,23 +392,23 @@ def _render_single_line( # Render the variable-width labels. # For now, very dumb algorithm: Each variable fragment gets w/N. # but we recalculate after each render. - for frag in sorted( + for fragment in sorted( [x for x in frags if x.variable_width], key=lambda x: x.priority, reverse=True, ): # Handle overheight if we have overheight turned off frag_available_y = Y_available / ( - 1 if allow_overheight else (frag.overheight or 1) + 1 if allow_overheight else (fragment.overheight or 1) ) - render = frag.render( + rendered_fragment = fragment.render( frag_available_y, - max(remaining_area / count_variable, frag.min_width(area.Y)), + max(remaining_area / count_variable, fragment.min_width(area.Y)), options, ) - rendered[frag] = render + rendered[fragment] = rendered_fragment count_variable -= 1 - remaining_area -= render.bounding_box().size.X + remaining_area -= rendered_fragment.bounding_box().size.X # Calculate the total width total_width = sum(x.bounding_box().size.X for x in rendered.values()) @@ -396,20 +419,30 @@ def _render_single_line( # Assemble these onto the target x = -total_width / 2 for fragment, frag_sketch in [(x, rendered[x]) for x in frags]: + fragment_name = fragment.fragment_data[FragmentDataItem.FRAGMENT_NAME] + fragment_color_name = fragment.fragment_data[FragmentDataItem.COLOR_NAME] + xscale = fragment.fragment_data[FragmentDataItem.XSCALE] + yscale = fragment.fragment_data[FragmentDataItem.YSCALE] + zscale = fragment.fragment_data[FragmentDataItem.ZSCALE] + fragment_offset = fragment.fragment_data[FragmentDataItem.OFFSET] fragment_width = frag_sketch.bounding_box().size.X fxy = Location(((x + fragment_width / 2, 0))) with Locations(fxy): if fragment.visible: with BuildPart(mode=Mode.PRIVATE) as child_bpart: - extrude(frag_sketch, self.opts.depth) + extruded = extrude(frag_sketch, self.opts.depth) + # Rescaling can be performance expensive, so only do it if needed + if xscale != 1 or yscale != 1 or zscale != 1: + logger.info(f"Scaling fragment '{fragment_name}' by ({xscale}, {yscale}, {zscale})") + extruded = scale(extruded, (xscale, yscale, zscale)) + add(extruded) child_part = child_bpart.part - child_part_color_name = fragment.fragment_data[FragmentDataItem.COLOR_NAME] - child_part.color = child_part_color_name + child_part.color = fragment_color_name child_part.locate(fxy) - fragment_name = fragment.fragment_data[FragmentDataItem.FRAGMENT_NAME] - child_part_label = fragment_name if fragment_name else "item" - if child_part_color_name != self.opts.default_color: - child_part_label += "__" + child_part_color_name + child_part.move(fragment_offset) + child_part_label = fragment_name if fragment_name else "item" # else shouldn't happen + if fragment_color_name != self.opts.default_color: + child_part_label += "__" + fragment_color_name child_part.label = clean_up_name(get_global_label(child_part_label)) child_parts.append(child_part) x += fragment_width @@ -462,7 +495,7 @@ def render_collection_of_labels(labels:list(str), divisions:int, y_offset_each_l child_pcomps.append(ch_pc) except fragments.InvalidFragmentSpecification as e: - rich.print(f"\n[y][b]Could not proceed: {e}[/b][/y]\n") + print(f"\n[y][b]Could not proceed: {e}[/b][/y]\n") sys.exit(1) y -= y_offset_each_label From aa81fc73e5cee5f066d05c5563ba96af091a5cf2 Mon Sep 17 00:00:00 2001 From: WJCarpenter Date: Tue, 27 Jan 2026 16:22:34 -0800 Subject: [PATCH 32/50] more fragment notes --- SCALE_AND_OFFSET_NOTES.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/SCALE_AND_OFFSET_NOTES.md b/SCALE_AND_OFFSET_NOTES.md index 206cc6f..905051a 100644 --- a/SCALE_AND_OFFSET_NOTES.md +++ b/SCALE_AND_OFFSET_NOTES.md @@ -23,6 +23,14 @@ The `{offset()}` fragment, as its name implies, lets you apply a specific offset to any of the X/Y/Z axes. For example, a negative X offset value will move the affected fragments to the left by that amount. +Both `scale()}` and `{offset()}` take pairs of arguments. +The first item in each pair is an axis letter (x, y, or z). +The second item in each pair is a number for the amount of scaling or offset for that axis. +You can give 1, 2, or 3 pairs of arguments in a single fragment. +Order of pairs and extra spaces are not significant. +If you are using both `{scale()}` and `{offset()}` consecutively, +it doesn't matter which comes first. + Here are some examples: ``` From 6f8f4a8eeda35d177ae0e4e05d8ab1bd6a76f127 Mon Sep 17 00:00:00 2001 From: WJCarpenter Date: Tue, 27 Jan 2026 17:19:20 -0800 Subject: [PATCH 33/50] add screenshots for the examples --- SCALE_AND_OFFSET_NOTES.md | 63 +++++++++++++++++++++++++++++++++------ 1 file changed, 54 insertions(+), 9 deletions(-) diff --git a/SCALE_AND_OFFSET_NOTES.md b/SCALE_AND_OFFSET_NOTES.md index 905051a..c8364d0 100644 --- a/SCALE_AND_OFFSET_NOTES.md +++ b/SCALE_AND_OFFSET_NOTES.md @@ -1,4 +1,4 @@ -There are two )OK, three) distinct types of scaling in gflabel. +There are two (OK, three) distinct types of scaling in `gflabel`. - The `--xscale`, `--yscale`, and `--zscale` command line options can be used to scale the entire created physical labels along any of the axes. That scaling is done at the very end of things and is best used for small nudges when the pysical label is not quite the right size. @@ -6,7 +6,7 @@ That scaling is done at the very end of things and is best used for small nudges The scaling is applied for individual fragments in a line after the fragment has been rendered into a Sketch and the Sketch is extruded into a Part. It's best used when you want to distort the shape of a rendered fragment. ---gflabel code has many options, calculations, and heuristics for presenting label content pleasantly in a generally desirable layout. +- `gflabel` code has many options, calculations, and heuristics for presenting label content pleasantly in a generally desirable layout. Those factors are extremely handy for someone who just wants to make a nice-looking label without a lot of bother. Unfortunately, these things can interact in mysterious ways that are difficult to overcome. @@ -17,52 +17,97 @@ or just generally show up in ways you don't expect. There is a brute force option when all else fails: the `{offset()}` fragment. Fragments are positioned on the label base according to calculations done by `gflabel` code. In the simplest case, a single item is placed at the (0,0,0) origin point, -with the X and Y dimensions centered and the Z dimension extruded from 0 according top the label style. -When there are mutliple fragments, the `gflabel` logic places each at a calculated location in the XCY plane. +with the X and Y dimensions centered and the Z dimension extruded from 0 according to the label style. +When there are mutliple fragments, the `gflabel` logic places each at a calculated location in the X/Y plane. The `{offset()}` fragment, as its name implies, lets you apply a specific offset to any of the X/Y/Z axes. -For example, a negative X offset value will move the affected fragments to the left by that amount. +For example, a negative X offset value will move the affected fragments to the left by that amount (in millimeters). -Both `scale()}` and `{offset()}` take pairs of arguments. +Both `{scale()}` and `{offset()}` take pairs of arguments. The first item in each pair is an axis letter (x, y, or z). The second item in each pair is a number for the amount of scaling or offset for that axis. You can give 1, 2, or 3 pairs of arguments in a single fragment. Order of pairs and extra spaces are not significant. If you are using both `{scale()}` and `{offset()}` consecutively, it doesn't matter which comes first. +The `{offset()}` is always applied after the `{scale()}` has been applied. Here are some examples: +This is an illustration of scaling on the X axis. +It's shown in separate labels to make the difference obvious. +A scale factor smaller than 1 makes the text thinner, +and a scale factor larger than 1 makes it wider. ``` gflabel --vscode pred "normal" "{scale(x,0.5)}thin" "{scale(x,2)}wide" ``` +image + +Here's another look, this time with divisions within a single label. +In this case, the `gflabel` autoscaling competes with us a bit. ``` gflabel --vscode pred "normal{|}{scale(x,0.5)}thin{|}{scale(x,2)}wide" ``` +image + +Negative scale factors are also allowed, providing a flipping effect. +``` +gflabel --vscode pred "{scale(x,-1)}flipped" "{scale(x,-0.5)}thin" "{scale(x,-2)}wide" +``` +image +Here are similar examples for scaling on the Y axis. +Notice that the `tall` rendering goes past the base boundary. +An `{offset()}` could be used to move it completely back onto the base. ``` gflabel --vscode pred "normal" "{scale(y,0.5)}short" "{scale(y,2)}tall" ``` +image ``` gflabel --vscode pred "normal{|}{scale(y,0.5)}short{|}{scale(y,2)}tall" ``` +image ``` -gflabel --vscode pred "{scale(x,0.5)}word" "{scale(y,2)}word" +gflabel --vscode pred "{scale(y,-1)}flipped" "{scale(y,-0.5)}short" "{scale(y,-2)}tall" ``` +image + +If you use negative scaling for both the X and Y axis, you generally end up with a normal label rendered upside down. +That's not that useful. + +You might expect that scaling X and Y with inverse values would yield similar results. +You are right that they are similar, but they are not identical. +That's because of the various manipulations within the `gflabel` code intended for default (non-scaled) cases. ``` -gflabel --vscode pred "normal" "{scale(z,0.5)}thin" "{scale(z,2)}thick" +gflabel --vscode pred "{scale(x,0.5)}word" "{scale(y,2)}word" ``` +image + +Here are some Z axis examples. +The images are tilted so that the differences can actually be seen. ``` -gflabel --vscode pred "grab here:{7|}{scale(z,8, x,0.2, y,0.2)}{color(red)}{head(hex)}" +gflabel --vscode pred "normal" "{scale(z,0.5)}thin" "{scale(z,2)}thick" ``` +image + +In this example, `--style embedded` is used, and a Z offset is used for part of the label to raise it above the base surface. ``` gflabel --vscode pred "twirl here:{7|}{scale(z,5, x,0.3, y,0.3)}{offset(z,2)}{color(red)}{head(hex)}" --style embedded ``` +image + +If you use `--style debossed` and scale on the Z axis to make it larger than the base's minimum Z axis value +(in this case, -0.4mm for `pred` base), +you create a stencil effect with the label content pierced all the way through. ``` gflabel --vscode pred "{scale(z,3)}stencil" --style debossed ``` +image + +For that simple example, the same thing could be achieved without scaling by using a larger `--depth` value: ``` gflabel --vscode pred "stencil" --style debossed --depth 3 ``` +image From 6319b811224ef2e14d0027fd990861db6826ba4e Mon Sep 17 00:00:00 2001 From: WJCarpenter Date: Tue, 27 Jan 2026 17:34:53 -0800 Subject: [PATCH 34/50] add offset and scale to fragment list --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 7946585..1204280 100644 --- a/README.md +++ b/README.md @@ -277,6 +277,8 @@ A list of all the fragments currently recognised: | lockwasher | Circular washer with a locking cutout. | | magnet | Horseshoe shaped magnet symbol. | | measure | Fills as much area as possible with a dimension line, and shows the length. Useful for debugging. | +| offset | Apply a placement offset on one or more axes for subsequent fragments on a line.| +| scale | Apply a placement offset on one or more axes for subsequent fragments on a line.| | sym, symbol | Render an electronic symbol. | | threaded_insert | Representation of a threaded insert. | | tnut | T-slot nut. | From 29fefe641b198e419437e59460d6072a91f8f14f Mon Sep 17 00:00:00 2001 From: WJCarpenter Date: Tue, 27 Jan 2026 18:30:40 -0800 Subject: [PATCH 35/50] some error checking for scale and offset fragment arguments --- src/gflabel/fragments.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/gflabel/fragments.py b/src/gflabel/fragments.py index 5139b7e..4ce125a 100644 --- a/src/gflabel/fragments.py +++ b/src/gflabel/fragments.py @@ -1279,6 +1279,8 @@ class ScaleFragment(ModifierFragment): examples = ["normal{scale(x, 2.5, y, 0.5)}scaled"] def __init__(self, *args: list[Any]): + if len(args) == 0 or len(args)%2 != 0: + raise InvalidFragmentSpecification(f"For scale fragments, arguments must be given in pairs. Saw {len(args)} arguments.") self.x = 1 self.y = 1 self.z = 1 @@ -1299,6 +1301,8 @@ class OffsetFragment(ModifierFragment): examples = ["normal{offset(x, 2.5, y, 0.5)}shifted"] def __init__(self, *args: list[Any]): + if len(args) == 0 or len(args)%2 != 0: + raise InvalidFragmentSpecification(f"For offset fragments, arguments must be given in pairs. Saw {len(args)} arguments.") self.x = 0 self.y = 0 self.z = 0 From 0545741110341df8feafdebb86ed19816ad93e1d Mon Sep 17 00:00:00 2001 From: WJCarpenter Date: Wed, 28 Jan 2026 12:26:39 -0800 Subject: [PATCH 36/50] KEY=VALUE args for scale and offset These key-=value arguments are more intuitive than the previous pairwise arguments. Example: {scale(x=2, y=0.5)}. --- SCALE_AND_OFFSET_NOTES.md | 30 +++++++++---------- src/gflabel/fragments.py | 61 +++++++++++++++++++-------------------- 2 files changed, 44 insertions(+), 47 deletions(-) diff --git a/SCALE_AND_OFFSET_NOTES.md b/SCALE_AND_OFFSET_NOTES.md index c8364d0..6155772 100644 --- a/SCALE_AND_OFFSET_NOTES.md +++ b/SCALE_AND_OFFSET_NOTES.md @@ -23,11 +23,11 @@ The `{offset()}` fragment, as its name implies, lets you apply a specific offset to any of the X/Y/Z axes. For example, a negative X offset value will move the affected fragments to the left by that amount (in millimeters). -Both `{scale()}` and `{offset()}` take pairs of arguments. -The first item in each pair is an axis letter (x, y, or z). -The second item in each pair is a number for the amount of scaling or offset for that axis. -You can give 1, 2, or 3 pairs of arguments in a single fragment. -Order of pairs and extra spaces are not significant. +Both `{scale()}` and `{offset()}` take "KEY=VALUE" arguments, +and 1, 2, or 3 such arguments can be given. +The key is an axis letter (x, y, or z). +The value is a number for the amount of scaling or offset for that axis. +Order of keys is not significant. If you are using both `{scale()}` and `{offset()}` consecutively, it doesn't matter which comes first. The `{offset()}` is always applied after the `{scale()}` has been applied. @@ -39,20 +39,20 @@ It's shown in separate labels to make the difference obvious. A scale factor smaller than 1 makes the text thinner, and a scale factor larger than 1 makes it wider. ``` -gflabel --vscode pred "normal" "{scale(x,0.5)}thin" "{scale(x,2)}wide" +gflabel --vscode pred "normal" "{scale(x=0.5)}thin" "{scale(x=2)}wide" ``` image Here's another look, this time with divisions within a single label. In this case, the `gflabel` autoscaling competes with us a bit. ``` -gflabel --vscode pred "normal{|}{scale(x,0.5)}thin{|}{scale(x,2)}wide" +gflabel --vscode pred "normal{|}{scale(x=0.5)}thin{|}{scale(x=2)}wide" ``` image Negative scale factors are also allowed, providing a flipping effect. ``` -gflabel --vscode pred "{scale(x,-1)}flipped" "{scale(x,-0.5)}thin" "{scale(x,-2)}wide" +gflabel --vscode pred "{scale(x=-1)}flipped" "{scale(x=-0.5)}thin" "{scale(x=-2)}wide" ``` image @@ -60,17 +60,17 @@ Here are similar examples for scaling on the Y axis. Notice that the `tall` rendering goes past the base boundary. An `{offset()}` could be used to move it completely back onto the base. ``` -gflabel --vscode pred "normal" "{scale(y,0.5)}short" "{scale(y,2)}tall" +gflabel --vscode pred "normal" "{scale(y=0.5)}short" "{scale(y=2)}tall" ``` image ``` -gflabel --vscode pred "normal{|}{scale(y,0.5)}short{|}{scale(y,2)}tall" +gflabel --vscode pred "normal{|}{scale(y=0.5)}short{|}{scale(y=2)}tall" ``` image ``` -gflabel --vscode pred "{scale(y,-1)}flipped" "{scale(y,-0.5)}short" "{scale(y,-2)}tall" +gflabel --vscode pred "{scale(y=-1)}flipped" "{scale(y=-0.5)}short" "{scale(y=-2)}tall" ``` image @@ -81,20 +81,20 @@ You might expect that scaling X and Y with inverse values would yield similar re You are right that they are similar, but they are not identical. That's because of the various manipulations within the `gflabel` code intended for default (non-scaled) cases. ``` -gflabel --vscode pred "{scale(x,0.5)}word" "{scale(y,2)}word" +gflabel --vscode pred "{scale(x=0.5)}word" "{scale(y=2)}word" ``` image Here are some Z axis examples. The images are tilted so that the differences can actually be seen. ``` -gflabel --vscode pred "normal" "{scale(z,0.5)}thin" "{scale(z,2)}thick" +gflabel --vscode pred "normal" "{scale(z=0.5)}thin" "{scale(z=2)}thick" ``` image In this example, `--style embedded` is used, and a Z offset is used for part of the label to raise it above the base surface. ``` -gflabel --vscode pred "twirl here:{7|}{scale(z,5, x,0.3, y,0.3)}{offset(z,2)}{color(red)}{head(hex)}" --style embedded +gflabel --vscode pred "twirl here:{7|}{scale(z=5, x=0.3, y=0.3)}{offset(z=2)}{color(red)}{head(hex)}" --style embedded ``` image @@ -102,7 +102,7 @@ If you use `--style debossed` and scale on the Z axis to make it larger than the (in this case, -0.4mm for `pred` base), you create a stencil effect with the label content pierced all the way through. ``` -gflabel --vscode pred "{scale(z,3)}stencil" --style debossed +gflabel --vscode pred "{scale(z=3)}stencil" --style debossed ``` image diff --git a/src/gflabel/fragments.py b/src/gflabel/fragments.py index 4ce125a..53cb2f4 100644 --- a/src/gflabel/fragments.py +++ b/src/gflabel/fragments.py @@ -84,6 +84,19 @@ class InvalidFragmentSpecification(RuntimeError): pass +def _args_to_dict(allowed:list[str]=None, *args): + args_dict = {} + for arg in args: + key, c, value = arg.partition("=") + key = key.strip().casefold() + value = value.strip() + if not c or not value: + raise InvalidFragmentSpecification(f"KEY=VALUE arguments expected, but saw {arg}.") + if allowed and not key in allowed: + raise InvalidFragmentSpecification(f"Key {key} is unexpected. Wanted one of {allowed}") + args_dict[key] = value + return args_dict + def fragment_from_spec(spec: str) -> Fragment: # If the fragment is just a number, this is distance to space out try: @@ -1276,45 +1289,29 @@ def __init__(self, color_name: str): class ScaleFragment(ModifierFragment): """Apply a scaling on one or more axes for subsequent fragments on a line.""" - examples = ["normal{scale(x, 2.5, y, 0.5)}scaled"] + examples = ["normal{scale(x = 2.5, y = 0.5)}scaled"] - def __init__(self, *args: list[Any]): - if len(args) == 0 or len(args)%2 != 0: - raise InvalidFragmentSpecification(f"For scale fragments, arguments must be given in pairs. Saw {len(args)} arguments.") - self.x = 1 - self.y = 1 - self.z = 1 - for axis, scale in zip(args[::2], args[1::2]): - if axis == 'x' or axis == 'X': - self.x = float(scale) - elif axis == 'y' or axis == 'Y': - self.y = float(scale) - elif axis == 'z' or axis == 'Z': - self.z = float(scale) - else: - raise InvalidFragmentSpecification(f"For scale fragments, axis must be 'x', 'y', or 'z'. Saw '{axis}'") + def __init__(self, *args: list[str]): + if len(args) == 0 or len(args) > 3: + raise InvalidFragmentSpecification(f"For scale fragments, must have 1, 2, or 3 arguments. Saw {len(args)} arguments: {args}") + args_dict = _args_to_dict(["x","y","z"], *args) + self.x = float(args_dict.get("x", "1")) + self.y = float(args_dict.get("y", "1")) + self.z = float(args_dict.get("z", "1")) @fragment("offset") class OffsetFragment(ModifierFragment): """Apply a placement offset on one or more axes for subsequent fragments on a line.""" - examples = ["normal{offset(x, 2.5, y, 0.5)}shifted"] + examples = ["normal{offset(x = 2.5, y = 0.5)}shifted"] - def __init__(self, *args: list[Any]): - if len(args) == 0 or len(args)%2 != 0: - raise InvalidFragmentSpecification(f"For offset fragments, arguments must be given in pairs. Saw {len(args)} arguments.") - self.x = 0 - self.y = 0 - self.z = 0 - for axis, offset in zip(args[::2], args[1::2]): - if axis == 'x' or axis == 'X': - self.x = float(offset) - elif axis == 'y' or axis == 'Y': - self.y = float(offset) - elif axis == 'z' or axis == 'Z': - self.z = float(offset) - else: - raise InvalidFragmentSpecification(f"For offset fragments, axis must be 'x', 'y', or 'z'. Saw '{axis}'") + def __init__(self, *args: list[str]): + if len(args) == 0 or len(args) > 3: + raise InvalidFragmentSpecification(f"For offset fragments, must have 1, 2, or 3 arguments. Saw {len(args)} arguments: {args}") + args_dict = _args_to_dict(["x","y","z"], *args) + self.x = float(args_dict.get("x", "0")) + self.y = float(args_dict.get("y", "0")) + self.z = float(args_dict.get("z", "0")) @fragment("magnet", examples=["{magnet}"]) def _fragment_magnet(height: float, _maxsize: float) -> Sketch: From cf33f5dce4c4dc8b1e4bf1a048451ed95f3fea38 Mon Sep 17 00:00:00 2001 From: WJCarpenter Date: Wed, 28 Jan 2026 14:11:59 -0800 Subject: [PATCH 37/50] Introduces --text-as-parts Text fragments are normally rendered as a single build123d Part. With this option, build123d still creates the single Sketch, but the resulting Faces are extruded to Parts individually. They are given Part labels that might make them easier to select in an external tool. The process of affiliating a character of the text fragment with a particular Face is not exact because some characters can render into multiple Faces, and the space character renders into no Faces. The Faces still get Part labels, but they may be less helpful or even confusing in some edge cases. The reason to let build123d create the Sketch of the entire text fragment is so that it does proper font spacing and other tricks. A more complete technique for the --text-as-parts option would render each character to a Sketch individually in order to count the Faces for that character. Then use that info to label the Parts. There's still no guarantee that build123d will return a Sketch with the Faces in character order. (In fact, we have observed that for some reason the first face corresponds last. We take that into account, but if that last character produced multiple Faces, we'd still be wrong in our workaround.) --- README.md | 6 +++-- src/gflabel/cli.py | 6 +++++ src/gflabel/label.py | 58 +++++++++++++++++++++++++++++++++++------- src/gflabel/options.py | 4 ++- 4 files changed, 62 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 1204280..d1c3d29 100644 --- a/README.md +++ b/README.md @@ -123,8 +123,8 @@ The full command parameter usage (as generate by `gflabel --help`): usage: gflabel [-h] [--vscode] [-w WIDTH] [--height HEIGHT] [--label-depth DEPTH] [--depth DEPTH_MM] [--no-overheight] [-d DIVISIONS] [--font FONT] [--font-size-maximum FONT_SIZE_MAXIMUM | --font-size FONT_SIZE] [--font-style {regular,bold,italic,bolditalic}] [--font-path FONT_PATH] [--margin MARGIN] [-o OUTPUT] [--style {embossed,debossed,embedded}] [--base-color BASE_COLOR] [--label-color LABEL_COLOR] [--svg-mono] - [--embedded-lift EMBEDDED_LIFT] [--list-fragments] [--list-symbols] [--label-gap LABEL_GAP] [--column-gap COLUMN_GAP] [--xscale XSCALE] - [--yscale YSCALE] [--zscale ZSCALE] [-v] [--version VERSION] + [--text-as-parts] [--embedded-lift EMBEDDED_LIFT] [--list-fragments] [--list-symbols] [--label-gap LABEL_GAP] [--column-gap COLUMN_GAP] + [--xscale XSCALE] [--yscale YSCALE] [--zscale ZSCALE] [-v] [--version VERSION] BASE LABEL [LABEL ...] Generate gridfinity bin labels @@ -169,6 +169,8 @@ options: 'debossed'. Default: blue --svg-mono SVG files are normally produced with the same colors as the label contents. If you specify this argument, they are produced with label contents in the default label color. + --text-as-parts Text fragments are rendered as a single Part. If you specify this argument, they are rendered as Parts for individual characters, + which can help identify them in external tools, though the Part labels are 'best effort' and are sometimes disordered. --embedded-lift EMBEDDED_LIFT Visualization can have artifacts for embedded style, so lift the embedded labels on Z axis by this (small) amount (in mm). Use 0 to ignore and get precise STEP/STL files. Default: 0.005 diff --git a/src/gflabel/cli.py b/src/gflabel/cli.py index 994dddf..753db6c 100644 --- a/src/gflabel/cli.py +++ b/src/gflabel/cli.py @@ -318,6 +318,12 @@ def run(argv: list[str] | None = None): action="store_true", default=False, ) + parser.add_argument( + "--text-as-parts", + help="Text fragments are rendered as a single Part. If you specify this argument, they are rendered as Parts for individual characters, which can help identify them in external tools, though the Part labels are 'best effort' and are sometimes disordered.", + action="store_true", + default=False, + ) parser.add_argument( "--embedded-lift", help="Visualization can have artifacts for embedded style, so lift the embedded labels on Z axis by this (small) amount (in mm). Use 0 to ignore and get precise STEP/STL files. Default: %(default)s", diff --git a/src/gflabel/label.py b/src/gflabel/label.py index bb02900..62dfd6c 100644 --- a/src/gflabel/label.py +++ b/src/gflabel/label.py @@ -13,6 +13,7 @@ BuildPart, BuildSketch, Compound, + Face, Location, Locations, Mode, @@ -66,7 +67,7 @@ def clean_up_name(dirty_name: str): char = "_" if char.isascii() and (char.isalnum() or char in "_-"): clean_name += char - if not clean_name[0].isalpha(): + if not clean_name or not clean_name[0].isalpha(): clean_name = "L" + clean_name return clean_name @@ -429,14 +430,53 @@ def _render_single_line( fxy = Location(((x + fragment_width / 2, 0))) with Locations(fxy): if fragment.visible: - with BuildPart(mode=Mode.PRIVATE) as child_bpart: - extruded = extrude(frag_sketch, self.opts.depth) - # Rescaling can be performance expensive, so only do it if needed - if xscale != 1 or yscale != 1 or zscale != 1: - logger.info(f"Scaling fragment '{fragment_name}' by ({xscale}, {yscale}, {zscale})") - extruded = scale(extruded, (xscale, yscale, zscale)) - add(extruded) - child_part = child_bpart.part + if self.opts.text_as_parts and isinstance(fragment, fragments.TextFragment): + + faces = frag_sketch.get_type(Face) + # build123d text rendering seems to order the faces with the + # final character first, and then the other characters in order. + # I don't know if that always holds true, but rearranging things + # on the assumption that it is. Best effort! + if len(faces): + the_first_shall_be_last = faces.pop(0) + faces.append(the_first_shall_be_last) + face_parts = [] + for face in faces: + with BuildPart(mode=Mode.PRIVATE) as face_bpart: + extruded = extrude(face, self.opts.depth) + add(extruded) + face_part = face_bpart.part + # Hoping that the character in the text fragment is in the right order + # Even so, some characters render as multiple faces (e.g., "i"). + # Only use this scheme if the face count is the same as the text length. + # It could still be wrong if the text contains spaces. For example, + # "i x" would contain 3 faces and mislead us. Another best effort! + if len(faces) == len(fragment.text) and not " " in fragment.text: + face_tick = fragment.text[len(face_parts)] + else: + face_tick = str(len(face_parts)) + face_part_label = clean_up_name(get_global_label(fragment_name + "_" + face_tick)) + # We have to extrude each face separately, else the colors and labels get + # lost if we wait and just extrude the Compound + if xscale != 1 or yscale != 1 or zscale != 1: + logger.info(f"Scaling fragment '{face_part_label}' by ({xscale}, {yscale}, {zscale})") + face_part = scale(face_part, (xscale, yscale, zscale)) + face_part.color = fragment_color_name + face_part.label = face_part_label + face_parts.append(face_part) + + child_part = Compound(children=face_parts) + else: + + with BuildPart(mode=Mode.PRIVATE) as child_bpart: + extruded = extrude(frag_sketch, self.opts.depth) + # Rescaling can be performance expensive, so only do it if needed + if xscale != 1 or yscale != 1 or zscale != 1: + logger.info(f"Scaling fragment '{fragment_name}' by ({xscale}, {yscale}, {zscale})") + extruded = scale(extruded, (xscale, yscale, zscale)) + add(extruded) + child_part = child_bpart.part + child_part.color = fragment_color_name child_part.locate(fxy) child_part.move(fragment_offset) diff --git a/src/gflabel/options.py b/src/gflabel/options.py index 99012a4..7d29a69 100644 --- a/src/gflabel/options.py +++ b/src/gflabel/options.py @@ -93,7 +93,8 @@ class RenderOptions(NamedTuple): column_gap: float = 0.4 depth: float = 0.4 default_color: str = "black" - + text_as_parts: bool = False + @classmethod def from_args(cls, args: argparse.Namespace) -> RenderOptions: font_style = [ @@ -122,4 +123,5 @@ def from_args(cls, args: argparse.Namespace) -> RenderOptions: column_gap=args.column_gap, depth=args.depth, default_color=args.label_color, + text_as_parts=args.text_as_parts, ) From 087ec87f39fe8fa0c5e9bf30ff4e3a545915fc08 Mon Sep 17 00:00:00 2001 From: WJCarpenter Date: Thu, 29 Jan 2026 10:09:02 -0800 Subject: [PATCH 38/50] change --svg-mono option to an enum --- README.md | 11 ++++++----- src/gflabel/cli.py | 11 ++++++----- src/gflabel/options.py | 16 ++++++++++++++++ 3 files changed, 28 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index d1c3d29..437a6d6 100644 --- a/README.md +++ b/README.md @@ -122,9 +122,9 @@ The full command parameter usage (as generate by `gflabel --help`): ``` usage: gflabel [-h] [--vscode] [-w WIDTH] [--height HEIGHT] [--label-depth DEPTH] [--depth DEPTH_MM] [--no-overheight] [-d DIVISIONS] [--font FONT] [--font-size-maximum FONT_SIZE_MAXIMUM | --font-size FONT_SIZE] [--font-style {regular,bold,italic,bolditalic}] [--font-path FONT_PATH] - [--margin MARGIN] [-o OUTPUT] [--style {embossed,debossed,embedded}] [--base-color BASE_COLOR] [--label-color LABEL_COLOR] [--svg-mono] - [--text-as-parts] [--embedded-lift EMBEDDED_LIFT] [--list-fragments] [--list-symbols] [--label-gap LABEL_GAP] [--column-gap COLUMN_GAP] - [--xscale XSCALE] [--yscale YSCALE] [--zscale ZSCALE] [-v] [--version VERSION] + [--margin MARGIN] [-o OUTPUT] [--style {embossed,debossed,embedded}] [--base-color BASE_COLOR] [--label-color LABEL_COLOR] + [--svg-mono {none,import,export,both}] [--text-as-parts] [--embedded-lift EMBEDDED_LIFT] [--list-fragments] [--list-symbols] + [--label-gap LABEL_GAP] [--column-gap COLUMN_GAP] [--xscale XSCALE] [--yscale YSCALE] [--zscale ZSCALE] [-v] [--version VERSION] BASE LABEL [LABEL ...] Generate gridfinity bin labels @@ -167,8 +167,9 @@ options: --label-color LABEL_COLOR The name of a color used for rendering the label contents. Can be any of the recognized CSS3 color names. Ignored for style 'debossed'. Default: blue - --svg-mono SVG files are normally produced with the same colors as the label contents. If you specify this argument, they are produced with label - contents in the default label color. + --svg-mono {none,import,export,both} + SVG imports and exports preserve coloring. You can suppress that (treating SVGs as monochome) with this argument for import, export, + or both. Default: none --text-as-parts Text fragments are rendered as a single Part. If you specify this argument, they are rendered as Parts for individual characters, which can help identify them in external tools, though the Part labels are 'best effort' and are sometimes disordered. --embedded-lift EMBEDDED_LIFT diff --git a/src/gflabel/cli.py b/src/gflabel/cli.py index 753db6c..26e42b3 100644 --- a/src/gflabel/cli.py +++ b/src/gflabel/cli.py @@ -49,7 +49,7 @@ from .bases.pred import PredBase, PredBoxBase from .bases.tailor import TailorBoxBase from .label import render_collection_of_labels, clean_up_name -from .options import LabelStyle, RenderOptions +from .options import LabelStyle, RenderOptions, SvgMono from .util import IndentingRichHandler, unit_registry logger = logging.getLogger(__name__) @@ -314,9 +314,10 @@ def run(argv: list[str] | None = None): ) parser.add_argument( "--svg-mono", - help="SVG files are normally produced with the same colors as the label contents. If you specify this argument, they are produced with label contents in the default label color.", - action="store_true", - default=False, + help="SVG imports and exports preserve coloring. You can suppress that (treating SVGs as monochome) with this argument for import, export, or both. Default: %(default)s", + choices=SvgMono, + default=SvgMono.NONE, + type=SvgMono, ) parser.add_argument( "--text-as-parts", @@ -535,7 +536,7 @@ def run(argv: list[str] | None = None): if args.box and is_2d: exporter.add_layer("Box", line_color=Color(args.base_color), line_weight=1) exporter.add_shape(body_box_sketch, layer="Box") - if args.svg_mono: + if args.svg_mono in [SvgMono.EXPORT, SvgMono.BOTH]: exporter.add_layer("Shapes", fill_color=Color(args.label_color), line_weight=0) compound_in_plane = labels_compound.intersect(Plane.XY) exporter.add_shape(compound_in_plane, layer="Shapes") diff --git a/src/gflabel/options.py b/src/gflabel/options.py index 7d29a69..c572a2f 100644 --- a/src/gflabel/options.py +++ b/src/gflabel/options.py @@ -29,6 +29,22 @@ def __str__(self): return self.name.lower() +class SvgMono(Enum): + NONE = auto() + IMPORT = auto() + EXPORT = auto() + BOTH = auto() + + @classmethod + def _missing_(cls, value): + for kind in cls: + if kind.name.lower() == value.lower(): + return kind + + def __str__(self): + return self.name.lower() + + class FontOptions(NamedTuple): font: str | None = None font_style: FontStyle = FontStyle.REGULAR From 99445266d2160fc38863361ae849cb66663e49ea Mon Sep 17 00:00:00 2001 From: WJCarpenter Date: Thu, 29 Jan 2026 11:19:48 -0800 Subject: [PATCH 39/50] option --svg-base to provide an outline or colored shape of the bases in SVG output --- README.md | 8 ++++++-- src/gflabel/cli.py | 25 ++++++++++++++++++++----- src/gflabel/label.py | 3 +-- src/gflabel/options.py | 15 +++++++++++++++ 4 files changed, 42 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 437a6d6..8373e9c 100644 --- a/README.md +++ b/README.md @@ -123,8 +123,9 @@ The full command parameter usage (as generate by `gflabel --help`): usage: gflabel [-h] [--vscode] [-w WIDTH] [--height HEIGHT] [--label-depth DEPTH] [--depth DEPTH_MM] [--no-overheight] [-d DIVISIONS] [--font FONT] [--font-size-maximum FONT_SIZE_MAXIMUM | --font-size FONT_SIZE] [--font-style {regular,bold,italic,bolditalic}] [--font-path FONT_PATH] [--margin MARGIN] [-o OUTPUT] [--style {embossed,debossed,embedded}] [--base-color BASE_COLOR] [--label-color LABEL_COLOR] - [--svg-mono {none,import,export,both}] [--text-as-parts] [--embedded-lift EMBEDDED_LIFT] [--list-fragments] [--list-symbols] - [--label-gap LABEL_GAP] [--column-gap COLUMN_GAP] [--xscale XSCALE] [--yscale YSCALE] [--zscale ZSCALE] [-v] [--version VERSION] + [--svg-mono {none,import,export,both}] [--svg-base {none,outline,solid}] [--text-as-parts] [--embedded-lift EMBEDDED_LIFT] [--list-fragments] + [--list-symbols] [--label-gap LABEL_GAP] [--column-gap COLUMN_GAP] [--xscale XSCALE] [--yscale YSCALE] [--zscale ZSCALE] [-v] + [--version VERSION] BASE LABEL [LABEL ...] Generate gridfinity bin labels @@ -170,6 +171,9 @@ options: --svg-mono {none,import,export,both} SVG imports and exports preserve coloring. You can suppress that (treating SVGs as monochome) with this argument for import, export, or both. Default: none + --svg-base {none,outline,solid} + SVG files are normally produced with just the label content and an optional box outline. With this option, an outline or full shape of + the bases can be included. --svg-base takes precedence over --box. --text-as-parts Text fragments are rendered as a single Part. If you specify this argument, they are rendered as Parts for individual characters, which can help identify them in external tools, though the Part labels are 'best effort' and are sometimes disordered. --embedded-lift EMBEDDED_LIFT diff --git a/src/gflabel/cli.py b/src/gflabel/cli.py index 26e42b3..293bc2d 100644 --- a/src/gflabel/cli.py +++ b/src/gflabel/cli.py @@ -49,7 +49,7 @@ from .bases.pred import PredBase, PredBoxBase from .bases.tailor import TailorBoxBase from .label import render_collection_of_labels, clean_up_name -from .options import LabelStyle, RenderOptions, SvgMono +from .options import LabelStyle, RenderOptions, SvgMono, SvgBase from .util import IndentingRichHandler, unit_registry logger = logging.getLogger(__name__) @@ -319,6 +319,13 @@ def run(argv: list[str] | None = None): default=SvgMono.NONE, type=SvgMono, ) + parser.add_argument( + "--svg-base", + help="SVG files are normally produced with just the label content and an optional box outline. With this option, an outline or full shape of the bases can be included. --svg-base takes precedence over --box.", + choices=SvgBase, + default=SvgBase.NONE, + type=SvgBase, + ) parser.add_argument( "--text-as-parts", help="Text fragments are rendered as a single Part. If you specify this argument, they are rendered as Parts for individual characters, which can help identify them in external tools, though the Part labels are 'best effort' and are sometimes disordered.", @@ -519,7 +526,8 @@ def run(argv: list[str] | None = None): assembly = Compound(children=[base_part, labels_compound]) base_part.label = clean_up_name("Base") base_part.color = Color(args.base_color) - + logger.info(f"Topology\n{assembly.show_topology(limit_class=Part)}") + for output in args.output: if output.endswith(".stl"): logger.info(f"Writing STL {output}") @@ -533,13 +541,20 @@ def run(argv: list[str] | None = None): ) exporter = ExportSVG(scale=100 / max_dimension) - if args.box and is_2d: + if args.svg_base is not SvgBase.NONE and not is_2d: # check is_2d since is_2d doesn't render the bases + if args.svg_base == SvgBase.OUTLINE: + exporter.add_layer("Base", line_color=Color(args.base_color), line_weight=1) + else: + exporter.add_layer("Base", fill_color=Color(args.base_color), line_weight=0) + part_in_plane = base_part.intersect(Plane.XY) + exporter.add_shape(part_in_plane, layer="Base") + elif args.box and is_2d: exporter.add_layer("Box", line_color=Color(args.base_color), line_weight=1) exporter.add_shape(body_box_sketch, layer="Box") if args.svg_mono in [SvgMono.EXPORT, SvgMono.BOTH]: - exporter.add_layer("Shapes", fill_color=Color(args.label_color), line_weight=0) + exporter.add_layer("Labels", fill_color=Color(args.label_color), line_weight=0) compound_in_plane = labels_compound.intersect(Plane.XY) - exporter.add_shape(compound_in_plane, layer="Shapes") + exporter.add_shape(compound_in_plane, layer="Labels") else: layer_dict = {} for pdex, part in enumerate(colored_parts(labels_compound)): diff --git a/src/gflabel/label.py b/src/gflabel/label.py index 62dfd6c..b2e8535 100644 --- a/src/gflabel/label.py +++ b/src/gflabel/label.py @@ -541,9 +541,8 @@ def render_collection_of_labels(labels:list(str), divisions:int, y_offset_each_l labels_compound = Compound(children=child_pcomps) labels_compound.label = clean_up_name("Labels") - logger.debug(f"FULL COMPOUND {labels_compound}\n{labels_compound.show_topology()}") + logger.debug(f"Labels topology {labels_compound}\n{labels_compound.show_topology(limit_class=Part)}") simplify_the_tree(labels_compound) - logger.info(f"SIMPLIFIED topology\n{labels_compound.show_topology(limit_class=Part)}") return labels_compound def simplify_the_tree(comp: Compound): diff --git a/src/gflabel/options.py b/src/gflabel/options.py index c572a2f..2b954fe 100644 --- a/src/gflabel/options.py +++ b/src/gflabel/options.py @@ -45,6 +45,21 @@ def __str__(self): return self.name.lower() +class SvgBase(Enum): + NONE = auto() + OUTLINE = auto() + SOLID = auto() + + @classmethod + def _missing_(cls, value): + for kind in cls: + if kind.name.lower() == value.lower(): + return kind + + def __str__(self): + return self.name.lower() + + class FontOptions(NamedTuple): font: str | None = None font_style: FontStyle = FontStyle.REGULAR From b887781f1df5c8c0ab044927c3c27f716304adaf Mon Sep 17 00:00:00 2001 From: Paul Heidenreich Date: Wed, 28 Jan 2026 23:37:53 +0100 Subject: [PATCH 40/50] working --- pyproject.toml | 1 + src/gflabel/cli.py | 65 +++++++++++++++++++++------- src/gflabel/three_mf.py | 93 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 144 insertions(+), 15 deletions(-) create mode 100644 src/gflabel/three_mf.py diff --git a/pyproject.toml b/pyproject.toml index 3b2f070..ed8c237 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ readme = "README.md" [tool.poetry.dependencies] python = "^3.10,<3.13" build123d = ">=0.8.0" +lib3mf = "^2.4.1" rich = "^13.7.1" pint = "^0.24.4" diff --git a/src/gflabel/cli.py b/src/gflabel/cli.py index 293bc2d..f395200 100644 --- a/src/gflabel/cli.py +++ b/src/gflabel/cli.py @@ -28,6 +28,7 @@ Location, Locations, Mode, + Mesher, Part, Plane, RectangleRounded, @@ -47,9 +48,11 @@ from .bases.none import NoneBase from .bases.plain import PlainBase from .bases.pred import PredBase, PredBoxBase -from .bases.tailor import TailorBoxBase + +# from .bases.tailor import TailorBoxBase from .label import render_collection_of_labels, clean_up_name from .options import LabelStyle, RenderOptions, SvgMono, SvgBase +from .three_mf import apply_3mf_face_colors from .util import IndentingRichHandler, unit_registry logger = logging.getLogger(__name__) @@ -155,7 +158,7 @@ def base_name_to_subclass(name: str) -> type[LabelBase]: "modern": ModernBase, "pred": PredBase, "predbox": PredBoxBase, - "tailorbox": TailorBoxBase, + # "tailorbox": TailorBoxBase, "plain": PlainBase, "none": NoneBase, None: NoneBase, @@ -188,9 +191,12 @@ def colored_parts(comp: Compound) -> list(Part): part_list.append(clone_part) return part_list + def run(argv: list[str] | None = None): # Handle the old way of specifying base - if any((x.startswith("--base") and x != "--base-color") for x in (argv or sys.argv)): + if any( + (x.startswith("--base") and x != "--base-color") for x in (argv or sys.argv) + ): sys.exit( "Error: --base is no longer the way to specify base geometry. Please pass in as a direct argument (gflabel )" ) @@ -440,7 +446,9 @@ def run(argv: list[str] | None = None): body: LabelBase | None = None body = base_type(args) if args.xscale != 1.0 or args.yscale != 1.0 or args.zscale != 1.0: - logger.info(f"Scaling overall label by ({args.xscale}, {args.yscale}, {args.zscale})") + logger.info( + f"Scaling overall label by ({args.xscale}, {args.yscale}, {args.zscale})" + ) if args.width: args.width *= args.xscale if args.margin: @@ -477,7 +485,7 @@ def run(argv: list[str] | None = None): if body.part: y_offset_each_label = body.part.bounding_box().size.Y + args.label_gap label_area = body.area - + else: # Only occurs if label type has no body e.g. "None" if args.height is None: @@ -488,14 +496,16 @@ def run(argv: list[str] | None = None): X=args.width.to("mm").magnitude, Y=args.height.to("mm").magnitude ) - labels_compound = render_collection_of_labels(args.labels, args.divisions, y_offset_each_label, options, label_area) + labels_compound = render_collection_of_labels( + args.labels, args.divisions, y_offset_each_label, options, label_area + ) y = 0 body_locations = [] for boddex in range(len(labels_compound.children)): body_locations.append((0, y)) y -= y_offset_each_label - + if not is_2d: # Create all of the bases if body.part: @@ -522,12 +532,14 @@ def run(argv: list[str] | None = None): assembly = Compound(children=[base_part]) else: if args.style == LabelStyle.EMBEDDED and args.embedded_lift != 0: - labels_compound.locate(Location(position=Vector(0, 0, args.embedded_lift))) + labels_compound.locate( + Location(position=Vector(0, 0, args.embedded_lift)) + ) assembly = Compound(children=[base_part, labels_compound]) base_part.label = clean_up_name("Base") base_part.color = Color(args.base_color) logger.info(f"Topology\n{assembly.show_topology(limit_class=Part)}") - + for output in args.output: if output.endswith(".stl"): logger.info(f"Writing STL {output}") @@ -541,18 +553,28 @@ def run(argv: list[str] | None = None): ) exporter = ExportSVG(scale=100 / max_dimension) - if args.svg_base is not SvgBase.NONE and not is_2d: # check is_2d since is_2d doesn't render the bases + if ( + args.svg_base is not SvgBase.NONE and not is_2d + ): # check is_2d since is_2d doesn't render the bases if args.svg_base == SvgBase.OUTLINE: - exporter.add_layer("Base", line_color=Color(args.base_color), line_weight=1) + exporter.add_layer( + "Base", line_color=Color(args.base_color), line_weight=1 + ) else: - exporter.add_layer("Base", fill_color=Color(args.base_color), line_weight=0) + exporter.add_layer( + "Base", fill_color=Color(args.base_color), line_weight=0 + ) part_in_plane = base_part.intersect(Plane.XY) exporter.add_shape(part_in_plane, layer="Base") elif args.box and is_2d: - exporter.add_layer("Box", line_color=Color(args.base_color), line_weight=1) + exporter.add_layer( + "Box", line_color=Color(args.base_color), line_weight=1 + ) exporter.add_shape(body_box_sketch, layer="Box") if args.svg_mono in [SvgMono.EXPORT, SvgMono.BOTH]: - exporter.add_layer("Labels", fill_color=Color(args.label_color), line_weight=0) + exporter.add_layer( + "Labels", fill_color=Color(args.label_color), line_weight=0 + ) compound_in_plane = labels_compound.intersect(Plane.XY) exporter.add_shape(compound_in_plane, layer="Labels") else: @@ -561,12 +583,24 @@ def run(argv: list[str] | None = None): color = part.color color_str = str(color) if not color_str in layer_dict: - exporter.add_layer(name=color_str, fill_color=color, line_weight=0) + exporter.add_layer( + name=color_str, fill_color=color, line_weight=0 + ) layer_dict[color_str] = True part_in_plane = part.intersect(Plane.XY) exporter.add_shape(part_in_plane, layer=color_str) logger.info(f"Writing SVG {output}") exporter.write(output) + elif output.endswith(".3mf"): + logger.info(f"Writing 3MF {output}") + exporter = Mesher() + parts = colored_parts(assembly) + for part in parts: + if part.color is not None and not isinstance(part.color, Color): + part.color = Color(part.color) + exporter.add_shape(parts) + exporter.write(output) + apply_3mf_face_colors(output, parts) else: logger.error(f"Error: Do not understand output format '{args.output}'") @@ -603,5 +637,6 @@ def run(argv: list[str] | None = None): show_cols.append(args.label_color) show(*show_parts, colors=show_cols) + if __name__ == "__main__": run() diff --git a/src/gflabel/three_mf.py b/src/gflabel/three_mf.py new file mode 100644 index 0000000..629db84 --- /dev/null +++ b/src/gflabel/three_mf.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from build123d import Color, Part + + +def _color_to_hex(color: Color | str | None) -> str: + if color is None: + return "#808080" + if isinstance(color, str): + color = Color(color) + + rgb = None + for attr in ("to_tuple", "to_rgb", "rgb", "rgba"): + if hasattr(color, attr): + value = getattr(color, attr) + rgb = value() if callable(value) else value + break + if rgb is None: + text = str(color) + if "r=" in text and "g=" in text and "b=" in text: + try: + cleaned = text + if cleaned.startswith("Color(") and cleaned.endswith(")"): + cleaned = cleaned[len("Color(") : -1] + parts = cleaned.replace(" ", "").split(",") + vals = {p.split("=")[0]: float(p.split("=")[1]) for p in parts if "=" in p} + rgb = (vals.get("r"), vals.get("g"), vals.get("b")) + except Exception: + rgb = (0.5, 0.5, 0.5) + else: + rgb = (0.5, 0.5, 0.5) + + r, g, b = (rgb[0], rgb[1], rgb[2]) + + def clamp_channel(val: float) -> int: + if val is None: + return 128 + return max(0, min(255, int(round(val * 255 if val <= 1 else val)))) + + return f"#{clamp_channel(r):02x}{clamp_channel(g):02x}{clamp_channel(b):02x}" + + +def apply_3mf_face_colors(path: str, parts: list[Part]) -> None: + import lib3mf # type: ignore + + wrapper = lib3mf.Wrapper() + model = wrapper.CreateModel() + reader = model.QueryReader("3mf") + reader.ReadFromFile(path) + + mesh_objects: list[object] = [] + mesh_iter = model.GetMeshObjects() + while mesh_iter.MoveNext(): + mesh_objects.append(mesh_iter.GetCurrent()) + + if not mesh_objects: + raise RuntimeError("No mesh objects found in 3MF") + + color_group = model.AddColorGroup() + resource_id = color_group.GetResourceID() + + def _add_color(hex_color: str) -> int: + r = int(hex_color[1:3], 16) + g = int(hex_color[3:5], 16) + b = int(hex_color[5:7], 16) + a = 255 + return color_group.AddColor(lib3mf.Color(r, g, b, a)) + + color_index: dict[str, int] = {} + + if len(mesh_objects) != len(parts): + raise RuntimeError( + f"3MF mesh object count ({len(mesh_objects)}) differs from parts ({len(parts)})" + ) + + for mesh, part in zip(mesh_objects, parts): + hex_color = _color_to_hex(part.color) + if hex_color not in color_index: + color_index[hex_color] = _add_color(hex_color) + color_idx = color_index[hex_color] + mesh.SetObjectLevelProperty(resource_id, color_idx) + props = lib3mf.TriangleProperties() + props.ResourceID = resource_id + props.PropertyIDs[0] = color_idx + props.PropertyIDs[1] = color_idx + props.PropertyIDs[2] = color_idx + + tri_count = mesh.GetTriangleCount() + for tri_index in range(tri_count): + mesh.SetTriangleProperties(tri_index, props) + + writer = model.QueryWriter("3mf") + writer.WriteToFile(path) From 212d0ddf23a7937a949bd04d0f5b5bf3bc26f0d1 Mon Sep 17 00:00:00 2001 From: Paul Heidenreich Date: Thu, 29 Jan 2026 20:20:15 +0100 Subject: [PATCH 41/50] Use part label to find colors when processing 3mf --- src/gflabel/cli.py | 4 +++- src/gflabel/three_mf.py | 19 +++++++++++-------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/gflabel/cli.py b/src/gflabel/cli.py index f395200..38c3792 100644 --- a/src/gflabel/cli.py +++ b/src/gflabel/cli.py @@ -595,10 +595,12 @@ def run(argv: list[str] | None = None): logger.info(f"Writing 3MF {output}") exporter = Mesher() parts = colored_parts(assembly) + for part in parts: if part.color is not None and not isinstance(part.color, Color): part.color = Color(part.color) - exporter.add_shape(parts) + exporter.add_shape(part, part_number=part.label) + exporter.write(output) apply_3mf_face_colors(output, parts) else: diff --git a/src/gflabel/three_mf.py b/src/gflabel/three_mf.py index 629db84..0c9ce95 100644 --- a/src/gflabel/three_mf.py +++ b/src/gflabel/three_mf.py @@ -1,6 +1,7 @@ from __future__ import annotations from build123d import Color, Part +import lib3mf def _color_to_hex(color: Color | str | None) -> str: @@ -23,7 +24,9 @@ def _color_to_hex(color: Color | str | None) -> str: if cleaned.startswith("Color(") and cleaned.endswith(")"): cleaned = cleaned[len("Color(") : -1] parts = cleaned.replace(" ", "").split(",") - vals = {p.split("=")[0]: float(p.split("=")[1]) for p in parts if "=" in p} + vals = { + p.split("=")[0]: float(p.split("=")[1]) for p in parts if "=" in p + } rgb = (vals.get("r"), vals.get("g"), vals.get("b")) except Exception: rgb = (0.5, 0.5, 0.5) @@ -41,8 +44,6 @@ def clamp_channel(val: float) -> int: def apply_3mf_face_colors(path: str, parts: list[Part]) -> None: - import lib3mf # type: ignore - wrapper = lib3mf.Wrapper() model = wrapper.CreateModel() reader = model.QueryReader("3mf") @@ -68,12 +69,14 @@ def _add_color(hex_color: str) -> int: color_index: dict[str, int] = {} - if len(mesh_objects) != len(parts): - raise RuntimeError( - f"3MF mesh object count ({len(mesh_objects)}) differs from parts ({len(parts)})" - ) + def _find_part_by_label(label: str) -> Part: + for part in parts: + if part.label == label: + return part + raise RuntimeError(f"No part found with label '{label}'") - for mesh, part in zip(mesh_objects, parts): + for mesh in mesh_objects: + part = _find_part_by_label(mesh.GetPartNumber()) hex_color = _color_to_hex(part.color) if hex_color not in color_index: color_index[hex_color] = _add_color(hex_color) From 7b29e3f8e021db285e5bd5e8f39e35b63ed390e8 Mon Sep 17 00:00:00 2001 From: WJCarpenter Date: Thu, 29 Jan 2026 13:37:02 -0800 Subject: [PATCH 42/50] prepare for rendering additional stuff The type hint for the render() function is changed from Sketch to Compound. (Compound is the superclass of Sketch.) A fragment can continue to return the simple sketch, or it can return a Compound with children. If there are children, each should be a 2D shape of some kind, and each is extruded to a Part separately. If the children have color or label attributes, they are respected. The implementation of --text-as-parts is moved into the TextFragment render() function and returns a Compound with children, each with its own label. --- src/gflabel/fragments.py | 60 +++++++++++++++----- src/gflabel/label.py | 120 ++++++++++++--------------------------- src/gflabel/options.py | 17 ++++++ 3 files changed, 98 insertions(+), 99 deletions(-) diff --git a/src/gflabel/fragments.py b/src/gflabel/fragments.py index 53cb2f4..689b9bd 100644 --- a/src/gflabel/fragments.py +++ b/src/gflabel/fragments.py @@ -22,7 +22,9 @@ CenterArc, Circle, Color, + Compound, EllipticalCenterArc, + Face, GridLocations, Line, Location, @@ -48,7 +50,7 @@ offset, ) -from .options import RenderOptions +from .options import RenderOptions, FragmentDataItem from .util import format_table logger = logging.getLogger(__name__) @@ -130,7 +132,7 @@ def _wrapped( # class FnWrapper(Fragment): # def render( # self, height: float, maxsize: float, options: Any - # ) -> Sketch: + # ) -> Compound: # return orig_fn(height, maxsize) def fragment(*args): frag = FunctionalFragment(fn, *args) @@ -187,7 +189,7 @@ def min_width(self, height: float) -> float: return 0 @abstractmethod - def render(self, height: float, maxsize: float, options: RenderOptions) -> Sketch: + def render(self, height: float, maxsize: float, options: RenderOptions) -> Compound: pass @@ -198,7 +200,7 @@ def __init__(self, fn: Callable[[float, float], Sketch], *args): self.args = args self.fn = fn - def render(self, height: float, maxsize: float, options: RenderOptions) -> Sketch: + def render(self, height: float, maxsize: float, options: RenderOptions) -> Compound: return self.fn(height, maxsize, *self.args) @@ -211,7 +213,7 @@ def __init__(self, distance: float, *args): super().__init__(*args) self.distance = distance - def render(self, height: float, maxsize: float, options: RenderOptions) -> Sketch: + def render(self, height: float, maxsize: float, options: RenderOptions) -> Compound: with BuildSketch() as sketch: Rectangle(self.distance, height) return sketch.sketch @@ -232,7 +234,7 @@ class ExpandingFragment(Fragment): examples = ["L{...}R"] - def render(self, height: float, maxsize: float, options: RenderOptions) -> Sketch: + def render(self, height: float, maxsize: float, options: RenderOptions) -> Compound: with BuildSketch() as sketch: Rectangle(maxsize, height) return sketch.sketch @@ -245,14 +247,42 @@ class TextFragment(Fragment): def __init__(self, text: str): self.text = text - def render(self, height: float, maxsize: float, options: RenderOptions) -> Sketch: + def render(self, height: float, maxsize: float, options: RenderOptions) -> Compound: if not height: raise ValueError("Trying to render zero-height text fragment") with BuildSketch() as sketch: with options.font.font_options() as f: print(f"Using {f}") Text(self.text, font_size=options.font.get_allowed_height(height), **f) - return sketch.sketch + frag_sketch = sketch.sketch + if not options.text_as_parts: + return frag_sketch + else: + faces = frag_sketch.get_type(Face) + # build123d text rendering seems to order the faces with the + # final character first, and then the other characters in order. + # I don't know if that always holds true, but rearranging things + # on the assumption that it is. Best effort! + if len(faces): + the_first_shall_be_last = faces.pop(0) + faces.append(the_first_shall_be_last) + face_sketches = [] + for face in faces: + # Hoping that the character in the text fragment is in the right order + # Even so, some characters render as multiple faces (e.g., "i"). + # Only use this scheme if the face count is the same as the text length. + # It could still be wrong if the text contains spaces. For example, + # "i x" would contain 3 faces and mislead us. Another best effort! + if len(faces) == len(self.text) and not " " in self.text: + face_tick = self.text[len(face_sketches)] + else: + face_tick = str(len(face_sketches)) + fragment_name = self.fragment_data[FragmentDataItem.FRAGMENT_NAME] + face.label = fragment_name if fragment_name == face_tick else fragment_name + "_" + face_tick + # if we did anything with colors, we'd set the face.color here + # and possibly suffix the frac.label with the color name + face_sketches.append(face) + return Compound(children=face_sketches) @functools.lru_cache @@ -292,7 +322,7 @@ def __init__(self, whitespace: str): ) self.whitespace = whitespace - def render(self, height: float, maxsize: float, options: RenderOptions) -> Sketch: + def render(self, height: float, maxsize: float, options: RenderOptions) -> Compound: with BuildSketch() as sketch: Rectangle(_whitespace_width(self.whitespace, height, options), height) return sketch.sketch @@ -539,7 +569,7 @@ def __init__(self, length: str, *features: str): def min_width(self, height: float) -> float: return height - def render(self, height: float, maxsize: float, options: RenderOptions) -> Sketch: + def render(self, height: float, maxsize: float, options: RenderOptions) -> Compound: length = self.length # line width: How thick the head and body are lw = height / 2.25 @@ -695,7 +725,7 @@ class CullenectBoltFragment(BoltBase): overheight = 1.6 - def render(self, height: float, maxsize: float, options: RenderOptions) -> Sketch: + def render(self, height: float, maxsize: float, options: RenderOptions) -> Compound: height *= self.overheight # 12 mm high for 15 mm wide. Scale to this. width = 1.456 * height # 15 / 12 * height @@ -1192,7 +1222,7 @@ def __init__(self, *selectors: str): ) self.shapes = import_svg(svg_data, flip_y=False) - def render(self, height: float, maxsize: float, options: RenderOptions) -> Sketch: + def render(self, height: float, maxsize: float, options: RenderOptions) -> Compound: with BuildSketch() as _sketch: add(self.shapes) bb = _sketch.sketch.bounding_box() @@ -1219,7 +1249,7 @@ def __init__( self.left = float(left or 1) self.right = float(right or 1) - def render(self, height: float, maxsize: float, options: RenderOptions) -> Sketch: + def render(self, height: float, maxsize: float, options: RenderOptions) -> Compound: # This should never happen; for now. We might decide to add # options for rendered dividers later. raise NotImplementedError("Splitters should never be rendered") @@ -1238,7 +1268,7 @@ class DimensionFragment(Fragment): def min_width(self, height: float) -> float: return 1 - def render(self, height: float, maxsize: float, options: RenderOptions) -> Sketch: + def render(self, height: float, maxsize: float, options: RenderOptions) -> Compound: lw = 0.4 with BuildSketch() as sketch: with Locations([(-maxsize / 2, 0)]): @@ -1273,7 +1303,7 @@ class ModifierFragment(Fragment): # a tiny, tiny circle for a microscopic bounding box, which in any case is invisible # this eliminates some tedious special cases in single line processing - def render(self, height: float, maxsize: float, options: RenderOptions) -> Sketch: + def render(self, height: float, maxsize: float, options: RenderOptions) -> Compound: raise NotImplementedError(f"Modifier fragments should never be rendered: {self.__class__.__name__}") @fragment("color") diff --git a/src/gflabel/label.py b/src/gflabel/label.py index b2e8535..ff54aeb 100644 --- a/src/gflabel/label.py +++ b/src/gflabel/label.py @@ -28,7 +28,7 @@ from enum import Enum, auto from . import fragments -from .options import RenderOptions, LabelStyle +from .options import RenderOptions, LabelStyle, FragmentDataItem from .util import IndentingRichHandler, batched logger = logging.getLogger(__name__) @@ -71,23 +71,6 @@ def clean_up_name(dirty_name: str): clean_name = "L" + clean_name return clean_name -class FragmentDataItem(Enum): - FRAGMENT_NAME = auto() - COLOR_NAME = auto() - XSCALE = auto() - YSCALE = auto() - ZSCALE = auto() - OFFSET = auto() - - @classmethod - def _missing_(cls, value): - for kind in cls: - if kind.name.lower() == value.lower(): - return kind - - def __str__(self): - return self.name.lower() - def _spec_to_fragments(spec: str) -> tuple[list[fragments.Fragment], list[str]]: """Convert a single line spec string to a list of renderable fragments.""" fragment_list = [] @@ -322,7 +305,7 @@ def _render_single_line( # For modifier fragments, the change needs to happen # in this loop so that fragment order is preserved. # If you need to reference something later, when the - # Sketch is extruded into a Part (which is pretty + # Sketch is rendered or extruded into a Part (which is pretty # likely!), a good technique is to store info as # entries in the fragment_data dictionary that gets # attached to the Fragment object @@ -420,77 +403,46 @@ def _render_single_line( # Assemble these onto the target x = -total_width / 2 for fragment, frag_sketch in [(x, rendered[x]) for x in frags]: - fragment_name = fragment.fragment_data[FragmentDataItem.FRAGMENT_NAME] - fragment_color_name = fragment.fragment_data[FragmentDataItem.COLOR_NAME] - xscale = fragment.fragment_data[FragmentDataItem.XSCALE] - yscale = fragment.fragment_data[FragmentDataItem.YSCALE] - zscale = fragment.fragment_data[FragmentDataItem.ZSCALE] - fragment_offset = fragment.fragment_data[FragmentDataItem.OFFSET] - fragment_width = frag_sketch.bounding_box().size.X + # The rendered value is always a Compound, but it can be a simple Sketch + # or a Compound with children. + frag_sketches = frag_sketch.children if len(frag_sketch.children)>0 else [frag_sketch] + fragment_width = Compound(children=frag_sketches).bounding_box().size.X fxy = Location(((x + fragment_width / 2, 0))) - with Locations(fxy): - if fragment.visible: - if self.opts.text_as_parts and isinstance(fragment, fragments.TextFragment): - - faces = frag_sketch.get_type(Face) - # build123d text rendering seems to order the faces with the - # final character first, and then the other characters in order. - # I don't know if that always holds true, but rearranging things - # on the assumption that it is. Best effort! - if len(faces): - the_first_shall_be_last = faces.pop(0) - faces.append(the_first_shall_be_last) - face_parts = [] - for face in faces: - with BuildPart(mode=Mode.PRIVATE) as face_bpart: - extruded = extrude(face, self.opts.depth) - add(extruded) - face_part = face_bpart.part - # Hoping that the character in the text fragment is in the right order - # Even so, some characters render as multiple faces (e.g., "i"). - # Only use this scheme if the face count is the same as the text length. - # It could still be wrong if the text contains spaces. For example, - # "i x" would contain 3 faces and mislead us. Another best effort! - if len(faces) == len(fragment.text) and not " " in fragment.text: - face_tick = fragment.text[len(face_parts)] - else: - face_tick = str(len(face_parts)) - face_part_label = clean_up_name(get_global_label(fragment_name + "_" + face_tick)) - # We have to extrude each face separately, else the colors and labels get - # lost if we wait and just extrude the Compound - if xscale != 1 or yscale != 1 or zscale != 1: - logger.info(f"Scaling fragment '{face_part_label}' by ({xscale}, {yscale}, {zscale})") - face_part = scale(face_part, (xscale, yscale, zscale)) - face_part.color = fragment_color_name - face_part.label = face_part_label - face_parts.append(face_part) - - child_part = Compound(children=face_parts) - else: - - with BuildPart(mode=Mode.PRIVATE) as child_bpart: - extruded = extrude(frag_sketch, self.opts.depth) - # Rescaling can be performance expensive, so only do it if needed - if xscale != 1 or yscale != 1 or zscale != 1: - logger.info(f"Scaling fragment '{fragment_name}' by ({xscale}, {yscale}, {zscale})") - extruded = scale(extruded, (xscale, yscale, zscale)) - add(extruded) - child_part = child_bpart.part - - child_part.color = fragment_color_name - child_part.locate(fxy) - child_part.move(fragment_offset) - child_part_label = fragment_name if fragment_name else "item" # else shouldn't happen - if fragment_color_name != self.opts.default_color: - child_part_label += "__" + fragment_color_name - child_part.label = clean_up_name(get_global_label(child_part_label)) - child_parts.append(child_part) + for one_frag_sketch in frag_sketches: + fragment_sketch_to_part(self.opts, child_parts, fxy, fragment, one_frag_sketch) x += fragment_width sl_compound = Compound(children=child_parts) sl_compound.label = clean_up_name(get_global_label("Line")) return sl_compound - + +def fragment_sketch_to_part(opts: RenderOptions, child_parts: list[Part], fxy: Location, fragment, frag_sketch: Shape) -> None: + fragment_name = fragment.fragment_data[FragmentDataItem.FRAGMENT_NAME] + fragment_color_name = fragment.fragment_data[FragmentDataItem.COLOR_NAME] + with Locations(fxy): + if fragment.visible: + with BuildPart(mode=Mode.PRIVATE) as child_bpart: + extruded = extrude(frag_sketch, opts.depth) + # Rescaling can be performance expensive, so only do it if needed + xscale = fragment.fragment_data[FragmentDataItem.XSCALE] + yscale = fragment.fragment_data[FragmentDataItem.YSCALE] + zscale = fragment.fragment_data[FragmentDataItem.ZSCALE] + if xscale != 1 or yscale != 1 or zscale != 1: + logger.info(f"Scaling fragment '{fragment_name}' by ({xscale}, {yscale}, {zscale})") + extruded = scale(extruded, (xscale, yscale, zscale)) + add(extruded) + child_part = child_bpart.part + + child_part.color = frag_sketch.color if frag_sketch.color else fragment_color_name + child_part.locate(fxy) + fragment_offset = fragment.fragment_data[FragmentDataItem.OFFSET] + child_part.move(fragment_offset) + child_part_label = frag_sketch.label if frag_sketch.label != "" else fragment_name if fragment_name else "item" # else shouldn't happen + if fragment_color_name != opts.default_color: + child_part_label += "__" + fragment_color_name + child_part.label = clean_up_name(get_global_label(child_part_label)) + child_parts.append(child_part) + def render_divided_label( labels: str, area: Vector, divisions: int, options: RenderOptions) -> Compound: """ diff --git a/src/gflabel/options.py b/src/gflabel/options.py index 2b954fe..2137b9d 100644 --- a/src/gflabel/options.py +++ b/src/gflabel/options.py @@ -156,3 +156,20 @@ def from_args(cls, args: argparse.Namespace) -> RenderOptions: default_color=args.label_color, text_as_parts=args.text_as_parts, ) + +class FragmentDataItem(Enum): + FRAGMENT_NAME = auto() + COLOR_NAME = auto() + XSCALE = auto() + YSCALE = auto() + ZSCALE = auto() + OFFSET = auto() + + @classmethod + def _missing_(cls, value): + for kind in cls: + if kind.name.lower() == value.lower(): + return kind + + def __str__(self): + return self.name.lower() From ef5d2dd58d91188ef8331e3523d817ceec580038 Mon Sep 17 00:00:00 2001 From: WJCarpenter Date: Fri, 30 Jan 2026 19:41:04 -0800 Subject: [PATCH 43/50] introduce {svg()} fragment. See COLOR_NOTES.md for some examples. --- COLOR_NOTES.md | 93 +++++++++++++++++++++++++++++++++++++ README.md | 1 + src/gflabel/fragments.py | 99 +++++++++++++++++++++++++++++++++++++++- src/gflabel/label.py | 1 - src/gflabel/options.py | 2 + 5 files changed, 194 insertions(+), 2 deletions(-) diff --git a/COLOR_NOTES.md b/COLOR_NOTES.md index 9279d04..13d0ea4 100644 --- a/COLOR_NOTES.md +++ b/COLOR_NOTES.md @@ -6,6 +6,8 @@ There are global default colors for the base and label, set via `--base-color` and `--label-color`, respectively. They default to `orange` and `blue`. Colors can be any of the names standardized in CSS3. +You can also specify a color in the 6-digit hex notation, +for example `#008080`. In addition, there is a label fragment type for changing colors within a label. Each line of a label starts with the default label color. @@ -107,3 +109,94 @@ Have a close look at the spacing between the tips of these letters: gflabel --vscode pred 'WWW' 'W{color(blue)}W{color(blue)}W' ``` image + +## SVG TREATMENT + +SVG files can be produced by `gflabel` (via the `-o` or `--output` options) +and can also be imported (via the `{svg()}` fragment). +Treatment of colors is controlled by the `--svf-mono` option, whose argument can be +`none` (default), `import`, `export`, or `both`. +With the default, colors are preserved both for imported SVG files +and for exported SVG files. + +Here is an multi-colored example (from [https://www.w3schools.com/graphics/tryit.asp?filename=trysvg_fill0](https://www.w3schools.com/graphics/tryit.asp?filename=trysvg_fill0)): +``` +gflabel --vscode -o label.step -o fillcolors.svg plain --width 25 --height 15 "{svg(file=wjc/fillcolors.svg)}" +``` + +For exported SVG files written in monocolor, the color is the default labels color, +which can be changed via the `--label-color` option. + +For imported SVG files read in monocolor, +any color values within the SVG file are replaced. +If a `color` value was given in the `{svg()}` fragment, +that color is used. +Else, the default labels color is used. +The same color choice is used for any element of the SVG file +that does not have its own color designation when read. + +Here is an SVG file (from [https://svgsilh.com/image/1801287.html](https://svgsilh.com/image/1801287.html)) +colored various ways. + +The color in the SVG file is black. +``` +gflabel --vscode -o label.step -o black.svg plain --width 25 --height 25 "{svg(file=wjc/kitten_bw.svg)}" +``` + +Here it is colored with the default label color (`blue`). +``` +gflabel --vscode -o label.step -o blue.svg plain --width 25 --height 25 "{svg(file=wjc/kitten_bw.svg)}" --svg-mono import +``` + +Here it is colored `red` due to an earlier `{color()}` fragment. + +``` +gflabel --vscode -o label.step -o red.svg plain --width 25 --height 25 "{color(red)}{svg(file=wjc/kitten_bw.svg)}" --svg-mono import +``` + +And here it is colored `green` due to an explicit `color` in the `{svg()}` fragment. +``` +gflabel --vscode -o label.step -o green.svg plain --width 25 --height 25 "{color(red)}{svg(file=wjc/kitten_bw.svg,color=green)}" --svg-mono import +``` + +The `{svg()}` fragment the `build123d` function `import_svg()` which in turn +uses the `ocpsvg` function `import_svg_document()`. +That function includes the important caveat: +_This importer does not cover the whole SVG specification, its most notable known limitations are...._ + +From our point of view, the most important limitations are +1. it does not import text items at all, and +1. it only deals with faces and wires. +1. it chokes on numbers like `100%` +1. various SVG translations and transformations seem to be ignored + +This basic example from +[https://developer.mozilla.org/en-US/docs/Web/SVG/Tutorials/SVG_from_scratch/Getting_started](https://developer.mozilla.org/en-US/docs/Web/SVG/Tutorials/SVG_from_scratch/Getting_started) +should have text "SVG" in the center of the green circle, +but the text does not get imported. +``` +gflabel --vscode -o label.step -o mdn_basic.svg plain --width 25 --height 15 "{svg(file=wjc/mdn_basic.svg)}" +``` +And sometimes things just go awry. +An imported element that becomes a `build123s` Wire can't be extruded into a Part because Wires are 1-dimensional. +The sensible thing to do in such cases is make the Wire 2-dimensional by calling `Wire.trace()`. +Unfortunately, that often throws an error. +The `{svg()}` fragment code watches for those errors and implements a tedious fallback strategy. +A message given in the fallback cases so you can know what happened. + +Even after all that, there still seem to be some glitches with complex SVGs. +Here's the famouse Ghostscript Tiger downloaded from +[https://commons.wikimedia.org/wiki/File:Ghostscript_Tiger.svg](https://commons.wikimedia.org/wiki/File:Ghostscript_Tiger.svg). +Something more than half of it works correctly, but it's not close to correct. +There are a lot of SVGs that just don't import properly this way. +I've even seen at least one that crashes the program. +Most simple graphics (without text) work well. +``` +gflabel --vscode -o tiger.step -o tiger.svg --svg-base solid plain --width 50 --height 25 'Beware of\nTiger!{|}{svg(label=tiger, file=wjc/tiger.svg)}' +``` + +Sure, that's still scary, but just compare it to this with an image obtained from +[https://svgsilh.com/image/161467.html](https://svgsilh.com/image/161467.html) +``` +gflabel --vscode -o rabbit.step -o rabbit.svg --svg-base solid plain --width 50 --height 25 'Beware of\nRabbit!{|}{svg(label=rabbit, file=wjc/rabbit.svg, color=chocolate)}' --svg-mono import +``` diff --git a/README.md b/README.md index 8373e9c..527d13f 100644 --- a/README.md +++ b/README.md @@ -286,6 +286,7 @@ A list of all the fragments currently recognised: | measure | Fills as much area as possible with a dimension line, and shows the length. Useful for debugging. | | offset | Apply a placement offset on one or more axes for subsequent fragments on a line.| | scale | Apply a placement offset on one or more axes for subsequent fragments on a line.| +| svg | Imports an SVG from a file and renders it as a collection of (colored) Sketches. See COLOR_NOTES.md| | sym, symbol | Render an electronic symbol. | | threaded_insert | Representation of a threaded insert. | | tnut | T-slot nut. | diff --git a/src/gflabel/fragments.py b/src/gflabel/fragments.py index 689b9bd..1423a78 100644 --- a/src/gflabel/fragments.py +++ b/src/gflabel/fragments.py @@ -1,5 +1,6 @@ from __future__ import annotations +import copy import functools import importlib.resources import io @@ -23,6 +24,7 @@ Circle, Color, Compound, + Edge, EllipticalCenterArc, Face, GridLocations, @@ -48,9 +50,11 @@ make_face, mirror, offset, + scale, + trace, ) -from .options import RenderOptions, FragmentDataItem +from .options import RenderOptions, FragmentDataItem, SvgMono from .util import format_table logger = logging.getLogger(__name__) @@ -284,6 +288,99 @@ def render(self, height: float, maxsize: float, options: RenderOptions) -> Compo face_sketches.append(face) return Compound(children=face_sketches) +@fragment("svg") +class SvgFragment(Fragment): + """Imports an SVG from a file and renders it as a collection of (colored) Sketches. See COLOR_NOTES.md""" + + examples = ["text{svg(file=/some/mysvg.svg, flip_y=true, label=mysvg, color=green}"] + + def __init__(self, *args: list[str]): + args_dict = _args_to_dict(["file","flip_y","label","color"], *args) + # file is required + if not "file" in args_dict: + raise InvalidFragmentSpecification(f"SvgFragment file argument is required but missing. {args_dict}'.") + self.file = args_dict["file"] + + if not "flip_y" in args_dict: + self.flip_y = True + else: + fy = args_dict["flip_y"].casefold() + if fy == "false": + self.flip_y = False + elif fy == "true": + self.flip_y = True + else: + raise InvalidFragmentSpecification(f"SvgFragment flip_y argument, if given, must be true or false. {args_dict}") + + self.label = args_dict.get("label", "") + self.color = args_dict.get("color", None) + + def render(self, height: float, maxsize: float, options: RenderOptions) -> Compound: + if not height: + raise ValueError("Trying to render zero-height fragment") + shapes = import_svg(self.file, flip_y=self.flip_y) + if options.svg_mono in [SvgMono.BOTH, SvgMono.IMPORT]: + logger.info(f"Discarding SVG colors due to --svg-mono {options.svg_mono}") + for sdex, shape in enumerate(shapes): + if options.svg_mono in [SvgMono.BOTH, SvgMono.IMPORT]: + shape.color = self.color if self.color else self.fragment_data[FragmentDataItem.COLOR_NAME] + label_from_file = shape.label if shape.label else str(sdex) + label = self.label + "_" + label_from_file if self.label else label_from_file + shape.label = label + shape.label_from_file = label_from_file + if shape.location.position.X != 0 or shape.location.position.Y != 0: + print(f" SHAPE non zero {shape} {shape.location.position}") + svg_compound = Compound(children=shapes) + bb = svg_compound.bounding_box() + yscale = height / bb.size.Y + xscale = maxsize / bb.size.X + best_scale = min(yscale, xscale) + scaled_shapes = [] + for sdex, shape in enumerate(shapes): + label = shape.label + label_from_file = shape.label_from_file + color = shape.color + if not isinstance(shape, Face): + # I tried widening the Wires with trace(), but it produced an error: + # OCP.OCP.Standard.Standard_ConstructionError: gp_Vec::Normalize() - vector has zero norm + # I don't know why that happens since it ultimately comes from import_svg, + # and the topology looks well-formed to my visual spot-checking. + # + # I found a worksaround, after a lot of trial and error. The scheme + # is to iterate the Edges that make up the Wire, and for each such + # Edge, get its Vertices, make a new Edge from them, and trace that. + # There must be something internal that isn't liked, but I don't know + # what it is. Most edges pass without problems. + shape = copy.deepcopy(shape) + try: + shape = trace(shape, 0.1) + except: + logger.info(f"SvgFragment id: '{label_from_file}' trace() of {shape.__class__.__name__} failed, iterating Edges.") + traced_edges = [] + for edex, edge in enumerate(shape.edges()): + try: + traced_edge = trace(edge, 0.1) + traced_edges.append(traced_edge) + except: + logger.info(f"SvgFragment id: '{label_from_file}' trace() of Edge {edex} failed, using Vertices.") + vertices = edge.vertices() + if len(vertices) != 2: + logger.info(f"SvgFragment id: '{label_from_file}' Edge {edex}, expected 2 vertices, saw {len(vertices)}") + new_edge = Edge.make_line(vertices[0], vertices[1]) + traced_edge = trace(new_edge, 0.1) + traced_edges.append(traced_edge) + shape = Compound(children=traced_edges) + if shape: + # Resize this to match the requested height, and to be centered. + # We have to scale the Shapes individually in order to keep track + # of the labels and colors. + translated_shape = shape.translate(-bb.center()) + scaled_shape = scale(translated_shape, (best_scale, best_scale, 0)) + scaled_shape.label = label + scaled_shape.color = color + scaled_shapes.append(scaled_shape) + svg_compound = Compound(children=scaled_shapes) + return svg_compound @functools.lru_cache def _whitespace_width(spacechar: str, height: float, options: RenderOptions) -> float: diff --git a/src/gflabel/label.py b/src/gflabel/label.py index ff54aeb..f5eed9b 100644 --- a/src/gflabel/label.py +++ b/src/gflabel/label.py @@ -432,7 +432,6 @@ def fragment_sketch_to_part(opts: RenderOptions, child_parts: list[Part], fxy: L extruded = scale(extruded, (xscale, yscale, zscale)) add(extruded) child_part = child_bpart.part - child_part.color = frag_sketch.color if frag_sketch.color else fragment_color_name child_part.locate(fxy) fragment_offset = fragment.fragment_data[FragmentDataItem.OFFSET] diff --git a/src/gflabel/options.py b/src/gflabel/options.py index 2137b9d..562bb97 100644 --- a/src/gflabel/options.py +++ b/src/gflabel/options.py @@ -125,6 +125,7 @@ class RenderOptions(NamedTuple): depth: float = 0.4 default_color: str = "black" text_as_parts: bool = False + svg_mono: SvgMono = SvgMono.NONE @classmethod def from_args(cls, args: argparse.Namespace) -> RenderOptions: @@ -155,6 +156,7 @@ def from_args(cls, args: argparse.Namespace) -> RenderOptions: depth=args.depth, default_color=args.label_color, text_as_parts=args.text_as_parts, + svg_mono = args.svg_mono, ) class FragmentDataItem(Enum): From bb52aeb405040584031ff0392bad1305c89efb97 Mon Sep 17 00:00:00 2001 From: WJCarpenter Date: Fri, 30 Jan 2026 19:57:25 -0800 Subject: [PATCH 44/50] add images to COLOR_NOTES.md --- COLOR_NOTES.md | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/COLOR_NOTES.md b/COLOR_NOTES.md index 13d0ea4..8df46bf 100644 --- a/COLOR_NOTES.md +++ b/COLOR_NOTES.md @@ -80,7 +80,7 @@ gflabel --vscode pred 'Danger! {head(triangle)}' '{color(red)}Danger! {color(bla ``` image -And another: +And another:git push --set-upstream origin SvgFragment ``` gflabel --vscode pred "{head(hex)} {bolt(50)}\nM5x50" "{color(tan)}{head(hex)} {color(red)}{bolt(50)}\n{color(blue)}M5x50" ``` @@ -123,6 +123,7 @@ Here is an multi-colored example (from [https://www.w3schools.com/graphics/tryit ``` gflabel --vscode -o label.step -o fillcolors.svg plain --width 25 --height 15 "{svg(file=wjc/fillcolors.svg)}" ``` +fillcolors For exported SVG files written in monocolor, the color is the default labels color, which can be changed via the `--label-color` option. @@ -142,24 +143,28 @@ The color in the SVG file is black. ``` gflabel --vscode -o label.step -o black.svg plain --width 25 --height 25 "{svg(file=wjc/kitten_bw.svg)}" ``` +black Here it is colored with the default label color (`blue`). ``` gflabel --vscode -o label.step -o blue.svg plain --width 25 --height 25 "{svg(file=wjc/kitten_bw.svg)}" --svg-mono import ``` +blue Here it is colored `red` due to an earlier `{color()}` fragment. ``` gflabel --vscode -o label.step -o red.svg plain --width 25 --height 25 "{color(red)}{svg(file=wjc/kitten_bw.svg)}" --svg-mono import ``` +red And here it is colored `green` due to an explicit `color` in the `{svg()}` fragment. ``` gflabel --vscode -o label.step -o green.svg plain --width 25 --height 25 "{color(red)}{svg(file=wjc/kitten_bw.svg,color=green)}" --svg-mono import ``` +green -The `{svg()}` fragment the `build123d` function `import_svg()` which in turn +The `{svg()}` fragment uses the `build123d` function `import_svg()` which in turn uses the `ocpsvg` function `import_svg_document()`. That function includes the important caveat: _This importer does not cover the whole SVG specification, its most notable known limitations are...._ @@ -177,15 +182,17 @@ but the text does not get imported. ``` gflabel --vscode -o label.step -o mdn_basic.svg plain --width 25 --height 15 "{svg(file=wjc/mdn_basic.svg)}" ``` +mdn_basic + And sometimes things just go awry. -An imported element that becomes a `build123s` Wire can't be extruded into a Part because Wires are 1-dimensional. +An imported element that becomes a `build123d` Wire can't be extruded into a Part because Wires are 1-dimensional. The sensible thing to do in such cases is make the Wire 2-dimensional by calling `Wire.trace()`. Unfortunately, that often throws an error. The `{svg()}` fragment code watches for those errors and implements a tedious fallback strategy. -A message given in the fallback cases so you can know what happened. +A message is given in the fallback cases so you can know what happened. Even after all that, there still seem to be some glitches with complex SVGs. -Here's the famouse Ghostscript Tiger downloaded from +Here's the famous Ghostscript Tiger, downloaded from [https://commons.wikimedia.org/wiki/File:Ghostscript_Tiger.svg](https://commons.wikimedia.org/wiki/File:Ghostscript_Tiger.svg). Something more than half of it works correctly, but it's not close to correct. There are a lot of SVGs that just don't import properly this way. @@ -194,9 +201,11 @@ Most simple graphics (without text) work well. ``` gflabel --vscode -o tiger.step -o tiger.svg --svg-base solid plain --width 50 --height 25 'Beware of\nTiger!{|}{svg(label=tiger, file=wjc/tiger.svg)}' ``` +tiger -Sure, that's still scary, but just compare it to this with an image obtained from +Sure, that's still scary, but just compare it to this with this label using an image obtained from [https://svgsilh.com/image/161467.html](https://svgsilh.com/image/161467.html) ``` gflabel --vscode -o rabbit.step -o rabbit.svg --svg-base solid plain --width 50 --height 25 'Beware of\nRabbit!{|}{svg(label=rabbit, file=wjc/rabbit.svg, color=chocolate)}' --svg-mono import ``` +rabbit From 5a1e46205465d086bb393684c6d4e240513a6c62 Mon Sep 17 00:00:00 2001 From: WJCarpenter Date: Fri, 30 Jan 2026 20:28:36 -0800 Subject: [PATCH 45/50] change to scaled rabbit SVG --- COLOR_NOTES.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/COLOR_NOTES.md b/COLOR_NOTES.md index 8df46bf..d184dc6 100644 --- a/COLOR_NOTES.md +++ b/COLOR_NOTES.md @@ -197,6 +197,7 @@ Here's the famous Ghostscript Tiger, downloaded from Something more than half of it works correctly, but it's not close to correct. There are a lot of SVGs that just don't import properly this way. I've even seen at least one that crashes the program. +Complex SVGs can also take a very long time to import and process. Most simple graphics (without text) work well. ``` gflabel --vscode -o tiger.step -o tiger.svg --svg-base solid plain --width 50 --height 25 'Beware of\nTiger!{|}{svg(label=tiger, file=wjc/tiger.svg)}' @@ -206,6 +207,7 @@ gflabel --vscode -o tiger.step -o tiger.svg --svg-base solid plain --width 50 -- Sure, that's still scary, but just compare it to this with this label using an image obtained from [https://svgsilh.com/image/161467.html](https://svgsilh.com/image/161467.html) ``` -gflabel --vscode -o rabbit.step -o rabbit.svg --svg-base solid plain --width 50 --height 25 'Beware of\nRabbit!{|}{svg(label=rabbit, file=wjc/rabbit.svg, color=chocolate)}' --svg-mono import +gflabel --vscode -o rabbit.step -o rabbit.svg --svg-base solid plain --width 50 --height 25 'Beware of\nRabbit!{|}{scale(x=0.6,y=0.6)}{svg(label=rabbit, file=wjc/rabbit.svg, color=chocolate)}' ``` -rabbit +rabbit + From 38cef8026367eb45b6ef79292473d6835ce2192e Mon Sep 17 00:00:00 2001 From: WJCarpenter Date: Fri, 30 Jan 2026 20:30:37 -0800 Subject: [PATCH 46/50] console out put tweakage --- src/gflabel/label.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/gflabel/label.py b/src/gflabel/label.py index f5eed9b..30e914c 100644 --- a/src/gflabel/label.py +++ b/src/gflabel/label.py @@ -418,6 +418,7 @@ def _render_single_line( def fragment_sketch_to_part(opts: RenderOptions, child_parts: list[Part], fxy: Location, fragment, frag_sketch: Shape) -> None: fragment_name = fragment.fragment_data[FragmentDataItem.FRAGMENT_NAME] + frag_sketch_label = frag_sketch.label fragment_color_name = fragment.fragment_data[FragmentDataItem.COLOR_NAME] with Locations(fxy): if fragment.visible: @@ -428,7 +429,7 @@ def fragment_sketch_to_part(opts: RenderOptions, child_parts: list[Part], fxy: L yscale = fragment.fragment_data[FragmentDataItem.YSCALE] zscale = fragment.fragment_data[FragmentDataItem.ZSCALE] if xscale != 1 or yscale != 1 or zscale != 1: - logger.info(f"Scaling fragment '{fragment_name}' by ({xscale}, {yscale}, {zscale})") + logger.info(f"Scaling {fragment_name} fragment {frag_sketch_label} by ({xscale}, {yscale}, {zscale})") extruded = scale(extruded, (xscale, yscale, zscale)) add(extruded) child_part = child_bpart.part @@ -436,7 +437,7 @@ def fragment_sketch_to_part(opts: RenderOptions, child_parts: list[Part], fxy: L child_part.locate(fxy) fragment_offset = fragment.fragment_data[FragmentDataItem.OFFSET] child_part.move(fragment_offset) - child_part_label = frag_sketch.label if frag_sketch.label != "" else fragment_name if fragment_name else "item" # else shouldn't happen + child_part_label = frag_sketch_label if frag_sketch_label else fragment_name if fragment_name else "item" # else shouldn't happen if fragment_color_name != opts.default_color: child_part_label += "__" + fragment_color_name child_part.label = clean_up_name(get_global_label(child_part_label)) From 295af53398eb07e7b5c5e86a18773f68bf17a166 Mon Sep 17 00:00:00 2001 From: WJCarpenter Date: Sat, 31 Jan 2026 10:45:59 -0800 Subject: [PATCH 47/50] rename and wordsmith color notes --- COLOR_NOTES.md => COLOR_AND_SVG_NOTES.md | 33 +++++++++++++++--------- 1 file changed, 21 insertions(+), 12 deletions(-) rename COLOR_NOTES.md => COLOR_AND_SVG_NOTES.md (90%) diff --git a/COLOR_NOTES.md b/COLOR_AND_SVG_NOTES.md similarity index 90% rename from COLOR_NOTES.md rename to COLOR_AND_SVG_NOTES.md index d184dc6..93d607d 100644 --- a/COLOR_NOTES.md +++ b/COLOR_AND_SVG_NOTES.md @@ -1,6 +1,6 @@ -# Color Notes +# Color and SVG Notes -## Basics +## Color Basics There are global default colors for the base and label, set via `--base-color` and `--label-color`, respectively. @@ -17,19 +17,19 @@ until another color fragment is seen or the end of the line is reached. There are some examples below. They are all rendered in VScode OCP CAD Viewer. -For each example, a label with just the default colors +For many examples, a label with just the default colors is shown along with the same label using colors. -The Viewer assemlby tree is expanded to show the node labels in the CAD model. +The Viewer assembly tree is expanded to show the node labels in the CAD model. ## Slicers -`gflabel` can produce STL and STEP output files. +`gflabel` can produce STL, STEP, and 3MF output files. STL format is not color-aware. -STEP format can handle colors, -and the colors described here are part of the STEP file export from `gflabel`. -However, treatment of color information when a STEP file is imported into a slicer varies a bit. -In general, most slicers don't bother with STEP file colors on import. -(Most CAD tools do, which is not surprising since STEP is a CAD file format.) +STEP and 3MF formats can handle colors, +and the colors described here are part of the STEP/3MF file export from `gflabel`. +However, treatment of color information when a STEP/3MF file is imported into a slicer varies a bit. +In general, most slicers don't bother with STEP/3MF file colors on import. +(Some CAD tools do, which is not surprising since STEP and 3MF are a CAD file formats.) Most slicer color testing was done with Bambu Studio. It does not notice colors in STEP files. @@ -38,6 +38,8 @@ though it deals with them slightly differently. The file converter at [convert3d.org](https://convert3d.org) can convert a STEP file into an OBJ or 3MF file that has colors expressed in a way that Bambu Studio understands. +As of Bambu Studio 2.5, it understands standard color indications in 3MF files, +so 3MF output from `gflabel` can be used directly. If you open one of those 3MF files in Bambu Studio, you will immediately see it rendered in the expected colors. @@ -52,7 +54,7 @@ the color mapping choices it has made. But, again, you must map the colors to specific filaments when you try to send the sliced model to the 3D printer. -## Examples +## Color Examples Here is a very simple example showing a lot of colors: ``` @@ -110,7 +112,7 @@ gflabel --vscode pred 'WWW' 'W{color(blue)}W{color(blue)}W' ``` image -## SVG TREATMENT +## SVG Treatment SVG files can be produced by `gflabel` (via the `-o` or `--output` options) and can also be imported (via the `{svg()}` fragment). @@ -119,6 +121,13 @@ Treatment of colors is controlled by the `--svf-mono` option, whose argument can With the default, colors are preserved both for imported SVG files and for exported SVG files. +The `{svg()}` fragment takes the following key=value arguments: +- `file` (required) the path to an SVG file +- `flip_y` (optional, `true` (default) or `false`) whether to flip the model +- `color` (optional, defaults to `--label-color`) the name of a color to use SVG elements without specific colors, or all SVG elements when the SVG file is being imported as monocolor + +## SVG Examples + Here is an multi-colored example (from [https://www.w3schools.com/graphics/tryit.asp?filename=trysvg_fill0](https://www.w3schools.com/graphics/tryit.asp?filename=trysvg_fill0)): ``` gflabel --vscode -o label.step -o fillcolors.svg plain --width 25 --height 15 "{svg(file=wjc/fillcolors.svg)}" From 87d3e2b9a47336756b2837e11a358a4b4338c374 Mon Sep 17 00:00:00 2001 From: WJCarpenter Date: Sat, 31 Jan 2026 10:55:59 -0800 Subject: [PATCH 48/50] more COLOR_NOTES renaming stuff --- README.md | 4 ++-- src/gflabel/fragments.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 527d13f..cb70a83 100644 --- a/README.md +++ b/README.md @@ -275,7 +275,7 @@ A list of all the fragments currently recognised: | bolt | Variable length bolt, in the style of Printables pred-box labels.

If the requested bolt is longer than the available space, then the
bolt will be as large as possible with a broken thread. | | box | Arbitrary width, height centered box. If height is not specified, will expand to row height. | | circle | A filled circle. | -| color | Changes the color to be used for subsequent fragments on a line. See COLOR_NOTES.md +| color | Changes the color to be used for subsequent fragments on a line. See COLOR_AND_SVG_NOTES.md| | head | Screw head with specifiable head-shape. | | hexhead | Hexagonal screw head. Will accept drives, but not compulsory. | | hexnut, nut | Hexagonal outer profile nut with circular cutout. | @@ -286,7 +286,7 @@ A list of all the fragments currently recognised: | measure | Fills as much area as possible with a dimension line, and shows the length. Useful for debugging. | | offset | Apply a placement offset on one or more axes for subsequent fragments on a line.| | scale | Apply a placement offset on one or more axes for subsequent fragments on a line.| -| svg | Imports an SVG from a file and renders it as a collection of (colored) Sketches. See COLOR_NOTES.md| +| svg | Imports an SVG from a file and renders it as a collection of (colored) Sketches. See COLOR_AND_SVG_NOTES.md| | sym, symbol | Render an electronic symbol. | | threaded_insert | Representation of a threaded insert. | | tnut | T-slot nut. | diff --git a/src/gflabel/fragments.py b/src/gflabel/fragments.py index 1423a78..a0be4ba 100644 --- a/src/gflabel/fragments.py +++ b/src/gflabel/fragments.py @@ -290,7 +290,7 @@ def render(self, height: float, maxsize: float, options: RenderOptions) -> Compo @fragment("svg") class SvgFragment(Fragment): - """Imports an SVG from a file and renders it as a collection of (colored) Sketches. See COLOR_NOTES.md""" + """Imports an SVG from a file and renders it as a collection of (colored) Sketches. See COLOR_AND_SVG_NOTES.md""" examples = ["text{svg(file=/some/mysvg.svg, flip_y=true, label=mysvg, color=green}"] @@ -1405,7 +1405,7 @@ def render(self, height: float, maxsize: float, options: RenderOptions) -> Compo @fragment("color") class ColorFragment(ModifierFragment): - """Changes the color to be used for subsequent fragments on a line. See COLOR_NOTES.md""" + """Changes the color to be used for subsequent fragments on a line. See COLOR_AND_SVG_NOTES.md""" examples = ["{color(blue)}BLUE{color(green)}GREEN]"] From 41fd0d00e316f1bb9c3f9915e2fd514a1912179d Mon Sep 17 00:00:00 2001 From: WJCarpenter Date: Thu, 12 Mar 2026 11:26:27 -0700 Subject: [PATCH 49/50] paste glitch in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index cb70a83..d3a30a3 100644 --- a/README.md +++ b/README.md @@ -285,7 +285,7 @@ A list of all the fragments currently recognised: | magnet | Horseshoe shaped magnet symbol. | | measure | Fills as much area as possible with a dimension line, and shows the length. Useful for debugging. | | offset | Apply a placement offset on one or more axes for subsequent fragments on a line.| -| scale | Apply a placement offset on one or more axes for subsequent fragments on a line.| +| scale | Apply a scaling on one or more axes for subsequent fragments on a line.| | svg | Imports an SVG from a file and renders it as a collection of (colored) Sketches. See COLOR_AND_SVG_NOTES.md| | sym, symbol | Render an electronic symbol. | | threaded_insert | Representation of a threaded insert. | From 6a287400bf2426cbc9b52c67f8b4bdf4c83939d8 Mon Sep 17 00:00:00 2001 From: WJCarpenter Date: Thu, 12 Mar 2026 11:28:50 -0700 Subject: [PATCH 50/50] remove obsolete comment --- src/gflabel/fragments.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/gflabel/fragments.py b/src/gflabel/fragments.py index a0be4ba..040b4de 100644 --- a/src/gflabel/fragments.py +++ b/src/gflabel/fragments.py @@ -1398,8 +1398,6 @@ class ModifierFragment(Fragment): visible = False - # a tiny, tiny circle for a microscopic bounding box, which in any case is invisible - # this eliminates some tedious special cases in single line processing def render(self, height: float, maxsize: float, options: RenderOptions) -> Compound: raise NotImplementedError(f"Modifier fragments should never be rendered: {self.__class__.__name__}")