diff --git a/COLOR_AND_SVG_NOTES.md b/COLOR_AND_SVG_NOTES.md
new file mode 100644
index 0000000..93d607d
--- /dev/null
+++ b/COLOR_AND_SVG_NOTES.md
@@ -0,0 +1,222 @@
+# Color and SVG Notes
+
+## Color 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.
+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.
+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.
+
+There are some examples below.
+They are all rendered in VScode OCP CAD Viewer.
+For many examples, a label with just the default colors
+is shown along with the same label using colors.
+The Viewer assembly tree is expanded to show the node labels in the CAD model.
+
+## Slicers
+
+`gflabel` can produce STL, STEP, and 3MF output files.
+STL format is not color-aware.
+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.
+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.
+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.
+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.
+
+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.
+
+## Color Examples
+
+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}'
+```
+
+
+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....'
+```
+
+
+This is an example of a divided label:
+```
+gflabel --vscode pred 'R{|}G{|}B' '{color(red)}R{|}{color(green)}G{|}{color(blue)}B'
+```
+
+
+Another example:
+```
+gflabel --vscode pred 'Danger! {head(triangle)}' '{color(red)}Danger! {color(black)}{head(triangle)}'
+```
+
+
+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"
+```
+
+
+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}'
+```
+
+
+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 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.
+
+Have a close look at the spacing between the tips of these letters:
+```
+gflabel --vscode pred 'WWW' 'W{color(blue)}W{color(blue)}W'
+```
+
+
+## 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.
+
+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)}"
+```
+
+
+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 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...._
+
+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 `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 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 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.
+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)}'
+```
+
+
+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!{|}{scale(x=0.6,y=0.6)}{svg(label=rabbit, file=wjc/rabbit.svg, color=chocolate)}'
+```
+
+
diff --git a/README.md b/README.md
index 36f7c9f..d3a30a3 100644
--- a/README.md
+++ b/README.md
@@ -120,38 +120,41 @@ 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]
+ [--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
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,12 +163,31 @@ 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 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'. Default: blue
+ --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
+ 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
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]
```
@@ -197,6 +219,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). |  |
| `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. | 
+| `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. | 
| `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. | 
| `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. | 
| `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. |  |
@@ -252,6 +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_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. |
@@ -260,8 +284,12 @@ 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 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. |
+| 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. |
diff --git a/SCALE_AND_OFFSET_NOTES.md b/SCALE_AND_OFFSET_NOTES.md
new file mode 100644
index 0000000..6155772
--- /dev/null
+++ b/SCALE_AND_OFFSET_NOTES.md
@@ -0,0 +1,113 @@
+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 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 (in millimeters).
+
+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.
+
+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"
+```
+
+
+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"
+```
+
+
+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"
+```
+
+
+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"
+```
+
+
+```
+gflabel --vscode pred "{scale(y=-1)}flipped" "{scale(y=-0.5)}short" "{scale(y=-2)}tall"
+```
+
+
+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 "{scale(x=0.5)}word" "{scale(y=2)}word"
+```
+
+
+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"
+```
+
+
+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
+```
+
+
+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
+```
+
+
+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
+```
+
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
old mode 100755
new mode 100644
index 4809c6f..38c3792
--- a/src/gflabel/cli.py
+++ b/src/gflabel/cli.py
@@ -20,7 +20,7 @@
from build123d import (
BuildPart,
BuildSketch,
- ColorIndex,
+ Color,
Compound,
ExportSVG,
FontStyle,
@@ -28,12 +28,17 @@
Location,
Locations,
Mode,
+ Mesher,
+ Part,
Plane,
RectangleRounded,
+ Solid,
Vector,
add,
export_step,
+ export_stl,
extrude,
+ scale,
)
from . import fragments
@@ -43,9 +48,12 @@
from .bases.none import NoneBase
from .bases.plain import PlainBase
from .bases.pred import PredBase, PredBoxBase
-from .label import render_divided_label
-from .options import LabelStyle, RenderOptions
-from .util import IndentingRichHandler, batched, unit_registry
+
+# 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__)
@@ -111,7 +119,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
@@ -142,6 +158,7 @@ def base_name_to_subclass(name: str) -> type[LabelBase]:
"modern": ModernBase,
"pred": PredBase,
"predbox": PredBoxBase,
+ # "tailorbox": TailorBoxBase,
"plain": PlainBase,
"none": NoneBase,
None: NoneBase,
@@ -153,9 +170,33 @@ 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") 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 +306,44 @@ 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 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'. Default: %(default)s",
+ type=str,
+ default="blue",
+ )
+ parser.add_argument(
+ "--svg-mono",
+ 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(
+ "--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.",
+ 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.",
@@ -284,6 +363,24 @@ 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: %(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,
+ )
+ parser.add_argument(
+ "--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")
parser.add_argument(
@@ -346,21 +443,49 @@ 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.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))
+ 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)
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)
- body: LabelBase | None = None
- with BuildPart() as part:
- y = 0
- body = base_type(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:
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:
@@ -371,32 +496,15 @@ 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 = []
- with BuildSketch(mode=Mode.PRIVATE) as label_sketch:
- all_labels = []
- for labels in batched(args.labels, args.divisions):
- body_locations.append((0, y))
- try:
- all_labels.append(
- render_divided_label(
- labels,
- label_area,
- divisions=args.divisions,
- options=options,
- ).locate(Location([0, y]))
- )
- 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)
-
- 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))
+ 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
@@ -405,85 +513,131 @@ def run(argv: list[str] | None = None):
with Locations(body_locations):
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.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"
+ base_part = base_bpart.part
- 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 not is_2d:
+ logger.debug(f"BASE PART {base_part}\n{base_part.show_topology()}")
+ if args.style == LabelStyle.DEBOSSED:
+ 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:
+ 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}")
- 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
+ *labels_compound.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)
- if args.box and is_2d:
- exporter.add_layer("Box", line_weight=1)
- exporter.add_shape(body_box.sketch, layer="Box")
+ 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(
+ "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:
+ layer_dict = {}
+ for pdex, part in enumerate(colored_parts(labels_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)
+ 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(part, part_number=part.label)
+
+ exporter.write(output)
+ apply_3mf_face_colors(output, parts)
else:
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)
+ show(labels_compound)
else:
- logger.info("Writing SVG label.stl")
+ # Export both step and stl in vscode_ocp mode
+ 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.EMBEDDED:
- show_parts.append(part.part)
- show_cols.append(None)
- show_parts.append(embedded_label)
- show_cols.append((0.2, 0.2, 0.2))
+
+ if args.style != LabelStyle.DEBOSSED:
+ # CAD viewer notices the Part colors
+ show(assembly)
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,
- )
- if top:
- show_parts.append(top)
- show_cols.append((0.2, 0.2, 0.2))
- if args.base != "none":
- bottom = part.part.split(Plane.XY, keep=Keep.BOTTOM)
- if bottom.wrapped:
- show_parts.append(bottom)
- show_cols.append(None)
-
- show(
- *show_parts,
- colors=show_cols,
- # position=[0, -10, 10],
- # target=[0, 0, 0],
- )
+ # 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__":
diff --git a/src/gflabel/fragments.py b/src/gflabel/fragments.py
index f65c817..040b4de 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
@@ -21,7 +22,11 @@
BuildSketch,
CenterArc,
Circle,
+ Color,
+ Compound,
+ Edge,
EllipticalCenterArc,
+ Face,
GridLocations,
Line,
Location,
@@ -31,6 +36,7 @@
PolarLocations,
Polyline,
Rectangle,
+ RectangleRounded,
RegularPolygon,
Rot,
Sketch,
@@ -44,9 +50,11 @@
make_face,
mirror,
offset,
+ scale,
+ trace,
)
-from .options import RenderOptions
+from .options import RenderOptions, FragmentDataItem, SvgMono
from .util import format_table
logger = logging.getLogger(__name__)
@@ -82,6 +90,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:
@@ -115,7 +136,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)
@@ -172,7 +193,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
@@ -183,7 +204,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)
@@ -196,7 +217,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
@@ -217,7 +238,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
@@ -230,15 +251,136 @@ 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)
+
+@fragment("svg")
+class SvgFragment(Fragment):
+ """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}"]
+
+ 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:
@@ -277,7 +419,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
@@ -456,6 +598,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"""
@@ -514,7 +666,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
@@ -670,7 +822,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
@@ -1167,7 +1319,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()
@@ -1194,7 +1346,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")
@@ -1213,7 +1365,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)]):
@@ -1241,6 +1393,51 @@ 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
+
+ def render(self, height: float, maxsize: float, options: RenderOptions) -> Compound:
+ 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_AND_SVG_NOTES.md"""
+
+ examples = ["{color(blue)}BLUE{color(green)}GREEN]"]
+
+ 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[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"]
+
+ 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:
"""Horseshoe shaped magnet symbol."""
diff --git a/src/gflabel/label.py b/src/gflabel/label.py
index 5735301..30e914c 100644
--- a/src/gflabel/label.py
+++ b/src/gflabel/label.py
@@ -6,34 +6,87 @@
import logging
import re
+import sys
+from collections.abc import Callable
from build123d import (
+ BuildPart,
BuildSketch,
+ Compound,
+ Face,
Location,
Locations,
Mode,
+ Part,
Sketch,
Vector,
add,
+ extrude,
+ scale,
)
from rich import print
+from enum import Enum, auto
from . import fragments
-from .options import RenderOptions
+from .options import RenderOptions, LabelStyle, FragmentDataItem
from .util import IndentingRichHandler, batched
logger = logging.getLogger(__name__)
RE_FRAGMENT = re.compile(r"((? list[fragments.Fragment]:
+# The nesting of logic here is:
+# Labels (overall collection from command line)
+# Batch (via the "-d" command line option)
+# Multiline (for possible embedded newlines)
+# Lines
+# Fragments (Part labels are computed from the fragment type)
+
+# We extrude fragments into Parts at the very lowest level. We
+# aggregate them into Compounds (with children) move up the stack.
+
+# This label dictionary is a global to try to give unique
+# labels in the entire batch of gflabels. Although unique,
+# some numbers might be discarded due to rescaling in the
+# rendering logic.
+label_dict: dict[str, int] = {}
+
+def get_global_label(candidate: str):
+ label_count = label_dict[candidate] if (candidate in label_dict) else 0
+ label_count += 1
+ label_dict[candidate] = label_count
+ unique = candidate + "_" + str(label_count)
+ return unique
+
+def clean_up_name(dirty_name: str):
+ # Sanitize the label. Not for security, but just to hope that
+ # any external tools don't freak out about labels they don't like.
+ clean_name = ""
+ for char in dirty_name.removeprefix("_fragment_").removesuffix("Fragment").removesuffix("fragment"):
+ if char == " ":
+ char = "_"
+ if char.isascii() and (char.isalnum() or char in "_-"):
+ clean_name += char
+ if not clean_name or not clean_name[0].isalpha():
+ clean_name = "L" + clean_name
+ return clean_name
+
+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(clean_up_name(fun_frag.__name__))
+ else:
+ fragment_name_list.append(clean_up_name(fragment.__class__.__name__))
+
else:
# We have text. Build123d Text object doesn't handle leading/
# trailing spaces, so let's split them out here and put in
@@ -42,22 +95,24 @@ 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(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(clean_up_name(part_stripped))
if chars := len(part) - len(part_stripped):
fragment_list.append(fragments.WhitespaceFragment(part[-chars:]))
- return fragment_list
-
+ 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) -> Sketch:
+ def render_batch(self, spec: str, area: Vector) -> Compound:
"""
Given a specification string, render a single label.
@@ -66,7 +121,7 @@ def render(self, spec: str, area: Vector) -> Sketch:
area: The width and height the label should be confined to.
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
@@ -132,22 +187,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):
- add(
- self._do_multiline_render(
- column_spec, Vector(X=width, Y=area.Y)
- ).locate(Location((x + (width / 2), 0)))
- )
- x += width + self.opts.column_gap
+ 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)
+ child_pcomps.append(ch_pc)
+ x += width + self.opts.column_gap
- return sketch.sketch
- # return self._do_multiline_render(spec, area)
+ 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
- ) -> 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"):
@@ -156,43 +211,47 @@ 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
- )
-
- 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
+ row_height = (area.Y - (self.opts.line_spacing_mm * (len(lines) - 1))) / len(lines)
+
+ 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()
- with Locations([(0, render_y)]):
- add(
- self._render_single_line(
- line,
- Vector(X=area.X, Y=row_height),
- self.opts.allow_overheight,
- )
- )
- IndentingRichHandler.dedent()
+ ch_pc.label = clean_up_name(get_global_label("Line"))
+ ch_pc.locate(xy)
+ child_pcomps.append(ch_pc)
+ IndentingRichHandler.dedent()
+
+ ml_compound = Compound(children=child_pcomps)
+ ml_compound.label = clean_up_name(get_global_label("Multiline"))
- scale_to_maxwidth = area.X / sketch.sketch.bounding_box().size.X
- scale_to_maxheight = area.Y / sketch.sketch.bounding_box().size.Y
+ 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.
@@ -205,7 +264,7 @@ 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)
second_try = self._do_multiline_render(
spec,
@@ -222,23 +281,67 @@ 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})'
)
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})'
)
- return sketch.sketch
+ return ml_compound
def _render_single_line(
- self, line: str, area: Vector, 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)
+ 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
+ # 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 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
+
+ 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, 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.FRAGMENT_NAME] = frag_names[fragdex]
+ 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
# scale the total height such that they fit.
@@ -256,13 +359,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()
@@ -272,56 +376,164 @@ 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())
if total_width > area.X:
logger.warning("Overfull Hbox: Label is wider than available area")
+ child_parts = []
# 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
-
- return sketch.sketch
-
-
+ x = -total_width / 2
+ for fragment, frag_sketch in [(x, rendered[x]) for x in frags]:
+ # 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)))
+ 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]
+ frag_sketch_label = frag_sketch.label
+ 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_name} fragment {frag_sketch_label} 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
-) -> 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):
- with Locations([(leftmost_label_x + i * area_per_label.X, 0)]):
- if label.strip():
- add(renderer.render(label, area_per_label))
-
- 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_batch(label, area_per_label)
+ ch_pc.locate(xy)
+ 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:
+ 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"Labels topology {labels_compound}\n{labels_compound.show_topology(limit_class=Part)}")
+ simplify_the_tree(labels_compound)
+ 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)
diff --git a/src/gflabel/options.py b/src/gflabel/options.py
index 110899d..562bb97 100644
--- a/src/gflabel/options.py
+++ b/src/gflabel/options.py
@@ -29,6 +29,37 @@ 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 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
@@ -91,7 +122,11 @@ class RenderOptions(NamedTuple):
# like everything else?
allow_overheight: bool = True
column_gap: float = 0.4
-
+ 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:
font_style = [
@@ -118,4 +153,25 @@ def from_args(cls, args: argparse.Namespace) -> RenderOptions:
),
allow_overheight=not args.no_overheight,
column_gap=args.column_gap,
+ depth=args.depth,
+ default_color=args.label_color,
+ text_as_parts=args.text_as_parts,
+ svg_mono = args.svg_mono,
)
+
+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()
diff --git a/src/gflabel/three_mf.py b/src/gflabel/three_mf.py
new file mode 100644
index 0000000..0c9ce95
--- /dev/null
+++ b/src/gflabel/three_mf.py
@@ -0,0 +1,96 @@
+from __future__ import annotations
+
+from build123d import Color, Part
+import lib3mf
+
+
+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:
+ 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] = {}
+
+ 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 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)
+ 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)