Files
OrcaSlicer/docs/HLSD/design-tab.md
T

382 lines
26 KiB
Markdown

# Design tab — High Level Design
## Purpose and scope
The Design tab is a parametric CAD environment inside the slicer: sketch, constrain, build
solid features, commit the result to the plate. It exists because the alternative is a round
trip through an external CAD application, and that round trip discards design intent at both
ends — a part edited after slicing comes back as a mesh rather than as the feature history
that produced it. Keeping the model in the project means a dimension can be changed after the
part has been sliced, with the nozzle diameter, the build volume and the material already
known.
Its coupling to the rest of the application is deliberately narrow. It adds no stage to the
slicing pipeline and touches neither the preset system nor `Tab`. It reaches the rest of Orca
in two places: **Commit to Plate**, which hands finished solids to Prepare as ordinary model
objects (by default one assembly object with a part per body, which keeps the bodies' relative
placement, or one object per body), and one optional 3MF archive entry that carries the recipe.
Everything else is contained in `src/libslic3r/CAD/` and `src/slic3r/GUI/CAD/`.
The user-facing manual lives in the wiki
([Design Tab](https://www.orcaslicer.com/wiki/design_tab)), not here. This document covers the
parts of the design that the code does not make evident.
## The model is a recipe
`CadDocument` holds an ordered list of `CadFeature` and nothing else that matters. Bodies,
meshes and display geometry are **derived**: `recompute()` replays the feature list from the
start and rebuilds them. Editing a dimension set twenty features ago is therefore an ordinary
edit — everything downstream is rebuilt by the same replay that built it the first time.
Two consequences follow from deriving rather than storing:
- **Undo is a snapshot of `features` alone.** The caller calls `checkpoint()` before the
mutations that make up one user action; undo restores that snapshot and recomputes. Because
everything else is derived, one checkpoint is exactly one `Ctrl+Z` step and the restored
state is exact rather than approximately reconstructed. The tab keeps this stack itself; it
is not Orca's project snapshot system, which operates on `Model` objects the Design tab does
not own until Commit.
- **Face and edge ids are session-scoped.** They are indices into `TopExp::MapShapes`, so a
rebuild invalidates every one of them. `CadDocument::topo_generation` is bumped on every
rebuild so a holder of an id can discover that it is stale instead of silently addressing a
different edge. The counter is deliberately not serialized: an id means nothing outside the
run that produced it.
## Geometry kernel and dependency surface
The kernel is OCCT, which OrcaSlicer **already** links — `Format/STEP.cpp`, `Format/svg.cpp`
and `Shape/TextShape.cpp` use it upstream. The Design tab adds no third-party dependency; it
widens the existing OCCT build by one module flag in `deps/OCCT/OCCT.cmake`:
```cmake
-DBUILD_MODULE_ModelingAlgorithms=${SLIC3R_CAD}
```
Most of that module's toolkits are built either way, because `DataExchange`, which the STEP
importer uses, depends on them. With the flag on, OCCT also builds `TKFillet` (used through
`BRepFilletAPI`), `TKOffset` (`BRepOffsetAPI`), and `TKFeat`, `TKHelix`, `TKXMesh` and
`TKExpress`, which nothing here references but which the module flag builds anyway, because
OCCT's module flags are all-or-nothing. On macOS and Linux OCCT links statically, so an
unreferenced toolkit costs build time and no shipped bytes. On Windows OCCT builds shared and
only the linked toolkits ship, so the tab adds the `TKFillet`, `TKOffset` and `TKBool` DLLs.
On Windows the packaging step asserts that every linked OCCT toolkit has a shipped DLL and
fails the configure with the name of any that is missing, because the alternative failure — a
deps prefix built with a different `SLIC3R_CAD` setting than the app — otherwise surfaces as a
missing DLL at first launch.
## The sketch constraint solver
`src/libslic3r/slvs/` is a vendored subset of SolveSpace's `libslvs`: self-contained, no
external dependencies, **GPL-3.0**, with its `LICENSE` preserved verbatim in the directory.
`SketchSolver.cpp` is its only consumer and drives every sketch constraint in the tab.
OrcaSlicer is AGPL-3.0. GPLv3 §13 permits combining a GPLv3 work with an AGPLv3 work and
AGPLv3 §13 grants the converse, so the combined work is distributable under AGPL-3.0 with the
solver's GPLv3 terms preserved. The solver is vendored rather than fetched as a dependency
because it is a pinned subset with no build system of its own; the cost of that choice is
upstream-sync burden, paid deliberately to keep `deps/` unchanged.
## The SLIC3R_CAD gate
`SLIC3R_CAD` (default ON) compiles the tab and selects the OCCT module flag above. With it OFF
the tab is not built and the deps prefix matches upstream exactly. The gate is cheap because
the hooks the Design tab adds to shared GUI code — chiefly the `m_design_sketch_tool` member
and the render, mouse and key hooks in `GLCanvas3D` — are null-guarded on the path they extend,
so removing the tab removes behaviour rather than requiring the host code to be rewritten.
The flag has to agree between the dependencies and the application; that is what the DLL
assertion above is checking.
## Project persistence
A project stores the recipe as one optional archive entry, `Metadata/orca_cad.bin`, backed by
a single `std::string cad_recipe` on `Model`. The entry is written only when the string is
non-empty, and readers that do not know it ignore it, so projects that contain no CAD model are
byte-identical to what upstream would have written and older readers are unaffected.
The blob is a cereal binary archive whose layout is the field order of `CadFeature`'s
save/load. That makes the format the one irreversible decision in the subsystem, and the rules
that keep it survivable are:
- **Append only, never reorder.** Enums serialize positionally as their underlying integer, so
inserting a value in the middle of `SketchConstraintType` or `CadFeatureType` reinterprets
every constraint in every saved project. New fields go at the end.
- **Features are length-framed.** Since v5 each feature is a length-prefixed, self-contained
cereal stream, so a reader can skip a feature written by a newer build and stop cleanly on an
older one. This is what makes appending a field a non-breaking change from here on. v4 and
earlier still open through the pre-framing flat path; v1 is deliberately not loadable and has
no migration path.
- **A newer stamp is refused, not guessed at.** `deserialize_recipe` rejects a blob whose
version exceeds `ORCA_CAD_RECIPE_VERSION` with a message naming both versions.
- **The rules are held by fixtures, not by discipline.** `tests/data/cad_recipe_v{3,4,5}.bin`
are checked-in blobs from the builds that wrote them, and the tests that load them fail if a
field is reordered — which the in-memory round-trip test cannot detect. A regeneration test
(`[.regen]`, not run by default) produces a fresh fixture when a new version is stamped.
`Import` features embed the imported solid as an OCCT BRep string inside the recipe rather than
referencing the source file, so a project opens without the STEP or mesh it was built from.
The cost is that saved projects are coupled to an OCCT BRep revision.
`tests/data/cad_brep_occt76.brep` holds a solid written by OCCT 7.6, and its test fails if the
bundled OCCT can no longer read it.
A Text feature follows the same rule: it stores the outlines it was vectorised into alongside
its string, font and height, so the project opens identically on a machine that lacks the font;
the three parameters are only what an edit reopens the dialog with.
## The interaction contract
Three inputs carry the whole modelling loop — left click, right click and `Esc` — and the
contract between them is stated in code rather than spread across handlers.
`DesignInteraction.hpp` defines a four-level LIFO stack whose enum value *is* the depth, so
"which level does this press belong to" is a comparison:
| Level | Holds | One `Esc` press |
| --- | --- | --- |
| `Transient` | a value field or a popup menu | closes it; the tool stays armed |
| `Gesture` | an uncommitted delta — an entity being drawn, a body being dragged | reverts it; committed work is untouched |
| `Tool` | a feature card, Sketch waiting for its plane, an armed sketch tool, a constrain session | exits it; drawn entities survive |
| `Idle` | nothing transient | clears the selection, a Feature tree or Bodies row included; leaves a sketch session only if it is empty |
`cad_escape_level()` is a `constexpr` free function over a POD of four booleans rather than a
method on the panel, so the ordering that is the entire contract is checkable without a window,
a GL context or an event loop — five `static_assert`s in the header do exactly that at compile
time.
**The strict invariant: no level of `Esc` deletes a feature, discards a sketch that holds
geometry, or rolls history back.** Destroying work needs a gesture that says so — `Del` on an
explicit selection, the sketch ribbon's Cancel, which asks first, or `Ctrl+Z`. A sketch
*session* is deliberately not a `Tool` level; it is the environment the `Idle` level lives in,
which makes the destructive path unrepresentable rather than merely unlikely.
A body Move is the one `Gesture` that outlives the press: its gizmo stays up between drags until
Confirm keeps the placement or `Esc` or Cancel puts the body back. Until then the selection is
held — a click off the gizmo only steers the camera — and undo is refused, since the placement
is not in the history. Anything that starts another edit (a feature card, a sketch, placing
imported art or text, another body's Move, a rebuild) keeps the placement, as switching gizmos
keeps a move in Prepare. The panel ends the Move in one place (`DesignPanel::end_body_move`), so
the gizmo, the Move / Rotate card and the ✓/✗ cannot outlive one another.
Right-click is read at button-up against one budget, 3 px of drift, applied to the whole press
rather than to its end points: a press that wandered past the budget at any moment is
navigation, even if it comes back to where it started, which is what stops a slow, careful
orbit from ending in a menu. There is no time budget — a gesture that means something different
when it is slow is exactly what the interaction charter rules out. The raycast uses the press
position, not the release. The sketch tool sees a right press only once the release has shown it
was a click: the press itself goes to the camera, which may pan or orbit with that button, and the
canvas replays it to the tool on a stationary release. A tool that uses the click (to terminate a
chain, say) keeps the menu closed. Past either budget the event is navigation, and navigation
does not transition the state machine.
Navigation itself is Prepare's: the camera reads the drag actions set in Preferences > Control
for each button, and in the Touchpad camera style a move with Alt held orbits and one with Shift
held pans, whatever tool is armed. The left button is shared with picking and drawing, so a tool
handle or a press that draws takes it first, as a gizmo does in Prepare; a whole body is swept
with a rectangle on plain left-drag only while no camera action is assigned to the left button,
and with Shift+left-drag otherwise — Prepare's own rectangle selection.
Entering a sketch changes two things at once so the mode is legible: a banner above the
canvas (a sibling of the canvas, not a child over it — on GTK a child window over a
`wxGLCanvas` is a native window and does not reliably stack over GL), and `N` to look normal to
the plane. The printer bed stays: there is no sketch grid, so the plate grid is the only ground
reference a sketch has. Code that changes either belongs with a change to this section.
Sketch mode is never entered without a plane under it, so the banner, the sketch keys and the
sketch offer always have a session to act on. Sketch on a picked flat face or reference plane opens
the session on it at once. With nothing picked it stays in Feature mode and waits for one — an
armed `Tool`, left with `Esc` or ✗, and ended by anything that starts another edit — and the
reference plane or flat face clicked next opens the session. A picked plane is a selection like a
face: the sketch on it uses it up, and `Esc` or a click on nothing lets go of it, so a plane that
can no longer be seen never decides where the next sketch goes.
The reference planes — XY, XZ and YZ through the modeling origin, with their half-axes — are
drawn on demand, because three translucent squares over every model are noise once they are not
the thing being picked. Sketch brings them up while it waits for a plane, which is exactly when
they are picked, and the session the pick opens takes them away; a live session draws none. The
Feature tree's Origin row keeps them up outside a sketch. Its state is a view preference in
AppConfig rather than part of the recipe, so it costs the project format nothing. The Plane tool
keeps its own rule: the planes and the datums as Offset bases, and nothing for the other methods,
where a click on a plane would rewrite the datum's references. The `P` and `A` keys are a
separate, unpickable view helper and do not follow the Origin row.
The Bed row, under the Origin row, is the printer bed's switch in the same way: it draws or hides
the bed and its plate grid in every mode. It is a view preference in AppConfig too, and the bed is
shown until it is turned off.
The two rows are view switches, not history, and the tree says so: they sit unframed on the Feature
tree's card, above the features' own framed list, and stay put while the features scroll. A click
never selects either row, since a selected Origin or Bed would have nothing to edit, move or
delete; the eye and the right-click menu are the only targets, and a row's label dims while its
thing is hidden, as a hidden body's does. Because the block never takes the focus, `Ctrl+Shift+O`
and `Ctrl+Shift+B` flip the Origin and the Bed from the keyboard.
## Rendering the bodies
The tab draws its bodies through the same `GLCanvas3D` object path as Prepare, so how they look
is decided in the shared object shader, not in the tab. The slicer's two lights both sit near
the camera, which leaves the sides of a part in nearly one tone; the Design canvas asks for a
studio model instead — a world-space sky/ground hemisphere, a key and a fill light, a
plastic-like highlight and a darker silhouette — through `GLCanvas3D::set_studio_lighting()`
and the phong shader's `lighting_model` uniform. The program is shared by every canvas, so each
use sets the uniform (0 for the slicer's canvases) rather than relying on a default: a canvas
that left it alone would inherit whatever the last canvas chose.
The B-rep edges of every body are drawn over it by the sketch overlay as thin view-facing
ribbons, depth tested and pulled a few pixels toward the eye so they win against the faces that
meet at them and still hide behind faces in front; lines are not used because they do not
rasterise under the software GL context the tab also supports. Seams of closed surfaces and
degenerate edges are left out (`GeometryEngine::display_edges`), and the polylines are sampled
once per shape, keyed by its `TShape`, because a recompute that leaves a body unchanged is the
common case.
While a feature card is open, its preview ghost is the whole model the candidate would produce,
drawn translucent over the bodies, so every face the feature leaves alone is in both at the same
depth. The ghost is drawn with a depth bias that pushes it back (`GLVolume::depth_bias`), so on a shared face
the body always wins instead of the two copies z-fighting, and the ghost shows only where the
result reaches past the bodies. Material a feature removes lies inside the old solid and would not
show at all, so the tools whose result mostly coincides with the body — Fillet/Chamfer, Draft,
Hole and the Mate hover — hide the bodies once the preview is valid and draw the result alone,
opaque.
## Showing what is selected
A selection is drawn on the faces it names, never as a tint over the body: a translucent
selection colour blended into the body's own colour turns a different hue on every body and
vanishes on one close to it. Selected faces are split out of their body into a volume of their
own, which the canvas draws opaque in the selection colour through the same shader and lighting
as the body (`DesignCanvas::rebuild_bodies`); the sketch overlay outlines them with a cased line
— a dark band under a selection-coloured one — so the outline still reads on a body that wears
the selection colour itself. A body picked whole, a face picked in the viewport and the faces of
the Feature tree's selected feature all draw this way. The hover pre-highlight is the outline
alone, uncased: it promises a click, it is not one. The automatic body colours keep clear of the
selection colour's blues and teals, so no body looks selected before anything is picked; a colour
the user sets on a body is theirs, and the cased outline keeps its selection readable.
Selecting a feature row lights the faces that feature made, not the whole body it sits on, so a
fillet row shows its round and the extrude under it keeps the faces the fillet trimmed.
`CadDocument::faces_made_by` answers it without per-feature history: it replays the recipe to
just before the feature and then the feature alone, and a face of the finished model belongs to
the feature when an interior point of it lies on the boundary afterwards and not before, facing
the same way — the facing keeps a block stacked on a base the owner of its bottom face. A feature
that makes no face of its own, such as a Boolean union, answers with the bodies it changed. The
replay costs up to a recompute, so the panel finds the faces once per row and topology
generation, off the UI thread, and only while no feature card is open. One selection is live at
a time: a viewport pick clears the feature row and a feature row clears the viewport pick, as the
Feature tree and Bodies list do between themselves. `Esc`, a click on empty space and an
empty rubber band all let go of it, whichever list or pick made it — except while a body Move is
open, which holds the selection until it ends (see the interaction contract).
Zoom to selection, on a Feature tree or Bodies row and in its right-click menu, frames one thing
along the current view direction, as the canvas's Fit button frames the selection. On a body it
frames the body whole, hidden or not, from its display mesh: the viewport never selects a hidden
body, so the selection cannot stand in for it. On a sketch it frames the sketch's own geometry,
drawn, consumed or suppressed; on any other feature, the faces the feature made, found as the row
highlight finds them and from the same cache. From the offer it frames whatever the selection is,
a body again included. A feature that is not a sketch and makes no faces, such as a datum plane
or a suppressed Extrude, has no Zoom to selection.
## Following the app
The tab is a page of Orca's main window and answers to the same settings as Prepare.
- **Theme.** Its chrome is coloured from a table of light/dark token pairs. A theme switch
reaches `DesignPanel::on_sys_color_changed` from `MainFrame`, which moves every colour that is
one theme's token onto the other theme's and then runs the app's own dark pass; the icons are
Orca's sidebar grey, which the icon cache maps per theme, so they are re-rasterised rather
than re-tinted.
- **Scale.** Sizes are in DIP, and a DPI change reaches `DesignPanel::msw_rescale`, which
re-rasterises every icon (button faces, flyout rows, card headers, the feature and body lists'
row icons).
- **Sidebar icons.** Every clickable icon in the sidebar shows a hover chip. The card-header and
constraint-row buttons are Orca's self-painted `Button`, because a native button cannot take a
hover background on macOS. The Feature tree and Bodies lists are a custom-drawn
`DesignRowList` rather than a `wxTreeCtrl`, so each row carries its own actions — Zoom to
selection, Edit, Show/hide and Delete on a feature, Zoom to selection, Move, Show/hide and
Delete on a body, and only Show/hide on the Origin and Bed rows, a separate non-selectable list
above the features — and the eye shows whether that row is hidden. A feature with nothing to
frame keeps a blank cell where Zoom to selection would be, so every icon stays in its column.
- **Plates.** This is the one thing the tab does not follow. The canvas has a bed of its own at
the printer bed's home position, whichever plate Prepare has current, and a new document's
modeling origin is that bed's centre. A bed that followed the current plate would slide out
from under a design: the origin is fixed once per document, baked into every sketch plane and
saved in the recipe, while the current plate can change between visits. Commit to Plate does
not need it either, since the committed object is placed on an empty spot of the current
plate. What the canvas does read from the plate is moved onto its bed: the exclude areas, the
plate box the camera orbits about when nothing is picked (`GLCanvas3D::_current_plate_box`),
and the first view, which starts from Prepare's camera turned to the tab's iso view: the CAD
isometric from the front-right corner that Home returns to, not Prepare's front-left one.
- **Viewport text.** The status line and the active tool's values are drawn by the canvas in
its ImGui pass, so they go with the canvas: a top-level window over GL does not follow its
frame and was left floating over other applications.
- **Undo.** The tab keeps its own history (the recipe is not part of Prepare's snapshots), but
it has no Undo/Redo of its own: the top bar, `Ctrl+Z` and Edit drive it while the tab is
shown, greyed to what an undo would actually do.
- **Docking.** The sidebar docks like Prepare's — either side, floating, resized, or collapsed
with the canvas's collapse button or `Shift+Tab` — through its own AUI manager under the
toolbar, because Prepare's manages the Plater and the Plater is not on this page. The button is
the canvas's own toolbar rather than Prepare's, which collapses Prepare's sidebar. The layout,
collapse included, is kept apart from Prepare's (`design_window_layout`) and starts where
Prepare's sidebar is, at its width, so the canvas edge holds still across the tab switch until
the user moves one of them. A floating sidebar is a top-level window, so it is hidden with the
tab rather than left over the other pages, and View > Reset Window Layout resets both tabs.
## The offer is generated, not hand-written
Right-clicking geometry opens the *offer*: eight families in a fixed order, each verb at a
permanent row index, verbs that do not apply shown disabled **in place with their reason**
rather than removed. The invariant is that a verb's row index is identical in every selection
where it appears and that adding a verb never moves an existing one — the hand learns the
position, so the menu is never re-sorted, compacted or adaptively ordered. Above the families
sits one *flat* row, holding Rename, Color and Zoom to selection — what a selection is opened for
most: its verbs are items of their own at the top of the menu rather than a family's submenu. It
is appended after the eight, so it moved no existing index, and it reads the same from the
viewport and from a row of the Bodies list.
An invariant across 93 verbs and 20 selection kinds does not survive by review, so the map
exists once, as data: `scripts/CAD/tool_atlas.json` carries every verb with its row, key, icon,
accepted selections, preconditions and refusal string, and `scripts/CAD/gen_offer_table.py`
emits `src/slic3r/GUI/CAD/DesignOffer.hpp` from it. The header is checked in and never
hand-edited; `scripts/CAD/run-all-checks.sh` runs the generator with `--check` as its first
rung, which is what makes "GENERATED — DO NOT EDIT" a fact rather than a request. The generator
also refuses an atlas with a duplicate verb id, since `mcp_run_verb` resolves a verb by id and
would make the second one unreachable.
The atlas and its generator sit in `scripts/CAD/` rather than in `docs/`: they are build inputs
for a checked-in header, not documentation.
## Automation surface
`McpControl` exposes the document over JSON-RPC when `ORCA_CAD_MCP` is set in the environment,
with `tools/orca_cad_mcp_bridge.py` as the client side. It describes the scene, queries
topology, measures, and runs the same verbs the offer does — it re-implements nothing, so a
scripted action and a clicked one cannot diverge. It is off unless the variable is set.
## Where the code lives
| Path | Role |
| --- | --- |
| `src/libslic3r/CAD/CadDocument.*` | the feature recipe, its replay, undo and serialization |
| `src/libslic3r/CAD/GeometryEngine.*` | OCCT wrapper — faces, edges, booleans, healing |
| `src/libslic3r/CAD/SketchEngine.*` | profile → wire → solid |
| `src/libslic3r/CAD/SketchSolver.*` | constraint solving, over the vendored solver |
| `src/libslic3r/slvs/` | vendored 2D constraint solver (GPLv3) |
| `src/slic3r/GUI/CAD/DesignPanel.*` | the tab: toolbar, feature cards, tree, key maps |
| `src/slic3r/GUI/CAD/DesignRowList.*` | the Feature tree and Bodies lists, with per-row actions |
| `src/slic3r/GUI/CAD/DesignCanvas.*` | viewport integration |
| `src/slic3r/GUI/CAD/DesignSketchTool.*` | in-canvas sketching |
| `src/slic3r/GUI/CAD/DesignInteraction.hpp` | the Esc level contract |
| `src/slic3r/GUI/CAD/DesignOffer.hpp` | generated offer table |
| `scripts/CAD/tool_atlas.json` | source of truth for the offer |
## Verification
The kernel is covered by Catch2 suites in `tests/libslic3r/` (`test_caddocument`,
`test_sketchconstraints`, `test_sketchedit`, `test_sketchimport`, `test_sketchinference`,
`test_sketchprofile`, `test_slvs_constraints`), which need no display;
`scripts/CAD/run-kernel-tests.sh` builds only `libslic3r_tests` and runs them headless.
The GUI half is not covered by CI, which has no OpenGL canvas or synthetic input: the ladders
in `scripts/CAD/` drive a running application in a local rig instead, and
`scripts/CAD/run-all-checks.sh` is the gate that runs all of them. A green kernel run says
nothing about the viewport, so the two are reported separately rather than as one number.