Commit Graph
100 Commits
Author SHA1 Message Date
Tommaso BianchiandClaude Opus 5 96f9261425 The offer table is generated again, and its last verb was unreachable
snaporca-ziam said gen_offer_table.py would silently delete the model-mode "Constrain sketch"
row, because that row lived in the generated header and not in tool_atlas.json. Running it found
more than that: FOUR rows existed only in the header — constrain, rename, and the three typed-
value rows sk_length / sk_radius / sk_angdist — and sk_delete's action had drifted, pointing the
sketch row at btn:delete, the FEATURE delete.

All five are now in the atlas, so the header regenerates byte-identically from it. Verbs may carry
a `note`, emitted as a C++ comment above the row: a rationale written into a generated file is
deleted by the next regeneration, which is how this started.

snaporca-z8rs (P1), found by making that true: after the atlas held all 92 verbs, the regenerated
header differed from the checked-in one by EXACTLY ONE LINE — kOfferVerbCount, 91 against 92.
Every consumer loops i < kOfferVerbCount, so the last row of the table was invisible: never listed
by show_offer_menu, never findable by mcp_run_verb. The verb that fell off the end is sk_angdist,
"Angle / distance…" — the typed-value row for a two-entity selection. On the one selection where
you would ask for the angle between two lines, the row that types it was not in the menu.

It survived because nothing compared the Sk2Ent menu against the table: sk_angdist accepts Sk2Ent
and nothing else, so an off-by-one that dropped the LAST verb was invisible from every other
selection. The vocabulary rung now covers Sk2Ent too, and picking the pair taught it one more
rig fact — shift-clicking a circle at its +X point grabs the RADIUS GRIP, which replaces the
selection with that one entity, so the pair silently collapsed to one and the offer answered
SkLine. Correctly, for the selection that actually existed.

gen_offer_table.py --check proves header == atlas and changes nothing; it is now the first step of
scripts/ladder-all.sh, and the only one that needs no rig.

Offer ladder 107/107, gesture ladder 93/93, both on the rig.

snaporca-ziam snaporca-z8rs

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MrMzTpAf78U4NG2M8jfvHY
2026-08-23 06:14:48 +02:00
Tommaso BianchiandClaude Opus 5 1bde448f51 Every 2D verb without a shortcut, driven from the offer — and three more defects
The 2D vocabulary is 46 verbs: 22 have a shortcut and the gesture ladder drives them, 24 have
none and nothing had ever exercised those. They are reachable only from the right-click offer, so
a key-driven ladder could not have touched them whatever it did. Four new rungs drive all 24, and
the coverage claim itself is now arithmetic against DesignOffer.hpp (rung O8) rather than a
sentence in a comment that rots when a verb is added.

The assertions are CONSTRUCTION invariants wherever a click cannot be exact — a regular polygon's
sides are equal to 1e-9 and its vertices lie on one circle; a tangent arc's radius at the shared
endpoint is perpendicular to the line to 1e-9 (measured cos 5.97e-17); the three clicks of a
3-point circle all lie on it; a circumscribed pentagon's circumradius is the inscribed one's over
cos(pi/5), 1.236067977 against 1.236067977. Where a value field opens, the typed value is graded
exactly: a moved line travels +25.000000000 in X and 0 in Y, a rotation turns 30.000000000 deg
and leaves the length alone, a scale multiplies it by exactly 3, a linear array's pitch is
[20.0, 20.0, 20.0] and a polar one's spokes are 60 deg apart all the way round.

Three defects found doing it, all fixed here:

snaporca-ua9g (P1) — delete_selected left three things behind. The AUTO-EDIT QUEUE, so a queued
field opened on a deleted entity and its commit went nowhere: draw a rounded rectangle, delete
everything, draw a 2-point circle, type 30 — the field opens, the digits are accepted, and the
radius stays 32.992020763. reset_autoedit() exists for exactly this and its own comment says so;
it was simply never called from here. The FEATURE GROUPS, whose [begin,end) ranges all shift on a
delete, so feature_of() answered with a group the user never drew — survivors are now remapped
and any group that lost a member is dropped, the rule the placed quotes already followed. And the
SOLVER STATE: no re-solve, so sketch_describe reported dof=16 for a document holding one circle.

snaporca-ekt9 (P2) — the read-back could not see three of its seven entity types. Ellipse,
EllipseArc and BSpline serialised as a bare type name: no centre, no semi-axes, no rotation, no
sweep, no poles. gui-ladder's ellipse rung had to grade the faceted area of the loop at 2e-2 —
that tolerance IS the faceting error — and its spline rung could only count entities. Now they
carry their parameters, and the ellipse arc's ends are asserted to satisfy (x/a)^2+(y/b)^2 = 1 to
1e-9.

Also read-only, and the reason the other two were found at all: sketch_describe now reports the
armed TOOL, the count of PENDING anchors, and whether a value field is EDITING. A menu walk that
lands one row off arms a neighbouring tool and then draws something plausible — the first run of
the authoring rung drew a circle of area 45238.93 and graded it as a rectangle. Every menu pick
now asserts which tool it armed, and the polyline rung (a per-segment Length field freezes the
canvas after every click) could only be written once the driver could ask whether a field was open.

Offer ladder 102/102 -> 105/105 with coverage. Gesture ladder 93/93 and the kernel suite
188 cases / 2532 assertions, both unchanged.

snaporca-ua9g snaporca-ekt9

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MrMzTpAf78U4NG2M8jfvHY
2026-08-23 05:11:15 +02:00
Tommaso BianchiandClaude Opus 5 8c4b05ae9d The offer ladder: drive right-click, and fix the two things it found
The gesture ladder proved the TARGET — a complex closed profile, exact in vertices, lengths, arcs
and symmetry, voids correctly attributed. It proved it by arming every tool with a letter key,
which leaves the goal's own MECHANISM untested: the design logic pivots on right-click, and the
verbs offered are supposed to adapt to the element under the cursor. 47 of 86 Design-tab verbs
have a GUI action and no shortcut, so a key-driven ladder cannot reach more than half of them.

scripts/offer-ladder.py drives the menu. It asserts nothing from pixels: show_offer_menu emits an
[OFFER] trace from the same loop that builds the rows (behind the existing SNAPORCA_KEYTRACE), so
what the ladder reads cannot drift from what the user is shown, and the expected row set is
predicted by parsing DesignOffer.hpp rather than transcribed by hand. 25 properties, four rungs:
what each element type offers, that the menu equals the table for four selections AND that the
four differ, a 120 x 80 profile authored entirely through the menu, and a tool with no keyboard
route at all driven from the only door it has.

Two real defects, both found by it, both fixed here:

snaporca-ghcz (P1) — right-click was a black hole while any draw tool was armed. Every draw case
ended with `if (evt.RightDown()) { m_points.clear(); return true; }` and returned true even with
nothing to abandon; on_mouse records that in m_right_consumed and DesignCanvas suppresses the
offer whenever it is set. Measured: with Line armed, two right-clicks in a row produced no menu
and no tool change; only Escape freed it. Same rule snaporca-xmh6 wrote for the selection —
clearing nothing is not a gesture terminator. One shared right_abandon() now consumes the click
only when an anchor was really down; 16 sites, plus Polyline/BSpline (which end a chain, correct
only when there IS one) and Point (which has no anchor at all).

snaporca-lnri (P2) — right-clicking a sketch point offered the empty vocabulary. select_at_screen
tests hit_test_point first and records the hit in m_point_sel, but the offer counts m_selection
only, so a Point entity could never reach the entity branch and SkPoint was unreachable by
construction. A Point IS its own handle, so it is selected as an entity; other entities keep the
handle pick, since a line's endpoint is a drag target, not a vocabulary.

Offer ladder 25/25, gesture ladder 93/93 (no regression), both on the rig. The offer ladder joins
scripts/ladder-all.sh as the fifth rung.

snaporca-ghcz snaporca-lnri

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MrMzTpAf78U4NG2M8jfvHY
2026-08-23 03:45:33 +02:00
Tommaso BianchiandClaude Opus 5 12047e4085 A bulk sketch_add no longer freezes the next gesture
Draw-then-edit is armed from a jump in the entity count: render() sees n > m_autoedit_seen,
selects the last entity and schedules open_primary_autoedit. A scripted add made while a
creation tool was armed looked exactly like a drawn gesture, so it opened that tool's value
field — and an open field freezes the canvas (on_mouse_impl returns early on m_awaiting_length)
and swallows every letter (in_text includes inline_busy()). Measured on the rig: after
sketch_add, 'p' + click added nothing (4 entities before, 4 after); one Escape and the identical
sequence gave 5. It also explains the selection = [last index] that sketch_describe reported
although action_sketch_add never selects anything — the render pass wrote it.

Escape worked because it sequences two set_tool calls: the pending CallAfter fires between them,
so the second one commits the field it finds open. Arming a tool directly is one call, and the
CallAfter fires after it.

Fix: resync m_autoedit_seen at the end of add_entities_scripted, so a scripted add is not read as
something the user just drew. An already-open field is left alone. Covers sketch_add,
sketch_mirror and sketch_offset — the three callers.

The scale rung's Escape workaround is deleted, which is the issue's acceptance criterion; it is
now the regression test. Gesture ladder 93/93 on the rig.

snaporca-j7gc

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MrMzTpAf78U4NG2M8jfvHY
2026-08-23 03:00:37 +02:00
Tommaso Bianchi bc1606a4d0 Port from snaporca ab22482e40: say why a sheet was skipped, and stop calling an encrypted PDF an engine failure
Over the whole 977-sheet corpus: 767 graded, 767 fully clean, 0 failures. The 210 skips are sheets whose part outline is not a closed stroked path at all (largest loop 5 to 132 mm2, measured), and one of them — MPD133 — is password-protected, which was being reported as an engine ERROR. Both now say what they are.

snaporca-j6sr
2026-08-23 02:19:30 +02:00
Tommaso Bianchi 4693542d0d Port from snaporca: the solver's 1024-unknown cliff, and the scale rungs
Two commits carried across (snaporca 579a9a9162, f68613cfc5).

Past about 480 entities a sketch had NO constraints at all and said nothing: libslvs
declares MAX_UNKNOWNS = 1024 and is handed every entity in the sketch at two params
per point, so the whole system came back TOO_MANY_UNKNOWNS and try_add_constraints
rolled the entire inferred batch back. From there no dimension could ever be applied.
Constraints only couple entities that share a point, so the solver now falls back —
only on TOO_MANY_UNKNOWNS — to solving connected components separately and committing
all-or-nothing. The auto-constraint pass batches its Horizontal/Vertical constraints
instead of one solve each, which is what kept the bulk path fast once solves started
succeeding: a 1204-entity load went 1585 ms -> 562 ms.

Plus the scale rungs (a thousand-entity plate drawn on by hand; the heaviest real
drawings graded and timed), the --step 1 fix that used to select nothing while
reporting a clean run, and scripts/ladder-all.sh as the one-command gate.

Parity 17 identical / 8 diverging as expected. Kernel suite here: 188 cases /
2532 assertions, including "a sketch past the solver's unknown limit still solves".

snaporca-yww4, snaporca-x6v7, snaporca-j6sr
2026-08-23 02:04:03 +02:00
Tommaso Bianchi 82db99f337 Port from snaporca: sketch-only projects save, scripted geometry arrives exact,
and a ladder that draws with the mouse

Three commits carried across (snaporca 4ffd60eacb, 421055c2ec, b71216ce0b):

1. A design made only of sketches must survive being saved. CadDocument::recompute
   returned false with "no solid-producing features" for a document that has no
   solid, and two callers read that as "unusable": the GUI syncs the 3MF recipe
   only after a successful recompute, so a sketch-only design was saved with no
   recipe at all, and deserialize_recipe ends with `return recompute()`, so even a
   project that carried one was refused on load. Having nothing to build is now a
   success; a feature that MEANT to build a solid and produced none still fails.
   DesignPanel::refresh_tree syncs the recipe too, for the paths that call
   m_doc.recompute() directly.

2. Scripted geometry arrives exact. The Horizontal/Vertical inference window and
   the endpoint weld window both close to zero for add_entities_scripted; void
   attribution probes from a point strictly inside each loop instead of from its
   first vertex. Corpus rung 39 graded / 39 fully clean, was 35 with 6 failures.

3. scripts/gui-ladder.py — 17 rungs, 84 properties, all driven by synthetic clicks
   and typed values rather than through the socket.

Parity 17 identical / 8 diverging as expected. Kernel suite here: 188 cases /
2532 assertions.

snaporca-mtav, snaporca-8xg1, snaporca-5hvl, snaporca-730j
2026-08-23 01:22:32 +02:00
Tommaso Bianchi 05ce2607a8 Ladder rung 9: grade the engine against 50 real drawings, not against my taste
Ported from snaporca 5b82c846f3.
2026-08-22 23:28:36 +02:00
Tommaso Bianchi 1274d97983 Sketch: closing a polyline is now a previewed snap, not an invisible bubble
Ported from snaporca 982968b1af.
2026-08-22 23:03:47 +02:00
Tommaso Bianchi 55a7baf067 Sketch usability: no invented geometry, no silent refusals, no stranded field
Ported from snaporca aab4248db8.
2026-08-22 22:51:28 +02:00
Tommaso Bianchi 1935ebb363 Sketch: a tool switch must not leave the rest of the queue armed
Ported from snaporca 8f5adda891. See that commit for the full analysis.
2026-08-22 16:05:18 +02:00
Tommaso BianchiandClaude Opus 5 fbacdeca7b Port: only the visible canvas may consume the 3D-mouse queue
Carries snaporca 9f0a6656bc.

Mouse3DController::apply DRAINS the input queue and every BOUND canvas idles and calls it, but a
hidden canvas's render() early-returns on _is_shown_on_screen() — so it swallows motion, applies
it to the shared plater camera, and draws nothing. The next visible frame jumps by more than one
state change. The plater keeps exactly one of its three views bound; the Design canvas binds once
at construction and never unbinds, so two canvases drain the same queue.

Guarded at the apply site so only the canvas actually on screen takes motion off the queue,
whatever happens to be bound, and without touching the plater's view-switching state machine.

Reported by exussum12 on PR #15238 as lag and jerkiness in the Design tab against "really smooth
on the other tab"; he guessed the mechanism correctly. NOT verified with a device — there is no
SpaceMouse here and the rig has no HID, so this is a mechanism traced in source and matched to a
user's description, not a measurement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 15:10:18 +02:00
Tommaso BianchiandClaude Opus 5 942f6c28c7 Port: mirror emits a half that continues the chain
Carries snaporca 0231bd5b68. Parity holds: 17 files identical, 8 diverging as expected.

A reflection reverses orientation, so mirror_entities now hands the reflected half back reversed
in ORDER and flipped per ENTITY — an arc swapping its angles as well as its ends, a spline
reversing its control points. Appending it to the source then yields one walkable chain instead
of two halves meeting head-to-head, and a mirrored CCW loop stays CCW.

This is the producer half of the confusion that cost three defects; the consumers (offset, and
the exact loop area) keep their defensive handling, because that is what makes them correct for
hand-built and imported sketches rather than only for geometry this function produced.

Contract change, carried with the reason: a mirrored line's p0 is the reflection of the SOURCE's
p1, and a mirrored CCW arc keeps a POSITIVE sweep — the reflection negates it, walking it the
other way negates it again. Both [SketchEdit] cases updated, and a new [SketchProfile] case
"a mirrored half continues the original chain" pins the property directly.

