scad123d 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. scad123d-0.1.0/.claude/launch.json +11 -0
  2. scad123d-0.1.0/.github/workflows/ci.yml +87 -0
  3. scad123d-0.1.0/.gitignore +13 -0
  4. scad123d-0.1.0/.python-version +1 -0
  5. scad123d-0.1.0/CONVERSATION.md +491 -0
  6. scad123d-0.1.0/LICENSE +21 -0
  7. scad123d-0.1.0/PKG-INFO +318 -0
  8. scad123d-0.1.0/README.md +295 -0
  9. scad123d-0.1.0/ROADMAP.md +178 -0
  10. scad123d-0.1.0/docs/REFERENCE.md +357 -0
  11. scad123d-0.1.0/docs/generate_images.py +190 -0
  12. scad123d-0.1.0/docs/images/bosl2_tube_filleted.png +0 -0
  13. scad123d-0.1.0/docs/images/hull_analytic.png +0 -0
  14. scad123d-0.1.0/docs/images/hull_fallback.png +0 -0
  15. scad123d-0.1.0/docs/images/mcad_gear.png +0 -0
  16. scad123d-0.1.0/docs/images/minkowski_after.png +0 -0
  17. scad123d-0.1.0/docs/images/minkowski_before.png +0 -0
  18. scad123d-0.1.0/docs/images/stl_vs_step_step.png +0 -0
  19. scad123d-0.1.0/docs/images/stl_vs_step_stl.png +0 -0
  20. scad123d-0.1.0/docs/models/.gitignore +4 -0
  21. scad123d-0.1.0/docs/models/bosl2_tube.bin +0 -0
  22. scad123d-0.1.0/docs/models/bosl2_tube.edges.json +1 -0
  23. scad123d-0.1.0/docs/models/bosl2_tube.glb +1 -0
  24. scad123d-0.1.0/docs/models/boss_step.bin +0 -0
  25. scad123d-0.1.0/docs/models/boss_step.edges.json +1 -0
  26. scad123d-0.1.0/docs/models/boss_step.glb +1 -0
  27. scad123d-0.1.0/docs/models/hull_fallback.bin +0 -0
  28. scad123d-0.1.0/docs/models/hull_fallback.glb +1 -0
  29. scad123d-0.1.0/docs/models/hull_ok.bin +0 -0
  30. scad123d-0.1.0/docs/models/hull_ok.glb +1 -0
  31. scad123d-0.1.0/docs/models/mcad_gear.bin +0 -0
  32. scad123d-0.1.0/docs/models/mcad_gear.glb +1 -0
  33. scad123d-0.1.0/docs/models/minkowski_after.bin +0 -0
  34. scad123d-0.1.0/docs/models/minkowski_after.glb +1 -0
  35. scad123d-0.1.0/docs/models/minkowski_before.bin +0 -0
  36. scad123d-0.1.0/docs/models/minkowski_before.glb +1 -0
  37. scad123d-0.1.0/docs/scad/boss.scad +4 -0
  38. scad123d-0.1.0/docs/scad/hull_corners.scad +10 -0
  39. scad123d-0.1.0/docs/scad/hull_fallback.scad +5 -0
  40. scad123d-0.1.0/docs/scad/mcad_gear.scad +2 -0
  41. scad123d-0.1.0/docs/scad/round_box.scad +4 -0
  42. scad123d-0.1.0/docs/scad/tube_example.scad +3 -0
  43. scad123d-0.1.0/docs/viewer.html +220 -0
  44. scad123d-0.1.0/justfile +17 -0
  45. scad123d-0.1.0/pyproject.toml +51 -0
  46. scad123d-0.1.0/src/scad123d/__init__.py +128 -0
  47. scad123d-0.1.0/src/scad123d/build.py +256 -0
  48. scad123d-0.1.0/src/scad123d/cli.py +134 -0
  49. scad123d-0.1.0/src/scad123d/csg.lark +37 -0
  50. scad123d-0.1.0/src/scad123d/emit.py +36 -0
  51. scad123d-0.1.0/src/scad123d/errors.py +31 -0
  52. scad123d-0.1.0/src/scad123d/facets.py +74 -0
  53. scad123d-0.1.0/src/scad123d/hull.py +336 -0
  54. scad123d-0.1.0/src/scad123d/mesh.py +49 -0
  55. scad123d-0.1.0/src/scad123d/minkowski.py +126 -0
  56. scad123d-0.1.0/src/scad123d/module_import.py +374 -0
  57. scad123d-0.1.0/src/scad123d/nodes.py +55 -0
  58. scad123d-0.1.0/src/scad123d/openscad.py +207 -0
  59. scad123d-0.1.0/src/scad123d/parser.py +95 -0
  60. scad123d-0.1.0/src/scad123d/scad_declarations.py +451 -0
  61. scad123d-0.1.0/src/scad123d/solids.py +202 -0
  62. scad123d-0.1.0/tests/__init__.py +0 -0
  63. scad123d-0.1.0/tests/conftest.py +154 -0
  64. scad123d-0.1.0/tests/fixtures/booleans.csg +17 -0
  65. scad123d-0.1.0/tests/fixtures/extrusions.csg +28 -0
  66. scad123d-0.1.0/tests/fixtures/facets.csg +16 -0
  67. scad123d-0.1.0/tests/fixtures/hull_capsule.csg +9 -0
  68. scad123d-0.1.0/tests/fixtures/hull_cylinders.csg +15 -0
  69. scad123d-0.1.0/tests/fixtures/hull_spheres.csg +27 -0
  70. scad123d-0.1.0/tests/fixtures/metrics.json +185 -0
  71. scad123d-0.1.0/tests/fixtures/minkowski_polyhedron.csg +5 -0
  72. scad123d-0.1.0/tests/fixtures/minkowski_sphere.csg +14 -0
  73. scad123d-0.1.0/tests/fixtures/modules/module_lib.scad +8 -0
  74. scad123d-0.1.0/tests/fixtures/params.csg +15 -0
  75. scad123d-0.1.0/tests/fixtures/polyhedron.csg +5 -0
  76. scad123d-0.1.0/tests/fixtures/primitives.csg +17 -0
  77. scad123d-0.1.0/tests/fixtures/scad/booleans.scad +12 -0
  78. scad123d-0.1.0/tests/fixtures/scad/extrusions.scad +5 -0
  79. scad123d-0.1.0/tests/fixtures/scad/facets.scad +7 -0
  80. scad123d-0.1.0/tests/fixtures/scad/hull_capsule.scad +5 -0
  81. scad123d-0.1.0/tests/fixtures/scad/hull_cylinders.scad +9 -0
  82. scad123d-0.1.0/tests/fixtures/scad/hull_spheres.scad +12 -0
  83. scad123d-0.1.0/tests/fixtures/scad/minkowski_polyhedron.scad +396 -0
  84. scad123d-0.1.0/tests/fixtures/scad/minkowski_sphere.scad +6 -0
  85. scad123d-0.1.0/tests/fixtures/scad/params.scad +10 -0
  86. scad123d-0.1.0/tests/fixtures/scad/polyhedron.scad +8 -0
  87. scad123d-0.1.0/tests/fixtures/scad/primitives.scad +6 -0
  88. scad123d-0.1.0/tests/fixtures/scad/transforms.scad +7 -0
  89. scad123d-0.1.0/tests/fixtures/scad/twod.scad +10 -0
  90. scad123d-0.1.0/tests/fixtures/transforms.csg +29 -0
  91. scad123d-0.1.0/tests/fixtures/twod.csg +14 -0
  92. scad123d-0.1.0/tests/regen_fixtures.py +49 -0
  93. scad123d-0.1.0/tests/test_build.py +300 -0
  94. scad123d-0.1.0/tests/test_cli.py +110 -0
  95. scad123d-0.1.0/tests/test_differential.py +280 -0
  96. scad123d-0.1.0/tests/test_module_import.py +310 -0
  97. scad123d-0.1.0/tests/test_openscad_discovery.py +200 -0
  98. scad123d-0.1.0/tests/test_parser.py +98 -0
  99. scad123d-0.1.0/tests/test_scad_declarations.py +227 -0
  100. scad123d-0.1.0/uv.lock +1653 -0
