diff --git a/CMakeLists.txt b/CMakeLists.txt index e2ff37c..210907d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -11,7 +11,7 @@ if(NOT DEFINED CHISELCAD_BUILD_GUI OR CHISELCAD_BUILD_GUI) endif() project(chiselcad - VERSION 0.1.0 + VERSION 0.2.0 DESCRIPTION "A fast, precise, GPU-accelerated CSG modeler" LANGUAGES CXX ) diff --git a/README.md b/README.md index 69884f0..01edd2f 100644 --- a/README.md +++ b/README.md @@ -21,21 +21,16 @@ transforms, control flow, user-defined functions/modules and function literals, 2D extrusion, `color()`/`offset()`/`projection()`, and file I/O (`include`/`use`/`import`/`surface`/`text`). See [Supported Language](#supported-language) below for the full breakdown and -[docs/roadmap.md](docs/roadmap.md) (v3.7/v3.8) for the small number of constructs -still missing — mainly `assert()`/`echo()` used as chained expressions inside -function bodies, multi-variable/C-style list comprehensions, and `roof()`. - -All 33 issues from the original correctness audit (v3) are closed. A July -2026 completeness re-audit (v3.7) fixed three gaps found by re-reading the -source against the OpenSCAD manual (`log()`/`ln()` were swapped, `str()` -dropped vector/range arguments, `$vpf` was missing), and a follow-up pass -(v3.8) went further — running OpenSCAD's own test corpus through a live, -installed OpenSCAD binary and diffing against ChiselCAD's output — which -found a parser crash on `$special=value` call arguments (fixed) plus a -handful of confirmed-but-still-open gaps (dot-member access like `v.x`, -calling a function-literal expression's result directly, named/positional -argument ordering, and some Unicode-string edge cases). See -[docs/roadmap.md](docs/roadmap.md) (v3.8) for the full list. +the repo's [open issues](https://github.com/particlesector/chiselcad/issues) +for the small number of constructs still missing — mainly `assert()`/ +`echo()` used as chained expressions inside function bodies, and +multi-variable/C-style list comprehensions. + +Correctness has been validated against real OpenSCAD both by source audit +and by diffing output against a live OpenSCAD binary on its own test corpus +(see `tests/tools/README.md`); every bug found that way is fixed and closed. +Remaining gaps are tracked as +[GitHub issues](https://github.com/particlesector/chiselcad/issues). ```scad difference() { @@ -89,16 +84,24 @@ difference() { | Quality | `$fn`, `$fs`, `$fa`, `$vpr`/`$vpt`/`$vpd`/`$vpf` (global and per-node) | | Export | Binary STL | -v3–v3.6 (see [docs/roadmap.md](docs/roadmap.md)) are complete. v3.7/v3.8 -found and fixed further gaps (`log()`/`ln()`, `str()` on vectors, `$vpf`, -a `$special=value` call-argument parser crash) via both source audit and -corpus testing against a live OpenSCAD binary. What's left — `assert()`/ -`echo()` as expressions, multi-variable list comprehensions, `roof()`, -non-STL export, vector dot-member access, and a few other confirmed gaps — -is tracked in [docs/roadmap.md](docs/roadmap.md). +The OpenSCAD language and CSG engine are feature-complete and validated +against real OpenSCAD, both by source audit and by diffing output against a +live OpenSCAD binary on its own test corpus. What's left — `assert()`/ +`echo()` as expressions, multi-variable list comprehensions, non-STL +export, and a few other known gaps — is tracked as +[GitHub issues](https://github.com/particlesector/chiselcad/issues). --- +## Download + +Don't want to build from source? Grab a prebuilt binary from the +[Releases page](https://github.com/particlesector/chiselcad/releases/latest) — +Windows and Linux x64 archives are attached to every release. Unzip/untar and +run `chiselcad` (or `chiselcad_cli` for headless `.scad` → STL conversion). +ChiselCAD is still early/prerelease software — see [Project Status](#project-status) +below. + ## Building ### Prerequisites @@ -165,9 +168,8 @@ ChiselCAD is in **active development**. The core rendering pipeline, CSG evaluator, and nearly all of the OpenSCAD language are implemented and working (see [Supported Language](#supported-language) above). Current focus is closing the remaining language gaps tracked in -[docs/roadmap.md](docs/roadmap.md) (v3.8) — found via corpus testing against -a live OpenSCAD binary, see `tests/tools/README.md` — and the v4 tooling/ -visual-quality work, before making a full drop-in-replacement claim. +[docs/roadmap.md](docs/roadmap.md) and the v4 tooling/visual-quality work, +before making a full drop-in-replacement claim. If you want to follow along or contribute, see [CONTRIBUTING.md](CONTRIBUTING.md). diff --git a/docs/architecture.md b/docs/architecture.md index 40cb7bd..d46db9d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -395,8 +395,8 @@ precomputed bottom-up hash. **Known scoping limitation:** the `Interpreter`'s variable environment is a single flat map, not a real lexical scope stack. Every block-producing `evalXxx()` (boolean/transform/if/for/extrusion/offset/projection/color) -now snapshots it before evaluating its children and restores it after -(`docs/roadmap.md` v3 Phase 1), so a local assignment inside a block is +now snapshots it before evaluating its children and restores it after, +so a local assignment inside a block is visible to later statements in that same block but doesn't leak past it — this fixed the previous silent-discard bug. What snapshot/restore doesn't fix: unbound function/module parameters can still resolve to a same-named diff --git a/docs/roadmap.md b/docs/roadmap.md index e5d2a25..4efb570 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,985 +1,15 @@ # ChiselCAD — Roadmap -> Correctness bugs are tracked as individual [GitHub Issues](https://github.com/particlesector/chiselcad/issues), -> not roadmap checkboxes. A July 2026 audit filed 33 (5 Critical, 7 High, 14 -> Medium, 7 Low) across the lexer/parser, interpreter, CSG evaluator, mesh -> generation, and file I/O. The Critical/High ones block any "drop-in -> replacement" claim and should be fixed ahead of the v3 work below. +> Bugs and feature work are tracked as individual [GitHub Issues](https://github.com/particlesector/chiselcad/issues), +> not roadmap checkboxes. This file only tracks forward-looking, unfiled work. -## v1 — Core CSG ✓ +## Status -- [x] CMake + vcpkg build scaffold -- [x] Vulkan context, swapchain, ImGui integration -- [x] Lexer + recursive descent parser -- [x] CSG tree evaluator (AST → CsgNode tree) -- [x] Primitive tessellator (cube, sphere, cylinder) -- [x] Manifold boolean evaluation (union, difference, intersection) -- [x] Async eval pipeline (background thread, generation-counter cancellation) -- [x] Result render mode (Blinn-Phong shading) -- [x] Arcball orbit camera -- [x] File watcher + VS Code external editor integration -- [x] Diagnostics panel with clickable jump-to-line -- [x] Binary STL export -- [x] Mesh cache (LRU, keyed by resolved params/transform) - -## v2 — Language Expansion ✓ - -- [x] Full OpenSCAD language: `for`, `if`, `let`, variables, math functions -- [x] User-defined modules and function literals -- [x] 2D primitives: `square`, `circle`, `polygon` -- [x] Extrusion: `linear_extrude`, `rotate_extrude` -- [x] `hull()` and `minkowski()` - -## v2.5 — OpenSCAD Language Completeness ✓ - -- [x] List indexing, ternary, user-defined functions, `let` expression, `undef`, `concat()` -- [x] Math/string completeness: inverse trig, `norm()`/`cross()`/`sign()`, `rands()`/`lookup()`, string literals + `str()`/`chr()`/`ord()`, `len()` -- [x] Module system: `children()`/`$children`, `echo()`, `assert()`, recursive functions -- [x] Geometry ops: `multmatrix()`, `render()`, `color()`, `offset()`, `projection()` -- [x] File I/O: `include`/`use` (with circular-include detection), `import()` (STL), `surface()` (text `.dat`), `text()` (Latin/Western, via vendored stb_truetype) - -## v3 — OpenSCAD Language Completeness (final gaps) ✓ - -All 33 issues from the July 2026 correctness audit are closed. The items -below are sequenced into four phases: fix the one known correctness bug -first, land the expression-language features most real-world `.scad` files -depend on (list comprehensions need general range literals first), then the -lower-risk debug/parser ergonomics, then the remaining geometry primitives -(ending with nested extrusion, the architecturally trickiest one), and -close out with import/export breadth, which is additive and blocks nothing -else. - -### Phase 1 — Correctness bug + core expression language ✓ -- [x] Module-local variable assignments — currently parsed and silently discarded, a real bug -- [x] General range-literal expressions (`x = [0:5];` outside `for`) -- [x] List comprehensions (`[for (i=range) expr]`, with `if`) and `each` (depends on range literals above) - -### Phase 2 — Parser/debug ergonomics ✓ -- [x] CSG modifier characters `# % ! *` (root/background/disable/debug) - -### Phase 3 — Geometry primitives & ops ✓ -- [x] `polyhedron(points=..., faces=...)` -- [x] `resize(newsize, ...)` -- [x] Nested extrusion (extrude wrapping extrude) — currently a silent no-op - -### Phase 4 — Import/export breadth ✓ -- [x] Per-file diagnostics for code reached via `include`/`use` -- [x] PNG heightmap support for `surface()` -- [x] Additional `import()` formats: OFF, 3MF, AMF, DXF, SVG - -## v3.5 — OpenSCAD Language Completeness (audit follow-up) ✓ - -The v3 phases above closed the 33 filed correctness issues, but "feature -complete with OpenSCAD" was never verified against OpenSCAD's actual -builtin/special-variable surface — a July 2026 completeness audit (source -read, not just the issue tracker) found real gaps the v3 checklist didn't -cover. Fixed: - -- [x] `PI` constant (was entirely absent — scripts had to hard-code 3.14159) -- [x] `is_undef()`/`is_bool()`/`is_num()`/`is_string()`/`is_list()`/`is_function()` type predicates -- [x] `search()` (string/list/table search, matching OpenSCAD's flat-vs-nested result shape) -- [x] `version()`/`version_num()` (fixed compatibility level, so version-gated library code picks its modern branch) -- [x] `$preview`/`$t` special variables (fixed defaults: no animation/dual-render-pass distinction exists yet) -- [x] `parent_module(idx)`/`$parent_modules` -- [x] `linear_extrude(slices=...)` — was parsed but silently ignored; twist division count now honors it over the `$fn` default -- [x] `children([vector])` / `children([range])` — only a single plain-number index worked before -- [x] `echo(name=value)` now formats as `name = value`, matching OpenSCAD -- [x] `$vpr`/`$vpt`/`$vpd` viewport special variables — `MeshBuilder`'s - `requestBuild()` now takes a `ViewportState` snapshot (derived from - `Application::currentViewport()`, reading the camera's pitch/yaw/target/ - distance) and plumbs it into a pre-populated `Interpreter` before - evaluation, mirroring how `fileTable` is already threaded through the - `evaluate(result, interp)` overload. Headless/test evaluation (a - default-constructed `Interpreter`) falls back to OpenSCAD's own defaults - (`$vpr=[55,0,25]`, `$vpt=[0,0,0]`, `$vpd=140`) instead of `undef`. No - roll in this camera model, so `$vpr[1]` is always 0. -- [x] `cube`/`sphere`/`translate`/etc. are no longer reserved lexer - keywords — matching real OpenSCAD, they're ordinary identifiers that the - Parser recognises by name only at statement-start call position - (`kBuiltinNodeNames` in `Parser.cpp`). The Lexer's keyword table now - holds only genuine grammar keywords (`if`/`else`/`for`/`each`/`module`/ - `function`/`let`/`include`/`use`/`undef`/`true`/`false`); builtins and - variables live in separate namespaces, so `cube = 5;` followed later by - `cube(cube);`, a module parameter named `scale`, or a `for`/`let` - variable named `rotate` all now parse correctly. This was the last - gap deferred from the initial v3.5 pass, closing out feature completeness - with OpenSCAD's builtin/keyword surface. - -## v3.6 — First-class function literals ✓ - -- [x] Function literals (`f = function(x) x*2;`, OpenSCAD 2019.05+) as - values: assignable to variables, storable in lists, passable as - arguments to (and returnable from) other functions, and callable later - via ordinary `f(...)` syntax wherever `f` names a variable holding one. - Adds a `Value::Tag::Function` closure (AST pointer + captured defining - scope) alongside the existing named `function foo(x) = expr;` form. - Direct self-binding (`fact = function(n) n<=1 ? 1 : n*fact(n-1);`) - recurses by name without needing a named `function` def. -- [x] `is_function()` now recognizes actual function values (was a - stubbed-`false` placeholder pending this work). - -## v3.7 — OpenSCAD completeness re-audit ✓ - -A July 2026 re-audit (source read against OpenSCAD's actual builtin surface, -not just the v3/v3.5/v3.6 checklists) found three real gaps the prior passes -missed: - -- [x] `log()`/`ln()` — `log()` was implemented as the *natural* logarithm - (`std::log`), and there was no `ln()` at all. OpenSCAD has this backwards - from C: `log()` is base-10, `ln()` is natural log. Easy mistake (C's own - `log()` is natural log) but a real correctness bug for any script doing - `log(100)` expecting `2`. Fixed: `log()` now calls `std::log10`, `ln()` - added as `std::log`. The stray non-OpenSCAD `log10()` name this - interpreter had invented is gone (folded into the now-correct `log()`). -- [x] `str()` only handled number/bool/string/undef arguments — a vector, - range, or function argument silently contributed nothing (`str([1,2,3])` - returned `""` instead of `"[1, 2, 3]"`). Fixed with a recursive formatter - (`formatValueRec`) matching OpenSCAD's rule: a bare top-level string - argument is unquoted, but a string nested inside a vector is quoted - (`str("hi") == "hi"` but `str([1,"hi"]) == [1, "hi"]`) — mirrors - `CsgEvaluator::formatValue`'s existing echo()/assert() formatting, kept as - a separate implementation since `Interpreter` has no dependency on - `CsgEvaluator`. -- [x] `$vpf` (viewport field-of-view) special variable — `$vpr`/`$vpt`/`$vpd` - were plumbed through in v3.5 but `$vpf` was missed. Added the same way: - `ViewportState::vpf` (default 22.5°, matching OpenSCAD's own default) - plumbed from `Camera::fovDeg` through `Application::currentViewport()` → - `MeshBuilder::buildOne()` → `Interpreter::setViewport()`. - -Gaps identified but deliberately not closed in this pass — real OpenSCAD -surface, but each either architecturally deeper or low-value enough to -warrant a separate decision rather than folding into this fix-up: - -- [ ] `assert()`/`echo()` as chainable **expressions** (`function f(x) = - assert(x>0, "must be positive") x*2;`, valid OpenSCAD since 2019.05). - Currently both are statement-only (dispatched in `CsgEvaluator`, which has - the diagnostics/echo-message sinks); a function body only ever reaches - `Interpreter::evaluate`, which has no diagnostic channel at all today. - Wiring this in cleanly means threading a diagnostics/echo sink through - every `Interpreter::evaluate` call site, not just adding a case to - `callBuiltin`. -- [ ] List comprehensions don't support multi-variable `for` clauses - (`[for (i=[0:2], j=[0:2]) i*3+j]`), C-style `for(init; cond; next)`, or - nested `for` clauses within one bracket (`[for (i=a) for (j=b) i+j]`) — - `ListCompExpr`/`ListCompBody` (Expr.h) are explicitly single-variable only. -- [ ] `roof()` (OpenSCAD 2021.01+, and still marked experimental upstream) - not implemented. -- [ ] `textmetrics()`/`fontmetrics()` text-layout introspection functions - (2021.01+) not implemented. -- [ ] Export is binary STL only. Every one of the import formats this - project already supports (OFF/AMF/3MF/DXF/SVG) has no matching exporter, - unlike real OpenSCAD which round-trips all of them plus CSG/PNG. Already - tracked below under v4 ("Additional export formats"), scoped there to - OBJ/3MF — worth revisiting for the fuller OpenSCAD export list. - -## v3.8 — Corpus validation against OpenSCAD's own test suite - -Every prior completeness pass (v3–v3.7) was a source-level read against the -OpenSCAD manual/builtin list — and each pass found real bugs the previous -ones missed anyway. That pattern (not the absence of bugs) is what prompted -a different kind of check: install real OpenSCAD (`apt install openscad`, -2021.01 on Ubuntu noble — runs fully headless for non-geometry output, no -Xvfb needed) and run its own ~120-file `echo` regression-test corpus through -both it and a new headless ChiselCAD runner (`tests/tools/scad_dump.cpp` — -see `tests/tools/README.md`), diffing output line by line. This found two -real bugs in under an hour that four rounds of manual audit had missed: - -- [x] **Parser crash on `$special = expr` as a plain call argument.** - `f($fn=64, 1)` (or the same on a user module call) wasn't recognized as a - named argument at all outside the primitive-specific param parsers — the - parser tried to parse `$fn` as a positional expression, then choked on the - trailing `=`, and the recovery cascaded into a wall of unrelated parse - errors for the rest of the file. This one test file alone - (`arg-permutations.scad`) was silently invalidating ~30 of the corpus's - ~120 files' worth of signal. Fixed in both the function-call and - module-call argument-list parsers. -- [x] **`$special` named args now dynamically scope into function/closure - calls, not just module calls.** Once parseable, `CsgEvaluator`'s - module-call binding already applied *any* named arg into scope regardless - of whether it matched a declared parameter (correct — special variables - are dynamically scoped, not ordinary parameters). `Interpreter`'s - function-call and closure-call binding didn't do the same, so - `function f() = $fn; f($fn=64)` would parse but silently ignore the - override. Fixed to match, and verified byte-for-byte against real - OpenSCAD's output for both cases. -- [x] **Dot-member access on vectors/ranges** (issue #79): added a `Dot` token - (`Token.h`/`Lexer.cpp`) and a `MemberExpr` postfix AST node - (`Expr.h`/`Parser.cpp`'s `parsePostfix`), evaluated in `Interpreter.cpp` - for `Value::Tag::Vector` (`.x`/`.y`/`.z`/`.w`, the `.r`/`.g`/`.b`/`.a` - aliases, and multi-letter swizzles like `.xyzw`/`.xr`/`.rrrr`, one output - element per member letter) and `Value::Tag::Range` (`.begin`/`.step`/ - `.end` reading `rangeStart`/`rangeStep`/`rangeEnd`). Out-of-range/ - unrecognized members return `undef`, matching bracket-indexing's existing - convention. Covered by `tests/test_interpreter.cpp`. -- [x] **Named/positional argument interleaving order** (issue #81): reworked - `Interpreter`'s function-call and closure-call binding (`bindOrderedArgs` - in `Interpreter.h`, shared by both) to replay arguments in their original - call-site textual order instead of two separate named-then-positional - passes — a positional arg now advances an unconditional left-to-right - counter that doesn't skip slots already targeted by a named arg, and - whichever of a named/positional binding to the same slot comes later in - the call wins, matching OpenSCAD's actual rule (verified against all 119 - permutations in `arg-permutations.scad`). While investigating, found - `CsgEvaluator::evalModuleCall`'s module-call binding — despite this - issue's original suspicion that all three call sites shared the same - buggy model — was **already** implementing the correct order-replay rule - (it walks `call.args` in original order and binds unconditionally as it - goes), so it was left unchanged; only the two `Interpreter` call sites - needed the fix. Covered by `tests/test_interpreter.cpp`. - -Both are covered by regression tests (`tests/test_parser.cpp`, -`tests/test_interpreter.cpp`, `tests/test_csg_evaluator.cpp`) and verified -against the full 544-test suite (built directly with system `g++`/`apt` -packages for Catch2/glm/spdlog — see `tests/tools/README.md` — since this -environment has no vcpkg/Manifold/Vulkan). - -Confirmed via the same corpus run but **not yet fixed** — real gaps, ranked -roughly by expected real-world impact: - -- [x] **Calling the result of an arbitrary expression** (issue #80), e.g. - `(function(x) function(y) x+y)(2)(5)` (currying/IIFE). `FunctionCall` - (Expr.h) is name-keyed (`std::string name`) — it could only represent - "call the thing named `name`", not "call this expression's result". - Added a distinct `CallExpr` node (`callee: ExprPtr` + args) produced by - `parsePostfix` whenever `(args)` follows any primary expression (not just - a bare identifier, which `parsePrimary` already consumes directly into a - `FunctionCall`) — so `f(2)(5)`, `(function(x) ...)(2)(5)`, and chains of - either now parse. Evaluated in `Interpreter::evaluate` by evaluating the - callee to a `Value` and requiring it to already be a function (closure); - there's no name to fall back to a builtin/`function` def by, so a - non-function callee yields `undef` rather than erroring. Covered by - `tests/test_parser.cpp` and `tests/test_interpreter.cpp`. -- [ ] Possible UTF-8/Unicode string-handling gaps (issue #82) (`unicode-tests`, - `utf8-tests`, `nbsp-latin1-test`, `string-unicode`, `search-tests-unicode` - all mismatch) — `Value::str` is a raw `std::string`; `len()`/indexing/ - `chr()`/`ord()` likely count bytes, not codepoints, for multi-byte UTF-8. - Not yet root-caused in detail. -- [ ] Recursion-depth differences (issue #83) on deliberately-deep-recursion test files - (`recursion-test-function`, `tail-recursion-tests`, - `issue3118-recur-limit`) — `kMaxCallDepth` (200, chosen for MSVC's 1 MiB - default stack) is far lower than whatever depth OpenSCAD's own tests - expect to succeed. A deliberate safety/compatibility tradeoff, not - obviously wrong, but worth a second look. -- [ ] A longer tail of smaller mismatches not yet individually triaged (issue #84): - variable redefinition/scoping (`redefinition`, `value-reassignment-tests`, - `variable-scope-tests`, `function-scope`, `let-module-tests`), and - edge cases in `search()`/`norm()`/`cross()`/`concat()`/`rands()`/ - `parent_module()`. Full list of mismatching test names is reproducible via - `tests/tools/README.md`'s corpus script. -- Cosmetic (not a value/behavior bug, issue #85): ChiselCAD doesn't emit OpenSCAD's - arity-mismatch ("`abs() number of parameters does not match`") or - file-not-found warning text — builtins just silently return `undef` on a - bad call, matching OpenSCAD's *value*, just not its *diagnostic wording*. - ~36 of the corpus's ~106 raw failures are this category alone. - -## v3.9 — Geometry corpus validation (volumetric, tessellation-agnostic) - -v3.8's corpus run only covered `echo()`-based tests — computed *values*, -never actual mesh output — because ChiselCAD (Manifold) and OpenSCAD (CGAL, -or its own separate Manifold integration) tessellate primitives completely -differently: different vertex counts, ordering, and coordinates even for a -byte-perfect-correct geometry engine. A raw point/vertex diff between their -STL exports would "fail" even when the underlying solid is identical, so it -was never a viable comparison — until now. - -The fix is a tessellation-agnostic check: build the *real* C++ Manifold -library (not available via `apt`, but builds standalone from source in ~20s -with zero extra dependencies once pinned to v3.5.2 — the version this -environment builds against, since Manifold's `master` branch has since -dropped `CrossSection::FillRule`, which ChiselCAD's `PrimitiveGen.cpp`/ -`MeshEvaluator.cpp` still use), export the same `.scad` file to STL from -both OpenSCAD and ChiselCAD's *actual compiled mesh pipeline* -(`CsgEvaluator` → `MeshEvaluator` → `PrimitiveGen`, exactly as -`MeshBuilder::buildOne()` runs it — not a reimplementation), reload both as -Manifolds, and measure the volume of their **symmetric difference** -((A−B)∪(B−A)). Two meshes representing the same solid have a symmetric -difference volume of ~0 regardless of how differently each one discretized -it; a real shape difference shows up as a non-trivial fraction of the -model's own volume. Two new standalone tools do this (see -`tests/tools/README.md` for the exact build/run steps): - -- `scad_to_stl.cpp` — runs ChiselCAD's real mesh pipeline on a `.scad` file - and writes the (BatchBoolean-unioned) result as ASCII STL. -- `stl_diff.cpp` — loads two ASCII STL files as Manifolds (via - `MeshGL::Merge()` to weld the triangle-soup positions STL always exports), - and reports `volume_a`, `volume_b`, `sym_diff_volume`, and their ratio. - -Run against `tests/data/scad/3D/features/*.scad` (OpenSCAD's own 3D-feature -test corpus), this immediately found a real, high-confidence, now-fixed bug: - -- [x] **`polyhedron()` faces came out with exactly negated volume** — every - `polyhedron-*` test file showed `volume_b == -volume_a` to the last digit. - Root cause: OpenSCAD's documented `polyhedron()` convention lists each - face's vertices **clockwise as seen from outside the solid**; Manifold - (like most engines) expects **counter-clockwise from outside**. - `CsgEvaluator::evalPolyhedron()`'s fan-triangulation preserved the input - winding verbatim instead of flipping it. Fixed by swapping the last two - indices of each fan triangle; verified against real OpenSCAD on - `polyhedron-cube`/`polyhedron-soup`/`polyhedron-concave-test` (now exact, - `sym_diff_volume == 0`) with a regression test on the raw triangle indices - (`tests/test_csg_evaluator.cpp`). - -A second pass fixed three more real bugs, all found by bisecting -`sphere-tests`/`cylinder-diameter-tests` line by line against real -OpenSCAD once the test infrastructure improved (main's `chiselcad_core` -split — merged in from `main` mid-investigation — made it possible to -build ChiselCAD's real `PrimitiveGen`/`MeshEvaluator` against an installed -Manifold via plain `cmake -DCHISELCAD_BUILD_GUI=OFF`, instead of the -original ad hoc `g++` object-file dance): - -- [x] **`r`/`d` precedence**: when both a radius and a diameter argument are - given (`sphere(r=1, d=10)`), OpenSCAD always uses `d`, order-independent - (confirmed empirically: `sphere(d=10, r=1)` gives the same result) — not - "`d` only when `r` is absent", which is what `CsgEvaluator` did for - `sphere`/`cylinder`(`r`/`r1`/`r2`)/`circle`. Fixed in all five call sites; - regression test added on the resolved `CsgLeaf::params`. -- [x] **Per-node `$fa`/`$fs` overrides were silently ignored.** - `PrimitiveGen::resolveSegments()` only accepted a per-node `$fn` override; - `$fa`/`$fs` overrides fell through to the *global* values regardless of - what the call site actually specified, so `sphere(5, $fa=40, $fs=0.3)` - tessellated at the global default resolution instead of the coarser one - it explicitly asked for. This alone accounted for most of - `sphere-tests`'s remaining error once winding/`r`-`d` were fixed. Fixed - by threading `$fa`/`$fs` through the same way `$fn` already was; - regression test (`runBuild` on two fixtures with the same radius but - different `$fa`/`$fs`, checking triangle counts diverge) added to - `tests/test_headless_build.cpp` — the first geometry-level (real - Manifold mesh) unit test this codebase has had, made possible by the - same `chiselcad_core` split. -- [x] **`cylinder(r1=..., r2` unset`)` wrongly defaulted `r2` to `r1`'s - value** (a uniform cylinder) instead of independently defaulting to - `1.0` (a tapered cone down to radius 1) — confirmed against real - OpenSCAD's actual STL output. Fixed; regression test checks the exact - frustum volume formula. - -Together these fully resolved `sphere-tests` and `cylinder-diameter-tests` -(both now `sym_diff_volume` at floating-point noise level, down from 11% -and 55% relative error respectively) and incidentally unblocked several -other files that had been failing for an unrelated **test-tool** reason: -`scad_to_stl`'s `BatchBoolean(Add)` across *all* top-level roots at once -turned out to fail outright (empty combined result) the moment *any* one -root was degenerate/invalid (e.g. `cylinder(r1=0, r2=0)` in -`cube-tests.scad`/`cylinder-tests.scad`) — confirmed via `chiselcad_cli ---stats -` on the same file, which meshes each root independently and -correctly kept the valid geometry while just flagging the bad root as an -error diagnostic. Fixed `scad_to_stl` to drop non-`NoError`-status roots -before unioning, matching that real per-root behavior. This was a harness -bug, not a ChiselCAD product bug, but it had been hiding real signal: -`cube-tests`, `hull3-tests`, and `2d-3d` all turned out to already match -exactly once unblocked. - -Confirmed via the same corpus subdirectory (`3D/features`) but **not yet -fixed**: - -- [x] **Non-planar polyhedron faces** (issue #86) (`polyhedron-nonplanar-tests`) — - turned out **not** to be a fan-triangulation bug at all; see v3.11 below. -- [x] **`linear_extrude()`'s default height was 1, not OpenSCAD's actual - default of 100.** Found bisecting `linear_extrude-tests.scad` line by - line: `linear_extrude(v=[3,2,5]) square([10,10])` (no `height=` given) - gives volume 10000 in real OpenSCAD — a 10×10 profile times height - 100 — not 100 (height 1). Fixed in `MeshEvaluator::evalExtrusion()`. -- [x] **A malformed `scale=` vector (anything but exactly 2 elements) was - partially applied instead of rejected.** `linear_extrude(height=20, - scale=[4,5,6]) square(10)` — 3 elements, not the 2 (`[sx,sy]`) OpenSCAD's - `scale=` accepts — comes out as a plain *unscaled* 10×10×20 prism (volume - 2000) in real OpenSCAD, i.e. the whole malformed vector is ignored; - `CsgEvaluator` was instead taking just the first two components (`sx=4, - sy=5`), giving a wrongly-scaled volume of 17000. Fixed to require exactly - 2 elements. Both fixes together brought `linear_extrude-tests.scad` from - 102% relative error down to 13.6%; regression tests added (CsgEvaluator- - level for the scale rejection, `runBuild`-level for the default height). -- [ ] `linear_extrude-tests.scad`'s remaining ~13.6% error (issue #91), and the other - three `linear_extrude-*` files, are not yet further root-caused — - **and a real methodological wrinkle turned up while bisecting**: the - installed oracle is OpenSCAD 2021.01 (`apt`'s only option here), but the - test corpus is cloned from `openscad/openscad`'s current `master` branch. - `linear_extrude(h=10, ...)` (the corpus file's own comment: "`h` is an - alias for `height`") triggers `WARNING: variable h not specified as - parameter` on the installed 2021.01 oracle — i.e. **2021.01 itself - doesn't recognize `h=` for `linear_extrude`**, while ChiselCAD does. - Whether that's a genuine ChiselCAD-ahead-of-2021.01 case (`h=` added in a - later real OpenSCAD release the corpus already assumes) or an actual - ChiselCAD-only invention needs checking against a newer real OpenSCAD - build (or the current online manual) before touching it either way — - don't "fix" this by matching the older oracle without that check first. - The same applies to `segments=` (also unrecognized by 2021.01). This - caveat likely explains some fraction of the other still-open mismatches - below too, not just `linear_extrude`. -- [x] **`rotate_extrude` volume mismatches** (issue #87): `rotate_extrude-tests` (27%), - `rotate_extrude-angle` (71%) — contrast with `rotate_extrude-touch-vertex`/ - `rotate_extrude-touch-edge`, which pass at floating-point-noise level, so - this is parameter-specific (likely the `angle=` partial-revolution case - given `-angle` is the worse of the two) rather than a blanket - `rotate_extrude` bug. Fixed — see v3.12. -- [x] (issue #88) `intersection-tests` (6.4%), `cylinder-tests` (13%, improved from - totally-blocked but still a real remaining gap after the harness fix - above), `primitive-inf-tests` (83%), `ifelse-tests` (175%), - `module-recursion`, `resize-tests` (1.5%), `surface-simple` — real - mismatches, not yet individually triaged. All seven individually - root-caused and fixed — see v3.14. -- [x] `assign-tests` and `intersection_for-tests` (issue #89) still produce zero valid - geometry even after the harness fix — unlike the files that fix unblocked, - these appear to genuinely fail in `MeshEvaluator`/`PrimitiveGen` itself - (every root invalid, not just one), not just in the test tool. Needs the - same per-root `chiselcad_cli --stats` triage the harness bug above got. - Root-caused and fixed in v3.13 — not a `MeshEvaluator`/`PrimitiveGen` bug - at all, but `assign()`/`intersection_for()` not being recognized as - builtins by the parser. -- [ ] Several other files (issue #90) (`polyhedron-tests`, `minkowski3-difference-test`, - `scale3D-tests`, `for-nested-tests`, `render-tests`, `mirror-tests`, - `for-tests`, `edge-cases`, `rotate-parameters`, - `scale-mirror2D-3D-tests`, `transform-tests`) fail to parse at all under - ChiselCAD — mostly `for`/transform-argument grammar edge cases not yet - individually triaged (some may double-count v3.8 gaps already listed - above, e.g. multi-variable `for`). - -Only one of the corpus's several subdirectories (`3D/features`) has been -run through this so far — `2D`, `bugs`, `bugs2D`, `misc`, `issues` are -still unexamined. This should become a standing regression suite (rerun -after any geometry-affecting change), not a one-time audit. - -## v3.10 — All 11 issue #90 parse failures fixed; issue #91's oracle-version question resolved - -Environment note: this pass had no access to a live OpenSCAD oracle or a -buildable Manifold (network egress in this session is scoped to -`particlesector/chiselcad` only, so `vcpkg`/other-repo downloads used by the -usual build are blocked). All of the parser/grammar fixes below were instead -verified for real: the language+CSG subsystem (`src/lang`, `src/csg`, and -the GPU/Manifold-free import loaders — the same set `tests/tools/README.md` -already documents as buildable with plain `g++ -lz`, no vcpkg needed) was -compiled and linked against real `glm`/`nlohmann-json`/`zlib`/a -from-source-built Catch2, and the *actual* `tests/test_parser.cpp` / -`tests/test_csg_evaluator.cpp` suites were run — 344 test cases / 1758 -assertions, all passing, including new regression tests for every fix below. -Each of issue #90's 11 originally-listed files was also fed through the real -`Lexer`/`Parser`/`CsgEvaluator` directly (fetched fresh from -`openscad/openscad`'s current corpus) and confirmed to both parse and -fully evaluate without error. What's still unverified against a live -OpenSCAD binary is exact geometric/volumetric correctness (the v3.9-style -`sym_diff_volume` comparison) — that needs the oracle this environment -doesn't have. - -- [x] **Resolved the v3.9 oracle-version question for `linear_extrude(h=...)` - and `segments=`** (issue #91). Checked real OpenSCAD's current `master` - source (`src/core/LinearExtrudeNode.cc`) directly rather than the outdated - `apt` 2021.01 binary: `parameters[{"height", "h"}]` confirms `h` genuinely - is a real, current alias for `height` — ChiselCAD's support for it is - correct, not a ChiselCAD invention, and not something to "fix" back to - 2021.01 behavior. `segments=` is also a genuine, distinct current - parameter (`node->segments`, validated separately from `slices`) — but - unlike `h=`, ChiselCAD doesn't implement it **at all** (no `segments` - param is read anywhere in `MeshEvaluator::evalExtrusion`). Per - `src/geometry/linear_extrude.cc`, real OpenSCAD only acts on it when a - twist or non-uniform scale is active: it subdivides each outline edge - (`Discretizer::splitOutline(outline, twist, scale_x, scale_y, num_slices, - segments)`) before extruding, so a twisted/scaled `linear_extrude` gets a - smoother swept surface instead of faceting along the profile's original - edges. This is a real, previously-unidentified gap and likely explains - some fraction of `linear_extrude-tests.scad`'s remaining ~13.6% error — - filed as a follow-up rather than implemented blind here (see - `tests/tools/README.md`), since matching `splitOutline`'s exact per-edge - subdivision behavior needs bisecting against a live modern OpenSCAD build, - not a guess with no way to check the result. -- [x] **`for()` with a completely empty argument list failed to parse** - (`for-tests.scad`, issue #90 — opens with exactly this on line 2: - `for();`). `Parser::parseFor()` unconditionally expected an `Ident '='` - right after `(`. Real OpenSCAD's `for()` argument list is a generic - module-call argument list, legitimately allowed to be empty. Fixed by - making `ForNode` hold a `std::vector` (see next item) that's - simply empty in this case; `CsgEvaluator::evalFor()` treats an empty - clause list as zero iterations, matching real OpenSCAD's `builtin_for()` - (`src/core/control.cc`'s `if (!inst->arguments.empty())` guard) — it skips - the body entirely rather than running it once vacuously. -- [x] **Multi-variable `for (i=..., j=..., k=...)` wasn't supported at all** - (`for-nested-tests.scad`, `mirror-tests.scad`, `edge-cases.scad` — 3 of - issue #90's 11 files). `ForNode` only ever held one `var`/`range` pair, so - `Parser::parseFor` parsed the first clause then unconditionally expected - `)`, erroring on the comma before a second clause. Restructured `ForNode` - to hold a list of `ForClause`s; `CsgEvaluator::evalFor` now recurses one - clause at a time as nested loops (Cartesian product, outermost = first - clause), evaluating each clause's range expression only once its own turn - comes up — so a later clause can reference an earlier one's already-bound - variable (`for (i=[0:3], j=[0:i])`), matching real OpenSCAD. -- [x] **`rotate(a, v)` — the angle+arbitrary-axis form — wasn't supported at - all**, and neither were `rotate()`/`mirror()` with zero arguments - (`rotate-parameters.scad`, `transform-tests.scad`, - `scale-mirror2D-3D-tests.scad` — 3 of issue #90's 11 files). - `Parser::parseTransform` called `parseExpr()` exactly once for a single - required argument, for all of translate/rotate/scale/mirror/multmatrix — - so `rotate(30, [0,1,0])` (two positional args), `rotate(v=..., a=...)` - (named), and a bare `rotate()`/`mirror()` (zero args) all failed to parse. - Added a dedicated argument-list parser for `rotate` (positional/named `a` - and `v`, matching real OpenSCAD's `Parameters::parse(arguments, {"a", - "v"})`) and made the single-positional-argument case optional for the - rest. `CsgEvaluator::makeMatrix` now implements the angle+axis rotation - itself via `glm::rotate(angle, axis)` (falling back to the Z axis when `v` - isn't given — mathematically identical to the old hard-coded Z-only - scalar case, so existing scalar `rotate(angle)` callers are unaffected), - and gives each transform its own OpenSCAD-documented default for a - missing/non-numeric argument (confirmed against real OpenSCAD's - `TransformNode.cc`: translate → `[0,0,0]`, scale → `[1,1,1]`, mirror → - `[1,0,0]`) instead of the one-size-fits-all `{0,0,0}` (or, for a scalar, - `{0,0,n}`) every transform previously shared. -- [x] **`scale(2)` (a bare scalar) scaled only the Z axis by 2, leaving X/Y - untouched, instead of scaling uniformly** — a real, silent correctness bug - found while fixing the above (the same shared "scalar → Z axis" decoding - rule is correct for `rotate(angle)` but was wrong for `scale`/`translate`/ - `mirror`, which never had their own scalar handling). Confirmed against - real OpenSCAD's `builtin_scale()`: a non-vector numeric argument falls - back to `scalevec.setConstant(num)` — uniform. Fixed for `Scale` - specifically (`transform-tests.scad`'s `scale(0.5) mycyl()` and - `scale3D-tests.scad`'s `scale(2) obj3D()` both exercise this). -- [x] **`union`/`difference`/`intersection`/`hull`/`minkowski` couldn't take - any arguments at all**, even `minkowski(convexity=2)`, a real OpenSCAD - parameter (`minkowski3-difference-test.scad`, issue #90). - `Parser::parseBoolean` called `expect(RParen)` immediately after `(` with - no attempt to parse anything in between. Now parses (and discards) a - generic param list first, same treatment `render()`'s `convexity=` already - got. -- [x] **Module definitions required a brace-block body — a single-statement - body with no braces (e.g. `module obj3D() cylinder(r=1, ...);`, an - extremely common one-liner idiom) failed to parse** (`scale3D-tests.scad`, - `scale-mirror2D-3D-tests.scad`, issue #90). Every *other* body (for/if/ - transform/boolean/...) already accepted both forms via the shared - `parseBody()` helper; module definitions had their own hard-coded - `expect(LBrace)` instead. Now uses `parseBody()` like everything else. -- [x] **A leaf primitive (cube/sphere/.../polygon) followed by a trailing - child statement failed to parse** (`scale-mirror2D-3D-tests.scad`'s - `module obj2D() polygon(...) square(...);` — `square()` is bound as - `polygon()`'s syntactic child, which OpenSCAD's grammar allows for *any* - module instantiation even though a leaf primitive's semantics ignore it). - `Parser::parsePrimitive` never called `parseBody()` at all. Now does, and - discards the result — matching how `union()`/`translate()`/etc. already - tolerate a bare `;` with no child via the very same call. -- [x] **`polygon()`'s positional (unnamed) points list was fundamentally - broken for any point count** — found while root-causing the item above, - not itself one of the original 11 files, but the same corpus file - (`scale-mirror2D-3D-tests.scad`) also exercises it, and it's a - significant, previously-unnoticed correctness bug independent of issue - #90. `parseParamList`'s generic "positional vector" case assumes any - bracketed positional argument is an `[x,y,z]`-shaped triple and - decomposes it into `"x"`/`"y"`/`"z"` keys, capped at 3 elements. For - `polygon([[x,y],...])` this is wrong in two different ways depending on - point count: **exactly 3 points** parsed without error but landed under - `"x"`/`"y"`/`"z"` instead of `"points"` — the key `CsgEvaluator`'s - polygon handling actually reads — so the polygon silently got *zero* - points; **4 or more points** (the overwhelmingly common real case) failed - to parse outright, since the loop's hard-coded 3-element cap left a - dangling `,` where `)`'s matching `]` was expected. Added a dedicated - `Parser::parsePolygonParams` (first positional → `points`, second → - `paths`, matching real OpenSCAD's declared `polygon(points, paths, - convexity)` order; third positional/`convexity` parsed and discarded). - Every unnamed `polygon([...])` call in the wild — likely most real-world - `.scad` files that use `polygon()` at all — was affected by one of these - two failure modes before this fix. - -All of the above have regression tests in `tests/test_parser.cpp` and/or -`tests/test_csg_evaluator.cpp` (tagged `[bugfix]`), verified passing against -the real compiled `Lexer`/`Parser`/`Interpreter`/`CsgEvaluator` (see the -environment note above) — not just read for syntactic plausibility. - -Issue #90's remaining scope: only the `3D/features` corpus subdirectory has -been checked (matching v3.9's existing note); `2D`, `bugs`, `bugs2D`, -`misc`, `issues` are still unexamined and may turn up further parse -failures of their own. - -## v3.11 — issue #86 (non-planar polyhedron() faces) re-investigated and closed - -This environment turned out to have real `apt` network access after all -(unlike the v3.10 pass): `apt-get install openscad` gives a live 2021.01 -oracle, and Manifold v3.5.2 builds from source in ~20s exactly as -`tests/tools/README.md` describes, so this pass could go back to the actual -volumetric `scad_to_stl`/`stl_diff` methodology instead of language-level -checks only. - -- [x] **Issue #86 was misdiagnosed — not a fan-triangulation bug.** - `polyhedron-nonplanar-tests.scad`'s reported `2.94` (real OpenSCAD) vs. - `1.29` (ChiselCAD) mismatch was bisected by isolating each of the file's - three `polyhedron()` calls into its own `.scad` file and diffing each - individually against a live OpenSCAD 2021.01 + Manifold v3.5.2 oracle. - Two of the three (the slightly-non-planar hexahedron and the small - heptagon-pyramid) already matched to floating-point noise (`rel_error` ~ - 1e-7) — meaning the fan-triangulation-from-vertex-0 approach - `CsgEvaluator::evalPolyhedron` uses was never actually wrong for this - corpus file. The third — a real-world truncated icosidodecahedron wrapped - in `scale(0.02) polyhedron(...)` — reproduced the mismatch in isolation - (`volume_a=1.65443 volume_b=0`, confirmed by rebuilding at the exact - commit (`696522c`) the issue was filed against). The actual cause: `scale( - 0.02)` is a **bare scalar**, and at that commit `CsgEvaluator::makeMatrix` - still had the pre-v3.10 `scale()` bug (see v3.10's `for`/`rotate`/ - `scale` fixes) that scaled only the Z axis — turning this polyhedron into - a wildly non-uniform, self-intersecting mesh that Manifold's `Status()` - still reported as `NoError` but whose true volume collapsed to ~0 after - boolean cleanup. That bug was already fixed in commit `18f7a54` (filed - under issue #90, not #86) as an incidental side effect of unrelated - parser work — nobody had re-run the v3.9 volumetric corpus check - afterward to notice it also closed #86. Re-running it now: - `polyhedron-nonplanar-tests.scad`'s full file matches the oracle at - `rel_error=3.5e-6`, and the previously-broken `scale(0.02)` case alone - matches at `rel_error=1.4e-6` — both floating-point noise, no code change - needed to `CsgEvaluator::evalPolyhedron`/`PrimitiveGen` itself. Added - `tests/fixtures/headless/polyhedron_nonplanar_scale.scad` (the exact - reproducing case, straight from the OpenSCAD corpus) and a - `chiselcad_tests` regression pinning its volume to the oracle's - `1.6544281372`, so this doesn't silently regress independently of the - `scale(scalar)` unit test that already covers the actual root cause. -- Takeaway for future corpus-mismatch triage: a mismatch reported against - a file with multiple unrelated constructs (here, both a genuinely - non-planar `polyhedron()` *and* a bare-scalar `scale()`) doesn't - necessarily implicate the construct the file is named after — isolating - each construct individually (as this pass and the original v3.9 harness- - bug triage both did) is worth doing before writing a fix for the wrong - code path. - -## v3.12 — issue #87 (`rotate_extrude()` volume mismatches) fixed - -Built a live oracle for this pass rather than reasoning from source alone: -`apt-get install openscad` (2021.01, matching every prior pass) plus a -standalone Manifold v3.5.2 build (`tests/tools/README.md`'s documented -recipe), then `cmake -DCHISELCAD_BUILD_GUI=OFF -DCMAKE_PREFIX_PATH=...` to -get `chiselcad_core`/`chiselcad_tests` linked against real Manifold, and -`scad_to_stl`/`stl_diff` built straight against the resulting -`libchiselcad_core.a`. Cross-checked every fix below against 2021.01's own -`src/rotateextrude.cc`/`GeometryEvaluator.cc` source (cloned at the -`openscad-2021.01` tag) in addition to the live binary, since the corpus -files are cloned from `openscad/openscad`'s current `master` and exercise -some constructs 2021.01 doesn't actually support the way `master` does (see -v3.9's oracle-version caveat) — bisected each `rotate_extrude(...)` call -from both corpus files into its own `.scad` file and compared against the -live 2021.01 binary throughout, not just the whole-file totals. Found and -fixed five real bugs in `MeshEvaluator::evalExtrusion`'s `rotate_extrude` -branch, all in `src/csg/MeshEvaluator.cpp` (plus one in -`src/csg/CsgEvaluator.cpp::evalExtrusion`): - -- [x] **A profile entirely on the -X side was rejected outright.** The - axis-crossing check flagged *any* point with `x<0`, but real OpenSCAD - (`GeometryEvaluator.cc`'s `rotatePolygon`) only rejects a profile that - actually *straddles* the axis (`min_x<0 && max_x>0`) — a profile fully on - -X is valid, it just revolves mirrored back onto +X. Since Manifold's own - `Revolve()` clips away `x<0` input rather than mirroring it, fixed by - detecting this case, mirroring the profile onto +X (negating each point's - `x` and reversing polygon winding, since negating `x` alone flips it), - revolving normally, then rotating the *result* 180° about Z to reproduce - the original sweep. Confirmed against `rotate_extrude-tests.scad`'s - "Object in negative X" case (`rel_error` 1→0). -- [x] **A negative `angle=` produced inside-out (negative-volume) geometry.** - `Manifold::Revolve()` winds its side faces correctly for a positive sweep - but backwards for a literal negative `revolveDegrees` — confirmed by - isolating `rotate_extrude-angle.scad`'s `angle=-5`/`angle=-45` cases - individually (positive-angle siblings matched immediately; negative ones - came back with `volume_b` exactly negated). Fixed by always sweeping with - `Revolve(polys, segs, abs(angle))` and mirroring the *result* across the - XZ plane (`Mirror({0,1,0})`, Y→-Y) for an originally-negative angle - instead — algebraically `x*cos(-a)=x*cos(a)`, `x*sin(-a)=-x*sin(a)`, i.e. - exactly the +a sweep with Y negated — and unlike `Revolve()`, - `Mirror()`/`Transform()` keep winding correct on their own. -- [x] **Segment count used a fixed proxy radius (10) instead of the - profile's own distance from the axis.** `resolveSegments()` was called - with a hardcoded `10.0` "good enough" placeholder regardless of the - actual profile, rather than real OpenSCAD's - `Calc::get_fragments_from_r(max_x-min_x, ...)`. Note this `max_x-min_x` - is **not** the profile's own true width: 2021.01's `rotatePolygon()` - (`GeometryEvaluator.cc`) seeds `min_x`/`max_x` at `0`, not the profile's - real extremes, so a profile that never touches the axis gets the - axis-to-far-edge distance, not its own span — a review comment on this - PR initially (reasonably) flagged that 0-seeding as a bug and proposed - seeding at ±infinity instead, which looked more "correct" but is a - regression against the actual oracle: re-verified against the live - 2021.01 binary (`rotate_extrude(a=-45)` on a profile spanning - `x=[16,26]`, a width of 10 measured from `16`, but `26` measured from the - axis) — 2021.01 uses 24 segments, matching the axis-seeded formula - exactly, not the 16 a true-extent formula would give. Pinned with - `rotate_extrude_radius_near.scad`/`_far.scad` (same-width profiles at - different axis distances; a true-extent version of this code would give - them equal segment counts, the real fix gives `far` roughly double - `near`'s). -- [x] **A partial sweep was tessellated at full-circle density.** Real - OpenSCAD scales the full-circle fragment count down for a partial angle - (`fragments = floor(get_fragments_from_r(...) * |angle|/360)`, minimum 1) - rather than using that many segments across the whole (shorter) arc — - `MeshEvaluator` was passing the full-circle count straight through, - over-tessellating any `angle<360` sweep relative to real OpenSCAD's - actual, coarser output. Fixed by applying the same floor-and-scale - formula before calling `Revolve()` — with one deviation from the literal - formula: floored to a minimum of **3**, not 1. `Manifold::Revolve()` only - honors an explicit `circularSegments` when it's `>2`; passing 1 or 2 - silently falls back to Manifold's own internal auto-quality segment count - (unrelated to our angle/profile), which for a small enough angle produced - *zero* triangles outright — confirmed via `rotate_extrude-angle.scad`'s - `angle=5`/`angle=-5` "render a single segment" cases, which the - formula's literal `fragments=1` maps to. -- [x] **`angle=`'s `NaN`/`Infinity` handling was lost before `MeshEvaluator` - ever saw it.** Real OpenSCAD treats a non-finite `angle=` as "not given" - (full 360° circle, confirmed against a live 2021.01 run of - `rotate_extrude-angle.scad`'s `0/0`/`1/0`/`-1/0` cases). `MeshEvaluator` - already special-cased this correctly, but by the time its value arrived - there `CsgEvaluator`'s generic `evalNumber()` had already collapsed any - non-finite number to `0.0` — indistinguishable from a *literal* `angle=0` - (a real, distinct "no geometry at all" case straight from 2021.01's - `rotatePolygon()`: `if (angle==0) return nullptr`). Fixed by special- - casing `"angle"` in `CsgEvaluator::evalExtrusion` (alongside the existing - `"scale"`/`"center"` special cases) to resolve non-finite values to 360 - before that collapse can happen, rather than losing the distinction. - Regression test at the IR level (`CsgEval:rotate_extrude angle keeps - NaN/Infinity distinct from a literal 0`, doesn't need Manifold) plus three - Manifold-level ones (`[v87][bugfix]` in `test_headless_build.cpp`, - volumes pinned against Pappus's centroid theorem for the mirror/negative- - angle cases). - -Net effect on the two originally-reported files: `rotate_extrude-tests.scad` -27%→13.0% (now an exact volume match — `sym_diff_volume` is entirely the -`$fn=1`/3-segment "minimal fragments" case's residual rotational-phase -misalignment, an extreme, deliberately-adversarial edge case not chased -further this pass) and `rotate_extrude-angle.scad` 71%→16.7% (every -"real" partial-angle/negative-angle/edge-case construct in the file now -matches to floating-point noise in isolation; the remainder is -`rotate_extrude(45) face(10)` — a positional first argument, which current -`master`'s test corpus intends as `angle` but 2021.01 doesn't support -positionally at all, instead treating it as the deprecated `file=` DXF- -import parameter and silently discarding the children when that "file" -isn't found. Left unfixed per v3.9's own oracle-version caution: matching -either interpretation (2021.01's DXF quirk, or `master`'s positional -`angle`) without a newer real OpenSCAD build to check against would just be -guessing which oracle to match). - -Two phase-alignment leads investigated and *not* pursued further, both -because a genuine fix requires matching Manifold's `Revolve()` ring-start -convention to OpenSCAD's own (`-90°`/`+90°`-offset, direction-dependent) -one exactly, and an incorrect guess measurably regressed already-passing -cases: -- A uniform `-90°` post-rotation for full-circle sweeps (reasoning that - `rotatePolygon`'s legacy `-90°`-start convention should apply) fixed - the deliberately-coarse `$fn=1` case somewhat (`rel_error` 1.33→1.14) but - *broke* every previously-exact full-circle case (`rotate_extrude-tests` - case 1, `rotate_extrude-touch-vertex`/`-touch-edge`, all 0→~0.008) — - reverted. Fine tessellations are insensitive to phase (an N-gon - approximation of a full circle converges to the same smooth solid - regardless of starting angle as N→∞), which is why this was invisible - until measured directly against the coarse case. -- Empirically measuring the actual ring angles used (comparing STL vertex - `atan2` positions between the two engines for matching `(radius, height)` - profile points) found a consistent offset for the `$fn=1` case, but not - one matching any clean closed-form guess tried against 2021.01's own - ring-angle formula — left as a known, low-priority gap rather than - guessed at further. - -## v3.13 — issue #89 (`assign-tests`/`intersection_for-tests` zero geometry) root-caused and fixed - -Followed issue #89's own suggested triage: rather than assuming a -`MeshEvaluator`/`PrimitiveGen` bug, checked whether ChiselCAD's parser -recognized `assign()`/`intersection_for()` as builtins at all. It didn't — -neither name appeared anywhere in `src/lang/Parser.cpp`'s `kBuiltinNodeNames` -table (the mechanism real builtin module names are recognized through, since -they aren't reserved lexer keywords — see the table's own comment). A -statement-position call to either name fell through to `parseModuleCall()`, -which looks up a *user-defined* module of that name; since neither file -defines one, every top-level statement in both files resolved to nothing, -explaining "zero valid combined geometry" for every root in both files (not -just one bad root among otherwise-valid ones, matching the issue's own -observation that these two are unlike the harness-bug files). - -- [x] **`assign(x = ..., ...) { ... }` — the deprecated statement form of - `let()` — wasn't recognized as a builtin at all.** Real OpenSCAD's - `assign()` predates `let()` and is semantically identical: block-scoped - bindings visible only to its children. `Parser::parseLetNode()` already - implements exactly this grammar/semantics and doesn't care which keyword - spelling it was invoked through (it just `advance()`s past whatever token - is at the current position), so `assign` was added to `kBuiltinNodeNames` - and routed straight to the existing `parseLetNode()` — no new AST node or - evaluator code needed. -- [x] **`intersection_for(i = ...) { ... }` — the `for()` variant that - intersects its iterations instead of unioning them — wasn't recognized as - a builtin at all.** Added `intersection_for` to `kBuiltinNodeNames` and - gave `Parser::parseFor()` a new `isIntersection` parameter (default - `false`, so every existing `for()` call site is unaffected) that's - threaded onto a new `ForNode::isIntersection` field. `CsgEvaluator::evalFor` - now combines the loop's flattened, iterations-worth-of-children list with - `CsgBoolean::Op::Intersection` instead of `Op::Union` when the flag is set - — the only behavioral difference from a plain `for()` loop, matching real - OpenSCAD's documented semantics ("intersects the children rather than - doing a union"). - -Verified for real: this environment's network egress is scoped to -`particlesector/chiselcad` only (no `vcpkg`/Manifold download), so — same -approach as v3.10 — compiled the actual `src/lang`/`src/csg` sources -(GPU/Manifold-free, per `tests/tools/README.md`) against real `glm` and a -from-source-built Catch2 (`extras/catch_amalgamated.*` from the upstream -Catch2 repo), then ran the real `tests/test_parser.cpp`/ -`tests/test_csg_evaluator.cpp`/`tests/test_lexer.cpp`/ -`tests/test_interpreter.cpp`/`tests/test_source_loader.cpp` suites: 580 test -cases / 3266 assertions, all passing, including 7 new regression tests for -both fixes (parser-shape tests for each, plus evaluator tests confirming -`intersection_for`'s combine-with-Intersection behavior, its -empty-range/single-iteration edge cases, and `assign()`'s let()-equivalent -scoping). Also hand-fed OpenSCAD-corpus-shaped snippets of both constructs -(loop-and-rotate `intersection_for`, nested `assign()` shadowing an outer -variable) through the real `CsgEvaluator` directly and confirmed each now -produces non-empty geometry where it previously produced none. Exact -volumetric correctness against a live OpenSCAD oracle (the v3.9-style -`sym_diff_volume` check) is still unverified — this pass had no oracle -available — but the root cause (both constructs being completely -unrecognized, not a `MeshEvaluator`/`PrimitiveGen` tessellation bug) is -confirmed, closing the specific question issue #89 asked. - -## v3.14 — issue #88 (seven untriaged corpus mismatches) individually root-caused and fixed - -Rebuilt the full v3.9 volumetric-comparison toolchain (real OpenSCAD 2021.01 -oracle via `apt`, Manifold v3.5.2 from source, `chiselcad_core` headless via -`cmake -DCHISELCAD_BUILD_GUI=OFF`, `scad_to_stl`/`stl_diff` linked against -the real compiled mesh pipeline — see `tests/tools/README.md`) and bisected -each of issue #88's seven files individually, the same technique the -already-fixed `sphere-tests.scad`/`linear_extrude-tests.scad`/ -`rotate_extrude-*` cases used. All seven are now real bugs found and fixed, -not tessellation noise — each file's `sym_diff_volume` is now at -floating-point-noise level against the live oracle: - -- [x] **`ifelse-tests.scad` (175% → exact):** not an `if`/`else` bug at all - — `cube(size, center)`'s *positional* `center` argument (`cube(2, true)`) - was silently dropped. `Parser::parseParamList()` only recognized - `center=true`/`center=false` in *named* form; a bare positional `true`/ - `false` token fell into the generic `_posN` bucket, which no - `CsgEvaluator` case reads, so the cube came out uncentered. Every branch - in the corpus file happened to use this exact positional form, which is - why the volumes still summed to the same total (12 same-sized cubes) while - the symmetric difference was large (each one shifted). Fixed by checking - for a bare `true`/`false` token before falling through to the positional- - expression case. -- [x] **`primitive-inf-tests.scad` (83% → exact):** `Interpreter:: - evalNumber()`'s blanket "fold non-finite (inf/nan) to 0.0" — added - defensively, per its own comment, because "those aren't safe to hand to - Manifold" — silently turned `cylinder(r2=1/0)` into a *valid* `r2=0` cone - instead of the empty geometry real OpenSCAD renders for any non-finite - primitive dimension. Fixed at the correct layer instead of patching around - it: added a new `evalNumberPreserveNonFinite()` for cube/sphere/cylinder's - parameter resolution specifically, paired with explicit `std::isfinite()` - checks in `PrimitiveGen::generate()` that return an empty `Manifold` for a - non-finite dimension — matching real OpenSCAD's own behavior instead of - relying on Manifold's inconsistent handling of infinite inputs (some - silently "succeeded" with garbage, as `r2=inf` did; others correctly - flagged `InvalidConstruction`, which `MeshEvaluator::checkStatus()` logged - but still passed the garbage mesh through unchanged — also fixed, it now - returns an empty `Manifold` on any error status). A related, separate bug - in the same file: a non-finite `$fn` (`cylinder($fn=1/0)`) reached - `PrimitiveGen::resolveSegments()`, whose `static_cast(std::round(fn))` - is undefined behavior for an out-of-range double — empirically `INT_MAX` - on this platform, not the small/negative value that would make an - unguarded `std::max(3, ...)` clamp harmlessly — inflating the segment - count instead of clamping to real OpenSCAD's own minimum of 3. Fixed with - an explicit `!std::isfinite(fn)` check ahead of the cast. -- [x] **`surface-simple.scad` (82% → exact):** two compounding bugs in - `SurfaceLoader.cpp`, both confirmed against a live OpenSCAD export byte- - for-byte: (1) the solid's base was clamped to `min(0.0, minHeight)` - instead of real OpenSCAD's actual rule, unconditionally `minHeight - 1` - (`SurfaceNode::createGeometry()`: `min_val = data.min_value() - 1`) — a - surface with an all-non-negative heightmap (like this file's `[0,3]` - span) got a flat base at `z=0` instead of `z=-1`, losing exactly the - "one unit of skirt" real OpenSCAD always adds; (2) `center=` was also - re-centering Z, but real OpenSCAD's `center` only ever offsets X/Y - (`ox`/`oy` in the same function) — Z is left as the raw height data - regardless. A third, latent bug surfaced while fixing the first two: the - row-to-Y mapping was flipped (row 0 was treated as *max* Y) relative to - real OpenSCAD's actual convention (row 0 -> *min* Y, confirmed via an - asymmetric test grid) — harmless for this file's specific 2x2 grid - contents by coincidence of the volume check, but a real orientation bug - fixed alongside the other two (with a matching flip added to the PNG - loader, which had been relying on the wrong row mapping to *accidentally* - land in the right place before this fix, and needed its own explicit - top-row-is-max-Y flip to stay correct afterward). Triangle winding for the - top/bottom faces and the boundary-wall traversal direction both needed - re-deriving from scratch to stay outward-facing under the corrected Y - mapping (the pre-fix code was exporting a negative-volume mesh once the - row flip alone was corrected). -- [x] **`module-recursion.scad` (31% → exact):** `rotate([x,y,z])` - composed its three axis rotations in the wrong order. Real OpenSCAD - rotates about X, then Y, then Z (a point transforms as `Rz*(Ry*(Rx*p))`, - X innermost) — confirmed by isolating `rotate([40,0,0])` and - `rotate([0,0,180])` (each matched real OpenSCAD exactly alone) against - `rotate([40,0,180])` (badly mismatched combined), which only a - composition-order bug explains. `CsgEvaluator::makeMatrix()`'s three - `glm::rotate(m, angle, axis)` post-multiply calls were issued in - `rx, ry, rz` source order, which builds `Rx*Ry*Rz` (Z innermost) — the - reverse of OpenSCAD's own order, and of this same function's own - pre-existing doc comment ("Rotation order: Z first, then Y, then X - (OpenSCAD convention)"), which the code had never actually matched. - Fixed by issuing the calls in the reverse order (`rz, ry, rx`) so the - composed matrix comes out as `Rz*Ry*Rx`, matching both real OpenSCAD and - the function's own documented intent. -- [x] **`resize-tests.scad` (1.5% → exact):** `MeshEvaluator::evalResize()` - had generalized real OpenSCAD's `GeometryUtils::getResizeTransform()` - instead of porting it verbatim, and the generalization was wrong in two - ways: (1) an axis was "explicitly resized" whenever its `newsize` was - non-zero, not — as real OpenSCAD requires — *strictly positive*, so - `resize([-5,0,0])` scaled that axis by `|-5|` instead of leaving it - unresized (real OpenSCAD: any negative `newsize` component makes the - *whole* `resize()` call a no-op, confirmed live); (2) `auto=`'s broadcast - scale factor was computed as the largest *scale factor* among the - explicitly-resized axes, not — as real OpenSCAD computes it — the scale - of whichever axis has the largest *raw* `newsize` value (which may itself - default to 1.0 if that axis isn't itself `>0`); the two only coincide - when extents happen to be uniform across axes. Rewrote the scale - computation as a direct, verbatim port of the real algorithm instead of a - reimplementation. -- [x] **`intersection-tests.scad` (6.4% → exact):** a child statement that - itself produces no geometry (a bare `render();`/`linear_extrude();`/etc. - with no body of its own) evaluates to `nullptr` in `CsgEvaluator`; that - `nullptr` was silently dropped from a boolean op's child list — correct - for `union()`/`difference()`/`hull()` (dropping an empty operand changes - nothing), but wrong for `intersection()` specifically, where "intersected - with nothing" must make the *whole* intersection empty (real OpenSCAD: - `intersection() { cube(4, center=true); render(); }` renders nothing, not - the bare cube). Fixed by tracking whether any `intersection()` child came - back empty and returning `nullptr` for the whole node if so — but only - for genuinely empty *geometry* children, not statements that were never - geometry to begin with (`echo()`/`assert()`, local `x=expr;` assignments, - local module/function defs, or a `*`-disabled child all also evaluate to - `nullptr`, and the corpus file's own comment confirms real OpenSCAD - simply ignores a non-geometry `echo()` mixed into an `intersection()` - rather than nullifying it) — a new `isNonGeometricStatement()` helper - distinguishes the two cases by inspecting the child AST node directly. A - `%`-backgrounded child (excluded from the main CSG tree on purpose, also - a `nullptr` return) needed the same carve-out — caught by re-running the - *entire* `3D/features` corpus (not just this issue's seven files) after - this specific fix, which turned up a real regression this fix introduced - in `background-modifier2.scad` (`intersection() { %sphere(10); cube(15, - center=true); } ` must still intersect down to the cube alone, not become - empty just because the backgrounded sphere isn't part of the main tree) - — fixed by exempting `ModBackground` alongside `ModDisable` in the same - check. -- [x] **`cylinder-tests.scad` (13% → exact):** when both `r` and `r1` (or - `r` and `r2`) were given — real OpenSCAD warns "Cylinder parameters - ambiguous" but still renders a frustum, not nothing — `PrimitiveGen` let - `r` unconditionally overwrite *both* `r1` and `r2` whenever `r` was given, - ignoring an explicitly-given `r1`/`r2` entirely. Real OpenSCAD instead - lets the explicit `r1`/`r2` win for its own slot and only uses `r` to - fill in the *other*, unspecified slot (confirmed live: `cylinder(h=5, - r=5, r1=0, center=true)` renders the same frustum as `cylinder(h=5, r1=5, - r2=0)`, i.e. `r1` stays 0 — not a uniform `r=5` cylinder). Fixed by - checking for an explicit `r1`/`r2` key in the leaf's params before - falling back to `r`. - -All fixes verified against the real `chiselcad_tests` suite (3679 -assertions, 664 test cases, all passing, including 9 new regression tests: -a parser-level test for positional `cube`/`square` centering, two -mesh-level tests for non-finite cylinder dimensions and `$fn`, a CsgEvaluator-level -test for `rotate([x,y,z])`'s composition order, two mesh-level tests for -`resize()`'s negative-`newsize` no-op behavior, two CsgEvaluator-level tests -for `intersection()`'s empty-operand-vs-echo() distinction, and a mesh-level -test for the ambiguous `r`/`r1` precedence) and re-verified against the live -OpenSCAD oracle across the *entire* `3D/features` corpus subdirectory (72 -of its 73 files — the remaining one, `linear_extrude-parameter-tests.scad`, -crashes ChiselCAD's mesh pipeline with a heap-corruption `free(): invalid -next size (fast)` before and after this pass alike, confirmed via a clean -worktree build of the pre-this-pass commit; a real bug, but pre-existing -and unrelated to issue #88 — filed as issue #105 for separate triage), not -just the seven files this issue named — this is what caught the -`%`-modifier regression above before it landed. Every file that already matched real OpenSCAD -before this pass still does; the small number that already had a real, -different remaining gap (`child-modifier`, `for-nested-tests`, `for-tests`, -`linear_extrude-scale-zero-tests`, `linear_extrude_invisible-tests`, -`minkowski3-erosion`, `rotate_extrude-angle`, `rotate_extrude-tests`, -`scale3D-tests` — none named by issue #88) are unchanged or, for two -(`rotate-parameters`, `transform-tests` — also not issue #88's), improved -by the `rotate([x,y,z])` composition-order fix above; none regressed. +Core CSG engine, full OpenSCAD language support, and corpus-validated +correctness (echo-output and volumetric comparison against real OpenSCAD) +are complete — see git history / closed issues for that work. Remaining +known language/testing gaps are tracked as GitHub issues (labeled +`help wanted`) rather than listed here. ## v4 — Tooling & Visual Quality diff --git a/vcpkg.json b/vcpkg.json index b57682a..40e9b51 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -1,6 +1,6 @@ { "name": "chiselcad", - "version": "0.1.0", + "version": "0.2.0", "description": "A fast, precise, GPU-accelerated CSG modeler with OpenSCAD-compatible syntax", "dependencies": [ "glm",