Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1094,3 +1094,84 @@ than the current pause point). Ported identically (same qualifier syntax, same e
profiling/debugging's own bookkeeping resets happen at the right point too.
- `tools/cli/main.cpp` — a 3-line wrapper: builds `args` from `argv`, calls `runCli(args)` with the
real `std::cin`/`std::cout`/`std::cerr`, returns its exit code.

## `render()` in expression position

`render()` has two jobs, decided by where it is written:

```openscad
render() cube(1); // STATEMENT -- draws, as always
obj = render() { cube(1); }; // EXPRESSION -- measures, draws NOTHING
```

The expression form builds its children's geometry, measures it, extracts the
mesh, and **discards the geometry**. It is the only way OpenSCAD can inspect its
own geometry — volume, surface area, genus, bounding box, and the mesh itself.

```openscad
obj = render() difference() { cube(100); sphere(20); };
echo(obj.volume, obj.genus, obj.boundingbox);
polyhedron(obj); // straight back in
```

Keys — ordered, and the order is part of the API (`ValueObject` is an ordered
vector and `oscEqual` is order-sensitive):

| dim | keys |
|---|---|
| 3 | `vertices`, `faces`, `volume`, `area`, `genus`, `boundingbox`, `dim`, `vnf` |
| 2 | `vertices`, `paths`, `area`, `perimeter`, `boundingbox`, `dim` |
| 0 | the 3D key set, all zero, `boundingbox = undef` |

`vertices`/`faces` are VNF-shaped — `[x,y,z]` points, 0-based N-gon index lists,
**clockwise seen from outside** — so they feed `polyhedron()` and BOSL2 unchanged.
`vnf` is the `[vertices, faces]` 2-list BOSL2 functions actually take.
`polyhedron()` and `polygon()` also accept the object directly, and will accept
*any* object carrying the right keys, not just one `render()` produced.

### Things that will bite

- **`render` is a reserved keyword.** It cannot be a variable, module, function,
argument, or member name. Required: LALR(1) cannot otherwise tell `render(` in
expression position from a function call, and bison's shift-over-reduce would
silently turn every function call in an expression into a module instantiation.
- **`obj = render() cube(1);` does not parse.** A bare call's `child_statement`
swallows the `;`, leaving the assignment unterminated. Use
`render() { cube(1); }` or a form ending in `}`. Inherent to OpenSCAD's grammar.
- **`genus` is Manifold's, for the whole result.** A cube with a sealed internal
cavity reports `-1`, not `0` — its boundary has two components. Correct, but
surprising.
- **Nothing is drawn from any context** — top level, module body, function body,
list comprehension, ternary. That is what preserves function purity, and why no
context needs a special case.

### Implementation notes

`Evaluator::measureCsgSubtree` (`src/measure_geometry.cpp`) is the *only*
implementation of "CSG subtree → `object()`"; both engines call it — the
interpreter from `evalRenderExpr`, the VM from `Op::PopBuiltinWrap`'s
`Kind::Measure` branch.

`Evaluator::measuring_` is set for the whole generate. It suppresses the four
writes that exist solely to describe *drawn* geometry — `idToNode`/`idToColor` in
`tagGenerated` and `tagDisplayOnly`, the `restampCachedIds` call on a cache hit,
and `cacheProducer_` — because those tables are cleared once per pass, so a leak
is permanent and surfaces later as wrong click-to-source. It also suppresses
`checkDebug`, which would otherwise inject stops at the paused statement's own
`callStack_` depth. The **geometry cache stays on**: `cacheKey` is
content-addressed, so an entry a measurement stores is genuinely reusable by the
real render.

Two details in the mesh conversion are load-bearing and silent when wrong: the
winding is **reversed** on the way out (Manifold's `triVerts` is CCW), and
vertices are welded by exact position (Manifold splits property-vertices, so
without the weld the round-tripped `polyhedron()` is an *open* mesh). A reversed
mesh still builds — `Status() == NoError` — but comes back with **negative
volume**, so tests assert a positive volume and must never use `abs()`.

On the VM side, `Kind::Measure` captures `(name, slot)` for every visible local
at compile time and republishes them into the children's `EvalContext` at Push.
A compiled function keeps its parameters in frame slots, which no `EvalContext`
can see, and the children are statement opcodes that resolve names through the
context — without this, `function f(w) = render() { cube(w); }.volume;` finds no
`w` and silently measures nothing.
15 changes: 15 additions & 0 deletions include/openscad_cpp_evaluator/bytecode.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -892,6 +892,13 @@ struct CompiledChunk {
Color,
Modifier,
Passthrough, // hull()/minkowski()/render() -- empty params, no compute function needed
// render() in EXPRESSION position. The only Kind whose Pop
// does NOT append a CSGNode to the parent's accumulator: it
// discards the subtree and pushes the measurement object() onto
// the operand stack instead. Its `node` is a RenderExpression,
// not a ModularCall -- warnUnexpectedBuiltinArgs already
// self-guards on that (registry.cpp).
Measure,
LinearExtrude,
RotateExtrude,
Projection,
Expand All @@ -901,6 +908,14 @@ struct CompiledChunk {
Kind kind;
std::string tagName; // "translate"/"rotate"/.../"color"/"highlight"/"background"/"show_only"/"hull"/...
const oscad::ASTNode* node = nullptr;
// Kind::Measure only. (name, slot) for every local visible at this
// site, so Op::PushBuiltinWrap can publish them into the children's
// EvalContext. A render() expression's children are STATEMENT
// opcodes and resolve names through the context, but a compiled
// FUNCTION keeps its parameters and lets in frame slots instead --
// without this, `function f(w) = render() { cube(w); }.volume;`
// cannot see `w` and silently builds nothing.
std::vector<std::pair<std::string, int>> capturedLocals;
};

// One Op::PushCsgWrap/PopCsgWrap site pair -- see those ops' own doc
Expand Down
12 changes: 12 additions & 0 deletions include/openscad_cpp_evaluator/bytecode_vm.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,18 @@ struct PendingBuiltinWrap {
// other kind computes its params at Push time and leaves this default-
// empty.
CallArgs deferredArgs;

// ev.measuring_ as it stood immediately BEFORE this bracket opened.
// Recorded for EVERY kind, not just Measure, so the exception teardown
// can restore from front() without inspecting kinds -- a non-Measure
// entry simply records and restores the same value.
bool savedMeasuring = false;
// f.stack.size() and ev.treeStack_.size() at Push time. Kind::Measure's
// Pop asserts both: it is the first bracket that runs STATEMENT opcodes
// with a non-empty operand stack beneath it, and nothing structurally
// enforces that those statements are operand-stack-neutral.
size_t stackDepthAtPush = 0;
size_t treeStackDepthAtPush = 0;
};

// One still-open Op::PushCsgWrap bracket's own state -- see that op's own
Expand Down
61 changes: 61 additions & 0 deletions include/openscad_cpp_evaluator/evaluator.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,26 @@ class Evaluator {
// `_generate_partial_render`.
std::vector<ColoredBody> generatePartialTree();

// Consumes a resolved CSG subtree: generates it, applies the top-level
// dimension rules, folds the surviving bodies into one, and returns the
// render()-expression measurement object(). The subtree is destroyed on
// return -- NOTHING is drawn.
//
// Callers must already have measuring_ set (see its doc comment): the
// generate inside would otherwise write provenance for geometry that is
// about to be discarded. The flag is the caller's to own because the VM
// holds it across two separate op handlers.
//
// THE only implementation of "CSG subtree -> object()". Both engines call
// exactly this -- the interpreter from evalRenderExpr, the VM from
// Op::PopBuiltinWrap's Kind::Measure branch.
Value measureCsgSubtree(std::vector<std::unique_ptr<CSGNode>> sub, const oscad::ASTNode& node);

// evalExpr's arm for `render() { ... }` in expression position. Pushes
// its own treeStack_ frame, resolves the children into it, then hands
// the frame to measureCsgSubtree and discards it.
Value evalRenderExpr(const oscad::RenderExpression& node, EvalContext& ctx);

// Provenance tables populated by tagGenerated() during generate --
// originalID -> the AST node that produced it / that node's own
// resolved color. Public, read by a caller (CLI, WYSIWYG picking) after
Expand Down Expand Up @@ -1084,6 +1104,47 @@ class Evaluator {
// whether evalExprMaybeCompiled's cache is safe to touch right now.
bool inResolvePass_ = false;

// True only while evalRenderExpr (or the VM's Kind::Measure bracket) is
// building geometry for a render() EXPRESSION -- geometry that is
// measured and then thrown away, never drawn.
//
// It suppresses everything that exists solely to describe DRAWN
// geometry, and nothing else:
// - idToNode/idToColor writes in tagGenerated/tagDisplayOnly, and the
// restampCachedIds call on a cache hit (which writes them too).
// Those maps are cleared once per pass, at resolveTreeImpl entry, so
// without this a discarded measurement would leave permanent
// provenance entries for triangles no render ever draws.
// - cacheProducer_ writes, which decide click-to-source attribution
// for a LATER cache hit. A discarded node must never become the
// recorded producer of geometry the real render then reuses.
// - checkDebug, so resolving a measured subtree does not inject
// debug stops at the paused statement's own callStack_ depth and
// corrupt lastStmtByDepth_'s duplicate-collapse state.
//
// The ManifoldCache itself deliberately stays ON: cacheKey is a pure
// function of kind/params/children, so an entry stored by a measurement
// is genuinely reusable by the real render (and vice versa) -- that is
// the point. Only the producer attribution above needed suppressing.
//
// Save/restore rather than a plain set/clear, so nesting is correct for
// free. The interpreter uses an RAII guard; the VM stores the saved
// value in its bracket (PendingBuiltinWrap::savedMeasuring).
public:
// Public for the same reason treeStack_ is: bytecode_vm.cpp is a separate
// translation unit and its Kind::Measure bracket owns this flag's
// lifetime across two separate op handlers (Push sets it, Pop restores
// it), which no RAII guard can span.
bool measuring_ = false;

// Test-only accessors for the invariants above -- a leaked measuring_ or
// an unbalanced treeStack_ is otherwise silent until something much
// later goes mysteriously wrong.
bool measuringForTesting() const { return measuring_; }
size_t treeStackDepthForTesting() const { return treeStack_.size(); }

private:

public:
// lookupOrCompileChunk/lookupCompiledLiteralChunk: public (not just
// private helpers of evalUserFunction*/evalFunctionLiteral* anymore)
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "scikit_build_core.build"

[project]
name = "openscad_cpp_evaluator"
version = "0.37.0"
version = "0.38.0"
description = "C++ OpenSCAD evaluator with Python bindings"
readme = "README.md"
requires-python = ">=3.12"
Expand Down
1 change: 1 addition & 0 deletions src/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ add_library(openscad_cpp_evaluator STATIC
css_colors.cpp
csg_resolve.cpp
csg_generate.cpp
measure_geometry.cpp
manifold_cache.cpp
debug_profile.cpp
eval_use.cpp
Expand Down
18 changes: 18 additions & 0 deletions src/builtins/builtins.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,24 @@ struct RoleSplit {
};
RoleSplit splitByRole(const std::vector<ColoredBody>& bodies);

// polyhedron(obj) / polygon(obj): a render()-expression result -- or any
// object() carrying the same keys -- may stand in for the two separate list
// arguments, so the round trip is `polyhedron(render() { ... })` rather than
// `polyhedron(obj.vertices, obj.faces)`. Returns nullptr when `v` is not an
// object or has no such key.
inline const Value* objectFieldOrNull(const Value& v, const std::string& key) {
const ObjectPtr* o = std::get_if<ObjectPtr>(&v);
if (!o || !*o) return nullptr;
for (const std::pair<std::string, Value>& kv : (*o)->items) {
if (kv.first == key) return &kv.second;
}
return nullptr;
}
inline bool isObject(const Value& v) {
const ObjectPtr* o = std::get_if<ObjectPtr>(&v);
return o && *o;
}

// manifold::ToString(Manifold::Error) only exists under MANIFOLD_DEBUG --
// not enabled in this build -- so this mirrors it. Shared by import.cpp and
// primitives_3d.cpp, both of which report why a mesh wouldn't build.
Expand Down
17 changes: 17 additions & 0 deletions src/builtins/primitives_2d.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,23 @@ CSGParams resolve2d(Evaluator& ev, const oscad::ModularCall& node, EvalContext&
// polygon
Value pointsArg = getArg(args, 0, "points", Value{});
Value pathsArg = getArg(args, 1, "paths", Value{});

// polygon(obj) -- the 2D counterpart of polyhedron(obj): an object()
// with `vertices` (and optionally `paths`) stands in for the two lists.
if (isObject(pointsArg)) {
const Value* verts = objectFieldOrNull(pointsArg, "vertices");
if (!verts) verts = objectFieldOrNull(pointsArg, "points");
if (!verts) ev.error("polygon: object has no 'vertices' (or 'points') key", node);
const Value* paths = objectFieldOrNull(pointsArg, "paths");
// Copy before assigning -- both borrow into pointsArg's ObjectPtr.
Value newPoints = *verts;
Value newPaths = paths ? *paths : Value{};
pointsArg = std::move(newPoints);
// A missing `paths` stays undef, which already means "pts is one
// single contour" below -- no error, unlike polyhedron's `faces`.
pathsArg = std::move(newPaths);
}

const ListPtr* pointsList = std::get_if<ListPtr>(&pointsArg);
if (!pointsList || !*pointsList) {
ev.error("polygon: 'points' is required", node);
Expand Down
19 changes: 19 additions & 0 deletions src/builtins/primitives_3d.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -610,6 +610,25 @@ CSGParams resolvePolyhedron(Evaluator& ev, const oscad::ModularCall& node, EvalC
Value facesArg = getArg(args, 1, "faces", Value{});
if (isUndef(facesArg)) facesArg = getArg(args, 1, "triangles", Value{}); // legacy alias

// polyhedron(obj) -- an object() with `vertices` and `faces` stands in
// for the two lists, so a render() expression round-trips in one call.
// Works for ANY such object, not just one this evaluator produced, so a
// script can build or transform its own. `points` is accepted as an
// alias for `vertices` since that is this argument's own name.
if (isObject(pointsArg)) {
const Value* verts = objectFieldOrNull(pointsArg, "vertices");
if (!verts) verts = objectFieldOrNull(pointsArg, "points");
const Value* faces = objectFieldOrNull(pointsArg, "faces");
if (!verts) ev.error("polyhedron: object has no 'vertices' (or 'points') key", node);
if (!faces) ev.error("polyhedron: object has no 'faces' key", node);
// Copy BEFORE assigning: both pointers borrow into pointsArg's own
// ObjectPtr, which the first assignment would release.
Value newPoints = *verts;
Value newFaces = *faces;
pointsArg = std::move(newPoints);
facesArg = std::move(newFaces);
}

const ListPtr* pointsList = std::get_if<ListPtr>(&pointsArg);
const ListPtr* facesList = std::get_if<ListPtr>(&facesArg);
if (!pointsList || !*pointsList || !facesList || !*facesList) {
Expand Down
Loading