Kernel here: all tests passed, 2687 assertions in 232 test cases. GUI target builds and links.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 15:00:50 +02:00
Tommaso BianchiandClaude Opus 5 cbbd24dcb4 Port the exact loop area, the offset traversal fix, and the 2D sketch ladder
Carries snaporca 572f794c84, d0f9a0052a, 9f7e4e3627 and 3974f8a170. Parity holds: 17 files
identical, 8 diverging by their expected counts.

EXACT AREA. A loop's area is now integrated entity by entity in traversal order — Green's
theorem — instead of being shoelaced over the render polyline, which faceted every arc into 24
chords and lost 2.02 mm2 on a 3706.86 mm2 stadium. 0.054%, invisible on screen, and wrong in a
number reported as "the area".

OFFSET FOLLOWS THE TRAVERSAL. Offsetting a mirrored profile put one half on the wrong side and
split the loop in two, because the chainer only followed p1->p0 links and each entity's offset
side was taken from its stored direction. Chains are now orientation-aware, seeded at a free end,
offset by `reversed ? -d : d`, and normalised head-to-tail on the way out — so offset is correct
for any input ordering and its own output cannot reintroduce the problem.

Both are the same underlying lesson, which has now cost three separate defects: an entity's
STORED direction is not its direction of TRAVEL around the loop.

THE LADDER. scripts/sketch-ladder.py is a graded suite of 2D sketches judged the way a person
judges them — VERTEX, LENGTH, ARC, TANGENT, SYMMETRY, CLOSED — with area only as a cross-check,
because area is derived and nobody can confirm it by eye. Eight rungs from a rectangle up to
MPD5 from the StudyCadCam corpus, a dia 27 x 95 pin reproduced as its revolve half-profile with
the R5 fillet tangency solved exactly. Entirely 2D: no extrude or any solid feature.

Kernel here: all tests passed, 2681 assertions in 231 test cases, including the new
"profile: a mirrored half offsets as one loop, not two". GUI target builds and links.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 14:26:46 +02:00
Tommaso BianchiandClaude Opus 5 510e63dff2 Port the sketch usability fixes: Enter/Esc, rename, stale picks
Carries snaporca 95e59289f9, faec177d42 and 20df726ecb. Parity re-verified: 17 files identical,
8 diverging by their expected counts — DesignCanvas.cpp back to 16 and DesignPanel.cpp back to
32, which is the proof each hunk landed on the right side of the FeatFlyout and TAB_ID_PREPARE
divergences rather than on top of them.

All three answer exussum12's review on OrcaSlicer PR #15238.

ENTER/ESC IN THE VALUE FIELD. The field is a borderless always-on-top frame, and whether it may
hold keyboard focus is the platform's decision — a borderless NSWindow can never be key, and
mutter refuses a re-mapped window. When focus is denied the keys reach the panel instead and the
queued-dimension chain (a line queues Length then Angle) cannot be walked. The CHAR_HOOK now
forwards Enter/Numpad-Enter/Tab/Esc to the field when it is open and unfocused, and stays out of
the way when it is focused.

ESC FROM ANYWHERE. Separately and more simply: `dismissable` is false throughout sketch mode
because m_active is the FEATURE tool, so Esc fell through to whatever widget had focus. Click
any toolbar button or the Construction checkbox first and Esc did nothing at all — the likelier
reading of "Esc hardly ever works", and platform-independent. DesignCanvas exposes
request_sketch_exit() and the hook calls it whenever a sketch is live, after the inline-field
forwarding so an open field still takes Esc first.

RENAME. wxTR_EDIT_LABELS plus the two label-edit events write through to CadFeature::name and the
recipe, with a Rename verb in the offer and F2. The rebuild is deferred with CallAfter because
refresh_tree() destroys the very wxTreeItemId wx is holding during END_LABEL_EDIT — inline, it
killed the process.

STALE PICKS. set_tool now drops the Dimension tool's first pick, the Constrain picks and
m_point_sel, and delete_selected clears the pending dimension reference that could otherwise
dereference a renumbered entity.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 12:11:53 +02:00
Tommaso BianchiandClaude Opus 5 fbf858ba47 Port the MCP verb surface: run_verb / list_verbs / sketch_set_value
Carries snaporca 39fac9b725. Parity re-verified: 17 files identical, 8 diverging by their
expected counts, DesignPanel.cpp still at 32 — the mirrored files were copied and the two
divergent ones patched hunk by hunk, so the counts returning to their expected values is the
proof each landed on the right side.

All 90 offer verbs are now firable by name over the socket, which matters because a deck key
can only send a keystroke and 49 of them have no shortcut at all. sketch_set_value calls the
same apply_dimension the in-canvas value field calls, so a typed dimension can be asserted with
no window manager in the way.

Three guards came with it, each confirmed against the source: on_mass_properties bounds-checks
m_sel_solid_body (it defaults to -1, and run_verb bypasses the menu grey-out that used to hide
that); sketch_set_value validates its value at the boundary because apply_dimension records a
driving constraint even for values it refused to apply; and run_verb refuses btn:/fly: verbs
that do not apply to the selection while leaving key: verbs alone, so the socket offers exactly
what the GUI offers. Dispatch is deferred through CallAfter so no modal verb can wedge the
socket thread.

GUI target builds and links against the rebuilt deps image.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 09:53:09 +02:00
Tommaso BianchiandClaude Opus 5 5d7fc8c545 Port the sketch layer work from snaporca: offset chains, right-click, MCP verbs
Carries snaporca 971320e129, 6b049f0dc6, 4aae782029, 444d59f212, 74cf3d7e54 and the build
guards from 597557a6e4. Parity re-verified after every hunk: 17 files identical, 8 diverging by
their expected counts — DesignPanel.cpp still 32, DesignCanvas.cpp still 16, which is the proof
each hunk landed on the right side rather than being copied over a real divergence.

OFFSET OFFSETS THE CHAIN. Per-entity offsetting returned a closed rectangle as four parallel
segments that no longer touch, so entities_to_wires gave four OPEN wires and nothing could be
extruded. offset_entities now chains by shared endpoints and repairs each seam by mitering the
neighbours to their intersection. Second bug, invisible to any single-entity test: +d meant
"left of travel" for a line but "radius + d" for an arc regardless of sweep, so a slot outline
offset with its straights going one way and its caps the other. The convention is now written on
the declaration and pinned by a test.

tests/libslic3r/test_sketchprofile.cpp is new and asserts the LOOP rather than coordinates —
the property that decides whether a profile can be built, and the one the existing single-entity
[SketchEdit] cases cannot see. Its include is catch2/catch_all.hpp here: this fork ships Catch2
v3 while snaporca is on v2, which is why the test files are a tolerated divergence.

RIGHT-CLICK PICKS WHAT YOU POINTED AT, so a line's own verbs are offered instead of the
empty-selection vocabulary; sk_delete stops sharing btn:delete with the feature tree; and an
element's defining number (length / radius / diameter / angle / distance) can be typed, from the
menu or from V.

TWELVE MCP SKETCH VERBS. The socket had ~40 verbs and none touched a sketch, so the 2D layer
could only be exercised by driving a GUI with synthetic clicks. sketch_describe reports each
closed loop, the loops it encloses as voids, exact areas, and where a chain is still open;
sketch_validate/sketch_heal are FreeCAD's ValidateSketch — find vertices that overlap within a
tolerance but carry no coincidence, then weld them AND record the constraint, so a loop closed
by floating-point luck becomes one closed by construction. scripts/mcp-sketch-smoke.py is the
loop that asserts all of it.

Kernel suite on this fork: all tests passed, 2677 assertions in 230 test cases. The GUI target
links against the rebuilt deps image (the wxInspector blockage is gone) and the binary carries
the new verbs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 09:00:26 +02:00
Tommaso Bianchi df45edb13d deps image: carry the rig's X runtime itself
Building orcacad-deps from this Dockerfile removes the snaporca-deps base, which is the point
(Trap 1: the old image's baked tree was project(Snapmaker_Orca)) — but that base was also where
Xvfb, openbox, xdotool and scrot came from. Without them gui-session.sh reports display DOWN and
orca-slicer dies with a trace trap on no display. Verified: the session now comes up with
display up, wm up, and the Untitled - OrcaSlicer window present.
2026-08-22 08:49:39 +02:00
Tommaso Bianchi 9764815cc3 rig-build: -j12, and a deps image that has wxInspector
Raises the bound from -j8 to -j12: the incident dump measured 1.17 GB average per cc1plus,
so 12 in flight is ~14 GB typical, well inside the 40 GB cgroup ceiling that is the real
guarantee. Keeps the file byte-identical to snaporca's copy, which the header requires.

Dockerfile.deps builds the deps with -j 12 for the same reason, and symlinks
deps/build/destdir -> deps/build/OrcaSlicer_dep: this tree installs under the latter name
while the orcacad_buildcache volume has the former baked as absolute paths in its CMakeCache.
Same tree, two names — without the link, one directory rename costs a full cold rebuild.
2026-08-22 08:49:39 +02:00
Tommaso BianchiandClaude Opus 5 8906bfa72b rig-build: bound the memory a build can take
Both forks ran this script at once on 2026-08-21, each with ninja -j$(nproc)=16.
36 cc1plus held 42 GB of a 62 GB box, the kernel OOM-killed for 2h28m, ssh went
unreachable, lightdm was destroyed (2946 session kill events), and neither build
produced a single object file.

Three bounds, weakest to strongest: a flock on a path SHARED by both forks so
they serialise instead of summing; -j8 so the box stays usable while it
compiles; and --memory on the container, which is the actual guarantee — a
runaway build now dies inside its own cgroup instead of taking the host with it.
--memory-swap is pinned equal to --memory because swap thrash is what made ssh
hang rather than fail.

Kept byte-identical to the copy in the snaporca fork, as the header requires.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 08:49:39 +02:00
Tommaso BianchiandClaude Opus 5 3ad6d2fd50 Give Commit to Plate and the bed toggle a keyboard, on a Ctrl+Shift layer
Both were mouse-only: Commit to Plate is a toolbar button bound to wxEVT_BUTTON,
the bed is a CheckBox, and neither had an accelerator. That put them out of
reach of anything driving the keyboard, and out of reach of a hand that had not
already left the model to find them.

Ctrl+Shift, because the Shift+letter space is full to the last letter and
because the char hook deliberately ignores every Ctrl-combo -- which is exactly
what leaves this layer free to claim. P is Plate and B is Bed; neither collides
with OrcaSlicer own Ctrl+Shift+S (Save as) or Ctrl+Shift+G (Print plate), and
nothing else in the tree binds either.

The lookup goes ahead of the guard that drops Ctrl-combos, and nothing already
bound changes meaning: a plain Shift+letter still resolves as before, because
the new layer only answers when Ctrl is held as well.

The bed toggle drives the checkbox rather than the viewport alone, so the
control and the view cannot disagree about what is shown, and it says which it
did in the status line.

Both verified on the running build: Ctrl+Shift+B toggles the grid and the
checkbox together, Ctrl+Shift+P commits to Prepare.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 16:25:29 +02:00
Tommaso BianchiandClaude Opus 5 eb66e45b7b Design: confine the mapped-frame workaround to GTK, so macOS stops hanging
Reported on PR #15238: on macOS, drawing a corner rectangle and pressing Enter
on the first dimension opens the second field and then wedges the entire
application — no typing, no Escape, dead menu bar, no tab switching, and the
field stays composited over the desktop after minimising. That last detail is
what identifies it: an already-drawn window keeps being composited by the
WindowServer once the process stops answering. The main thread is stuck.

4c8c93b512 is the only commit that ever changed the second-queued-field path.
It stopped unmapping the value field between two queued dimensions, and its
whole justification is mutter: focus-stealing prevention refuses keyboard focus
to a window that was just re-mapped, which left the second field visible but
dead on GNOME (snaporca-p8uw). It was applied with no platform guard, so macOS
re-activates an already-visible borderless NSWindow at NSModalPanelWindowLevel
from inside wxOSX's pending-event drain — which on macOS runs from a
CFRunLoopObserver at kCFRunLoopBeforeTimers (evtloop_cf.cpp) over an unbounded
`while (!m_handlersWithPendingEvents.IsEmpty())` (appbase.cpp).

One constexpr now carries that choice, and both halves of the contract read it,
because the failure mode of this fix is the two halves drifting apart: open()'s
reuse-if-shown and do_commit()'s deferred hide are one decision, not two.

Not a macOS guess I could not check: the non-GTK branch was exercised on the
Linux rig by forcing the constant to false and rebuilding. A corner rectangle
drew, its first field committed at 60 mm, the SECOND field opened, committed at
40 mm, and the sketch ended at 60.0 x 40.0 mm with the field closed and no
freeze — so the map-afresh path is functionally complete, not merely different.
On GTK the constant is true and every generated instruction is unchanged.

What is still unproven is the exact line where macOS wedges; the reporter has
been asked for a `sample` of the hung process. This fixes the cause the evidence
points at without waiting for that, and cannot regress the GTK behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 18:37:01 +02:00
Tommaso Bianchi 52d16e4218 Merge remote-tracking branch 'prfork/cad-mainline' into cad-mainline 2026-08-20 17:45:15 +02:00
Tommaso Bianchi 2b02a8e3dd Design tab: move the CAD sources into their own folder
Review request on PR #15238: "Place CAD-related files (e.g. CadDocument/
GeometryEngine) into a separate folder."

  src/libslic3r/CAD/     the kernel — CadDocument, GeometryEngine, the four
                         Sketch* units, SketchSolver, ThreadStandards
  src/slic3r/GUI/CAD/    the tab — DesignPanel, DesignCanvas, DesignSketchTool,
                         SketchInlineEditor, McpControl, generated DesignOffer

Pure relocation: no line of logic changes. Two include rewrites follow from it —
files that moved re-spell their own neighbours against src/ (already on the
include path), and files that did not move pick up the new folder. docs and
docs/ux/mockups/gen_offer_table.py follow the same paths.

Verified: libslic3r, libslic3r_gui and libslic3r_tests all build, CAD suite green
at 2518 assertions in 194 test cases, and the sibling fork builds identically —
17 shared sources still byte-identical, 8 diverging by their expected counts.
2026-08-20 17:44:34 +02:00
Tommaso Bianchi 5d120921d5 deps: build libslvs as a dependency instead of vendoring it in src
Review request on PR #15238: "Move the SLVS to deps if the source code remains
unmodified. If any changes were made to the source code, move it to deps_src."

It is unmodified — all 20 files under src/libslic3r/slvs were byte-identical to
JacobStoren/SolveSpaceLib@4d87045, the extraction of solvespace.com's libslvs.
So deps/ it is, fetched by hash like every other dependency.

Only the CMakeLists is ours: upstream's builds a demo executable and installs
nothing, so deps/SLVS/CMakeLists.txt.in replaces it via PATCH_COMMAND — the same
shape deps/OpenCSG already uses. The public header keeps its spelling, so
SketchSolver.cpp still says `#include <slvs.h>` and needs no edit.

The CI deps cache is keyed on hashFiles('deps/**'), so it rebuilds itself.