@@ -0,0 +1,11 @@
1
+ {
2
+ "version": "0.0.1",
3
+ "configurations": [
4
+ {
5
+ "name": "docs-viewer",
6
+ "runtimeExecutable": "python3",
7
+ "runtimeArgs": ["-m", "http.server", "8743", "--directory", "docs"],
8
+ "port": 8743
9
+ }
10
+ ]
11
+ }
@@ -0,0 +1,87 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ # Tier 1: parses committed .csg fixtures and checks geometry against
10
+ # committed reference metrics. Needs no OpenSCAD binary, so it runs anywhere.
11
+ fixtures:
12
+ runs-on: ${{ matrix.os }}
13
+ strategy:
14
+ fail-fast: false
15
+ matrix:
16
+ os: [ubuntu-latest, macos-latest, windows-latest]
17
+ python: ["3.10", "3.13"]
18
+ steps:
19
+ - uses: actions/checkout@v7
20
+ - uses: astral-sh/setup-uv@v10.0.1
21
+ with:
22
+ python-version: ${{ matrix.python }}
23
+ - run: uv sync --all-extras --dev
24
+ - run: uv run pytest -q -m "not needs_openscad"
25
+
26
+ # Tier 2: differential comparison against OpenSCAD itself. Ubuntu's packaged
27
+ # OpenSCAD is typically older than the version the fixtures were generated
28
+ # with; the handful of tests that need byte/tessellation-exact agreement
29
+ # (not the usual convergence-based comparison) detect that mismatch via
30
+ # require_fixture_openscad_version() and skip themselves rather than fail,
31
+ # so this job's pass/fail is real signal -- no continue-on-error needed.
32
+ differential:
33
+ runs-on: ubuntu-latest
34
+ steps:
35
+ - uses: actions/checkout@v7
36
+ - uses: astral-sh/setup-uv@v10.0.1
37
+ with:
38
+ python-version: "3.13"
39
+ - name: Install OpenSCAD (AppImage)
40
+ # apt-get install openscad pulls OpenSCAD's whole dependency chain
41
+ # (Qt, X11/GL, CGAL, Boost -- dozens of packages) from Ubuntu's
42
+ # external mirrors on every single run; that's what a previous run of
43
+ # this job once hung on for 15+ minutes. The AppImage is a single,
44
+ # self-contained file with no dependency resolution.
45
+ #
46
+ # It's pinned to a specific snapshot for reproducibility, but
47
+ # files.openscad.org/snapshots/ only retains a rolling window of
48
+ # recent builds, so this pin WILL eventually 404 as it rotates out.
49
+ # If so: pick a current filename from
50
+ # https://files.openscad.org/snapshots/ and update SNAPSHOT below.
51
+ # The fallback keeps CI working meanwhile by grabbing whatever's
52
+ # newest, at the cost of a floating (not reproducible) version.
53
+ run: |
54
+ set -euo pipefail
55
+ # The AppImage's own bundled binary is dynamically linked against
56
+ # libEGL/libOpenGL even for headless CSG export -- it fails to even
57
+ # start without them (confirmed via `ldd`, no other libraries
58
+ # missing). Two small packages, not OpenSCAD's whole dependency
59
+ # chain -- Qt/CGAL/Boost/the app itself all still come bundled
60
+ # inside the AppImage, unaffected.
61
+ sudo apt-get update -qq
62
+ sudo apt-get install -y -qq libegl1 libopengl0
63
+ SNAPSHOT="OpenSCAD-2026.08.13-x86_64.AppImage"
64
+ URL="https://files.openscad.org/snapshots/$SNAPSHOT"
65
+ if ! curl -fsSL -o openscad.AppImage "$URL"; then
66
+ echo "::warning::$SNAPSHOT is gone from files.openscad.org/snapshots/ (rotated out) -- falling back to the latest available snapshot. Update SNAPSHOT in this workflow step."
67
+ LATEST=$(curl -fsSL https://files.openscad.org/snapshots/ | grep -oE 'href="[^"]*x86_64\.AppImage"' | sed 's/href="//;s/"//' | sort | tail -1)
68
+ curl -fsSL -o openscad.AppImage "https://files.openscad.org/snapshots/$LATEST"
69
+ fi
70
+ chmod +x openscad.AppImage
71
+ # Extract once here rather than run the AppImage directly: mounting
72
+ # an AppImage needs FUSE, which GitHub-hosted runners don't
73
+ # reliably have. Extraction needs no FUSE, and leaves a plain
74
+ # directory scad123d's own subprocess calls can invoke normally.
75
+ ./openscad.AppImage --appimage-extract || true
76
+ echo "--- missing shared libraries (diagnostic) ---"
77
+ if [ -f squashfs-root/usr/bin/openscad ]; then
78
+ ldd squashfs-root/usr/bin/openscad | grep "not found" || echo "(none missing)"
79
+ else
80
+ echo "extraction did not produce squashfs-root/usr/bin/openscad"
81
+ find squashfs-root -maxdepth 3 || true
82
+ fi
83
+ sudo mv squashfs-root /opt/openscad
84
+ sudo ln -s /opt/openscad/AppRun /usr/local/bin/openscad
85
+ openscad --version
86
+ - run: uv sync --all-extras --dev
87
+ - run: uv run pytest -q
@@ -0,0 +1,13 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .pytest_cache/
8
+ .mypy_cache/
9
+ .ruff_cache/
10
+ .DS_Store
11
+ *.step
12
+ *.stl
13
+ *.3mf
@@ -0,0 +1 @@
1
+ 3.13
@@ -0,0 +1,491 @@
1
+ # scad123d — Conversation Log
2
+
3
+ ## 2026-08-18 — Initial brief
4
+
5
+ ### User
6
+
7
+ > You might look at ~/Projects/solid123d. That project allowed an import-only
8
+ > substitution to use SolidPython code to generate Build123d geometry. I'd like
9
+ > to make `scad123d` to allow us to import OpenSCAD code and generate Build123D
10
+ > geometry from it. I don't think we need to allow users to write OpenSCAD code
11
+ > in a Python file but we'd like to import openSCAD code and use it in a Python
12
+ > file. So it could be that this module would contain only one importable
13
+ > function, `scad123d.import_scad(path:pathlib.Path) -> build123d.Shape:` Or
14
+ > maybe there are others? I think the thing to begin with is probably a
15
+ > pyparsing (or other? there are a lot of parser builders in Python) parser for
16
+ > OpenSCAD, and then a translation from that AST to Build123d primitives. It
17
+ > might be easiest to generate SolidPython code using the OpenSCAD AST and then
18
+ > using solid123d to convert that, or it might be good to go straight to
19
+ > Build123d. Is that a clear brief, and do you have any questions?
20
+
21
+ ### Assistant
22
+
23
+ Read the prior art: `/Users/jonese/Dropbox/Projects/3D_geometry/scad_2_build123d`
24
+ (package name `solid123d`, on PyPI). ~1000 lines, clean separation into
25
+ primitives/transforms/booleans/extrusions/render.
26
+
27
+ Brief is clear. One correction to the framing, then questions.
28
+
29
+ #### The framing issue
30
+
31
+ "pyparsing parser -> AST -> translate to build123d" understates it. OpenSCAD is a
32
+ full programming language; a `.scad` file is a *program* and geometry is its
33
+ output. You need three layers, not two:
34
+
35
+ 1. **Parser** -> AST
36
+ 2. **Evaluator** — the actual hard part: lexical scoping, `module`/`function`
37
+ definitions with defaults, `children()`/`$children`, `for`/`intersection_for`/
38
+ `if`/`let`, list comprehensions (`for`/`if`/`each`/`let` inside `[]`),
39
+ `include`/`use`, `$`-variable *dynamic* scoping, and OpenSCAD's
40
+ non-sequential variable semantics (within a scope the last assignment wins for
41
+ the whole scope — `x=1; echo(x); x=2;` prints `2`)
42
+ 3. **Geometry backend** -> build123d
43
+
44
+ Layer 3 is essentially solid123d already.
45
+
46
+ #### Don't route through SolidPython source
47
+
48
+ Generating SolidPython code doesn't save you layer 2 — SolidPython has no
49
+ representation for `for`, `if`, or `module`, so you'd have to evaluate those
50
+ first anyway. You'd get a codegen step and two debugging hops for no reduction in
51
+ work.
52
+
53
+ But solid123d is still the right thing to reuse — as a **library, not a codegen
54
+ target**. The evaluator calls `solid123d.cube(...)`, `solid123d.translate(v)(...)`
55
+ directly; those already return `build123d.Shape`. See
56
+ `@/Users/jonese/Dropbox/Projects/3D_geometry/scad_2_build123d/src/solid123d/primitives.py:22`.
57
+
58
+ #### Parser choice
59
+
60
+ **Lark** over pyparsing: grammar in EBNF in its own file, Earley for the ambiguous
61
+ corners and LALR when you want speed, far better error messages, pure Python. A
62
+ C-like expression grammar with precedence is exactly where pyparsing is least
63
+ pleasant. `tree-sitter-openscad` also exists and is battle-tested, but adds a
64
+ native dep and hands you a CST to walk.
65
+
66
+ #### Where fidelity actually breaks
67
+
68
+ - **`$fn`** — the sneaky one. `circle(r=10, $fn=6)` *is a hexagon* in OpenSCAD,
69
+ not an approximation of a circle. solid123d ignores `$fn` (exact BRep curves),
70
+ which is correct for a migration bridge but silently wrong for an importer
71
+ whose job is fidelity.
72
+ - **`hull()`** — common in real files, no OCCT equivalent. Either hard-fail or
73
+ approximate via `scipy.spatial.ConvexHull` over tessellated vertices ->
74
+ polyhedron (exact for polyhedral input, wrong for spheres).
75
+ - **`minkowski()`** — also common (rounded boxes). Sometimes expressible as
76
+ `offset`; generally not.
77
+ - **`polyhedron()`, `import()` (STL/DXF/SVG), `surface()`, `projection()`,
78
+ `linear_extrude(twist=)`** — each needs its own call. Mesh imports especially:
79
+ OCCT booleans on imported triangle soup are miserable.
80
+ - **Modifiers** `*` `!` `#` `%` — `*`/`!` are cheap and genuinely useful; `#`/`%`
81
+ map to color/discard.
82
+
83
+ #### API surface
84
+
85
+ `import_scad(path) -> Shape` covers "give me the model", but most `.scad` files
86
+ worth importing are *libraries of modules*. Proposed:
87
+
88
+ ```python
89
+ lib = scad123d.import_scad(path, **overrides) # -> ScadModule namespace
90
+ nut = lib.hex_nut(size=5) # modules as Python callables -> Shape
91
+ part = lib.geometry # top-level geometry, implicitly unioned
92
+ ```
93
+
94
+ `**overrides` sets top-level variables (Customizer-style) — nearly free once the
95
+ evaluator exists, and very valuable.
96
+
97
+ #### Testing
98
+
99
+ OpenSCAD.app is installed on this machine, so differential testing is practical:
100
+ render each fixture with `openscad -o out.stl`, compare volume / bbox / centroid
101
+ against the build123d result within tolerance. That's the only way to trust this.
102
+ Worth building the harness early rather than bolting it on.
103
+
104
+ #### Questions
105
+
106
+ 1. **Target corpus** — what do you actually want to import? This decides
107
+ everything. Hand-written single files? Thingiverse downloads? Files built on
108
+ BOSL2/MCAD/dotSCAD? (BOSL2 is a project unto itself: heavy list-comprehension
109
+ math, function literals, `$`-var conventions.) There are ~40 `.scad` files
110
+ under `~/Dropbox/Projects/sp_scratch` — is that representative?
111
+ 2. **`$fn` fidelity** — honor it (faceted, matches OpenSCAD exactly), ignore it
112
+ (exact BRep, prettier and filletable), or honor-when-explicitly-set?
113
+ 3. **`hull`/`minkowski`** — hard fail, or best-effort approximation with a
114
+ warning?
115
+ 4. **solid123d dependency** — depend on the PyPI package, or vendor it so
116
+ scad123d stands alone?
117
+ 5. **`include`/`use` resolution** — full `OPENSCADPATH` + library-dir search, or
118
+ restricted to paths relative to the imported file? (`include` executes
119
+ arbitrary code from arbitrary paths, so this is a sandboxing question too.)
120
+ 6. **Depth vs. breadth first** — MVP is primitives + transforms + booleans +
121
+ variables + `for`/`if` + modules + `include`. Full language adds list
122
+ comprehensions, function literals, `assert`/`echo`, `intersection_for`,
123
+ ranges, string ops. Ship the MVP first, or get the language right before
124
+ widening geometry coverage?
125
+
126
+ ---
127
+
128
+ ## 2026-08-18 — Decisions, and the CSG-export discovery
129
+
130
+ ### User
131
+
132
+ > BOSL2 & MCAD are good goals. For our purposes, let's pick something like 10
133
+ > modules from BOSL2 and get fidelity between those two? We'll ignore
134
+ > `sp_scratch`; that's just scratch code I've worked on in the past.
135
+ >
136
+ > When $fn is set globally, it's usually there as a compilation-complexity
137
+ > switch; I use something like $fn=48 for development work and $fn=128 for
138
+ > exports. When $fn is set explicitly in a function call, (like your hexagon
139
+ > example) that's usually intentional. I'd say let's honor explicit $fn
140
+ > invocations, and ignore global usage for any $fn >= 20, with explicit notes in
141
+ > the README describing behavior and workarounds.
142
+ >
143
+ > 3. Let's do a hard fail on hull and minkowski for now. We might look to include
144
+ > more universal approximations at a later stage, but for now let's throw a
145
+ > NotImplementedError or something and make similar explicit notes in the README
146
+ > 4. Do we actually need solid123d? If so, let's depend on PyPI. If not, let's not
147
+ > do it at all. Definitely no vendoring.
148
+ > 5. Yes, let's include OPENSCADPATH logic to mimic OpenSCAD's. There's probably a
149
+ > fair amount of testing to make sure we resolve to the same paths as OpenSCAD.
150
+ > Your point about sandboxing is a good one -- worth including a security note
151
+ > in the docs
152
+ > 6. Let's get the language right first. The primitives are the easy part.
153
+ > Interpreting all the language constructs correctly is a bigger job.
154
+ >
155
+ > *UNLESS* we want to replace OpenSCAD calls with calls to the OpenSCAD executable
156
+ > to create STLs, then import those STLs normally. That's a much much smaller
157
+ > project and still gets us mostly similar results. Can you tell me some pros and
158
+ > cons of this approach? I suspect translating all the code in an OpenSCAD
159
+ > repository to Build123d code is a big job. Letting OpenSCAD do its thing and
160
+ > then using the output seems like a reasonable approach even if it means we don't
161
+ > get e.g. per-face awareness from the OpenSCAD objects.
162
+
163
+ ### Assistant
164
+
165
+ Ran experiments against OpenSCAD 2025.07.18 (installed at
166
+ `/Applications/OpenSCAD.app/Contents/MacOS/OpenSCAD`). BOSL2 is already present
167
+ in `~/Documents/OpenSCAD/libraries/`.
168
+
169
+ #### Finding: `openscad -o out.csg` gives a fully-evaluated tree of *analytic primitives*
170
+
171
+ OpenSCAD's CSG export runs the entire language and emits a flattened tree.
172
+ Verified resolved: modules inlined with defaults, `if` decided per-instance,
173
+ `for` unrolled, list comprehensions evaluated, `children()` spliced, transforms
174
+ collapsed to 4x4 `multmatrix`, `include`/`use`/`OPENSCADPATH` resolved, `*`
175
+ disable-modifier stripped.
176
+
177
+ Two key properties:
178
+ - **Primitives stay analytic** — `cylinder(h=8, r1=3, r2=1.5)`, not triangles.
179
+ The BRep survives.
180
+ - **`$fn` is emitted per-primitive and distinguishes global-inherited from
181
+ call-site** (`$fn=48` from global vs `$fn=6` explicit). The agreed $fn policy
182
+ falls out for free. `$fn=0` means unset (use `$fa`/`$fs`).
183
+
184
+ **BOSL2 works.** `include <BOSL2/std.scad> + gears.scad` with `cuboid(rounding=3)`,
185
+ `spur_gear()`, `prismoid(chamfer=2)` exported in **0.33s**. Full node vocabulary
186
+ of that output:
187
+
188
+ ```
189
+ 945 group 65 multmatrix 9 polygon 9 linear_extrude
190
+ 8 intersection 8 cylinder 4 union 1 polyhedron 1 hull
191
+ ```
192
+
193
+ Across all probes the CSG vocabulary is ~24 node types with no expressions, no
194
+ variables, no scoping — a ~100-line Lark grammar plus a tree walk, not an
195
+ interpreter.
196
+
197
+ #### Three options
198
+
199
+ | | A: full interpreter | B: shell -> STL | C: shell -> CSG |
200
+ |---|---|---|---|
201
+ | Effort | months | ~1 week | ~2-3 weeks |
202
+ | BOSL2/MCAD day one | no | yes | yes |
203
+ | Geometry out | BRep | **mesh** | **BRep** |
204
+ | `fillet`/`chamfer` after import | yes | **no** | yes |
205
+ | Face/edge selectors | yes | triangles only | yes |
206
+ | Real STEP export | yes | mesh-as-STEP | yes |
207
+ | `hull`/`minkowski` | no | **yes** | no (mesh fallback) |
208
+ | Needs OpenSCAD binary | no | yes | yes |
209
+
210
+ #### Pros/cons of the STL route
211
+
212
+ **Pros:** perfect semantic fidelity by construction including `hull`,
213
+ `minkowski`, `projection`, `surface`. Smallest codebase. Zero language-tracking
214
+ maintenance.
215
+
216
+ **Cons** (the mesh consequence is bigger than "no per-face awareness"):
217
+ - `fillet()`/`chamfer()` effectively dead — filleting a 128-facet cylinder rim
218
+ gives 128 tiny facet-edge fillets, not a rounded rim.
219
+ - Selectors return triangles; `.faces().sort_by(Axis.Z)[-1]` is one triangle.
220
+ No `Plane(face)` workplane placement.
221
+ - OCCT booleans on triangle soup: much slower, much more failure-prone.
222
+ - STEP export is mesh-as-STEP; downstream CAD imports a mesh body, not a solid.
223
+ - Faceting baked at import time; changing `$fn` means re-running.
224
+ - STL is unsealed triangle soup, needs sewing/healing. 3MF is the better
225
+ container if going this route.
226
+
227
+ Decisive point: B's only real advantage over C is that
228
+ `hull`/`minkowski`/`projection`/`surface` get evaluated. Everything else B
229
+ offers, C offers too — plus analytic geometry. Both need the same binary, so B
230
+ buys nothing on the dependency front.
231
+
232
+ #### Recommendation: C, with B as a localized fallback
233
+
234
+ Take the CSG route. When the walker hits a node with no BRep equivalent
235
+ (`hull`, `minkowski`, `projection`, `surface`, mesh `import`), write *that
236
+ subtree alone* back out (CSG syntax is valid OpenSCAD input), re-invoke OpenSCAD
237
+ to mesh just it, splice the mesh in with a warning. Analytic everywhere
238
+ possible; mesh confined to the smallest subtree that needs it.
239
+
240
+ **This bears on decision #3.** `cuboid([20,15,10], rounding=3, edges="Z")`
241
+ compiled to `hull()` over eight thin cylinders — BOSL2's rounded boxes *are*
242
+ `hull()`. A hard `NotImplementedError` would reject one of the most common
243
+ BOSL2 idioms. Recommend the mesh fallback instead of a hard fail.
244
+
245
+ #### Revised answers to the open questions
246
+
247
+ - **#4 solid123d** — yes, emphatically; it becomes the entire backend. The CSG
248
+ node set is nearly 1:1 with its public API. Depend on the PyPI package. Needs
249
+ two additions: `polyhedron()` and a `multmatrix()` applying a raw 4x4.
250
+ - **#5 OPENSCADPATH** — moot; OpenSCAD resolves includes itself. Security note
251
+ gets *stronger*: we execute OpenSCAD on user-supplied `.scad`, which can
252
+ `include`/`import()` arbitrary paths. Prominent docs warning warranted.
253
+ - **#6 language first** — moot; OpenSCAD is the interpreter.
254
+ - **#2 `$fn`** — implementable exactly as specified; CSG gives per-primitive `$fn`.
255
+
256
+ Caveats stated: C is not an independent reimplementation — it hard-depends on
257
+ the OpenSCAD binary, and a pure-Python no-binary scad123d remains project A.
258
+ Verified on OpenSCAD 2025.07.18 against a moderate BOSL2 sample, not proof it
259
+ holds across all of BOSL2.
260
+
261
+ **Open question:** does depending on the OpenSCAD binary kill it? If scad123d
262
+ must work without OpenSCAD installed, C is off the table. If acceptable,
263
+ suggested a fidelity suite spanning mapping paths rather than 10 arbitrary
264
+ modules: `cuboid(rounding=)` / `cyl(rounding=)` (hull path), `spur_gear` /
265
+ `star` (linear_extrude+polygon), `prismoid(chamfer=)` / `rounded_prism` /
266
+ `path_sweep` (polyhedron), `torus` (rotate_extrude), `tube` (booleans),
267
+ `text3d` (fonts).
268
+
269
+ ---
270
+
271
+ ## 2026-08-18 — Scaffolded v1 (rungs 0 and 1)
272
+
273
+ ### User
274
+
275
+ > Yes, scaffold it with rungs 0 and 1. Please add docs regarding the tradeoffs
276
+ > involved, but since 95% of OpenSCAD Minkowski use is just adding rounds, this
277
+ > will cover most of our bases and improve on existing solution.
278
+
279
+ ### Assistant
280
+
281
+ Built the project. 76 tests pass, 1 skipped.
282
+
283
+ #### Layout
284
+
285
+ ```
286
+ src/scad123d/
287
+ csg.lark grammar for OpenSCAD's flattened .csg format (~24 nodes, no expressions)
288
+ parser.py .csg text -> CsgNode tree
289
+ nodes.py CsgNode; UNSUPPORTED set
290
+ emit.py CsgNode -> .csg text (enables the subtree fallback)
291
+ openscad.py binary discovery (macOS/Windows/Linux/flatpak/snap) + invocation
292
+ build.py the walker; delegates primitives to solid123d
293
+ solids.py polyhedron() and multmatrix (no solid123d equivalent)
294
+ facets.py the $fn policy + analytic faceted circle/cylinder
295
+ minkowski.py rung 1
296
+ mesh.py rung 0
297
+ errors.py
298
+ ```
299
+
300
+ Public API: `import_scad(path, **overrides)`, `import_csg(tree|text|path)`,
301
+ `to_csg(path)`, plus `find_openscad`/`openscad_version`.
302
+
303
+ #### Verified results
304
+
305
+ - **BOSL2 works end to end.** `tube(h=20, or=15, ir=10)` imports as 2 planes +
306
+ **2 analytic CYLINDER faces**, volume exact to 1.16e-16.
307
+ `torus(r_maj=20, r_min=5)` imports as a **single TORUS face**, exact to
308
+ 1.84e-16. `prismoid(chamfer=2)` and `spur_gear(teeth=16)` build exactly via
309
+ the polyhedron and linear_extrude paths. `cuboid(rounding=4)` takes the hull
310
+ mesh path as predicted.
311
+ - **The payoff claim holds.** Filleting an imported BOSL2 tube selects 2
312
+ circular edges (not triangles) and produces 2 TORUS fillet surfaces, valid
313
+ solid, 14KB analytic STEP.
314
+ - **Rung 1 is exact.** minkowski(cube, sphere) matches the Steiner formula to
315
+ 6.7e-11, returning 6 planes + 12 cylinders + 8 spheres.
316
+ - **Faceted primitives are exact.** Hexagon + octagon + pentagonal frustum +
317
+ square pyramid + exact cylinder summed to 6315.27 by hand vs 6315.2881 built.
318
+ - **CI tier runs with no binary**: 44 tests in 0.24s, 33 deselected.
319
+
320
+ #### Four non-obvious things found while building
321
+
322
+ 1. **OpenSCAD writes CSG matrices at only 6 significant figures**, so a rotation
323
+ arrives orthonormal to just ~3e-7 (cos 45 deg is emitted as `0.707107`). That
324
+ residual is enough to make OCCT report the transformed shape invalid. Fixed
325
+ by decomposing and re-orthonormalizing via Gram-Schmidt.
326
+ 2. **A `gp_Trsf` carrying any scale factor other than 1 produces a shape that
327
+ fails `BRepCheck`** — the geometry scales but its tolerances do not. Scaling
328
+ must be applied separately, never folded into the rigid transform.
329
+ 3. **`build123d.transform_shape()` is unusable** for this: it routes through
330
+ `gp_GTrsf.Trsf()`, and OCCT never sets the form flag on an element-wise
331
+ GTrsf, so even a pure translation raises "non-orthogonal GTrsf".
332
+ 4. **`build123d.scale()` defaults to scaling about the object's location, not
333
+ the origin.** This is an upstream bug in solid123d, which calls it without
334
+ `about=` — OpenSCAD's `scale()` is origin-based, so bridged solid123d code
335
+ with an off-origin object gets the wrong result. Recorded in ROADMAP.md.
336
+
337
+ Also corrected an earlier claim of mine: I had said the CSG output distinguishes
338
+ a global `$fn` from a call-site one. It does not — it records only the effective
339
+ value per node. The agreed policy is implemented as a magnitude threshold
340
+ (`facet_threshold`, default 20), which matches the intent; documented as a
341
+ heuristic in the README with workarounds.
342
+
343
+ One test-design error found and fixed: I first asserted BRep volume >= OpenSCAD
344
+ mesh volume. That flips when a curved feature is *subtracted* — an exact
345
+ cylindrical hole removes more material than a faceted one. Replaced with a
346
+ direction-agnostic convergence assertion (raising $fn must move OpenSCAD's
347
+ volume toward ours).
348
+
349
+ ---
350
+
351
+ ## 2026-08-18 — Committed; patched solid123d's minkowski() upstream
352
+
353
+ ### User
354
+
355
+ > Yes, commit this and do rung 2
356
+ > [Request interrupted by user]
357
+ > Let's not do rung 2 yet, but let's commit. Also, can you tell me how
358
+ > solid123d handles hull & minkowski? It seems like there could be a problem
359
+ > with those calls that our rung 1 solution would resolve for most use cases
360
+
361
+ ### Assistant
362
+
363
+ Committed scad123d's v1 scaffold (`fb5b59d`).
364
+
365
+ Checked `src/solid123d/booleans.py`: `hull()` and `minkowski()` both raised
366
+ `NotImplementedError` **unconditionally**, regardless of arguments -- so
367
+ `minkowski()(box, sphere(3))`, the single most common real-world Minkowski
368
+ call, hard-failed for every SolidPython-bridge user. Confirmed this was a
369
+ better fit to fix upstream than to route around: the analytic-offset trick
370
+ needs no OpenSCAD binary (pure `build123d.offset`), so it's a zero-dependency,
371
+ strictly backward-compatible addition to solid123d itself.
372
+
373
+ Patched `~/Dropbox/Projects/3D_geometry/scad_2_build123d` directly (with the
374
+ user's go-ahead): `minkowski()` now detects a bare, untransformed
375
+ `sphere()`/`circle()` at the origin as either argument (`BRepAdaptor_Surface`/
376
+ `BRepAdaptor_Curve`, checking `GeomType` and that the center is within 1e-6 of
377
+ the origin) and computes the sum as `offset(A, r)`. Verified exact against the
378
+ 3D and 2D Steiner formulas, commutative in either argument order, and correct
379
+ on `minkowski(sphere(2), sphere(3))` -> radius-5 sphere. Everything else still
380
+ raises `NotImplementedError` exactly as before. Added 6 tests to
381
+ `tests/test_solid123d.py`; all 50 pass. Committed as `5ba9785`, with a
382
+ CONVERSATION.md entry in that repo per its own house convention (`9037aa0`).
383
+
384
+ **Note for later**: this fix lives in the local solid123d repo, not yet
385
+ released. scad123d depends on `solid123d>=0.1.0` from PyPI but does not
386
+ actually call `solid123d.minkowski()` -- scad123d's own rung 1
387
+ (`src/scad123d/minkowski.py`) is an independent implementation that the CSG
388
+ walker (`build.py`) calls directly, so scad123d is unaffected either way.
389
+ The solid123d fix only reaches solid123d's own (SolidPython-bridge) users once
390
+ it's released as a new version to PyPI -- that's a separate decision for the
391
+ user to make, not bundled into this session.
392
+
393
+ ---
394
+
395
+ ## 2026-08-18 (cont.) — Rung 2, and a real fix upstream instead
396
+
397
+ ### User
398
+
399
+ > Yes, let's do rung 2.
400
+
401
+ ### Assistant
402
+
403
+ Before writing code, verified rung 2's premise against the real BOSL2 calls it
404
+ was supposed to fix.
405
+
406
+ #### The premise was half wrong
407
+
408
+ Traced `cuboid([20,15,10], rounding=3, edges="Z")`'s actual CSG output. The
409
+ `hull()` node found during the original design exploration turns out to be
410
+ inert: all 8 of its children resolve to *empty* geometry (two intersection
411
+ operands with non-overlapping bounding boxes — `z in [-0.005,0.005]` vs
412
+ `z in [-0.165,-0.055]`), apparently anchor/attachment bookkeeping. The real
413
+ rounded profile is a many-vertex 2D polygon, pre-faceted by BOSL2 itself at
414
+ `$fn` resolution, fed straight into `linear_extrude` — already handled
415
+ natively by scad123d, no hull involved. Rung 2 as scoped would not have
416
+ touched this call at all.
417
+
418
+ `cuboid([20,15,10], rounding=3)` (every edge, not just `Z`) doesn't use
419
+ `hull()` either — it's `minkowski(cube, polyhedron)`, where the polyhedron has
420
+ 258 vertices all at radius 3.000000 +/- 4e-6 from a shared centroid. A sphere,
421
+ expressed as an explicit facet mesh rather than a `sphere()` node. Rung 1 only
422
+ recognized a literal `sphere`/`circle`, so this took the mesh path too.
423
+
424
+ Surfaced this to the user with three options (original rung 2 only /
425
+ polyhedron-ball extension only / both) rather than silently redefining scope.
426
+ User chose both.
427
+
428
+ #### Built, in order
429
+
430
+ **Rung 1.5 — Minkowski with a polyhedron/polygon ball.** Redesigned
431
+ `minkowski.py`'s classification to operate on the *already-built* Shape for
432
+ each operand rather than the raw CSG node -- mirrors the redesign that made
433
+ rung 2 itself work on real code (see below), and was necessary here too: a
434
+ raw-node classifier stalled on a 2-child `group()` partway down the real
435
+ BOSL2 tree (one inert sibling, one real), which the existing walker's
436
+ `_union()` already resolves correctly by filtering empties. Added detection
437
+ of a polyhedron/polygon whose vertices are all equidistant from a centroid,
438
+ gated at >=24 vertices (above all 5 Platonic solids' vertex counts -- a
439
+ cube's 8 corners are *also* equidistant from centroid, so count is what
440
+ distinguishes a genuine tessellation from a deliberate few-sided kernel).
441
+ Verified against the real BOSL2 file: `cuboid(rounding=3)` now imports as 6
442
+ planes + 12 cylinders + 8 spheres, Steiner formula to ~4e-7 (limited by
443
+ BOSL2's own kernel precision).
444
+
445
+ **Rung 2 — hull of equal-radius spheres and parallel cylinders.** Classifies
446
+ each hull() child's *built* shape (single-face SPHERE, or 1-cylinder+2-plane
447
+ solid) rather than the raw tree, for the same real-code-robustness reason.
448
+ Sphere case: qhull `ConvexHull` of centers -> `offset(polyhedron, r, ARC)`,
449
+ verified exact (~1e-9) against a box-of-8-corners and a tetrahedron,
450
+ reproducing the original design-phase numbers (953.14152, 8285.4424) as
451
+ regression fixtures. Cylinder case: project onto the plane perpendicular to
452
+ the shared axis, 2D-hull, offset, extrude -- verified exact against a 4-post
453
+ rounded-rectangle case and an arbitrarily-tilted 2-cylinder case. A fully
454
+ collinear point set (2+ spheres on one line) builds an explicit capsule
455
+ instead of a degenerate hull -- also just the common 2-post slot idiom.
456
+
457
+ Two implementation bugs found and fixed along the way:
458
+ - Passing qhull's raw triangulated simplices into `offset_3d` made OCCT raise
459
+ `Null TopoDS_Shape object` -- a box face comes back as 2 triangles sharing a
460
+ plane equation, and offset needs merged planar faces, not adjacent coplanar
461
+ triangles. Fixed by grouping simplices via `hull.equations` and re-deriving
462
+ each merged face as the 2D hull of its own projected vertices.
463
+ - The first stadium (2D capsule) construction, built as a union of a
464
+ rectangle and two circles, left 12 lateral faces after extrusion instead of
465
+ 4 -- OCCT's fuse doesn't merge the collinear segments the union introduces
466
+ at tangent points. Rebuilt as one closed wire (2 lines + 2 tangent arcs).
467
+
468
+ Also fixed, orthogonally, discovered while tracing the real BOSL2 tree: an
469
+ intersection producing empty geometry (legitimate -- non-overlapping
470
+ operands) would later crash `apply_matrix`/`moved()` with "Cannot move an
471
+ empty shape" instead of just vanishing from the tree the way OpenSCAD treats
472
+ it. `_children()` now filters `shape._wrapped is None` the same as it already
473
+ filtered `None` itself.
474
+
475
+ **What rung 2 deliberately does not cover** (falls back to mesh, does not
476
+ crash): coplanar-but-not-collinear sphere hulls (qhull raises on degenerate
477
+ input); cylinders not sharing one axial span; mixed sphere+cylinder or
478
+ unequal-radius hulls. Documented in ROADMAP.md as Rung 2.5 and folded into
479
+ Rung 4/5's existing scope notes.
480
+
481
+ 76 tests before this session; 104 after (58 more in the local run counting
482
+ fixture-parametrized cases), all passing across two clean full runs.
483
+
484
+ #### Note on solid123d, unaffected either way
485
+
486
+ scad123d's rung 1/1.5/2 are independent implementations the walker calls
487
+ directly, not routed through solid123d, so none of this session's work
488
+ touches or depends on it. For the record: solid123d's own `minkowski()`
489
+ already got the plain-ball fix in the prior session (2026-08-18 earlier,
490
+ `5ba9785`); its `hull()` still raises `NotImplementedError` unconditionally,
491
+ and the polyhedron-ball extension built here has not been ported upstream.
scad123d-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Evan Jones
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.