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
29 changes: 27 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -866,8 +866,33 @@ grep for `ponytail:`.
And the invariant behind both: **`triColors` must index the body's CURRENT mesh**. The renderer
masks its vertex arrays with it, so a stale length is an `IndexError` and a blank viewport rather
than a wrong colour. Anything that re-meshes clears it — `simplify()`, `minkowski_difference()`,
and the ID-retag rebuild when the triangle count moves — and `bodyToDict` drops a mismatched array
as a backstop for whichever one gets missed next.
and `bodyToDict` drops a mismatched array as a backstop for whichever one gets missed next. The
ID-retag rebuild on a cache hit (`restampCachedIds`) used to clear it too when the triangle count
moved; it now **rebuilds it from the runs**, which survive the rebuild, and **re-records every
run's colour against the fresh IDs** from the cached body itself (its `color`, or per run what its
`triColors` carry) — this render's `idToColor` has never heard of an ID an earlier render minted,
and without it every cached operand looked uncoloured to the next merge: an edited three-colour
`difference()` lost the unchanged subtrahend's colour on the first re-render and went flat on the
next (BelfrySCAD #412).
**An uncoloured subtrahend paints the faces it exposes the cut green** (`kCutFaceColor`,
`colored_body.hpp`: #9DCB51, the reference's CGAL back-face green, which its preview shows and its
colour-preserving render keeps — measured in a 3MF export from 2026.02.01), recorded against the
tool's runs before the merge, and the merge is forced to look even when every operand's own colour
agrees. It used to fall to the default geometry colour, so a cut through an uncoloured part could
not be seen as a cut. A coloured tool still paints its cut with its own colour, as the reference
does.
**`Evaluator::keepMinuendColor`** (bindings: `Evaluator(keep_minuend_color=True)`) is the viewer
option the reference cannot offer (openscad/openscad#4798 — OpenCSG preview is pixels in a frame
buffer): `difference()` paints its cut faces with the **minuend's** colour instead. Since
(A ∪ B) − S = (A − S) ∪ (B − S), each minuend part is differenced on its own and its cut-face runs
are re-minted under fresh IDs carrying that part's colour (`finishKeepMinuend`, `booleans.cpp`; the
source node stays the tool's, so a click on a cut face still finds it), then the parts are unioned.
A `union()` built in this mode remembers what it merged (`ColoredBody::mergedFrom`, unmerged and
carried through `generateTransform`/`generateColor`/`restampCachedIds`), so `difference() {
union() { red; blue; } tool }` cuts red on the red side and blue on the blue side; any op that builds
a new body drops the record and that body is one part. Off by default — a viewer option, not a
language feature, so it is honest for every existing script — and cache keys are prefixed per
mode so the two never serve each other's bodies. Costs one boolean per minuend part.
**2D cannot use any of that, and keeps colour geometrically instead** (`Part2d`, same file): a
`CrossSection` is contours, not a mesh, so nothing in it remembers which child an edge came from
and there is no provenance for `attachTriColors` to read back. So the 2D accumulator holds **one
Expand Down
5 changes: 3 additions & 2 deletions bindings/module.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -477,7 +477,7 @@ nb::object coverageResultToPy(const std::optional<oscadeval::CoverageResult>& cr

nb::object evaluate(const std::string& path, nb::dict viewportParams,
std::shared_ptr<oscadeval::ManifoldCache> manifoldCache, bool profile,
bool generate, bool strictCommas, bool coverage) {
bool generate, bool strictCommas, bool coverage, bool keepMinuendColor) {
std::unordered_map<std::string, oscadeval::Value> vp = toViewportParams(viewportParams);

std::vector<oscadeval::ColoredBody> bodies;
Expand All @@ -501,6 +501,7 @@ nb::object evaluate(const std::string& path, nb::dict viewportParams,
oscad::ParsedProgram program = oscad::getProgramFromFile(path);
oscadeval::ResolvedUseScopes used = oscadeval::resolveUseScopes(program.nodes, path, logFn);
oscadeval::Evaluator ev(logFn, nullptr, manifoldCache, oscadeval::DebugHooks{}, profile, coverage);
ev.keepMinuendColor = keepMinuendColor;
ev.setUsedFileGlobals(used.usedFileGlobals);
oscadeval::EvalContext ctx = oscadeval::EvalContext::makeRoot(used.rootScope.get());
bodies = oscadeval::toRenderableBodies(ev.evaluate(used.processedNodes, ctx, vp, generate));
Expand Down Expand Up @@ -955,7 +956,7 @@ NB_MODULE(_openscad_cpp_evaluator, m) {

m.def("evaluate", &evaluate, nb::arg("path"), nb::arg("viewport_params"), nb::arg("manifold_cache") = nullptr,
nb::arg("profile") = false, nb::arg("generate") = true, nb::arg("strict_commas") = false,
nb::arg("coverage") = false,
nb::arg("coverage") = false, nb::arg("keep_minuend_color") = false,
"Evaluate a .scad file; return (bodies, echoes, id_to_node, csg_tree, profile_result, dyn, dyn_explicit, "
"geometry, coverage_result).\n"
"coverage=True records which statements, branch arms and bodies ran: coverage_result is a dict "
Expand Down
18 changes: 18 additions & 0 deletions include/openscad_cpp_evaluator/colored_body.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
#include <manifold/manifold.h>

#include <array>
#include <memory>
#include <optional>
#include <vector>

Expand All @@ -28,6 +29,13 @@ enum class BodyRole { Normal, Highlight, Background, ShowOnly };
// Matches the reference's SceneRenderer._default_color.
inline constexpr std::array<float, 4> kDefaultGeometryColor{0.9f, 0.85f, 0.1f, 1.0f};

// The colour of a face a difference() exposed when the subtrahend that cut
// it carried no colour of its own: the reference's CGAL "back face" green,
// the same one its preview paints and its colour-preserving render (and
// 3MF export, measured on 2026.02.01) keeps. Distinct from the default
// geometry colour so a cut reads as a cut.
inline constexpr std::array<float, 4> kCutFaceColor{157.0f / 255.0f, 203.0f / 255.0f, 81.0f / 255.0f, 1.0f};

struct ColoredBody {
std::optional<manifold::Manifold> body;
std::optional<std::array<float, 4>> color; // RGBA; nullopt = "no explicit color() -- follow the live theme"
Expand Down Expand Up @@ -58,6 +66,16 @@ struct ColoredBody {
BodyRole role = BodyRole::Normal;
std::optional<std::vector<std::array<float, 4>>> triColors; // per-triangle RGBA, multi-color CSG merges only

// The operands a union() merged into this body, kept unmerged, each as
// it was when merged (colour and transforms carried along since --
// generateTransform and generateColor map over them). Set only under
// Evaluator::keepMinuendColor, where a difference() cuts each part on
// its own so its cut faces take THAT part's colour; nothing else reads
// it, and any other op that builds a new body from this one drops it.
// Shared, never mutated in place: an op that changes it makes a new
// vector.
std::shared_ptr<const std::vector<ColoredBody>> mergedFrom;

// Whether `body` is empty, once anything has asked (isEmptyBody,
// csg_generate.cpp). Asking Manifold is not free: every accessor goes
// through GetImpl(), which MATERIALIZES a lazy transform (a full copy of
Expand Down
16 changes: 16 additions & 0 deletions include/openscad_cpp_evaluator/evaluator.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,22 @@ class Evaluator {
std::size_t generatedNodeCount = 0;
std::unordered_map<uint32_t, std::optional<std::array<float, 4>>> idToColor;

// Record `rgba` against every run ID of `b`, so a later merge can still
// tell the body's triangles apart once the body itself is gone
// (attachTriColors looks runs up in idToColor). Cheap for a body that is
// still one original; a merged body pays a GetMeshGL().
void recordRunColors(ColoredBody& b, const std::optional<std::array<float, 4>>& rgba);

// difference() keeps the minuend's colour on the faces a subtrahend
// exposes, instead of the subtrahend's colour (or the cut green). Off
// by default: OpenSCAD paints cut faces with the cutter's colour, and
// that is what a script author sees there. A viewer option, not a
// language feature -- it is honest for every existing script. Costs one
// boolean per coloured minuend part instead of one per difference,
// since (A ∪ B) − S = (A − S) ∪ (B − S) is how each part's cut faces get
// that part's colour. Cached results are keyed apart per mode.
bool keepMinuendColor = false;

// Called by primitive-construction generate functions (cube, sphere,
// cylinder, polyhedron) right after building a brand-new Manifold:
// reads back its mesh's runOriginalID run(s) and records each against
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 = "1.19.3"
version = "1.20.0"
description = "C++ OpenSCAD evaluator with Python bindings"
readme = "README.md"
requires-python = ">=3.12"
Expand Down
10 changes: 8 additions & 2 deletions python/openscad_cpp_evaluator/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -532,7 +532,8 @@ class Evaluator:
"""

def __init__(self, echo_fn=None, debug_hook=None, error_break_fn=None, return_hook=None,
manifold_cache=None, profile=False, fast_continue_signal=None, coverage=False):
manifold_cache=None, profile=False, fast_continue_signal=None, coverage=False,
keep_minuend_color=False):
self._echo_fn = echo_fn
self._debug_hook = debug_hook
self._error_break_fn = error_break_fn
Expand All @@ -541,6 +542,11 @@ def __init__(self, echo_fn=None, debug_hook=None, error_break_fn=None, return_ho
self._profile = profile
self._fast_continue_signal = fast_continue_signal
self._coverage = coverage
# keep_minuend_color=True: difference() paints the faces a
# subtrahend exposes with the MINUEND's colour rather than the
# subtrahend's (or the cut green). A viewer option; see
# Evaluator::keepMinuendColor in evaluator.hpp.
self._keep_minuend_color = keep_minuend_color
self.csg_tree = []
self.profile_result = None
# coverage=True: after evaluate(), {"spans": [...], "files": [...],
Expand Down Expand Up @@ -594,7 +600,7 @@ def evaluate(self, source_path: str, viewport_params: Optional[dict] = None,
(body_dicts, echoes, id_spans, csg_tree, profile_result, dyn,
dyn_explicit, geometry, coverage_result) = _ext.evaluate(
source_path, vp, self._manifold_cache, self._profile, generate,
strict_commas, self._coverage)
strict_commas, self._coverage, self._keep_minuend_color)
# The evaluated bodies, still on the C++ side. Stashed like
# csg_tree/profile_result rather than returned, so
# evaluate()'s own 2-tuple result is unchanged -- callers
Expand Down
106 changes: 106 additions & 0 deletions src/builtins/booleans.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
#include <array>
#include <cstdint>
#include <optional>
#include <unordered_map>

namespace oscadeval {

Expand Down Expand Up @@ -199,6 +200,61 @@ void attachTriColors(Evaluator& ev, ColoredBody& cb) {
cb.triColors = std::move(triColors);
}

// keepMinuendColor: the leaf parts of a minuend operand -- the operand
// itself, or what a union() merged it from, recursively.
void collectKeepParts(const ColoredBody& b, std::vector<ColoredBody>& out) {
if (b.mergedFrom) {
for (const ColoredBody& part : *b.mergedFrom) collectKeepParts(part, out);
} else if (b.body) {
out.push_back(b);
}
}

// keepMinuendColor: one minuend part, differenced on its own.
struct KeepPart {
ColoredBody body;
std::vector<uint32_t> ownIds; // run IDs the part was born with; anything else on it afterwards is a cut face
};

std::vector<uint32_t> runIdsOf(const ColoredBody& b) {
const int original = b.body->OriginalID();
if (original >= 0) return {static_cast<uint32_t>(original)};
return b.body->GetMeshGL().runOriginalID;
}

// Union the per-part differences back into one body, first re-minting each
// part's cut-face runs (the subtrahend's IDs, shared by every part's result)
// under fresh IDs carrying THAT part's colour, so attachTriColors can tell
// one part's cut faces from another's. The source node stays the
// subtrahend's: clicking a cut face still finds the tool that made it.
manifold::Manifold finishKeepMinuend(Evaluator& ev, std::vector<KeepPart>& parts) {
std::optional<manifold::Manifold> out;
for (KeepPart& part : parts) {
if (!part.body.body) continue;
manifold::MeshGL mesh = part.body.body->GetMeshGL();
if (mesh.triVerts.empty()) continue;
std::unordered_map<uint32_t, uint32_t> remap;
for (uint32_t& id : mesh.runOriginalID) {
if (std::find(part.ownIds.begin(), part.ownIds.end(), id) != part.ownIds.end()) continue;
auto found = remap.find(id);
if (found == remap.end()) {
const uint32_t fresh = manifold::Manifold::ReserveIDs(1);
auto node = ev.idToNode.find(id);
if (node != ev.idToNode.end()) ev.idToNode[fresh] = node->second;
// ponytail: a part that is itself a multi-colour merge
// gives its cut faces its first child's colour rather than
// the colour of whichever child the cut passed through.
ev.idToColor[fresh] = part.body.color;
found = remap.emplace(id, fresh).first;
}
id = found->second;
}
manifold::Manifold rebuilt(mesh);
out = out ? *out + rebuilt : rebuilt;
}
return out.value_or(manifold::Manifold());
}

// One colour's worth of 2D result.
//
// 3D recovers per-child colour AFTER the merge, from Manifold's own
Expand Down Expand Up @@ -286,6 +342,15 @@ std::vector<ColoredBody> generateCsg(Evaluator& ev, const CSGParams& params, con
// only produce a uniformly coloured result.
std::optional<std::optional<std::array<float, 4>>> firstColor;
bool mixedColors = false;
// keepMinuendColor: the minuend's parts, each differenced on its own
// (see Evaluator::keepMinuendColor). Not while measuring: a render()
// expression only wants the volume, which is the same either way.
const bool keeping = op == "difference" && ev.keepMinuendColor && !ev.measuring();
std::vector<KeepPart> keepParts;
// ... and a union() built under that mode remembers what it merged, so
// a difference() it is the minuend of can cut each part on its own.
const bool rememberParts = op == "union" && ev.keepMinuendColor && !ev.measuring();
std::vector<ColoredBody> unionParts;

size_t stmtIndex = 0;
for (const Value& sizeVal : groupSizes) {
Expand Down Expand Up @@ -338,6 +403,20 @@ std::vector<ColoredBody> generateCsg(Evaluator& ev, const CSGParams& params, con
if (!firstColor) firstColor = c.color;
else if (*firstColor != c.color) mixedColors = true;
}
// A subtrahend with no colour of its own paints the faces it
// exposes the cut green, as the reference does, rather than the
// default geometry colour -- otherwise a cut through an uncoloured
// part is invisible as a cut. Recorded against its runs, which is
// all attachTriColors will have left after the merge; and the
// merge must then look, even when every operand's own colour
// agrees.
if (op == "difference" && res3d && !ev.measuring()) {
for (ColoredBody& c : bodies3d) {
if (c.color || c.triColors) continue;
ev.recordRunColors(c, kCutFaceColor);
mixedColors = true;
}
}
for (const ColoredBody& c : split.foreground) {
if (c.section) sections2d.push_back(c);
}
Expand Down Expand Up @@ -377,8 +456,27 @@ std::vector<ColoredBody> generateCsg(Evaluator& ev, const CSGParams& params, con
cb.knownStatus = manifold::Manifold::Error::NoError; // every operand was
cb.knownEmpty = grpEmpty;
res3d = std::move(cb);
if (keeping) {
std::vector<ColoredBody> leaves;
for (const ColoredBody& c : bodies3d) collectKeepParts(c, leaves);
for (ColoredBody& leaf : leaves) {
std::vector<uint32_t> ids = runIdsOf(leaf);
keepParts.push_back({std::move(leaf), std::move(ids)});
}
}
if (rememberParts) {
for (const ColoredBody& c : bodies3d) collectKeepParts(c, unionParts);
}
} else if (keeping) {
// The whole-minuend result above is never evaluated (Manifold
// is lazy); finishKeepMinuend replaces it after the loop.
for (KeepPart& part : keepParts) part.body.body = *part.body.body - grp;
res3d->knownEmpty.reset();
} else if (op == "union") {
res3d->body = *res3d->body + grp;
if (rememberParts) {
for (const ColoredBody& c : bodies3d) collectKeepParts(c, unionParts);
}
if (known(res3d->knownEmpty, false) || known(grpEmpty, false)) res3d->knownEmpty = false;
else if (known(res3d->knownEmpty, true) && known(grpEmpty, true)) res3d->knownEmpty = true;
else res3d->knownEmpty.reset();
Expand Down Expand Up @@ -415,6 +513,14 @@ std::vector<ColoredBody> generateCsg(Evaluator& ev, const CSGParams& params, con
}
}

if (res3d && res3d->body && rememberParts && unionParts.size() > 1) {
res3d->mergedFrom = std::make_shared<const std::vector<ColoredBody>>(std::move(unionParts));
}
if (res3d && res3d->body && keeping && !keepParts.empty()) {
res3d->body = finishKeepMinuend(ev, keepParts);
res3d->knownEmpty.reset();
mixedColors = true; // attachTriColors still no-ops when every run agrees
}
if (res3d && res3d->body && mixedColors) attachTriColors(ev, *res3d);

std::vector<ColoredBody> result;
Expand Down
Loading