Verified: dep_SLVS builds and installs, libslic3r links against SLVS::slvs, and
the CAD suite is unchanged at 2518 assertions in 194 test cases.
2026-08-20 17:44:19 +02:00
Tommaso Bianchi 6fc3c99e31 Mate connectors: draw the dashed pair line between the two origins
The last unbuilt element of snaporca-wgsc. Two connectors a mate binds are one
object with a gap still in it; drawn as two separate frames they read as
unrelated, and "which two of these five frames are the mate?" had no answer on
screen at all.

Dashed, grey, drawn IN WORLD along the segment joining the origins -- so it
foreshortens with the model and its length is the gap the mate has left to
close. A Fastened mate therefore draws nothing, which is correct: the gap is
zero. Screen-constant dash pitch (6 px dash, 4 px gap) like every other gizmo
here, with the pitch opening up beyond 400 dashes so a mate across a large
assembly cannot emit thousands of segments. Depth test off: the line's job is
to say "these two belong together", and it has to say it even when a part sits
between the camera and one end.

Fed from BOTH sources of polarity truth, the same two the role colours already
use: every committed Mate feature (connectors named by feature index), and the
live pick of an open Mate card (named by combo ordinal). Only resolved frames
are eligible, so an unresolved end draws no line rather than a line to the
origin of the world.

RIG-VERIFIED on :10 with the fresh binary (/OrcaSlicer/build/src/Release,
2026-08-17 12:43): two imported bodies, a face-and-direction connector on each,
Planar mate offset 40. Sampling the segment between the two origins gives a
regular dash/gap alternation of 13:10 sample units -- the 6:4 px pitch -- in the
stroke grey (107,117,133), over both solids. The grey end keeps its open collar
head and the blue end its filled one, so polarity and pair now read together.

Refs snaporca-wgsc
2026-08-17 14:48:05 +02:00
Tommaso BianchiandClaude Opus 5 b4d6abc57a Design: draw the mate connector as a bear face, with the disc kept behind a preference
Tommaso's decision (snaporca-x0kd): face orientation is hardwired perception -- a toddler reads
a face's roll and verse with no instruction -- so the connector is a face by default and the
conventional disc + roll quadrant stays, selectable, for users who expect it.

  Preferences > Control > Camera > "Draw mate connectors as a face", default ON, key
  design_connector_face_glyph. Read every frame rather than latched, so toggling takes effect on
  the next repaint -- a look you cannot A/B without restarting will not get compared. Verified on
  the rig: unchecking it switches the viewport to the disc live, no restart.

WHY A RELIEF AND NOT A DRAWING. A flat face in the connector's plane foreshortens by
sin(elevation) and collapses at a grazing view exactly like the quadrant it replaces -- measured,
the quadrant falls 89 -> 20 -> 3 -> 0 lit pixels from 47 degrees to edge-on. The relief does not:
its silhouette carries the information. So the glyph is a small shaded solid, painter-sorted,
lambert-shaded against a light fixed in CAMERA space so orbiting does not swing the shading.

THE MUZZLE, AND THE MISTAKE THAT NEARLY LOST IT. It is the only feature standing along +Z, so it
says which way the connector points and it is all that survives edge-on. Two errors on the way:

  1. I built its footprint from height*tan(draft) and got a needle. The real base OVERHANGS the
     crest at both ends (0.062 nose, 0.034 tail) and that overhang is what makes it a wedge. Base
     now lifted straight off the mesh.

  2. Worse, I chased fidelity. Scaled honestly the ridge is 11.3 mm on an 83.3 mm face -- 13.6 %
     of the width -- and at 22-48 px that is a scratch. Tommaso looked at it and could not find
     the muzzle at all, which is the only test that counts. A glyph is a symbol, not a scale
     model, so it now gets two deliberate exaggerations, and COLOUR does most of the work:
     muzzle share of lit pixels at 90/16/6 deg -- body tone 14.8/11.3/17.5 %, accent gold
     18.3/19.2/23.9 %, accent gold at 1.8x width 23.5/25.2/31.2 %.

  The accent is the same gold the disc spends on its roll quadrant, so it stays this tab's "here
  is the direction that matters" colour. Polarity is still on the Z arrow's head; nothing collides.

A connector whose ROLL COULD NOT BE DERIVED keeps the disc treatment whatever the preference says.
A face asserts a definite orientation, and asserting one for a roll that was never derived is the
same confident lie that got billboarding rejected.

Geometry is emitted from the part by docs/design/mate-connectors/emit_glyph_table.py, not
hand-drawn, so glyph and printed connector cannot drift: 12-vertex outline, two eyes, chin bar,
cheek dot, and the snout wedge. Crest 29.0 mm / 6.58 mm drop / 13.1 deg against the review's
28.3 / 6.61 / 13.1 on the B-rep.

Also fixes extract_outline.py, which walked w.Edges: OCC returns them in storage order, not ring
order, ignoring per-edge orientation, so the outline was scrambled -- 45 points and perimeter
6.380 where a clean ring gives 31 and 3.335. Every measurement in the design notes was re-run.
The correction reversed one earlier finding: handedness does NOT read on its own (5.4/8.0/9.1 %
different from its mirror, not the 32-35 % the scrambled ring produced), so the cheek dot is
required rather than merely nice.

RIG-VERIFIED on Xvfb :12 against a 60x40x10 box with a face+edge connector: the face renders with
both eyes, ears, chin bar, cheek dot and a gold muzzle standing proud; the Z arrow degenerates to
its ring when viewed down the axis; and the preference switches to the disc live.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 18:26:50 +02:00
Tommaso BianchiandClaude Opus 5 555af98474 Mate connectors: bring the design record and the BearConnector pair into the repo
The connector work has lived outside the code since 2026-08-05, in a workspace repo with no
remote. It is the basis of a decision that now shapes the Design tab, so it belongs here.

  docs/design/mate-connectors/

  DESIGN_MATE_CONNECTORS.md   seven CAD systems surveyed; the frame-pair model this kernel
                              already matches; sections 8b/8c on the glyph, and section 9's
                              four open decisions (D1-D4) still awaiting Tommaso.
  bear.step                   the male, Onshape 2026-08-05T08:27Z, md5 faf228326ee3f971
  BearConnector_Female*.step/.stl, BearConnector_Cutter.step
                              built by make_female.py FROM the real male B-rep rather than
                              re-modelled, so the pocket is complementary by construction
                              including every deliberate asymmetry. Fit measured at exactly
                              0.2000 mm, zero interference, mated hosts proven coplanar.
  BEAR_CONNECTOR_REVIEW.md    the symmetry-group result: identity 81/81 edges, mirror-x 0/81,
                              mirror-y 0/81, rot180Z 0/81, rot90Z 0/81, diagonal 0/81 at
                              0.1 mm. Trivial group, so every PARTIAL view fixes orientation.
  extract_outline.py, simplify_study.py, relief_sheet.py, handedness.py, make_female.py,
  trim_female.py, fit_check.py, verify_trimmed.py, coplanar_test.py + their sheets

THE DECISION THIS SUPPORTS (snaporca-x0kd): the mate connector is drawn as a simplified BEAR
FACE by default, with the standard disc + roll quadrant + Z arrow kept behind a preference.
Face orientation is hardwired perception -- a toddler reads a face's roll and verse with no
instruction -- and no abstract glyph earns that. Measured against the alternative: the disc's
gold quadrant+tick falls 89 -> 66 -> 37 -> 20 -> 3 -> 0 lit pixels as the camera drops from
47 deg to edge-on, and is a shapeless blob by 16 deg.

WHAT THE SIMPLIFICATION STUDY SETTLED (snaporca-wi3z), all measured off the real B-rep:

  The eyes are load-bearing. Same outline and muzzle with the eyes removed stops reading as
  a face at every size. Whatever else goes, they stay.

  45 -> 22 outline vertices with no loss of read at 22 / 32 / 48 px; the muzzle reduces to
  one filled triangle. Three marks plus a cheek dot.

  Drawn FLAT the face fails exactly where the disc fails: in the connector's plane everything
  foreshortens by sin(elevation). Rendered as its real relief instead, lit pixels at 32 px go
  164 -> 210 at 16 deg and 69 -> 120 at 6 deg, and the snout ridge stands proud as a profile
  rather than smearing. The glyph must be a shaded relief, not an outline.

  Handedness already reads without any added mark -- 32 to 35 % of lit pixels differ from the
  mirror, and re-registering by best whole-pixel translation returns offset (0,0), so it is
  real shape asymmetry. But it reads only BY COMPARISON. A dot on one cheek makes it local:
  34.5 / 37.0 / 36.4 %, and unlike uneven eyes (42 %) it does not read as a defect.

Tommaso's calls: it stays a bear, and handedness must read.

The scripts were repointed at the co-located male and extract_outline.py re-run from here to
prove it -- same 45 outline points, same three inner wires, same 3829.5 mm2 back plate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 17:05:09 +02:00
Tommaso BianchiandClaude Opus 5 799f840218 Thicken Surface: fill the corners of a closed-loop wall
snaporca-wm4s. Thickening the 4-walled open box (60x60 in plan, 40 tall, no caps)
by 5 produced volume 29648.15 where the geometry requires (60^2-50^2)*40 = 44000
— about 67% of it. The corner material at the four vertical edges was simply
absent.

CAUSE. MakeThickSolidBySimple offsets each face along its own normal and sews;
it never extends neighbours to meet, so wherever two faces join at an angle the
corner is empty. A flat sheet has no such join and was always exact (18000.000),
which is why the defect looked like a measurement artefact.

WHY THE TWO EARLIER ATTEMPTS COULD NOT HAVE WORKED. Both switched to ByJoin —
plain, then with Intersection/GeomAbs_Intersection — and both returned a shell,
not a solid, so the body lost its volume entirely and both were reverted. That is
not a parameter problem: in OCCT, BRepOffset_MakeOffset::MakeThickSolid builds a
solid only inside `if (!myFaces.IsEmpty())` (BRepOffset_MakeOffset.cxx:1115).
Handed an open sheet with no closing faces, it stops after the offset shell and
returns it, reporting IsDone() with a non-null shape containing no TopAbs_SOLID.
ByJoin hollows a CLOSED solid by removing faces; an open sheet is outside its
contract.

FIX. Close the sheet, then use the call that mitres: cap the free rims
(ShapeAnalysis_FreeBounds -> MakeFace), sew shell+caps into a closed shell, make
a solid, and hollow it inward passing the caps as the faces to remove — the caps
come back off and leave the wall. Two details, each found by measurement rather
than reasoning:

* A shell sewn from an extruded sheet carries no guarantee of outward
  orientation, and MakeSolid does not fix it. Inside-out, the inward offset goes
  OUTWARD: measured bbox 70x70x40 and volume 339141.59, larger than its own
  bounding box because the result overlaps itself. A negative GProp mass is
  exactly that inversion, so it is the test; Reverse() on it.
* A SINGLE face has no neighbour to mitre and must keep the BySimple path. It
  does have a free boundary, so "has free wires" is the wrong question — capping
  a lone face with its own rim sews a zero-thickness shell and measures 6000
  against 18000.

Also: IsDone() is not a success test here, since both failed attempts had it
true. The code now explores for TopAbs_SOLID and refuses a shell.

Tests: new case asserts 44000 with the wall's bbox at 60x60x40 (catching the
inverted-orientation shape, which has the right volume nowhere near the right
place), plus the flat-sheet control at 18000 that must not regress. Full kernel
suite green: 2502 assertions in 187 test cases.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 23:13:32 +02:00
Tommaso BianchiandClaude Opus 5 98135cf529 Don't block cloud sign-in because the setup wizard is unfinished
Follow-up to the previous commit, found by driving the app: clicking Login /
Register on a fresh install pops "You are currently in Stealth Mode. To log
into the Cloud, you need to disable Stealth Mode first." — to a user who has
never touched the toggle, whose config says stealth_mode: false.

It is the same pre-wizard latch one step earlier. handle_web_request() gates the
login commands on get_stealth_mode(), which reports stealth while
firstguide/finish is unset, so the app blocks sign-in because the wizard is
unfinished — backwards, since signing in is how a user leaves that state.

Worse, the escape it offers does not work. "Quit Stealth Mode" writes
stealth_mode = false, which was ALREADY false, and never touches the latch: the
config is byte-identical afterwards and get_stealth_mode() still returns true.
The user clicks the button, believes stealth is off, signs in, and finds every
cloud feature still dead. That is the state the reporter of #15239 described.

So the login guard now reads the user's OWN setting via the new
get_stealth_mode_setting(), not the pre-wizard default. A user who deliberately
enabled Stealth mode still gets the dialog and the working Quit button; a user
who merely closed the wizard goes straight to the login page.

Measured on Xvfb with a fresh datadir (firstguide absent, stealth_mode false):
before, clicking Login produced a "Stealth Mode" window; after, it opens the
"Login" window directly. And with the previous commit's latch release, a real
Orca Cloud sign-in on that same unfinished-wizard profile now runs the whole
post-login flow — the sync prompt fires (sync_user_preset lands in the config),
the per-user preset folder is created, and Sync Presets syncs with no refusal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 22:18:34 +02:00
Tommaso BianchiandClaude Opus 5 7d313159df Fix cloud features staying off after login when the setup wizard was closed
Reported on #15239: after signing in to Orca Cloud on a fresh install, no sync
prompt appears and File > Sync Presets is greyed out with nothing to explain why.

