Every version bump so far has permanently orphaned every project saved before
it. deserialize_recipe refused anything that was not exactly the current
version, and with no migration path v2 and v3 projects are unopenable today —
the 3MF still carries the mesh, so the user gets a frozen solid and no feature
history, which is the whole point of the subsystem silently absent.
The cause was the shape of the data, not the gate. save/load is one flat
symmetric list of ~90 fields with no framing, so a reader has no way to know
where a feature ends unless it agrees on every field.
Each feature is now written as its own cereal stream behind a length prefix, and
the same few lines handle both directions of mismatch. Older file, newer build:
the sub-stream ends early, the read throws, and the fields already assigned are
kept while the rest default — cereal assigns sequentially, so a mid-list throw
leaves the earlier fields set, and that is what makes this work. Newer file,
older build: the sub-stream holds more bytes than the reader knows; it reads what
it knows and stops, and the outer stream is untouched because the length prefix
was consumed in full. A field a project predates is not a corrupt project, so
neither case is an error.
v4 keeps its own pre-framing flat path and opens exactly as before —
cad_recipe_v4.bin is untouched and now serves as the witness for that. v2 and v3
stay refused, by name: their field lists no longer exist in this code. This fixes
the future, not the past, and the comment says so rather than implying otherwise.
From here a new field only needs appending to save/load — no bump, no orphaned
projects. That removes the cost that had blocked snaporca-44m and snaporca-dgv.
The helix round-trip test was reading the blob back flat, reaching into the
format instead of through it; framing necessarily breaks that, so it now goes
through deserialize_recipe, which is a stronger assertion than it made before.
Every field check it carried is unchanged.
Tests: four new [CadDocument][recipe] cases, including the one the change exists
for — a deliberately truncated feature blob must LOAD, keeping what it could read.
Suite 177 -> 181 cases, 2366 -> 2411 assertions.
snaporca-2txy.
entities_to_wire handled exactly two shapes of sketch: one lone Circle/Ellipse, or
any number of Line/Arc/EllipseArc/BSpline pushed into a single MakeWire. Everything
else fell off the end as a null wire, so a circle drawn inside a rectangle — the
most ordinary thing in this whole program — refused with "not supported yet". Two
separate closed polygons were quietly worse: both went into one MakeWire, which
does not mean "two loops" to OCCT.
entities_to_wires now returns one wire per loop. A Circle or Ellipse is a loop on
its own; chain entities are grouped by shared endpoints (union-find, 1e-6 in sketch
coordinates), and an open chain still comes back as a wire because a sweep path is
legitimately open. It is all-or-nothing: one loop that fails to build poisons the
whole result, because a partial profile would extrude a shape the user did not draw
— the failure 2e6a8f9e91 was written to stop.
entities_to_wire survives as a two-line wrapper returning the single wire when
there is exactly one loop and a null wire otherwise, so all nine of its call sites
keep their exact contract and Revolve/Sweep/Loft/Surface* are untouched. What a
holed profile means for each of those is a separate question.
wires_to_face takes the largest-area loop as the outer boundary and adds the rest
reversed, which is how OCCT is told a wire is a hole. Containment is CHECKED with
BRepClass_FaceClassifier, not assumed: a loop outside the largest one is a second
island, and one sketch producing several solids is a much bigger feature, so it is
refused by name ("two disjoint regions") rather than guessed at.
Only the Extrude case consumes the new face. Tapered extrudes of a holed profile
are refused — offsetting inner loops has to go the opposite way — and the guard
counts wires on the face already built rather than rebuilding every wire to ask how
many there are, which is also the more honest test: what matters is the profile
being extruded.
Tests: six new [CadDocument][sketchwire] cases, proved by VOLUME rather than by not
throwing — plate-with-hole, two holes, and two regression guards that a lone circle
and a lone polygon extrude exactly as before. Suite 177 cases / 2366 assertions.
No serialized field, recipe version untouched, golden fixtures unchanged.
snaporca-88v.
snaporca-lukg wants a palette offering all five mate types with the non-viable
ones DIMMED AND EXPLAINED rather than hidden — its reasoning being that a menu
changing shape between invocations destroys the motor memory experts rely on.
That needs an answer this document could not give. This is that answer, and
nothing else: mate_options(cs_a, cs_b) returns five MateOption{kind, viable,
reason}, always five, always in kind order, never filtered.
The geometry test rides on the fingerprint added for snaporca-kqih, which is why
it costs no new serialized field: coordsys_face_kind already records the surface
type. Revolute and Cylindrical need a cylindrical face at both ends because they
need an axis to turn about; Planar needs flat faces; Fastened and Slider
constrain frames rather than surfaces, so no geometry test applies to them.
UNKNOWN IS PERMISSIVE. A fingerprint of -1 means PointWorld or a connector that
has not resolved yet, and it does NOT make a type non-viable. Refusing on missing
information is the false-alarm behaviour that gets a whole feature ignored — the
same reasoning already recorded on kqih for the drift warning, applied again
because it is the same trade.
Reasons name WHICH connector is the problem when only one is. "needs a
cylindrical face at both ends" tells the user what the rule is; "connector A is
on a flat face" tells them where to look, and the second half is the one that
saves the time.
The stability contract has its own test, asserting five entries in kind order
even for a completely invalid pair. That matters more than any individual
verdict: the palette addresses rows by position, so a shorter list would move
every row below it.
Golden fixture unchanged — this is a pure query. Suite 167 -> 171 cases,
2284 -> 2348 assertions, green. snaporca-lukg part A; the palette is part B.
snaporca-rgbj measured the damage: four chamfers on a box remove
0.400/0.397/0.397/0.395 mm3 when each id is re-read, and
0.400/0.008/0.397/0.280 when the four ids are captured up front. The kernel is
right in both runs — the second one asks for the wrong edges. Neither errors,
because a stale id still resolves to a real edge, just not the one that was
measured.
That makes it an API problem rather than a script bug. Reading the scene once and
then issuing several operations is the natural way to drive a socket, it is what
every agent will write, and it produced silently wrong geometry with nothing
anywhere reporting it.
CadDocument::topo_generation is bumped where the bodies are replaced — the single
line in recompute() where the face and edge maps actually change, so a feature
type added later cannot forget to bump it, which a per-mutator counter would
invite. describe_scene and query_topology return it. A caller may pass it back as
"generation" on any call, and a mismatch is refused with a message that says what
to do about it.
Two deliberate choices:
OPTIONAL, not mandatory. Every existing script keeps working unchanged; passing
the generation is what buys the guarantee. Making it required would break every
caller to fix a mistake only some of them make.
CHECKED AT THE DISPATCHER, not in each handler. One site covers fillet, chamfer,
shell, draft, coordsys, thicken, cut, project, delete_face and everything added
after them. A per-handler check is a list that goes stale the first time someone
adds a method in a hurry.
Not serialized: an id means something only within the run that produced it, so
persisting the counter would promise a stability the ids themselves do not have.
No recipe version change.
describe_tools now carries an id_lifetime note, because the guard only helps a
caller who knows to ask for it.
Kernel suite 167 cases / 2277 assertions green; libslic3r_gui builds. The guard
itself is NOT exercised — it needs the socket, so it is on snaporca-bdco.
snaporca-o1l2.
kqih option (c). A FaceAndDirection connector stores a global face index, and an
upstream edit can renumber faces so the index silently names a different one. The
DANGLING case already threw; this is the in-range-but-wrong case, which nothing
detected.
Fingerprint the face on first resolve, compare afterwards, and report a mismatch
into mate_conflicts — the channel that already marks the tree row — never as an
error. A drift warning must not abort the recompute, because the alternative
makes a legitimate Draft on a mated face fatal.
WHAT THE FINGERPRINT IS, AND WHAT IT IS NOT. Surface type plus edge count. Not
centroid or area: legitimate parametric edits move and resize faces, which is the
entire point of the model, so either would fire on every dimension change. Not
the normal, which is the tempting one — Draft deliberately tilts a face and
Transform reorients a body, both legitimate. Type and edge count survive rigid
motion, tilting and resizing, and catch the case that actually happens: a planar
index sliding onto a fillet's cylindrical face after a dress-up inserts faces.
The accepted cost is that a slide between two planar 4-edge faces is invisible. A
partial detector that never cries wolf beats a total one that does, because a
false alarm on a valid connector teaches people to ignore the warning.
Connectors with no fingerprint record one on first recompute, so old recipes
self-heal and both writers (DesignPanel, McpControl) get it without changing.
THE VERSION BUMP IS THE IMPORTANT HALF. The task was specified with "do not
change the recipe version" — that was wrong, and the rule is written in the
header three lines above the constant: bump whenever save/load gains a field.
deserialize_recipe() gates on v == VERSION and then reads a FLAT symmetric field
list. A v3 blob under a v3 build that has grown two fields passes the gate and
reads two ints past the end of every connector, into the next feature's bytes.
That is silent corruption of a saved project, which is worse than any load error.
Now v4, and v3 gets the existing clean refusal.
cad_recipe_v3.bin is KEPT, unregenerated, with a test asserting it is refused and
that nothing half-read is left behind. It is the only artefact that can prove the
gate works, because it was written by an older build — regenerating it with
today's code would destroy the evidence, which the test says in as many words.
Suite 163 -> 167 cases, 2248 -> 2277 assertions, green. Fixture v4 34928 bytes.
snaporca-kqih.
Driving the control socket: hexagon prism, six vertical fillets, four chamfers
on the already-filleted rim, an M8 hole. Afterwards describe_scene reported
bodies=3 and error='' — entirely healthy — while body 2's TopoDS_Shape was null.
Only mass_properties on that one body revealed anything was wrong.
So a feature destroyed a body, recompute() returned true, and the document went
on advertising it. Any downstream consumer — slicing, STEP export, a mass
properties report — met a null shape with no warning. That is the silent
corruption class, which is the one class this project does not tolerate.
recompute() now scans the freshly built bodies for a null shape, names the body
and the feature that destroyed it, and returns false. Returning false rather than
just setting error is the point: it hands the caller its normal rollback path, so
the operation that destroyed the body is undone instead of committed.
The message says "an unidentified feature" when source_feature is -1. "feature 0"
would be a lie, and a message that exists to tell you where to look has to be
trusted.
TEST IS A POSITIVE CONTRACT, AND THE REASON MATTERS. The reported order was
driven headlessly first, as the better test: it does NOT reproduce. The dress-up
step throws "fillet radius too large", which is an already-loud already-caught
path, so recompute fails honestly and never nulls a body. No public-API sequence
found so far reaches the guard's branch without a GUI, and faking a null into
`bodies` after the fact would not exercise it — the guard runs on `built`, before
the swap. So the test asserts what can be asserted: a box + fillet recomputes
true, error is empty, and no body is null. The guard's own branch is defensive
and currently unexercised; that is stated here rather than implied by a green
suite.
Kernel suite: 2217 assertions in 161 test cases, all passing. No existing test
relied on a null body surviving a recompute, so hardening this broke nothing.
snaporca-5425 (part a). Part b — why the chamfer chain degenerates on an
already-filleted rim — is untouched and stays open.
Two enabled mates driving the same body is not an error today — the later one
just wins, and the earlier mate looks ignored with nothing said. A cycle in the
mate graph is worse: composition still produces a result, but an arbitrary,
order-dependent one.
recompute() now fills a mate_conflicts vector of (feature index, reason) before
the geometry pass, so it survives a throw further down. It catches a second mate
on the same target body, a mate positioning a body against itself, and a cycle,
via an iterative three-colour DFS over the body graph. Broken mates are skipped
silently — apply_mate() already errors on those.
Deliberately non-fatal: recompute() still returns true and error stays empty.
Deliberately not "over-constraint" — that word promises DOF analysis from a
solver this kernel does not have.
Port of snaporca ec4ffeb979. Kernel half of snaporca-bioq.
Suite: 2213 assertions / 160 cases green on this fork too.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
mate_cs_a/mate_cs_b are feature indices. remove_feature() remapped sketch_ref
through the deletion but not the mate connectors, and move_feature() swapped
sketch_ref but not the mate connectors. Deleting or reordering any feature
ahead of a connector slid both references onto whatever features landed on
those slots.
Nothing reported it. recompute() only rejects out-of-range and non-CoordSys
targets, and a shifted index normally lands on the assembly's other CoordSys —
an assembly carries at least two by construction. So the mate resolved against
the wrong frames and moved the wrong body, silently.
Extracted a remap lambda in remove_feature() and a swap_ref lambda in
move_feature(), applied to sketch_ref and both mate connectors.
Two tests, both confirmed red before the fix. [mate] tags green here:
395 assertions / 29 cases — the first end-to-end kernel compile of this fork.
Ported from snaporca; CadDocument.cpp is byte-identical across forks again.
Refs: snaporca-kqih
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reported by Tommaso: select a body, and there is no Delete in the offer. Two independent
faults stacked behind that.
FIRST, clicking a body never selects the body. Whole-body picking is deliberately unbound
(DesignSketchTool.cpp) pending the rubber band, so a viewport click only ever yields
Face/Edge/Vertex. The offer therefore saw face_planar, and "delete" accepted body_solid but
no face kind, so the row was filtered out entirely — while the status line read "Body 1
face 0 selected", which actively teaches the wrong model.
SECOND, even selecting the body from the Bodies list, Delete refused in red: "Select the
FEATURE that created this body". CadBody had no link back to its maker, so the offer was
advertising a verb it could not perform — worse than the action:null rows fixed earlier this
session, because this one is ENABLED and its refusal reads like user error.
CadBody::source_feature fixes the second. It is stamped in ONE place, the recompute loop,
and the rule is just "still unset?". That is sufficient because of an invariant worth
stating: no feature ever replaces a whole CadBody. Every in-place op writes only .shape
(boolean, cut, mirror-fuse, transform, dress-up — all 8 sites checked), so a body keeps the
stamp it was born with; a consumed body is erased outright, taking its stamp with it; and
the only bodies still at -1 are the ones the current feature just pushed. A feature type
added later needs no change here as long as it keeps to that invariant.
"Delete Body" fixes the first, sitting beside "Delete Face" in Modify and reachable by
pointing at any face/edge/vertex. The two names cannot be confused, and "delete" gave up the
body kinds so both can never appear for one selection. Deleting a body removes the feature
that made it, which is a real edit to the recipe, so it asks first and NAMES the feature — a
body vanishing from the viewport is not evidence of which feature went, and this is the one
action here that cannot be eyeballed.
Multi-body delete is NOT offered. bodies_2 was in the first draft of the verb; the handler
deletes exactly one body, so a two-body selection would have silently deleted whichever was
m_sel_solid_body. Caught before it reached a binary, at the cost of one rebuild.
Verified on BOTH rigs, full round trip: click a face -> Modify > Delete Body -> "Delete
Extrude2?" -> body gone, Sketch1 correctly left behind, panel falls back to the idle hint ->
Undo -> Extrude2 and Body 1 restored.
Selecting a face and sketching on it is the most common gesture in solid
modelling, and it was impossible. The plane came from a combo holding
XY/XZ/YZ plus datums, and plane_from_choice had no face branch at all, so
the only route onto a face was to build a Coincident datum plane on it
first, confirm that, reopen the sketch and find the datum in the
dropdown. Three extra steps and a junk feature in the tree.
The fix is not another combo row. A new sketch now takes its plane from
what is SELECTED IN THE VIEWPORT: a picked planar face wins outright, and
only when nothing is picked does it fall back to the reference plane —
which is itself normally set by clicking one of the ghost planes in 3D,
not by opening the combo. The tool hint names the target ("Circle — click
center, then radius · on the picked face") so the choice is visible on the
geometry side rather than needing a control to read back.
CadDocument::plane_of_face is the shared derivation, so the sketch path
and the Coincident datum method cannot drift apart. It refuses
non-planar faces: face_normal_world evaluates at the mid parameter, which
on a cylinder or a fillet is a tangent plane at one arbitrary point —
fine for offsetting a datum, wrong as a sketch plane, and silently
sketching on a tangent is worse than declining.
Picking the face also CONSUMES it. Leaving the pick live meant the next
Extrude saw a selected face and push/pulled it instead of extruding the
sketch just drawn — the same trap the imported-art path already guards
against.
Verified on :10 end to end with no combo interaction: build a box, click
its top face twice to cycle whole -> face, press S then C, and the circle
is drawn in the plane of that face with its Radius tab on the geometry
(artifacts/shots/f3a2-03-face.png, f3a2-04-sketch-on-face.png,
f3a2-05-circle-drawn.png). Kernel side: 154 cases / 2125 assertions green
on both forks, including that a cylinder resolves exactly its two flat
caps and refuses the barrel.
Still side-panel-shaped and to be dealt with separately: the Plane combo
remains on the card and now merely displays a stale row when a face is
the real target. It should show the actual target or go away.
snaporca-3a2.
entities_to_wire handles exactly two shapes: one lone closed entity
(Circle/Ellipse), or a chain of open ones (Line/Arc/EllipseArc/BSpline).
Anything else -- a circle coexisting with a line, two circles -- returns a
null wire. build_sketch_wire answered that by falling through to its
legacy tail, which ends in a rectangle built from width/height. For an
entity sketch those fields are whatever they were initialised to, so the
extrude produced a box the user never drew, silently and with ok:true.
That is how the ellipse+stray-arc case in the P2 Tier-B.1 verification
turned into a default-rectangle solid.
Throw there instead. The legacy profile/shape paths below are still
reached by sketches that legitimately carry no entities at all, so the
enum and profile constructors are untouched -- only the case where
entities exist and cannot be turned into a wire now fails, which is
exactly the case that was fabricating geometry.
This does NOT implement the multi-loop support the issue asks for. Doing
that properly means deciding containment -- a circle inside a rectangle is
a hole, a circle beside it is a second region -- and make_extrude_regions
cannot be reused because it takes flattened Vec2d contours for imported
Text/SVG art and would discard the analytic circle. Guessing containment
would trade a visible failure for a wrong solid, which is the opposite of
the point. Left scoped on snaporca-88v.
Also converts the three float comparisons in the two test cases added this
session from Approx to WithinAbs/WithinRel, per tests/CLAUDE.md, which
rules Approx out for being asymmetric and double-only. The rest of the
file's pre-existing Approx uses are left alone.
153 cases / 2090 assertions green on both forks; no existing test depended
on the default-rectangle fallback.
snaporca-88v (partial: the silent-fallback half).
A boolean cut whose tool misses the target is a perfectly legal
operation: OCCT reports IsDone(), the shape comes back unchanged, and the
feature lands in the recipe reporting ok:true. Driving the MCP socket,
that produced two consecutive {ok: true, bodies: 1, error: ''} responses
for a hole that was never drilled -- same viewport, same 46939.11 mm3 --
and the tree grew two Hole features that will never cut anything. A
caller, an agent especially, has no signal at all that the thing it asked
for did not happen.
Measure the volume across the op in route_feature's in-place branch and
refuse the no-op. Only for removals: Hole, Thread, and Extrude / Revolve
/ Sweep / Loft in Cut mode. Everything else may legitimately leave the
volume alone -- a Transform certainly does. The tolerance is relative,
because an absolute epsilon is wrong across the mm-to-metre range of real
parts, and a cut that shaves a numerically invisible sliver is a miss
too. The existing rollback in the MCP actions already preserves the
reason, so a missed hole now answers ok:false with the error and undoes
the feature.
The confusion underneath was not itself a bug: hole's x/y are in the
sketch plane's frame, whose origin is describe_scene's modeling_origin,
and describe_tools documented them only as "number, unit mm". Passing the
world centre put the hole 135 mm clear of the solid. All six x/y params
on hole / hole_styled / hole_standard now say which frame they are in,
since the wrong guess was silent.
Regression test drives the reported failure directly: a hole at x=135 on
a 20x20x20 box is rejected and leaves the body untouched, the same hole
at the origin still removes exactly pi*4^2*20, and a cut-mode extrude
whose profile sits at x=200 is rejected too. 152 cases / 2083 assertions
green on both forks.
snaporca-daf.
snaporca-kzy was filed as "internal thread cuts too little material". It does
not. Measured on the test's own fixture, a 40x40x20 box:
plain Ø12 bore removes 2261 mm3
internal thread removes 2157 = 1571 (minor bore) + 586 (groove)
apply_thread bores at the MINOR radius (radius - depth = 5) and then carves the
groove out to radius + depth = 7. A tapped hole therefore keeps the crests
between turns and holds MORE material than a plain clearance hole at the
nominal radius — which is what every real tapped hole does. The test asserted
the opposite, so it was asking for something physically wrong and had been
quarantined for it since it was written.
One hypothesis discarded on the way: that the shortfall was a tessellation
artefact, since chords on a helical surface undercut a concave bore. Exact
BRepGProp::VolumeProperties agreed with the tessellated volume to within
2.5 mm3, so that was not it and is not offered as a hedge.
The reference is now the tap-drill bore the thread actually starts from (Ø10),
against which the groove's 586 mm3 is the meaningful quantity — that is what
"the thread cuts" means. Test re-tagged [CadDocument][thread], so CI covers the
thread path again instead of skipping it.
Also documented the (void)internal in make_thread_profile. It reads like a bug
and is not: the V is the same shape either way and the caller decides, fusing
it onto a shaft or cutting it out of a wall. Someone "fixing" it to point
inward for the internal case would make the groove sweep already-empty bore
space and cut nothing — the exact failure the old comment described.
Suite 148 cases / 2035 assertions, with this test now among them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
datum_frame took a FaceAndDirection frame's Z from the face normal, which
follows the body, but its X from coordsys_x_hint, a world constant, whenever
no explicit edge reference was set. Spinning a body about its own face normal
therefore left the frame bit-identical: the connector could not encode that
rotation at all, so Fastened and Slider mates claimed to fix an orientation
the frame could not see.
X now comes from the face's own first usable edge, which rotates with the
body. The hint survives only as a last resort, for faces that offer no
in-plane direction — a full circular edge has coincident endpoints, and a
seam projects to nothing in-plane.
The new test spins a box 90 degrees about its top-face normal and asserts the
frame's X turned with it. Reverting just the X_tent derivation and rerunning
makes it fail with "1.0 is within 0.000001 of 0.0" — cos(angle) between the
before and after X is exactly 1, i.e. the frame did not move — and that is the
only failure in 2019 assertions, so the test discriminates this defect and
nothing else.
Note for anyone replaying an older document: a face-only connector's frame
can now differ from what that recipe produced before, so a mate built on one
may place its body differently. Nothing in the suite or the golden v3 fixture
changed, but the semantics did.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rib guarded with `sk.type != CadFeatureType::Sketch`, while every other
sketch consumer — Extrude, SurfaceExtrude, SurfaceRevolve, the loft paths —
tests `!= Sketch && != Project`. A Project feature carries a plane and Line
entities, which is all a rib reads, so the guard blocked "project a body
edge, then rib along it" for no stated reason.
The picker in the Design tab offered Sketch features only, so it is widened
to match: a kernel that accepts Project refs and a GUI that never lists them
would have left the path unreachable anyway.
Worth recording for whoever hits this next: Rib also needs a sketch carrying
EXPLICIT entities. A parametric Rectangle sketch (add_sketch with
width/height) has an empty entities vector — build_sketch_wire synthesises
its profile on demand — so rib_entity 0 is out of range there and it fails
with "rib: bad entity". That is why Rib could not be driven headlessly at
all before this change; a Project feature is now the one programmatic way to
produce a ribbable line.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two defects found while driving the tools that Phase B wired but nobody had
exercised yet.
apply_project had no all-edges branch: with no face picked and no explicit
edge list it threw "no edges or face selected". That is precisely the state
the Project card opens in, and its label reads "(all edges)" — so the card's
default could never be confirmed. It now projects every edge of the source
body. Edges perpendicular to the target plane collapse to a point when
projected, so segments whose endpoints coincide are dropped instead of being
emitted as zero-length lines that would poison the sketch downstream.
The second defect is why the first one was invisible. 29 of the 31 rollback
sites in McpControl ran `if (!ok) doc.undo();`, and undo() recomputes the
restored feature list — which succeeds and clears doc.error. Every failing
command therefore reported `error: ""`. Yesterday's fix covered 2 sites and I
treated the file as done; it was not. All 31 now capture the reason before
the rollback and restore it after. Failures that read as `""` now read as
"rib: bad entity" / "surface-revolve: revolve failed".
Verified on the running GUI through the control socket: the Project call that
previously returned ok:false now returns ok:true, and failures carry a reason.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
axis_plane_a/b are filled by the GUI from populate_plane_choices(), whose rows
are XY / XZ / YZ followed by the datum planes, and the row is stored verbatim.
The kernel's base_plane() indexed datum_planes[ref] directly, so the two spaces
were off by three: picking XY resolved to datum plane 0, and picking the first
datum ran past the end and failed with "plane ref not found". The
PlaneIntersection axis type could not work from the GUI at all.
base_plane() now uses the encoding CadFeature::plane_base already uses — 0/1/2
are the base planes through the modeling origin, >=3 indexes datum_planes[ref-3]
— so there is one convention for plane references instead of two. That also
makes two base planes usable, which the previous code rejected as out of scope
even though XY x XZ is an ordinary way to define the X axis.
Removed the dead find_plane lambda directly above it. It was never called and
half-anticipated this exact offset ("if (ref >= 3) // base plane offset"),
which is presumably where the confusion started.
Tests: the existing parallel-planes case encoded the OLD convention, passing
axis_plane_a = 0 to mean "datum 0" — values the GUI cannot produce — so it is
re-based onto rows 3 and 4. Two new cases cover what the GUI actually emits:
base x base (XY x XZ -> X) and base x datum, the latter pinning the +3 offset.
Both verified to FAIL against the previous indexing, at test_caddocument.cpp
:2325 and :2346.
Found by auditing the remaining tools for the index-space defect class that had
already produced three bugs in the GUI; this is the first instance of it
crossing the GUI/kernel boundary.
Suite 143 cases / 1980 assertions (was 141/1972). GUI compiles clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
checkpoint() snapshotted `features` and undo() restored `features`, but
`variables` is a separate member of CadDocument. Every caller of the documented
checkpoint -> mutate -> recompute -> undo-on-failure pattern therefore failed to
roll a variable back: the bad value stayed in the document and every later
recompute failed, which is exactly the corruption the pattern exists to prevent.
Feature `expr` bindings were unaffected only because expr lives inside
CadFeature and rode along in the features snapshot — which is why the feature
side appeared to work.
This was a kernel gap, not a GUI one: McpControl::action_set_variable has the
same sequence and was equally broken.
The undo/redo stacks now hold a {features, variables} Snapshot. Nothing here is
serialized, so no recipe version change and no golden-fixture regeneration.
Two tests, both verified to FAIL against a faithful reproduction of the bug
(undo() leaving `variables` untouched) at test_caddocument.cpp:4420 and :4441:
one covers restoring a variable's previous value, the other covers removing a
variable that did not exist before the checkpoint. Worth recording that the
first mutation attempt was NOT faithful — it dropped the restore but kept
std::move(variables) into the redo stack, which empties the map as a side effect
and made the second test pass for the wrong reason. A mutation has to reproduce
the original defect, not merely break the code.
Second defect, same area: undo() calls recompute(), which succeeds and clears
doc.error, so the reason an edit was rejected was destroyed before anything
could display it. Six sites — four in DesignPanel, two in McpControl — now carry
the message across the rollback. on_remove_variable additionally asserted
"referenced by a feature expression" as fact; it now offers that as the likely
cause and appends the real error, since that diagnosis is wrong for any other
failure.
Suite 141 cases / 1972 assertions (was 139/1960). GUI compiles clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
check_interference(min_volume) reports every pair of solid bodies whose
intersection encloses more than min_volume, as {body_a, body_b, volume}. It
reports only — no geometry is mutated, so calling it cannot disturb mates or
placements. Read-only at the MCP surface too: no checkpoint, no recompute.
Sheet bodies are skipped up front: an intersection involving one encloses no
volume, so the boolean would be wasted work. Bodies that merely touch share a
face and enclose nothing, so face-to-face contact is not an interference.
A boolean that fails on one pair must not lose the report for every other pair,
so each pair is guarded — and OCCT raises Standard_Failure, which is not a
std::exception and would otherwise escape.
No new serialized fields, no recipe bump: this reads `bodies`, which is
recompute output and was never serialized.
No separate MCP listing for instances and mates: describe_scene already emits
the feature tree, and Mate has rendered there correctly since M8a fixed
feature_type_name.
Tests assert the exact overlap volume (20*20*4 = 1600 mm^3), both negative cases
(clearly apart, and exact face contact), that sheets are skipped, that the
min_volume gate silences a real overlap, and that a clash created by a Fastened
mate is detected — which ties the M8b placement work to this report.
Suite 139 cases / 1960 assertions green. McpControl.cpp is reviewed but not
compiled by kernel-test.sh, which builds only libslic3r_tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each kind constrains the DOFs it owns and PRESERVES the rest from the body's
current pose, following the pattern Planar established in M8a. Resolved instead
as "Fastened with a parameter", all three would have been geometrically
identical to Fastened — relabelling rather than behaviour.
Revolute fixes position on the axis line; rotation about it survives
Slider fixes orientation and perpendicular position; axial position survives
Cylindrical fixes the axis line only; rotation and axial position both survive
No new serialized fields, no recipe bump, no fixture regeneration: mate_kind is
already an int and mate_offset / mate_angle already exist.
The minimum-rotation z-alignment (including the antiparallel 180 deg case fixed
in M8a) is now a shared make_z_align lambda rather than a second copy.
Fixes a rotation-about-pivot bug found by the no-op tests: R_full was built as a
rotation about the origin with a translation to oB appended, instead of a proper
rotation about oB (translation = oB - R*oB). It moved bodies that were already
correctly placed, and accounted for three of the seven initially failing cases.
Testing notes, both of which cost real debugging time here:
- Mates are defined on connector FRAMES, but the convenient thing to measure is
CentreOfMass(), and the two coincide only when the body is symmetric about its
connector. Five expectations in this milestone asserted the centroid while
meaning the connector. These tests assert on the mated face's centroid.
- A CoordSys built from a face ALONE takes its z from the face normal (which
follows the body) but its x from coordsys_x_hint, a world constant. Such a
frame cannot see rotation about its own normal, so no mate can correct or
preserve a spin it does not encode. The Slider and Cylindrical rotation tests
pin coordsys_edge to an edge of their own body; without that both passed
vacuously, one of them for a wrong implementation.
The Cylindrical rotation test was verified to fail when its mate kind is mutated
to Slider, and the Slider test failed at axis_aligned == 2 before the connectors
were edge-pinned. Neither is green by accident.
Known wart: mate_angle is silently ignored for Slider, whose rotation is fully
constrained. Defensible but undiagnosed at the API surface.
Suite 134 cases / 1927 assertions green. McpControl.cpp is reviewed but not
compiled by kernel-test.sh, which builds only libslic3r_tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An assembly is a multi-body document. An instance is already expressible as
Transform with xf_copy=true, and a mate connector is already a CoordSys
feature, so this adds exactly one feature type: Mate.
No constraint solver. A mate rigidly transforms the body carrying connector B
so that B's frame lands on connector A's, applied in feature order like every
other feature. Chains resolve by composition; closed kinematic loops do not
converge (last mate wins) and are out of scope.
The vendored SolveSpace in src/libslic3r/slvs/ was evaluated for 3D extension
and rejected: it is built and linked but has zero callers, and SketchEngine's
solver is hand-rolled. Extending it would mean adopting a dependency to write
more code than the alternative.
- CadFeatureType::Mate appended; six fields (mate_kind, mate_cs_a, mate_cs_b,
mate_offset, mate_angle, mate_flip) appended at the END of both cereal lists
- SNAPORCA_CAD_RECIPE_VERSION 2 -> 3; v2 blobs are rejected, as by design there
is no migration path. Golden fixture renamed to cad_recipe_v3.bin and
regenerated once, extended with two CoordSys + one Mate so the new fields are
tripwired by the field-order assertions
- datum_frame() extracted from resolve_datum_coordsys() so a mate can resolve
its connectors against the in-progress bodies vector during replay
- apply_mate dispatched early-return, so Mate is deliberately absent from
starts_new (unreachable for that dispatch style)
- Planar: the degenerate branch splits on the sign of zB.z_target — antiparallel
needs a 180 deg rotation about a perpendicular axis, which an earlier revision
silently skipped, leaving the body's normal inverted
- MCP: mate command, named bare to match the other 38 methods
Drive-by: feature_type_name() was missing Mirror, ThickenSurface, SurfaceOffset,
SurfaceLoft and SurfaceFill, which reported as "Unknown" to MCP clients.
Suite 122 cases / 1741 assertions green. Note that kernel-test.sh builds only
libslic3r_tests, so McpControl.cpp is reviewed but not compiled here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The two remaining ways to create a sheet body. SurfaceLoft skins 2+ profile
sketches without end caps via a new SketchEngine::make_loft_surface — a
sibling of make_loft with the ThruSections solid flag false, so no existing
call site changes. SurfaceFill patches a single closed boundary wire into a
smooth face with BRepOffsetAPI_MakeFilling, adding each boundary edge as a
C0 constraint.
Purely additive: two enum values appended to CadFeatureType, reusing the
existing loft_profile_refs/loft_ruled and sketch_ref fields. No new cereal
fields, recipe stays v2, golden fixture unchanged (30773). MCP
surface_loft/surface_fill added as pure additions. Suite 107 cases / 1553
assertions green, including a test contrasting the open skin against the
solid loft of the same profiles.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
Two features bridging sheet bodies back to solids and to other sheets:
ThickenSurface feeds a whole sheet shell to MakeThickSolidBySimple (the same
OCCT recipe the face-level Thicken already uses) and appends the result as a
solid; SurfaceOffset offsets a sheet's shell along its normals via
MakeOffsetShape::PerformBySimple, keeping it open. Both refuse a non-sheet
target with a clear error.
Purely additive: two enum values appended to CadFeatureType, reusing the
existing target_body / thicken_thickness / thicken_flip / plane_offset
fields. No new cereal fields, recipe stays v2, golden fixture unchanged
(30773). MCP thicken_surface/surface_offset added as pure additions.
Suite 103 cases / 1520 assertions green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
Two body-producing features that emit an open shell instead of a capped
solid: SurfaceExtrude (prism of a sketch wire, no end caps) and
SurfaceRevolve (revolve of a wire about an in-plane axis, no caps). Each
appends a new sheet body whose TopoDS_Shape has no TopAbs_SOLID.
Purely additive: two enum values appended at the end of CadFeatureType,
reusing existing serialized fields (sketch_ref/distance,
revolve_angle/revolve_axis). No new cereal fields, recipe stays v2, golden
fixture unchanged. is_sheet_shape() derives sheet-ness from the OCCT shape
type (bodies are not serialized). MCP surface_extrude/surface_revolve added
as pure additions. Suite 99 cases / 1474 assertions green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
Add named document variables (CadDocument::variables) and per-feature
expression bindings (CadFeature::expr, field-name -> expression). On
recompute(), variables are evaluated topologically (cycle detection), then
each feature's expr entries are evaluated and written into its numeric fields
before geometry runs. Self-contained shunting-yard evaluator (+ - * /, parens,
unary minus, sqrt/abs/sin/cos/tan(deg)/min/max, pi). assign_field allow-lists
the 33 dimension fields + pattern_count; unknown names error loudly.
Additive: recipe stays v2 (fields appended to both cereal lists, golden
fixture regenerated). MCP set_variable / set_feature_expr are pure additions.
Suite 95 cases / 1440 assertions green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
Extend CadFeatureType::Pattern (no new enum) with a curve mode: when
pattern_curve_sketch >= 0 it takes precedence over linear/circular. The guide
entity is sampled at equal-parameter points via a file-local sample_entity_2d()
(Line lerp, Arc angle-lerp, cubic-BSpline Bernstein, p0->p1 fallback), and each
seed copy is translated by (P_i - P_0) and fused. Two serialized fields
(pattern_curve_sketch/pattern_curve_entity) appended to both symmetric cereal
lists (version stays 2, golden fixture regenerated 30269->30517). MCP:
pattern_on_curve. 3 new [CadDocument][pattern] tests; suite 89/1395.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
New CadFeatureType::Rib (appended). A straight open Line entity in a sketch
is offset ±thickness/2 along its in-plane perpendicular into a thin rectangle,
extruded rib_depth along the sketch-plane normal, and fused to the target body.
Line-only for now (ponytail; polyline/arc ribs are a later extension) — a
non-line entity fails cleanly at recompute. Four serialized fields
(rib_sketch_ref/rib_entity/rib_thickness/rib_depth) appended to both symmetric
cereal lists (version stays 2, golden fixture regenerated 29525->30269). MCP:
rib. 3 new [CadDocument][rib] tests; suite 86/1376.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
Extend CadFeatureType::Hole (no new enum value) with a style flag
(simple/counterbore/countersink) and the matching geometry: a coaxial
shallow cylinder cut for counterbores, a cone-frustum cut for countersinks.
A file-local hole_std_lookup() resolves screw designations (ISO 273/4762/
10642 metric M3–M10 + common ANSI unified) into clearance/cbore/csink dims;
add_hole_standard() fills the feature from it, add_hole_styled() takes them
explicitly. Six serialized fields appended to both symmetric cereal lists
(recipe version stays 2, golden fixture regenerated 28161->29525). MCP:
hole_styled, hole_standard. 4 new [CadDocument][hole] tests; suite 83/1351.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
New CadFeatureType::DeleteFace: removes a set of global face ids from target_body
and heals the gap via BRepAlgoAPI_Defeaturing (TKBO, already linked), mirroring the
Shell/Draft body-modifying pattern. delete_faces appended to both symmetric cereal
lists (recipe version stays 2, golden fixture regenerated 27913->28161). MCP
delete_face method (pure additions). 3 new [CadDocument][deleteface] tests: remove a
fillet face restores the sharp-box volume, bad index fails safely, round-trip.
Full kernel suite green (79 cases, 1309 asserts).
Move-face / replace-face deferred to snaporca-3c4 / snaporca-tc6 (no clean shipping
OCCT direct-modeling primitive; need research, and replace-face depends on M7 surfaces).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
Adds SketchEngine::make_bridge (4-pole cubic Bezier, G1-tangent to Line/Arc
endpoints, straight-line fallback for other types) emitted as the existing
BSpline SketchEntity — no new geometry type, no serialized-field change, golden
recipe fixture untouched. CadDocument::add_bridge appends it (non-parametric,
index-validated, throws on bad refs). MCP `bridge` method mirrors action_project.
4 new [CadDocument][bridge] tests; full kernel suite green (76 cases, 1280 asserts).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
Onshape-style "Use / Convert entities": pick edges (or a whole face) of an
existing solid and get sketch geometry projected onto a target plane, then
extrude/revolve/edit it like any sketch. Parametric: apply_project re-derives
the feature's entities from the source body on every recompute, so editing the
source updates the projection.
Line edges -> Line entities (exact); circles/arcs whose plane is parallel to
the sketch plane -> Circle/Arc (exact); everything else (incl. non-parallel
circles that project to ellipses) -> sampled Line chain.
Append-only: new enum value Project + project_source_body/project_edges/
project_face fields at the end of save/load; recipe version stays 2. Recompute
loop made non-const solely so apply_project can write back f.entities.
Suite 67->71 cases, 1178->1229 assertions, RC=0. Fixture 25493->26835 B.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
Split-by-face reuses the existing Cut feature rather than adding a new type:
two appended fields (cut_face_body, cut_face) let apply_cut derive the cut
plane from a picked face via SketchPlane::from_face when cut_face >= 0,
otherwise it keeps using the base `plane`. cut_offset / cut_flip still apply
along the derived normal, so the same square-wire split machinery handles
both cases. add_split_by_face() is the convenience entry point; MCP gains a
`split` method (body / face_body / face / keep_upper / keep_lower).
Serialization stays append-only — cut_face_body, cut_face appended to
save/load, recipe version unchanged at 2.
Tests: the previously-missing both-halves plane cut (keep_upper && keep_lower
=> two bodies whose volumes sum to the original), split-by-face via a
top-face plane offset into the interior, keep-upper-only, and a round-trip.
Golden fixture regenerated with a GoldenSplit cut-by-face feature and exact
field-value assertions. Suite 63 -> 67 cases, 1119 -> 1178 assertions.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
Pick a face of an existing body, offset it by a wall thickness along its
normal, and append the resulting thin solid as a new body. Onshape-parity
Tier-2 item; the kernel had Shell (hollow a whole solid) but no way to turn
a single face into a plate.
Kernel: CadFeatureType::Thicken, add_thicken()/apply_thicken() as a
body-level op next to Transform/Mirror. The picked face is wrapped in a
TopoDS_Shell and offset via BRepOffsetAPI_MakeThickSolid::MakeThickSolidBySimple;
the result is orientation-normalised to positive volume (same convention as
apply_mirror). Serialization stays append-only — thicken_face,
thicken_thickness, thicken_flip appended to save/load, recipe version
unchanged at 2.
MCP: `thicken` method (body/face/thickness/flip) plus the missing
feature_type_name() case.
Tests: 6 new [CadDocument] cases (plate volume within 1%, flip direction,
bad face id, zero thickness, fuse-with-source, round-trip). Golden fixture
regenerated with a GoldenThicken feature and exact field-value assertions.
Suite 57 -> 63 cases, 1054 -> 1119 assertions.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
Until now move and rotate lived only in the GUI as m_body_xform, a display
transform. That made them a correctness hole, not a missing tool: a moved body
recomputed and booleaned at its ORIGINAL position, and the move was not in the
recipe at all, so it vanished on save/reload. Only export_step consulted the
transform, which is why the discrepancy stayed hidden.
CadFeatureType::Transform makes it a real feature: rotate angle_deg about
xf_axis through xf_pivot, then translate by xf_translate, applied to the target
body with BRepBuilderAPI_Transform. xf_copy=true keeps the source and appends
the transformed body instead of mutating in place, which covers Onshape's
Transform/copy in the same feature.
Rotation is composed before translation (trsf = tr * rot) so the pivot means
what a user expects — the point the body turns about, not a point that then
drifts with the translation. A rotation with a degenerate axis is refused
rather than silently skipped; a zero angle skips the rotation entirely so a
pure move needs no axis at all.
The decisive test is not the bbox arithmetic but "moved body participates in a
later boolean at its new position": two coincident boxes, one moved to partial
overlap, fused. The fused volume must be strictly greater than one box (the
move took effect in the kernel) and strictly less than both (they still
intersect). With a display-only transform the first assertion fails.
Serialization stays append-only; recipe version unchanged at 2. Golden fixture
regenerated with a GoldenTransform feature carrying distinctive literals so a
field reorder shows up as obviously wrong values. Also fills in the Helix arm
of feature_type_name(), missing since the helix commit.
Kernel suite 51 -> 57 cases, 985 -> 1054 assertions, green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
Adds CadFeatureType::Helix — the missing input for Sweep. Sweep already
existed but could only follow a sketch, so springs, coils, augers and
non-standard-pitch threads were unreachable. Helix + the existing Sweep now
gives all of them with no further work.
Built the OCCT way: a 2D line on a Geom_CylindricalSurface (Geom_ConicalSurface
when helix_taper_deg != 0) turned into an edge and lifted to 3D with
BRepLib::BuildCurves3d — a true analytic helix, not a sampled polyline, so a
swept spring is smooth rather than faceted. The axis is the plane normal
through the plane origin, matching how Revolve and Plane already work.
Sweep's path resolution is widened to accept either a Sketch (unchanged
behaviour) or a Helix, and rejects anything else with a clear error. Helix
itself is skipped in route_feature and recompute — like the datum features, it
produces no body and exists to be consumed.
Invalid input is refused rather than approximated: non-positive radius or
pitch, negative height, a turn count above 10000 (which would hang OCCT), and
a taper that would drive the radius negative before reaching the top all fail
with a specific error.
Serialization: helix_radius/pitch/height/left_handed/taper_deg appended at the
very end of both save and load, identical order, after the coordsys block.
SNAPORCA_CAD_RECIPE_VERSION stays 2; Helix is appended to the end of
CadFeatureType. Golden fixture regenerated with distinctive literals and
field-value assertions; all pre-existing assertions pass unchanged.
Tests assert analytic values. The one that actually proves it is a helix and
not a circle or a spiral: arc length of r=5 pitch=2 height=10 measured with
BRepGProp::LinearProperties against 5*sqrt((2*pi*5)^2 + 2^2), WithinRel 1e-3.
Plus bounding box (2r in X and Y, height in Z), the conical top radius, the
left-handed winding compared at equal parameter, and the integration test:
a circle r=1.5 swept along a 5-turn helix gives one valid solid of ~1115 mm^3
(WithinRel 0.1 — pipe sweeping is not exact).
MCP: `helix` method registered in describe_tools().
Ported from snaporca 6cdc6b5459. Two fork-specific adjustments: the two new
error-message assertions use Catch2 v3's ContainsSubstring (v2's Contains does
not exist here), and the golden fixture is copied rather than regenerated
because this fork cannot be compiled locally — make_golden_doc_v1() is
byte-identical across both forks, so the blob is provably the same.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
Adds CadFeatureType::Axis and ::CoordSys — reference geometry that produces
no solid, modelled on the existing Plane datum feature.
Axis construction methods (AxisType): TwoPoints, FaceNormal,
CylinderCenterline, PlaneIntersection, AlongEdge. The centreline case is the
useful one: it gives a real axis through an existing hole or boss.
Coordinate systems (CoordSysType): PointWorld and FaceAndDirection. The
latter Gram-Schmidts the picked references, so the stored frame is
orthonormal even when the user's X hint is not perpendicular to the face
normal; the third axis is derived by cross product rather than stored, so it
cannot drift out of sync.
Revolve and pattern are deliberately NOT rewired to consume these — this
commit adds the reference geometry only and changes no existing behaviour.
Serialization: all axis_*/coordsys_* fields appended at the very end of both
CadFeature::save and load, identical order, after mirror_keep_original.
SNAPORCA_CAD_RECIPE_VERSION stays 2; Axis and CoordSys are appended to the
end of CadFeatureType so existing type ordinals are unchanged. Golden fixture
regenerated with distinctive non-default literals and field-value assertions
for every new field; all pre-existing assertions pass unchanged.
Tests assert analytic values: two-point axis direction exactly +Z with unit
length, cylinder centreline collinear with Z and on the true axis, parallel
planes fail cleanly, and the Gram-Schmidt frame is orthonormal to 1e-9 with
X x Y == Z. Degenerate input (identical points) fails with a non-empty error
rather than producing NaNs.
MCP: `axis` and `coordsys` methods registered in describe_tools().
Ported from snaporca 242d4efecb. The golden fixture is copied rather than
regenerated because this fork cannot be compiled locally (Eigen 5.0.1 vs the
build image's 3.3); make_golden_doc_v1() is byte-identical across both forks,
so the two fixtures are provably the same blob.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
Adds CadFeatureType::Mirror: reflect a target body about a plane using
gp_Trsf::SetMirror + BRepBuilderAPI_Transform. BooleanMode::New keeps the
mirrored copy as its own body (mirror_keep_original decides whether the
source survives); BooleanMode::Add fuses it back into the source, so an
overlapping mirror does not double-count volume.
Serialization: mirror_keep_original is appended at the very end of both
CadFeature::save and load (append-only contract). The mirror plane reuses
the existing `plane` member and the body selector reuses `target_body`,
as Cut already does. Golden fixture regenerated at the current
SNAPORCA_CAD_RECIPE_VERSION = 2; the existing field-value assertions all
still pass unchanged, and the reorder tripwire was re-verified after
regeneration (swapping draft_face/draft_angle in `load` alone still
fails the golden test).
Tests assert analytic values: mirrored volumes equal (8000 each) with the
reflected centroid, Add on a non-overlapping asymmetric body gives exactly
2x volume, Add across an intersecting plane gives strictly less than 2x,
and an invalid body index fails cleanly with a non-empty error.
MCP: `mirror` method registered in describe_tools().
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
Port of snaporca c2821af783. New GeometryEngine::mass_properties over BRepGProp
(separate VolumeProperties/SurfaceProperties), bounds-checked
CadDocument::body_mass_properties, and a mass_properties MCP method. Query-only:
no CadFeature, no serialization, no version change. Analytic tests
(WithinRel/WithinAbs): cube 8000/2400/COM(0,0,10)/inertia 533333, cylinder
500pi/300pi, hollow = solid-500pi, invalid index -> valid=false.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
Port of snaporca 04c3d579a7. Bump SNAPORCA_CAD_RECIPE_VERSION 1 -> 2;
deserialize_recipe sets a user-facing error distinguishing too-new / too-old
/ corrupt instead of a silent bare false. No per-version migration by design.
Golden fixture regenerated at v2 (v1 retired), field-value reorder tripwire
unchanged. Fork adjustment: Catch2 v3 string matcher ContainsSubstring
(not v2's Contains) in the two new version-mismatch tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
Hole: with no face explicitly picked, the tool fell back to the XY datum at
z=0 (the model's underside), so placing a hole from a top view read parallax-
shifted. Default to the solid's top face (top_face_index_of) so the footprint
sits on the surface being viewed; the XY/XZ/YZ dropdown still overrides.
Internal thread: the bore was re-cut at the nominal radius, which coincides
with an existing hole's wall — the coincident faces fouled the groove boolean
so it removed ~nothing (invisible thread). Cut the bore at the minor diameter
(radius - depth) instead: strictly inside any existing wall, leaving it clean
for the groove; on solid stock it forms the tap-drill.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
Sketch dimension labels — now identical to the Prepare/Preview Measure gizmo:
- draw_text repurposed to draw_dim_label: white ImGui text in a translucent-white box,
mirroring GLGizmoMeasure::render_dimensioning exactly (push_common_window_style sets the
text colour, BringWindowToDisplayFront, imgui_internal.h).
- ROOT-CAUSE FIX: world_to_screen_px multiplied two Eigen Transform3d objects
((proj * view).matrix()); a projection is not affine so Eigen mangled it -> garbage screen
coords, so labels never appeared. Now proj.matrix() * view.matrix() like Measure. (That
helper was previously [[maybe_unused]] dead code, never exercised.)
- Leaders: offset clear of the sketch line (no longer coincident with the geometry), single
point-to-point dimension line + arrows, neutral colour, width 0.6 -> 0.2.
- dim_text appends mm/in on linear dims (angles keep the degree sign).
New Design + delete:
- New "New Design" button wipes the whole document (confirm dialog) — the clear-all the
per-row Delete can't give. CadDocument::clear() now also clears bodies + display_body_meshes
(it left them stale, so solids lingered after a clear).
- on_delete_feature: a Body-row selection now shows a helpful hint (bodies are recomputed
results with no directly-removable feature) instead of silently doing nothing.
Feature tree: auto-fits its content (refresh_tree clamps height 1..9 rows, scrolls past),
instead of a fixed 140px block.
i18n (Design tab pinned English, per the UX contract):
- Restore the lost #undef _L / #define _L(s) wxString::FromUTF8(s) override atop DesignPanel.cpp;
wrap all ~54 dropdown options in _L so the single lever governs them. feature_type_name left
untranslated (machine-facing MCP JSON).
UX: per-card Value Confirm/Cancel buttons removed — the single ribbon action bar owns value
confirm/cancel via an m_value_cont guard in tool_confirm/tool_cancel. "needs a body" status
messages unified.
Both forks; DesignSketchTool.{cpp,hpp} + CadDocument.cpp byte-identical across forks. Built
clean; New Design / feature-delete / rotation / tree auto-fit live-verified on :10.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
Add an "Export STEP…" button to the Design panel that writes every body to a
.step file as native B-rep (not mesh).
- CadDocument::export_step: compound all bodies (applying their per-body Move
display transform so the STEP matches what Commit ships) and write via OCCT
STEPControl_Writer (AsIs). Full error handling incl. OCCT Standard_Failure.
- DesignPanel::on_export_step: bake any open preview, wxFileDialog save, export
at the displayed body positions, status feedback.
Kernel write path verified with a standalone OCCT box->STEP->readback check
(1 solid, non-null) against the same OCCT build.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
Port of the snaporca CAD work to the mainline fork.
- Onshape default planes (XY/XZ/YZ) at the bed centre (transparent, labelled);
modeling origin unified to the bed centre (CadDocument::modeling_origin);
world-axis triad moved to the bed centre on the Design canvas only.
- Datum plane: clickable ghost-plane base pick + draggable offset arrow.
- Slot: dims reassessed to inter-centre distance / radius / angle; fixed the
duplicate cap-arc radius quote.
- Hole: 3D cube move-handle on the face; binds to the face on the first click;
decluttered side-distance construction lines.
- Thread: derive the M spec (diameter/pitch/depth) from a picked cylindrical
surface or circular edge (GeometryEngine::circle_of_edge); fuse the helical
ridge onto the existing body; MakePipeShell fixed-binormal sweep (uniform, no
twist) + self-intersection/param guards.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
Dual-fork mandate: port the parametric-recipe 3MF persistence from
snaporca-cad commits ed19eac+147e1c4 so a saved project reopens with the
editable CAD feature tree, not just the baked mesh.
Shared kernel/format/GUI (identical to snaporca-cad):
- CadDocument serialize_recipe/deserialize_recipe (cereal BinaryArchive,
versioned) + CadFeature split save/load + imported_solid<->BRep string.
- Model::cad_recipe carried through 3MF zip entry Metadata/SnapOrca_cad.bin
(writer + binary-verbatim reader branch).
- DesignPanel on_commit() stamps the recipe; on_tab_shown() rehydrates a
loaded project via load_recipe() (deserialize -> feed_bodies + refresh_tree).
Mainline-only adapters (no snaporca-cad counterpart — Catch2 v3 vs v2):
- tests/libslic3r/test_caddocument.cpp: <catch2/catch_all.hpp> +
`using Catch::Approx;` (v3 scopes Approx under Catch::).
- tests/libslic3r/CMakeLists.txt: register test_caddocument.cpp (the
original CAD port had left it out of the test build).
Verified on behemoth (snaporca-deps toolchain): libslic3r_tests clean;
[CadDocument] 17/18 (only the pre-existing tangent-to-circle SIGABRT fails,
identical to snaporca-tkz); K1 serialize round-trip + version-reject pass
(13 assertions); new [3mf] "CAD recipe blob survives a 3mf save/load cycle"
passes byte-for-byte; orca-slicer GUI links clean (186/186, DesignPanel.cpp
compiled). Interactive :10 click-through pending (no Design-tab automation).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q