CAUSE. AppConfig::get_stealth_mode() returns true whenever `firstguide/finish` is
unset, and that flag is written in exactly one place — GuideFrame::SaveProfile(),
i.e. only when the setup wizard is COMPLETED. Closing the wizard is what people do
today to reach the login (the wizard never offers it, which is #15239 itself), so a
new user ends up permanently in a stealth mode they never chose. Every cloud gate
keyed on get_stealth_mode() then switches off silently, including:

  * GUI_App::on_user_login_handle(), which returns EARLY on stealth — so the whole
    post-login flow is skipped: preset migration, plugin fetch, user-preset load and
    show_sync_dialog(). That is the missing sync prompt.
  * the Sync Presets item in both the top menu and the File menu, whose enable
    lambda was `is_user_login() && !get_stealth_mode()`. That is the greyed item.

The result is indistinguishable from real Stealth mode, and nothing in the UI says
so, because the one place that DOES explain it — the "Quit Stealth Mode" dialog in
handle_web_request() — only covers the homepage login commands.

FIX, two parts.

1. The pre-wizard value is a DEFAULT for "the user has not been asked yet", not a
   setting, so it must not survive the user answering. Signing in to a cloud account
   is that answer. AppConfig now carries a session-only `m_cloud_logged_in` mirrored
   from the network agent (on login, on logout, and at agent start so a restored
   session counts), and get_stealth_mode() consults it before falling back to the
   pre-wizard default. An explicit Stealth mode setting is untouched and still wins:
   a user who turned it on deliberately stays offline whether or not they sign in.

2. Sync Presets no longer greys itself out. Both refusal paths already had a message
   to show — "You must be logged in…" and now one for Stealth mode naming the
   Preferences toggle — and the enable lambda was making both unreachable. A disabled
   item that cannot say why is the reason this took a bug report to find.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 21:14:35 +02:00
Tommaso BianchiandClaude Opus 5 8e5d0d195c Design: double-click a feature row to edit it, and preview Transform live
snaporca-x1k7 filed three Transform defects. Two were real and are fixed here;
the third does not reproduce and is withdrawn with its measurement.

(1) The feature tree had no ITEM_ACTIVATED binding at all, so double-clicking any
row only highlighted it. Double-click is the documented edit gesture elsewhere
(a committed sketch opens that way on the canvas), which made every feature look
dead until the user found the Edit button in the section header. Bound to
on_edit_feature(), so the gesture now works for every feature type, not just
Transform.

(3) The Transform card's typed fields called refresh_preview(), but preview_fields
returns {} for Transform and the solid-preview path has no ghost to build for a
feature that moves an existing body — so typing a distance changed nothing on
screen until Confirm. The fields now drive the same channel the gizmo drag already
uses: the body's display transform. In EDIT mode the committed transform is
already baked into the kernel geometry, so the preview undoes it first; without
that term, re-opening a committed Z=20 and typing 40 would show the body at 60.

(2) NOT REPRODUCED. Dragging a rotation ring does fill the field: a tangential
drag on the red ring gave Rotate axis = X, Angle = 27.44 deg, plus the translation
that rotating about the card's pivot implies (Y 17.60, Z -62.11). The original
reading came from a drag that never grabbed the 7 px ring; this run took its
candidate points from the rendered ring pixels themselves and 6 of 6 answered.

Rig-measured (docker snaporca-gui, Xvfb :10, llvmpipe), vertical screen shift of
the body by image correlation:
  commit Translate Z 0 -> 20        : +140 px
  double-click the Transform row     : +0 px, and the card re-opens showing 20.00
  step the re-opened card 20 -> 40   : +142 px  (not +280 -> the undo term is right)
  Cancel                             : +0 px vs the committed frame, residual 0.46
Add mode: stepping Z moves the body immediately (viewport diff bbox
200,143-1181,999); Cancel puts it back with only the status strip differing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 14:16:08 +02:00
Tommaso BianchiandClaude Opus 5 f4a0bf8845 CAD: give Rib a shortcut, completing the pick-the-line work (snaporca-3648)
Ported from snaporca ea0e11e49d. See that commit for the acceptance measurements.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 23:43:54 +02:00
Tommaso BianchiandClaude Opus 5 dcbda7d42c CAD: deliver the picked sketch ENTITY to the panel, and point Rib at it (snaporca-3648)
Ported from snaporca 94b6b564de. See that commit for what is and is not measured.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 23:25:21 +02:00
Tommaso BianchiandClaude Opus 5 315a35e2ea CAD: Sweep path and Loft profiles fill from a viewport sketch pick (snaporca-ysm2, e1p item 6)
Ported from snaporca 314d30c660. See that commit for the measurements.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 23:16:14 +02:00
Tommaso BianchiandClaude Opus 5 7920e55413 CAD: give the keyboard back when the action bar hides (snaporca-ehrm)
Ported from snaporca ace5778d4c. See that commit for the measurements.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 23:05:57 +02:00
Tommaso BianchiandClaude Opus 5 31548a230d CAD: Mirror takes its body from the viewport (snaporca-gtd3, e1p item 6)
Ported from snaporca 2516961a32. See that commit for the measurements.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 22:36:39 +02:00
Tommaso BianchiandClaude Opus 5 2643c778dc CAD: Boolean takes its two operands from the viewport (snaporca-310o, e1p item 4)
Ported from snaporca 572eb56d0e. See that commit for the measurements.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 22:12:38 +02:00
Tommaso BianchiandClaude Opus 5 dd6363f536 CAD: clicking a reference plane's label selects THAT plane (snaporca-uw3c)
Ported from snaporca 373ef325d8. See that commit for the full rationale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 21:50:27 +02:00
Tommaso BianchiandClaude Opus 5 920f0bd126 CAD: keep the pick trace, stop paying for it when it is off (snaporca-txp8)
Ported from snaporca 3710d34568. See that commit for the full rationale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 21:30:27 +02:00
Tommaso BianchiandClaude Opus 5 457610108e CAD tests: pin the sheet-body mass properties with the rig's own numbers (snaporca-lu27)
Ported from snaporca 35befd965c. See that commit for the full rationale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 21:10:40 +02:00
Tommaso BianchiandClaude Opus 5 4c8c93b512 Design: keep the inline dimension frame mapped across queued fields (snaporca-p8uw)
Ported from snaporca e4e0e21581. See that commit for the full analysis.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 20:49:37 +02:00
Tommaso Bianchi d9abadc88f Revert "CAD: type a sketch dimension without clicking the field first"
This reverts commit ab15f386e4.
2026-08-14 17:55:39 +02:00
Tommaso BianchiandClaude Opus 5 ab15f386e4 CAD: type a sketch dimension without clicking the field first
On a Wayland session the in-canvas value field never took the keyboard focus, so
after drawing a rectangle the first keystrokes went nowhere and the field had to
be clicked before a number could be typed. open() already did Show, Raise,
SetFocus on both frame and control, SelectAll, and re-asserted all of it in a
CallAfter — none of it works here, and no amount of re-asserting would: under
Wayland a client cannot focus itself, and mutter ignores gtk_window_present()
without an activation token as focus-stealing prevention. The earlier fix
recorded in this file (dropping wxFRAME_FLOAT_ON_PARENT, whose GTK _UTILITY_
hint made an xrdp session refuse focus) addressed a different compositor.

Stop needing WM focus. The canvas keeps the focus and feeds the field:
SketchInlineEditor::feed_key() types into the control directly — Enter commits,
Esc cancels, Backspace/Delete edit, digits and '-' '.' ',' are accepted, and
anything else is handed back so a stray letter cannot vanish into a numeric
field. A m_fresh flag reproduces the SelectAll semantics the field already had,
so the first digit replaces the prefill. It returns false when the control
genuinely holds the focus, so X11 keeps wx's normal routing and no character is
typed twice.

The CHAR_HOOK gates on the editor's own is_open(), NOT on inline_busy().
inline_busy is a freeze flag for the sketch tool: cleared on commit, re-set only
when the next queued field opens, with a CallAfter between them. Gating on it
left a window where the field was on screen and the flag was false — typing
worked for a rectangle's Width and not its Height.

VERIFIED at the machine on behemoth: typing the first dimension directly, with
no click, works. NOT yet confirmed: the Width -> Height handover; the is_open()
gate is diagnosed from the handover code, not observed. The hook's
SNAPORCA_KEYTRACE=1 switch logs each key with the focused widget if it needs
chasing further.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 17:53:41 +02:00
Tommaso BianchiandClaude Opus 5 614824ce89 CAD: right-click in a sketch offers verbs for what is selected, instead of for nothing
Select a line in a sketch, right-click, and every sketch verb was greyed: Trim,
Extend, Fillet, Chamfer, Offset, Mirror, the arrays, Constrain. The menu was
right and the selection was gone — two independent faults, each of which hid
the other.

FIRST, the Select-mode RightDown branch called clear_selection() before handing
the click back. Handing it back is correct: the m_right_consumed flag means "the
tool USED this right-click", and a plain right-click is not a gesture
terminator, so the offer should open. Clearing first is not: the offer describes
WHAT IS SELECTED, so wiping the selection guaranteed it could only ever describe
nothing. Deselection keeps its own gesture — left-click on empty space, a few
lines above in the same handler.

SECOND, offer_selection_kind() returned SkNone for every sketch state. The offer
table has always carried verbs for a selected line, arc, point or pair, but
nothing ever RETURNED those kinds, so fourteen rows were gated on selection bits
no code path could set. Classify the selection instead: SkLine / SkArc / SkPoint
/ Sk2Ent, via a first_selected_type() accessor on the tool and two forwarders on
the canvas.

Either fix alone measures as a failure — the classification is handed an empty
selection, or the preserved selection has no kind to match — which is why both
land together.

This is the second half of the report behind 3eb6e5d608: a user comparing the
Design tab with Onshape said "adding constraints seems to be missing"
(OrcaSlicer PR #15238). Constrain was one of the fourteen dead rows, and the
gesture that would have shown it threw the selection away first.

Verified at the machine on behemoth by Tommaso: select a line of a rectangle,
right-click, and the sketch verbs are live.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 17:37:37 +02:00
Tommaso BianchiandClaude Opus 5 eb52972a8e CAD: the offer menu speaks one language, not two
On a non-English desktop the offer menu came out mixed: "Create / Add material /
Rimuovi / Fillet / chamfer / draft / Repeat / Transform / Reference / Modify",
and under Modify, "Elimina" beside "Constrain sketch".

Nothing was mistranslated. The row names went through a bare wxGetTranslation(),
which searches EVERY loaded catalogue — including wxWidgets' own wxstd. That
catalogue is loaded in the desktop's language whether or not the application has
one, and it happens to contain exactly two of our eight row names:

    wxstd it: 'Remove' -> 'Rimuovi', 'Delete' -> 'Elimina'

Create, Add material, Repeat, Transform, Reference and Modify are not wx
vocabulary, so they stayed English. Two words in one language, six in another,
in the same menu — and the same trap is set for every other locale wx ships:
Supprimer, Löschen, Eliminar.

Name the domain: wxGetTranslation(s, SLIC3R_APP_KEY). These strings are now
translated by our own catalogue or not at all, which is consistent either way.

Left deliberately alone: the accelerator still renders as "Canc" rather than
"Del" on an Italian system. That is wx naming the physical key, and on an
Italian keyboard the key really is marked Canc — telling that user to press
"Del" would name a key they do not have.

Verified on the rig with LANG=it_IT: the menu now reads Remove and Delete, and
the submenu shows "Delete    Canc" beside "Constrain sketch".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 14:10:48 +02:00
Tommaso BianchiandClaude Opus 5 3eb6e5d608 CAD: constraints are reachable from the offer menu, not only from a toolbar icon
A user evaluating the Design tab against Onshape reported that "adding
constraints seems to be missing" — with nineteen constraint types and a solver
shipped behind it (OrcaSlicer PR #15238, exussum12).

They were not wrong about what they could see. The only ways in were an
icon-only toolbar button whose tooltip you have to hover to read, and an offer
row gated on sketch_mode with a sketch ENTITY selected, filed under "Reference".
Right after finishing a sketch — the moment you want to constrain it — neither
was in front of the user, so a shipped headline feature read as absent.

Add a model-mode row: "Constrain sketch", offered under Modify when a sketch
region is selected, routed through the new btn:constrain verb action.

on_begin_constrain() also gains a fallback to m_sel_sketch_feat. The offer
reaches it from a SkLoop selection, which carries no TREE selection, and the
function read only tree_selection() — so the new row would have answered
"Select a sketch in the tree first" about a sketch the user had visibly
selected. It now adopts the region's owning sketch and syncs the tree to match.

Verified on the rig: draw a rectangle, finish the sketch, click the region,
right-click -> Modify -> "Constrain sketch" enters Constrain mode with
"Pick 1-2 lines, then a constraint" and the Constraints (8) card listing the
sketch's inferred constraints. That path did not exist before.

Does NOT address the other half of the report: there is still no Pierce
constraint, so a sweep profile cannot be tied to its path. Tracked separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 14:00:30 +02:00
Tommaso BianchiandClaude Opus 5 9013f530fa CAD: a sheet body reports no volume, instead of a confident wrong one
mass_properties on an open shell returned volume 96000 with an inertia diagonal
of [-4.2e7, -4.2e7, -6.9e7] for a 60x60x40 four-walled box — negative principal
moments, which no real body can have. BRepGProp::VolumeProperties integrates the
divergence theorem over whatever faces exist; on an open shell that is not a
volume at all, and the old code hid the only obvious tell by taking std::abs()
of the mass. "valid: true" then asserted the number was trustworthy.

This matters because mass_properties is what an agent or a user reaches for to
confirm a cut removed the right material. Silent nonsense there means the check
passes on garbage.

MassProps gains is_solid. For a sheet we compute surface area only — that stays
exact — and report volume 0 with the inertia left zeroed. The MCP verb returns
is_solid plus a note saying volume and inertia are not defined for an open
shell; the GUI's Mass command says "sheet body — N cm² of surface, no volume"
rather than quoting material that is not there.

Verified on the rig: the sheet now returns volume 0.0, surface_area 9600.0
(exactly 4 x 60 x 40), is_solid false. The solid controls are unchanged and
exact — a 60 mm cube reports 216000.0 and 21600.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 13:35:03 +02:00
Tommaso BianchiandClaude Opus 5 1545fb7946 CAD: an armed Plane or Axis pick captures the face, instead of escalating to the body
Clicking a face that already happened to be selected, while a Plane or Axis pick
was armed, read as a repeat pick: the click escalated to "whole body", the
capture was lost, and the card's label stayed "(none)" with nothing on screen to
explain it. On a cube it is easy to hit — the face under the cursor is often the
one already selected from the previous step.

The capture path in on_solid_picked already restores the flag for all three
tools, and reset_plane_refs()/reset_axis_refs() restore it when a pick is
abandoned — both were written as if the arm side disabled escalation. Only
CoordSys actually did (that was snaporca-u0wd). Plane and Axis never had it.

Verified on the rig: Midplane on a 60 mm cube now captures Face A (#5, top) and
Face B (#3, side) on the FIRST click each, and the resulting plane renders as
the 45-degree bisector between them, which is what a midplane of two
perpendicular faces should be. Before this, the first pick escalated to the body
and Face A stayed "(none)".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 12:45:25 +02:00
Tommaso BianchiandClaude Opus 5 f6f2edb906 CAD: the Plane card refuses a method it cannot build, instead of quietly building another one
Every method in CadDocument's plane dispatch falls back to offset_angle_plane()
when its references are missing. Picking Tangent and confirming with nothing
selected therefore produced an OFFSET plane, announced as "Plane added — pick it
as a sketch plane". The user asked for one construction and silently received a
different one, with nothing on screen to reveal the substitution.

Validate at the GUI boundary instead: Angle needs an edge, Midplane two faces
(and not the same face twice — that yields a plane coincident with the face,
which is well-defined and useless), Tangent a face, Two-edges two edges. Offset
and Coincident are unchanged: both are meaningful with no reference, since they
fall back to the base plane by design.

on_add_plane() now returns false when it refuses, and confirm_tool() skips
close_tool() in that case — a refusal that also threw away the picks the user
had already made would be worse than the bug.

The kernel keeps fallback_offset(): it must return something. It should just
never be reachable from a user gesture without a warning.

Verified on the Xvfb rig: Tangent with no pick refuses and creates no feature
(it created one before), the card stays open with the type preserved, Midplane
with no faces refuses with its own message, and Offset with no picks still
creates a plane as it always did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 12:18:13 +02:00
Tommaso Bianchi 9a5e9dfc36 CAD: the wheel scrolls the card panel, it does not edit the field under it
wxSpinCtrlDouble takes the mouse wheel whenever the pointer is over it. A card
taller than the panel therefore could not be scrolled past without silently
incrementing whatever field the cursor happened to be over — measured on the
rig: eight notches over the Coord Sys card turned an X hint from 1,00 into 6,00
while the panel did not move at all. The same gesture over an Extrude distance,
a Hole diameter or a mate Offset is a silent model change made by someone who
believed they were navigating, and nothing on screen reports it.

Every spin in this panel comes from one factory, so the guard goes there: an
unfocused spin hands the wheel to its parent, and the scrolled cards panel gets
it. A spin the user has deliberately focused still takes the wheel, which is the
one case where editing is what was meant.

Verified on the rig with an Extrude card: eight notches over an unfocused
Distance leave it at 10,00; clicking into it first and scrolling takes it to
13,00.
2026-08-14 10:33:28 +02:00
Tommaso Bianchi 03fb81020e CAD: picking a face for a Coord Sys must also make it a face-based frame
A mate between two connectors committed cleanly, recomputed without error, and
moved nothing. apply_mate was reached and computed a translation of exactly
(0, 0, 0).

The connectors were the cause, not the mate. Picking a face stores
coordsys_face and coordsys_body but left the Type combo on its default,
Point (world) — and datum_frame ignores the pick entirely for that type,
resolving the connector to coordsys_point, which is (0,0,0) unless the user
typed otherwise. Two connectors built that way share one frame, so the mate
between them is an identity transform: everything reports success and the
assembly never moves.

Capturing a face or an edge now switches the type to FaceAndDirection. Picking
a face IS the choice of a face-based frame; asking for it twice, with no hint
that the second half is required, is what made every mate a silent no-op.

Verified on the rig, two bodies with a face-based connector each:
  before  Extrude4 [-71.6, 402.6, 156.5] .. [-8.8, 412.6, 237.3]
  after   Extrude4 [-31.4, -40.4, -10.0] .. [ 31.4,  40.4,  -0.0]
with the mate transform now (40.19, -196.88, 402.56) instead of (0, 0, 0).
That is the first mate in this tree that assembles anything.

Not the kernel: recompute applies mates exactly as preview does, proven by an
A/B harness over all five kinds — the two paths give identical bounding boxes.
2026-08-14 10:01:28 +02:00
Tommaso Bianchi c45f84edf4 CAD: three Design-tab fixes — consumed holed loop, constraint rows, hover ghost
1. A consumed loop WITH HOLES was never dropped from the sketch overlay.
   sync_sketch_display compares each region's entity list against what a per-loop
   extrude stored, but rebuilt the candidate from the region's OWN entities while
   the extrude stores the region's entities PLUS every hole's (see
   selected_loop_entities). For a plate with one bore that is 4 against 5, so the
   match never fired and the extruded rectangle stayed drawn on top of the solid
   it had become. Adds region_entity_indices_with_holes, which returns the same
   order the extrude uses, and compares against that; a matching region now drops
   its holes' entities too. Hole-less regions are unaffected.

   This is also the artefact that made a correct plate-with-a-bore read as a
   plate with a plug in it during rig testing.

2. The Constraints card drew its header and its first row on top of each other.
   The rows and the delete buttons were parented to m_form rather than m_cards,
   so they were laid out in the wrong window's coordinate space and started at
   the card's top edge. Re-parented; nothing else about the card changed.

3. The mate hover ghost never appeared. The highlight handler asked for a repaint
   with request_repaint(), which only queues a Refresh — and a wxMenu popup runs
   its own modal loop, so the paint was not serviced until the menu closed, by
   which time the ghost had been dropped. Adds DesignCanvas::repaint_now(), which
   flushes the paint immediately, mirroring the m_status->Update() the status line
   in the same function already needed for the same reason.

Delegated to opencode (DeepSeek V4 Pro) and reviewed by diff. Fix 1 needed an
accessor on DesignSketchTool because region_loops/RegionLoop are private — that
was outside the file list it was given, and it said so rather than working around
it.

Verified on the rig: a rectangle-plus-circle sketch extrudes to a plate with a
bore and NO overlay left on top of it, and the Constraints card shows its header
clear of eight readable rows.
2026-08-14 09:15:51 +02:00
Tommaso Bianchi 9340d4c7dc CAD: the DoF readout comes back in Constrain mode
Leaving Sketch clears the "N degrees of freedom" line, which is right — it
describes a sketch's constraint state and means nothing in Feature mode. But
entering CONSTRAIN left it blank too, and Constrain is where the number is the
whole point: the readout is fed only by a live solve, and no solve fires merely
because the mode changed, so the line stayed empty until the user happened to
change something.

The solve callback now caches its last result and the labelling is split into
apply_dof_status(), which set_ui_mode re-applies on entry to Constrain.

Verified on the rig: a rectangle reports "4 degrees of freedom" in Sketch, and
the same line is there after K enters Constrain.
2026-08-14 08:36:19 +02:00
Tommaso Bianchi 1631963ba1 CAD: pick tolerances scale with the face, so a narrow face is reachable
The edge and vertex tolerances were fixed at 8 and 11 px. On a face that is
barely wider than that on screen — a thin plate, or any part once you zoom out —
every point on it lies within the edge budget, so the pick alternated edge and
whole body and the FACE level could never be reached at all. That is not just
awkward: a face pick is what gives a Coord Sys its owning body, and a mate needs
one, so thin parts could not be assembled.

Both tolerances are now capped at a third of the face's shorter on-screen side,
measured from the edge samples the picker already walks. They only ever shrink,
so a face with room keeps the full budget and nothing changes for ordinary
geometry; a narrow one keeps its middle for itself.

Verified on the rig on a 16.7 mm-wide plate at three zoom levels including one
far enough out to make the face a thin sliver: the middle reports "face 5
selected" every time, while points near the rim still take the edge.
2026-08-14 08:32:19 +02:00
Tommaso Bianchi 76d7dd6946 CAD: an armed face/edge pick must not lose its click to the body escalation
A card that asks for a face ("Click a solid FACE in the viewport") could not be
satisfied. Clicking the same sub-element twice deliberately escalates to the
whole body — right for free picking, wrong here: the user clicks the very face
the card is pointing at, the escalation turns it into a whole-body pick, and the
armed capture rejects it and leaves "(none)". Both orders failed, so a
face-based Coord Sys was reachable only by accident of ordering. That mattered
beyond the card: a mate needs a connector with an owning body, and a face or
edge pick is the only thing that sets one.

Adds DesignSketchTool::set_escalate_on_repick, off while the Plane, Axis or
CoordSys card has a pick armed and back on as soon as it is captured or
abandoned. While armed, clicking a face means "this face", which is what the
prompt already says.

Verified on the rig: arm Pick Face, click the face, and it reads "#5" on the
first click. With nothing armed the escalation still alternates whole <-> face
as before.
2026-08-14 08:26:24 +02:00
Tommaso Bianchi d8103f794b CAD: a mate needs B to have a body, so stop offering one when it does not
The mate palette called all five kinds viable on connectors created as
Point(world), which belong to no body. Clicking one built the Mate feature and
the RECOMPUTE then failed with "mate: mate_cs_b has no associated body" — the
refusal arrived one step too late, after the feature was already in the tree,
and the user is told about it by an error line rather than by the palette that
offered the thing.

mate_options now checks the same two conditions the apply path throws on: B must
have a body (B is the connector whose body moves; A is the fixed reference and
needs none), and that body must still resolve. Either way all five kinds go
non-viable with a reason, so the offer and the kernel cannot disagree.

Verified on the rig: with two Point(world) connectors, every row is now dimmed
and reads "connector B is not attached to a body — a mate moves B's body". That
also exercises the dimmed-with-a-reason presentation for the first time, which
until now had nothing to show because every kind was always viable.

Tests: a new [mate] case covering no body, a body that no longer resolves, and
the revival once B is given one. One existing case needed its setup widened
rather than its assertion weakened: "an unrecorded fingerprint does not make a
type non-viable" built two body-less connectors, so it was asserting a side
effect of the old permissiveness instead of the property it is named for. Its
connectors now have a body, leaving the missing fingerprint as the only variable.
Full [CadDocument] suite: 2458 assertions in 185 cases.
2026-08-14 07:45:32 +02:00
Tommaso Bianchi 08ef43fad8 CAD: the mate palette fired nothing — two menu handlers were shadowing it
Right-clicking a document with two coordinate systems builds the mate palette
exactly as designed: a separator, a disabled "Mate: A -> B" header, then five
rows in fixed order. Hovering a row showed no ghost and left the previous status
message on screen; clicking one created nothing at all. The palette enumerated
perfectly and fired nothing, which put the whole M8 mate epic out of reach from
the UI.

The mate handlers were never invoked. show_offer_menu binds two pairs of
handlers to the SAME wxMenu: the mate pair (by mate_base, at base + 500) and the
generic verb pair. wxWidgets pushes dynamic entries to the FRONT of the handler
list, so the pair bound LAST runs FIRST for every id — and the generic pair was
last. A mate id lands outside the verb table's range, so both generic lambdas
returned early WITHOUT e.Skip(), which wx reads as "handled", and the mate
handlers behind them never saw the event.

Binds the two generic handlers to the verb id range [base, base + 499] so each
half only sees its own ids regardless of bind order.

Verified on the rig with a purpose-built assembly (two bodies 60 mm apart, a
Coord Sys on each): before, clicking Fastened produced no feature and no status
change; after, it produces a Mate feature. The verb rows keep working — the same
run created both coordinate systems through Reference > Coord Sys.

Note the mate then fails its recompute with "mate: mate_cs_b has no associated
body", because a Point(world) coordinate system belongs to no body while the
palette still advertises all five kinds as viable. That is a separate defect and
is filed; this commit is about the palette being reachable at all.
2026-08-14 07:34:33 +02:00
Tommaso Bianchi 7b953d564d CAD: a project whose model is a recipe is not an empty file
Reopening a saved CAD design ended on a modal "The file does not contain any
geometry data." warning. A CAD project legitimately carries no mesh — the model
lives in the feature tree (Metadata/SnapOrca_cad.bin) until Commit to Plate — so
the warning is false for one, and it is the last thing the user sees after
opening a design they spent an hour on. It reads as "your work is gone" at the
exact moment the recipe HAS just loaded and the Design tab is about to rehydrate
it, and the main window sits disabled behind the dialog until it is dismissed.

Counts a non-empty model.cad_recipe as geometry.

Verified on the rig: save without Commit to Plate, reopen, and the Design tab
comes up with Sketch1 -> Extrude2 -> Body 1 editable, with no dialog at all.
2026-08-14 00:15:46 +02:00
Tommaso Bianchi 5889f6640f CAD: extrude a sketch region with its holes, and stop crashing at startup
A rectangle with a circle inside it, drawn in ONE sketch, could not be extruded
to a plate with a bore from the GUI. Five defects were in the way. Each was
found by driving the app on a headless rig and measuring the result — the code
reads correctly at every one of these points, which is why they survived.

1. Wire orientation (kernel). SketchEngine::wires_to_face added every hole as
   wires[i].Reversed(), which is only right when the sketch happens to wind both
   loops the same way. A circle drawn clockwise inside a counter-clockwise
   rectangle came out matching the outer boundary, OCCT swept it as a SECOND
   contour, and the prism was the plate with its bore filled and the disc's
   volume counted twice. Measured: bbox 67.17 x 219.67 x 10 with volume
   152088 mm3 against a solid box of 147542 — a body larger than its own
   bounding box, which is the signature. Holes are now added as-is and
   ShapeFix_Face::FixOrientation() classifies them; that is winding-independent
   and is the idiom make_extrude_regions already used for imported glyphs, which
   is why holed TEXT always extruded correctly while a holed SKETCH never did.
   After the fix: 142996 mm3, implied bore radius 12.03 mm against the circle
   drawn.

2. The live-sketch click threw the picked region away. region_at() served only
   as a yes/no gate and on_face_selected() carried no argument, so Extrude fell
   back to whichever loop the resolver found first — clicking the material of a
   plate-with-a-hole extruded the disc. The region is now carried through, and
   DesignPanel also hands it to the tool with set_loop_pick(), AFTER open_tool()
   because that re-derives selection state, since extrude_uses_loop() reads
   selected_loop_entities() and that lives on the tool.

3. region_at() had no hole awareness and no innermost preference: it returned
   the first polygon containing the point. It now skips a region when the point
   lies inside one of that region's holes, and picks the smallest containing
   loop, so a click in the bore selects the disc and a click on the material
   selects the plate.

4. Startup segfault. DesignCanvas::request_repaint probed the GL backend via
   OpenGLManager::get_gl_info().get_renderer() before the canvas had initialised
   GL — glGetString with no context current and, before init_opengl(), no loaded
   function pointers. Anything that asked for a repaint while the panel was
   still being built landed there, with no window and nothing in the log. It now
   bails at the top on !is_initialized() and asks for a Refresh instead. Note
   the crash was in the PROBE, not in render(), which already guards itself.

5. A holed sketch on a plane whose normal points -Z came out as the full box PLUS
   a disc — 220274 mm3 where 163726 was due (192000 + 28274). wires_to_face took
   a SketchPlane parameter it never used and let OCCT infer a surface from the
   outer wire; when the inferred normal disagreed with the sketch's, the hole
   classification produced no hole. Every face is now built on the sketch's own
   gp_Pln.

Also in this change, from the same rig session:

- A right-click that only clears the sketch selection no longer reports itself
  as consumed, so it stops suppressing the offer menu. With any geometry in a
  live sketch there was no menu route left to add a second entity.
- Escape no longer discards a live sketch that holds drawn geometry; it says so
  and keeps the work (live_sketch_has_work()).
- The holed-region fill is an even-odd scanline instead of a keyhole bridge, so
  no corridor triangle leaks from the bore to the nearest corner.
- Cyan is reserved for the selection: an unselected region no longer wears a
  shade one step off the selected one.
- The origin planes follow the mode, so pressing Sketch on a document that
  already has a body offers them again instead of naming a plane you cannot see.

Tests: three [holes] cases over add_extrude_entities asserting the plate-with-bore
volume, solid and face counts on both a +Z and a -Z sketch plane, and the
by-name refusal of two disjoint regions. Full [CadDocument] suite green.
2026-08-13 23:43:36 +02:00
Tommaso Bianchi e27a44e6ef Merge remote-tracking branch 'prfork/cad-mainline' into cad-mainline 2026-08-13 17:37:10 +02:00
Tommaso Bianchi 57b42bc059 Offer the origin planes while choosing a sketch plane, not only before the first body
Delete a sketch on a document that still has a body and you could not start a new
one. Pressing Sketch said "click a face or a reference plane in the viewport" —
while update_reference_planes had already called clear_base_pick, because a body
existed. The instruction named something that was no longer there, and short of
finding a face to click there was no way back into sketching at all.

The planes are now offered when there is no solid yet OR while the UI is in
Sketch mode, and set_ui_mode refreshes them so they appear the moment you press
Sketch rather than at the next tree rebuild — which is not an event that pressing
Sketch causes.

Deliberately not always-on. m_dbp_active both RENDERS and picks, so leaving it
set would float three translucent planes over every finished model. Tying them to
the mode shows them exactly when they are the thing being chosen and takes them
away again on Finish.

Safe against stealing clicks: a base-plane pick is the last resort in on_mouse,
firing only on a click that hit no geometry, so solids and committed sketches
still win where they overlap.

Found on the rig by Tommaso: "if i remove a sketch, i cannot create sketches
anymore".
2026-08-13 17:36:24 +02:00
Tommaso Bianchi 81876a6ce6 Sketch regions understand holes, so the plate with the hole can be selected and extruded
A rectangle with a circle inside it extruded to a plain box. Tommaso reported it
exactly right on the first attempt — "no intersection selectable, hence no plate
with hole" — and it was a causal chain, not a guess.

Two things were wrong and they compounded.

region_loops() returned N independent filled polygons with no notion of nesting,
so the only selectable things were the rectangle alone and the circle alone. The
region a user actually wants — the bounded area WITH its hole — did not exist to
be pointed at. Worse, the first polygon containing the click won, so clicking
inside the circle selected the rectangle.

And extrude_uses_loop() hands selected_loop_entities() to add_extrude_entities,
which copied only that one loop's entities into the feature. So the circle never
reached the kernel, build_sketch_face saw a single loop, and the multi-loop path
added in 5c4ced91e7 never ran. Proven from his saved project: Sketch1 held 5
entities, Extrude2 held 4, and the committed mesh was 8 vertices — a box of
260.40 x 220.91 x 10.00. Eight vertices cannot describe a bore.

A RegionLoop now carries the loops nested inside it. Containment is decided by
testing one vertex, which is sufficient because loops in a well-formed sketch do
not cross, and each loop is assigned to the SMALLEST loop containing it so a hole
belongs to the region that actually bounds it. Picking respects holes: a click in
the plate selects the plate, a click in the bore selects the disc. The selection
hands over the region's own entities plus its holes', which is what finally
reaches the kernel. The highlight lights the holes with their region, because it
has to show what will be extruded.

The status line said "Loop selected"; it now says "Region selected". What is
selected is a bounded area that may contain holes, not a single closed curve —
the old wording described the old, broken behaviour.

snaporca-txp8, and it is what makes snaporca-88v reachable from the GUI at all:
the kernel could build the holed face all along (verified on his own recipe:
2 closed loops, wires_to_face OK, area 74812.119 mm2), but nothing could ask it to.

Reviewed and compiled (RC=0). NOT exercised — the rig check is the point.
2026-08-13 17:22:55 +02:00
Tommaso Bianchi b80f4e3036 Persist the CAD recipe on every save, not only on Commit to Plate
Modelling in the Design tab and pressing Ctrl+S saved a project with no feature
history at all, and the app reported success. Found on the rig: a project saved
after drawing a rectangle and a circle contained twelve archive entries, none of
them Metadata/SnapOrca_cad.bin, and a 3dmodel.model with zero vertices.

plater->model().cad_recipe was assigned in exactly one place — on_commit(),
immediately after load_mesh_object. The 3MF exporter was never at fault: it
faithfully wrote whatever the Model held, and on a save that had not gone through
Commit to Plate that string had never been set. The recipe reached the Model only
as a side effect of a different user action.

It now tracks the document instead. sync_recipe_to_model() is called after a
successful recompute, after tree edits (deletes, reorders and suppressions bypass
recompute_guarded), and from on_commit, which delegates rather than repeating the
rule. Only on success — a failed recompute leaves the document mid-edit, and
persisting that would save a model the user never had. An empty document still
clears it, so a non-CAD project carries no stale recipe.

Doing it here rather than in the save path is deliberate: Ctrl+S, Save As,
autosave and crash recovery all read model.cad_recipe, so keeping it current
after each change makes every one of them correct at once, instead of teaching
each save path to ask the Design tab. Commit to Plate means "send this to the
slicer" — making saving depend on it was the bug, not the cure.

Cost is one serialization per recompute, tens of KB against an OCCT rebuild that
has just run.

Why nothing caught it: the kernel round-trip tests serialize a CadDocument
directly, and the 3MF tests exercise the exporter with a recipe already present.
Neither can observe that the GUI never populates it, and every save in testing
happened to follow a Commit to Plate.

snaporca-vjk5. Reviewed and compiled (RC=0); persistence NOT yet confirmed on the
rig — that check is the reason the issue stays open.
2026-08-13 15:54:57 +02:00
Tommaso Bianchi 56eebe3398 kernel-test: stop configuring the GUI, which the kernel suite never needed
This script builds only libslic3r_tests, which links libslic3r and no GUI code —
but cmake still processed the whole if(SLIC3R_GUI) block and every find_package
inside it, so the kernel suite silently depended on the GUI's dependency set.

That came due the moment upstream added wxInspector as a REQUIRED find_package:
the orcacad-deps image predates it, so configure died pointing at
src/CMakeLists.txt:92 with nothing about the kernel having changed. Turning the
block off is not a workaround for that one dependency — it is the suite finally
declaring what it actually needs, so the next GUI-side dependency added upstream
cannot break it either.

Surfaced by taking SoftFever's merge of main into the PR branch.
2026-08-13 10:00:10 +02:00
Tommaso Bianchi 358c331cc6 Merge SoftFever's main-into-cad-mainline update
He merged upstream main into the PR branch himself on 2026-08-13. Taking it into
the local branch rather than force-pushing over it: the fork copy is what PR
#15238 shows, and discarding a maintainer's merge to make my own push
fast-forward would be both rude and a loss of 130 upstream commits.

Brings the branch far closer to main than the 2026-07-24 merge-base the PR body
describes, which is most of what snaporca-36u9 was filed for.
2026-08-13 09:44:30 +02:00
Tommaso Bianchi 0e7fcb3daf Recipe v5: length-frame every feature, so the format stops orphaning projects
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.
2026-08-13 09:36:21 +02:00
Tommaso Bianchi 4b3ff99004 Project load: say WHY the CAD model could not be restored
deserialize_recipe distinguishes three cases that matter very differently to the
person reading the message — saved by a NEWER build, saved by an OLDER one, or
genuinely unreadable — and names the version in each. load_recipe threw all of
that away and printed one generic sentence, so the user could not tell "update
SnapOrca" from "your file is damaged", and had no way to find out.

Same error-loss class as the 31 McpControl sites fixed in 1de72de9ed: the message
existed, it was simply not passed on. The generic sentence stays as the fallback
for the case where the kernel really has nothing to say.

This does not make old projects loadable — that is snaporca-2txy, which the audit
behind this change opened. It only stops the reason being withheld.

snaporca-2txy (partial). Reviewed and compiled (RC=0), not exercised.
2026-08-13 09:01:18 +02:00
Tommaso Bianchi 7245415af7 Sketch: a profile may hold more than one closed loop — a plate with a hole extrudes
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.
2026-08-13 08:49:18 +02:00
Tommaso Bianchi ad8b5a73fe Hover pre-highlight: show what a click would take, before it is taken
Third and last piece of the selection model. The other two turned out to be
built already — the rubber band is pick_bodies_in_rectangle and vertex picking
is SolidSel::Vertex with its camera-facing square, both live — so this closes
what the issue actually still described.

Vertex beats edge beats face is a rule the user cannot see until after they have
committed to a click. Showing the outcome under the pointer is what makes the
precedence learnable at all, and is the charter's L5 read honestly: one click,
one visible change means the change has to be predictable BEFORE the click, not
only explicable after it.

The resolution is now one function, resolve_solid_pick, const and writing only
into its out-parameter. The click applies it and then runs its escalation
unchanged; the hover applies nothing. Split this way the promise cannot drift
from the act — a second implementation of "what is under the cursor" would
eventually disagree with the first, and the disagreement would look like a
picking bug rather than a duplication one.

Rendering is likewise one function called twice. The pre-highlight draws first
so the committed selection paints over it, and is suppressed entirely when the
two are the same thing: two coats of the same colour reads as a rendering fault,
and a promise about a click that would change nothing is not worth making. It is
desaturated toward white rather than given its own hue — a distinct colour would
read as a distinct KIND of selection, when it is the same selection one moment
earlier.

Two things that would have been silent bugs. The edge ribbon and the vertex
square render with GL_BLEND off, so an alpha below 1 there is ignored; those two
are quietened by a muted rgb and only the blended face fill takes the alpha
multiplier. And the pre-highlight is cleared in clear_solid_selection, because it
names a face by an index into a shape a recompute has just rebuilt — left behind,
it would keep glowing on whatever now sits at that index, a real entity but not
the one meant.

Hover runs on plain motion only, with no button down and no band running: during
a drag the pointer is doing something else and a promise about clicking would be
a lie. It returns false so the event still reaches the camera — it asks for a
repaint, it does not consume the gesture.

snaporca-9xw. Reviewed and compiled (RC=0), not exercised.
2026-08-13 08:49:18 +02:00
Tommaso Bianchi 498c7ff8d1 Pattern/Cut/Boolean: grey the button when there is no body, and say why
Tommaso reported the array controls as missing. They were not — Shift+N opens a
Pattern card with every control correct — but the report was fair. With no body
the button accepts the click, opens nothing, and writes its refusal somewhere
other than where the click happened. From the user's seat that is
indistinguishable from a dead button, and the icon is one unlabelled glyph among
fourteen, which is how I mis-clicked it into Section view while reproducing this.

A control that cannot act should look like it cannot act, before it is pressed.
The three FEATURE buttons carrying a body-count guard — Pattern and Cut at one
body, Boolean at two — are now greyed below their threshold with a tooltip
naming what is missing.

Only those three. The same guard shape also appears on rows INSIDE the flyouts,
and those stay live: a drawer holds sketch-only entries too, so disabling the
drawer would hide tools that are perfectly usable. The keyboard shortcuts keep
running the guarded action rather than being gated — a key press has no
greyed-out state to see, so the sentence is the only feedback there is.

Re-evaluated in feed_bodies(), before its viewport early-return since this is
about the toolbar and not the canvas, and once after the toolbar is built: an
empty document is the state the bug was reported in and feed_bodies has not run
yet on a fresh tab.

snaporca-o9j. Reviewed and compiled (RC=0), not exercised.
2026-08-13 08:49:18 +02:00
Tommaso Bianchi 13922a52b6 Design status: clear the DoF line on leaving sketch mode, and wrap the HUD chip
Two independent leftovers, both in the same status area.

snaporca-752: the "N degrees of freedom" line described a sketch's constraint
state and stayed on screen after Confirm, Cancel and the Escape downgrade, in
Feature mode where it means nothing — visible in every Feature-mode screenshot of
the 2026-07-27 sweep. Cleared in set_ui_mode rather than at those three exits,
because that is the one place all of them pass through and a fourth exit added
later would otherwise reintroduce it. Constrain mode keeps the readout: that is
where the number is the whole point.

snaporca-8cc: moving the status out of the panel and into the viewport HUD
removed the clipping, but not the underlying problem. The chip is a top-level
popup that Fit()s to its text, so a long sentence grew past the right edge of the
canvas and hung over the window instead of being cut off inside it — the same
silent length limit wearing a different hat. The label now wraps to the room
actually available (canvas width minus the view-cube inset), which is what makes
the earlier promise that "a sentence can be a sentence" true at 1366 as well as
at 1920.

SetLabel + Wrap + Fit are now one function called from both the text change and
the placement. Wrap() rewrites the label it is handed, so it has to follow a
fresh SetLabel every time, and the placement path runs on resize — a chip wrapped
for the old width either overhangs a narrowed canvas or wastes a widened one.
The left inset is one constant now because the wrap width and the anchor have to
agree, or the chip wraps to a width it is not then given.

snaporca-752, snaporca-8cc. Reviewed and compiled (RC=0), not exercised.
2026-08-13 08:49:18 +02:00
Tommaso Bianchi 8737ff701e i18n: drop regenerated catalogues from the PR branch
The .pot, the Italian .po and list.txt are build product: 27,314 of the added
lines in this branch were regenerated catalogues rather than code, and a reviewer
running git diff --shortstat met that number before anything else. Restored to
the merge-base so their diff is zero; they regenerate from source with
scripts/run_gettext.sh whenever the maintainers want them refreshed.

The Romanian catalogue goes with them, for a different reason: it is a complete
new translation and deserves its own PR rather than riding along inside a CAD
feature, where nobody qualified to review it would think to look.

Nothing here changes what the Design tab does. The strings are still marked for
translation in the sources; only the generated catalogues are out.
2026-08-13 08:44:44 +02:00
Tommaso Bianchi 1a6252c88c Hole/Thread re-edit: restore the face latch from the feature, not from the last pick
m_hole_on_face and m_thread_on_face are cleared only by their tool's flyout and by
their plane combobox, so after any on-face hole or thread the flag stays true for
the rest of the session. load_feature_into_dialog restored the stored plane into
the dropdown but never touched the latch, so re-editing from the feature tree
ignored the plane it had just restored: hole_plane() returned the still-latched
face plane, which may belong to a different face, a different body, or a body
since rebuilt. Silent until snaporca-200 added the "On face" row, which then read
as a confidently wrong answer rather than as nothing.

The latch is now rebuilt from the stored feature, which is the only source that
describes THIS hole. Not from the dropdown row: index_from_plane snaps an
arbitrary face plane to the nearest XY/XZ/YZ, so driving the re-edit from the row
would MOVE a hole drilled on a slanted or offset face — that was the reason the
other candidate fix was rejected.

is_base_plane() decides which of the two a stored plane is. It compares the origin
as well as the axes (a plane parallel to XY but 12 mm up snaps to row 0 and would
come back at z=0), and adds modeling_origin before comparing, because hole_plane()
and thread_plane() add it to the dropdown plane before the feature stores it — a
document with a shifted origin would otherwise mistake every dropdown hole for a
face pick. Vector norms, not isApprox, which is relative to magnitude and useless
against the zero origin.

The face's (u,v) extent is not serialized, so m_hole_has_bounds is cleared: the
gizmo's footprint clamp goes unbounded, which is honest, where another face's
bounds are not. The label says which body the face belongs to instead of a face
number the feature does not carry; "(none — uses Hole plane)" is the one thing
that is definitely false there.

snaporca-uif9. Reviewed and compiled (RC=0), not exercised.
2026-08-13 07:35:39 +02:00
Tommaso Bianchi 6b3711fb08 Mate preview: hover a mate row and see the assembly move, commit nothing (G3)
refresh_preview() listed Tool::Mate among the features that produce no solid and
cleared the ghost, with a comment saying a mate has no 3D ghost. The kernel never
agreed: preview() routes a Mate candidate through apply_mate on a throwaway copy
of the bodies, and build_candidate already filled the mate fields. That one early
return was the whole of epic gap G3.

A mate makes no NEW geometry but it MOVES a body, and the moved assembly is the
ghost worth showing. Both the Mate card and the offer's mate palette now show it:
hovering a palette row previews that kind, leaving the row drops it, and choosing
one commits. Nothing is written to the document until the click.

The committed bodies are hidden while the ghost is up — it is the whole assembly
in its post-mate pose, not an added lump, so leaving them visible would draw the
mated body twice and z-fight every other body against its own copy. Same reason
Dressup and Draft hide them.

Cleanup is after PopupMenu rather than on a close event: PopupMenu is modal, so by
then the menu is gone and any command it raised has run. A flag distinguishes a
ghost this menu put up from a preview that was already on screen.

snaporca-b4sp. Reviewed and compiled (RC=0), not exercised.
2026-08-13 07:28:21 +02:00
Tommaso Bianchi 8e15ad23e2 Mate palette: five types on the offer, dimmed with the reason, naming the pair
snaporca-lukg part B. The issue describes building a contextual viewport palette
with a stable icon set, non-viable options dimmed and explained rather than
hidden, and edge-aware placement. show_offer_menu() already does all three — its
dead-row branch appends a disabled row with "   —   " and a reason, and wxMenu
places itself against the screen edge. So this is not a new widget. It is one
section added to that menu, fed by mate_options().

The header row names the pair: "Mate: A → B". That is epic gap G4 — the mate card
is abstract dropdowns and never says which body moves. B is the connector on the
body that MOVES, so B is the arrow's destination; the parameter order invites the
opposite guess, which is why it is commented at the point of use.

Five rows in one loop over the kernel's result, never reordered and never
filtered. The palette addresses rows by position, so a shorter list would move
every row below it — which is the whole argument for dimming instead of hiding.

The pair comes from the Mate card's combos when that card is open, so the offer
and the card cannot disagree about what they are acting on; otherwise the first
two enabled connectors, which is defensible only because the header names them.
An offer acting on an unnamed pair would be worse than no offer.

REVIEW CATCH: the five type names arrived as an array indexed by kind, read as
_L(table[i]). That compiles and is silently untranslatable — _L is a gettext
macro and the extractor scans SOURCE for literals, so five strings would have
shipped that are never in the catalogue. These names appear nowhere else in the
tree, so that would have been their only occurrence. Now a switch of literal
_L() calls.

G3 INVESTIGATED, NOT BUILT, as specified. preview() DOES handle a Mate candidate:
it copies the committed bodies to a temporary and routes the candidate through
apply_mate on that copy, committing nothing, and build_candidate already fills
the mate fields. The only blocker to a hover preview is refresh_preview()'s
Tool::Mate early return, which clears the preview on the belief that a mate has
no ghost. Nothing in the kernel refuses it. Filed rather than built.

Reviewed and compiled (libslic3r_gui, RC=0); not exercised. snaporca-lukg.
2026-08-12 23:55:24 +02:00
Tommaso Bianchi 6b3642fa53 Mate viability: which of the five apply, and why the others do not
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.
2026-08-12 23:45:37 +02:00
Tommaso Bianchi a3398c6609 MCP: let a caller find out its face/edge ids went stale
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.
2026-08-12 23:27:11 +02:00
Tommaso Bianchi 9125e0b4f8 Connector face drift: warn without crying wolf — and bump the recipe version
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.
2026-08-12 23:04:49 +02:00
Tommaso Bianchi 13c702bed3 Chamfer drift is the driver's, not the kernel's — measured, not argued
Two tests that separate a hypothesis nobody had tested. The socket showed four
chamfers on a filleted rim removing 29.6 / 20.0 / 10.3 / 7.5 mm3, falling
steadily. That could be the chamfer maths degenerating on a filleted rim, or it
could be how the driver captured its edge ids. Those have completely different
fixes, so the first job was to find out which.

dressup_edge is a global index into TopExp::MapShapes(shape, TopAbs_EDGE),
resolved against the body AS IT STANDS at that feature's position, and every
dress-up rewrites that map. So the two usage patterns are:

  ids re-read after each chamfer:  0.400, 0.397, 0.397, 0.395 mm3  (max/min 1.01)
  four ids captured up-front:      0.400, 0.008, 0.397, 0.280 mm3  (max/min ~48)

The kernel chamfers uniformly when handed a fresh id. It degrades only when
handed ids snapshot against an earlier shape — and the second chamfer's stale id
landed on a nearly-consumed edge and cut two percent of what was asked. That is
the accumulating-drift signature the socket showed.

Conclusion: driver artefact. apply_chamfer and OCCT are not at fault.

The part that makes this worth a test rather than a note: IT DOES NOT THROW.
ok=1, error empty. A stale id still resolves to a valid edge — just the wrong
one — so nothing anywhere reports it. Silent wrong geometry, which is the class
this project does not tolerate, reachable by any caller that reads the scene once
and then issues several dress-ups.

Test 2 asserts the non-uniformity as CURRENT BEHAVIOUR and says so in the code:
it documents a defect, it does not bless one. When the driver contract is fixed
it should be rewritten, not deleted.

Suite 161 -> 163 cases, 2217 -> 2248 assertions, green. Tests only, no
production code. snaporca-rgbj.
2026-08-12 22:36:20 +02:00
Tommaso Bianchi 488c94e957 SurfaceOffset and ThickenSurface: the arrow stands on a face, the tool still takes the sheet
These were the last tools from the charter audit with no handle at all, and the
issue filed against them offered three options, all of which changed the tool.
Reading on_add_surface_offset() dissolved the question instead.

The premise was that a distance handle needs a frame, a sheet body has no single
normal, and therefore the tool must start demanding a face. But the face was
never needed for the OPERATION — only for the ARROW. Both tools still offset or
thicken the entire sheet named in the combo. The picked face only says where to
stand the handle.

So the arrow appears whenever a face of that sheet is under selection, and its
absence costs nothing: the card alone works exactly as before. Purely additive —
no existing flow changes, and there is no new precondition for the user to learn.
That is strictly better than any of (a) anchor on the first face and be wrong on
a curved sheet, (b) sample a normal at the bbox centre and be arbitrary on a
folded one, or (c) require a face pick and change what the tool demands.

The arrow is refused when the picked face belongs to a DIFFERENT body than the
sheet in the combo. An arrow standing on one body while the tool acts on another
would name the wrong thing, which is worse than no arrow.

ThickenSurface was not on the audit's list — it is a distinct tool from Thicken,
with its own card and its own sheet-body combo, and it has exactly the same
shape. Fixing one and not the other would have left the same gap under a
different name.

Reviewed and compiled (libslic3r_gui, RC=0); not exercised. snaporca-9fel.
2026-08-12 22:16:31 +02:00
Tommaso Bianchi 416e7f7321 Mate conflicts: mark the row that carries them, and name the way out
detect_mate_conflicts() has been filling m_doc.mate_conflicts on every recompute
since the kernel half landed, and nothing read it. The diagnostics existed and
were invisible — a conflicting assembly looked exactly like a working one.

The tree row is where they go, because the tree is where the user is already
looking for which feature to change. Three states, in precedence order:

  disabled  -> dim. A SUPPRESSED mate is the user's answer to a conflict, so it
               must read as suppressed rather than keep shouting about it.
  conflict  -> warn.
  otherwise -> normal.

Selecting a marked row puts the reason on the status line — "Mate3 already
positions Body 2", the cycle, the self-mate — and names the eye as the way to
suppress it. A message that describes a problem with no action is a message that
gets ignored; the action here is already one click away on the row just selected.

Deliberately NOT a modal, and deliberately not treated as a document error. The
document still evaluates with a conflict present: the mate graph merely has more
than one answer for a body, and which one wins is the thing the user needs to
see. Blocking the loop to say so would interrupt without helping.

The dimming of non-involved bodies from the original UX proposal is still not
implemented, on purpose: under transform composition a failure mid-chain
propagates, so "not involved" is not a well-defined set, and dimming the wrong
bodies would hide the context needed to understand the conflict.

Reviewed and compiled (libslic3r_gui, RC=0); not exercised. snaporca-bioq.
2026-08-12 21:53:38 +02:00
Tommaso Bianchi 7311cb12cb Body-focus picking: fail open, and keep the combo and the viewport as one state
Two defects found by auditing the body-focus x-ray path, which shipped compiled
but never exercised. Neither is reachable from the happy path its test plan
walks, which is why compiling it proved nothing.

1. A STALE FOCUS KILLED THE VIEWPORT. The focus is a body INDEX held by the panel
   across recomputes, so it outlives the body it names: delete a body and the
   stored index can point past the end. body_pickable() then rejected EVERY body,
   because none of them equals an index that no longer exists — a viewport that
   silently accepts no clicks at all, with nothing on screen saying why. Out of
   range now means no restriction. Fail open, never dead.

2. THE COMBO AND THE FOCUS COULD DISAGREE. refresh_cs_body_choice() rebuilds the
   Body combo and, when the body list shrank, silently reset the selection to
   "(all)" — while the viewport stayed focused on the old index. Every other body
   kept its 25% alpha and picking stayed restricted to a body that might be gone.
   That is the exact mirror of the open_tool ordering bug this feature already
   fixed once: that one showed "Body N" over an opaque scene, this one shows
   "(all)" over a dimmed one. They are one state and are now written together.

   Guarded on CoordSys being the active tool, since it is the only card that owns
   this focus. In the edit path the function runs BEFORE open_tool with the
   previous tool still active, so the guard is false and the caller's explicit
   set_xray_focus still wins.

Also confirmed while reading, since the header asserts it: set_solid_pick() does
NOT touch m_pick_only_body, so the focus really does survive the mesh feed. That
claim now has a check behind it rather than a comment.

Reviewed and compiled (libslic3r_gui, RC=0); not exercised. snaporca-bgvk.
2026-08-12 21:49:08 +02:00
Tommaso Bianchi 23585382ab Mate connector: a roll mark that survives a grazing view, and a quieter warning
Two open findings from the first rig judgement of the connector glyph.

F4 — the quadrant collapses to a blob at grazing angles, which is exactly when
the roll is hardest to read. Adds a radial tick along +X extending past the disc
rim. As the disc flattens to a line the sector loses all its area, but a radial
spoke keeps its length and its direction along the one axis that still projects.

The alternative on the issue was to billboard the quadrant while the disc stayed
in-plane. Rejected, and not on taste: at true grazing the view direction lies IN
the connector's plane, so every in-plane direction projects onto the same screen
line and the roll is geometrically unrecoverable. Billboarding would not recover
it — it would face the camera and read as a definite orientation that is not the
frame's. Degrading to a direction that can still be trusted beats drawing a
confident lie. The tick is additive, so unlike billboarding it cannot make the
non-grazing case worse; it still wants judging on the rig at a true grazing view
before F4 is called closed.

F5 — roll-undefined was a loud red: the strongest colour in the viewport spent on
the least important connector, pulling the eye off the mate being made. It marks
"this one could not be derived", not an error. Muted amber says look-here without
shouting.

No tick is drawn when the roll is undefined — a tick there would assert a
direction that does not exist, which is the silent guess the hatched quadrant
exists to avoid.

Reviewed and compiled (libslic3r_gui, RC=0); not exercised. snaporca-wgsc.
2026-08-12 21:46:19 +02:00
Tommaso Bianchi e6a14b39c9 Rib: the thickness gets its handle, so the whole tool is draggable
Rib's depth already reused the Extrude arrow. Its thickness could not: the arrow
points along the plane normal, and thickness is an offset either side of the rib
line, IN the plane. Different direction, different handle.

Two square handles at mid ± perp·half, plus the slab's actual footprint drawn as
a thin closed rectangle — the footprint matters more than the dots, because what
a rib thickness means is how wide that slab lands on the body, and until now
there was no way to see it before committing.

A drag on either handle sets the FULL thickness, twice the perpendicular distance
from the line, because the slab is centred on the line and the handle sits at
half. Both handles behave identically for the same reason, so they share one
colour rather than pretending to be two different actions.

A zero-length line has no direction to grow a slab perpendicular to, so the
shared rib_frame() helper returns false and render and drag both draw nothing
rather than dividing by zero. Non-Line entities clear the gizmo instead of
guessing: the kernel is line-only and a gizmo that guesses would be lying about
what Confirm will build.

Unlike the helix callback this one goes through refresh_preview(), because Rib
builds a real solid ghost that has to rebuild. The helix has none and skips it
deliberately.

Both gizmos coexist and resolve the sketch and entity the same way, so the depth
arrow and the thickness handles can never disagree about which line they are on.

Reviewed and compiled (libslic3r_gui, RC=0); not exercised. snaporca-plew.
2026-08-12 21:34:59 +02:00
Tommaso Bianchi b9d6b59f90 A feature that destroys a body must say so, not ship a phantom
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.
2026-08-12 20:57:35 +02:00
Tommaso Bianchi e8306a6e9a Helix: draw the thing, then let the numbers be dragged
grep -i helix over the viewport code returned nothing at all. The tool was four
coupled numbers and a Confirm button — you typed radius, pitch, height and taper
blind and pressed OK to find out what you had made. So this is not only the
charter's L2 failure; the tool had no visible state whatsoever while it was open.

Adds a plane-anchored helix gizmo built on the datum-plane gizmo as its template,
being the closest existing thing: also plane-anchored, also driven by a card while
the sketch tool is inactive, also a render / hit-test / drag triad.

It draws the live curve and the axis, and puts a handle on each of the three
lengths: radius on the base circle, height at the top of the axis, pitch at the
end of the first turn — which is exactly where one pitch of rise lands, so the
handle means what it is standing on. Below one full turn the pitch handle moves
to the end of the curve rather than floating off a curve that does not exist yet.

Taper and handedness stay on the card. One is a shape modifier and the other a
flag; L2 governs numbers you can point at.

A drag reports the whole (radius, pitch, height) triple rather than one value,
because pitch and height are coupled through the turn count and writing one alone
would redraw a stale curve. The callback re-feeds the gizmo directly instead of
going through refresh_preview(), since Helix takes the produces-no-solid early
return and refresh_preview would rewrite the status line on every mouse move.

REVIEW CATCH, fixed here: the first cut read taper as a fraction of the radius
consumed over the turn count. It is an ANGLE IN DEGREES — helix_spine() builds a
Geom_ConicalSurface of half-angle taper and takes the top radius as R+H*tan(taper),
growing with the height risen. The wrong reading drew a preview that collapsed to
a point for any non-zero taper while the committed feature was perfectly fine. A
preview that lies is worse than no preview, which is what this commit replaced.

Reviewed and compiled (libslic3r_gui, RC=0); not exercised. snaporca-i3jc.
2026-08-12 20:40:21 +02:00
Tommaso Bianchi a5bb41e340 Rib: the depth is the same arrow again
Rib's depth is a distance along the sketch plane normal, so it is the Extrude
arrow for the fourth time — anchored at the midpoint of the line the rib is
built on, because a rib's line IS its profile.

This is half of Rib's L2 failure. The thickness is an in-plane offset either
side of that line and no existing gizmo draws that; it needs a handle that does
not exist yet, filed as snaporca-plew rather than left implied. One of two
numbers draggable is strictly better than neither, and saying which half is
missing is the point.

Reviewed and compiled (libslic3r_gui, RC=0); not exercised. snaporca-i3jc.
2026-08-12 20:12:01 +02:00
Tommaso Bianchi 4ed14eb0be SurfaceExtrude and Thicken: drag the distance instead of only typing it
Both tools produce exactly one number — a distance along a known normal — and
neither had a handle for it. That is the same shape as the Extrude depth arrow,
which was already written, already draggable and already had an editable label
on the geometry. So this adds no gizmo: it points the existing one at two more
tools.

SurfaceExtrude anchors on its sketch's plane, at the profile centroid.
Thicken anchors on the picked face, and reuses the face-as-profile recipe from
the Extrude path verbatim — including the two things that path learned the hard
way: look the face up on its OWNER body rather than the whole-document compound,
and carry that body's display Move transform onto both the origin and the
normal, or the arrow draws on the bed instead of on the face.

The drag callback routes by active tool. `second` stays Extrude's alone: it is
the two-sided pair, and the other two have a single distance each.

SurfaceOffset is the third tool in this group and is deliberately NOT here. Its
target is an arbitrary sheet body, which has no single normal to anchor an arrow
on — that is a design decision, not typing, and it stays on the audit.

Reviewed and compiled (libslic3r_gui, RC=0); not exercised. snaporca-i3jc.
2026-08-12 20:09:29 +02:00
Tommaso Bianchi b7397b48bd Transform: drag the body, the numbers follow
The Placement > Transform verb opened a card of spin controls — dx/dy/dz, an
axis combo, an angle — with nothing on the geometry. The 3-axis drag gizmo the
charter asks for already existed and was fully implemented (arrows, rotation
rings, click-to-type per axis), reachable only from a small icon button in the
tree card header. The prominent verb opened the form; the geometry-first
control was hidden behind an icon. That was backwards.

Transform now arms that same gizmo on the target body. The card stays as L2's
typed half: the drag writes dx/dy/dz, the axis and the angle, and the pivot is
seeded from the body's centroid so the parametric feature reproduces exactly
what was dragged.

Decomposition is exact for the interaction that matters — the gizmo's rings are
per-world-axis, so a ring drag is an axial rotation. A pose composed from two
rings is not axial and the card can only name one axis, so it reports the
dominant one rather than refusing to answer.

Three things this had to get right:

- The gizmo bakes its drag into the display transform so the body follows the
  cursor, and the feature performs the same motion parametrically. Committing
  without reverting first would move the body twice.
- tool_confirm() and tool_cancel() both tested moving_body() BEFORE the active
  tool, so with the gizmo armed Confirm would have dropped the gizmo and never
  created the feature. Both are now guarded on Tool::None.
- close_tool() is the single revert point. Esc, Cancel and switching tools all
  pass through it, so a Transform that was never committed cannot leave the body
  displaced.

Edit mode is untouched: re-seeding the gizmo from a stored feature is a separate
problem, so editing an existing Transform still gets the card alone.

Reviewed and compiled (libslic3r_gui, RC=0); not exercised. snaporca-qtf4.
2026-08-12 20:04:45 +02:00
Tommaso Bianchi 3f62d4d58d Bodies: colour that survives selection, hide that toggles twice, Delete that acts
Three defects behind one report ("bodies cannot be moved or hidden/shown or
deleted, colour does not work"). They are unrelated to each other; only the
symptom was shared.

1. The Color tool wrote a per-body override that was correct end to end —
   stored on CadBody, carried across recompute (CadDocument.cpp:3381), read
   back by DesignCanvas::body_color() — and then overpainted every frame.
   m_body_selected is a DOCUMENT-WIDE flag raised whenever a non-Sketch
   feature row is selected, which is the resting state after any modelling
   operation, and while it was true every body rendered gold. An explicit
   colour now outranks the selection tint; unpainted bodies still tint, which
   is all the tint was ever for.

2. The eye toggle re-selected the body row through m_tree, using item ids that
   belong to m_parts. The row came back unselected, so the second press found
   tree_body_selection() == -1 and fell through to the feature-level branch
   instead of un-hiding. Hide worked exactly once. The sibling call in
   refresh_parts() had it right.

3. The tree card's Delete button answered a selected body row with "select the
   FEATURE that created this body" — an instruction the user cannot act on,
   because the tree does not say which feature that is. on_delete_body()
   already resolves CadBody::source_feature and confirms by name; it was
   reachable only from the right-click offer. The button now routes to it.

Reviewed and compiled (libslic3r_gui, RC=0); not exercised — needs a session at
the machine to confirm all three in the viewport. snaporca-zjvg.
2026-08-12 19:57:14 +02:00
Tommaso BianchiandClaude Opus 5 606026a920 Home: axonometric view, fitted
DesignCanvas::set_view() and fit_view() were both written and then never called
from anywhere in the tree. The Design viewport has had no way back to a standard
view since it existed: no key, no button, nothing but orbiting by hand until the
model happens to drift into frame.

That is worse than a missing convenience. A camera left pointing along the bed
plane renders a scene that looks exactly like a failed renderer — geometry
present, nothing visible — and an hour went into blaming the software GL stack
before the real cause turned out to be two uncalled functions.

Home rather than a letter: every letter A-Z is already a Shift+letter tool
shortcut. Home is also the reset-the-view key most users arrive with. The
dispatcher needed no change, it keys on the raw wx keycode. set_view() already
does select_view + zoom_to_volumes, so this is fit and orient in one call.

Doc row added to the View toggles table in docs/design_tab.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 18:59:22 +02:00
Tommaso BianchiandClaude Opus 5 b305e8b154 tests: compile the five CAD test files that were never wired
tests/libslic3r/CMakeLists.txt added only test_caddocument.cpp under
SLIC3R_CAD. The other five shipped in the tree and were never compiled, so
49 TEST_CASE blocks looked like coverage and were not: sketch constraints,
sketch editing, sketch import, inference, and the libslvs constraint set.

They also still targeted Catch2 v2 — mainline is on v3, where the umbrella
header is catch2/catch_all.hpp and Approx lives in the Catch namespace rather
than at global scope. Both fixed; nothing else in the files changed.

Found by building the tree rather than reading it. Suite goes from 374 to 423
test cases, 54,424 to 54,620 assertions, all passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 18:05:02 +02:00
Tommaso BianchiandClaude Opus 5 d584d66003 Mate conflicts: name what silently wins, don't call it over-constraint
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>
2026-08-12 15:06:17 +02:00
Tommaso BianchiandClaude Opus 5 3eec65f2bd CoordSys: pick a body first, x-ray the rest
In an assembly the face you want for a mate connector is nearly always behind
another body, and a click only ever returns the frontmost hit. Hiding the
occluder from the Parts list works but means leaving the tool mid-pick.

The CoordSys card now carries a Body chooser. Pick a body and every other one
drops to 0.25 alpha AND stops catching clicks, so the wanted face is both
visible and reachable in one gesture. "(all)" restores normal picking.

Deliberately NOT hit cycling: repeated-click cycling was removed from solid
picking as a charter L5/§10 violation (DesignSketchTool.cpp, "NO CYCLE"), and
re-introducing it here would make "click a face" a multi-click gesture again.

Mechanics: DesignSketchTool::set_pick_only_body() gates body_pickable(), which
every pick path already consults; DesignCanvas::set_xray_focus() drives both it
and the per-body alpha in reload(). The chooser stays a pick FILTER only --
coordsys_body still comes from the actual pick, so nothing in the kernel moves.

Body focus follows the CoordSys card: open_tool() reads it back from the combo
rather than clearing outright, because editing a CoordSys feature loads the card
(and its body) before open_tool runs.

snaporca-bgvk. NOT COMPILED: deps/build lacks OpenVDB so the GUI tree will not
configure here; reviewed by diff only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 13:45:37 +02:00
Tommaso BianchiandClaude Opus 5 8ff7ba440d CadDocument: reindex mate connectors on feature delete and reorder
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>
2026-08-12 13:14:10 +02:00
Tommaso Bianchi 0a2faedc32 Design: draw the mate connector, so its verse and polarity are visible
A mate connector was visible only to a program. resolve_datum_coordsys had exactly ONE consumer
in the whole tree -- McpControl.cpp, the agent socket -- so the frame every mate is built on
could not be seen at all, and the two questions a connector has to answer on sight had no
answer in the viewport: which way does Z point (the VERSE), and which of the pair is anchored
versus about to move (the POLARITY).

The glyph is Onshape's proven core plus the part nobody ships. Disc for the XY plane, one gold
quadrant for the roll -- the only in-glyph answer to "where is X", which matters because
Fastened and Slider lock the clocking -- and a Z arrow drawn on +Z ONLY, never double-headed.
Polarity is carried by the head: a filled cone travels, an open collar receives. Onshape,
Fusion, Inventor and FreeCAD all draw both ends of a mate identically, which is why "which part
moves?" is a standing complaint; nothing here invents new semantics, it just stops hiding them.

Polarity is read from the committed Mate features, not only from the open card. A connector some
mate drives must read as driven whenever it is on screen, or the glyph tells the truth only while
a dialog happens to be open. The card, when open, still wins -- that is the live intent.

Judged on the rig rather than in a mock, which changed three decisions:

- Three RGB axis arms lose to one Z arrow. Rendered side by side (SNAPORCA_GLYPH=A selects the
  Onshape-style trio), the three heads are as large as the 22 px disc, they bury the quadrant, and
  at an oblique angle they pile into a smudge -- and the trio is indistinguishable from the move
  gizmo and the bed triad, which are already RGB arrow trios in this viewport.

- Depth off floats, depth on tears. With GL_DEPTH_TEST off, connectors on faces pointing AWAY from
  the camera drew their discs over the solid, so the part looked covered in frames that were on its
  back. Turning depth on fixed that and immediately z-fought: the disc is exactly coplanar with its
  face and came out a broken dotted arc. Depth ON plus a 0.7*upp lift along Z buys both, and scaling
  the lift by upp keeps it sub-pixel instead of opening a visible gap on zoom-in.

- Foreshortening degenerates an arrow into a dot when the axis points at the camera. It now draws a
  ring instead of silently vanishing, which is what a naive projection does.

Everything is sized in screen pixels via upp = 1/zoom, like every other gizmo here: a connector is
a symbol, not a part, so it must not shrink with the model.

Research and the empirical findings are written up in DESIGN_MATE_CONNECTORS.md section 8b;
rig images in artifacts/shots/g-0*.png, vendor reference glyphs in artifacts/glyphs/.

Not fixed here, and recorded rather than papered over: the quadrant collapses to a blob at a
grazing angle, which is exactly when the roll is hardest to read (F4); roll-undefined in red makes
the least important connector the loudest thing on screen (F5); and a true grazing view, a curved
face, and overlap with the move gizmo are still untested. Also surfaced while testing and unrelated
to drawing: add_mate accepted a mate between two connectors on the SAME body and duly transformed
the body relative to itself -- a concrete instance of the missing validation already filed as G6.

Fork parity unchanged: DesignCanvas.cpp 16, DesignPanel.cpp 30, the other four files 0.
2026-08-05 09:50:09 +02:00
Tommaso Bianchi 24f4076bb5 Design: the Hole and Thread cards say which face they are holding
snaporca-200 asked which of the two models of "the card's face input" is right,
because clicking empty canvas now clears the selection (snaporca-od0) and made
them visibly disagree: Thicken / Shell / Draft read the LIVE selection and their
label reverts to "(pick a solid face)", while Hole / Thread LATCH the face they
were opened or picked on and keep it. The complaint was that Hole then drills a
face you can no longer see selected.

Taken to the rig, that turns out to be the wrong half of the story. With Hole
open and its face picked, a click on empty canvas leaves the Ø6.0 ghost and its
dimension gizmo drawn on that exact face — the card was never operating in
secret, it was showing its target the strongest way a CAD tool can. Meanwhile
Draft, whose behaviour was held up as the honest one, threw the pick away and
had to be told the face again.

So neither model replaces the other. They are different in kind: Thicken /
Shell / Draft are operations whose operand IS the selected face, and Hole /
Thread are placement tools with their own plane state that a pick merely seeds.
The latch is also the kinder of the two now that empty clicks are a deliberate
gesture — a stray one costs Thicken a pick and costs Hole nothing.

What was genuinely missing is that nothing in those two cards NAMED the latched
face, so after such a click the only words on screen were the viewport's
"Nothing selected" over a ghost about to drill. Both cards now carry an "On
face" row, the way the other three already do:

  Hole    Face 5 | (none — uses Hole plane)
  Thread  Face 1 | Edge 2 | (none — uses Thread plane)

Thread names an edge when the cylinder came from a circular rim rather than a
cylindrical face, which the code already distinguished internally and never
said out loud.

Verified on both rigs, every state driven through the GUI: face pick on open
and on live pick, survival across a click on empty canvas, and the fallback
after choosing XY/XZ/YZ from the plane dropdown. Thread's edge branch was
exercised on a revolved tube's rim, its face branch on the same tube's outer
wall.

Filed while here, surfaced by the new row rather than caused by it —
snaporca-uif9: re-editing a stored Hole/Thread from the feature tree restores
f.plane into the dropdown but never clears m_hole_on_face, so the re-edit
silently reuses the PREVIOUS card's latched face. Now visible by name instead
of invisible.

Fork parity unchanged: DesignPanel.cpp 30, DesignPanel.hpp 0.
2026-08-03 15:06:17 +02:00
Tommaso Bianchi 93395b7888 Design: escalate on the entity that was picked, and let the chip follow the window
Both found by Kimi reviewing the previous two commits, both then reproduced here
before being touched.

snaporca-97z. The re-pick escalation required m_solid_sel, m_sel_body, m_sel_face AND
m_sel_edge to all match the previous pick. That looked stricter and was wrong: the
edge branch sets only m_sel_edge and m_solid_sel, leaving m_sel_face as whichever face
the ray happened to enter through — and a shared edge is entered through a different
face depending on which side you view it from. So picking an edge and picking that
same edge again from the other side compared equal edges, unequal faces, and refused
the escalation the status line had just promised. Now the comparison is made at the
level that was picked and nothing else. The edge id is already the stable global one
from edge_index_of, so it identifies the edge without help from the face.

Reproduced on the rig without needing to orbit, since two clicks 8px apart across an
edge enter through different faces:

  pick -> sel=3 body=0 face=5 edge=3
  ray  -> body=0 face=0
  re-pick -> escalated to whole body 0
  pick -> sel=1 body=0 face=-1 edge=-1

Same edge, face 5 then face 0, escalation fires. The old condition could not.

The frame-move case. The chip is anchored at an absolute screen position, and until now
nothing told it the window had moved — only a resize, a status change or a tab switch
re-placed it. Dragging the window by its title bar left it stranded where it was,
verified on the rig by moving the frame and watching it stay put. wxEVT_MOVE on the
top-level frame, alongside the ICONIZE and ACTIVATE binds from the previous commit.

Two related cases are filed rather than bound, because the list of window-geometry
events to chase is exactly what snaporca-lcq argues should stop: a layout change that
translates the canvas without resizing it, and wxEVT_DPI_CHANGED.

Not fixed, deliberately, and recorded on snaporca-97z: clicking the same FACE but
landing within the vertex or edge tolerance resolves to a different kind and so does
not escalate — that is the "smallest thing under the cursor" rule working as
documented; and a vertex re-pick after moving the body compares stale world
coordinates.

Verified on both rigs. The cross-face edge case was exercised on orca_cad;
DesignSketchTool.cpp is byte-identical across the forks, so snaporca inherits it, and
its face-level escalation and empty-click clear were re-checked there directly.

Fork parity unchanged: DesignSketchTool.cpp 0, DesignCanvas.cpp 16.
2026-08-03 14:17:57 +02:00
Tommaso Bianchi 6d1a4078ca Design: the status chip goes away with the window, not just with the page
Found by minimising the app on the rig with a face selected: the whole screen goes
black and the chip is still drawn on the bare desktop. A wxPopupWindow is
override-redirect — the window manager does not own it — so it neither iconises with
its frame nor stacks behind other applications. IsShownOnScreen does not catch this
either: an iconised frame still counts as shown, which is why the guard added for the
tab case sails straight past it.

So the frame has to say so itself: ICONIZE and ACTIVATE, both routed through the same
show_status_hud the page change already uses. Restoring is safe — a popup cannot take
focus, so our own Show() cannot re-trigger either event — and restoring while some
other page is up still leaves the chip down, because show_status_hud(true) goes
through place_status_hud's IsShownOnScreen guard.

Verified on both rigs: chip up, minimise -> screen black and empty, restore -> chip
back with its text and the face still selected. Restore while on Prepare -> chip stays
down.

This is the fourth defect from the same root, so snaporca-lcq now asks the question
these binds keep deferring: whether the line should be canvas content, like the view
cube and the round view buttons, rather than a window that has to be told about every
way a window can stop being visible.

Fork parity unchanged: DesignCanvas.cpp 16.
2026-08-03 13:36:09 +02:00