Compare commits

..

941 Commits

Author SHA1 Message Date
selune 2a4707404a Settle appearance panel scrolling before targeting Search (#6079)
* claim: #6076

* test(render): settle appearance scroll before resolving Search (#6076)

Expanded starter swatches moved under the scenario click while the existing scroll animation was active. Advance 20 deterministic harness frames before resolving the Search handle. No wall-clock sleep, timeout change, assertion deletion or production change. Original native reproduction fails at the empty-state assertion; all 124 scenarios now pass with one pre-existing ignored adhoc entry.
2026-10-24 15:41:29 +02:00
selune cad0767a10 Merge main synchronization into render (#6098)
Resolves #6050 branch conflict while retaining both original nextest policies. Fresh quick run 40097215892 passed. Preserve main ancestry; no advanced experimental feature or full-green claim.
2026-10-24 15:17:44 +02:00
0xlillith 644a70960e Merge pinned main into render; preserve both independent nextest additions
Refs #6050, #5493. Manual resolution is confined to .config/nextest.toml and retains the existing CI gpu-path policy and main local-ci/windows-gpu policy unchanged. Parsed TOML semantics and automatic merge-tree equality for every other path are verified. cargo-nextest is not installed locally; native nextest parsing is NOT claimed. Fresh repository/compile checks remain required. No advanced experimental code is introduced.
2026-10-24 15:12:23 +02:00
0xlillith c2de6c636d claim: resolve the main/render synchronization conflict for #6050 2026-10-24 15:11:44 +02:00
fugazister a633a67e63 Preferences: choose the window system on Linux (Wayland or X11) (#3083) (#3084)
* claim: #3083

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* WIP: choose the window system on Linux in Preferences (#3083)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Preferences: a window system set over the control channel is saved for the next start again

The merge kept main's check in ui.prefs, which only looked at the anti-aliasing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Preferences: the window system section drops the restart badge its note already states

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 12:12:38 +00:00
0xlillith 6fbcb5f1ea feat(render): expose bounded original appearance recipes (#1722) 2026-10-24 14:10:09 +02:00
selune d94b0a1a7b GPU: match index-matched Fresnel in path and viewport (#6036)
Fixes #6035. Preserve actual GPU/f64 material-oracle checks in both translations. Scoped Vulkan, render, Clippy, WASM and fresh quick checks pass. No CI configuration or gate changes.
2026-10-24 13:51:31 +02:00
phanoob 5ca7c2fb95 Sketch tools: pressing a tool's shortcut again cycles its creation modes (#2459) (#3564)
* claim: #2459

* Sketch tools: pressing a tool's shortcut again cycles its creation modes (#2459)

R starts 2-Point Rectangle; R again, before the first corner, switches to
3-Point, Center, Rounded, then back to 2-Point (likewise for Circle,
Line, Arc, Slot, Polygon and Spline keys). A held key's auto-repeat
doesn't cycle, the first press always starts the bound mode, and once a
point is placed the key keeps the tool and its points.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Sketch tools: the mode cycling is a Sketch preference, off by default (#2459)

A tool's shortcut pressed again keeps restarting the tool, as before;
Preferences > Sketch > "Pressing a tool's shortcut again switches its
mode" turns the cycling on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Phanoob <phanoob@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 11:46:46 +00:00
cronos 592f6a6fee ci: fix local scheduling and Windows test baselines (#4941)
* ci: repair local nextest scheduling and Windows fixtures

* test: refresh MSVC robustness references and isolate GNU policy

Integrate the Windows reference records from #3361 and the target-environment
selection and recording policy from #4159. Keep shared records and comparison
tolerances intact, and retain seed 36 as an existing failure tracked by #3583.

Co-authored-by: Ray <raykholo@users.noreply.github.com>
Co-authored-by: chris <christopher.nourse@gmail.com>

* test: make CI subprocess and revolve fixtures deterministic

---------

Co-authored-by: Ray <raykholo@users.noreply.github.com>
Co-authored-by: chris <christopher.nourse@gmail.com>
2026-10-24 11:33:10 +00:00
DuBearte 4612c5e6a9 Equal with three or more curves selected makes them all equal (#4985) (#5139)
* claim: #4985

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Equal with three or more curves selected makes them all equal (#4985)

With more than two curves selected, Equal fell back to the pick tool and
applied nothing. sketch.constraint.equal also takes curves: [2..200],
each made equal to the first in one step (strict, so a conflict changes
nothing), and the toolbar sends a selection of three or more curves as
that list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 11:26:38 +00:00
DuBearte c0da9fe861 Trim cuts a line at its tangent point on a circle (#5074) (#5163)
* claim: #5074

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Trim cuts a line at its tangent point on a circle (#5074)

A line the solver made tangent to a circle touches it within rounding
noise, and the exact intersection missed the touch whenever the noise
fell outside, so trim found no cut there and deleted the whole line.
Trim takes its cut points from intersections_within at its own merge
distance, as profile detection does (#588).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 11:26:36 +00:00
selune a29a45eca1 Render: isolate matched GGX visible-normal sampling (#1654)
Independent numerical constituent of #5493. Part of #1654; the CPU reflection consumer remains #1698, so this does not close its image acceptance. Original sampler, matched PDF and analytic/hostile-input tests are preserved from the reviewed donor. 189 render tests, strict Clippy, wasm32 and repository gates pass; fresh quick run 40086624771 passes. No CI configuration, saved-data or kernel change.
2026-10-24 12:41:21 +02:00
amolinari-autotex 19db763899 Tapped Hole: verified metric fine, UNC/UNF and parallel G/Rp catalogues (#4178)
* feat: expand tapped-hole catalogue with metric fine and Unified sizes

* docs: distinguish metric designations from expanded tapped catalogue

* feat: add internal pipe catalogue and checked Rc profile data

* Connect parallel G and Rp catalogues to cylindrical tapped holes

* fix(i18n): register tapped thread catalogue labels

* fix(i18n): complete thread labels in required locales
2026-10-24 10:22:57 +00:00
selune f443a388cc Merge pull request #5980 from 0xlillith/sync/render-main-ancestry-polish-20261024
Restore main ancestry after #5976 (merge commit only; no file changes)
2026-10-24 11:17:59 +02:00
0xlillith e4cceb3db1 Restore main ancestry after the accepted render synchronization
History-only follow-up to #5976 under #5493. The accepted squash d8c2abdb has the correct tree but omits main ancestry. Normal merge of the accepted original sync head restores that ancestry with ZERO file changes. Both source/merge trees are verified identical. Must be merged with Create a merge commit, never squash/rebase; no shared branch rewrite or direct main push.
2026-10-24 10:57:00 +02:00
selune d8c2abdb2b Sync main into render and preserve GPU CLI help (#5976)
* CLI: --help on each subcommand prints its usage and exits 0 (#4538) (#4545)

* claim: #4538

* CLI: --help on each subcommand prints its usage and exits 0 (#4538)

Every command now answers `--help` and `-h` with its own usage on stdout and
exit 0, the way the top level has since #1828. Before, each subcommand
answered differently: `fuzz --help` started a fuzz run with the default seeds
and ran until it was stopped, `recipe --help` took the flag for a file name,
and `snapshot`, `render`, `oracle`, `run`, `eval`, `script` and `crashes`
answered with an error on stderr and exit 1.

The help is read before the command runs, so `--help` never starts work: no
fuzz run, no MCP server, no file read.

`fuzz` also refuses an argument it doesn't take, as `snapshot` already did: a
flag it doesn't know and a stray word are errors, not arguments to ignore
while a hundred seeds run.

The usage lives in one place now: each command's lines are a constant, and the
top-level usage is composed from them, so the two cannot drift apart. The
top-level usage is unchanged, byte for byte.

# Conflicts:
#	apps/solidcraft-cli/src/main.rs

* Renaming a parameter follows it into sheet-metal and plastic rules and canvases (#4420) (#4423)

* claim: #4420

* Renaming a parameter follows it into sheet-metal and plastic rules and canvases (#4420)

Document::rename_param rewrote other parameters, feature inputs, dimensions,
configurations and joints, but not the expressions of sheet metal and plastic rules or
the plane a canvas lies on, so after a rename the sheet metal and plastic features
using them failed with "unknown parameter" and the canvas left the view. It renames
them there now, and param_users lists them (a rule's field, a canvas's input), so a
parameter they use can't be deleted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Parameters: rules' features in drives and the graph; shared rule field lists (#4420)

A sheet metal or plastic rule that uses a parameter makes the sheet metal (or plastic)
features rebuild when it changes, so parameters.users lists them in drives and
parameters.graph links them. SheetRule::fields/fields_mut and PlasticRule::fields_mut
replace the hand-written copies of the rules' fields in params.rs, eval.rs and
sheet.manage_rules. Suggested by @CamWight.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fmt (#4420)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Mirror and pattern: a pattern or mirror feature as the source (#395) (#3998)

* claim: #395

* Mirror and pattern: a pattern or mirror feature as the source (#395)

A pattern or mirror of features refused another pattern or mirror as a
source ("Pat cannot be patterned or mirrored yet"). Such a source now
stands for what it copied: its own sources are replayed at each of its
transforms moved by each of ours, to any depth up to eight. Copies of
a feature picked directly keep the instance numbers they had, so
existing designs keep their face names; copies made through a nested
pattern or mirror are numbered after them, so one feature reached two
ways never names two copies alike. A pattern or mirror of bodies as
the source is still refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Expression completion: keep longer names visible when the typed word is a unit (#3663) (#3664)

* claim: #3663

* Expression completion: keep longer names listed when the typed word is a unit (#3663)

Typing `rad` into an expression box showed nothing because `rad` is the radians unit and
a word that is itself a known token closed the popup. The popup now stays open on such a
word. A complete name (`width` with `width_extra` defined) opens it unarmed: the longer
names are listed with no row selected, Enter submits what was typed and Tab moves on; Up
or Down picks a row, after which Enter or Tab complete it as before. A unit means
something only after a number, so a unit typed anywhere else (`rad` on the way to
`radius`) is a prefix like any other: the popup is armed and Enter completes `radius`
instead of submitting one radian.

A complete token is extended only by names that start with it (`in` lists `inner`, not
`rounding`), a typed unit offers no other units (`in` does not list `inch`), and a unit
right after a number (`2 in`, the form every dialog starts its sizes in) opens no popup at
all, so nothing sits over a dialog's OK button.

Fixes #3663

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Homero Thompson <vdirienzo@gmail.com>
Co-authored-by: SS-EN <84559909+SS-EN@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Martin Pålsson <martin.palsson@outlook.com>
Co-authored-by: Daniel <daniel.owsianski@gmail.com>
2026-10-24 04:51:48 -04:00
0xlillith 81d3e4cb14 Merge pinned main into render, retaining its GPU CLI help
Periodic normal main-to-render synchronization under #5493. Preserve all four main commits and both parents. Resolve the single CLI help conflict by keeping main subcommand help dispatch and the renderer progressive/lighting/environment options; add a regression checking both top-level and render help. No renderer/kernel/format/workflow source is otherwise edited. Formatting/diff checks pass; fresh quick CI gates the merge. Current private combined execution retains its older exact source pin.
2026-10-24 10:49:29 +02:00
Mateusz Sych 9a737bd63c Linked components: joints keep their face after Update Links (#3330) (#4874)
* claim: #3330

* Linked components: joints keep their face after Update Links (#3330)

A linked design comes in as STEP text, which keeps no face names, so the
faces of its bodies were named by their place in the file (`face:<k>`).
An edit of the part that adds a face earlier in the file (a second hole
of the same size) gave that place to another face, and a joint on the
linked part moved there silently.

When a link is made or updated, each imported face is matched to the
part face it was written from (the same geometry), and the Import
feature keeps that face's name in the part (`Feature::link_faces`, a new
pending format change). The linked face is named
`F<import>🔗<digest of that name>`, pieces keeping their qualifiers,
so references follow the part's own history through Update Links as they
do inside the part, and a face the part no longer has is reported.
Linked faces still answer to `face:<k>`, so older references keep
resolving; Update Links warns about joints that use them and reports
joints that no longer solve.

* Linked faces: a solid's twin keeps its own names; renamed faces count as an update

Two solids of a part alike in one place each take the names of the solid
the file names them after. Update Links reports a link whose part named
its faces differently since as updated (a joint may follow that name to
another face), not as unchanged; only a link getting its first names
stays unchanged.

* Linked faces: Extract to File carries the design's face names too

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: MateuszSychXpl <151755914+MateuszSychXpl@users.noreply.github.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 08:33:36 +00:00
Daniel 8b65d41b31 Expression completion: keep longer names visible when the typed word is a unit (#3663) (#3664)
* claim: #3663

* Expression completion: keep longer names listed when the typed word is a unit (#3663)

Typing `rad` into an expression box showed nothing because `rad` is the radians unit and
a word that is itself a known token closed the popup. The popup now stays open on such a
word. A complete name (`width` with `width_extra` defined) opens it unarmed: the longer
names are listed with no row selected, Enter submits what was typed and Tab moves on; Up
or Down picks a row, after which Enter or Tab complete it as before. A unit means
something only after a number, so a unit typed anywhere else (`rad` on the way to
`radius`) is a prefix like any other: the popup is armed and Enter completes `radius`
instead of submitting one radian.

A complete token is extended only by names that start with it (`in` lists `inner`, not
`rounding`), a typed unit offers no other units (`in` does not list `inch`), and a unit
right after a number (`2 in`, the form every dialog starts its sizes in) opens no popup at
all, so nothing sits over a dialog's OK button.

Fixes #3663

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-24 08:01:48 +00:00
Martin Pålsson 4dc30c092d Mirror and pattern: a pattern or mirror feature as the source (#395) (#3998)
* claim: #395

* Mirror and pattern: a pattern or mirror feature as the source (#395)

A pattern or mirror of features refused another pattern or mirror as a
source ("Pat cannot be patterned or mirrored yet"). Such a source now
stands for what it copied: its own sources are replayed at each of its
transforms moved by each of ours, to any depth up to eight. Copies of
a feature picked directly keep the instance numbers they had, so
existing designs keep their face names; copies made through a nested
pattern or mirror are numbered after them, so one feature reached two
ways never names two copies alike. A pattern or mirror of bodies as
the source is still refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 07:55:14 +00:00
SS-EN e67ae66f3e Renaming a parameter follows it into sheet-metal and plastic rules and canvases (#4420) (#4423)
* claim: #4420

* Renaming a parameter follows it into sheet-metal and plastic rules and canvases (#4420)

Document::rename_param rewrote other parameters, feature inputs, dimensions,
configurations and joints, but not the expressions of sheet metal and plastic rules or
the plane a canvas lies on, so after a rename the sheet metal and plastic features
using them failed with "unknown parameter" and the canvas left the view. It renames
them there now, and param_users lists them (a rule's field, a canvas's input), so a
parameter they use can't be deleted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Parameters: rules' features in drives and the graph; shared rule field lists (#4420)

A sheet metal or plastic rule that uses a parameter makes the sheet metal (or plastic)
features rebuild when it changes, so parameters.users lists them in drives and
parameters.graph links them. SheetRule::fields/fields_mut and PlasticRule::fields_mut
replace the hand-written copies of the rules' fields in params.rs, eval.rs and
sheet.manage_rules. Suggested by @CamWight.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fmt (#4420)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 07:35:31 +00:00
Homero Thompson 5e0e7a4c6a CLI: --help on each subcommand prints its usage and exits 0 (#4538) (#4545)
* claim: #4538

* CLI: --help on each subcommand prints its usage and exits 0 (#4538)

Every command now answers `--help` and `-h` with its own usage on stdout and
exit 0, the way the top level has since #1828. Before, each subcommand
answered differently: `fuzz --help` started a fuzz run with the default seeds
and ran until it was stopped, `recipe --help` took the flag for a file name,
and `snapshot`, `render`, `oracle`, `run`, `eval`, `script` and `crashes`
answered with an error on stderr and exit 1.

The help is read before the command runs, so `--help` never starts work: no
fuzz run, no MCP server, no file read.

`fuzz` also refuses an argument it doesn't take, as `snapshot` already did: a
flag it doesn't know and a stray word are errors, not arguments to ignore
while a hundred seeds run.

The usage lives in one place now: each command's lines are a constant, and the
top-level usage is composed from them, so the two cannot drift apart. The
top-level usage is unchanged, byte for byte.

# Conflicts:
#	apps/solidcraft-cli/src/main.rs
2026-10-24 07:35:25 +00:00
selune 5c0e48ed2b feat(render): add bounded linear film accumulation (#1696) (#1697)
Extract the independent FilmAccumulator from owned donor 3b892eab5e. Keep the existing job cancellation and caller-owned admission/work contracts. PNG resolution, primary camera coverage and progressive consumer wiring remain separate. Add analytic statistics, atomic refusal, allocation/range boundaries and real 4096/1048576-count tests. No kernel, document schema, workflow or capability change.
2026-10-24 08:48:08 +02:00
selune ce1c8db7fb feat(render): reconcile definition-local visual mapping (#1214) (#1223)
Retain the original mapping implementation, all eight analytic/hostile-input tests and mapping contract byte-for-byte from #1223. Reconcile only module registration and the current roadmap on render. Actual current render library 166/0; strict all-target render Clippy, wasm32 and repository gates pass. No persistence, sampler, new capability, kernel/vendor change or external code is added. Historical mixed-base full-CI failures are not relabelled.
2026-10-24 08:31:59 +02:00
selune f0995dab1c Merge pull request #5871 from 0xlillith/sync/render-main-ancestry-20261024
History-only: retain main ancestry in render (merge, not squash)
2026-10-24 08:12:54 +02:00
0xlillith 600f12ea0e Restore pinned main ancestry after the render synchronization squash 2026-10-24 08:10:54 +02:00
selune d4ec5e6811 Sync pinned main into render (#5869)
* Viewport: show reference geometry through bodies (Preferences, off by default) (#2697)

A new Graphics preference, "Show reference geometry through bodies", draws
inactive sketch contours, origin and construction planes and their axes over
shaded bodies. It is off by default, so the viewport looks as before: reference
geometry is depth-tested and a section leaves it whole (#1229); Create Sketch
and dialogs picking a plane still draw the planes through the model.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* ui-egui tests: offscreen GPU tests start their devices one at a time (#3989) (#3990)

* claim: #3989

* ui-egui tests: offscreen GPU tests start their devices one at a time

Every offscreen test started a wgpu instance of its own, and starting one
makes the Vulkan loader scan its drivers. With several test threads two
tests doing that at once crashed the test process with SIGSEGV inside the
loader (loader_icd_scan). offscreen_device now holds a process-wide lock
while it starts the instance, adapter and device; each test keeps its own
device and the rendering still runs side by side.

* DXF: write and read a Bézier at its own degree (#5269) (#5281)

* claim: #5269

* DXF: write and read a Bézier at its own degree (#5269)

The DXF writer and reader held a spline's degree to 7, the value MAX_BEZIER_DEGREE had before
#1642 raised it to 9. A G4 blend (one Bézier of degree 9) was written as a degree-7 SPLINE over
its first 8 control points, so a flat pattern's cut-out stopped 4.444 mm short in the file.

- seg_shape takes its limit from MAX_BEZIER_DEGREE: a Bézier is written whole, at its own degree.
- read_dxf reads SPLINE degrees 1 to MAX_BEZIER_DEGREE, the degrees a sketch spline takes.
- Tests: every Bézier degree 4 to 9, a degree-9 round trip, the reader's degrees, hostile SPLINE
  degrees and too few control points, the G4 cut-out exported and read back with its areas, and a
  degree-9 SPLINE with 2 to 10 points inserted into a sketch.

Fixes #5269

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Renaming a plane keeps its projections, canvases and hidden state (#4353) (#4354)

* claim: #4353

* Renaming a plane keeps its projections, canvases and hidden state (#4353)

timeline.rename only renamed the feature in other features' references. A sketch's
projection of a construction plane, a canvas placed on it and the hidden list keep it
by name too, so they lost it: the sketch warned that the plane no longer exists, the
canvas left the view and a hidden plane showed again. Document::rename_feature_keys
renames it in sketch links and canvases, as rename_body_keys does for bodies; the
selection follows in the command, and the hidden list in Session::refresh_from, so a
hidden plane stays hidden through undo and redo too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Browser: add Rename and Delete to feature menus (#3839, #4343) (#4342)

* claim: #3839

* fix(ui): add Rename and Delete to browser feature menus

* Browser: a second click on a Document Settings entry closes its window (#4150) (#4152)

* claim: #4150

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Browser: a second click on a Document Settings entry closes its window (#4150)

Each entry under Document Settings only opened its window. A second click on
Units closed the menu on the press (the row is outside it) and opened it again
on the release; Precision left Preferences open; Parameters and Design
Properties came back as a fresh copy. Now a click on an entry whose window is
open closes it the way the window's own x does:
- Units: context_menu::toggle_for with the row as the opener, the fix #1522
  made for the workspace button's menu. A right-click still opens it at the
  pointer.
- Precision: prefs::close(app, true), the Cancel/x path that puts back what
  changed since the window opened (now pub(crate)).
- Parameters and Design Properties: the window is dropped, as its x does. A
  Parameters value typed but not applied yet is dropped with it, as today's
  second click already drops it.

Fixes #4150

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Preserve expression-positioned sketch fillet corners (#4097) (#4106)

* claim: #4097

* Sketch fillet keeps a corner placed by an expression (#4097)

Filleting a corner whose position comes from parameter expressions deleted
the point and its expressions, so the sketch gained two degrees of freedom
and later parameter changes distorted the outline. keep_corner now also
keeps fixed or expression-positioned points as virtual sharp corners.

* Fix feature editor picks after upstream sketch resizing (#4083) (#4084)

* claim: #4083

* Feature editors refill named edges and faces where they are now (#4083)

After an upstream sketch change, Fillet, Chamfer, Shell and Draft evaluated
correctly, but their editors refilled the originally clicked points and
could then fail with "no edge at ...". Resolve the stored names through the
document's naming helpers first, keep the resolved index, and map the pick
into the displayed body's frame; the point lookup stays as the fallback.

* ui-egui tests: clear the texture deltas of the frames they run (#4039)

Nine tests dropped an egui frame's output with its texture delta still
unapplied (the expression completion editor's frames, the slot preview's
three, the browser's feature rows). epaint asserts against that in debug
builds, so they failed under the dev profile and passed only where debug
assertions are off. They clear the delta as the other tests do; one more
hover test that dropped its output the same way does too.

* Frames: profile library, members along frame sketch lines, and the Frame Member dialog (#4003) (#4004)

* claim: #4003

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Frames: the profile library (#4003)

The first slice of frames and weldments: the cross-sections structural members
will be made of, each SolidCraft's own parametric definition built in code
(no vendor or standard size tables are shipped).

- solidcraft_doc::frame: bar, round bar, rectangular and round tube, angle,
  channel, I-beam and a generic T-slot extrusion as exact line/arc regions,
  with defaults worked out from the profile's size, refusals that name the
  dimension that does not fit, anchors and placement, and exact section
  properties (area, centroid, Ixx/Iyy/Ixy by Green's theorem).
- frame.profile.list, frame.profile.properties (with mass per metre for a
  density) and frame.profile.insert (draws a profile into a sketch, locked by
  default, at a point, anchor and angle).
- Tests against hand-computed values (rectangles, circles, composite
  rectangles by the parallel-axis theorem, fillet spandrels), hostile input,
  extrusion volume, undo; catalog entries in every locale; docs/frames.md;
  ROADMAP M15.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Frames: drop the built-in T-slot profile (#4003)

Aluminium extrusions and other vendor sections come from the user's own
sketch (drawn or inserted from the vendor's DXF), so the library keeps the
plain steel shapes only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Frames: members along frame sketch lines (#4003)

frame.member lays one body per straight line of a frame sketch (2D lines or
3D sketch lines), its cross-section square to the line from end to end:

- profile: a built-in profile, or profile_sketch, a sketch whose material
  (nested loops alternating, so two circles make a tube) is the section in
  its own x and y around its origin; profiles picks override it.
- anchor (origin, centre, centroid or a bounding-box corner or edge middle),
  angle (an angle expression, so a parameter), mirror.
- Orientation: the profile's y as near world up as the line allows, x
  square to both; a vertical member's x is world X (frame::member_axes).
- Members follow sketch and parameter edits, undo as one step, save and
  reopen (format: pending/frame-members.rs), and are named after the profile
  (Rect Tube 40x40x3, (2)…), numbering on past names already taken.

Tests against hand-computed volumes and bounding boxes (rectangle, 3D
tower lines, parameter edit, sketch tube profile and picks, anchor/angle/
mirror), refusals, undo/redo and reopen; walk fixtures and goldens; docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Frames: fixes from the adversarial review (#4003)

- Default member lines are what was drawn: projected and other linked curves
  and 3D wires (a face sketch's edges) and lines of no length are left out.
- A profile sketch's material starts from its drawn profiles, as
  solid.extrude's "all" does, so a section sketched on a face works; the
  even-odd test uses outlines and boxes worked out once, capped at 256.
- Member names are pinned to their lines (`names`, by line id; `body_name`
  is the base): deleting or adding a line, or deleting another feature, no
  longer renames other members, and frame.member stores every name it gives.
  body.rename renames a member on its line (also for later member features);
  a copy whose body_names were set still names its members by position.
- Built-in profile dimensions are expressions (ProfileSpec): they follow
  parameters, bare numbers take the design's units, and they are feature
  inputs. frame.profile.properties/insert read them the same way.
- Default root/toe radii shrink to the room a thick section leaves; radii
  given are checked by name; a radius must be 0 or at least 0.01 mm; error
  messages report the wall or flange value given, not twice it.
- A line within 1e-6 rad of vertical orients as vertical, so rounding never
  turns a post a quarter turn.
- A 2D frame sketch in a differently placed component is mapped into the
  member's frame, and member features recompute when placements change.
- Caps before lookups for a design's `lines`; a line listed twice is
  refused; a 3D wire that runs back on itself is not a line.

Regression tests for each, from the review's scenarios.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Frames: the Frame Member dialog on the ribbon (#4003)

- Frame Member (MODEL > Create) opens a dialog: the frame sketch's lines
  picked in the view (or All Lines), a built-in profile and its dimensions or
  a section picked in another sketch, anchor, angle, mirror and name, with a
  live preview. Edit Feature reopens it filled in.
- Lines picked in the view keep their sketch (a SKETCH_LINES input picks
  ExternalSketchCurve): line ids are each sketch's own, so two 3D sketches
  both have a `w2`, and the members went on the newer sketch's lines.
- timeline.redefine takes frame.member. Members keep their names through an
  edit unless it gives them a new body_name; frame.member takes `names`.
- A member whose section is a whole profile sketch keeps it when edited
  (the dialog shows the sketch instead of picking every region, which would
  fill a tube's hole).
- An original frame_member icon, open for review, on the ribbon, timeline
  and browser.
- The angle profile is listed as L-Angle, and web and flange as Web/Flange
  thickness, so they translate apart from the existing Angle, Web and Flange;
  the dialog's labels are in en, ja and zh. en-GB no longer repeats the frame
  command labels it spells the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Rectangular and circular pattern: copies that miss the body are reported (#3042) (#3919)

* claim: #3042

* Warn when cut pattern copies remove no material

A rectangular or circular pattern whose copies miss the body succeeded silently and those copies did nothing. The pattern now reports how many of its cut copies removed no material, and keeps the copies that land.

* Delete in a rolled-back history warns about the features after the marker (#3471) (#3698)

* claim: #3471

* Delete in a rolled-back history warns about the features after the marker (#3471)

With the timeline rolled back, the checks a delete runs first (the
confirmation's "these features will fail", timeline.dependents and
selection.delete's would_fail) compared models evaluated only up to the
marker. Features after it, broken by the delete, were not listed, so their
error marks turned up only once the timeline moved to the end.

newly_broken_in_history evaluates both sides over the whole history when
either has a marker (on copies; the model and document are unchanged) and is
used by the delete's report and by timeline.dependents.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* macOS: Fusion navigation preset does not support Shift + two-finger orbit (#3446)

* Macos: keep touchpad/key modifiers true to fusion

* fix: remove obsolete orbit snap reset from trackpad navigation

Upstream removed ViewportState::orbit_snap. The new trackpad orbit path retained one reference, causing E0609 in the PR merge build.

Validation: all seven trackpad tests pass; cargo check -p solidcraft-ui-egui --all-targets passes on the exact failing CI merge 66a2791b with this fix applied; rustfmt and diff checks pass.

Co-Authored-By: GitHub Copilot <noreply@github.com>

* fix: retain Fusion trackpad help in refactored preferences

Preserve upstream's new preferences pages and apply the translated Fusion trackpad tooltip in prefs/navigation.rs.

Validation: cargo build -p solidcraft passed. Tests and full CI were not run, as requested.

Co-Authored-By: GitHub Copilot <noreply@github.com>

* style: format Fusion trackpad preferences tooltip

Apply rustfmt's required wrapping for the relocated tooltip. No behavior changes.

Validation: cargo fmt --all -- --check and git diff --check passed.

Co-Authored-By: GitHub Copilot <noreply@github.com>

---------

Co-authored-by: GitHub Copilot <noreply@github.com>

* 2-point circle takes a typed diameter (#3278) (#3335)

* claim: #3278

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 2-point circle takes a typed diameter (#3278)

After its first point the 2-point circle tool showed no value box, so the
circle could only be sized with the cursor. It now shows a Diameter box,
like the centre circle: the typed value puts the second point that far
along the cursor's direction, and Enter draws the circle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Phanoob <phanoob@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Reorder design tabs by dragging them (#3187) (#3188)

* claim: #3187

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Reorder design tabs by dragging them (#3187)

A tab dragged along the strip follows the pointer while the others make
room, and lands where it is released; Escape, or releasing down the
window, leaves the order as it was. With the custom title bar a drag that
starts mostly up or down (or on the only tab) still moves the window.
The control channel's ui.documents gains a move action (from, to).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Phanoob <phanoob@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Renaming a body keeps the evaluated timeline, off the window thread (#2467) (#2468)

* Renaming a body that later features change evaluates nothing (#2467)

Model::rename_body now keeps the results when later features change the
body (a pattern, a chamfer, a join). It still evaluates when a body of
that name comes and goes or isn't the same body throughout, when a later
body's name is worked out from it (X (2)), or when a message quotes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Rename box: rename off the window thread, and locked bodies follow a rename (#2467)

The rename box commits body.rename through run_heavy, so a rename that
still evaluates later features (one names the body in its targets)
computes beside the window instead of freezing it. Locked bodies now
follow a body.rename from anywhere (the rename box, the control channel,
scripts), not only the rename box.

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Toolbar: a constraint dragged out of the small box becomes a full button (#5254) (#5456)

* claim: #5254

* Toolbar: a constraint dragged out of the small box becomes a full button (#5254)

The Constrain panel's small box of constraint icons was a fixed list, so
dragging one onto the full-size buttons saved a new order but it was still
drawn in the box. The toolbar settings now remember, per tab, which box
commands were moved out; those are drawn as full buttons, the box closes
up, and dropping one back on a box icon returns it. Reset Toolbar restores
the default box, and settings saved before this load unchanged.

* Sketch offset keeps its source lines free to turn (#4464) (#4466)

* claim: #4464

* Sketch offset keeps its source lines free to turn (#4464)

An offset adds a Parallel between each source line and its offset line.
Since offsets became associative, the offset's points are held during a
solve, so each Parallel pinned its source line to the offset line's
current direction: a closed chain lost its turning freedom (8 degrees of
freedom became 6), and Horizontal or an angle edit on a source line was
refused as conflicting.

The solver and the constructive reading now leave out a Parallel that
ties an offset line to the source it follows while the association lives;
the association already keeps them parallel. After Break the stored
Parallel drives again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Flat Pattern view draws perforations (#4440) (#4441)

* claim: #4440

* Flat Pattern view: draw perforation holes (#4440)

The flat view drew the outline, cut-outs, pockets and bend lines but not
perforation fields, though the flat-pattern data and the DXF export list
their holes. The view now reads the hole centres from the sheet through
SheetBody::perforation_holes, caches them per model state and body, and
draws them as circles in the cut-out style. Holes outside the viewport or
smaller than one pixel are skipped, and the cache is capped at one million
holes.

* "Don't Save" looks like a button in the Save changes prompt (#4380) (#4400)

* claim: #4380

* "Don't Save" looks like a button in the Save changes prompt (#4380)

The prompt's third action used the kit's low-emphasis text style, so it
didn't read as clickable and people thought Cancel was the only way out.
Add an opt-in secondary_left style to the confirm footer (a framed
secondary button from the theme tokens) and use it for Don't Save.

* Save prompt test: clear each frame's texture delta once it is applied

egui 0.36 panics when a TexturesDelta with unapplied deltas is dropped;
the test kept only the last frame's output and dropped the earlier ones.

* 3D mouse test: clear the frame's texture changes, so it passes in debug builds (#4382)

each_frame_hands_the_view_its_motion_through_the_drivers_mapping dropped
the output of a frame it ran (`let _ = ctx.run_ui(...)`). epaint panics
in debug builds when a TexturesDelta is dropped with deltas left ("Dropped
TexturesDelta with 1 unapplied deltas"), so the test failed under a plain
`cargo test` on every platform while the ci profile passed. It now clears
them, as the other UI tests do since #60.

Co-authored-by: Tyler Chiriboga <18270301+Tyler-Chiriboga@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Spanish (es-ES): catch up with en.json (#4292)

Co-authored-by: carlymx <24634598+carlymx@users.noreply.github.com>

* Esc ends the navigation bar's Pan, Orbit and Zoom modes (#4226) (#4233)

* claim: #4226

* Esc ends the navigation bar's Pan, Orbit and Zoom modes (#4226)

The nav bar toggled a mode on, but nothing cleared it on Esc, so a left drag
kept panning, orbiting or zooming. Esc now ends an active nav mode first;
the next Esc reaches the tool or dialog as before.

* Keep long Select Other labels inside the popup (#4209) (#4225)

* claim: #4209

* fix(ui): bound Select Other labels and expose full names (#4209)

* Deleting an inserted file removes its components, placements and joints (#4100) (#4113)

* claim: #4100

* Deleting an inserted file removes its components, placements and joints (#4100)

A STEP or mesh file inserted into a design makes a component tree. Deleting
its feature left that tree behind: the components, their occurrences, the
rigid joints that hold them and the bodies' component assignments. A later
body that took an imported body's name was then placed in the stale component.

Document::remove_component_tree removes a component subtree and everything
that refers to it: the features made inside it, its occurrences, the joints
on them with their motion links, joint origins, contact set members, motion
study keys and exploded moves, body component and offset entries, standard
part and link records, and configuration columns. Deleting an inserted
file's feature and component.delete both use it; component.delete now also
removes joints left pointing at a deleted occurrence. The body transfer
refusal moves with it, unchanged.

* Only an inserted file's feature takes its component tree when deleted

Mark the feature a STEP, IGES or mesh insert makes (`inserted`), and remove
the component tree on delete only for those and for a linked design's
feature. Deleting a copied component's independent base deletes just that
feature, as before. Older designs get the mark as they open (pending format
change), except copied components' independent bases.

* Rigid joint saved with the wrong occurrence when two occurrences share a component (#3857) (#3939)

* claim: #3857

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Joint dialog: a body of a further instance picks that occurrence, not the component's first (#3857)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>

* Cancel Sketch: leave a sketch without keeping its changes (#3838) (#3938)

* claim: #3838

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SBN24y2TcqRcm2zn8Gkj27

* Cancel Sketch: leave a sketch without keeping its changes (#3838)

sketch.cancel (Cancel Sketch, ribbon Exit group and the sketch's
right-click menu) puts the design back as it was when the open sketch
was created or opened for editing: a new sketch is removed with its
dimension parameters, an edited one returns to how it was. It is one
undo step, so Undo goes back into the sketch.

The session remembers the design and the undo history at the point a
sketch is entered. Cancel uses that only while the history still runs
through it (the same steps before, every step since taken in this
sketch); otherwise it refuses and points to Undo, so a stale entry can
never throw away later work.

The icon is the approved close glyph in the sketch accent (alias
sketch_cancel); ja and zh carry the new label.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SBN24y2TcqRcm2zn8Gkj27

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* 3MF import: name a Bambu Studio project's bodies after its objects (#3858) (#3859)

* claim: #3858 (3MF import: names from a Bambu Studio project)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3MF import: name a Bambu Studio project's bodies after its objects (#3858)

Bambu Studio leaves the name off the root model's objects and keeps it in
Metadata/model_settings.config (<config><object id="N"><metadata key="name">),
so every object of a project came in as Mesh1, Mesh2, ... A build item whose
root object has no name of its own now takes the object's name from those
settings. A name in the model still comes first, and part names, other
settings and objects of other model parts are left alone.

The settings are read only when a body needs a name, within what the model
parts left of the 1 GB budget. Settings that can't be read (malformed, nested
too deeply, too large) never fail the import: the bodies keep the generic
names, with a warning saying why.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Tyler Chiriboga <18270301+Tyler-Chiriboga@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Viewport: right-click keeps a multi-selection of faces and edges (#3733) (#3788)

* claim: #3733

* Viewport: right-click keeps a multi-selection of faces and edges (#3733)

---------

Co-authored-by: AhmetV <344941595+ahmetv10@users.noreply.github.com>

* Parameter import and image pickers offer their own file types (#3216) (#3314)

* claim: #3216

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Parameter import and canvas image pickers offer their file types (#3216)

Both opened the picker with no file type of their own, so it listed only
designs, CAD files and meshes (and All files). macOS lets you pick only
the types a filter names: CSV parameters, and PNG or JPEG images for a
canvas, were greyed out. They now ask for csv/json and png/jpg/jpeg,
which the picker lists first (#1801).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Phanoob <phanoob@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Return to the originating tab after finishing a sketch (#3250) (#3264)

* claim: #3250

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Return to the originating toolbar tab after finishing a sketch (#3250)

A sketch started from SHEET METAL or PLASTIC always ended on the SOLID
tab. Remember the tab that was active when the sketch began
(UiState::sketch_return_tab, not saved) and go back to it when the
sketch ends. Falls back to SOLID when that is not one of the normal
toolbar tabs. A sketch started from SOLID behaves as before.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Test: no one-element loop in the return-tab test (clippy single_element_loop)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>

* STEP import: let a cancelled import release the import slot (#3171) (#3189)

* claim: #3171

* Release cancelled file import slots (#3171)

* Dragging a part along its joint no longer highlights what's under the pointer (#5137) (#5140)

* claim: #5137

* Dragging a part along its joint no longer highlights what's under the pointer (#5137)

The viewport's hover pick was skipped during a box selection and camera
moves, but not while a jointed part was dragged, so faces and edges under
the pointer lit up on every frame of the drag. The pick now waits until
the joint drag ends.

* Browser: a body picked in the viewport is opened to and scrolled to (#3005) (#3081)

* claim: #3005

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Browser: a body picked in the viewport is shown in the Browser (#3005)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Merge main; viewport::select takes a toggle flag now

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Phanoob <phanoob@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Line tool snaps a diameter through the circle centre (#4326) (#4327)

A line drawn across a circle only snapped its end onto the curve, so a
diameter could miss the centre at any angle. Near the centre the line now
locks onto the radial line: on the rim it stays on the circle and the centre
is kept on the line, and on the centre point the end lands there.

* Flatpak: find the host's fonts for CJK text and the font list (#5124) (#5127)

* claim: #5124

* Flatpak: find the host's fonts for CJK text and the font list (#5124)

Inside the Flatpak sandbox the host's fonts are mounted at /run/host/fonts
and /run/host/user-fonts, which no font search looked at. Japanese and
Chinese text fell back to boxes, and the font list missed the host's
families.

- Search both folders for the CJK and Chinese fallbacks and for the
  installed-font list.
- The CJK fallback reads SOLIDCRAFT_FONT_DIRS (a path list, as the font
  list already does), with CRAFT_FONTS_DIR kept as a fallback.

* Crash reports: keep component.activate in the journal (#5079) (#5121)

* claim: #5079

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Crash journal keeps component.activate (#5079)

Activating a component changes only the session's active component and
occurrence path, not the design, so the journal dropped the command and a
crash report's replay built later parts under the previously active
component. The journal now also keeps a command that changes either.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Control channel and MCP on by default, with a setting to turn it off (#5561)

The app opens its localhost control channel on port 27878 (or the next free
one) unless Preferences > Assistant turns it off; --control and
SOLIDCRAFT_CONTROL_PORT still win. A current.port discovery file lets
solidcraft-cli mcp find the running app with no arguments.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>

* Tests: moving an occurrence recomputes only the cuts and joins it touches (#4965) (#4982)

* claim: #4965

* Joins and cuts follow only the placements they depend on (#4965)

Tests that turning a joint between parts cuts no hole again, and that a
tool cutting another part cuts again when that part moves relative to it,
while an unrelated part moving keeps the result.

Booleans and Combine now hash only the placements of the bodies they take
as inputs, relative to the frame that evaluates them (#3397), so these
pin that behaviour for joins and cuts with named targets across parts.

* fix(ui): preview shortcut chords and wait for complete modifier input (#1810)

Keep physical modifier events out of the ordinary-key slot, preview held keys immediately, and confirm after release and a one-second settling interval. Preserve legacy control-channel modifier behavior while allowing explicit physical Control/Command input. Add regression coverage, a UI scenario, localized guidance and documentation.

Fixes #1809.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>

* Move: scope box selection to the active component (#4459) (#4527)

* claim: #4459

* fix: scope Move box selection to active component (#4459)

* Delete removes a selected construction axis (#4692) (#4695)

* claim: #4692

* Delete removes a selected construction axis (#4692)

Delete on a construction axis skipped it with "the origin axes can't be
deleted". It now deletes the construction axis feature, like construction
planes; the origin axes X, Y and Z are still refused.

* Agent chat: end the agent's process group where `kill` is busybox' (#4587) (#4588)

* claim: #4587

* Agent chat: end the agent's process group with the system call (#4587)

The group was signalled with a `kill` program: `kill -s TERM -- -<pgid>`.
That works with procps' `kill` and with BSD's, but busybox' `kill` takes no
`--`: it prints its usage, exits 1 and signals nothing. The agent's helpers
(the CLI an adapter wraps, the MCP servers it starts) then stay alive after
the agent is ended.

The group is signalled with the system call instead, so no `kill` program and
no shell are needed and the behaviour is the same everywhere. `nix` (MIT) is
already in the tree through zip, and only on Unix: the browser build does not
take it.

The two tests that cover this now pass where they failed before:
`agent_chat::process::tests::dropping_the_process_ends_its_group` and
`agent_chat::process::tests::stopping_the_agent_leaves_no_helpers_running`.

* Extrude: picking a face, vertex or parallel plane off the profile switches the extent to To Object (#2855)

* Extrude: picking a face, vertex or parallel plane off the profile turns the extent to To Object

With profiles picked, a face off their plane (or curved), a vertex off it, or a
construction plane parallel to it is taken as the To Object target instead of
another profile: the extent switches without the Extent Type choice, and the
distance shows how far the target is. Coplanar faces still join the profiles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Extrude: automatic To Object is a Preferences setting, off by default

Preferences > Design > "Extrude to a picked target" (`extrude_auto_to_object`, default
false) turns the automatic To Object on. Off, picks in the Extrude dialog only add
profiles and the distance is not synced to a target, as before this change.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>

* Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci]

* FreeCAD/Rhino import: decode caps with room for detailed parts (#1783) (#3209)

* claim: #1783

* FreeCAD/Rhino import: decode caps with room for detailed parts (#1783)

cadmpeg decoded FreeCAD and Rhino files under its service profile, whose
million collection items and 2.56e8 work units a detailed part (a sprocket,
a threaded rod, a part with a few hundred faces) runs past: the import was
refused with "resource limit on CollectionItems: BudgetExceeded". Allow
8 million items and 2e9 work units, half of cadmpeg's desktop profile; the
input-size cap stays SolidCraft's STEP limit and every other cap stays at
the service profile's.

decoding_is_capped checks the two raised caps (and that they stay below the
desktop profile's) and that every other cap is still the service one.

Fixes #1783

* A spherical shell revolves into a solid with a void, and cuts and joins with hollow tools work (#451) (#4182)

* Assembly: ground the first part; drag free joint chains (#2804) (#2805)

* assembly(feat): ground first parts and drag free joint chains

Ground the first top-level part when it is created and let face dragging reposition free occurrences or any member of a floating joint chain in one undo step. Preserve exact repeated-placement frames, joint values, modifier selection and Escape cancellation. Keep the accumulated fixture, viewport layout and documentation integration fixes from the existing PR.

Signed-off-by: Karl Von Richter<karl@embeint.com>

* assembly(fix): update movable fixtures for first-part grounding

Accept the documented grounded override in component creation, explicitly free movement fixtures, and keep linked clipboard pastes movable even in an empty document. Cover first and reused linked pastes, default insertion grounding, and single-step undo in the existing link tests.

Signed-off-by: Karl Von Richter<karl@embeint.com>

* assembly(test): keep the motion-cost crank free

Unground the first component in the document-level joint-face measurement fixture so its motion samples can rotate after first-part grounding. Preserve the rotation and shared-measurement assertions that caught the Linux CI failure.

Signed-off-by: Karl Von Richter<karl@embeint.com>

* assembly(test): free the frame placement fixture

Create the frame-member placement fixture ungrounded before moving it above its root sketch. Keep its world-space member assertions while respecting first-part grounding in the frame tests added on main.

Signed-off-by: Karl Von Richter<karl@embeint.com>

---------

Signed-off-by: Karl Von Richter<karl@embeint.com>

* UI: integrate theme radii, borders, palettes and viewport refresh (#5045) (#5046)

* claim: #4922

* UI: let themes own corner radii

* UI: use four-point panel and window radii in SolidCraft 95

* UI: align the 95 radius assertion with four-point panels

* claim: #4953

* UI: let themes own structural and control borders

* UI: single Blueprint outlines and dotted secondary rules

* UI: separate container outlines and add theme-owned 95 bevels

* claim: #5007

* UI: apply theme palettes to icons, workspace tabs and Gruvbox settings

* claim: #5045

* Viewport: invalidate cached geometry colours when the theme palette changes

* UI: preserve legacy theme logo colours with the borderless mark

* Section Analysis: OK with no plane picked turns the section off (#2410) (#2411)

While a section is on, Section Analysis opened with no plane picked now
says "No plane picked: OK turns the section off." and OK runs
inspect.section {"clear": true}. With no section on, OK with no plane
still asks for a plane.

Fixes #2410.

* Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci]

* Close Change Parameters with Escape (#3372) (#3399)

* claim: #3372

* Fix Escape cancellation in Change Parameters

Consume Escape before parameter editors can commit pending text on focus loss, while preserving autocomplete and shortcut capture priority. Add 11 UI regressions covering draft cancellation, preview cleanup, document and history preservation, and reopening saved values.

* Preserve construction references when axes and points are renamed (#4419) (#4449)

* claim: #4419

* fix: preserve renamed construction references (#4419)

* Browser: route body picks into open command dialogs (#2560) (#4371)

* claim: #2560

* fix(ui): route Browser body picks into active dialogs

* Fix midpoint picks in sketch dimensions (#4872)

Retain MID snaps across both picks and materialize reusable midpoint
constraints for point-to-line and point-to-point dimensions. Let midpoint
pairs choose horizontal or vertical spans with pointer placement before
entering a value.

Cover projected model edges, overlapping geometry, helper reuse,
undo/cancel/reopen, and existing angular and line-circle workflows.

* Sketch slots: provide typed dimension inputs for every variant (#2423) (#4318)

* claim: #2423

* fix(sketch): add typed dimensions to every slot tool (#2423)

* Version 0.21.0 on main (released from rc.2's code) (#5774)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* docs(mcp): Windows setup for Claude Desktop (#3794)

* docs(mcp): Windows setup for Claude Desktop (paths, two servers, Store config file)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(mcp): say the Store build can keep the config elsewhere, not that the other path fails

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci]

* MCP: wait out long requests, honest batch outcomes, batch tool aliases (#2901)

Rebased onto main as one commit (was 120d26f7, df488f9a, db9d1073 plus
merges of main).

- A request the app reports as still running (#1618) is waited for on the
  same connection through `control.pending`; past the wait the error says it
  was not cancelled and will still apply (or only write its file), later calls
  are not sent behind it, and the new read-only `pending` tool returns its
  result once it lands.
- A failed batch says what really happened: `rolled_back` only when the
  design is back where the batch started (as #3288 / #3242 on main: the app's
  `rolled_back` is believed; for an older app, every completed step undone;
  only a rollback asked for is reported as one), plus `applied`, `not_run` and
  `failed.still_running`.
- A batch step may name a tool (set_parameter, list_commands,
  describe_command, execute, measure, inspect_design, undo, redo, export, save,
  open, new_design, crash_reports); screenshot, body_topology, batch and
  pending are refused before anything is sent.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* 3D mouse: driver choice, guided calibration, button mapping and a picture of the device (#1463) (#3825)

* Add a small test boat to steer around with the 3D mouse

A little hull in its own camera, drawn by the UI itself and not the CAD
viewport, so a 3D mouse can be tried out without opening a design. It
brings the model (generated by assets/models/make_test_boat.py) and the
tests for how the camera moves it.

Nothing uses it yet; Preferences picks it up with the rest of the 3D mouse
work.

* UI kit: keep a choice row from flipping with the scrollbar

A choice row picks segments or a dropdown by whether its labels fit. A page
scrollbar that comes and goes narrows the row by its own width, so the row
could flip back and forth between the two on a page that sits right at the
edge. It is now judged on the whole row and through a small helper,
fits_steadily, that only changes its answer once the miss is wider than the
scrollbar.

Also tests for that, for typed numbers in a slider's value box, and a
test hook to put up the issue-report input barrier by hand.

* 3D mouse: device model, guided calibration, buttons and a picture

The 3D mouse becomes a device we know something about instead of six raw
numbers.

The reader picks between spacenavd and the direct hidraw driver, falls back
from one to the other, and says so when it can't open the device. Guided
calibration asks whether the device is a spring cap or a trackball, adds a
touch step, and uses a settle filter so a ball let go of stops reading as
motion. Buttons get jobs (keys, held modifiers, commands), saved per
device, with defaults for the SpaceMouse Pro.

Preferences gets a picture of the device that moves as it does, next to the
test boat, and the same picture can sit in the 3D view at Left Viewport,
Right Viewport or Left Window, above the floating History card like the
legend and readouts.

These ride together because the crate's calibration types, the Preferences
page, the Linux reader and the button hook all use each other.

* 3D mouse: offer to calibrate an uncalibrated device

When a 3D mouse shows up that has not been calibrated, a card in the 3D
view offers to calibrate it, to swap drivers when the other one would work
better, to close, or to never ask again. It is made once per device, goes
away after 30 seconds in view, and gives way to a status or computing
toast instead of stacking on it.

The card is the shared toast, which now takes buttons: they group by
purpose, wrap inside the card at narrow widths, and do nothing while an
issue report holds CAD input. The picture of the device in the 3D view
keeps clear of the card.

* 3D mouse: translations and attribution

The 3D mouse strings (ui.3d_mouse_*, the calibration steps and the offer
card) in every shipped language, the Japanese catalog note, and the credit
for the test boat model in ATTRIBUTION.md.

This is last because the code before it only looks keys up: it builds and
runs without these, falling back to English until they land.

* 3D mouse: draw its preferences with the settings kit like the rest of Navigation

The 3D mouse section was a folded header with its own layout. It now
uses the same kit as the sections above it: a settings_section card,
settings_form rows with the shared label column, dividers between the
device, axes, picture and buttons groups, Text::Hint for the helper
lines, and settings_control_width for the dropdowns.

The section is open like every other one (the header still folds it),
so the device badge on the folded header is gone; the calibration row
already names the device. Every setting, string and key is unchanged.
Tests that unfolded the section no longer click it, and the hint lines
are looked up as hints instead of notes.

* 3D mouse: show each axis's live tilt in Preferences, like main's axis rows

Each view axis row is main's axis row again: a meter that follows the tilt of the motion driving it, with the dead zone marked, next to Invert, Speed and the "driven by" menu. The meter reads the calibrated travel when the device has a calibration and the raw reading over full scale when it does not, and asks for a frame whenever the reading changes. The helpers are back as they are on main, and the calibration state is a badge by the enable toggle.

* 3D mouse: line its preferences up with the rest of Navigation

- The two checkboxes sit side by side in the same two-column grid as Wheel and trackpad.
- Row labels stop repeating "3D mouse" and use sentence case: Calibration, Driver, Restore defaults, Reset view, Left viewport, Right viewport, Left window.
- Hints line up under the control column instead of the left edge.
- The six view axes are named Pan X, Pan Y, Zoom, Orbit X, Orbit Y and Roll so they fit the name column.
- Buttons are a divider and rows in a form like the other groups, without the bold sub-heading.
- The driver, dead zone, axes, picture and buttons sit behind a collapsed Advanced section, since most people never touch them.

* 3D mouse: tidy the Advanced test for clippy

* Timeline: suppressing a feature keeps later features' body names (#4302) (#4311)

* claim: #4302

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Timeline: suppressing a feature keeps later features' body names (#4302)

Bodies numbered by the counter (`BodyN`) take the next free number on every
recompute, so suppressing a feature that made one moved every later counted
body down one (`Body2` became `Body1`) and broke what named it, such as a
Move of `Body2`. timeline.suppress now stores the current names of later
features' counted bodies in their `body_names` first, as body.rename already
does for a renamed counted body (#1109). Engine only: no evaluation or
design-format change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Timeline: a body made while a feature is suppressed keeps its name (#4302)

Suppressing a feature freed its counted name (`Body1`), so a body made
meanwhile took it and became `Body3` once the feature was back. Suppress
now also stores the suppressed feature's own counted names, and the
evaluator adds the names stored on earlier suppressed features to the
state's `reserved_body_names` (the names a rename freed, #4957), which the
`BodyN` counter passes by. They are taken from the design before each
feature is computed and are part of the state digest, so a cached result
is only reused when they match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* cadmpeg 0.6.3: FreeCAD and Rhino import in the browser too (#4482)

cadmpeg 0.6.3 is pure Rust (ruzstd instead of the zstd C library, zip 9), so
solidcraft-io depends on it on every target and the wasm build reads .FCStd
and .3dm files like the desktop app. The browser's file pickers offer them.

0.6.3 replaces DecodeOptions::container_only with a decode depth; ask for
Geometry, what its STEP writer reads. zip 8 leaves the tree, and with it the
three cargo-deny advisory exceptions kept for it.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Pattern dialogs: Quantity fields get up/down step arrows (#4260) (#4264)

A new kit control, `ui_kit::count_field` / `count_row`, is an expression field for a
whole-number count with stacked step arrows at its right edge. An arrow steps the
evaluated count by one within the field's limits: a plain number is replaced, an
expression keeps its parameters and gains or adjusts a trailing `+ n`. The text
changes as typing changes it, so the live preview follows.

Used by the Quantity rows of the sketch Rectangular (1-500, both directions together
at most 500) and Circular (2-500) patterns, and the solid Rectangular, Circular and
Path patterns (1-1000).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci]

* Join: say so when the joined piece doesn't touch the body (#4434) (#4436)

* claim: #4434

* Join: say so when the joined piece doesn't touch the body (#4434)

A Join (extrude, revolve, sweep, loft, pipe, coil, primitives, thicken,
boundary fill, surface network) whose material meets none of its targets
still joins it to the first target as a separate piece. The result stays
as it was; the feature now warns: "the joined piece doesn't touch `Body1`,
so `Body1` is now in 2 separate pieces (New Body makes it a body of its
own)". History marks it and the command reply carries it.

The check compares pieces (outer shells, not voids) of the bodies the
feature changed or absorbed before and after it, so it holds for the
batched multi-profile path too, stays quiet for a join that bridges
pieces or closes a cavity, and leaves Combine (which joins the bodies it
is given) alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Hole: snap to face and round-edge centres, drag placed holes, section in the dialog (#2171) (#2912)

Placing a Single hole put it exactly where the click landed, with nothing to line it up by and no
way to move it but taking it out and picking again; the dialog was a list of size fields.

- Preferences › Design › Create Hole has two settings, both off by default, so a new installation
  places holes as before:
  - Snap to face and edge centres (`hole_snaps`): the planar face under the cursor offers its
    middle and the centres of its round edges (a boss, a rounded corner, a hole already there) as
    marks; a pick near one, or on or beside such an edge, takes that centre and the mark is named.
    Sketch points on the face still come first; Alt or Ctrl held places it where clicked.
  - Move holes by clicking or dragging (`hole_moves`): each placed hole has a handle that drags it
    (snapping when snaps are on) and takes it out when clicked, and a pick elsewhere on its face
    moves it there. Edit Feature shows the handle too, so a hole can be moved later (#1047).
- The Hole dialog draws a section of the hole as its options make it, with each size in a field
  beside the dimension it sets; a hovered or focused field lights its dimension. The layout is a
  new kit element, `ui_kit::figure_rows` (a caller-painted figure beside captioned fields).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Hole: place a Single hole by distances from reference edges (#37) (#2931)

A Single hole can take up to two straight edges of its face as references,
each with the distance of the hole's centre from that edge's line, measured
in the face's plane. `solid.hole` takes them as `references: [{edge, distance}]`;
the edges are found again by their persistent names, else their points, so
the hole follows them when the part changes, and each distance is a
parameter. A bad reference (no edge there, a curved edge, one square to the
face, parallel edges, a negative distance, a centre off the face) is refused.

In the Hole dialog, Reference 1 and 2 follow the face, each with its Distance
row; the canvas shows each distance as a dimension from its edge to the hole,
with a box just past the edge to type it in. Picking an edge measures its
distance (and its box takes the keyboard), typing one moves the hole, and
moving the hole measures them again. Edit Feature picks the edges again with
their distances.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

---------

Signed-off-by: Karl Von Richter<karl@embeint.com>
Co-authored-by: Frank <58932224+plexi32@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: raykholo <raykholo@users.noreply.github.com>
Co-authored-by: CamWight <91156642+CamWight@users.noreply.github.com>
Co-authored-by: SS-EN <84559909+SS-EN@users.noreply.github.com>
Co-authored-by: Colin Faulkingham <colin.faulkingham@gmail.com>
Co-authored-by: Karl Von Richter <karl@von-richter.net>
Co-authored-by: HiroKenEz <59478073+HiroKenEz@users.noreply.github.com>
Co-authored-by: Peppe Bergqvist <p@bergqvi.st>
Co-authored-by: GitHub Copilot <noreply@github.com>
Co-authored-by: phanoob <lesur.Stephane@wanadoo.fr>
Co-authored-by: Phanoob <phanoob@gmail.com>
Co-authored-by: Kai Evans <nemshilov@gmail.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Tyler Chiriboga <tylerchiriboga.apps@gmail.com>
Co-authored-by: Tyler Chiriboga <18270301+Tyler-Chiriboga@users.noreply.github.com>
Co-authored-by: CaRLymx <carlymx@gmail.com>
Co-authored-by: carlymx <24634598+carlymx@users.noreply.github.com>
Co-authored-by: Loohi <78334654+Loohi@users.noreply.github.com>
Co-authored-by: leValdemar19 <valdemarnord@hotmail.com>
Co-authored-by: AhmetV <mihendiz10@gmail.com>
Co-authored-by: AhmetV <344941595+ahmetv10@users.noreply.github.com>
Co-authored-by: rlartot <94693724+rlartot@users.noreply.github.com>
Co-authored-by: Christian Svensson <blue@cmd.nu>
Co-authored-by: Thodoris Tsagkogeorgas <thodoristsagko@gmail.com>
Co-authored-by: ahmetalpcelik <80778880+ahmetalpcelik@users.noreply.github.com>
Co-authored-by: ealitt <ealitt@gmail.com>
Co-authored-by: Homero Thompson <vdirienzo@gmail.com>
Co-authored-by: Diogo Soares Rodrigues <dsrodrigues20@gmail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Artem Sushchev <Suschev.Artem+github@gmail.com>
Co-authored-by: Nathan Dane <nathandane.dev@gmail.com>
Co-authored-by: Kai Evans <hi@kaievans.co>
Co-authored-by: Victor Santos Oliveira <56417388+Vittuu@users.noreply.github.com>
Co-authored-by: cronos <crono2250@users.noreply.github.com>
Co-authored-by: rbousigues88 <rbousigues@gmail.com>
Co-authored-by: The Nomad <nomad@positron3d.com>
Co-authored-by: Jackson Oursland <jacob.oursland@gmail.com>
Co-authored-by: Jacques <59315319+Jacquesvdberg92@users.noreply.github.com>
2026-10-24 02:09:20 -04:00
selune 0b3e9a9e1e fix(render): wrap complete backend diagnostics (#5714) (#5715)
Preserve full backend/job/decoded-image diagnostics in the existing shared panel without ellipsis. The painted-galley regression covers Floating/Compact and light/dark, complete text, wrapping and fixed panel width. Exact-tree Render UI 33/0, strict UI Clippy, WASM and repository gates pass. Real safe-mode window captures, camera restore, CPU export and normal exit are recorded; no backend behavior or unrelated UI policy changes.
2026-10-24 08:05:00 +02:00
selune 24c9d14663 GPU: adapt dispatches and retire lost devices (#5754) (#5755)
* claim: #5754

* fix(gpu): adapt dispatch size and retire lost path devices (#5754)

Start with a 64-item quantum and adapt bounded work toward 20 ms from observed completion, including denoiser tiles. Never exceed 16384 work items; hardware-specific shader cost means this is not a universal TDR guarantee. Publish device loss/uncaptured failure/poll failure through live executor availability without reinitializing automatically. Source-pinned required-Vulkan GPU suite: 105 passed, zero failed, three existing ignores, including actual device.destroy retirement and session availability. Strict GPU/engine all-target Clippy passes. Broader engine and repository/WASM continuation remains separately recorded. No geometry baseline, CI rule or published format history change.
2026-10-24 08:04:52 +02:00
Jacques 807e367bdb Hole: place a Single hole by distances from reference edges (#37) (#2931)
A Single hole can take up to two straight edges of its face as references,
each with the distance of the hole's centre from that edge's line, measured
in the face's plane. `solid.hole` takes them as `references: [{edge, distance}]`;
the edges are found again by their persistent names, else their points, so
the hole follows them when the part changes, and each distance is a
parameter. A bad reference (no edge there, a curved edge, one square to the
face, parallel edges, a negative distance, a centre off the face) is refused.

In the Hole dialog, Reference 1 and 2 follow the face, each with its Distance
row; the canvas shows each distance as a dimension from its edge to the hole,
with a box just past the edge to type it in. Picking an edge measures its
distance (and its box takes the keyboard), typing one moves the hole, and
moving the hole measures them again. Edit Feature picks the edges again with
their distances.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 05:32:30 +00:00
Jacques 400e06c202 Hole: snap to face and round-edge centres, drag placed holes, section in the dialog (#2171) (#2912)
Placing a Single hole put it exactly where the click landed, with nothing to line it up by and no
way to move it but taking it out and picking again; the dialog was a list of size fields.

- Preferences › Design › Create Hole has two settings, both off by default, so a new installation
  places holes as before:
  - Snap to face and edge centres (`hole_snaps`): the planar face under the cursor offers its
    middle and the centres of its round edges (a boss, a rounded corner, a hole already there) as
    marks; a pick near one, or on or beside such an edge, takes that centre and the mark is named.
    Sketch points on the face still come first; Alt or Ctrl held places it where clicked.
  - Move holes by clicking or dragging (`hole_moves`): each placed hole has a handle that drags it
    (snapping when snaps are on) and takes it out when clicked, and a pick elsewhere on its face
    moves it there. Edit Feature shows the handle too, so a hole can be moved later (#1047).
- The Hole dialog draws a section of the hole as its options make it, with each size in a field
  beside the dimension it sets; a hovered or focused field lights its dimension. The layout is a
  new kit element, `ui_kit::figure_rows` (a caller-painted figure beside captioned fields).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 05:25:56 +00:00
selune 934d4ddae2 Render: defer path-host startup until first use (#5742) (#5743)
* claim: #5742

* fix(render): defer GPU path initialization until first use (#5742)

Retain the selected adapter factory during Design/safe-mode startup; instantiate once on first Render entry, including late viewport installation. Preserve the deferred/preparing/ready/failed state across document changes and retain selected fallback adapter policy. Exact tree passes 32 render-workspace and 1966 full UI tests (0 failures; 7 existing ignored), strict native all-target Clippy, wasm check and repository gates. Three wasm warnings remain in untouched upstream files. Fresh native desktop acceptance is separate and in progress; no whole-renderer/main sign-off implied.
2026-10-24 07:25:50 +02:00
selune 7a81a7fc15 library: transport PBR and engineering cards without lossy copies (#1086)
* wip(library): reconcile exact rich-catalog replacement semantics (#1062)

Source-only scoped extraction from original 0f801962 / #1086 on the refreshed #1060 card prerequisite. Preserve current asynchronous picker guards and source-ID handles. Scalar v1 and rich-v2-without-PBR clear existing PBR; supplied rich PBR is retained. No new saved concept or duplicate card descriptor. Formatting and source review completed; compile/native acceptance is still pending.

* test(library): preserve explicit downgrade and fallible UI regressions (#1062)

Request scalar v1 explicitly in the card downgrade refusal regression; rich v2 remains the default lossless writer. Propagate fallible harness actions with ? in the shared clicked-catalog helper, preserving all assertions. No production code, limits or ignore changes. Fresh grouped IO/engine/UI tests, scoped strict Clippy and WASM pass on this exact tree.
2026-10-24 07:13:19 +02:00
Frank 1fa0371c75 Pattern dialogs: Quantity fields get up/down step arrows (#4260) (#4264)
A new kit control, `ui_kit::count_field` / `count_row`, is an expression field for a
whole-number count with stacked step arrows at its right edge. An arrow steps the
evaluated count by one within the field's limits: a plain number is replaced, an
expression keeps its parameters and gains or adjusts a trailing `+ n`. The text
changes as typing changes it, so the live preview follows.

Used by the Quantity rows of the sketch Rectangular (1-500, both directions together
at most 500) and Circular (2-500) patterns, and the solid Rectangular, Circular and
Path patterns (1-1000).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 04:13:39 +00:00
Frank fc3e3b6c8d Join: say so when the joined piece doesn't touch the body (#4434) (#4436)
* claim: #4434

* Join: say so when the joined piece doesn't touch the body (#4434)

A Join (extrude, revolve, sweep, loft, pipe, coil, primitives, thicken,
boundary fill, surface network) whose material meets none of its targets
still joins it to the first target as a separate piece. The result stays
as it was; the feature now warns: "the joined piece doesn't touch `Body1`,
so `Body1` is now in 2 separate pieces (New Body makes it a body of its
own)". History marks it and the command reply carries it.

The check compares pieces (outer shells, not voids) of the bodies the
feature changed or absorbed before and after it, so it holds for the
batched multi-profile path too, stays quiet for a join that bridges
pieces or closes a cavity, and leaves Combine (which joins the bodies it
is given) alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 04:13:31 +00:00
github-actions[bot] d1a6262861 Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci] 2026-10-24 04:15:11 +00:00
Jackson Oursland 612d9d08dd cadmpeg 0.6.3: FreeCAD and Rhino import in the browser too (#4482)
cadmpeg 0.6.3 is pure Rust (ruzstd instead of the zstd C library, zip 9), so
solidcraft-io depends on it on every target and the wasm build reads .FCStd
and .3dm files like the desktop app. The browser's file pickers offer them.

0.6.3 replaces DecodeOptions::container_only with a decode depth; ask for
Geometry, what its STEP writer reads. zip 8 leaves the tree, and with it the
three cargo-deny advisory exceptions kept for it.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 03:47:00 +00:00
Frank d2eb8cf639 Timeline: suppressing a feature keeps later features' body names (#4302) (#4311)
* claim: #4302

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Timeline: suppressing a feature keeps later features' body names (#4302)

Bodies numbered by the counter (`BodyN`) take the next free number on every
recompute, so suppressing a feature that made one moved every later counted
body down one (`Body2` became `Body1`) and broke what named it, such as a
Move of `Body2`. timeline.suppress now stores the current names of later
features' counted bodies in their `body_names` first, as body.rename already
does for a renamed counted body (#1109). Engine only: no evaluation or
design-format change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Timeline: a body made while a feature is suppressed keeps its name (#4302)

Suppressing a feature freed its counted name (`Body1`), so a body made
meanwhile took it and became `Body3` once the feature was back. Suppress
now also stores the suppressed feature's own counted names, and the
evaluator adds the names stored on earlier suppressed features to the
state's `reserved_body_names` (the names a rename freed, #4957), which the
`BodyN` counter passes by. They are taken from the design before each
feature is computed and are part of the state digest, so a cached result
is only reused when they match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 03:40:39 +00:00
The Nomad 1f6a2d4daf 3D mouse: driver choice, guided calibration, button mapping and a picture of the device (#1463) (#3825)
* Add a small test boat to steer around with the 3D mouse

A little hull in its own camera, drawn by the UI itself and not the CAD
viewport, so a 3D mouse can be tried out without opening a design. It
brings the model (generated by assets/models/make_test_boat.py) and the
tests for how the camera moves it.

Nothing uses it yet; Preferences picks it up with the rest of the 3D mouse
work.

* UI kit: keep a choice row from flipping with the scrollbar

A choice row picks segments or a dropdown by whether its labels fit. A page
scrollbar that comes and goes narrows the row by its own width, so the row
could flip back and forth between the two on a page that sits right at the
edge. It is now judged on the whole row and through a small helper,
fits_steadily, that only changes its answer once the miss is wider than the
scrollbar.

Also tests for that, for typed numbers in a slider's value box, and a
test hook to put up the issue-report input barrier by hand.

* 3D mouse: device model, guided calibration, buttons and a picture

The 3D mouse becomes a device we know something about instead of six raw
numbers.

The reader picks between spacenavd and the direct hidraw driver, falls back
from one to the other, and says so when it can't open the device. Guided
calibration asks whether the device is a spring cap or a trackball, adds a
touch step, and uses a settle filter so a ball let go of stops reading as
motion. Buttons get jobs (keys, held modifiers, commands), saved per
device, with defaults for the SpaceMouse Pro.

Preferences gets a picture of the device that moves as it does, next to the
test boat, and the same picture can sit in the 3D view at Left Viewport,
Right Viewport or Left Window, above the floating History card like the
legend and readouts.

These ride together because the crate's calibration types, the Preferences
page, the Linux reader and the button hook all use each other.

* 3D mouse: offer to calibrate an uncalibrated device

When a 3D mouse shows up that has not been calibrated, a card in the 3D
view offers to calibrate it, to swap drivers when the other one would work
better, to close, or to never ask again. It is made once per device, goes
away after 30 seconds in view, and gives way to a status or computing
toast instead of stacking on it.

The card is the shared toast, which now takes buttons: they group by
purpose, wrap inside the card at narrow widths, and do nothing while an
issue report holds CAD input. The picture of the device in the 3D view
keeps clear of the card.

* 3D mouse: translations and attribution

The 3D mouse strings (ui.3d_mouse_*, the calibration steps and the offer
card) in every shipped language, the Japanese catalog note, and the credit
for the test boat model in ATTRIBUTION.md.

This is last because the code before it only looks keys up: it builds and
runs without these, falling back to English until they land.

* 3D mouse: draw its preferences with the settings kit like the rest of Navigation

The 3D mouse section was a folded header with its own layout. It now
uses the same kit as the sections above it: a settings_section card,
settings_form rows with the shared label column, dividers between the
device, axes, picture and buttons groups, Text::Hint for the helper
lines, and settings_control_width for the dropdowns.

The section is open like every other one (the header still folds it),
so the device badge on the folded header is gone; the calibration row
already names the device. Every setting, string and key is unchanged.
Tests that unfolded the section no longer click it, and the hint lines
are looked up as hints instead of notes.

* 3D mouse: show each axis's live tilt in Preferences, like main's axis rows

Each view axis row is main's axis row again: a meter that follows the tilt of the motion driving it, with the dead zone marked, next to Invert, Speed and the "driven by" menu. The meter reads the calibrated travel when the device has a calibration and the raw reading over full scale when it does not, and asks for a frame whenever the reading changes. The helpers are back as they are on main, and the calibration state is a badge by the enable toggle.

* 3D mouse: line its preferences up with the rest of Navigation

- The two checkboxes sit side by side in the same two-column grid as Wheel and trackpad.
- Row labels stop repeating "3D mouse" and use sentence case: Calibration, Driver, Restore defaults, Reset view, Left viewport, Right viewport, Left window.
- Hints line up under the control column instead of the left edge.
- The six view axes are named Pan X, Pan Y, Zoom, Orbit X, Orbit Y and Roll so they fit the name column.
- Buttons are a divider and rows in a form like the other groups, without the bold sub-heading.
- The driver, dead zone, axes, picture and buttons sit behind a collapsed Advanced section, since most people never touch them.

* 3D mouse: tidy the Advanced test for clippy
2026-10-24 03:34:21 +00:00
SS-EN 6c6ffe4802 MCP: wait out long requests, honest batch outcomes, batch tool aliases (#2901)
Rebased onto main as one commit (was 120d26f7, df488f9a, db9d1073 plus
merges of main).

- A request the app reports as still running (#1618) is waited for on the
  same connection through `control.pending`; past the wait the error says it
  was not cancelled and will still apply (or only write its file), later calls
  are not sent behind it, and the new read-only `pending` tool returns its
  result once it lands.
- A failed batch says what really happened: `rolled_back` only when the
  design is back where the batch started (as #3288 / #3242 on main: the app's
  `rolled_back` is believed; for an older app, every completed step undone;
  only a rollback asked for is reported as one), plus `applied`, `not_run` and
  `failed.still_running`.
- A batch step may name a tool (set_parameter, list_commands,
  describe_command, execute, measure, inspect_design, undo, redo, export, save,
  open, new_design, crash_reports); screenshot, body_topology, batch and
  pending are refused before anything is sent.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 02:41:41 +00:00
github-actions[bot] 46fca160e1 Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci] 2026-10-24 02:34:23 +00:00
rbousigues88 16727f331c docs(mcp): Windows setup for Claude Desktop (#3794)
* docs(mcp): Windows setup for Claude Desktop (paths, two servers, Store config file)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(mcp): say the Store build can keep the config elsewhere, not that the other path fails

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:40:56 +00:00
bherbruck 01220d3eb5 Version 0.21.0 on main (released from rc.2's code) (#5774)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:05:06 +00:00
Colin Faulkingham 89c1f02850 Sketch slots: provide typed dimension inputs for every variant (#2423) (#4318)
* claim: #2423

* fix(sketch): add typed dimensions to every slot tool (#2423)
2026-10-24 00:59:27 +00:00
cronos 45edd5baf5 Fix midpoint picks in sketch dimensions (#4872)
Retain MID snaps across both picks and materialize reusable midpoint
constraints for point-to-line and point-to-point dimensions. Let midpoint
pairs choose horizontal or vertical spans with pointer placement before
entering a value.

Cover projected model edges, overlapping geometry, helper reuse,
undo/cancel/reopen, and existing angular and line-circle workflows.
2026-10-24 00:58:56 +00:00
Colin Faulkingham 7dd5baac5c Browser: route body picks into open command dialogs (#2560) (#4371)
* claim: #2560

* fix(ui): route Browser body picks into active dialogs
2026-10-24 00:52:31 +00:00
Colin Faulkingham 2e5924ed5e Preserve construction references when axes and points are renamed (#4419) (#4449)
* claim: #4419

* fix: preserve renamed construction references (#4419)
2026-10-24 00:24:51 +00:00
Kai Evans a199a2cc25 Section Analysis: OK with no plane picked turns the section off (#2410) (#2411)
While a section is on, Section Analysis opened with no plane picked now
says "No plane picked: OK turns the section off." and OK runs
inspect.section {"clear": true}. With no section on, OK with no plane
still asks for a plane.

Fixes #2410.
2026-10-24 00:11:38 +00:00
Nathan Dane fb374b543b UI: integrate theme radii, borders, palettes and viewport refresh (#5045) (#5046)
* claim: #4922

* UI: let themes own corner radii

* UI: use four-point panel and window radii in SolidCraft 95

* UI: align the 95 radius assertion with four-point panels

* claim: #4953

* UI: let themes own structural and control borders

* UI: single Blueprint outlines and dotted secondary rules

* UI: separate container outlines and add theme-owned 95 bevels

* claim: #5007

* UI: apply theme palettes to icons, workspace tabs and Gruvbox settings

* claim: #5045

* Viewport: invalidate cached geometry colours when the theme palette changes

* UI: preserve legacy theme logo colours with the borderless mark
2026-10-23 23:57:34 +00:00
Karl Von Richter 0dffb206b1 Assembly: ground the first part; drag free joint chains (#2804) (#2805)
* assembly(feat): ground first parts and drag free joint chains

Ground the first top-level part when it is created and let face dragging reposition free occurrences or any member of a floating joint chain in one undo step. Preserve exact repeated-placement frames, joint values, modifier selection and Escape cancellation. Keep the accumulated fixture, viewport layout and documentation integration fixes from the existing PR.

Signed-off-by: Karl Von Richter<karl@embeint.com>

* assembly(fix): update movable fixtures for first-part grounding

Accept the documented grounded override in component creation, explicitly free movement fixtures, and keep linked clipboard pastes movable even in an empty document. Cover first and reused linked pastes, default insertion grounding, and single-step undo in the existing link tests.

Signed-off-by: Karl Von Richter<karl@embeint.com>

* assembly(test): keep the motion-cost crank free

Unground the first component in the document-level joint-face measurement fixture so its motion samples can rotate after first-part grounding. Preserve the rotation and shared-measurement assertions that caught the Linux CI failure.

Signed-off-by: Karl Von Richter<karl@embeint.com>

* assembly(test): free the frame placement fixture

Create the frame-member placement fixture ungrounded before moving it above its root sketch. Keep its world-space member assertions while respecting first-part grounding in the frame tests added on main.

Signed-off-by: Karl Von Richter<karl@embeint.com>

---------

Signed-off-by: Karl Von Richter<karl@embeint.com>
2026-10-23 23:15:34 +00:00
selune 04acdc251f GPU: preserve error scopes on cancelled preparation (#5722) (#5723)
* claim: #5722

* fix(gpu): unwind preparation error scopes on cancellation (#5722)

Move fallible CPU HDR packing before GPU scopes and drain the remaining scope stack in reverse order before propagating denoiser/resource allocation failures. Native regressions cover cancellation and allocation-error cleanup plus subsequent scope reuse. Exact candidate Vulkan-required GPU suite: 101 passed, 0 failed, 3 existing ignored; strict GPU all-target Clippy and repository assets/layers/lists/container checks pass. No driver exception, shader transport or test limit is suppressed.
2026-10-24 01:04:07 +02:00
Artem Sushchev 30b01e8d79 A spherical shell revolves into a solid with a void, and cuts and joins with hollow tools work (#451) (#4182) 2026-10-23 22:53:42 +00:00
Diogo Soares Rodrigues 1ed394f8d6 Extrude: picking a face, vertex or parallel plane off the profile switches the extent to To Object (#2855)
* Extrude: picking a face, vertex or parallel plane off the profile turns the extent to To Object

With profiles picked, a face off their plane (or curved), a vertex off it, or a
construction plane parallel to it is taken as the To Object target instead of
another profile: the extent switches without the Extent Type choice, and the
distance shows how far the target is. Coplanar faces still join the profiles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Extrude: automatic To Object is a Preferences setting, off by default

Preferences > Design > "Extrude to a picked target" (`extrude_auto_to_object`, default
false) turns the automatic To Object on. Off, picks in the Extrude dialog only add
profiles and the distance is not synced to a target, as before this change.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-23 22:03:23 +00:00
selune 8cef71d2d2 Merge pinned main ancestry after #5705
The validated content already landed by squash. Restore the normal main-to-render ancestry requested in #5493 without changing its tree.
2026-10-23 23:35:47 +02:00
selune 47a1459060 Sync current main into render, preserving release and viewport changes (#5705)
* Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci]

* Viewport: whole-body highlights reuse the model's own triangles (#4554) (#5109)

Hovering or selecting whole bodies -- a browser row, a History group, a body
under the pointer in body-pick mode -- built a CPU copy of every body's
triangles into the highlight scene. On a 40-body 4.3 MB STEP assembly that was
147 bodies, 70 MB of triangle copies and a 519 ms frame.

The model already has the same geometry on the GPU. The highlight now names it
and the renderer draws it again in the highlight colour:

- bodies drawn from a display plan: the model's evaluated plan chunks, with a
  hover/selection tint uniform (the chunks stay resident in the model scene);
- bodies drawn as placements of shared triangles: the same placements of the
  model's shared triangles, coloured per instance;
- bodies the model builds on the CPU: their vertex range in the model scene's
  block.

See-through and faded bodies keep the CPU feedback, which colours them; faces
and edges highlighted on their own are unchanged. The highlight looks the same
(verified side by side).

On the assembly the highlight build drops from 519 ms to 9 ms and no longer
copies any triangles; selecting the whole assembly takes 9-20 ms.

Co-authored-by: Claude Code <noreply@anthropic.com>

* Offer to stand up SolidWorks files instead of turning them silently; add Turn Up Axis to the component menu (#5655)

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>

* Sketch on a picked origin plane uses the plane shown after standing an import up (#5659)

* Sketch on a picked origin plane uses the plane shown, also in a moved component

After a STEP file opens its component stays the active one, and standing it up
turns that component a quarter about X. Starting a sketch on XY picked in the
viewport then sent `{"plane": "XY"}`, which names the active component's own
XY: upright after the stand-up. The sketch landed on the world XZ, Look At
turned to face Y and the extrude went along Y.

The viewport and browser only show the design's origin planes, so a picked
origin plane now goes to sketch.create written out as that world plane while
the active component is moved; sketch.create maps it into the component's
frame once. The flip toward the viewer uses the same plane.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Stand-up axes tests: stand the file up through the offer when one is waiting

#5655 offers to stand up SolidWorks files instead of turning them silently.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* gpu tests: give the two test frames the new hover and selection tints (#5698)

#5109 added Frame::hover_tint and selection_tint; two test frames that landed alongside it didn't set them, so solidcraft-gpu's tests stopped compiling on main.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Release 0.21.0-rc.2 (#5691)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Jackson Oursland <jacob.oursland@gmail.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-23 17:29:50 -04:00
Homero Thompson d5a046c3d3 Agent chat: end the agent's process group where kill is busybox' (#4587) (#4588)
* claim: #4587

* Agent chat: end the agent's process group with the system call (#4587)

The group was signalled with a `kill` program: `kill -s TERM -- -<pgid>`.
That works with procps' `kill` and with BSD's, but busybox' `kill` takes no
`--`: it prints its usage, exits 1 and signals nothing. The agent's helpers
(the CLI an adapter wraps, the MCP servers it starts) then stay alive after
the agent is ended.

The group is signalled with the system call instead, so no `kill` program and
no shell are needed and the behaviour is the same everywhere. `nix` (MIT) is
already in the tree through zip, and only on Unix: the browser build does not
take it.

The two tests that cover this now pass where they failed before:
`agent_chat::process::tests::dropping_the_process_ends_its_group` and
`agent_chat::process::tests::stopping_the_agent_leaves_no_helpers_running`.
2026-10-23 21:25:09 +00:00
raykholo 4353f34bd1 Delete removes a selected construction axis (#4692) (#4695)
* claim: #4692

* Delete removes a selected construction axis (#4692)

Delete on a construction axis skipped it with "the origin axes can't be
deleted". It now deletes the construction axis feature, like construction
planes; the origin axes X, Y and Z are still refused.
2026-10-23 21:25:02 +00:00
bherbruck a51dfc9e1f Release 0.21.0-rc.2 (#5691)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 17:14:35 -04:00
selune ee5ca2430b feat(render): expose independent anisotropic GGX core (#1694) (#1699)
Extract the original numerical source and analytic tests from published #1698 at 463a3e0d into its existing #1699 constituent. Keep current GPU/shared jobs, format and kernel untouched. Add scoped numerical-contract documentation. Full render library 158 passed, strict scoped Clippy and wasm pass, as do assets/layers/lists/format; source tree pinned in the acceptance record. No runtime consumer or final combined renderer acceptance is claimed.
2026-10-23 23:06:31 +02:00
bherbruck 9aaa8016e5 gpu tests: give the two test frames the new hover and selection tints (#5698)
#5109 added Frame::hover_tint and selection_tint; two test frames that landed alongside it didn't set them, so solidcraft-gpu's tests stopped compiling on main.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 17:04:32 -04:00
Colin Faulkingham 71f7a8e697 Move: scope box selection to the active component (#4459) (#4527)
* claim: #4459

* fix: scope Move box selection to active component (#4459)
2026-10-23 20:25:00 +00:00
bherbruck 632ec4157c Sketch on a picked origin plane uses the plane shown after standing an import up (#5659)
* Sketch on a picked origin plane uses the plane shown, also in a moved component

After a STEP file opens its component stays the active one, and standing it up
turns that component a quarter about X. Starting a sketch on XY picked in the
viewport then sent `{"plane": "XY"}`, which names the active component's own
XY: upright after the stand-up. The sketch landed on the world XZ, Look At
turned to face Y and the extrude went along Y.

The viewport and browser only show the design's origin planes, so a picked
origin plane now goes to sketch.create written out as that world plane while
the active component is moved; sketch.create maps it into the component's
frame once. The flip toward the viewer uses the same plane.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Stand-up axes tests: stand the file up through the offer when one is waiting

#5655 offers to stand up SolidWorks files instead of turning them silently.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 16:11:39 -04:00
selune ac08c3f106 Merge pinned main into render (#5669)
Normal history-preserving main-to-render synchronization under #5493. Retain both material defaults and engineering-card bounds; initialize the new upstream test fixture with no optional card. The corrected source passed fresh quick checks. No rewrite of main/render, manual document format bump or final main promotion is performed.
2026-10-23 22:10:14 +02:00
0xlillith 6a197b6cf6 test(material): initialize optional cards in upstream density regression
Fix the E0063 found by quick run 40015318093 while combining main #4785 with render #1060. The new upstream fixture explicitly has no engineering card. Keep every density/name/default-material assertion and production implementation unchanged.
2026-10-23 22:06:32 +02:00
ealitt 6eaf723041 fix(ui): preview shortcut chords and wait for complete modifier input (#1810)
Keep physical modifier events out of the ordinary-key slot, preview held keys immediately, and confirm after release and a one-second settling interval. Preserve legacy control-channel modifier behavior while allowing explicit physical Control/Command input. Add regression coverage, a UI scenario, localized guidance and documentation.

Fixes #1809.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 20:05:58 +00:00
bherbruck c3ed8b1c07 Offer to stand up SolidWorks files instead of turning them silently; add Turn Up Axis to the component menu (#5655)
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 16:05:46 -04:00
Jackson Oursland 8301d1c48e Viewport: whole-body highlights reuse the model's own triangles (#4554) (#5109)
Hovering or selecting whole bodies -- a browser row, a History group, a body
under the pointer in body-pick mode -- built a CPU copy of every body's
triangles into the highlight scene. On a 40-body 4.3 MB STEP assembly that was
147 bodies, 70 MB of triangle copies and a 519 ms frame.

The model already has the same geometry on the GPU. The highlight now names it
and the renderer draws it again in the highlight colour:

- bodies drawn from a display plan: the model's evaluated plan chunks, with a
  hover/selection tint uniform (the chunks stay resident in the model scene);
- bodies drawn as placements of shared triangles: the same placements of the
  model's shared triangles, coloured per instance;
- bodies the model builds on the CPU: their vertex range in the model scene's
  block.

See-through and faded bodies keep the CPU feedback, which colours them; faces
and edges highlighted on their own are unchanged. The highlight looks the same
(verified side by side).

On the assembly the highlight build drops from 519 ms to 9 ms and no longer
copies any triangles; selecting the whole assembly takes 9-20 ms.

Co-authored-by: Claude Code <noreply@anthropic.com>
2026-10-23 16:05:41 -04:00
github-actions[bot] 2a02eb71ac Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci] 2026-10-23 20:04:02 +00:00
0xlillith e3d7cbe830 Merge pinned main into the shared render integration
Synchronize official main 17bac141c8 into render e3c9f22ead under #5493. Resolve only the independent DEFAULT_MATERIAL / MAX_USER_MATERIALS imports, keeping both rules. Preserve all main and renderer history. No manual document format bump or released migration edit; release folding is inherited from main. Fresh quick checks are required before this normal merge reaches render.
2026-10-23 21:56:13 +02:00
Christian Svensson e3c9f22ead GPU path tracing: use compatible Vulkan adapters (#3306)
Land the final Vulkan-only path-executor policy after #2191 under the #5493 constituent route. Fixes #3217. Fresh quick run 40012565783 passed. Preserve adapter/software policy and CPU fallback; no DXC dependency or workflow changes. Full native/hardware acceptance and final jyviko sign-off remain separate.

Co-Authored-By: Christian Svensson <blue@cmd.nu>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 21:40:28 +02:00
Christian Svensson c6cbde3706 Render: studio lighting, clear coat and glass on GPU (#2191)
Integrate the original studio renderer after the GPU executor and async UI foundations, under #5493. Fresh render-target quick run 40011408339 passes. Preserve attribution, bounded resource admission and documented glass/denoising limits. Historical native benchmarks retain their source pins. Full renderer-to-main CI and jyviko sign-off remain separate.

Co-Authored-By: Christian Svensson <blue@cmd.nu>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex <noreply@openai.com>
2026-10-23 21:33:49 +02:00
Christian Svensson b0d7572daf Render: asynchronous GPU setup and completed-image panel (#2780)
Integrate the original asynchronous setup, progress and completed-image UI after #2067. Refreshed source passes quick gate 40009901424. Original attribution retained. Final render-to-main full CI and jyviko sign-off remain separate.

Co-Authored-By: Christian Svensson <blue@cmd.nu>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 21:24:40 +02:00
Christian Svensson 30449418ac Render: progressive GPU path-tracing executor (#2067)
Land the unchanged contributor GPU foundation into render under the maintainer policy in #5493. Fresh quick gate 39993339605 passed. Original author and co-author attribution retained; no workflow file changes. Final render-to-main CI and jyviko sign-off remain separate.

Co-Authored-By: Christian Svensson <blue@cmd.nu>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 21:10:55 +02:00
selune 95a56ca00e Persist validated engineering cards independently of mass and optics (#1060)
Render constituent under #5493. Fixes #1048. One pending material-engineering-cards descriptor, immutable released migrations, independent assigned density and source conditions. Nine persistence and ten physical-card regressions plus the fresh quick gate pass. Additional native follow-up is reported separately; no full-stack acceptance is implied.
2026-10-23 20:49:32 +02:00
selune fbf4a5458d Preserve translated source-label aliases (#5619)
Render integration prerequisite for #5493. Fixes #5618. Retain stable-ID fallback and translated legacy captions; include controlled regression coverage.
2026-10-23 20:06:26 +02:00
selune 2f8677a640 Merge main into render for constituent reconciliation (#5493) 2026-10-23 19:46:29 +02:00
bherbruck b31fddb98e OpenGL: the viewport scene renders 1x again, like the window (#5605)
#5187 gave the scene its own offscreen target with the app's sample count,
which skipped the OpenGL clamp to 1x from 6dd202d. The scene's count now
goes through effective_msaa_samples as well.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:37:27 -04:00
raykholo bb155d446a Tests: moving an occurrence recomputes only the cuts and joins it touches (#4965) (#4982)
* claim: #4965

* Joins and cuts follow only the placements they depend on (#4965)

Tests that turning a joint between parts cuts no hole again, and that a
tool cutting another part cuts again when that part moves relative to it,
while an unrelated part moving keeps the result.

Booleans and Combine now hash only the placements of the bodies they take
as inputs, relative to the frame that evaluates them (#3397), so these
pin that behaviour for joins and cuts with named targets across parts.
2026-10-23 17:22:24 +00:00
selune bf69db04dd Normalize UI container violation paths across platforms (#5599)
Report repository-relative paths with forward slashes so diagnostics and the existing nested-package regression assertions agree on Windows and Unix. Keep the container detection rules unchanged.

Validation: official ui-containers gate and all seven focused xtask tests passed; rustfmt and diff checks passed. Only xtask/dependencies were compiled; no application build or heavy tests.

(cherry picked from commit 44b58d8e10)

Scoped render-branch prerequisite from #4507, coordinated under #5493. No unrelated translation/kernel changes are included. Fresh Windows fmt, seven ui_containers regressions and the production container gate pass on render base 3a6153d18f. Full CI is reserved for final render-to-main acceptance under the maintainer policy.

Co-authored-by: Victor Santos Oliveira <56417388+Vittuu@users.noreply.github.com>
2026-10-23 13:21:05 -04:00
selune e9f2f251e4 Merge current main into render per #5493 integration policy 2026-10-23 19:18:26 +02:00
Jackson Oursland 3a6153d18f Add .vscode to .gitignore. (#5588) 2026-10-23 12:49:05 -04:00
bherbruck 2bf3d4e11f Release 0.21.0-rc.1 (#5566)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:09:47 -04:00
github-actions[bot] 9852a2e758 Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci] 2026-10-23 16:05:55 +00:00
bherbruck 98c4f335e8 Control channel and MCP on by default, with a setting to turn it off (#5561)
The app opens its localhost control channel on port 27878 (or the next free
one) unless Preferences > Assistant turns it off; --control and
SOLIDCRAFT_CONTROL_PORT still win. A current.port discovery file lets
solidcraft-cli mcp find the running app with no arguments.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 16:00:35 +00:00
ahmetalpcelik 4bdeaacde6 Crash reports: keep component.activate in the journal (#5079) (#5121)
* claim: #5079

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Crash journal keeps component.activate (#5079)

Activating a component changes only the session's active component and
occurrence path, not the design, so the journal dropped the command and a
crash report's replay built later parts under the previously active
component. The journal now also keeps a command that changes either.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:35:54 +00:00
raykholo e5dffc7052 Flatpak: find the host's fonts for CJK text and the font list (#5124) (#5127)
* claim: #5124

* Flatpak: find the host's fonts for CJK text and the font list (#5124)

Inside the Flatpak sandbox the host's fonts are mounted at /run/host/fonts
and /run/host/user-fonts, which no font search looked at. Japanese and
Chinese text fell back to boxes, and the font list missed the host's
families.

- Search both folders for the CJK and Chinese fallbacks and for the
  installed-font list.
- The CJK fallback reads SOLIDCRAFT_FONT_DIRS (a path list, as the font
  list already does), with CRAFT_FONTS_DIR kept as a fallback.
2026-10-23 15:18:58 +00:00
Thodoris Tsagkogeorgas 43fa25458d Line tool snaps a diameter through the circle centre (#4326) (#4327)
A line drawn across a circle only snapped its end onto the curve, so a
diameter could miss the centre at any angle. Near the centre the line now
locks onto the radial line: on the rim it stays on the circle and the centre
is kept on the line, and on the centre point the end lands there.
2026-10-23 14:55:39 +00:00
phanoob 9b8fa3d357 Browser: a body picked in the viewport is opened to and scrolled to (#3005) (#3081)
* claim: #3005

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Browser: a body picked in the viewport is shown in the Browser (#3005)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Merge main; viewport::select takes a toggle flag now

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Phanoob <phanoob@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:48:11 +00:00
raykholo 755ad9af07 Dragging a part along its joint no longer highlights what's under the pointer (#5137) (#5140)
* claim: #5137

* Dragging a part along its joint no longer highlights what's under the pointer (#5137)

The viewport's hover pick was skipped during a box selection and camera
moves, but not while a jointed part was dragged, so faces and edges under
the pointer lit up on every frame of the drag. The pick now waits until
the joint drag ends.
2026-10-23 14:45:45 +00:00
Christian Svensson 46aa3d5d47 STEP import: let a cancelled import release the import slot (#3171) (#3189)
* claim: #3171

* Release cancelled file import slots (#3171)
2026-10-23 14:31:51 +00:00
rlartot 125b8d5387 Return to the originating tab after finishing a sketch (#3250) (#3264)
* claim: #3250

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Return to the originating toolbar tab after finishing a sketch (#3250)

A sketch started from SHEET METAL or PLASTIC always ended on the SOLID
tab. Remember the tab that was active when the sketch began
(UiState::sketch_return_tab, not saved) and go back to it when the
sketch ends. Falls back to SOLID when that is not one of the normal
toolbar tabs. A sketch started from SOLID behaves as before.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Test: no one-element loop in the return-tab test (clippy single_element_loop)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 14:31:48 +00:00
phanoob b5edd98bc9 Parameter import and image pickers offer their own file types (#3216) (#3314)
* claim: #3216

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Parameter import and canvas image pickers offer their file types (#3216)

Both opened the picker with no file type of their own, so it listed only
designs, CAD files and meshes (and All files). macOS lets you pick only
the types a filter names: CSV parameters, and PNG or JPEG images for a
canvas, were greyed out. They now ask for csv/json and png/jpg/jpeg,
which the picker lists first (#1801).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Phanoob <phanoob@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:31:46 +00:00
Victor Santos Oliveira 647a52d0fb Close Change Parameters with Escape (#3372) (#3399)
* claim: #3372

* Fix Escape cancellation in Change Parameters

Consume Escape before parameter editors can commit pending text on focus loss, while preserving autocomplete and shortcut capture priority. Add 11 UI regressions covering draft cancellation, preview cleanup, document and history preservation, and reopening saved values.
2026-10-23 14:31:42 +00:00
github-actions[bot] 8c977131d9 Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci] 2026-10-24 00:20:18 +00:00
AhmetV 30a1fe74ca Viewport: right-click keeps a multi-selection of faces and edges (#3733) (#3788)
* claim: #3733

* Viewport: right-click keeps a multi-selection of faces and edges (#3733)

---------

Co-authored-by: AhmetV <344941595+ahmetv10@users.noreply.github.com>
2026-10-23 14:31:30 +00:00
Tyler Chiriboga fa2c1c31f6 3MF import: name a Bambu Studio project's bodies after its objects (#3858) (#3859)
* claim: #3858 (3MF import: names from a Bambu Studio project)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3MF import: name a Bambu Studio project's bodies after its objects (#3858)

Bambu Studio leaves the name off the root model's objects and keeps it in
Metadata/model_settings.config (<config><object id="N"><metadata key="name">),
so every object of a project came in as Mesh1, Mesh2, ... A build item whose
root object has no name of its own now takes the object's name from those
settings. A name in the model still comes first, and part names, other
settings and objects of other model parts are left alone.

The settings are read only when a body needs a name, within what the model
parts left of the 1 GB budget. Settings that can't be read (malformed, nested
too deeply, too large) never fail the import: the bodies keep the generic
names, with a warning saying why.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Tyler Chiriboga <18270301+Tyler-Chiriboga@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:31:27 +00:00
leValdemar19 7e472bc4da Cancel Sketch: leave a sketch without keeping its changes (#3838) (#3938)
* claim: #3838

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SBN24y2TcqRcm2zn8Gkj27

* Cancel Sketch: leave a sketch without keeping its changes (#3838)

sketch.cancel (Cancel Sketch, ribbon Exit group and the sketch's
right-click menu) puts the design back as it was when the open sketch
was created or opened for editing: a new sketch is removed with its
dimension parameters, an edited one returns to how it was. It is one
undo step, so Undo goes back into the sketch.

The session remembers the design and the undo history at the point a
sketch is entered. Cancel uses that only while the history still runs
through it (the same steps before, every step since taken in this
sketch); otherwise it refuses and points to Undo, so a stale entry can
never throw away later work.

The icon is the approved close glyph in the sketch accent (alias
sketch_cancel); ja and zh carry the new label.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SBN24y2TcqRcm2zn8Gkj27

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:31:22 +00:00
Loohi 145c6ad60d Rigid joint saved with the wrong occurrence when two occurrences share a component (#3857) (#3939)
* claim: #3857

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Joint dialog: a body of a further instance picks that occurrence, not the component's first (#3857)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 14:31:20 +00:00
raykholo e1bf784014 Deleting an inserted file removes its components, placements and joints (#4100) (#4113)
* claim: #4100

* Deleting an inserted file removes its components, placements and joints (#4100)

A STEP or mesh file inserted into a design makes a component tree. Deleting
its feature left that tree behind: the components, their occurrences, the
rigid joints that hold them and the bodies' component assignments. A later
body that took an imported body's name was then placed in the stale component.

Document::remove_component_tree removes a component subtree and everything
that refers to it: the features made inside it, its occurrences, the joints
on them with their motion links, joint origins, contact set members, motion
study keys and exploded moves, body component and offset entries, standard
part and link records, and configuration columns. Deleting an inserted
file's feature and component.delete both use it; component.delete now also
removes joints left pointing at a deleted occurrence. The body transfer
refusal moves with it, unchanged.

* Only an inserted file's feature takes its component tree when deleted

Mark the feature a STEP, IGES or mesh insert makes (`inserted`), and remove
the component tree on delete only for those and for a linked design's
feature. Deleting a copied component's independent base deletes just that
feature, as before. Older designs get the mark as they open (pending format
change), except copied components' independent bases.
2026-10-23 14:31:08 +00:00
Colin Faulkingham 1bbc5ab0f7 Keep long Select Other labels inside the popup (#4209) (#4225)
* claim: #4209

* fix(ui): bound Select Other labels and expose full names (#4209)
2026-10-23 14:30:57 +00:00
raykholo c1e9d2f057 Esc ends the navigation bar's Pan, Orbit and Zoom modes (#4226) (#4233)
* claim: #4226

* Esc ends the navigation bar's Pan, Orbit and Zoom modes (#4226)

The nav bar toggled a mode on, but nothing cleared it on Esc, so a left drag
kept panning, orbiting or zooming. Esc now ends an active nav mode first;
the next Esc reaches the tool or dialog as before.
2026-10-23 14:30:54 +00:00
CaRLymx 68abb15d7f Spanish (es-ES): catch up with en.json (#4292)
Co-authored-by: carlymx <24634598+carlymx@users.noreply.github.com>
2026-10-23 14:30:51 +00:00
Tyler Chiriboga 945e6261cc 3D mouse test: clear the frame's texture changes, so it passes in debug builds (#4382)
each_frame_hands_the_view_its_motion_through_the_drivers_mapping dropped
the output of a frame it ran (`let _ = ctx.run_ui(...)`). epaint panics
in debug builds when a TexturesDelta is dropped with deltas left ("Dropped
TexturesDelta with 1 unapplied deltas"), so the test failed under a plain
`cargo test` on every platform while the ci profile passed. It now clears
them, as the other UI tests do since #60.

Co-authored-by: Tyler Chiriboga <18270301+Tyler-Chiriboga@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:30:38 +00:00
raykholo d6b1608f9d "Don't Save" looks like a button in the Save changes prompt (#4380) (#4400)
* claim: #4380

* "Don't Save" looks like a button in the Save changes prompt (#4380)

The prompt's third action used the kit's low-emphasis text style, so it
didn't read as clickable and people thought Cancel was the only way out.
Add an opt-in secondary_left style to the confirm footer (a framed
secondary button from the theme tokens) and use it for Don't Save.

* Save prompt test: clear each frame's texture delta once it is applied

egui 0.36 panics when a TexturesDelta with unapplied deltas is dropped;
the test kept only the last frame's output and dropped the earlier ones.
2026-10-23 14:30:32 +00:00
raykholo 2d54cb31bc Flat Pattern view draws perforations (#4440) (#4441)
* claim: #4440

* Flat Pattern view: draw perforation holes (#4440)

The flat view drew the outline, cut-outs, pockets and bend lines but not
perforation fields, though the flat-pattern data and the DXF export list
their holes. The view now reads the hole centres from the sheet through
SheetBody::perforation_holes, caches them per model state and body, and
draws them as circles in the cut-out style. Holes outside the viewport or
smaller than one pixel are skipped, and the cache is capped at one million
holes.
2026-10-23 14:30:27 +00:00
Frank 3acb81964a Sketch offset keeps its source lines free to turn (#4464) (#4466)
* claim: #4464

* Sketch offset keeps its source lines free to turn (#4464)

An offset adds a Parallel between each source line and its offset line.
Since offsets became associative, the offset's points are held during a
solve, so each Parallel pinned its source line to the offset line's
current direction: a closed chain lost its turning freedom (8 degrees of
freedom became 6), and Horizontal or an angle edit on a source line was
refused as conflicting.

The solver and the constructive reading now leave out a Parallel that
ties an offset line to the source it follows while the association lives;
the association already keeps them parallel. After Break the stored
Parallel drives again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:30:21 +00:00
raykholo 4e7bf2b63a Toolbar: a constraint dragged out of the small box becomes a full button (#5254) (#5456)
* claim: #5254

* Toolbar: a constraint dragged out of the small box becomes a full button (#5254)

The Constrain panel's small box of constraint icons was a fixed list, so
dragging one onto the full-size buttons saved a new order but it was still
drawn in the box. The toolbar settings now remember, per tab, which box
commands were moved out; those are drawn as full buttons, the box closes
up, and dropping one back on a box icon returns it. Reset Toolbar restores
the default box, and settings saved before this load unchanged.
2026-10-23 14:28:23 +00:00
Kai Evans 3093ce1d87 Renaming a body keeps the evaluated timeline, off the window thread (#2467) (#2468)
* Renaming a body that later features change evaluates nothing (#2467)

Model::rename_body now keeps the results when later features change the
body (a pattern, a chamfer, a join). It still evaluates when a body of
that name comes and goes or isn't the same body throughout, when a later
body's name is worked out from it (X (2)), or when a message quotes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Rename box: rename off the window thread, and locked bodies follow a rename (#2467)

The rename box commits body.rename through run_heavy, so a rename that
still evaluates later features (one names the body in its targets)
computes beside the window instead of freezing it. Locked bodies now
follow a body.rename from anywhere (the rename box, the control channel,
scripts), not only the rename box.

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:23:13 +00:00
phanoob ca9dd02201 Reorder design tabs by dragging them (#3187) (#3188)
* claim: #3187

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Reorder design tabs by dragging them (#3187)

A tab dragged along the strip follows the pointer while the others make
room, and lands where it is released; Escape, or releasing down the
window, leaves the order as it was. With the custom title bar a drag that
starts mostly up or down (or on the only tab) still moves the window.
The control channel's ui.documents gains a move action (from, to).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Phanoob <phanoob@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:23:04 +00:00
raykholo 1d9bdb1ca3 FreeCAD/Rhino import: decode caps with room for detailed parts (#1783) (#3209)
* claim: #1783

* FreeCAD/Rhino import: decode caps with room for detailed parts (#1783)

cadmpeg decoded FreeCAD and Rhino files under its service profile, whose
million collection items and 2.56e8 work units a detailed part (a sprocket,
a threaded rod, a part with a few hundred faces) runs past: the import was
refused with "resource limit on CollectionItems: BudgetExceeded". Allow
8 million items and 2e9 work units, half of cadmpeg's desktop profile; the
input-size cap stays SolidCraft's STEP limit and every other cap stays at
the service profile's.

decoding_is_capped checks the two raised caps (and that they stay below the
desktop profile's) and that every other cap is still the service one.

Fixes #1783
2026-10-23 14:23:02 +00:00
github-actions[bot] d78494e016 Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci] 2026-10-23 22:44:53 +00:00
phanoob d18cc0df4e 2-point circle takes a typed diameter (#3278) (#3335)
* claim: #3278

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 2-point circle takes a typed diameter (#3278)

After its first point the 2-point circle tool showed no value box, so the
circle could only be sized with the cursor. It now shows a Diameter box,
like the centre circle: the typed value puts the second point that far
along the cursor's direction, and Enter draws the circle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Phanoob <phanoob@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:22:59 +00:00
Peppe Bergqvist 04dc68d988 macOS: Fusion navigation preset does not support Shift + two-finger orbit (#3446)
* Macos: keep touchpad/key modifiers true to fusion

* fix: remove obsolete orbit snap reset from trackpad navigation

Upstream removed ViewportState::orbit_snap. The new trackpad orbit path retained one reference, causing E0609 in the PR merge build.

Validation: all seven trackpad tests pass; cargo check -p solidcraft-ui-egui --all-targets passes on the exact failing CI merge 66a2791b with this fix applied; rustfmt and diff checks pass.

Co-Authored-By: GitHub Copilot <noreply@github.com>

* fix: retain Fusion trackpad help in refactored preferences

Preserve upstream's new preferences pages and apply the translated Fusion trackpad tooltip in prefs/navigation.rs.

Validation: cargo build -p solidcraft passed. Tests and full CI were not run, as requested.

Co-Authored-By: GitHub Copilot <noreply@github.com>

* style: format Fusion trackpad preferences tooltip

Apply rustfmt's required wrapping for the relocated tooltip. No behavior changes.

Validation: cargo fmt --all -- --check and git diff --check passed.

Co-Authored-By: GitHub Copilot <noreply@github.com>

---------

Co-authored-by: GitHub Copilot <noreply@github.com>
2026-10-23 14:22:57 +00:00
HiroKenEz efb45d7411 Delete in a rolled-back history warns about the features after the marker (#3471) (#3698)
* claim: #3471

* Delete in a rolled-back history warns about the features after the marker (#3471)

With the timeline rolled back, the checks a delete runs first (the
confirmation's "these features will fail", timeline.dependents and
selection.delete's would_fail) compared models evaluated only up to the
marker. Features after it, broken by the delete, were not listed, so their
error marks turned up only once the timeline moved to the end.

newly_broken_in_history evaluates both sides over the whole history when
either has a marker (on copies; the model and document are unchanged) and is
used by the delete's report and by timeline.dependents.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:22:52 +00:00
raykholo 426f8ddd18 Rectangular and circular pattern: copies that miss the body are reported (#3042) (#3919)
* claim: #3042

* Warn when cut pattern copies remove no material

A rectangular or circular pattern whose copies miss the body succeeded silently and those copies did nothing. The pattern now reports how many of its cut copies removed no material, and keeps the copies that land.
2026-10-23 14:22:45 +00:00
Karl Von Richter 27d2fe6ec0 Frames: profile library, members along frame sketch lines, and the Frame Member dialog (#4003) (#4004)
* claim: #4003

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Frames: the profile library (#4003)

The first slice of frames and weldments: the cross-sections structural members
will be made of, each SolidCraft's own parametric definition built in code
(no vendor or standard size tables are shipped).

- solidcraft_doc::frame: bar, round bar, rectangular and round tube, angle,
  channel, I-beam and a generic T-slot extrusion as exact line/arc regions,
  with defaults worked out from the profile's size, refusals that name the
  dimension that does not fit, anchors and placement, and exact section
  properties (area, centroid, Ixx/Iyy/Ixy by Green's theorem).
- frame.profile.list, frame.profile.properties (with mass per metre for a
  density) and frame.profile.insert (draws a profile into a sketch, locked by
  default, at a point, anchor and angle).
- Tests against hand-computed values (rectangles, circles, composite
  rectangles by the parallel-axis theorem, fillet spandrels), hostile input,
  extrusion volume, undo; catalog entries in every locale; docs/frames.md;
  ROADMAP M15.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Frames: drop the built-in T-slot profile (#4003)

Aluminium extrusions and other vendor sections come from the user's own
sketch (drawn or inserted from the vendor's DXF), so the library keeps the
plain steel shapes only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Frames: members along frame sketch lines (#4003)

frame.member lays one body per straight line of a frame sketch (2D lines or
3D sketch lines), its cross-section square to the line from end to end:

- profile: a built-in profile, or profile_sketch, a sketch whose material
  (nested loops alternating, so two circles make a tube) is the section in
  its own x and y around its origin; profiles picks override it.
- anchor (origin, centre, centroid or a bounding-box corner or edge middle),
  angle (an angle expression, so a parameter), mirror.
- Orientation: the profile's y as near world up as the line allows, x
  square to both; a vertical member's x is world X (frame::member_axes).
- Members follow sketch and parameter edits, undo as one step, save and
  reopen (format: pending/frame-members.rs), and are named after the profile
  (Rect Tube 40x40x3, (2)…), numbering on past names already taken.

Tests against hand-computed volumes and bounding boxes (rectangle, 3D
tower lines, parameter edit, sketch tube profile and picks, anchor/angle/
mirror), refusals, undo/redo and reopen; walk fixtures and goldens; docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Frames: fixes from the adversarial review (#4003)

- Default member lines are what was drawn: projected and other linked curves
  and 3D wires (a face sketch's edges) and lines of no length are left out.
- A profile sketch's material starts from its drawn profiles, as
  solid.extrude's "all" does, so a section sketched on a face works; the
  even-odd test uses outlines and boxes worked out once, capped at 256.
- Member names are pinned to their lines (`names`, by line id; `body_name`
  is the base): deleting or adding a line, or deleting another feature, no
  longer renames other members, and frame.member stores every name it gives.
  body.rename renames a member on its line (also for later member features);
  a copy whose body_names were set still names its members by position.
- Built-in profile dimensions are expressions (ProfileSpec): they follow
  parameters, bare numbers take the design's units, and they are feature
  inputs. frame.profile.properties/insert read them the same way.
- Default root/toe radii shrink to the room a thick section leaves; radii
  given are checked by name; a radius must be 0 or at least 0.01 mm; error
  messages report the wall or flange value given, not twice it.
- A line within 1e-6 rad of vertical orients as vertical, so rounding never
  turns a post a quarter turn.
- A 2D frame sketch in a differently placed component is mapped into the
  member's frame, and member features recompute when placements change.
- Caps before lookups for a design's `lines`; a line listed twice is
  refused; a 3D wire that runs back on itself is not a line.

Regression tests for each, from the review's scenarios.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Frames: the Frame Member dialog on the ribbon (#4003)

- Frame Member (MODEL > Create) opens a dialog: the frame sketch's lines
  picked in the view (or All Lines), a built-in profile and its dimensions or
  a section picked in another sketch, anchor, angle, mirror and name, with a
  live preview. Edit Feature reopens it filled in.
- Lines picked in the view keep their sketch (a SKETCH_LINES input picks
  ExternalSketchCurve): line ids are each sketch's own, so two 3D sketches
  both have a `w2`, and the members went on the newer sketch's lines.
- timeline.redefine takes frame.member. Members keep their names through an
  edit unless it gives them a new body_name; frame.member takes `names`.
- A member whose section is a whole profile sketch keeps it when edited
  (the dialog shows the sketch instead of picking every region, which would
  fill a tube's hole).
- An original frame_member icon, open for review, on the ribbon, timeline
  and browser.
- The angle profile is listed as L-Angle, and web and flange as Web/Flange
  thickness, so they translate apart from the existing Angle, Web and Flange;
  the dialog's labels are in en, ja and zh. en-GB no longer repeats the frame
  command labels it spells the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:22:41 +00:00
raykholo 6fb72c7401 ui-egui tests: clear the texture deltas of the frames they run (#4039)
Nine tests dropped an egui frame's output with its texture delta still
unapplied (the expression completion editor's frames, the slot preview's
three, the browser's feature rows). epaint asserts against that in debug
builds, so they failed under the dev profile and passed only where debug
assertions are off. They clear the delta as the other tests do; one more
hover test that dropped its output the same way does too.
2026-10-23 14:22:37 +00:00
raykholo 14ccc71291 Fix feature editor picks after upstream sketch resizing (#4083) (#4084)
* claim: #4083

* Feature editors refill named edges and faces where they are now (#4083)

After an upstream sketch change, Fillet, Chamfer, Shell and Draft evaluated
correctly, but their editors refilled the originally clicked points and
could then fail with "no edge at ...". Resolve the stored names through the
document's naming helpers first, keep the resolved index, and map the pick
into the displayed body's frame; the point lookup stays as the fallback.
2026-10-23 14:22:35 +00:00
raykholo ec56df3ffd Preserve expression-positioned sketch fillet corners (#4097) (#4106)
* claim: #4097

* Sketch fillet keeps a corner placed by an expression (#4097)

Filleting a corner whose position comes from parameter expressions deleted
the point and its expressions, so the sketch gained two degrees of freedom
and later parameter changes distorted the outline. keep_corner now also
keeps fixed or expression-positioned points as virtual sharp corners.
2026-10-23 14:22:33 +00:00
CamWight 8565f3f1b3 Browser: a second click on a Document Settings entry closes its window (#4150) (#4152)
* claim: #4150

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Browser: a second click on a Document Settings entry closes its window (#4150)

Each entry under Document Settings only opened its window. A second click on
Units closed the menu on the press (the row is outside it) and opened it again
on the release; Precision left Preferences open; Parameters and Design
Properties came back as a fresh copy. Now a click on an entry whose window is
open closes it the way the window's own x does:
- Units: context_menu::toggle_for with the row as the opener, the fix #1522
  made for the workspace button's menu. A right-click still opens it at the
  pointer.
- Precision: prefs::close(app, true), the Cancel/x path that puts back what
  changed since the window opened (now pub(crate)).
- Parameters and Design Properties: the window is dropped, as its x does. A
  Parameters value typed but not applied yet is dropped with it, as today's
  second click already drops it.

Fixes #4150

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:22:29 +00:00
Colin Faulkingham 12c823ffdd Browser: add Rename and Delete to feature menus (#3839, #4343) (#4342)
* claim: #3839

* fix(ui): add Rename and Delete to browser feature menus
2026-10-23 14:22:17 +00:00
SS-EN 5d92ad1e23 Renaming a plane keeps its projections, canvases and hidden state (#4353) (#4354)
* claim: #4353

* Renaming a plane keeps its projections, canvases and hidden state (#4353)

timeline.rename only renamed the feature in other features' references. A sketch's
projection of a construction plane, a canvas placed on it and the hidden list keep it
by name too, so they lost it: the sketch warned that the plane no longer exists, the
canvas left the view and a hidden plane showed again. Document::rename_feature_keys
renames it in sketch links and canvases, as rename_body_keys does for bodies; the
selection follows in the command, and the hidden list in Session::refresh_from, so a
hidden plane stays hidden through undo and redo too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:22:13 +00:00
CamWight e8a6a5d3ff DXF: write and read a Bézier at its own degree (#5269) (#5281)
* claim: #5269

* DXF: write and read a Bézier at its own degree (#5269)

The DXF writer and reader held a spline's degree to 7, the value MAX_BEZIER_DEGREE had before
#1642 raised it to 9. A G4 blend (one Bézier of degree 9) was written as a degree-7 SPLINE over
its first 8 control points, so a flat pattern's cut-out stopped 4.444 mm short in the file.

- seg_shape takes its limit from MAX_BEZIER_DEGREE: a Bézier is written whole, at its own degree.
- read_dxf reads SPLINE degrees 1 to MAX_BEZIER_DEGREE, the degrees a sketch spline takes.
- Tests: every Bézier degree 4 to 9, a degree-9 round trip, the reader's degrees, hostile SPLINE
  degrees and too few control points, the G4 cut-out exported and read back with its areas, and a
  degree-9 SPLINE with 2 to 10 points inserted into a sketch.

Fixes #5269

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:20:12 +00:00
raykholo 605d32ceb0 ui-egui tests: offscreen GPU tests start their devices one at a time (#3989) (#3990)
* claim: #3989

* ui-egui tests: offscreen GPU tests start their devices one at a time

Every offscreen test started a wgpu instance of its own, and starting one
makes the Vulkan loader scan its drivers. With several test threads two
tests doing that at once crashed the test process with SIGSEGV inside the
loader (loader_icd_scan). offscreen_device now holds a process-wide lock
while it starts the instance, adapter and device; each test keeps its own
device and the rendering still runs side by side.
2026-10-23 14:17:30 +00:00
Frank b79c03d227 Viewport: show reference geometry through bodies (Preferences, off by default) (#2697)
A new Graphics preference, "Show reference geometry through bodies", draws
inactive sketch contours, origin and construction planes and their axes over
shaded bodies. It is off by default, so the viewport looks as before: reference
geometry is depth-tested and a section leaves it whole (#1229); Create Sketch
and dialogs picking a plane still draw the planes through the model.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:14:48 +00:00
raykholo 17bac141c8 An intersect that would leave nothing is refused instead of deleting the body (#5295) (#5303)
* claim: #5295

* An empty intersect is refused instead of deleting the body (#5295)

An intersect whose boolean came out empty removed the target body and
reported success. The shared evaluator now refuses it when nothing of a
named target would remain, when nothing of any target would remain, or
when the tools meet a target and the result is still empty. An unnamed
target the tools miss altogether is still removed, as an intersect with
every body does (#5160). Sheet metal flat machining follows the same rule.

* Sheet metal: an intersect with already removed material is refused (#5295)
2026-10-23 13:50:47 +00:00
gsrunion 4b42ef50c7 Mesh leftovers from the ribbon redesign (#133) (#4471)
* claim: #133

* mesh: Save As Mesh declares the panel that shows it (#133)

The ribbon redesign (#2176) removed the MESH tab, but three things still
pointed at it:

- `file.save_mesh` declared `.at("MESH", "MODIFY")`. The command inventory
  prints the placement a spec declares, so `docs/features.md` advertised a
  `MESH > MODIFY` location no ribbon has. Its real home is already
  INSERT / FILES (`SECTIONS` lists it), so the declaration now agrees with
  the ribbon; `destination()` resolved it there before and after, so nothing
  moves on screen.
- The user guide sent people to "the Mesh tab under Modify" for Save as mesh.
- A test now refuses any command that declares a tab the ribbon removed, and
  pins where Save As Mesh lives.

`PLACEMENTS` keeps its MESH entries: those migrate toolbars users saved under
the old panels, which is a different question from what a spec declares.

---------

Co-authored-by: Geraldrunion <geraldrunion@users.noreply.github.com>
2026-10-23 13:45:35 +00:00
Peppe Bergqvist fdde3f9da9 Add macOS native menu bar (#2361) (#4531)
* claim: #2361

Co-Authored-By: GitHub Copilot <noreply@github.com>

* feat: add native macOS menu bar (#2361)

Reuse document actions and unsaved quit handling in native application, File, Edit, Window and Help menus. Replace duplicate header controls only when native installation succeeds, preserving other platforms and the fallback UI.

Co-Authored-By: GitHub Copilot <noreply@github.com>

---------

Co-authored-by: GitHub Copilot <noreply@github.com>
2026-10-23 13:45:31 +00:00
Colin Faulkingham 74ea81519b Keep Parameters responsive in large feature histories (#2733) (#4549)
* claim: #2733

* Keep Parameters responsive with many feature groups (#2733)
2026-10-23 13:45:26 +00:00
SS-EN 769dfff1a5 Section Analysis: a Hatch option (#4563) (#4565)
* claim: #4563

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Section Analysis: a Hatch option, off for a plain cut in the body's colour (#4563)

The Section Analysis dialog gets a Hatch check box beside Flip, and
inspect.section a hatch?: bool (default true), so scripts, the CLI and the
control channel reach it too. Hatched, a cut is its body's colour tinted
toward the cap colour with 45 degree stripes, as before; off, it is the
body's own solid colour. The viewport (cap alpha in the uniform), the CPU
rasterizer and ui.render follow it, and the section keeps it when hidden
and shown again or edited.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:45:23 +00:00
SS-EN 3ef68c708f Expression suggestions stay closed while another control is dragged (#4589) (#4590)
* claim: #4589

* Expression suggestions stay closed while another control is dragged (#4589)

A drag elsewhere leaves a text box its focus (egui gives it up on a click,
not a drag), so dragging the Drive Joints slider rewrote the focused Angle
box under its old selection. A letter before the caret ("5.0 d|eg") made a
new word, a fresh undismissed popup, and the list opened over the slider.

Dismissed suggestions now stay closed until the box is typed in, a drag of
another widget dismisses them, and a hidden list forgets its row rects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:45:17 +00:00
Reefwalker 3ed1829cc7 fix: retain circular sketch pattern Quantity expressions (#4435) (#4607) 2026-10-23 13:45:15 +00:00
raykholo b982ccd8c4 Mesh Convert works without activating the mesh's component first (#4609) (#4613)
* claim: #4609

* Mesh Convert works without activating the component first (#4609)

Inserting a mesh puts it in a new component while the parent stays
active, so Convert Mesh always refused the first time. Convert now
resolves the mesh body's owning component and adds the conversion
there when the active component is that component or an ancestor,
leaving the active component unchanged. From another branch it still
refuses, and the error names the component to activate.
2026-10-23 13:45:13 +00:00
dudedagger42 2586b09f5a Control: a whole-design measure reads a copy off the window thread (#4604) (#4614)
* Control: a whole-design measure reads a copy off the window thread

`inspect.measure` and `document.inspect` (which measures every body by
default) ran on the window thread. On a large assembly that took about a
minute, the window answered nothing meanwhile, and Windows closed it as
not responding (#4604). They change nothing, so they now run on a copy of
the session on a worker and are answered from there, like exports, but
whatever the "agent commands off the window thread" preference says: an
answer from the design as it was when asked has no order to keep. The
window keeps answering `ui.*` and drawing meanwhile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Control: clearance and interference checks read a copy off the window thread too

inspect.clearance and inspect.interference only read the design, and on a
large assembly measure every pair of bodies: on a 153-body design a clearance
check computed for over five minutes on the window thread, the window showing
"not responding". They join inspect.measure and document.inspect in running on
a copy, answered from there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:45:10 +00:00
DuBearte a5f9ff45dd Sketch Symmetry: a dimensioned shape slides onto the axis (#4030) (#4626)
* claim: #4030

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Sketch Symmetry: a dimensioned shape slides onto the axis (#4030)

With a rectangle's width dimensioned, Symmetry of its two sides about an
axis was refused: the second side can't move on its own, the axis never
moves, and the ordinary solve that slides the whole rectangle only ran
when every first-picked entity could move. One movable first entity is
now enough.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:45:08 +00:00
raykholo 61360c3b0d Convert to Sheet Metal: a bend picked on its tangent edge connects, on rotated parts too (#4328) (#4630)
* claim: #4328

* Fix sheet conversion picks on tangent bend edges (#4328)

Resolve cylindrical bends through the nearest edge's incident faces when a
pick on a tangent boundary resolves to its planar panel. This keeps valid
remote bends connected, including picks on the other skin of a shell.

Keep conversion geometry and picks in the body's local frame, and format
conversion diagnostics through its world placement. Cover rotated bend
picks and disconnected-pick coordinates with synthetic regressions.
2026-10-23 13:45:06 +00:00
Reefwalker eec6da060f Measure: report obtuse angles at a shared corner (#4625) (#4634)
* claim: #4625

* fix: measure obtuse corners independently of edge direction (#4625)
2026-10-23 13:45:04 +00:00
DuBearte f422bb1263 Intersection edges divide at 1e-6 when asked for less, instead of panicking (#4623) (#4650)
A join failed with the kernel's internal error "tolerance must be no less
than 1e-6" at vendor/truck-geometry/src/decorators/intersection_curve.rs:172:
IntersectionCurve::parameter_division asserted truck's minimum tolerance,
and the guard turned the panic into a failed operation. A tolerance below
truck's TOLERANCE, or one that is not a number, now divides at TOLERANCE:
the curve's points are only found to within it anyway.

The test divides the intersection edges of a hole cut through a B-spline
face at 5e-7, 1e-12, 0, -1 and NaN: each gives the division at 1e-6. Without
the fix it panics with the reported message at the reported line.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:44:56 +00:00
Martin Pålsson 96ce31153c Pattern and mirror: a rib as the source (#4593) (#4667)
* claim: #4593

* Pattern and mirror: a rib as the source (#4593)

A pattern or mirror of features refused a rib as a source ("Rib1
cannot be patterned or mirrored yet"). A rib's walls run until they
meet the body, so each copy is built again where it is: the rib's
sketch is moved by the copy's transform and the walls are run against
the bodies there, as a hole is drilled again at each copy. The copies
join the body, as the rib does. The rib's own tool building moves into
rib_tools, which the rib feature calls unmoved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:44:52 +00:00
Steve Jason a9c3c8bb8a Accept a leading = in expressions, so a fillet radius can be =d3 (#4681) (#4686)
* Sketch fillet radius: accept a leading = in an expression (#4681)

* A second = after a name is still not a definition
2026-10-23 13:44:48 +00:00
dudedagger42 a147a7c083 Sketch Point: a point put on a midpoint or a curve snap is held there (#4691) (#4745)
* Sketch Point: a point put on a midpoint or a curve snap is held there (#4691)

The Point tool sent only a snapped point's coordinates and sketch.point
took only [x, y], so a point clicked on a line's midpoint (or on a curve,
or a model vertex) was free to move off it. sketch.point now takes the
snap forms the other draw commands take ("mid:<line>", "on:<curve>:x,y",
{vertex}, {on_edge}); the snap makes the point with its constraint. The
tool passes those snaps as themselves; a snap to an existing point still
goes as coordinates, where `connect` joins them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Sketch Point: refuse a snap it can't read instead of answering as if it made a point

sketch_project::snap_points leaves a snap it can't parse ("on:c1:abc",
"on:c1", "on:c1:1,2,3") as it was given, and sketch.point then read
that string back as the point it had made: no error, no point, and an id
a script would go on to dimension. It now takes the snap only when it
names a point of the sketch, and otherwise refuses, listing the snap
forms (found by the test agent on #4745).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:44:42 +00:00
dudedagger42 89a11d75c0 Imports through the app keep their matrix, name and options (#4676) (#4746)
The app's background loader (file_load::control) kept only `up` and
`names` of a control-channel open or import, so file.insert_step's
`matrix` and `name` were dropped and the part landed at the origin, and
file.insert_mesh lost all its options. It keeps every parameter but the
path, as given; each command checks them as it does when run directly.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:44:40 +00:00
dudedagger42 84cad43154 Timeline marker: refuse a negative position, and name the command asked for (#4712) (#4749)
timeline.rollback / timeline.roll_to read a negative position as 0, so
an agent passing -1 for "one step back" rolled the whole timeline back
and every body disappeared, with no error. A negative position is now
refused. The two commands shared one function that refused in
roll_to's name; each now refuses in its own.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:44:38 +00:00
rlartot 5c280b9a94 fr: Répétition instead of Réseau for patterns (#4751)
Nine messages: the pattern tools, their headings and two sample
descriptions. "Réseau" can be mistaken for a network.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 13:44:36 +00:00
DuBearte 1a953736ff Combine takes bodies clicked in the Browser (#4452) (#4766)
* claim: #4452

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Combine takes bodies clicked in the Browser (#4452)

A body row in the Browser only replaced the selection, so with Combine
open its target and tools stayed empty and overlapping bodies could only
be picked in the view. When the open dialog's active input takes bodies,
a body row now goes there, as plane and axis rows already do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Combine Browser picks: test both layouts and control-channel clicks (#4452)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:44:32 +00:00
DuBearte 4106332738 Shortcuts: a sketch tool and a model command can share a key (#4163) (#4781)
The Keyboard Shortcuts page refused a key another command used, even a
sketch tool and a model command that never run in the same mode, so
Shift+S could not open Create Sketch in the model and place a point in a
sketch. Edited by hand, the press ran Create Sketch inside the sketch.

A sketch tool (SKETCH tab) and a model command (the other tabs) may now
share a key. A press runs the enabled command of the mode the view is in
first: the tool in a sketch, the model command outside. Two commands of
one mode still clash, and commands on no tab (undo, views, files) clash
with everything.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:44:29 +00:00
CamWight 9559ad398a Physical material: one rule for a body's material and its density (#4783) (#4785)
* claim: #4783

* Physical material: one rule for a body's material and its density (#4783)

inspect.measure, component.properties, occurrence.properties and the
Properties window took a body's material name from the raw assignment,
but its density from Document::density, which falls back to the default
material when the assigned one is unknown or has an unusable density. So
a damaged or hand-edited design read one material and weighed another: a
design material "Broken" at -3 g/cm³ read "Broken" at 1.29 g/cm³.

One rule, in one place:
- DEFAULT_MATERIAL (new, beside DEFAULT_DENSITY in the material library)
  is the default material's name. The library's first row uses it.
- Document::body_material (new, beside Document::density) is the material
  a body's mass is computed with, as the library or the design spells it:
  the assigned one when it is known and has a usable density, else
  DEFAULT_MATERIAL. Document::density is that material's density, with
  the same value as before for every input.

inspect.measure and the physical properties name body_material.
inspect.measure now names every body's material, measured or not, since
naming it takes no measuring; the density stays with the mass, on the
bodies measured. So the Properties window shows the engine's name without
a fallback of its own. The places that keep the assignment as stored
(configuration cells, material.assign, material.edit, material.delete
and the dialogs) only use the constant, so a configuration still keeps a
damaged assignment as it is.

The visible change: a body whose material is unknown, or has an unusable
density, reads "Default", the material it weighs, and a miscased name
reads as the library or the design spells it. A body inspect.measure
can't measure now has its material too. Masses and densities don't
change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:44:27 +00:00
DuBearte bddb52c96a Move: the value box follows the dragged axis, and Tab steps through X, Y, Z (#4776) (#4788)
The Move dialog's floating value box always showed and edited the X
distance, whichever arrow was dragged, and Tab left the box for another
widget. Now:
- dragging an arrow shows that axis's distance, turning a ring (Free Move)
  that axis's angle; a plane square or the ball leaves the box as it is;
- Tab in the box goes to the next value (X, Y, Z, then in Free Move the
  X, Y and Z angles) and Shift+Tab back, the keyboard staying in the box.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:44:25 +00:00
raykholo a0fd39a7be Flange on a plain solid says how to start sheet metal (#2987) (#4789)
* claim: #2987

* Flange on a plain solid says how to start sheet metal (#2987)

Flange, Flat Pattern, and Export DXF on a design with no sheet metal body
now say how to make one: Flange > Base, or Convert to Sheet Metal on a
plate's large flat face. An edge flange checks for a sheet metal body
before it adds a feature. The message is in the catalogs, and engine
messages that match a catalog entry are shown in the interface language.

* Dialogs show a refusal without the engine's parameter prefix (#2987)

The Flange dialog showed "invalid parameters for `sheet.flange`: " in front
of the new guidance. The dialog already names its command, so the red
refusal text leaves that prefix out and follows the interface language
when the message is in the catalogs. Create Issue still gets the full text.
2026-10-23 13:44:24 +00:00
raykholo be87303683 Web: a trackpad swipe pans instead of zooming (#4116) (#4834)
* claim: #4116

* Web: a trackpad swipe pans instead of zooming (#4116)

Browsers deliver trackpad swipes as wheel events without scroll phases, so
the shared navigation treated every vertical swipe as a mouse-wheel zoom.
The web raw-input hook now brackets point-mode swipes (small or horizontal
deltas) with gesture start/end phases, ending after 100 ms idle or before
line/page wheel or pinch input. Wheel notches and pinch still zoom, and
camera input still goes through navigation::handle.
2026-10-23 13:44:20 +00:00
raykholo bd0cc4bb51 Units of new designs apply to the empty design already open (#221) (#4835)
* claim: #221

* Units of new designs apply to the blank design already open (#221)

Changing Preferences > Units of new designs only took effect at the next
File > New, so the empty design open at launch stayed in millimetres. When
the preference changes (the dialog, Cancel, Restore Defaults, or
preferences.set) and the open design is blank and unsaved, it takes the new
units at once, without an undo step or unsaved changes. A design with
content or a file keeps its units.
2026-10-23 13:44:18 +00:00
raykholo 4c8ae5d078 Hem: the direction arrow points the way the hem builds (#4794) (#4836)
* claim: #4794

* Hem: the direction arrow points the way the hem builds (#4794)

The Hem dialog shared the edge flange's arrow, which points out of the
sheet's larger neighbouring face. A hem folds back the other way, so the
arrow pointed away from the material it adds. Flip the hem's arrow so it
matches the geometry; edge flanges keep theirs.

A regression test compares the arrow with the side the finished hem adds
material on, for both Flip states, top and bottom edge picks, and both
dialog layouts.
2026-10-23 13:44:17 +00:00
Mateusz Sych e33c371e99 Model cache: thin the checkpoints of a design too big for its file instead of leaving the cache out (#3220) (#4882)
* claim: #3220

* Model cache: keep fewer states of a design too big for its file instead of none (#3220)

A model cache over the container's budget keeps the final state, then the
slowest features' states while the bodies they add fit (a body several
states hold counts once), and is left out only when not even the final
state fits. Opening evaluates just the features whose state the file left
out, each from the state before it, so the model is whole afterwards.

* Model cache: keep each encoded body apart, copy only what a cache stores

Encoding a model too big for its file no longer copies every distinct body
into one buffer before thinning: the payload is copied from the encodings
once its size fits, so saving holds no more than before.

* Model cache tests: canonical runs in the timeline fixture; rustfmt

* Model cache: count the records a thinned cache keeps; always try the final state

Thinning counted only the bodies, so a model of many copies of one part
(records, not bodies, filling the room) shrank its budget to nothing and
saved no cache although its final state fitted. The choice now counts the
records of what it keeps, always keeps the final state, and says what it
counted, so each retry keeps less and the final state alone is always tried.

* docs: a thinned model cache counts its records and always tries the final state

* Model cache test: the slowest feature's state holds bodies 16 to 23

* Model cache: thinned opens with fingerprints that read the state before a feature

Since #3397 a feature's fingerprint reads where the bodies before it lie.
After a state the file left out, adopt takes the saved fingerprint and the
evaluation that works the state out checks it. A result evaluated ahead
is never taken as one the file left out. The thinned round trips include
the placed components' design, whose cut reads those placements.

---------

Co-authored-by: MateuszSychXpl <151755914+MateuszSychXpl@users.noreply.github.com>
2026-10-23 13:44:11 +00:00
raykholo e104e241a3 Rename a body: keep the names of siblings a later feature removed (#4880) (#4904)
* claim: #4880

* Rename a body: keep the names of siblings a later feature removed (#4880)

Renaming a body sets the stored names of all bodies its feature made, so
the siblings are not renumbered. Those names were read from the final
model, where a body that a later feature removed no longer exists: its
stored name was lost, it came back as `Ext (3)`, and the later Remove
failed with "no body `Body3`". Read the siblings from the feature's own
result instead, as the later features already are.

* Test: renaming one half of a split leaves the other

Renaming one half keeps the other half's name, also once a later feature
joins that other half away, after both an incremental and a full
evaluation.
2026-10-23 13:44:09 +00:00
raykholo 3bca787213 MCP tools: unknown command ids suggest the closest ids (#4846) (#4915)
* claim: #4846

* MCP tools: unknown command ids suggest the closest ids (#4846)

An unknown command id in `execute` or `batch` now lists up to three closest
ids, and an id with parameters packed into it says to pass them in `params`.
2026-10-23 13:44:07 +00:00
raykholo 21257c39c2 Keyboard Shortcuts page: draw only the visible rows (#4804) (#4919)
* claim: #4804

* Keyboard Shortcuts page: draw only the visible rows (#4804)

Off-screen rows keep their height but skip their cells, the list is
filtered once before measuring and drawing, and each row's shortcut
comes from the command spec already in hand instead of a registry
lookup.
2026-10-23 13:44:06 +00:00
raykholo ed6e123d54 Sketch: offer Break Offset Association for offsets of lines (#4899) (#4920)
* claim: #4899

* Sketch: offer Break Offset Association for offsets of lines (#4899)

Trim and Extend refuse an associated offset output and point to Break
Offset Association, but the right-click menu offered it only for
free-form offsets. Offer it whenever the curve is an offset output
(analytic, topology or free-form), using the sketch's shared lookup.
2026-10-23 13:44:04 +00:00
raykholo dad28abecf solidcraft-cli bench-assistant: the standard Assistant model test (#4851) (#4930)
* claim: #4851

* solidcraft-cli bench-assistant: the standard Assistant model test

Runs the eleven v1 tasks of #4851 against a desktop app that is already
running with a control port. Each task starts a new design and a new chat
with All permissions, runs only the prescribed setup, sends the prompt
once and waits for the turn. The result is checked on the design
(document.inspect with measurements): body count, volume within 1%
(0.3% for the fillet), bounding-box size in any orientation, the lowest
corner, the body name, live feature kinds, and changed or unchanged
content; the volume question is checked on the reply. Failures get the
issue's categories from the conversation's items. Prints a table, and
writes the issue's comment template (--markdown) and the raw results
with each conversation (--json).
2026-10-23 13:44:02 +00:00
Jasper van Loenen d1f5d6e223 History: drag features across the marker in one step, clearer drop feedback (#4938)
Dropping a feature just behind the history marker now rolls it back, and
dropping it just before the marker makes it active again, in both the
bottom strip and the left panel. timeline.reorder and timeline.move_many
take an optional `active` param that sets the marker in the same command,
so the move is one undo step and one recompute. A position on the wrong
side of the marker for the requested `active` is refused.

Drop feedback:
- bottom strip: the drop line sits centred between two features (it
  used to touch the previous one) and shows at the first position
  (it used to be drawn outside the strip there)
- left panel: a drop slot below the marker when it is at the end
- no drop line when the marker is dragged back to where it is
- a dragged feature is outlined in the drop line's green
- grab cursor on the marker, grabbing cursor while dragging the marker
  or a feature

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:44:00 +00:00
Logan Night 1b215b521d icon_audit: compare the requested-icons manifest with CRLF checkouts ignored (#4932) (#4960)
* claim: #4932

* icon_audit: compare the requested-icons manifest with CRLF checkouts ignored (#4932)

On Windows, Git for Windows checks out requested_icons.txt with CRLF line
endings (core.autocrlf; the repo has no .gitattributes), while the test
builds the manifest with \n, so the comparison fails although the lists
are the same. Read the file with \r\n normalised to \n, as #1931 did for
the icon tools. Regenerating still writes \n.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:43:56 +00:00
raykholo 36a354e3a7 Agent chat: serve the bundled skills' reference through an MCP tool (#4966) (#4970)
* claim: #4966

* Agent chat: serve the bundled skills' reference through an MCP tool (#4966)

The bundled skills start with "read reference.md", but SolidCraft's own
chat turns the agent's file tools off, so its agent could not follow them.

The MCP server gains a read-only `skill_reference` tool that returns a
skill's documents from copies built into the binary. It takes a skill
name and a file name from a fixed list, never a path, and refuses an
unknown skill or file with the available ones. The skills point to it,
and docs/mcp.md and skills/README.md describe it.
2026-10-23 13:43:54 +00:00
bherbruck f120285c96 Fix design format 33 for 0.21.0-rc.1 (#5610)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:47:48 -04:00
Taha Zarif a05a0f1053 Fix/mcp stdio bounded request lines (#4972)
* test(mcp): reproduce whitespace bypass of request-size limit

Add a source-level regression that rejects a JSON-RPC request larger than
MAX_LINE even when most of it is whitespace, plus bounded-stdio framing
coverage and invalid UTF-8 recovery.

* fix(mcp): bound stdio line buffering and validate raw request size

Consume oversize JSON-RPC lines in bounded memory while keeping the
following request available. Reject whitespace-padded lines before
trimming, preventing a request-size cap bypass.

* docs(mcp): keep server framing comment idiomatic
2026-10-23 13:43:52 +00:00
HiroKenEz 60efbe39e3 Sketch drag: a linkage with redundant parallelogram constraints still moves (#4585) (#4983)
* claim: #4585

* Sketch drag: a linkage held by redundant parallelograms still moves (#4585)

Cursor motion looks for the sketch's freedom in the null space of the
constraint Jacobian. Redundant constraints (a parallelogram held both
parallel and equal on each pair of sides) are dependent only at an exact
solution; after a correction to the solver's tolerance their rows differ
by about 1e-9, which the 1e-9 pivot tolerance counted as rank, so the
null space came out empty and a one-DoF linkage stayed locked. Use the
rank analysis' 1e-7, so the drag sees the freedom the sketch reports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:43:49 +00:00
raykholo 9e20a373ca Browser: Shift+click above the anchor selects the whole range (#4991) (#4997)
* claim: #4991

* Browser: Shift+click above the anchor selects the whole range (#4991)

The browser rebuilds its row order while drawing, and a Shift click looked
the anchor up in that half-built list, so an anchor further down was not
there yet and only the clicked row was selected. Resolve the range against
the previous frame's complete row order instead.
2026-10-23 13:43:47 +00:00
Daniel 2f55a39c9c Timeline: delete the selected features, Shift+click ranges, delete after the history marker (#5005) (#5006)
* claim: #5005

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Timeline: delete the selected features, Shift+click ranges, delete after the history marker (#5005)

Delete in a feature's menu deletes the selected features when the
right-clicked one is among them ("Delete 3 Selected Features").
Shift+click selects the range from the last feature clicked (or the last
selected one), in the order taken before drawing. The menu of a feature
after the history marker deletes every feature after the marker, with
their number in the label. All of it goes through delete_features: one
timeline.delete and one undo step. The confirmation no longer lists
features that are themselves being deleted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:43:45 +00:00
Jackson Oursland 80fd2fbbf1 Assistant: add Anthropic-compatible API agents (#5015) (#5017)
* claim: #5015

Co-Authored-By: Claude Code <noreply@anthropic.com>

* Assistant: add Anthropic-compatible API agents (#5015)

The Add agent wizard now offers Anthropic-compatible endpoints beside OpenAI-compatible ones. The API backend speaks the Anthropic Messages protocol with the same MCP bridge, tool gate, limits, undo and keychain rules: x-api-key and anthropic-version, a top-level system note, input_schema tools, content-block answers and one user tool_result message per round. Test sends a short message because compatible endpoints (Qwen's) need not list models.

Co-Authored-By: Claude Code <noreply@anthropic.com>

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
2026-10-23 13:43:41 +00:00
raykholo c3d1ce8237 Opening a design: range-check integer-valued coordinates too (#5021) (#5026)
* claim: #5021

* Opening a design: range-check integer-valued coordinates too (#5021)

The file-level number check skips every integer-valued number so that ids
and counters may be large, which let a coordinate such as
9223372036854775808 through. The typed design is now checked as well:
float fields (coordinates, sizes) are serialized as floats and must be
within the same +-1e9 limit, while integer fields stay integers and are
not limited.
2026-10-23 13:43:40 +00:00
raykholo 567bc50723 Snap fit: refuse sizes too small to build (#5022) (#5027)
* claim: #5022

* Plastic features: refuse sizes under 0.01 mm (#5022)

A snap fit with a near-zero thickness (1e-9 mm) got past the size checks,
which only asked for a positive number, and panicked in the kernel's
intersection code. The boss, lip, groove and rest had the same gap.

Every length of a plastic feature that becomes geometry (snap fit length,
thickness, width, catch_depth and catch_length; boss diameter, height,
hole_diameter, hole_depth and rib sizes; lip and groove width and height;
rest width, length, height and thickness) must now be at least 0.01 mm.
The check sits where the feature is evaluated, so a parameter changed later
is refused the same way, and the message names the parameter.
2026-10-23 13:43:38 +00:00
raykholo 1a5fcd05d6 Extrude and loft: refuse duplicate profile indexes (#5023) (#5028)
* claim: #5023

* Extrude and loft: refuse repeated profile indexes (#5023)

`profiles: [0, 0, ...]` with 10 000 copies built 10 000 identical bodies in
about 35 s, and a loft section listing one profile 10 000 times took 25 s
before it was refused.

A list of profile indexes now refuses a repeated index while it is parsed
("profile 0 is listed twice"), as offset association ids already were.
Before the profiles are resolved, a selection naming more profiles than its
sketch has, an index the sketch doesn't have, or two points in the same
profile is refused too. A design file that repeats an index still opens,
using the profile once.
2026-10-23 13:43:36 +00:00
Cyryl Sochacki d273c45de6 i18n(pl): translate 213 missing messages (#5031)
Catch up with en.json: the new Preferences pages, assistant settings,
3D mouse axis assignment, bug report form, history names and the tool
help texts. pl now covers every message of en.json.
2026-10-23 13:43:32 +00:00
dudedagger42 da46c660df Coincident on two circles lays the second on the first (#5038)
Coincident with two circles (or arcs) picked was refused: "two curves must both
be straight lines; otherwise choose a point and a curve". It now lays the second
pick on the first: Concentric and Equal together, in one step, with the first
staying put. A pair already concentric or equal gets only the other half; one
already lying on the other is refused as nothing to add.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:43:30 +00:00
vdirienzo 92d1331978 A renamed body keeps its number: the next body does not take the freed name (#4957) (#5053)
* A renamed body keeps its number (#4957)

The design's body counter starts over at every evaluation, so a body
renamed after a join (the join changed it, so the rename cannot be
followed without evaluating the design again) gave its number back: the
next body took the name the rename freed, and a script that names a body
found another one.

The design now saves the names a rename freed, and the counter skips
them.

* A renamed body keeps its number (#4957)

The design's body counter starts over at every evaluation, so a body
renamed after a join (the join changed it, so the rename cannot be
followed without evaluating the design again) gave its number back: the
next body took the name the rename freed, and a script that names a body
found another one.

The design now saves the names a rename freed, and the counter skips
them.

* Format
2026-10-23 13:43:28 +00:00
dudedagger42 01bbc0aa9f Parameters: a favourite renames by double-click (#4952) (#5070)
A favourite is listed twice, under Favourites and under User. Double-clicking its name opened a
name editor in both rows, under one widget id; the two took each other's focus, one lost it at
once, and the rename closed with the name unchanged. The rename now opens only in the table it
was started in, under an id that includes that table.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:43:26 +00:00
raykholo dbbad6d4ee view.apply: keep the camera's roll, and refuse an up axis (#5087) (#5094)
* claim: #5087

* view.apply: keep the camera's roll, and refuse an up axis (#5087)

An explicit camera was read as a named view, which has no roll or up, so
roll was silently reset to 0 and up was ignored. Read roll as a finite
number in radians, clamped to ±π, and refuse up with a message saying the
up axis comes from the design settings.
2026-10-23 13:43:24 +00:00
raykholo 4f69e6540c Dragging a docked dialog no longer highlights the view under it (#5086) (#5095)
A docked dialog is placed from the previous frame's drag offset, so a fast
drag can leave the pointer outside the dialog, over the model. The viewport
then treated the pointer as hovering the model and highlighted faces and
edges under it.

drag_dock now marks the frame in egui temp data while its title band is
dragged, and navigation::handle treats the pointer as not hovering the view
while that mark is current and a button is held. This covers every dialog
docked through drag_dock.
2026-10-23 13:43:23 +00:00
ivisaf 55608665ef gpu: open the OpenGL regression device without indirect-call validation (#5101)
NVIDIA's EGL answers wgpu's request for a GL 3.3 context with exactly 3.3
(GLSL 3.30) yet lists GL_ARB_compute_shader. wgpu-hal then reports compute
shaders, wgpu-core builds its indirect-call validation pipeline, naga cannot
emit compute GLSL below 4.30, and request_device fails as Device(Lost).
The test draws nothing indirect, so it opts out of that validation.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:43:20 +00:00
raykholo 0fb4a3e868 GPU tessellation: an empty plan no longer fails buffer validation (#5049) (#5104)
* claim: #5049

* GPU tessellation: an empty plan no longer fails buffer validation (#5049)

Empty tessellation inputs were padded to 16 bytes, smaller than the
shader's 32-byte vertex and 48-byte surface records, so wgpu refused the
dispatch. Skip the dispatch when there is nothing to evaluate, and pad
each empty buffer to one whole element of what the shader reads.
2026-10-23 13:43:18 +00:00
Christian Hammacher 2dc31a30d4 Extract to File: move a component into its own linked design (#5113)
The component's sketches, features, bodies, subcomponents, the joints among
them and the parameters they use move to a new .solidcraft design, keeping
their ids and names, and the component becomes linked to it as Insert Design
makes it. The new design must build the same bodies and the assembly must
look the same afterwards, else nothing changes and no file is written. Joint
faces on the component are found again on the linked bodies, and components
that build the same bodies can become placements of the link.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:43:16 +00:00
sthifen 8b68eb7eac fix(extrude): a sketch on an inserted part joins and cuts it, through all too (#5115)
A STEP file inserted into a new design sits in its own component under the
empty root, and a sketch on its face must join that part going out and cut it
going in rather than make a separate body beside it. With a distance, main
already resolves this; Through All with no distance gave `auto` no reach to
probe, so it fell back to New Body and cut nothing. Through All now reaches
past every body in its direction, and `auto` reads it as a cut (#1234).

Co-authored-by: Steven <steven2021067613@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:43:14 +00:00
bherbruck 96679f3e2b Themes: drop the logo's fourth colour, which #5465 retired (#5548)
The 14 new built-in themes still listed four logo colours, so none of them read after #5465 and main's theme test failed.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 11:39:30 -04:00
froglemonade2 3ba17bb517 App icon: draw the borderless mark everywhere (#5461) (#5465)
* claim: #5461

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* App icon: draw the borderless mark everywhere (#5461)

app_icon.rs drew the older mark with white lines between the faces and a
white outline around it, so the Windows, Linux, web and README icons did
not match the macOS icon, the social card or the title bar. It now draws
the borderless mark from the faces and gradients of the macOS artwork
(03-solid-blue-cutout.svg): five faces with transparent gaps between them.
The filler fills even-odd spans so the concave faces work.

The Linux hicolor PNGs and both Windows .ico files are regenerated with
`cargo xtask app-icons`. The README logo, the web app's icons and every
package pick them up from there.

The logo colour token's fourth entry (the white edge) is gone: nothing
draws it any more.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:40:09 -04:00
N3MI-DG ae3b36ac88 Agent cursor follows the agent's own pointer input (ui.move, ui.click, ui.drag, ui.scroll) (#5162)
The agent cursor only acted out engine commands: pointer input sent over the control channel
reached the app as synthetic events without moving it, so an agent's clicks, right-clicks and
drags were invisible to a person watching. While the cursor is shown, each synthetic pointer event
now moves it to where the input lands, a press leaves a ripple, and a drag is followed step by
step. A request being acted out keeps the pointer; with the cursor hidden nothing changes.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:42:59 +00:00
raykholo 5367ef4367 Extrude without a profile selection keeps holes in nested loops (#5172) (#5176)
* claim: #5172

* Extrude without profiles keeps nested loops as holes (#5172)

With no `profiles`, solid.extrude took every region of the sketch, so
circles drawn inside a plate outline were extruded as well and filled the
plate's holes. The default now applies even-odd nesting to the drawn
profiles: a loop inside an outline is its hole, a loop inside that hole is
material again, and separate outlines all extrude. Sketches without nesting
keep the `all` selection, and explicit `profiles` (including "all") are
unchanged. Saved designs store their selection, so they recompute as before.
2026-10-23 13:42:55 +00:00
raykholo caa1e6e196 component.create refuses parameters it does not take (#5174) (#5178)
* claim: #5174

* component.create refuses parameters it does not take (#5174)

A call with `bodies` (or a misspelled key) used to succeed and make an empty
component. Unknown keys are now refused before anything changes, the error
lists the accepted parameters, and `bodies` points to component.move_bodies
and component.from_bodies.
2026-10-23 13:42:53 +00:00
raykholo 8bd17cea42 Joints: a joint origin's name given as a face says to pass it as the origin (#5175) (#5179)
* claim: #5175

* Joints: a joint origin's name given as a face says to pass it as the origin (#5175)

When the name in `face` or `circle` matches no face but is a joint origin,
the error now says so instead of "no face is named".
2026-10-23 13:42:51 +00:00
takuto-NA 4256775274 Joint: avoid redundant analysis and residual placement work (#5185) (#5186)
* claim: #5185

* Joint: reuse residual placements and skip discarded mobility reports (#5185)
2026-10-23 13:42:49 +00:00
Jackson Oursland 5962f56f87 Viewport: reuse still pictures and draw scenes into a 1x window pass (#4820) (#5187)
* Viewport: show the picture it drew last when nothing changed (#4820)

A frame redrew the whole scene whenever egui asked for a frame, even when
the camera, scene, highlight and overlays were identical: pointer moves over
empty space and UI animations over a still view paid for the full scene.

The renderer now hashes everything the picture depends on (scene keys,
camera, size, section, images, analysis, accessibility map, occlusion) and
blits the last offscreen picture when the hash matches. The first still
frame filters a supersampled picture down once and later frames copy it as
it is. A frame with the same content costs a blit instead of a scene draw.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* Viewport: draw the scene offscreen, into a 1x window pass (#4820)

The scene keeps the app's depth, stencil and MSAA, but egui's window pass now
has one sample and no depth. Whenever the two targets differ, the scene
(product previews too) is drawn offscreen and blitted, so the UI pass no
longer clears and resolves a multisampled window.

Over 8 s of pointer moves on the 450E rotor head, the app's GPU intervals fell
from 1325 ms to 1006 ms and egui_render from 892 ms to 509 ms; idle stays at
0%. A GPU test checks the split path's picture matches the direct one.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* 3D mouse: a repeated report at rest asks for no frame (#4820)

A device may repeat its latest report while the cap rests. Publishing it
again asked egui for a frame although nothing changed. Only a report that
changes the reading now asks for one; while the cap moves, the view asks for
the next frame itself.

Co-Authored-By: Claude Code <noreply@anthropic.com>

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
2026-10-23 13:42:47 +00:00
github-actions[bot] b6a45b8278 Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci] 2026-10-23 14:19:26 +00:00
N3MI-DG 8ae90f9667 Home: a recent design's location shows in one tooltip, not two (#5208)
The File view's recent row already shows its path on hover; Home added
a second hover text with the raw path, so both appeared.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:42:43 +00:00
William Dunn 17f0d6bc68 Format registry: one table of formats, extensions and options (#5215) (#5217)
* Add the format registry: one table of formats, extensions and options in crates/io.

The hosts' Open-dialog filters and the web accept list derive from it, and
registry tests pin it to the path predicates and the command docs.

* Fix format-registry tests that compared FormatSpec without Eq.

assert_eq on Option<&FormatSpec> does not compile; the none cases
only need is_none, matching the other rows that compare ids.
2026-10-23 13:42:40 +00:00
raykholo 8b1a722f06 Viewport: the ground shadow no longer sends every frame through the transparency path (#5230) (#5255)
* claim: #5230

* Viewport: draw the ground shadow on the direct path (#5230)

The ground shadow went into the model's translucent triangles, so every
frame with a body counted as see-through and took the order-independent
transparency path. Give it its own batch, drawn alpha-blended after the
opaque geometry as before, so only truly see-through bodies use that path.
2026-10-23 13:42:38 +00:00
takuto-NA 63d81cc096 Motion Trace: regenerate kept curves only when inputs change (#5248) (#5270)
* claim: #5248

* feat(motion): regenerate kept traces when source inputs change

* feat(motion): add a parametric Chebyshev trace demo

* perf(joints): reuse face measurements across motion samples

Face-centred joint origins called Body::faces on every solve, bypassing the
reference-mesh reuse used for rigid placements. Share face measurements with
unchanged model geometry at the existing tolerance, while guarding replaced
bodies and applying offsets after the cached measurements.

Cover repeated named/nearest-face solves, shape edits, retained snapshots and
direct replacement with deterministic work counts. The 145-point Chebyshev
dimension and range updates retain identical sampled and interpolated curves.

* perf(naming): skip preparation for cached face names

Both face lookup paths spawned fresh workers over every body even when their name cells were ready. Prepare only cold cells, with direct handling of zero or one cold body. Preserve the existing parallel naming path and all face selection semantics.

Deterministic regressions cover repeated picked and unique-name lookups, misses, partial warmup, renames, snapshots and moved copies. Release Chebyshev medians fall from 859 to 50 ms for a dimension edit and 871 to 34 ms for a sweep edit; all 145 samples and interpolated points remain identical. Full cargo xtask ci passes with RUST_TEST_THREADS=4 and the existing local Windows validation overlays.
2026-10-23 13:42:36 +00:00
raykholo 46a8f86e80 web-offline-test: wait only for the files the browser can ask for at once (#5287) (#5294)
* claim: #5287

* web-offline-test: wait only for the files the browser can ask for at once

The fail-fast step waited for every other file of build v2 to reach the
server. Chrome sends at most six requests to one server at a time, and
the page's failure cancels the download, with it the files still waiting
for a connection. A real build keeps nine files, so only six were ever
asked for and the Pages job failed with "only 6 of 8 slow files were
asked for" whenever the installing worker didn't happen to ask again.

Wait for as many as can be in flight (less the page's connection), and
give the stand-in app more files than that, so the default run covers it.
2026-10-23 13:42:33 +00:00
Mateusz Sych 94279fe059 Web: Ctrl+Tab and Ctrl+Shift+Tab switch browser tabs again (#5227) (#5350)
* claim: #5227

* Web: leave Ctrl+Tab and Ctrl+Shift+Tab to the browser (#5227)

eframe cancels the default action of every Tab keydown on its canvas
(egui moves focus with Tab), so browsers that let a page cancel their
shortcuts, such as Vivaldi, could not switch tabs while SolidCraft had
the keyboard. A capture listener on the window now stops Ctrl+Tab and
Ctrl+Shift+Tab before they reach the canvas, without cancelling them,
so the browser acts on them as usual. Plain Tab and Shift+Tab still
move between the app's fields. Desktop builds are unchanged.

web-api-test checks it in headless Chrome: Ctrl+Tab and Ctrl+Shift+Tab
keydowns are not cancelled, Tab still is.

---------

Co-authored-by: MateuszSychXpl <151755914+MateuszSychXpl@users.noreply.github.com>
2026-10-23 13:42:27 +00:00
raykholo 867df396ea Agent chat in the Flatpak says why no agent is found and how to allow one (#4765) (#4769)
* claim: #4765

* Agent chat: explain the Flatpak sandbox when no agent is found (#4765)

In the Flatpak the sandbox has no home-folder or network access, so agents
installed through nvm or ~/.local/bin are never discovered and the chat only
said that no agent was found. With FLATPAK_ID set and nothing discovered, the
picker and Preferences > AI now say why and offer the exact
`flatpak override --user ai.storyteller.solidcraft ...` command with a Copy
button. The message is in every catalog, and docs/agent-chat.md gains a
Flatpak section listing what each permission is for. The manifest is
unchanged.
2026-10-23 13:42:14 +00:00
JEREMY 858e5995d6 Plane at Angle: a planar face is a base plane (#1939) (#1956)
Co-authored-by: jeremymiribung-blip <jeremymiribung-blip@users.noreply.github.com>
2026-10-23 11:32:24 +00:00
bherbruck edf57cec29 Stand up Y-up STEP and IGES files from SolidWorks automatically (#5191)
* claim: #5150

* Stand it up: don't offer it for SolidCraft's own STEP files (#5150)

STEP replies from file.insert_step and doc.open now carry the file's
originating system, and the window skips the stand-up offer when it is
exactly SolidCraft, as SolidCraft's STEP export writes it.

* Stand up Y-up STEP and IGES files from SolidWorks automatically

The STEP header (originating system, preprocessor version, FILE_DESCRIPTION) and the IGES
global section's native system ID say which program wrote a file. A SolidWorks header means
Y up: the import's component is stood up as it opens, as one undo step, and the message offers
Undo. SolidCraft's own files are left alone; an unknown exporter keeps the offer.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Ray <raykholo@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 02:53:20 +00:00
raykholo 0c72ad56d2 An idle window draws nothing with the frame-rate readout on (#5165)
The viewport's frame-rate readout asked for a new frame every frame, so with
it on an untouched window redrew as fast as the display allowed and kept the
GPU busy: 77% GPU at 60 fps on an M1 with a 5K display showing the sample,
against 0% with the readout off. It now asks for no frames of its own and
shows how often the view really draws; one frame a little over a second after
the frames stop shows the view idle at 0 FPS. The preference's note no longer
says the view redraws continuously.

The status toast also asked for a frame every half second for the six
seconds it shows; it now asks for one frame, when it is due to go.
2026-10-23 02:19:37 +00:00
raykholo 92d3a2fde5 GPU: a lost device stops drawing instead of crashing (#4172) (#4229)
* claim: #4172

* GPU: a lost device stops drawing instead of crashing (#4172)

After the driver resets the GPU (Windows TDR), the first queue write to meet the
lost device panicked inside wgpu-core (PendingWrites::activate unwrapping
DeviceError::Lost). Nothing watched the device for its loss.

- egui-wgpu: RenderState::device_lost (DeviceLost) is set by the device's lost
  callback. The painter draws nothing on a lost device, and before a frame's
  uploads it submits nothing (DeviceLost::probe), so a loss the driver reports
  is found by that submission instead of the first write.
- Pictures (thumbnails of saves, ui.render) watch the same flag: on a lost
  device the capturer refuses with GpuError::DeviceLost without touching it,
  and the picture comes from the CPU rasterizer. Headless capturers watch
  their own devices.
- The app, seeing the loss, closes keeping unsaved changes for recovery (as a
  forced quit does) and starts again; the new start says what happened. A
  second loss within ten minutes starts on the software renderer. The loss is
  noted in the crash journal.
2026-10-23 02:19:35 +00:00
Jackson Oursland b0048a6104 Mesh bodies: measure their size once, when made (#4662) (#4663)
`Body::size` walked a mesh body's vertices on every call, and it is asked
for every frame. A mesh body's triangles never change after `mesh_body` or
`mesh_transformed` makes it, so both now measure it then (`TriMesh::span`)
and `size` returns it like a B-rep body's. `ModelBody::mesh` returns a mesh
it already has before working out the display tolerance.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 21:51:40 -04:00
raykholo 0db2abbeca Viewport legend and readouts stay above the floating History card (#4107) (#4129)
* claim: #4107

* Keep bottom viewport overlays above the floating History card (#4107)

In the floating layout the History card covered the analysis legend and
the frame-rate readout (#3896). One bottom inset, computed from the
History card (zero when History is hidden or docked), now positions every
bottom-anchored viewport overlay: the surface-analysis and Continuity
legends, the frame-rate readout, the command-progress chip and the
navigation bar fallback.
2026-10-23 00:48:27 +00:00
bherbruck 9761d75699 Release: a failed snap build no longer holds up the release (#5168)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 20:50:38 -04:00
bherbruck 030187452a Release 0.20.0 (#5118)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 17:40:01 -04:00
ralfkuh-lab 198fbeb814 i18n: every UI label reaches the catalog, on the UI kit (#2483)
On main, 120 label literals (95 distinct) can't be translated in any
locale: the English catalog doesn't reach them. Catalogue them in en,
ja, zh and de, translate indented row labels once for display and
measuring, look up the thread profile by its id, and give each
navigation preset's help, shown by the mouse mapping picker, a catalog
message.

A guard tokenizes the UI sources and checks every label literal of a
kit call, of context_menu::button and of tr_label/tr/SelInput against
the English catalog, resolving kit calls by their imports.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 21:08:05 +00:00
Adam Hickerson 186096cbe6 agent chat: agent detection finishes on the start page (#3265) 2026-10-22 20:52:13 +00:00
Colin Faulkingham 3b982ca371 Prevent deleted item IDs from being reused (#1016) (#3641)
* claim: #1016

* Prevent deleted design item IDs from being reused
2026-10-22 18:31:39 +00:00
Loohi aaa110218b Edit Offset Plane: keep a face base, refuse a plane on itself (#3567) (#3889)
* claim: #3567

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Yz6dHZiXcgTgrxjVNC6hx

* Edit Offset Plane: keep a face base, refuse a plane on itself (#3567)

Reopening an Offset Plane built on a face looked the face up at the
plane's origin, which can lie off the face, so the Plane input came up
empty. Look it up at the point picked on the face. A redefine that
builds a feature on itself is refused with a plain message instead of
failing with "plane offsets nested too deeply".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Yz6dHZiXcgTgrxjVNC6hx

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 18:30:02 +00:00
raykholo 46f80b769f Sketch fillet and chamfer refuse an offset's corners instead of leaving circles (#2874) (#3860)
* claim: #2874

* Sketch fillet and chamfer refuse an offset's corners (#2874)

An analytic offset's output ends are recomputed from its source curves on
every solve and held fixed there. Filleting an output corner moved those
ends to the tangent points, the next refresh pulled them back to the sharp
corner, and the new arcs closed into full circles with the sketch left
unsolved.

Fillet and chamfer now refuse a corner on an offset output before anything
changes (a batch fillet applies all of its corners or none) and say how to
get the rounded result: round the source corners before offsetting, or
break the offset association first, after which the same corners round
normally.
2026-10-22 18:21:13 +00:00
raykholo 26ca82735b Circular Pattern and Revolve: a construction axis can be the axis (#2885, #4506) (#3796)
* claim: #2885

* Circular Pattern and Revolve: a construction axis can be the axis (#2885)

The dialogs send a picked axis by its name, but solid.pattern.circular
only knew X, Y and Z, and solid.revolve / surface.revolve read any other
name as a sketch line id. Picking a construction axis therefore failed.

- solid.pattern.circular takes a construction axis name, saves it as
  `axis_feature` and turns about the axis as evaluated, so the copies
  follow it when it is edited.
- AxisRef::Construction { name } for revolve and surface revolve,
  resolved at evaluation and checked to lie in the sketch plane.
- construct.plane.at_angle accepts a construction axis name (its fixed
  axis line is taken from the axis as it is now).
- Reopening these features shows the construction axis as the pick.
- Design format: pending/construction-axis-refs.rs.

* Construction axis references follow the axis in the feature's component frame (#4506)

- Revolve, Surface Revolve and Circular Pattern resolve a construction axis
  where it is evaluated, mapped into the feature's component frame.
- A line of the revolved sketch with the same id as a construction axis
  still wins, so existing scripts keep their meaning.
- Tests: the revolve keeps the reference through save and reopen and
  follows an edited axis (60π to 80π mm³); perpendicular, offset and
  profile-crossing axes are refused without changing the design.
2026-10-22 18:21:11 +00:00
SS-EN 408d30ea56 Joint drive moves parts without evaluating them (#3391) (#3397)
* Joint drive moves parts without evaluating them

A joint drive (also the Drive dialog and motion steps) changes only occurrence transforms, yet it
re-evaluated nearly the whole timeline: every feature that might read another component's
geometry (a sketch with any link, even to its own axis) hashed every occurrence's transform into
its fingerprint, so any motion invalidated it and everything after it, and no new pose was ever
in the cache.

- Placement digest: a feature now hashes only where the geometry it actually reads lies relative
  to its own frame (link sources, anchors, profiles and paths from other components, mirror
  placements; To Object and face/edge-picked planes read every component, relatively). Moving
  parts that keep those relative placements leaves every result in place: nothing is evaluated
  or re-meshed. In-context features whose referenced part really moved still rebuild.
- Evaluated ahead, applied once: `Ahead::evaluate` returns what it evaluated and
  `Session::prime` lets the window-thread pass take it (history commands and dialog OK), so an
  off-thread command no longer costs twice on designs past the cache's budget.
- Contact meshes are kept per body geometry across drives.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Joint drive: booleans read only their input bodies' placements

A join, cut or intersect with named targets, and a Combine, read every
placement, so a joined part transferred to a component re-ran its boolean
at each new joint pose. Each now hashes only the placements of the bodies
it actually takes as inputs, resolved in its timeline input state (before
any later BodyTransfer), relative to the frame that evaluates it; Combine
reads its tools relative to its target. Unresolved and legacy component or
path reads keep the conservative dependency. 24 new joint_pose_ tests
cover new angles, transfers, targets, save/reopen, undo and real relative
motion (900 -> 800 mm3).

Supplement offered on #3397 by takuto-NA.

Co-Authored-By: takuto-NA <183465875+takuto-NA@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Creating a component recomputes nothing

A new, empty component changes nothing a feature reads, yet it changed
the placement fingerprint of every targeted boolean, Combine and To
Object feature, so `component.create` evaluated the timeline again:
- the first component of a design: `placement_digest` returned 0 while
  the design had no placements and a nonzero value once it had one;
- To / From Object and legacy face- or edge-picked planes hashed every
  component, not just those holding the bodies, sketches and
  construction geometry the feature can reach.

Co-Authored-By: Tyler Chiriboga <18270301+Tyler-Chiriboga@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test: creating a component after a legacy face sketch recomputes nothing

A face extruded after its construction certificate is gone (a patterned
cut, a pad joined flush with it, then one more cut) is sketched on a
legacy face plane, which reads every placed body. A new, empty component
must not change what it reads either (reported by Tyler Chiriboga).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: takuto-NA <183465875+takuto-NA@users.noreply.github.com>
Co-authored-by: Tyler Chiriboga <18270301+Tyler-Chiriboga@users.noreply.github.com>
2026-10-22 18:21:08 +00:00
Jackson Oursland 75261cd797 Viewport: order-independent transparency for see-through surfaces (#4734) (#4903)
* Viewport: levels of detail for large meshes (#4724)

A large instanced mesh (20 k triangles or more) gets coarser copies, made
once on a worker (`mesh_lod`): a quarter, a sixteenth and a sixty-fourth of
its triangles, by quadric edge collapse (`geom::simplify`, after Garland and
Heckbert 1997; no fold-overs, no sheets joined, open boundaries kept), each
with its measured deviation from the full mesh. Each frame, as placements
are culled, each is drawn from the coarsest copy whose deviation stays
under 0.75 px where it stands (`placement_scale`, `level_for`). Until the
copies are made the mesh is drawn in full; the scene is built again when
they are. Display only: picking, measuring and exports keep the reference
mesh. Outside the kernel.

25 placements of a 2 M-triangle STL, 3600 x 2190 px, 4x MSAA, M2 Max
(`instanced_scene_gpu_benchmark`): 20.2 -> 1.2 ms a frame with all in
view, 21.9 -> 2.9 ms close up. The copies take 10 s to make, on a worker.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Viewport: see-through and faded parts drawn as instances (#4888)

See-through (an appearance's opacity) and faded (context around an active
component) bodies in one colour are drawn as instances like opaque ones,
their alpha in each placement's colour: one copy of the triangles, levels
of detail and culling, no per-placement copies built on the window thread.
Their placements are ordered back to front each frame (not their
triangles one by one) for devices that sort see-through surfaces.

The GPU benchmark gains `SOLIDCRAFT_BENCH_OPACITY` and the CPU share of
each frame, and its pictures each placement's own triangles too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Viewport: order-independent transparency for see-through surfaces (#4734)

The model's see-through surfaces (glass, faded context; instanced or not)
were sorted back to front triangle by triangle on the window thread
whenever the view turned. They are now drawn with weighted blended order-
independent transparency (McGuire and Bavoil 2013): when there are any,
the frame is drawn into the renderer's own textures (as supersampling
does), the see-through surfaces gathered into a weighted colour and a
revealage target against the frame's depth, laid over the frame in one
full-screen pass, then the overlays drawn over them. Nothing is sorted.
A device that cannot blend into float targets keeps the sorted way.

25 placements of a 2 M-triangle STL at opacity 0.5, M2 Max, 3600 x 2190 px:
about 10 s a frame sorted, 21.6 ms (17.2 ms instanced, 2.8 ms with levels
of detail).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 16:17:39 +00:00
Tyler Chiriboga 6f5d0038ab 3D mouse: find devices on Windows without asking every HID device for its strings (#2614) (#3653)
* claim: #2614 (3D mouse: find devices on Windows without asking every HID device for its strings)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3D mouse: find devices on Windows without asking every HID device for its strings (#2614)

On Windows the 3D mouse reader rescanned every two seconds while no 3D
mouse was plugged in, with hidapi's refresh_devices(). hidapi's Windows
enumeration opens every HID interface and reads its serial number,
manufacturer and product strings: on USB, three control requests to
every keyboard, mouse and touchscreen, forever, on a machine without a
3D mouse. That fits the report of an idle machine whose USB keyboard
drops off and returns and whose cursor jumps.

Windows now asks Plug and Play which present HID interfaces are
multi-axis controllers (hardware ID HID_DEVICE_UP:0001_U:0008), which
sends nothing to any device, and opens only those with open_path().
hidapi's own discovery is turned off before the reader makes its one
context, so making it touches no device either. macOS keeps hidapi's
enumeration (IOKit's registry); Linux is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* AGENTS.md: list the Plug and Play scan among the files allowed unsafe code (#2614)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Tyler Chiriboga <18270301+Tyler-Chiriboga@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-22 15:44:27 +00:00
bherbruck 1814adb06a Leaving a sketch keeps the camera where it is (#4909)
Finish Sketch, double Esc (including a blank sketch discarded), and Cancel no longer
restore the pre-sketch view. Entering a sketch still turns the view to face the plane.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 11:04:04 -04:00
Nathan Dane 971b28c982 claim: preferences redesign (#4608) 2026-10-22 13:53:36 +00:00
Nathan Dane f79cbc216c Redesign the home page as the File view (#2027) (#3822)
* claim: #2027

* Redesign the home page as the File view (#2027)

* Match the File view with the accepted component design (#2027)

* Restore the Start subtitle and simplify the recovery icon (#2027)

* Integrate File view with main's shared UI containers
2026-10-22 12:19:09 +00:00
Jackson Oursland 4dc1828138 Imported meshes: share their triangles between document copies (#4673) (#4675)
Every edit clones the document for undo, and `MeshData`'s positions and
triangles (plain `Vec`s) were copied with it: ~165 MB a command for a 2 M-
triangle STL. They are now `Arc<[f64]>` and `Arc<[u32]>`, shared by clones;
the few places that rewrite them build new arrays. Saved designs are
unchanged (an `Arc<[T]>` serializes as the array).

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 12:06:44 +00:00
Jackson Oursland f843a3f83c Picking: remove the GPU pick buffer, the per-body BVHs answer alone (#4665) (#4666)
The pick buffer drew the model's surfaces and edge ids for every still
view to narrow which edges a pick tests. Measured against the BVHs alone
(release, M2 Max, 1400 x 900 view, 600 cursor positions), a pick costs
2-51 us through the BVHs and the tile saved 0-4 us of it, while each pass
cost 0.7-12.5 ms of GPU time per still view, plus gathering its scene on
the window thread, read-backs a frame or two late and repaints to wait for
them (and, before, a copy of every triangle, which made it refuse models
past 6 M triangles). Picks are unchanged: the BVHs give the same answers.

`pick_timing_on_curved_models` gains eight layers of bodies seen from above
(the pick buffer's best case) and `SOLIDCRAFT_BENCH_MESH` placements.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 12:03:37 +00:00
Jackson Oursland f0fd5c10b7 Viewport: keep a face's hover highlight while the pointer moves across it (#4668) (#4669)
The highlight's key hashed the hovered item with its point under the
pointer, so every move across a face built and uploaded its highlight
again: for an imported mesh (one face) all its triangles, every frame.
The key now leaves out a face's and a plane's point, which the highlight
does not draw; an edge's middle stays in (it picks the part lit).

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 12:03:33 +00:00
Jackson Oursland 981d23d1d2 Viewport: weld a large mesh's shared vertices quicker, and ahead of its first frame (#4679) (#4680)
`SharedTris::new` used the standard SipHash hasher and collected each
triangle's corners into a Vec: 451 ms for a 2 M-triangle STL on the window
thread. A quick hasher for the coordinates' bits and a fixed array make it
278 ms, and the background mesher that meshes an opened or inserted
file's bodies now welds their large meshes too (`warm_shared_tris`), so the
first scene that shows them takes the shared vertices ready-made.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 12:03:28 +00:00
Jackson Oursland 01648da72d Ribbon: work out each tab's panel lists once (#4658) (#4659)
`toolbar_custom::panel_lists` is asked for every frame and matched every
registered command against every section by string each time (~337 us a
call for MODEL, several calls a frame). The registry and the ribbon layout
never change while the app runs, so each tab's lists are kept: 0.2 us a call.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 12:03:24 +00:00
Jackson Oursland 3d3dd27439 Viewport: leave out placements of an instanced mesh that are out of view (#4646) (#4647)
* claim: #4616

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Viewport: draw a part placed many times from one copy of its triangles (#4616)

A part placed by many occurrences was drawn, framed and picked from a moved
copy of its mesh per placement: on the CPU (the moved mesh and its scene
bytes) and on the GPU (its vertices uploaded once per placement).

- The model scene draws bodies that share a mesh as GPU instances: the
  shared triangles once, a rigid move and colour per placement
  (`InstancedMesh`, `vs_tri_inst`), in the solid, section and AO passes.
  `GpuScene::merged` expands them, so scene tests see world triangles.
- `ModelBody::mesh_in_place`, `plan_mesh_in_place_at`, `mesh_bounds` and
  `display_identity` give the shared mesh, its placement, the exact box and
  an identity without making the moved copy.
- Display refinement's body ids, Fit/Home bounds, the viewport's body boxes,
  the background mesher and picking use them (picking moves the ray into
  the part's frame and shares one pick index).

25 placements of a 2 M-triangle STL on an M2 Max: orbit 45 -> 37 ms a
frame, footprint 41 -> 16 GB, 24 copies 39 -> 18 s.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Viewport: draw large meshes from shared, indexed vertices (#4637)

An instanced mesh's vertices are shared between its triangles and drawn
indexed (`SharedTris`). A flat-faceted mesh (an STL's facets) shares them by
position alone and lights each fragment with its triangle's own plane
(`vs_tri_inst_flat`, `fs_*_flat`), taken from the fragment's position
relative to the eye so it stays exact at any zoom; its vertex outputs are
kept few, since a tiled GPU stores every vertex's outputs. A body alone is
drawn this way from 50 k triangles. A mesh too large for one buffer falls
back to unshared vertices.

`instanced_scene_gpu_benchmark` (manual, ignored): 25 placements of a 2 M-
triangle STL at 3600 x 2190 px, 4x MSAA, M2 Max: 21.3 ms a frame for the
placements' own triangles, 16.1 ms shared and indexed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Viewport: leave out placements of an instanced mesh that are out of view (#4646)

Each frame `Batches::cull` keeps the placements whose box may be seen
(`placed_box_in_view`: all eight corners past one side of the view in clip
space, so it holds behind the eye too) and writes them again only when
that set changes. The manual benchmark gains `SOLIDCRAFT_BENCH_CLOSE=1`:
close up on the middle of 25 placements of a 2 M-triangle STL (13 in
view), 3600 x 2190 px, 4x MSAA, M2 Max: 28.2 ms a frame for the
placements' own triangles, 17.2 ms instanced and culled.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 04:04:59 +00:00
bherbruck 6c60f1a9ed web-offline-test: hold responses until the test lets them go (#4620)
The slow steps waited on the clock (6 s sleeps, a 1 s timer, a 30 s cap), so on a busy
runner a launch could outlast them and time out at 240 s. The server now holds a response
until the test releases it, and the test waits on events:

- overlapping downloads: build v5's own files are held; files both builds share (index.html,
  names no build changes) are served, since the browser queues a request behind an in-flight
  one for the same URL. The held files are released once v6 has started.
- fail-fast step: the other files wait until the test has seen them asked for.
- update in another tab: the other tab updates once the launch is waiting for build.json, and
  the browser's own sw.js check waits for the same moment.
- hung requests no longer give up after 20 s.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 21:51:01 -04:00
bherbruck 5899e5b6c5 Release 0.19.0 (#4603)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:13:21 -04:00
bherbruck e23a5bf243 Ship 14 more official themes: SolidCraft 95, Platinum 9, Cupertino, phosphor pair, Blueprint, One Dark and a pack of open-source palettes (#4602)
Registers them after the Gruvbox set in the Theme list, adds attribution rows,
and keeps the heading-band ordering check passing by nudging heading/subheading
(and, for High Contrast, dialog) backgrounds.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 21:00:11 -04:00
Nathan Dane 4b23bb3a03 Implement compact workspace layout (#2029) (#4533)
* Vertical history strip: left of the browser, compact rows, hollow-circle marker, collapsed rail hover

* Vertical history strip: seam handle to expand and collapse names, drag to resize with snap

* Vertical history strip: no empty gap below the transport

* Show the history seam arrow only on hover (#2028)

* claim: #2021

* Redesign the Assistant chat panel with shared UI components

* Show Assistant in the shared floating panel shell

* Extend floating Assistant to viewport height and shift navigation

* Dock Assistant before history and match docking preview bounds

* Align Assistant top margin with floating shell cards

* Extract a shared panel drag grip

* Rebuild responsive history sizing and three-way docking

* Share browser and History collapse hover styling

* Show the History title as soon as its text fits

* Animate stacked browser and History expansion and collapse

* Make panel folding responsive and independent of frame delays

* Let folding panel contents shrink continuously to header height

* Use four explicit History transport actions in every layout

* Resize the shared Browser and History column from either card

* claim: shared compact-mode containers (#2029)

* Centralize layout-aware UI containers and enforce their use

* Keep docked Assistant at the outer edge beside tool sheets

* Use shared surface policy for Assistant tool space

* Let tool sheets drag over shell panels while avoiding Assistant

* Update Parameters drag bounds assertion for shell overlap

* Keep dragged tool sheets below ribbon and workspace selector

* Enforce universal UI containers in mandatory repository checks

* Remove legacy report windows and require shared window composition

* Remove retired UI styling and require shared controls

* claim: compact layout preference (#2029)

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-21 20:52:36 -04:00
bherbruck 920398ac53 Bring back the view-change glide; orbit, pan and zoom stay instant (#4596)
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 20:37:19 -04:00
bherbruck 688124937d Chamfer: a cutter that would reach into a nearby wall is not used (#4539)
* Chamfer: a cutter that would reach into a nearby wall is not used

A straight chamfer's cutter reaches several times the chamfer's size out
from its edge, and further on a larger part. On a pin standing in a socket,
that far side lands inside the socket's walls, and the cut took bites out
of them. A cutter whose far side is inside the part is now refused, as one
whose ends are, and the chamfer is built locally instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Robustness goldens: seed 9 re-recorded for the chamfer cutter check

A chamfer in seed 9 was refused because its result changed more of the
part than a chamfer can. With its cutter refused, it now builds locally.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:05:35 -04:00
github-actions[bot] b49557c294 Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci] 2026-10-21 23:54:54 +00:00
cronos dfd58293a6 Navigation: opt-in free orbit and shared Normal to Selection (#1912)
* Navigation: opt-in free orbit and shared Normal to Selection

Preserve the complete reviewed feature, shared camera setting propagation,
rigid pivot behavior and focus/drag regressions from PR #1912.

Consolidate the prior main-integration commits before rebasing, so their
merge-resolution fixes remain in the linear feature history.

* docs: move free orbit help beside viewport controls

* Merge main into fix/solidworks-free-orbit

Camera animation is gone on main, and with it the orbit snap spring: the Shift
snap keeps its unsnapped orbit in orbit_free, which the free-orbit checks now read.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 23:29:29 +00:00
raykholo ba4f7864ff Export Current View as DXF includes shown sketches (#3708) (#3780)
* claim: #3708

* Export Current View as DXF includes shown sketches (#3708)

The view export refused a design with only a sketch and left shown
sketches out when bodies were present. It now draws the sketches the
viewport shows (profile curves only: no construction geometry, points,
dimensions or constraints) on a SKETCH layer, one entity per curve.
Finished sketches are hidden behind bodies through the body-edge
occlusion; the active sketch is drawn on top, as in the viewport.
Sketch points use the same projection, budgets and cancellation.

The export is refused only when there is neither a visible body nor a
shown sketch curve; the dialog and the command share that check
(cmd::view_dxf_exportable).

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 23:09:57 +00:00
Daniel b289dc2421 Sketch Dimension: name = value defines a user parameter (#2916) (#3638)
* Sketch Dimension: `name = value` defines a user parameter (#2916)

In a dimension's value (the D tool's box, and `sketch.dimension`'s `value`
from scripts, the CLI, MCP and the control channel), `name = expression`
adds the user parameter `name` with that expression and dimensions with
`name`: typing `width = 40` on a line gives a 40 mm user parameter `width`
and a dimension whose expression is `width`. A bare number is read in the
design's unit, as a bare dimension value is; an angle's parameter is in
degrees. The expression may be anything the parameter editor takes.

It is one command: one undo removes the parameter and the dimension
together, and a dimension the sketch refuses leaves no parameter behind.
A taken name is refused with a hint (type the name to use it, or change
it in Parameters), a reserved name with the parameter editor's message,
and a left side that is not a name (`2 = 3`) still fails to parse as
before. A driven dimension defines nothing. The reply carries `defined`,
and the status line names the parameter.

The splitter lives in the doc crate (`expr::definition`) so other value
inputs can take the same form later; editing an existing dimension, the
inline shape boxes and feature dialogs are follow-ups.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Sketch Dimension: say why a name is refused, in the command's own words (#2916)

The dimension editor showed a refused definition raw, in the box and the
status line: "invalid parameters for `sketch.dimension`: value: `mm` is a
unit, constant or function name". It now reads "Sketch Dimension: `mm` is
a unit name, so it cannot be a parameter name": the editor passes the
command's errors through `friendly_error` like every other command, and
drops the `value:` field tag, since the box is the value. The taken-name
hint reads the same way: "Sketch Dimension: there is already a parameter
`w`: type `w` to use it, or change it in Parameters".

A reserved name says what kind of word it is and what that means
(`param_name_problem`, shared with the Parameters dialog): a unit name, a
function name, or a constant, "so it cannot be a parameter name";
`expr::reserved_kind` tells them apart.

`friendly_error` translates the command label with the interface language
(the engine's sentence stays English), so the prefix reads "Wymiar szkicu:"
in Polish.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Sketch Dimension test: keep the third line off the ribbon (#2916)

On current `main` the ribbon floats over the top of the canvas, and the
test's third line at sketch y = 50 projected under it: the click opened
Parameters instead of picking the line. The line now goes below the
first one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-21 22:57:22 +00:00
raykholo 2daf22923c Extrude preview: a cut from an empty root is no longer refused (#3085) (#3363)
* claim: #3085

* Extrude preview: a cut from an empty root is not refused (#3085)

A cut or intersect sketched where the active component has no body of
its own (the root of an assembly, or of a design whose part was
imported into a component) takes part in every body (#1234): the
command makes the cut. The Extrude preview checked the targets itself,
looked only in the active component, and showed "there is no body to
cut or intersect" beside a tool going through the part. Pressing OK
still cut, so the dialog said one thing and the model another.

The rule now lives in one place, Session::cut_takes_every_body, which
both the command and the preview ask, so they cannot disagree again.

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 22:37:31 +00:00
bherbruck 2fdc105d87 IGES import: separate lumps of one part are separate solids (#3928) (#3972)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 22:26:43 +00:00
DuBearte 95b23f28d6 Rename box: Tab (and leaving the box) keeps the new name (#3313) (#3336)
* claim: #3313

* fix(ui): the Rename box keeps the new name when it loses focus (#3313)

Only Enter committed a rename: Tab (and anything else that moved focus away)
dropped the typed name. Leaving the box now keeps it, as a click elsewhere
already did; Escape still cancels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-21 22:07:38 +00:00
DuBearte 14b27f323a Dialogs picking a plane show the origin planes in the view (#2581) (#2618)
* claim: #2581

* fix(view): dialogs picking a plane show the origin planes (#2581)

Create Sketch showed the origin planes while it picked its plane; now any dialog
whose active input takes origin planes does (Mirror's plane, Offset Plane,
Midplane, the construct tools), so the plane can be picked in the view.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-21 21:49:18 +00:00
takuto-NA 4ab3a85323 Bug report: clarify the form and retain margins on resize (#2244) (#2246)
* claim: #2244

* fix(ui): guide bug reports with a bounded, labelled form

* fix(ui): export bug reports after processing current-frame edits

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 21:49:11 +00:00
DuBearte c54b711345 CLI: --help prints usage to stdout and exits 0 (#1818) (#1828)
* claim: #1818

* claim: #1818

* fix(cli): --help, -h and help print usage to stdout and exit 0 (#1818)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-21 20:39:30 +00:00
Mauznemo 84ec512d7a fix: midpoint tool rejects two lines (#3836) 2026-10-21 20:36:29 +00:00
github-actions[bot] 17ece2edc0 Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci] 2026-10-21 20:28:26 +00:00
Nathan Dane 63f210b49f Restyle the vertical history sidebar (#2028) (#3953)
* Vertical history strip: left of the browser, compact rows, hollow-circle marker, collapsed rail hover

* Vertical history strip: seam handle to expand and collapse names, drag to resize with snap

* Vertical history strip: no empty gap below the transport

* Show the history seam arrow only on hover (#2028)

* Extract a shared panel drag grip

* Rebuild responsive history sizing and three-way docking

* Share browser and History collapse hover styling

* Show the History title as soon as its text fits

* Animate stacked browser and History expansion and collapse

* Make panel folding responsive and independent of frame delays

* Let folding panel contents shrink continuously to header height

* Use four explicit History transport actions in every layout

* Resize the shared Browser and History column from either card
2026-10-21 18:56:55 +00:00
raykholo 9acff62422 Sketch trim and extend refuse an offset's ends instead of undoing silently (#3918)
An offset's output curves have their ends recomputed from the source
curves on every solve. Trimming a piece off one reported success and left
the curve whole, because the refresh pulled the new end straight back.
Extending one was refused as "connected to other geometry", which does not
say why. Both now refuse with the reason and the way out: edit the source
curves, or break the offset association first. Trimming away a whole
offset curve, which removes it from its association, still works.
2026-10-21 18:56:53 +00:00
Tyler Chiriboga 915ee8a178 Fuzzer: a chamfer may add material, as a fillet may (#3868)
The fuzzer chamfers randomly picked edges and expected every chamfer to
shrink the body, so a chamfer of a concave edge, which fills the corner,
was reported as a wrong volume: 53 of the 6,000 designs of a run (seed
7100465: a box, a shell, a chamfer of an inside edge). Fillets already
expect any volume change for the same reason; chamfers now do too.

The regression test checks both directions on a shelled 20 mm box (an
inside-edge chamfer of 1 mm adds 8 mm³, an outside one takes 10 mm³
away) and that seed 7100465 is no longer a volume finding.

Co-authored-by: Tyler Chiriboga <18270301+Tyler-Chiriboga@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:56:51 +00:00
phanoob ac1768e08b Center circle through a snapped point stays on it (#3099) (#3277)
* claim: #3099

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Centre circle: a second click snapped to a point keeps the circle on it (#3099)

The tool sent the centre's snap reference but only a radius for the
second click, so a circle drawn from a line's midpoint through its end
was left free to change size. sketch.circle.center takes through: a point
reference, kept on the circle (PointOnCurve), and the tool passes it when
its second click snapped to a point.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Phanoob <phanoob@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:56:43 +00:00
phanoob e1daf0a8b6 Clicking a parameter suggestion inserts it (#3257) (#3260)
* claim: #3257

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Clicking a parameter suggestion inserts it (#3257)

A press on a suggestion took the focus from the expression box before
the list could see the click: the list stopped drawing, the click never
landed, and the box gave up what was typed. The rows are kept from the
frame before; a press on one picks it and gives the box its focus back
(again on the release, which the box takes as a click away). The rows
are no longer focusable. The completion tests' frames now drop their
texture updates on purpose, so they run where egui checks that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Phanoob <phanoob@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:56:41 +00:00
phanoob 57c3e94df6 Parameters: a new parameter's expression starts blank, and Tab selects a field (#2919) (#3096)
* claim: #2919

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Parameters: a new parameter's expression starts blank, and Tab selects a box of the add row (#2919)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Parameters tests: the add row's blank expression in the manage dialog tests (#2919)

The two inventory tests typed a name and pressed Enter, counting on the
10 mm the expression used to start with; they now type it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Phanoob <phanoob@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:56:39 +00:00
amolinari-autotex 5e05d925e0 Tapped Hole: keep Custom selection and edit nominal diameter (#2440) (#2441)
* claim: #2440

* feat: choose ISO metric tapped-hole sizes and derive the drill diameter

* fix: label the tap drill and hide the unused diameter editor

* test: cover tapped hole catalogue and drill sizing

* fix: omit coarse metric pitch from generated designations

* Keep custom tapped-hole nominal diameter and pitch editable

Preserve Custom as dialog state, derive the existing metric designation from numeric inputs, and refuse invalid drafts without applying the last valid size. Retain manual text editing, physical tap-drill geometry and saved-design compatibility.

* fix(i18n): register custom tapped hole labels in source catalogs

* Adapt tapped-hole Custom fields to the current form kit

* fix: preserve tapped designation focus with dynamic custom rows
2026-10-21 18:56:38 +00:00
NOWN-DEV 50313d147f solid.mirror: copies join their source's component; follow the saved placement (#1938)
* solid.mirror: copies join their source's component; follow the saved placement

Three follow-ups to mirroring bodies of placed components:

- A copy joins the component of the body it mirrors (or, when that
  component is placed more than once and a copy there would show at
  every placement, the innermost component around it placed once), so
  checks that group bodies by component see it beside its source.
- The saved placement path, not the body's name, says which component
  a mirrored body is in: inserting the same file again reuses the body
  names, and the copies of the first insert vanished.
- The mirror is evaluated again only when its own placements or its
  component's frame change, not on every occurrence move: each later
  command paid for one more mirror evaluation.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* solid.mirror: a saved placement must still lead to the body's component

A stale or edited placement path that leads to another component's
occurrence mirrored the body silently where that other component is
placed. The mirror now reports that the placement no longer exists.

The body's component comes from the evaluated body where a transfer
recorded it, else from the component its name is recorded in or its
feature's. When a later insert of the same file has taken over the name
(the recorded component lies outside the components the body's own
feature made), the placement has to lie inside those components instead,
so mirrors of the first insert keep working.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* solid.mirror: the copies' component change is a pending format change

v26 shipped in 0.15.0, so its mirror-placements note stays as released;
the change of where a Mirror with placements puts its copies is noted in
a new pending file instead.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-21 18:56:36 +00:00
bherbruck 728f753b80 Release 0.18.4 (#4362)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:30:18 -04:00
The Nomad f22a026c27 3D mouse: choose which motion drives each axis (#2782)
* 3D mouse calibration: find each axis from a guided recording (#1463)

A recording of the cap at rest and moved along each axis both ways gives which raw axis is which,
which way it runs, where it rests, how much it wanders at rest and how far it goes each way.
Hands aren't exact: on our SpaceMouse Pro, sliding the cap right moved Ry further than X. So each
raw axis is picked by the one-to-one assignment that best explains all six motions together, each
raw axis scored against its own range so a wider one doesn't win through cross-talk. Each
direction is scaled on its own, so a device that goes further one way still reaches full scale
both ways; a direction under a twelfth of the furthest axis counts as not having responded.

A small state machine walks the user through it and moves on by itself once the cap has been held
out for 0.4 s in one go and let go. "Held out" is the least the solver accepts, twice an axis's own
rest wander, so whatever the solver would accept can be recorded and a noisy axis doesn't hold up a
quiet one.

A calibration also carries a dead zone the user sets, a share of full deflection taken off the
bottom of the travel, with full deflection still reading full scale.

Serde support is behind an optional feature, so the crate still needs nothing by default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3D mouse calibration in Preferences, saved per device (#1463)

Preferences > Navigation gets a 3D mouse calibration row: whether the device being read is
calibrated, Calibrate... and Forget calibration. The window asks for each motion in turn and
saves what it found under the device (hid, web, spacenavd or 3dxware, with vendor and product ids), so
switching devices or turning spacenavd on doesn't apply the wrong mapping. A calibration is saved
for the device it started with, and ends if another one is read instead.

navigate_frame puts the reading through the device's calibration before the driver mapping, so
the per-axis Invert and speed and everything after them are unchanged. While the window is open
the reading goes to it and the view stays put; it closes with Preferences. Restore Defaults on
the Navigation page keeps calibrations, since they describe the device.

The web app hands over the gamepad's readings as they are, so calibration sees where the cap
rests; navigate_frame now leaves a reading inside the dead zone alone after calibration, so a
cap resting just off zero still doesn't keep redrawing. Whether the cap is moving is asked of the
calibrated reading there, in the web page's idle waker and in both wheel filters. Those run before
the frame, so they keep the last frame's calibration with its device and use it only for that
device. On the desktop, an axis with nothing fresh (timed out, or not reported since the device
connected) reads as the device's calibrated rest, so a device resting off zero doesn't drift.

A saved calibration that can't be read or used (a hand edit) is dropped on its own when the
preferences load, with a log line.

The window shows the device's raw readings live and has a dead zone slider, a share of full
travel on top of the measured noise; it's saved with the calibration and adjustable in Preferences
afterwards.

New strings are in all 14 catalogs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3D mouse: choose which motion drives each axis (#1463)

After calibrating, which motion of the cap should drive which view axis is partly a matter of
taste: rolling and twisting swapped, say, or Y and Z. Each axis row in Preferences > Navigation
now has a "driven by" choice next to Invert and speed. Picking a motion another axis uses swaps
the two, so each motion always drives exactly one axis, and Reset axes puts them back.

The choice applies after the device's calibration and before the driver mapping, so Invert and
speed stay with the view axis rather than moving with the motion. It's a preference, not part
of a device's calibration, and stays in force when recalibrating; the calibration window says
so while one is set. A saved assignment that doesn't drive each axis once loads as the default.

New strings are in all 14 catalogs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3D mouse: axis choice review follow-ups (#1463)

- An axis's speed, like its Invert, stays with the view axis when a motion is reassigned; tested.
- Calibrating records the device's own reading, before the axis choice, so a saved swap isn't
  baked into a new calibration and then applied twice; tested through a whole calibration.
- A settings file without the axis choice, or with one of the wrong length, loads with the
  default and keeps every other setting; tested.
- Each axis row says "driven by" in sight before its choice, which otherwise only explained
  itself on hover (no help on a touchscreen or the web).
- "Reset axes" is now "Reset axis assignment", so it doesn't read as forgetting the
  calibration just below it; the "driven by" tip reads more clearly in English.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3D mouse: name axis rows by what they do, and motions without a device in mind (#1463)

The axis rows and the "driven by" choices showed the same names, "Push left / right" and so
on, which read as one thing mapped to itself, and they described a cap that springs back to
centre, which a trackball or a knob that stays put isn't.

Each row is now named for what it does to the view: Pan left / right, Zoom, Pan up / down,
Orbit up / down, Orbit left / right or Roll, or Off. The name comes from the driver mapping
navigation used last frame, so it stays right where the mapping swaps axes (object mode swaps
the last two). Before navigation has run there's no mapping to read, and a row is named for
its own motion instead of a guessed effect. The choices are the device's calibrated motions:
Translation X / Y / Z and Rotation X / Y / Z, under a header row, and the calibration window
names a motion that barely moved the same way. This renames #1814's existing rows, which had
no translations; every new name is in all 14 catalogs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3D mouse: rows never fall back to a motion name (#1463)

Without a driver mapping, a row was named for its own calibrated motion,
which is also what its dropdown lists. It now takes the default mapping's
view axis, so the two sides never share a name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3D mouse: grey out Invert and speed on rows the driver turns off (#1463)

A row the driver profile turns off says so on hover, and its Invert and
speed are greyed out, since they do nothing there. Its motion choice stays
live so a motion can still be swapped out. The column header is shorter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3D mouse: unmapped rows say Axis 1-6, and the off-row tip explains the swap (#1463)

Without a driver mapping, a row is now just "Axis n" rather than the
default mapping's view axis, which would be a guess there. The tip on a row
the driver turns off says that a motion swapped into it stops moving the
view, and a test swaps one in and back out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3D mouse calibration: a device that stops short of rest after a release (#2778)

Through spacenavd, a relative-axis device like the Space Navigator never
reports its return to zero, so the reading stays at its last small report
after every release. The wizard never saw the cap let go, and once
calibrated, the stuck value crept the view.

A reading that stays exactly the same, close to rest, now counts as let go,
and where the cap settled after each step goes into the calibration: such a
device rests at the middle of where it settled, with a dead zone out to the
furthest of them. A device that comes back to rest calibrates as before.

* 3D mouse calibration: a steady hold through spacenavd stays a hold (#2778)

* 3D mouse calibration: stopping short of rest only counts from spacenavd and 3DxWare (#2778)

Recentring on where the cap settled kept the rest stage's wander radius but
not its offset, so a rest extreme could fall outside the dead zone; the
radius now grows by how far the centre moved. A reading that stops changing
close to rest only ends a step from a source that sends nothing at rest, so
HID and web devices calibrate exactly as before.

* 3D mouse calibration: settling off-centre counts on any device (#2778)

A drifting stick settles off its rest after a release just as spacenavd's
stuck reading does, but it keeps reporting, noisily. A step now ends once
the reading stays steady, each raw axis within twice its rest wander (so
exactly still from a silent source), close to rest, on any device. The
dead zone takes that wander on top of the furthest settle point.

* 3D mouse calibration: steadiness by the run's spread, settled on the step's own axis (#2778)

An axis that read flat at rest may dither once off-centre, so steady
readings may spread at least 2 units and a 50th of the furthest any step
went. Steadiness is the spread of the whole run, not distance from its
first reading. Close to rest is judged on the axis that set the step's
peak, since a device's axes may have different ranges, and the settle point
is the middle of the steady run.

* 3D mouse calibration: readings close to rest never count as held (#2778)

* 3D mouse calibration: the dead zone reaches every reading of each release (#2778)

Saving one reading of each release and padding it by the rest wander could
miss a reading seen in the same steady run. Each release now keeps the whole
run, and the dead zone is centred on and reaches everything read at rest
and after the releases.

* 3D mouse calibration: settle for half a second before a step counts as let go (#2778)

A hand held still close to rest is less likely to end a step early; a
device that comes back to rest never waits for it.

* 3D mouse calibration: show the dead zones as a game controller tester would (#2778)

Once calibrated, the window, and Preferences under the Dead zone slider,
show three pads: slide, press and twist, and tilt. Each has a circle for
the full travel, the dead zone shaded inside it with each direction its
own size and the user's dead zone on top, and a dot where the cap is now,
lit once the view moves.

* 3D mouse calibration: settle through cross-talk, per-axis steadiness floor (#2778)

The axis that went furthest in a step needn't be the one asked for:
cross-talk can move another raw axis further. A step now counts as let go
once the furthest axis is at least halfway back and some axis that moved
about as far is within a quarter of its own peak; a hand still holding the
push keeps the furthest axis out. The steadiness floor follows each axis's
own reach, and each stage's steadiness starts with its own readings.

* 3D mouse calibration: steady readings may dither ±2 on any axis (#2778)

* space-mouse: make the settle check's near band match the steadiness spread, test the 4-unit floor

near() measured distance from the settled run's middle against the whole tolerance,
so it accepted twice the band the steadiness check does. Use half. Add a short-range
test where only the .max(4.0) floor lets a +-2 dither settle, and one where dither
the spread check rejects is also not skipped as untouched.

* space-mouse: settle on the middle of the steady run so a lopsided dither isnt movement

near() allows half the steadiness spread around the settled point, but the point was the run mean. A dither that is mostly one value with an occasional far one (10, 10, 10, 14) is steady, yet the far reading sat 3 from the mean and started a hold in the next step. Use the middle of the run range instead.

Adds a lopsided-dither test and a test pinning the documented cross-talk limit ([100,300] then [5,90] stays open).

* Judge an untouched cap by the settled run's spread, not a rounded midpoint

Rounding the midpoint to i16 could leave one end of an odd-width run more
than half the tolerance away, so the cap read as touched while sitting at
the end of the run it settled on. Keep the run itself and ask whether one
more reading keeps its spread within tolerance.

* space-mouse: pin the wider skip band of a narrow settled run and a single-reading settle

* space-mouse: keep a step held while a lesser axis stays out as the larger one relaxes

Settling needed one axis furthest and returned, and one axis about as far and settled, which a single relaxed cross-talk axis satisfied alone. Every axis that moved at least half as far as the furthest now has to be back by RETURNED_SHARE of its own peak too.

* 3D mouse: the calibration tests run in a focused window, as the focus check now requires

* 3D mouse: the axis tests run in a focused window, as the focus check now requires

* 3D mouse: the calibrated-rest test runs in a focused window, as the focus check now requires

* Settle a spacenavd reading that is nearly at rest (#3523)

spacenavd sends a record only when the values change and nothing when the cap springs back, so the last small leftover of a release was held forever and the view kept drifting.

A held reading now reads as rest once every axis is within 150 raw units of the device rest and no record has arrived for 0.5 s. A firmer push stays as long as it lasts, and so does a small value next to a large one. 150 is the smallest round number above the worst leftover in the issue (129 on one axis).

* Choose which 3D mouse driver to read on Linux

When both spacenavd and the device itself (hidraw) are there, Preferences now has a "3D mouse driver" choice: Automatic, spacenavd or Direct. Automatic is what it always was, spacenavd when it runs and the device otherwise, so nobody's setup changes. If the chosen one isn't available the other is read and the log says so once. Each driver keeps its own calibration, as before.

To switch without a restart both reads now wake up when idle and look at the choice: spacenavd with a 250 ms read timeout, hidraw opened non-blocking and polled every 5 ms when it has nothing. A record that arrives is read as before, and one split across a timeout is kept. The reader also publishes which of the two exist, which is what shows the combo box; with only one it says which one is in use.

* Build the 3D mouse row header from existing labels

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-21 11:29:33 -04:00
bherbruck 4b37c046e4 Sketch tools come back in the variant used last (#4308)
* Sketch tools come back in the variant used last, and it is kept in preferences

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Scenario: a new design keeps the remembered sketch variant

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 11:22:04 -04:00
bherbruck 54072b7014 Start page: sample pictures and the existence check of recent files run off the window thread (#4349)
A sample picked before its picture arrives still opens; the language test waits for the file check.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 11:20:12 -04:00
bherbruck cb3cabeda1 Remove camera animation: every view change and orbit applies at once (#4296)
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 11:19:40 -04:00
bherbruck 775e5155bb Main green: revert the start page change (#4295) and fix the format 28 test (#4335)
* Revert "Start page: sample pictures and the existence check of recent files run off the window thread (#4295)"

This reverts commit d9059d8da5.

* Test: an older design is marked format 28 now that 0.18.3 folded face pieces into 29

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 10:52:08 -04:00
bherbruck e570c0b4eb Windows: a restored window that is out of reach is fitted to the monitor's work area (#4299)
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 09:36:36 -04:00
bherbruck d9059d8da5 Start page: sample pictures and the existence check of recent files run off the window thread (#4295)
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 09:36:27 -04:00
bherbruck 463cea0615 Windows: ask for the foreground once, when the first frame is on screen (#4284)
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 09:17:10 -04:00
bherbruck 0225e4d2a5 Startup: the first frame no longer waits for the system CJK/Hangul/Thai font search; SOLIDCRAFT_PROFILE_STARTUP prints startup steps (#4283)
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 09:17:02 -04:00
bherbruck 034aa60d47 Release 0.18.3 (#4270)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 08:46:14 -04:00
bherbruck 971b223246 Thread: turn a shaft thicker than a modelled thread down to its major diameter (#4256)
* Thread: turn a shaft thicker than a modelled thread down to its major diameter

A modelled external thread on a shaft thicker than the thread no longer refuses: the shaft
is turned down to the major diameter over the threaded length (run past a free end of the
face), then the thread is cut as before. The turned face is named F<n>:turned and the step
it leaves F<n>:shoulder:start/end. A shaft the part goes on around is refused rather than
cut into, shafts under the minor diameter and cosmetic threads on thicker shafts are still
refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Thread preview test: an 8 mm shaft is turned down for M6; M10 still refused

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 08:27:18 -04:00
bherbruck 8c861f9f46 Face names: pieces of a split face keep their numbers across evaluations (#3902) (#3991)
* Face names: pieces of a split face keep their numbers across evaluations (#3902)

Pieces sharing a name were numbered in the kernel's order, or by the index of the operand face
they came from. A boolean can list its result's faces in another order on the next evaluation,
and an operand made by one (a closed sweep tool) carries that order into the indices, so the
#1/#2 of a split face could swap between two evaluations of the same design. Pieces that
nothing else tells apart are now numbered along the spread of their centres (as edge pieces
already were), and pieces from different faces of one operand by kind, then by place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Face names: older designs keep the piece numbers they were saved with

Numbering pieces by place renumbers split faces in many existing designs, and a stored name
matched exactly would then land on the other piece. Features of a design saved before this
change are marked `kernel_piece_order` by a pending format step and number their pieces as
before; features added afterwards number by place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 08:08:08 -04:00
bherbruck e04a016e7c Align takes planes and the origin (#4248)
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 08:06:32 -04:00
Irakli Kourtis 93a2922c5e Display plans and the model cache for large STEP assemblies (#2814) (#4021)
* STEP import: mesh band faces that wrap around a closed surface correctly

Fixes two related bugs that caused SolidCraft to fail to mesh a face wrapping
all the way around a closed, non-periodic surface (a revolved ridge band, or a
tube-shaped B-spline surface): a missing face, a folded/self-overlapping
triangulation, or a hole in the rendered body.

1. Ring edges that run slightly off their surface. STEP files write a surface
   and the curves bounding it to the file's own declared uncertainty, so a
   ring edge can legitimately sit a few micrometres off the surface it
   bounds. The code finding where a ring edge crosses the planned split line
   compared against the edge's raw (off-surface) point using a hardcoded
   tolerance far stricter than the file's own precision, rejecting a valid
   cut. Now matches against the point on the surface directly below the ring
   crossing, respecting the file's declared tolerance.

2. Closed B-spline surfaces, their slits and their slivers. truck only treats
   the rotation of a revolved surface as periodic; a face on a B-spline
   surface whose control net closes on itself (a tube) wasn't recognized as
   wrapping around at all. This produced a folded face, a hole (a boundary
   loop landing a full period away from where it should cut), and one
   near-zero-width sliver face costing the entire body its tessellation
   plan. Adds periodicity detection for closed B-spline surfaces, period-
   aware hole-loop alignment, dropping of zero-width slit boundaries, and
   isolating a too-thin-to-triangulate face so it no longer takes the rest
   of the body down with it. The GPU shader's periodic wrap mirrors the CPU
   math so the two don't drift apart.

Regression tests: band_faces_split_when_ring_edges_run_off_their_surface,
band_face_with_a_zero_width_slit_is_still_meshed,
a_sliver_face_too_thin_to_triangulate_does_not_cost_the_body_its_plan.
Ablation-tested: disabling either mechanism in (2) reproduces its target
test's original failure message while leaving the other test unaffected.

* STEP import: mesh the band's split faces, not just split them

jyviko asked whether a band wall missing from the interactive app's render
was a shading issue or a genuinely missing face. It was missing: the ring-
edge split fix made the band's topology correct, but plan_face still
produced zero triangles for the two new sub-faces.

Two causes, both in plan_face (crates/kernel/src/tess/mod.rs):

1. The tolerance for how far an imported boundary may lie off its surface
   was tied to the requested mesh's chord tolerance instead of to the
   body's own size. A finer display tolerance could then reject a boundary
   a coarser one had just accepted, so the face would drop out exactly when
   zooming in on it.

2. Near a sharp corner of this flat-panelled band, several boundary points
   that are legitimately a few micrometres apart (within the STEP file's
   own declared precision) all projected onto the same surface point. The
   boundary loop then repeated a point in (u, v), which a triangulator
   can't follow, so the face's plan failed outright. One face without a
   plan costs the whole body its plan, and the fallback mesher can't
   handle a closed B-spline surface either, leaving both halves of the
   split band with no triangles at all: present in the topology, invisible
   in every renderer, and absent from the body's volume.

Fix: scale the off-surface tolerance to the body's own extent (new
Rules::extent), and collapse a run of boundary nodes that land on the same
surface point to one (drop_collapsed) before triangulating.

Regression test additions to band_faces_split_when_ring_edges_run_off_their_surface:
the body's volume now matches the analytic pentagon prism (was a third of
it - only the top cap - before this fix); display_plan leaves no face
without triangles from coarse to fine tolerance; and the actual tolerance
and facet angle the viewport draws with gives the split band its full
lateral area on both halves.

Confirmed on review_band_big.step, the fixture behind the review screenshots:
volume's relative error against the analytic pentagon prism drops from
1.4e-4 to 4.1e-6, and the band wall now occludes the floor grid behind it
in a snapshot the same way the top and bottom caps always did.

Full kernel suite: 739 tests, 0 failed.

* STEP import: hold an imported boundary to the file's own tolerance, not the part's size

jyviko: the off-surface acceptance added in the last commit
(rules.extent * OFF_SURFACE) scaled with the part, so a 2 m part
silently accepted a boundary 2 mm off its surface while a 20 mm copy
of the same part accepted only 0.02 mm. A STEP file's own declared
uncertainty does not grow with the part, so the bound shouldn't either.

Body gains boundary_tol: Option<f64>, Some(file's declared tolerance)
for a body read from STEP (crate::step_in::Units::tol, already used
by the ring-split fix in split.rs), None for anything this kernel
built. It's set before the import's own unmeshed-face check runs, and
carried over by the two face-split operations that re-cut an imported
surface (splitface.rs, outline.rs), since the file's uncertainty still
describes those boundaries afterward.

plan_face's accept now reads: the file's own tolerance when the body
declares one, or the old chord-tolerance/extent bound otherwise. The
old bound's tol * 10.0 term had to go too, not just the extent term:
at a 2 m part's typical display tolerance that term alone is ~20 mm,
nearly ten times the extent bound jyviko objected to, so keeping it
would have left the exact 2 m/2 mm example still silently accepted.

Two new tests in step_in/tests.rs:
- band_faces_split_when_a_scaled_copy_runs_off_its_surface: the same
  band fixture at a realistic 2 m scale with the same absolute slop
  and declared uncertainty as the small one, meshes the same way at
  every chord tolerance from coarse to fine.
- off_surface_budget_follows_the_file_not_the_part_size: the actual
  regression. The same off-surface error is accepted or refused the
  same way whether the part is 20 mm or 2 m; a 2 mm error against a
  1 µm declared uncertainty is refused at both sizes now (accepted at
  2 m under the old bound, refused at 20 mm - that asymmetry was the
  bug). Checked at both a coarse and a fine display tolerance.

Known gap, not fixed here: a STEP-imported body saved into a
.solidcraft design and reopened loses boundary_tol (native.rs's
decoder has no field to read it from) and falls back to the old
extent-based bound on reload. Today's design format has no concept of
this value at all; persisting it is a real format change, out of
scope for this fix.

Full kernel suite: 776 tests, 0 failed. clippy -D warnings clean.

* Display plans for STEP threads and pointed ends (#3847)

Three faces cost a threaded or pointed STEP part its whole display plan, so it
was drawn from the reference mesh: cracked, faceted, and never refined up close.

- A loop through a pole (a cone's apex on the boundary) seemed to wind around
  the surface: the jump at the apex, unwrapped to the nearest copy, was
  arbitrary. The loop is turned to end where it leaves the pole, so the step
  that closes it is an ordinary one (`seam::close_through_pole`).
- Projection onto a spline with hundreds of spans (a helical flank) restarted
  from a 16 x 16 grid and settled on another turn; it now starts from two
  points per span, capped.
- Sag refinement no longer inserts a point exactly on a boundary side, which
  split the side and failed the face.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* tess: a refused face's edges are divided finer and it is planned again (#2814)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* tess: sliver faces plan empty; a face no method plans is filled flat from its nodes (#2814)

A face whose boundary runs out and back along itself (two edges between the
same vertices, a rounding apart, as STEP files write slivers) encloses nothing
and is planned with no triangles. A face still refused after its edges are
divided finer (a cusp, a sliver whose sides sag across each other, a small cap
at a pole) is filled flat from its shared boundary nodes, so the rest of the
body keeps its display plan instead of falling back to the reference mesh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* display mesh tests: a rim the chord tolerance leaves a line is divided again (#2814)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* portable B-rep: up to 8192 control points along a spline direction (#2814)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* solidcraft-cli: --debug logs what the engine reports to stderr

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* STEP import: a spline written over a tiny parameter range is read over one (#2814)

Some writers parameterize splines over a few millionths (a humanoid robot's
edges span 5.4e-6). The kernel tells parameters apart only to 1e-6, so over
such a range it evaluated the curve's end wrongly, and trimming an edge to its
vertices dropped a control point: the bodies could not be stored exactly and
the whole model cache was left out of the design. The knots are now stretched
to span one; the curve is the same and edges are trimmed by their vertices, so
nothing read from the file refers to the old parameters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* model cache: a body keeps its file's declared accuracy (#2814)

A body restored from the cache dropped the accuracy the file it was read from
declares, so the planner held its boundaries to the kernel's own bound and a
reopened design drew differently from the import. It is stored with the body's
runtime values, and only when there is one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* tess: a boundary within a hundredth of its body lies on its surface, at every quality (#2814)

#1721 held a body read from a file to the accuracy the file declares. A writer
declares what it aims for, not a bound its edges keep: a 251-body robot
assembly declaring 0.01 µm has boundaries 10 to 30 µm off their surfaces, and
95 of its bodies lost their display plans. The bound is a hundredth of the
body again, what Normal quality always allowed, and now the same at every
quality, so zooming in still never drops a face (#1721's point). A file
declaring more raises it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* STEP import: keep faces whose edge vertices sit slightly off their curves

Some exporters write an edge's vertex beside the end of its curve, further off
than the accuracy the file declares (a surface export declares
0.01 mm and puts a vertex 0.19 mm off two spline ends). The reader refused
such edges and left the face, and with single-face bodies the body, out.

A vertex may now sit up to the file's tolerance or 1/1000 of the model's size
(the diagonal around its VERTEX_POINTs), whichever is larger, never more than
the existing 1 mm ceiling. Spline and open-conic edges have the end control
point moved onto the vertex, so every such edge meets its vertices exactly.
The file's declared accuracy still governs closed-curve, seam and circle
checks.

* tess, io: review clean-ups (#2814)

- A sliver face is one whose loops are all slit: drop_slits finds it in the
  retry after a failed plan, so the pre-check on every face (folded) is gone.
- The flat fill uses cdt::triangulate (a lenient fill that leaves out sides
  crossing one already kept) and its flood-fill inside test, and the plane
  from geom::Plane.
- Refinement only builds its indexes when a face was refused, takes the
  faces to plan again from edge_faces, and re-samples edges on the pool;
  Rules::finer for the finer rules.
- One PackageWriter::fits for the model cache, the preview and the thumbnail.
- The band split's ring tolerance lives in split.rs; comments say what the
  off-surface bound is now.
- The CLI logger prints Level::as_str().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

---------

Co-authored-by: David Lackovic <lackovicdavid1234@gmail.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Ray <raykholo@users.noreply.github.com>
2026-10-21 10:37:12 +00:00
Irakli Kourtis ff8b09c225 Kernel paused: label new kernel issues, PRs and discussions for a maintainer to answer (#3994)
While a new geometry kernel is evaluated, a workflow labels new issues, pull
requests and discussions about kernel work kernel-paused, so a maintainer finds
them and answers in person. A pull request counts when it changes
crates/kernel/ or vendor/truck*, anything else when its title names a kernel
operation. Crash and data-loss reports and anyone who can push are left alone.
CONTRIBUTING.md says so.


Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 10:03:47 +00:00
raykholo 975a08c992 ui-egui tests: hold OK's evaluation where a test checks it is computing (#4040)
Two OK-off-the-window-thread tests checked that OK was computing right
after Enter without holding the evaluation. The preview's evaluation is in
the cache, so the worker was usually in before OK's zero wait was over and
OK applied at once: cancel_while_computing_applies_nothing failed in most
runs (5 of 5 in a debug build, 4 of 5 with the ci profile). Both now hold
the evaluation with the test gate, as their neighbour does, and let it go
once the computing state is checked.
2026-10-21 09:38:14 +00:00
bherbruck 55587f4ac4 View keys: a held arrow turns the view at a steady rate, one tween at most (#4090)
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 04:22:26 -04:00
bherbruck e94900933d IGES import: close small gaps between trim pieces (#3845) (#3846)
Consecutive pieces of a trim loop can miss each other by micrometres to a tenth of a
millimetre, past the file's resolution, so the loop did not close and the face was
dropped. Faces are now read first and sewn after: each small gap's ends, and the other
piece ends at them, move to one vertex. Gaps over 0.1 mm or not small next to their
pieces stay open; the sewing tolerance stays the file's. The stated accuracy covers how
far a vertex moved, so the STEP reader keeps those edges.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 02:20:02 +00:00
SS-EN 776541b081 Agent commands compute off the window thread, so the window stays live (opt-in) (#1989) (#3340)
An agent's design changes over the control channel (engine.execute, engine.batch) are evaluated
ahead on a copy of the session on an `agent` worker, then run on the window thread reusing every
feature computed there (doc::KeptResults, whatever the bounded evaluation cache dropped); the
worker also makes the display meshes of the bodies they end with. Exports run on a copy. While a
change computes, or the agent cursor acts one out, ui.*, inspection and screenshots answer; later
design changes wait their turn. A chip names the command with Stop. A person's edit meanwhile goes
first; a replaced design refuses the request. Preference: Preferences > AI, agent_off_thread, off
by default.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:58:56 +00:00
raykholo 79a843c963 Transform: refuse a matrix that is not an affine placement (#3900)
transform_matrix took any finite 4x4 matrix. A perspective entry in the
last row, or a singular linear part, was built into faces that mesh to
nonsense: a revolved ring placed with such a matrix measured hundreds of
millions of mm3, and a boolean on it ran for over half an hour before
failing with "no result". Matrices come from design files and command
parameters, so they are refused with KernelError::Invalid instead.
2026-10-21 00:58:49 +00:00
AlF 1bcc3602b4 STEP export: a merged cylinder or cone face's loop starts on the side its angle grows from (#3394) (#3557)
* claim: #3394

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* STEP export: a merged cylinder or cone face's loop starts on the side its angle grows from (#3394)

A whole-turn cylinder or cone face is written as one loop: ring, seam, ring, seam reversed. Readers lay the ring that follows the seam's first use out from the surface's angle 0, so that ring must turn the way the angle grows (anticlockwise about the written axis). Three of the four tapered legs in #3394 came out the other way and other readers dropped their sides. When the second ring turns against the angle, the loop now starts with it.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-21 00:58:47 +00:00
raykholo 93684a4728 Expressions: a leading decimal comma such as ,5 is accepted (#2966) (#3797)
* claim: #2966

* Expressions: a leading decimal comma such as ,5 is accepted (#2966)

In a comma-decimal locale `,5` means 0,5, but the lexer only started a
number on a digit or a point. A comma now starts a number outside
parentheses when a digit follows and an operand is expected (at the
start or after + - * / ^). Commas after an operand keep their meaning,
so function arguments and the existing error cases are unchanged.
2026-10-21 00:58:44 +00:00
raykholo 9db39f3d7d Holes from a sketch on a body's face drill that body (#2882) (#3770)
* claim: #2882

* Holes from a sketch on a body's face drill that body (#2882)

A multi-point hole from a sketch attached to a body's face chose its
side and moved its points onto whichever body lay along the direction,
then cut every body, so points on a front body's face could drill the
body behind it. `targets` was accepted and ignored.

- solid.hole: holes at the points of a sketch on a body's face store
  that body as their participant (the lookup a face-sketch Join uses);
  `targets` sets the bodies to drill, `["*"]` every body; explicit
  participants still override.
- Evaluation: for sketch-point holes with participants, the drilling
  side, the surface each point moves onto and pattern copies are found
  on those bodies only.

Sketches on planes, position and Single holes, and saved holes without
participants recompute as before; no design-format change.
2026-10-21 00:58:43 +00:00
Colin Faulkingham 29369a0bb5 Extrude to an edge or corner ends at the requested plane (#3729) (#3748)
* claim: #3729

* Fix extrusion targets at planar face boundaries (#3729)
2026-10-21 00:58:41 +00:00
kezmdor 5a9e771cc9 io: the 3MF and DXF budget tests do not check wall-clock time (#2998) (#2999)
* claim: #2998

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* io: the 3MF and DXF budget tests do not check wall-clock time

nested_fan_out_of_empty_objects_is_bounded spends about 20 million
visits reaching the work budget: 1.5 s alone, up to 40 s and more when
every test binary runs at once, past its 30 s limit. The time checks
run only after the reader returns, so a missing budget hangs these tests
with or without them; the error, the empty geometry and the three
vertices per placement are what show the budgets work. CONTRIBUTING.md:
a test must not depend on wall-clock timing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: kezmdor <15539254+kezmdor@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:58:39 +00:00
NOWN-DEV 63cb48a4ee Measure: a body whose meshes leave a gap keeps its volume wherever it is (#2069)
* Measure: a body whose meshes leave a gap keeps its volume wherever it is

A part ring cut by solid.combine (two prisms through its ends, a notch
through its chamfer) measured 992.03 mm3 at the origin, 999.17 moved
100 mm and 1063.48 moved 1000 mm; it encloses 993.92. The reference
mesher leaves a crack where the notch meets the chamfer's cone: the
volume beside it is left out, and the divergence-theorem sums, taken
about the world origin, close the crack by a cone to the origin, so the
error grows with the body's distance from it.

A closed surface's area vectors sum to zero. When the picked meshes'
sum could move the volume by more than 1e-5 within the body's size, and
the display plan (which shares every edge's nodes between its faces) is
closed, the body is measured on the plan, about its box centre. Every
other body measures exactly as before.

Tests: the cut ring at the origin and moved by up to 10000 mm, against a
grid quadrature of the cut; a revolved ring of cones and cylinders moved
100/1000/10000 mm along each axis (1e-6); a cylinder and a frustum far
from the origin against hand values. Robustness seed 64 moves by 1.4e-6
and is recorded.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Measure: a gap the display plan can't close is measured about the box's centre

When the meshes leave a gap and the display plan isn't closed either, the
sums used to fall back to the origin-based volume, which drifts with the
body's distance from the origin (7 % at 1000 mm for the part ring). The
per-face sums are now taken about the fine mesh's box centre, which closes
the gap by a cone to a point inside the body; over a closed mesh nothing
changes. The gap decision moves into gap_closed, and a unit test with a
cube left open by hand covers closed vs open meshes, the closed plan, and
the fallback with an open plan or none.

Seeds 14, 20, 40, 55 and 61 end with such a body and are recorded with
ROBUSTNESS_GOLDENS_BLESS (volumes move by -0.51 %, +0.16 %, +1.9e-4,
+9.0e-5 and -1.5e-5; steps, outcomes and counts unchanged).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Record robustness seed 86 after measuring bodies whose meshes leave a gap

Seed 86 was added on main by #2387 after this branch's goldens were recorded. Its last
body leaves a gap in its meshes, so the measure change here moves its volume by -0.27 %
and its area by +0.13 %; steps, outcome and counts are unchanged. Seed 86 passes
unchanged on main 57d21013. Every other seed matches on the merged branch.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* measure: take the display plan only where it agrees with the sums to within what the gap could move

A closed display plan meshed less finely than the sums differs from them by its own chord error,
which can be larger than anything the gap explains: an imported part with small blends measured
0.03 % low on its plan. The plan is now taken only where the two differ by at most what the
gap's area could move the volume within the body's size; otherwise the sums about the box's
centre stand.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* measure: sum a mesh left open about its box's centre only, without the display plan

The closed display plan meshes some faces of an imported B-spline part
more coarsely than the sums do, so a body and its boolean pieces no
longer add up: clearance's union cross-check then refused a 17.3 mm3
overlap between two such parts (the pieces implied 20.5). Summing about
the box's centre alone keeps the gap closed by a cone to a point inside
the body and stays consistent with the pieces. Seeds 64 and 86 are
re-recorded with the reason.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* measure: placement tests in the area's test files

The ring and frustum tests join measure.rs's tests, and the cut ring
joins measure_centre_tests.rs (AGENTS.md: a regression test goes into
the existing test file for its area). The goldens note for seeds 14, 20,
40, 55 and 61 describes the centre-only sums.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* measure: keep the origin for a body left open on purpose

A surface, or a cup whose lid is still a separate sheet, has no volume of
its own. Measured about its box's centre, two such bodies stopped
comparing once the centre moved (a crowned lid rises), which broke the
patch crown test. Only a mesh with a small crack is measured about the
centre now.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aac7mP9rthK3WsPpPRvCsF

* Robustness goldens: keep main's notes, one note for this change's seeds

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aac7mP9rthK3WsPpPRvCsF

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-21 00:57:03 +00:00
Colin Faulkingham 36c732ddb7 Shell: show the tangent-chain face selection and explain full-body removal (#209) (#3809)
* claim: #209

* Shell: show tangent-chain faces and explain full-body removal (#209)
2026-10-21 00:54:34 +00:00
Mateusz Sych fdec948abc Control channel: control.running lists what the app is running, across connections (#3654) (#3689)
* claim: #3654

* Control channel: control.running lists what the app is running, across connections (#3654)

control.pending answers only for the connection that asks, so a second client,
the MCP bridge after a reconnect or a script saw {"pending": null} while the app
was busy. control.running answers for the whole app, the same on every
connection and at once (the connection's own thread answers it, also while it
is busy with a timed-out request): every request the app has been sent and not
answered yet, oldest first, with its connection, id, method, command id (or a
batch's step count), seconds since it arrived and whether it still waits its
turn. A timed-out request stays listed until the app answers it, also after its
connection closed. Parameters are never shown.

The window marks a request started when it takes it (ControlRequest.started),
which tells queued from running. The MCP server gets a read-only `running` tool.

---------

Co-authored-by: MateuszSychXpl <151755914+MateuszSychXpl@users.noreply.github.com>
2026-10-21 00:54:33 +00:00
bherbruck 7803b3968f Fix main: a test built Applying without the new seen field (#4001)
#3609 added Applying::seen; dialogs_sketch_tests' apply_keeps_the_panel_open_after_asynchronous_evaluation
still built the struct without it, so ui-egui's tests stopped compiling on main. The test stands
for a job that is already being shown, so seen is true (the behaviour it had before).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:41:24 -04:00
JEREMY cbac4eab3f OK off the window thread: a job that has just started waits a frame (#2929) (#3609)
* claim: #2929

Co-Authored-By: Claude Code <noreply@anthropic.com>

* OK off the window thread: a job that has just started waits a frame (#2929)

The off-thread evaluation of a feature dialog was taken up in the same frame
it started, so a job whose worker had already finished was applied at once
and the dialog never said it is computing. Under load the window thread is
held up between starting the job and the check, and the feature appears
where the computing state belongs - which is what made
`apply_off_thread_tests::cancel_while_computing_applies_nothing` fail with
`assertion failed: h.app.applying.is_some()`.

A job that has just started now waits one frame: its result is taken up from
the next one, so the dialog says it is computing before the feature appears.

The app waits APPLY_AT_ONCE (150 ms) for the evaluation before it lets the
window go on, so a job only exists when the evaluation took longer than
that: the change shows the computing state for a frame in the 150-151 ms
window, and nothing else moves.

Co-Authored-By: Claude Code <noreply@anthropic.com>

---------

Co-authored-by: jeremymiribung-blip <jeremymiribung-blip@users.noreply.github.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-10-20 23:02:34 -04:00
bherbruck 08eff4f9dd Sketch on a face: project from the model's own body, not a copy that meshes it again (#3981)
A sketch attached to a face is evaluated in the face's frame. When that frame is the body's
own, the body was still rebuilt as a new ModelBody with empty mesh cells, so projecting the
face's edges display-meshed a throwaway copy and the next cut or join meshed the same body a
second time. On a body whose cut edges are slow to mesh this added about a second to each
feature after a sketch on a face. In place, the anchored body is now the model's own clone and
shares its mesh.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:02:22 -04:00
raykholo 9b80163421 Extrudes from a nested STEP part's face join and cut it with the imported root active (#3223, #2872) (#3852)
* claim: #3223

* Extrudes from a nested STEP part's face join and cut it with the imported root active (#3223, #2872)

Opening a STEP assembly leaves its root component active, and the root
owns no body: its parts are components inside it. A profile sketched on
such a part's face was therefore treated as outside the active scope.
Join made a separate body instead of joining the part, and the automatic
operation saw no material and offered New Body in either direction; an
automatic extrude that came out as a cut found nothing to cut.

A component with no body of its own now works on the bodies of the
components inside it whose geometry lies in its own frame (as an import
places them): Join targets the body whose face the sketch is on, the
automatic operation looks at those bodies, and an extrude whose own
operation is Cut or Intersect takes part in every body as an explicit
cut already did.
2026-10-21 00:54:16 +00:00
Tyler Chiriboga 164554edba 3MF import: production extension review follow-ups (#2089) (#3628)
* claim: #2089 (3MF production extension: review follow-ups)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3MF import: production extension review follow-ups (#2089)

Follow-ups from the review of #2127:
- Only the root model's build is read; other parts' build sections are
  ignored as the spec asks (a slicer's child build pointing at a thumbnail
  no longer matters).
- A model part that can't be read fails only the placements that need it;
  the package-wide caps (part names, 1 GB of model XML) still refuse the
  package. The root XML is dropped once parsed and each part is read
  against the remaining budget.
- Loops are reported apart from depth, keyed by (part, object).
- Messages name parts as written; objects are kept in id order so parts are
  read in the same order every run; %XX escapes in part names are decoded;
  the model unit is a local of the parser.
- Tests: a component without p:path stays in its part (T1), the same id in
  two parts is no loop (T2), a slicer-shaped project (T3), the placement
  budget across parts (T4), a loop through three parts and a chain past the
  depth cap (T5), broken parts and child builds (T6), escaped names; the
  hostile-parts test asserts its messages; the mutation test also mutates
  the parts' XML and zips it again.
- Guide: what slicer-only data is not read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Tyler Chiriboga <18270301+Tyler-Chiriboga@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:54:14 +00:00
Matthew Knutson 453c6bcb14 Split schema contracts per command file and add shared field types (#2829) (#3558)
* Split the schema contracts into one module per command file

`schema/commands.rs` becomes `schema/features.rs` (solid.box, solid.extrude,
FeatureResult) and `schema/inspect.rs` (engine.describe), each with its own
tests file holding its `schemas_and_the_key_check_agree` key lists and its
`strict_keeps_todays_errors` cases, so conversions of different command files
touch different files. The shared loops become helpers in `schema/tests.rs`;
the assertions are unchanged. `engine.describe` gains a key-list test, and
`schema_defaults_are_valid` now covers every command with a contract.

No behaviour change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Add shared schema field types for the next conversions

For the feature commands to be converted next, each deciding acceptance with
the reader the handlers already use:

- `FaceRef` and `FaceRefs` (`face::face_ref`, `features::face_points`)
- `EdgeRef` and `EdgeRefs` (`features::edge_points`)
- `SketchRef` (`features::sketch_id`)
- `FeatureCommon`: `name`, `body_name`, `body_names` and `participants`,
  which `add_feature` reads for every feature, flattened into a contract
  with `deserialize_with = "feature_common"`, which keeps the key in the
  message and the issue's path.

The shape checks of `face_ref`, `edge_points` and `sketch_id` (and
`feature_sketch`) move into pure helpers (`face_ref_form`, `edge_ref_form`,
`sketch_key`) that the handlers and the types share; the handlers behave as
before. Tests check that each type and its schema accept what the reader
accepts (apart from a face name's byte cap and an index written `1.0`),
that the contract's messages match the handlers' errors, and that the
handlers still look picks up one at a time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:54:13 +00:00
phanoob f44ff18ca4 Split Body: each piece can be renamed on its own (#3510) (#3527)
* claim: #3510

* Split Body: each piece can be renamed on its own (#3510)

A split named its new pieces after the split body every time (`Top (1)`),
so renaming a piece did nothing, and renaming the split body renamed the
other piece too (and broke the features that used it). The pieces now
take the names body.rename stores, and a rename keeps the names later
features derive from the renamed body.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Phanoob <phanoob@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:54:11 +00:00
raykholo 98cd67f029 Joins, cuts and intersects meet their tools where they are shown, across placed components (#3383) (#3384)
* claim: #3383

* Combine: tools from another component meet the target where they are shown (#3383)

Bodies are evaluated in their own component's frame and an occurrence's
placement is applied only for display. Combine passed tool bodies to the
boolean untouched, so a tool in a component placed differently from the
target's was read as if its coordinates were in the target's frame: a
join that overlapped on screen gave two separate shells, and cuts and
intersects missed the same way. Any placement difference did it, a turn
or a plain move, in either direction.

Combine now moves each tool by the inverse of the target's placement
times the tool's own, the same placement used to show the body, keeping
face lineage. A tool placed exactly like the target is left untouched,
so combines within one component give the same result as before.
Moving an occurrence now re-evaluates a combine that depends on it.

* Joins, cuts and intersects draw their tools where they are shown (#3383)

A primitive, extrude, revolve, loft, sweep, pipe or coil that joins,
cuts or intersects bodies draws its tool in its own component's frame,
but each body is kept in its own component's. A target in a component
placed differently (an occurrence moved or turned, or a body moved into
it) met the tool as if the tool's coordinates were in the target's
frame: a join that overlapped on screen gave a separate lump 100 mm
away, and cuts missed the same way.

While such a feature evaluates, the tool now goes into each body's frame
(the inverse of the body's placement times the feature's component's)
wherever the join, cut or intersect meets it, and a body joined into one
placed differently is moved into its frame. Bodies placed as the feature
is are left untouched, so results within one component are unchanged.
Features that name the bodies they change re-evaluate when an occurrence
moves.

This also settles the moved-fastener cut of #439 (Combine, earlier commit).

* Through all and an extrude's contact tests see targets where they are shown (#3383)

An extrude or revolve measured its through-all length, and tested where
its tools meet the targets (a profile on a target's face, coplanar
growth, holes over empty space, taper run-on), on the targets as stored
in their own component's frame. With the tool now moved into a target
placed differently, a through-all cut drawn from the root under a cube
in a component moved 100 mm up was cut only as far as the cube is tall
in its own frame, and missed it.

These features now see the bodies they change that are placed
differently moved into their own component's frame, the same moves the
tools are taken back through, so the length reaches the target where it
is shown. Bodies placed as the feature is are read as before.
2026-10-21 00:54:09 +00:00
raykholo d9d6af51c6 Sheet metal: mirrored and copied sheet bodies stay sheet metal (#3353) (#3354)
* claim: #3353

* Sheet metal: mirrored and copied sheet bodies stay sheet metal

Mirroring a sheet metal body into a new body gave a plain solid: the
opposite-hand bracket had no flat pattern, DXF or bend table
("`Body1 (2)` is not a sheet metal body"). Copies from a body pattern or
Move with copy lost their sheet model the same way.

A copy now takes the sheet model with it (SheetBody::copied). A rigid
copy moves the flat frame. A mirror moves the frame with its x axis
turned round, so the thickness side stays, and mirrors everything in
flat coordinates (x -> -x): base, flange bend lines (run from their far
end, with their end extensions, slopes and tears swapped), panel shapes,
cut-outs, pockets, bores, letters and base sketch lines. Arcs stay exact
and loops keep their sense. A perforation field of a mirrored copy runs
its grid from the right, so its holes are the mirror image too. The flat
length, bends and K-factor are unchanged, and the copy takes further
sheet metal features of its own.

Mirror with combine is unchanged: the joined body has no single sheet
model yet. Mirroring or patterning flange features is still refused.
Sheet models are derived from the timeline, so no design format change.

* Format: note the saved from_right of a mirrored sheet's perforation field

The new `from_right` flag is saved in the design, so it gets a pending
format note. No migration: a field without it runs from the left as
before.
2026-10-21 00:54:07 +00:00
Christian Svensson 38c710e72e Save designs with large STEP imports: compress the STEP part (#3221) (#3285)
* claim: #3221

* Save designs with large STEP imports: compress the STEP part (#3221)

A STEP between 256 and 512 MiB imported fine but could not be saved: a
design stored the STEP text uncompressed, and a design part is capped at
256 MiB. STEP sources are now always Deflated (level 2), may be as large
as an import accepts inside the file, and an import that could not be
saved even compressed is refused with a clear message. Reading stops at a
part's declared size, so a decompression bomb is refused early.
2026-10-21 00:54:05 +00:00
raykholo 5b5b051e02 Delete in Edit Joint deletes that joint, and a refused joint delete says why (#3749) (#3776)
* claim: #3749

* Edit Joint: deleting the joint being edited closes the dialog and deletes it (#3749)

A joint delete was dropped without a word while any dialog was open, and
Edit Joint is a dialog. Deleting only the joint Edit Joint edits now
cancels the dialog first (as its Cancel does) and goes through the usual
selection.delete dry run; Delete with nothing else selected in Edit Joint
targets that joint. Every other refusal (another dialog, a tool, a
computing history, a loading file) now says why in the status bar.
2026-10-21 00:49:50 +00:00
kaltenbrunnermarkus 686576e368 Section Analysis: Edit reopens the dialog where the section was left (#3617)
Edit Section Analysis (its menu item, or a double-click on its browser row)
opened the dialog at distance 0 with no plane, so moving the cut on by a
millimetre meant picking the plane and typing the whole distance again.

OK now keeps the dialog's plane pick, distance and flip with the cut they
made, and Edit starts from them while the section shown (or hidden) is still
that cut. The toolbar still starts a new section afresh, and a section made
some other way (a command, an agent) opens the dialog as before.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:49:48 +00:00
rlartot 68e4186080 fix(ai-chat): replay with reasoning_effort none when OpenAI refuses tools (#3798) (#3817)
* claim: #3798

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* fix(ai-chat): replay once with reasoning_effort none when tools are refused (#3798)

Newer OpenAI models answer HTTP 400 to function tools on /v1/chat/completions
unless reasoning_effort is "none". On that exact refusal the request is sent
again once with it, and later requests of the chat carry it directly.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 00:44:52 +00:00
Colin Faulkingham b66ad0a9c9 Move shortcut respects the selected component (#3580) (#3588)
* claim: #3580

* fix(ui): move the selected component from its shortcut (#3580)
2026-10-21 00:44:48 +00:00
amolinari-autotex 2eacbc37f4 Keep sketches out of the Browser Features folder (#3584) 2026-10-21 00:44:43 +00:00
phanoob f2f9c6fe27 Settings › AI: Add agent opens from the Home tab too (#3483, #3513) (#3522)
* claim: #3483

* Settings > AI: Add agent opens from the Home tab too (#3483, #3513)

The Add agent dialog was drawn by the chat panel, which Home doesn't
show, so Add agent did nothing while Home was open. It is now drawn
from the frame itself, before Home and the workspace part ways.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Phanoob <phanoob@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:44:41 +00:00
SS-EN d21e2b1afd Keep picked points when a type-first dimension turns horizontal or vertical (#3168)
Type-first (pick, then type), the dimension's text follows the pointer, and where it goes
picks the kind: across from two points or a slanted line the distance between them, above or
below the horizontal span, beside them the vertical one. The draft kept the value of the kind it
started as, so moving from diagonal to horizontal previewed (and Enter applied) the aligned
length as the horizontal span: the solver moved the picked points (the first dimension of a
sketch even scaled it), so the dimension's extension lines no longer met them.

While nothing has been typed, moving the text now re-derives the draft for the new place, so
its value is what that kind measures and stays selected for typing. A typed value is kept.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:44:40 +00:00
bherbruck 57bcc40838 Release 0.18.2 (#3970)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:08:36 -04:00
bherbruck 9974413a5b Ribbon: Finish Sketch follows the last sketch panel (#3958)
* Ribbon: Finish Sketch follows the last sketch panel

The Sketch tab's exit group sat alone at the far right of the ribbon,
about 600 px from the sketch tools on a 1600 px window. It now comes right
after the last panel, as before the redesign. Its room is still kept at
every width, and when panels go into the » menu it moves to the right end
after it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ui_layout test: Finish follows the last sketch panel, right end only with the » menu

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:08:14 -04:00
bherbruck 818af2b856 Icon audit: regenerate the requested-icons manifest (#3969)
The menus no longer ask for offset_one_side, offset_symmetric,
offset_two_sides, pattern_center or pattern_flip, so the manifest check
failed on main and on every UI pull request.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:08:10 -04:00
bherbruck 80238a7cfb Sketch: a quick double Esc finishes the sketch (#3950)
* Sketch: a quick double Esc finishes the sketch

One Esc still puts the tool down, then clears the selection, and with
nothing left to cancel keeps the sketch open (#1804). A second press within
0.4 s finishes the sketch, keeping it, the same as Finish Sketch, also when
the first press cancelled a tool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Sketch: a double Esc takes away a blank sketch; only Esc twice in a row counts

A quick double Esc on a sketch the user added nothing to removes it in one
undo step, the view and tab going back as on Finish, so no empty sketch is
left behind. A sketch with anything in it is still finished and kept.

The double press counts only for two Esc presses in a row with no tool or
dialog left after the first: any other key in between resets it, and while
a tool is still out (its first Esc only ended a chain) the press puts it
down as before. Tests that press Esc on its own now wait past the window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 21:57:32 -04:00
bherbruck bd65a561c8 Ribbon: New Sketch on the Surface tab (#3964)
Sketches drive surface extrudes, revolves, sweeps and lofts, so the Surface
tab gets the same Sketch panel as Model and Sheet Metal. A sketch started
there keeps the tab until it opens.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 21:57:10 -04:00
phanoob bdd2fb1a5c Fillet and chamfer previews keep moving while a radius or distance changes (#3150)
Co-authored-by: Phanoob <phanoob@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:44:38 +00:00
JZiegener e64f1e0427 Rejoin the split Files table in ATTRIBUTION.md (#3143)
A blank line left after the oracle rows ended the table, so GitHub
rendered the 339 rows after it as one paragraph of pipe-separated text.
2026-10-21 00:44:21 +00:00
CamWight ac362945cc English (UK) as a language: a plain catalogue of the 25 messages UK English spells differently (#2501) (#2502)
* claim: #2501

* English (UK) as a language: a plain catalogue of the 25 messages UK English spells differently (#2501)

assets/locales/en-GB.json holds only the English messages whose spelling differs in UK English (Centre, Customise, Organisation, Licence, colours, towards, ...), in UK spelling; every other message falls back to English. One registration in i18n.rs (English first, then by id), a test that shows UK spelling and English for the rest, a row in ATTRIBUTION.md, and the docs/localization.md note. The translation-coverage checks skip a regional English catalogue, which has no gaps to report.

Fixes #2501

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:44:19 +00:00
JEREMY 382680d8e8 fix(skills): refresh Boolean checking facts that fail on current release (#1835) (#2093)
* fix(skills): refresh Boolean checking facts that fail on current release (#1835)

* fix(skills): restore warning coverage in test_run and use present tense in Boolean facts

* fix(skills): the slot keeps its side at zero since the solver takes the shortest step

The upstream solver now takes the shortest step that fits (#2060), so the slot
that other constraints also held slides to the origin when the distance reaches
0 and keeps the side it was drawn on when the value comes back. It no longer
comes back mirrored, so the trap and its test state the current behaviour.

---------

Co-authored-by: jeremymiribung-blip <jeremymiribung-blip@users.noreply.github.com>
2026-10-21 00:41:45 +00:00
Yeonghun Lee 316b49cd84 Extrude from a whole face rounded by fillets: join, and cuts whose walls only touch (#2040) (#2041)
* Extrude: a join from a whole face rounded by fillets sweeps the face itself (#2040)

A face's sketch fits an edge that is no line or arc (the quarter-ellipses
where fillets meet a slanted face) as splines a hair off the body's edges,
so a join from the face touched the body along a face it did not match and
failed with "boolean Union: no result" after minutes. When the profile is
a whole planar face of the one target, the join now sweeps the body's own
face, which shares its edges and is sewn on along it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Extrude: a cut into a whole face keeps the walls that cut and moves the others off (#2040)

A cut from a whole planar face sweeps walls from each of its edges. Where
the face beside an edge turns away from the sweep (n_N . -n_P >= 0) the wall
only touches the body along that edge, or lies in the face beside it, and
the boolean failed on it with "no result". The cut now keeps the walls that
go into the body, exactly (the body's own edges), runs the outline round
the others well outside the body, and starts a little out from the face.
A cut whose walls all only touch takes a slab beyond its depth plane.

Not covered: a cutting wall tangent to the face beside a touching one (a
round's wall meeting an end face, as in the issue's part); the boolean
still fails there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Extrude: leave a whole face outlined by lines and arcs to the profile's own tool

Only a face whose outline the projection had to fit needs the body's own face as the tool. A square drawn over a box's top already meets the body exactly, and the swept face lost the names a sketch on the new top attaches to (stacked_face_tests, #489).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Run the whole cut tool from a face under the kernel guard (#2040)

Review: `builder::vertex`, `builder::line`, `Face::try_new` and
`builder::translated` ran outside `crate::guard`; only the sweep was
guarded. `cut_tool` now runs all of it under one guard.

Adds an end-to-end test of a cut into a face the body stands out past
below it (the block's top strip between its rounds): it takes only
what lies over the face.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: the slab cut of whole_face_tool, on a spline prism (#2040)

A prism on a closed spline standing on a wider plate, cut 3 into its whole
top face: the slab takes the face's area times the depth and leaves the
plate alone. A test-only counter asserts the slab was the tool, so the test
cannot pass through the profile's own tool. Shortens the name of the
rounded block's top-face cut test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Kernel: the face's outline as a Vec now that polyline returns a closed chain (#2040)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:41:41 +00:00
phanoob b0932a487e Document tabs: a right-click menu to close a tab, the others or all (#3055) (#3152)
* claim: #3055

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document tabs: a right-click menu closes the tab, the others or all of them (#3055)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Phanoob <phanoob@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:39:45 +00:00
rlartot edad8078ba Ribbon tooltips: a short written help for the main tools (#2758) (#3089)
* claim: #2758

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2qZQXd3qSwu9mzaYcB9yW

* Ribbon tooltips: a short written help for six tools (#2758)

Extrude, Revolve, Fillet, Chamfer, Shell and Hole get two sentences under the
tool name in the ribbon tooltip (en, ja, zh). Text only.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2qZQXd3qSwu9mzaYcB9yW

* fr: aide des infobulles (6 outils) (#2758)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Ribbon help: also in the panel drop-downs (#2758)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 00:39:43 +00:00
Luke Jones 26c0c19ef7 Preserve imported part names and IGES assembly structure (#1609)
Build on #1426's import components and nested STEP assembly instances.
Replace placeholder solid labels with meaningful part or instance names.
Translate IGES groups and subfigure instances through the existing STEP
component path, preserving nesting and placements and sewing surfaces
within each group. Bound assembly walks and reuse work buffers.

Related to:
- tangency-labs/solidcraft#1386
- tangency-labs/solidcraft#1426

Co-authored-by: Codex <noreply@openai.com>
2026-10-21 00:39:08 +00:00
raykholo 2f43ad5c5b Loft: a circle lofted to a non-star-shaped profile is split once round (#2570) (#3670)
* claim: #2570

* Loft: a circle lofted to a non-star-shaped profile is split once round (#2570)

A circle section was split at the lead section's vertex directions, kept in
the lead's order and forced to increase by whole turns. A non-star-shaped
lead (a C, a comb) turns back on itself, so the circle wound round many
times and closed with a negative arc: self-crossing walls and a huge mesh
(which froze the web build while meshing it).

The circle is now split exactly once round: at the lead's directions when
they go round once in order (clockwise on a plane facing the other way),
otherwise at the lead's perimeter fractions, evenly as the fallback. Every
arc is finite and positive and they total one turn; the unbounded loop is
gone. Section preparation is split out as loft_section_loops so the
resampled sections can be checked.
2026-10-21 00:30:58 +00:00
rlartot 6a8604c55d Fix lofts through sections turning past 90 degrees (#2847) (#3452)
* claim: #2847

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* loft: keep sections turned past 90 degrees facing the same way (#2847)

The loft oriented each section by the sign of its normal against the first section's, so on planes turning about an axis every section beyond 90 degrees was reversed and the side faces folded. Follow the facing neighbour to neighbour instead.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 00:15:41 +00:00
bherbruck 4098ff9dc8 Ribbon: New Sketch on the Sheet Metal tab again; tests for entering and leaving sketches (#3946)
* Ribbon: New Sketch on the Sheet Metal tab again; tests pin sketch enter/exit

Before the ribbon redesign the Sheet Metal tab started with New Sketch. It
gets its own Sketch panel again, like the Model tab's, and a sketch started
there keeps the tab until it opens.

Workspace tests pin the ways into and out of a sketch: New Sketch then a
plane, a double-click in the timeline or the browser, and the browser's Edit
Sketch all open the SKETCH tab looking at the sketch. Finish Sketch from the
palette or the ribbon lands on MODEL with the earlier view back. Escape and
Enter keep the sketch open (#1804), and Undo after Finish reopens it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: ribbon layout lists Sheet Metal's Sketch panel

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Workspace test: press the keys well apart

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 21:32:48 -04:00
bherbruck 97383454c6 Browser rows truncate long names with an ellipsis and a tooltip; Solve Sketch is no longer offered in the UI (#3940)
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 21:32:42 -04:00
bherbruck 10d7bd045e CI: build and run the tests in one job, sccache on a Blacksmith disk (#3941)
* CI: build and run the tests in one job, sccache on a Blacksmith sticky disk

The tests job compiles and runs the tests itself on the 32 vCPU runner, so a pull request or merge
group uploads and downloads no 3 GB archive (the download took 45 to 385 s per shard, and once
14 minutes) and starts beside the repository checks instead of after them. The archive is built
only for the nightly and manual jobs that need it. sccache moves from GitHub's 10 GB cache, where
about 1100 per-ref entries were evicting each other, to a sticky disk that only main commits.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* TEMP: let this branch save caches so the warm run can be measured

* Revert "TEMP: let this branch save caches so the warm run can be measured"

This reverts commit 1bbbfcf5ff.

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 21:25:35 -04:00
bherbruck 6e87fb1a25 Ribbon: Parameters and Configurations back on Model, Sheet Metal and Sketch; Sheet Metal tab always reachable; Finish Sketch button back in the sketch palette (#3937)
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 21:08:04 -04:00
denelson1-dot 30e0db1e05 Pattern of a join that merged its targets keeps working (#3378) (#3387)
* claim: #3378

* Pattern and mirror copies of a join that merged its targets join the merged body (#3378)

Since #2755 a pattern or mirror copy of a primitive reuses the seed's targets. When the
seed was a join that merged two targets, the second one no longer exists, so the copies
failed with `unknown body`. The copies now keep the seed targets that are still there;
when none is, the names stay and the error says which body is missing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Rerun CI: the previous run was cancelled before any check ran

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:15:39 +00:00
Dahak Builds 2b1cb2c4e1 MCP batch: rollback restores the original document (#3242) (#3288)
* claim: #3242

* MCP batch: rollback restores the design the batch began in (#3242)

A failed batch with rollback: true reported rolled_back: true even when the
design never went back. Two defects, both fixed:

- file.new (and any document-replacing command) replaced the session with it,
  taking its checkpoints down too, so a batch that failed after file.new had
  nothing to restore. Session::replace_design now carries the checkpoints over
  to the fresh session: the checkpoint belongs to the batch, not to the
  session that happened to be holding the document.
- rolled_back echoed the requested flag. It now reports whether the design is
  actually back: the app answers engine.batch with a new restored field, and
  the MCP tool reports restored && rollback. An app that predates the field
  falls back to undone/completed.

* Batch: answer with main's rolled_back field after the merge (#3242)

Upstream #2900 put the real restore check on engine.batch as
`rolled_back` while this branch named it `restored`; the merge kept
main's control test and this branch's field, and CI caught it.
Standardize on main's wire name: the app answers `rolled_back`, the
MCP tool reads it (fallback for older apps unchanged), docs and tests
follow. The checkpoint carry-over across file.new is unchanged.

---------

Co-authored-by: DahakBuilds <dahakbuilds@users.noreply.github.com>
2026-10-21 00:15:36 +00:00
phanoob f815491936 2-tangent circle: start from the touching circle, not the click (#3141) (#3210)
* claim: #3141

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 2-tangent circle: start from the touching circle, not the click (#3141)

The tool passed its second click as the place to start solving, and a
click lies on a curve: the circle began centred on that curve, where its
tangency has no side yet. The solve then moved the user's curves to fit
(a rectangle came out skewed), or failed outright when they were fixed.
The command now starts from the centre, nearest the requested point, of
a circle of that radius touching both curves (where the curves moved by
the radius meet), and the tool asks for the circle between its two clicks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Phanoob <phanoob@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:15:34 +00:00
rlartot a595805fc8 Sketch: symmetry about a line pairs line ends by mirror image (#2961, #2940) (#3029)
* claim: #2961

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2qZQXd3qSwu9mzaYcB9yW

* Tests: symmetry about a line keeps rectangle sides' height (#2961, #2940)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2qZQXd3qSwu9mzaYcB9yW

* Sketch: symmetry about a line pairs line ends by mirror image (#2961, #2940)

c_symmetry paired the ends of two lines by index. Opposite sides of a
rectangle run in opposite directions, so that forced both heights to 0
(or was refused when a side had a length). Pair each end with the end
of the other line nearest its mirror image, as symmetry about a point does.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2qZQXd3qSwu9mzaYcB9yW

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 00:15:32 +00:00
bherbruck 0379ee76c6 CI: spread the slowest tests across the test shards (#3646)
* CI: give the slowest tests a shard each

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* CI: spread the slow tests on pull requests too, and keep both junit files

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* CI: pull requests skip the goldens and the action matrix sample unless the kernel changes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Docs: which pull requests run the goldens

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* CI: run the tests in one job on a big runner, slowest first

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* CI: allow the 32 vCPU runner label in actionlint, read junit.xml in the golden hint

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 20:41:58 -04:00
plexi32 4f7ff8c4d8 Move: Point to Point takes the origin point (#3696) (#3718)
The origin point was drawn in the view and listed in the browser, but
nothing could pick it: the viewport never offered it as a hit and the
browser's O row had no selection. A new ORIGIN accept bit lets an input
take it as a vertex of no body; Move's point inputs (From, To, Point,
Pivot) accept it, from the view or the browser's Origin folder.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:13:59 +00:00
bherbruck d2188fb7ab IGES import: a B-spline curve over a tiny knot span keeps its faces (#3885) (#3886)
A curve written over a knot span of 1e-7 or less was refused on the STEP side
("knot vector consists single value"), and both faces it bounds were lost. Its
knots are moved and scaled onto (0, 1), which leaves its shape as it is.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:13:28 +00:00
bherbruck 438abbcf82 STEP conformance sweep: the cones to a point read right now (#258) (#3901)
cone_point and cone_point_vertex join the sweep now that #3871 reads a cone
bounded only by its base circle up to its point.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:03:46 +00:00
Christian Svensson 079a600481 Recovery: autosave every open design tab (#2884) (#2952)
* Fix recovery for unsaved background tabs (#2884)

* Recovery: scheduled autosaves of every tab write on the worker thread (#2884)

After #3139, Autosaver::save waits for the write and Autosaver::tick runs
it on a worker. The scheduled path now goes through tick (which skips a
tab whose previous write is still running), so several dirty tabs no
longer write on the window thread each interval (#3044). save stays for
Save, close and shutdown. Each tab's autosaver has no clock of its own:
the shared cadence decides when it is due. The tests wait for the
writes a tick started.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Recovery: closing a tab waits for its running autosave before removing the entry (#2884)

With the autosave on a worker thread, discard could remove the files and
the lock while a write was still running; the write then recreated the
entry without a lock, and the next launch offered a design closed with
Don't Save. discard now ends the running write first, as Drop does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:58:05 +00:00
bherbruck 94bd8719aa STEP import: a void reads as a cavity whichever way its shell flag points (#3730) (#3872)
A BREP_WITH_VOIDS void's faces face into the cavity, so it encloses volume of
the opposite sign to the outer shell. Its ORIENTED_CLOSED_SHELL flag alone
doesn't tell: SOLIDWORKS writes .F. over a shell already facing into the
cavity, SolidCraft .T., and flipping by the flag added the cavity's volume. A
void enclosing volume of the outer shell's sign is now turned inside out.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:54:54 +00:00
bherbruck fc3dedfd5c STEP import: regression sweep over the step-conformance parts of known shape (#3880)
* STEP import: regression sweep over the step-conformance parts of known shape

Fifteen generated parts from step-conformance (CC0): plain, NURBS, seamed,
placed, inch and far-away cylinders, domes, a grooved shaft, a plate with 100
holes, a 3.6 m disc, a 0.5 mm pin, a B-spline half-torus and a washer. Each
reads back with its exact volume, its box and its face count. The half-torus
on a TOROIDAL_SURFACE and the cones to a point are left out until they read
right.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* STEP conformance sweep: the pin's volume as FRAC_PI_8 (clippy)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:07:47 +00:00
bherbruck 41a62d210c File menu: Export STL… and Export 3MF… open the mesh dialog with that format; command search finds mesh export by format (#3873) 2026-10-20 22:43:30 +00:00
bherbruck 5638259737 STEP import: a cone bounded only by its base circle reaches its point (#258) (#3871)
A cone face written with no vertex at its point has the base circle as its only
loop (or that circle and a VERTEX_LOOP at the point, which has no edges). Its
surface was spanned over the circle alone, a sliver, so a solid cone came in as
a flat disc. Such a face is now spanned to the apex, and its circle is joined to
the apex by a seam line out and back, as a cone with a seam is written.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:35:12 +00:00
NOWN-DEV 090119a5c5 Boolean: a small hole in a large body's face stays a hole when the face is divided (#2579) (#2580)
* Boolean: a small hole in a large body's face stays a hole when the face is divided

A boolean divides faces at a tolerance that follows the larger operand (a few
millimetres on a part a few metres long). A small hole's rim, two half circles
when read from STEP, then became two chords: its polygon in the face's
parameters had no area, and the sign that tells a hole from a face piece came
out at random. The hole became a face of its own, inside out, and the face it
was cut in stayed whole. A box cut round such a hole gave a piece without it,
and the shared part of that piece and a pin in the hole came out negative
("malformed") or the boolean failed.

divide_face now divides a loop whose polygon is flat again, finer (down to a
hundredth of the loop's own size), before reading its sign. Only the polygon
that places the loop changes; loops that have an area are untouched.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Kernel: the long-plate hole test in tests.rs; a flat loop's size from its box

Review: the test moves into the kernel's test file (no file of its own),
and finer_if_flat takes the loop's size from the diagonal of the box round
its sample points instead of the largest distance between any two.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aac7mP9rthK3WsPpPRvCsF

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 21:56:33 +00:00
bherbruck 522ae77556 Release 0.18.1 (#3851)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 17:33:29 -04:00
raykholo 5ba8c815a6 IGES import: trim curves with reversed pieces no longer drop their faces (#3358) (#3359)
* claim: #3358

* IGES import: trim curves with reversed pieces no longer drop their faces

A curve on a surface (142) gives each trim loop twice: B in the surface's
parameter space and C in model space, running the same way. Some exporters
write C as a composite curve (102) whose pieces are in order but some of
them end to start. The reader passed those pieces on as they were, so the
loop did not close and the STEP reader left the face out ("face boundary is
not a closed loop").

The pieces of C are now turned to run head to tail, starting at S(B(start)),
the point where the parameter-space curve begins on the surface. That keeps
the loop's sense from the file, so holes still run against the outer loop.
A piece whose ends are one vertex at the sewing tolerance keeps its
direction.

On a 9,276-face trimmed-surface car model, 7,887 faces now import instead
of 3,882.
2026-10-20 17:32:48 -04:00
bherbruck cb1b230732 Add Dutch, Swedish, Traditional Chinese, Indonesian, Romanian and Hungarian (#3826)
The new catalogs are machine translated; native speakers, corrections are very welcome.
2026-10-20 17:32:44 -04:00
bherbruck 6e22e3709d Web: reopen designs saved on the computer from the recent list after a reload; forget stale remembered files (#104) (#3834) 2026-10-20 17:32:36 -04:00
Krzysztof Heim 1b84ce6330 fix: skip impossible drags of determined sketch geometry (#2006) (#2199)
Co-authored-by: Krzysztof Heim <krzysztof.heim@b4bridge.com>
2026-10-20 20:56:50 +00:00
bherbruck 687caad24c IGES import: a revolved arc is a cap, not a whole sphere (#3806) (#3813)
A surface of revolution (120) wrote its arc generatrix as a whole STEP circle, which
turns into a whole sphere twice over when the arc is centred on the axis; the trim could
not pick the cap out of it and the face meshed as the full ball. An arc in a plane through
the axis now comes in as a sphere or torus, any other arc as an exact rational B-spline of
just the arc.
2026-10-20 20:56:35 +00:00
bherbruck 7e05c8b23c Hovering a long helical edge no longer freezes (#3745) (#3818)
Hovering an edge lights its circular rim, which starts from the edge's
tangent chain. That looked at every edge of the mesh for each edge in the
chain (and searched the chain itself each time), so a long helical edge in
many pieces, a thread's crest, took seconds: 7.6 s inside the frame on the
first hover of the stem's thread in a real watch-movement STEP file. The
edges are now found by the grid cells of their ends, two join tolerances
across, so each step looks only at the edges that end near it: the same chain
in the same order, in 29 ms for that thread.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 17:08:50 -04:00
DuBearte 7dfbaf2a2c Parameter autocomplete matches anywhere in the name, prefix matches first (#2757) (#2849)
* claim: #2757

* fix(params): autocomplete offers a parameter for any part of its name (#2757)

Typing `side` now offers `cab_side_thickness`: parameters match the word
anywhere in their name, after those that start with it, both in the expression
boxes and in parameters.complete. Functions, constants and units still match
from the start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-20 20:39:14 +00:00
phanoob 1e0e8f78dc Shift+R repeats the last tool or dialog (#3045) (#3160)
* claim: #3045

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Shift+R repeats the last tool or dialog (#3045)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Phanoob <phanoob@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 20:29:22 +00:00
bherbruck c3151fb7fe Release 0.18.0 (#3792)
Version 0.18.0; no pending design format changes, so it writes format 28 like 0.17.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:28:46 -04:00
bherbruck a9aab13d92 Translations: complete Ukrainian, French, Danish (#3802)
The added strings are machine translated; native speakers, corrections are very welcome.
2026-10-20 16:28:31 -04:00
bherbruck 6235295ba6 Translations: complete Czech, Italian, Vietnamese, Spanish (#3801)
The added strings are machine translated; native speakers, corrections are very welcome.
2026-10-20 16:28:27 -04:00
bherbruck 51e2f0a34b Translations: complete Malay, Brazilian Portuguese, Polish, Galician (#3800)
The added strings are machine translated; native speakers, corrections are very welcome.
2026-10-20 16:28:22 -04:00
bherbruck 0191e3e157 Translations: complete Russian, Korean, German, Turkish (#3799)
The added strings are machine translated; native speakers, corrections are very welcome.
2026-10-20 16:28:17 -04:00
bherbruck 754eb56e5b perf: hovering a large body never waits for its pick index or plan mesh (#3745) (#3766)
The first hover over a large body made two things in the frame: the body's
pick index (its triangles and edges in hierarchies; about 0.3 s for a long
modelled thread, a quarter of a million triangles) and, with the GPU viewport,
the plan mesh a highlighted face is drawn from (about 0.7 s for the same
thread). The window froze for the sum.

A large mesh's pick index is now made on a worker, and picks scan the mesh
until it is there (a few milliseconds); a body's plan mesh is made on a worker
too (`ModelBody::plan_mesh_soon`), and until it is there the hovered face is
lit on the mesh the viewport already has. Small meshes, and the browser build,
make both at once as before.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:28:11 -04:00
github-actions[bot] 9d04e28b51 Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci] 2026-10-20 20:21:42 +00:00
bherbruck 8e77d9fd85 Merge pull request #3627 from Nathan-Dane/ui/icon-accents
UI redesign stack: floating workspace, shared controls, search and icon accents
2026-10-20 16:16:24 -04:00
Nathan Dane c78095ccde Fix UI stack integration after rebasing on main 2026-10-20 21:50:22 +02:00
Nathan Dane 6decbf407f Command search: concentric corner radii, a wider closed field with tighter spacing 2026-10-20 21:01:32 +02:00
Nathan Dane 6f521991c7 Command search: the closed ribbon field keeps its original size; the I-beam cursor over it 2026-10-20 21:01:32 +02:00
Nathan Dane 6c9ddc2528 Command search: the ribbon field animates into the search field; the footer fills the sheet edge to edge 2026-10-20 21:01:32 +02:00
Nathan Dane 41f5957d60 Icons keep their multi-tone artwork: tone accents for confirm, menu and radial glyphs 2026-10-20 21:01:32 +02:00
Nathan Dane d45fcce72c Command search popup on the UI kit; the bottom command line is gone 2026-10-20 21:01:32 +02:00
Nathan Dane a3e2fe25bb Context menus, radial menu and rename popover on the UI kit 2026-10-20 21:01:25 +02:00
Nathan Dane 57aad71bbe Window frame with page list and the confirm dialog on the UI kit 2026-10-20 21:01:25 +02:00
Nathan Dane f2eaead594 Per-tab accent tokens: fill, ink and tint for every tab, with a contrast guard 2026-10-20 21:01:25 +02:00
Nathan Dane a5e0fe3ec4 Parameters, Configuration Table, Crash Reports and Appearance redesign (step 8 of #2016, after the cleanup PR) 2026-10-20 21:01:25 +02:00
Nathan Dane dad56187df Cleanup after the popup move (step 7 of #2016, after the panels PR) 2026-10-20 21:01:18 +02:00
Nathan Dane 7026248e25 Every popup and panel on the UI library (step 6 of #2016, after the library PR) 2026-10-20 21:01:11 +02:00
Nathan Dane 1122cd44b8 UI library, tokens, fonts and icons (step 5 of #2016, after #2287) 2026-10-20 21:00:56 +02:00
Nathan Dane 837dbe66df Compass and viewport overlays (step 4 of #2016, rebased onto step 3) 2026-10-20 21:00:50 +02:00
Nathan Dane 845edeba5f Floating browser card and history strip (step 3 of #2016, rebased onto step 2) 2026-10-20 21:00:50 +02:00
Nathan Dane c2e3bdfe1c Floating shell frame: workspace and ribbon cards over a full-bleed viewport (step 2 of #2016, rebased onto #2176) 2026-10-20 21:00:50 +02:00
Nathan Dane a0c1a67220 Application bar and ribbon redesign (step 1 of #2016) (#2176)
* ui: spacing, radius and type tokens, per-tab accents, agent and on-accent tokens

* ui: per-tab accent behaviour and a larger application bar with a Start page button

* icons: silhouette master and flat assistant and agent link icons (open for review)

* icons: apply review notes (sparkle assistant, refined plug, silhouette halo)

* icons: wire the silhouette master to inspect.silhouette; record the accepted icons

* ui: application bar to the design: separators, Save, house, assistant icon, readable hovers

* ui: ribbon structure: Model to View tabs, contextual Sketch and Sheet Metal, migrated pins

* ui: ribbon painting to the compact design

* ui: smaller icon button highlight and assistant mark on the bubble corner

* ui: short ribbon labels that wrap at spaces

* ui: ribbon buttons fit their labels and Finish Sketch shows its full name

* ui: constraint gallery, workspace menu, section list headings and richer tooltips

* ui: lighter selected tab label, ribbon surface and dimmer bar separators match the dark design

* ui: document tabs as rounded chips with a dimmer inactive label

* ui: document tabs fit their names and show close only when active or hovered

* icons: close and plus masters for the document tabs (open for review)

* ui: centre the new-tab plus and overflow button on the bar

* ui: a quiet rule under the ribbon tabs on non-contextual tabs

* ui: the application bar mark separates its faces with the bar colour, not white

* ui: open ribbon section label uses the selected tint and a chevron

* ui: dim the tab close and plus icons until hovered

* ui: every ribbon dropdown uses the chevron, the workspace card included

* ui: the active document tab uses the design's surface colour

* ui: the bug button's right padding mirrors its icon side

* ui: square constraint cells and a rule before the grid

* ui: constraint cells back to their earlier size, keeping the rule

* ui: smaller constraint cells and icons, centred in the section

* ui: constraint cells fit the height of the large buttons' icon and label

* ui: sketch Create titles share the other labels' line

* ui: format the application bar and ribbon changes

* ui: active tab surface token and the colour and hover tests that follow the redesign

* tests: the merged parameter and narrow toolbar tests follow the compact ribbon

* i18n: the Malay Gap label has an English source and its Japanese and Chinese translations

* apply_off_thread tests: the evaluation lingers in tests, so still computing never depends on machine speed

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-20 17:23:49 +00:00
bherbruck 3606d44ad9 README: Trendshift Rust badge next to the overall one (#3714) 2026-10-20 14:20:13 -04:00
bherbruck 20a0d3b2e4 README: Trendshift badge (#3706) 2026-10-20 14:13:53 -04:00
bherbruck 5d9ebe9911 Renaming a feature no longer rebuilds the design (#3044) (#3097)
* perf: renaming a feature evaluates nothing (#3044)

A feature's name labels it and builds nothing, but a timeline rename evaluated
the whole design again (about 70 s on the Prusa Core One assembly, and again
for undo). When the only change is one feature's name, the model renames its
result, sketch and construction geometry in place and takes the renamed
design's fingerprints. A rename other features follow (a plane a sketch is on)
or of a failed feature still evaluates the design.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Guard cheap commands on designs with thousands of bodies, plus a scaling bench (#3044) (#3121)

Cheap commands stay cheap on designs with thousands of bodies, with a scaling bench.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:08:59 -04:00
bherbruck 76af4ee855 Web: Save to computer… for designs kept in the browser; OPFS writes route as before (#104) (#3687) 2026-10-20 13:51:01 -04:00
bherbruck b2168435ba Web: save to and open from files on your computer (#104) (#3681)
Save As and Open use the browser's file dialogs where the File System Access API exists; Save writes back to the same file, its handle kept in IndexedDB across reloads. File > Download design added. Other browsers keep storing designs in OPFS.
2026-10-20 13:39:55 -04:00
SS-EN 34afcf75c1 Joints: a loop that meets only to within nanometres still moves (#3475)
A closed loop whose joints meet a few nanometres apart along their parallel
axes (faces modelled that far apart) passes the conflict check, but the loop
solver wanted 1e-7 mm before it moved anything. No value of a planar loop
takes such a gap out, so every drive of it stopped at once (stopped_by_loop,
fraction 0) and joint.reach called the loop open.

The solver now closes such a loop as far as it comes and, when what is left
is within the conflict check, keeps it that closed on the way. joint.drive,
joint.reach and the nearby pose of the dof count share it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:41:37 +00:00
Irakli Kourtis 932d8d6b4f Tessellation: trimmed faces of display meshes stay on their surfaces (#1653)
* Tessellation: trimmed faces of display meshes stay on their surfaces

Display meshes triangulate a trimmed face in parameters scaled by the
surface's speed along each, so a strip stretched along one parameter is
not fanned across, and every boundary segment stays a constraint: one that
crosses another is split where they meet. Boolean meshes keep truck's way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* Tessellation: one boundary rule for every mesh; drop the #1648 retry

A boundary segment that crosses one already added is split where they
meet in Boolean meshes too, which keeps a pinched face (#1648) whole
without the snap-and-retry. Faces whose segments all go in mesh as
before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* Robustness goldens: seeds 1, 23, 55, 64 and 86 re-recorded on current main for #1653

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QWhMwdqJYQKQq8RCLSL1LT

* Thread preview test: wait for the stale fit error to clear before the next preview

After switching back from M6x1.3 to M8x1.3, wait_feature_preview stopped at the M6 error still on
screen when the new preview started a few frames later (seen on Linux CI with #1653's meshing).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QWhMwdqJYQKQq8RCLSL1LT

* Robustness goldens: the slow seeds 14, 20 and 40 re-recorded for #1653 from the merge queue

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QWhMwdqJYQKQq8RCLSL1LT

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:49:45 +00:00
fkirill fb87cde6a1 Sketch: persist Shift-picked midpoints with ordinary constraints (#2087) (#2378) 2026-10-20 16:26:19 +00:00
Victor Santos Oliveira 5305d7c82d Clarify delayed detection in unclean-exit reports (#3640) 2026-10-20 16:05:55 +00:00
Colin Faulkingham 59cd55f531 Sketch: keep three-point arcs attached to the through point (#2166) (#3610)
* claim: #2166

* Fix three-point arc through-point attachments (#2166)
2026-10-20 15:55:58 +00:00
kaltenbrunnermarkus 2668518079 Section Analysis: dragging the cut stays smooth on large parts (#3616)
On an imported part with 2.5 million triangles, every frame of a Section
Analysis drag looked up the picked face's plane several times (each lookup
tessellates the whole body, about 0.1 s) and cut every triangle again for
the outline (38 ms).

- planar_face keeps its last answers for the model state they were found in.
- The outline cuts through a SectionIndex: the mesh's triangles sorted into
  slabs along the cut's normal, kept per mesh while the normal stays, so a
  cut looks at one slab (0.06 ms on that part) and finds the same segments.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:54:04 +00:00
rlartot e29a57dc3a inspect.interference/clearance: list mesh bodies as unchecked (#3249) (#3258)
* claim: #3249

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* inspect.interference/clearance: list mesh bodies as unchecked

Mesh bodies (STL, 3MF and OBJ imports) were filtered out before any pair
was formed, so two overlapping STL imports reported count 0 and an empty
unchecked list, which reads as "no collision". Each mesh body is now
listed in `unchecked` as {a: name, b: null, reason: "mesh body: not
checked"} by both commands. inspect.clearance also stops refusing a mesh
body named in `bodies` or `against` as "no solid body"; it is listed
instead. The parameter docs and the assemblies guide say so.

Fixes #3249

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* docs: separate the mesh-body note from the paragraph above

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 15:42:39 +00:00
Jakobonly 23b0120759 Sketch: a radius dimension past an arc's end extends the arc to its arrow (#3393) (#3408)
* claim: #3393

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0158is7fBbAVh538YmVF3MpZ

* Sketch: a radius dimension past an arc's end extends the arc to its arrow (#3393)

A radius or diameter dimension on an arc whose leader is dragged past the arc's
ends had its arrow pointing at empty space. The radial frame now keeps the arc's
span, and an arrow outside it gets the arc continued from the nearer end round
to the arrow tip and a little past it, as dimension drawings do. Full circles,
ellipses and arrows on the arc are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0158is7fBbAVh538YmVF3MpZ

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:40:26 +00:00
SS-EN 76b17f2663 sketch(feat): a click on another dimension inserts its name into the value being edited (#3186)
While editing a dimension's value, a single click on another driving
dimension now inserts its parameter name (a double-click no longer needed,
and its second click does not insert it twice). A lone value such as `10`
or `10 mm` is replaced by the name; an expression in progress keeps what
was typed and gets the name at the cursor (replacing selected text), so
`10 + ` becomes `10 + d10`. The cursor ends after the name and the field
keeps the keyboard.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:32:07 +00:00
phanoob 83790139b1 Editing a sketch past the history marker rolls the marker to it (#2716) (#3339)
* claim: #2716

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Editing a sketch past the history marker rolls the marker to it (#2716)

Edit Sketch rolled the timeline back to just after the sketch only when
the marker was later. With the marker before the sketch it stayed there:
the sketch wasn't built, so it opened with no palette and couldn't be
edited. The marker now moves to just after the sketch either way, and
Finish Sketch puts it back as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Phanoob <phanoob@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:28:52 +00:00
Dahak Builds 7bda28e786 Contact: a check finds bodies crossing face to face, not only a corner inside (#3323) (#3388)
* claim: #3323

* Contact: a check finds bodies crossing face to face, not only a corner inside (#3323)

The collision probe sampled each tessellation's points (vertices, edge
midpoints, triangle centres) against the other body, so plates crossing
face to face - where the crossing strip is smaller than a face triangle -
put no sample point inside and went unreported while inspect.interference
found the overlap. Where an edge of one body passes through the other's
surface, the middle of the chord it cuts lies inside; its distance from
that surface is the depth. Touching and grazing are unchanged. The sweep's
collision work model pays for the added edge pass.

---------

Co-authored-by: DahakBuilds <dahakbuilds@users.noreply.github.com>
2026-10-20 15:21:17 +00:00
Jakobonly 2f5f65d6e3 Sketch: an angle dimension between two lines from one point measures the angle between them (#2724) (#3439)
* claim: #2724

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0158is7fBbAVh538YmVF3MpZ

* Sketch: an angle between two lines from one point measures the angle between them (#2724)

An angle dimension on two lines that share an end (two radii of a circle, the
two sides of a corner) took the acute reading of the lines' directions, so
radii 120° apart measured 60° and 150° apart 30°. With no text position given,
the angle is now measured from the shared end towards the lines' other ends,
the reading anyone would mean; the supplement stays the alternative reading.
Lines that share no end, and dimensions placed with a text position, are
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0158is7fBbAVh538YmVF3MpZ

* ci: re-run the checks; the run on the last push was cancelled

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0158is7fBbAVh538YmVF3MpZ

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:51:06 +00:00
bherbruck 9e78e86b92 perf: renaming a body evaluates nothing (#3044) (#3095)
A body rename changes names only, but committing it went through the general
"design changed" path: the whole timeline was evaluated again. On an imported
assembly that means reading the STEP file again and meshing every body (about
100 s on the Prusa Core One, and the same again for undo).

When the only change from the previous design is one body rename (the
features' stored body names and the body-keyed maps the rename updates), the
model now renames the body in its results instead: every body keeps its
geometry and meshes, and the fingerprints are set to the renamed design's so
the next evaluation reuses everything. Undo and redo of the rename take the
same path. A rename the model can't follow that way (the body is changed or
named by a later feature, sheet metal, threads) still evaluates the design as
before.

`bench-open --ops script.json` times commands and their undo on an opened
design, with the view work each leaves.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:18:40 -04:00
Christian Svensson 511faedcfa Fix OpenGL startup with portable AO depth reads (#2888) (#2933)
* claim: #2888

* fix: preserve AO depth in a portable integer texture (#2888)
2026-10-20 11:41:40 -04:00
bherbruck c5129a45d7 Release 0.17.0 (#3629)
Version 0.17.0; design format 28 fixed (13 pending changes folded).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:41:10 -04:00
bherbruck db75b7bfac Make the off-thread OK test deterministic (#3622)
The test could lose a race: with a zero wait the evaluation sometimes finished and applied
before the check that the dialog was still computing. The evaluation is now held at a test
gate until the dialog has been seen saying so.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:18:32 -04:00
amolinari-autotex 4dcca282da claim: #3245 (#3246) 2026-10-20 14:36:04 +00:00
bherbruck e98e065584 Open saved designs from the model they were saved with (#3053) (#3102)
* WIP: adopt the saved model on open (#3053)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* WIP: tests

* WIP: bench-open opens as Open does

* docs: opening from the saved model

* Tests: an edit after opening evaluates from the feature it changes

* clippy

* Read SOLIDCRAFT_NO_MODEL_CACHE through the env helper

* Open: no picture from the model when the model itself opens; cancel test evaluates

* Saved model: bodies passed through found without writing their record; states list runs of bodies

* Adopted results go into the evaluation cache: an undone edit, or the marker rolled forward, takes them again

* Model cache size guard: the saved model's writer

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:24:34 +00:00
github-actions[bot] af32243df1 Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci] 2026-10-20 14:48:16 +00:00
rlartot 885d2aafbb sketch.create: reject a non-numeric custom plane origin (#3248) (#3252)
* claim: #3248

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* sketch.create: reject a custom plane origin that is not three numbers

A custom plane {origin, x_dir, y_dir} whose origin was given as an
expression ("w/2") or a number with a unit ("20 mm") silently became
[0, 0, 0], while a bad x_dir or y_dir was an error. A missing origin is
still the world origin; one that is present but is not three numbers now
returns a clear error. Expressions are not evaluated, so the saved
format is unchanged.

Fixes #3248

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 14:24:18 +00:00
SS-EN c7bd467be7 joint.inspect: show agents what each joint is attached to (#3453)
* joint.inspect: what each joint is attached to, for agents

A read-only `joint.inspect {joint?}` gives each joint's definition: for each
side the occurrence, its component and path, the snap as saved, and what
each persistent face/edge name refers to (the feature that made it, and the
sketch and curve a swept side came from), whether the origin resolves and
why not; plus kind, offset, angle, flip, values with limits, expressions,
the motion links it is in, and its solve errors and conflicts. Its id ends
in `.inspect`, so agent gates treat it as reading only.

MCP `inspect_design` includes the definitions when the design has joints
(`joints: false` leaves them out). joint.list and joint.inspect share the
motion-link JSON.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* i18n: catalog entries for Inspect Joint (en, ja, zh)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:21:54 +00:00
SS-EN fb811fbf21 Sketch trim: drag a path across curves to trim every piece it crosses (#3182)
`sketch.trim.path {points, dry_run?}` walks a polyline and, at each place it
crosses a curve trim may change, removes that piece exactly as `sketch.trim`
would there, in the order crossed and in one undo step. A piece already gone
is not trimmed again; projected geometry, infinite lines, offset outputs and
pieces trim refuses are passed over (reported in `skipped`). It returns each
trim's result and each removed piece as a polyline; `dry_run` reports them
without changing the design.

With Trim on, a press and drag in the viewport draws that path: the pieces it
would remove light up (a dry run of the command) and letting go runs it. A
click still trims the one piece under it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:20:12 +00:00
takuto-NA 843c0bf97a Reduce repeated sketch diagnostics and enable normal-CI profiling regressions (#2355) (#2359)
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-20 13:55:25 +00:00
Irakli Kourtis 50da77abc4 CI: a run another label starts reports the commit's real result as ci (#3596)
The merge queue reads the required ci check from the newest run of a commit. Adding any label but
approved-for-ci starts a run whose jobs all skip, and its ci job was skipped too (as
'ci (ignored label)'), so a pull request that had passed could not enter the queue (#1850, #3182,
#3252). On such a run the ci job now waits for the commit's other runs to finish and passes only
when the newest one that ran its checks passed.


Claude-Session: https://claude.ai/code/session_01QWhMwdqJYQKQq8RCLSL1LT

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:09:49 +00:00
rlartot 95de388ba1 Sheet metal: refuse an edge flange on a bend line (#3234) (#3273)
* claim: #3234

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Sheet metal: refuse an edge flange on a flange's bend line (#3234)

A pick on the edge where a bend meets the plate (or the flange panel)
found a panel edge that is already a bend line and made a second flange
on it, leaving a gap and a sheet of two shells. add_flanges_each now
refuses such an edge before anything moves.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 12:46:10 +00:00
takuto-NA d8eb65d26e Make constrained sketch edges follow the cursor (#3360) (#3362)
* claim: #3360

* Fix constrained sketch edge following and final drag coordinates
2026-10-20 12:43:59 +00:00
cronos 7c6845da95 Paste: the file-link test builds proper file URLs on Windows too (#3348) (#3494)
The test turned a temp path into a link with `file://` + the path, which on
Windows gives `file://C:\Users\...`, not a file URL, so the test failed there.
It now builds `file:///C:/Users/.../my%20part.stl` on Windows (what Explorer
and browsers put on the clipboard) and `file:///tmp/...` on Linux and macOS,
as before. `files_in_text` already reads Windows file URLs correctly; the
fixed test now checks that on Windows.

Co-authored-by: Ray <raykholo@users.noreply.github.com>
2026-10-20 12:42:43 +00:00
SS-EN 9c26f6dbc9 Agent cursor: bound the show per request; run engine.batch while shown (#2900)
Every engine.execute was acted out stop by stop (0.45 s glide + 0.25 s click
per toolbar button and per on-screen point), so a 720-point sketch.line took
minutes while the app answered no other control request. Now the pointer
visits the toolbar button and at most three picks (a long point list is
summarised by its first, middle and last point on screen), and a request is
acted out within 1.5 s whatever its size (the legs get shorter).

engine.batch is no longer refused while the cursor is shown: the pointer acts
out a short summary (the toolbar buttons of up to four of its distinct
commands, within the same budget), then the whole batch runs in one request.
A failed engine.batch now says whether the design really went back
(rolled_back).


(cherry picked from commit 55535e01af45cc9ee7555320863ff5ddcd7c151f)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:41:05 +00:00
shop-3d 2061ad1694 i18n: add missing Danish translations (#3028)
* i18n: add Danish (da) UI translation

* i18n: refine Danish part properties wording

* i18n: sort Danish catalog in canonical key order
2026-10-20 12:39:11 +00:00
Lucas Montenegro Carvalhaes e37a8fa6bb Fillet: skip smooth seams in tangent chain and restore picked edges when unticked (#3370) (#3437)
* claim: #3370

* fillet: stop tangent chains at smooth seams and sync chain toggle (#3370)
2026-10-20 12:38:48 +00:00
Igor Bronovskyi b3811168fc fix(web): keep the CJK face a fallback for a non-CJK UI (#3535)
BIZ UDPGothic's Cyrillic glyphs are full-width (1.0 em), so promoting the
CJK font to the front of Proportional made Ukrainian text look wide with
large gaps between letters, broke word wrapping, and truncated UI labels.
When CJK text loaded on the web, install_cjk_font hard-coded "ja" and
stayed promoted after switching to Ukrainian or English.

Pass the live UI language to install_cjk_font_for so the CJK face only
leads a CJK UI. Keep fetched CJK font bytes on the web shell and re-apply
fonts when the language changes, and add a regression test that a
non-CJK UI keeps the CJK face as the last fallback.
2026-10-20 12:38:27 +00:00
rlartot c64a611818 Boolean: fail an unverifiable intersection instead of keeping what it should trim (#3244) (#3344)
* claim: #3244

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fail an unverifiable intersection instead of keeping what it should trim (#3244)

When every route of the general boolean fails, it falls back on the sampled answer
(one body whole, or nothing) with a "not verified ... that part is ignored" warning,
even when the bodies' facets cross well past their faceting error. For an
intersection that keeps exactly the material it was asked to trim: teeth standing
0.27 mm out of a trimming cylinder came back untrimmed, and `solidcraft-cli run`
exited 0 with a part 16.918 mm in radius.

An intersection whose sampled answer is contradicted by facets crossing by more than
twice the depth the warning reports (6 x the check mesh tolerance, three times the
faceting error of two meshes) now fails, and the model is kept. Cuts and unions keep
the fallback: shelling a body in two pieces relies on it (a cavity tool grazing the
other piece), with the same depth and ignored volume as this case.

Fixes #3244

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Leave an empty unverified intersection as it was (#3244)

Only a doubted intersection that keeps one body whole is refused. An empty
one ("the bodies are apart") stays a result reported unverified: the clearance
and interference checks rely on it, and main's new
a_pin_through_a_d_bore_of_its_size_still_overlaps_the_flat (#2714) expects it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:37:43 +00:00
phanoob 9a0e6f8ae0 A body dropped on a component's Bodies folder moves into that component (#3270) (#3347)
* claim: #3270

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* A body dropped on a component's Bodies folder moves into that component (#3270)

The Bodies (and Sketches) folder row only took items of its own
component, to take them out of groups. A body dragged there from another
component was refused, though the folder looks like the place to drop
it, so moving a body back to a parent seemed impossible. Items of the
same folder from another component now move into the folder's
component, as a drop on the component's own row does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Phanoob <phanoob@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:36:37 +00:00
rlartot 689038d187 Delete Face asks for its faces instead of failing (#2167) (#3296)
* claim: #2167

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Delete Face asks for its faces instead of failing (#2167)

Clicking Delete Face in the toolbar ran face.delete with no parameters and
reported "`faces` must be a list of points or face names", ignoring any
preselected face. It now opens a dialog with a Faces input, like Shell,
Draft and Offset Face, takes the preselected faces, and reopens from the
timeline.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 12:35:30 +00:00
Nathan Charles 3f7959b536 Canvas and Decal: the file picker opens on PNG and JPEG images (#3266)
* Canvas and Decal: the file picker opens on PNG and JPEG images

canvas.insert and canvas.decal opened the general Open dialog with no file
type, so it started on "Designs, CAD files and meshes" and PNG/JPEG files
were greyed out. Offer the image types first, as Insert SVG/DXF do (#1801).

* Canvas and Decal: rustfmt the file-picker test
2026-10-20 12:34:23 +00:00
Irakli Kourtis a7bc7fe2ff AGENTS.md: merge queue, no merges of main, check open work before starting (#3559)
- The repository is tangency-labs/solidcraft (origin and the claim check pointed at the old one).
- PRs land through the merge queue (`gh pr merge N --squash --auto`) after a maintainer adds
  approved-for-ci; merging your own PR straight away is gone.
- A PR is rebased onto main, never merged with it.
- New "Before you start": an authenticated gh, the full open PR and issue lists, and a look for the
  same feature or the same files before opening a second PR for one change.


Claude-Session: https://claude.ai/code/session_01QWhMwdqJYQKQq8RCLSL1LT

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:28:22 +00:00
Georgette 876d57a67b Mouse gestures: ring of tools on the right button, per studio and customizable (#2934) (#2936)
* Mouse gestures: a ring of tools on the right button, per studio and customizable

Preferences > Navigation gets a Mouse gestures switch and a Customize window (number of
tools and their place, per studio). Gestures need the SolidWorks mouse mode and turn the
right-drag orbit off. The ring is 280/350 pt, one zone per tool, alternating tints (three
when the count is odd). The radial context menu is unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dwcq9eNUMZ1jNeKvWd1bcZ

* Mouse gestures: the ring opens on the right-button press; the right-click menus are off while gestures are on

The ring opens at once on the press, even while a tool or dialog is active. While gestures are
active the viewport's right-click context menu, the right-click that finishes a tool and the
radial context menu no longer fire. With gestures off nothing changes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dwcq9eNUMZ1jNeKvWd1bcZ

* Mouse gestures: the ring opens after 4 px with the right button held; a plain right-click keeps its menu

The ring no longer opens on the press. It opens where the button went down once
the pointer has moved 4 points with the button held, even while a tool is in
progress. A right-click that does not move opens the usual context menu (or
finishes the tool) again; the release that ends a gesture opens no menu. The
radial menu stays off while gestures are active.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dwcq9eNUMZ1jNeKvWd1bcZ

* Re-trigger CI (the run was cancelled when approved-for-ci was added)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dwcq9eNUMZ1jNeKvWd1bcZ

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-20 12:28:11 +00:00
bherbruck 47108fa063 Open: show a design as its bodies are meshed (#3053) (#3178)
* Open: show the design as its bodies are meshed, off the UI thread (#3053)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Open: hold screenshots until the design is meshed; profile lines

* Tests: time-bounded opens

* Open: a Loading parts n/N note while bodies are meshed

* Open: frame the design from outlines while it goes into the window

* Outline bounds: vertices and edges only; test

* Frame watch: the window thread's frame times by open phase (SOLIDCRAFT_PROFILE_FRAMES)

* Open: the picture grows in mesh order and stays once complete; refreshes pace themselves; named frame steps; watchdog test

* Open: outlines measured on the file worker and kept per body

* Open: draw the Home picture under the opened file's own key once meshed

* Format the open wait

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:27:42 +00:00
raykholo ca9ebb44db Sheet metal: refuse a flat pattern that no longer matches the body (#3324) (#3325)
* claim: #3324

* Sheet metal: refuse a flat pattern that no longer matches the body

A feature that is not sheet metal can change a sheet body's material
without its sheet model knowing: a mirror combined into it, an extrude
joined onto it, a fillet or chamfer on a corner. The flat pattern, the
DXF and the bend table then wrote the sheet as it was before, a part
other than the one in the design, with no warning.

The evaluator now notes, per sheet, the first such feature: one that
changes the body's shape while its sheet model stays the same. That
feature warns, and sheet.flat_pattern, sheet.export_dxf (also with
per_body) and sheet.export_bend_table refuse with "not supported yet",
naming it. A sheet metal feature, or machining the sheet keeps as
cut-outs, rebuilds the body from the model: it clears the note and
warns that the change is not kept. Moving the body, splitting its faces
and cosmetic threads leave the flat pattern as it is.

* Sheet metal: a modelled thread is refused in the flat pattern too

Only a cosmetic thread leaves a sheet body's material as it is. A
modelled thread cuts the thread profile into the hole, which the flat
pattern does not have, and a later sheet metal feature dropped it
without a word. It now marks the sheet like a fillet does: it warns,
the flat pattern, DXF and bend table refuse naming it, and the feature
that rebuilds the body says the thread is not kept.
2026-10-20 12:07:24 +00:00
jb69 e44dd4ff9e Loft: refuse consecutive sections that lie in one plane (#3412)
A loft through two sketches on the same plane built a flat body of zero
volume and reported success, so the mistake surfaced later, if at all
(a measure of 0, or a boolean that did nothing).

`loft_wires` now checks each pair of consecutive sections: parallel
planes with the second section's centroid in the first's plane are
refused with "loft sections i and i+1 lie in the same plane (no volume
between them)". The tolerance scales with the section's distance from
its plane's origin.

Test: two circles on XY; the loft fails with that message and leaves no
body.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:43:14 +00:00
jb69 ebada963a4 Parameters: walk through a flipped plane to its base's expressions (#3481)
`PlaneRef::walk_exprs` stopped at `Flipped`, so a sketch created with
`flip` on an offset plane (`{plane: "XZ", offset: "d", flip: "x"}`) did
not list the offset as an input. Changing `d` rebuilt nothing that read
the sketch, and the model went stale.

`Flipped` now continues to its base, as `Offset` and `AtAngle` do, in
both `walk_exprs` and `walk_exprs_mut`.

Test: a loft between XZ and a flipped XZ plane offset by `d` has the
volume for d = 30, and for d = 50 after `parameters.change`.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:43:10 +00:00
raykholo 9666c0be56 Convert to Sheet Metal keeps a plate where it is (#3316)
* claim: Convert to Sheet Metal keeps a plate where it is

* Convert to Sheet Metal keeps a plate where it is

Converted without bend edges, a plate moved by one thickness: a 100 x 60 x 2
plate on XY came out at z -2..0 picked on its top face, 2..4 picked on its
bottom. The sheet's frame (its base panel's bottom face, thickness along its
normal) was put on the plate's far face facing away from the picked face, so
the material grew out into empty space.

The frame now lies on the picked face with z into the material, as Convert
with bend edges does. The sheet is the old one moved back by the thickness:
same orientation, flat pattern, flange directions and pocket layers.

A test converts the plate from either face and checks its bounding box; the
engraved-plate test now checks the plate's position, not only its thickness.
2026-10-20 11:43:07 +00:00
yadi choudhary 9506336829 Fonts: find a Chinese fallback font on Linux and Windows (#3376) (#3466)
* claim: #3376

* Fonts: find a Chinese fallback font on Linux and Windows (#3376)

The Chinese fallback was searched for at a few fixed paths, and in the
font folders only on macOS. On Linux and Windows a Chinese font anywhere
else (Fedora's NotoSansCJK-VF.ttc, WenQuanYi under truetype/wqy/, Source
Han Sans, a per-user font) was never found, so a Japanese CJK font drew
the Chinese interface alone and its simplified characters (设, 语, 实)
showed as boxes.

Search the system and per-user font folders on every desktop system,
three folders deep, and take a font only when it draws simplified
Chinese, so a Japanese-only file with a matching name is skipped. The
"sourcehanss" keyword matched no real file; match SourceHanSans* and
SourceHanSerif*, and Noto Serif CJK/SC.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:33:06 +00:00
Jakobonly 9dfa9e2d52 Timeline: the mouse wheel scrolls the horizontal timeline (#3356) (#3409)
* claim: #3356

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0158is7fBbAVh538YmVF3MpZ

* Timeline: the mouse wheel scrolls the horizontal timeline (#3356)

The horizontal timeline is a horizontal-only scroll area, which egui scrolls
only with Shift + wheel or a sideways trackpad swipe, so with a plain mouse
wheel a history wider than the window could not be scrolled at all. The strip
now takes the vertical wheel as sideways scrolling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0158is7fBbAVh538YmVF3MpZ

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:33:03 +00:00
rlartot c5b637600c Chamfer: one pick follows a straight edge's curved tangent chain at any part size (#2672) (#3295)
* claim: #2672

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Chamfer: one pick follows a straight edge's curved tangent chain at any size (#2672)

chamfer_tool built a single-edge cutter for a straight edge even when the
edge runs on tangentially into an arc (a slot side into its end). The
cutter's overshoot (max(2*distance, 1% of the part size)) is probed for
material beyond the edge ends; on a small part the probe hit the arc's
material and the cutter was refused, so the blend followed the whole
chain. Past a bounding-box diagonal of about 200 mm the overshoot cleared
the arc, the cutter was accepted, and only the straight edge was chamfered,
silently.

A straight edge whose smooth chain contains a curved edge no longer gets a
cutter; it takes the chain-following blend, as at small sizes and as a
fillet does.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 11:21:32 +00:00
JEREMY 3855c4c7e2 Sheet metal: two corner reliefs that would overlap are both left out (#3421)
The two reliefs that would overlap are still resolved one after the other: the
second one is left out, with a note. The corners come out in flange-index
order, so which of the two keeps its relief depends on the order the flanges
were made in.

Two corners that share an edge never come this far: each relief reaches
exactly r along that edge, and a relief is refused unless 2r fits in it. Two
corners that do not share one can: the square relief at a corner of 120°
reaches 2r from it along the bisector, so at the two 120° corners opposite
each other in a rhombus the two reach each other.

Two reliefs that would overlap are now left out together, each with a note
naming the other, so that which of them is cut does not depend on the order
the flanges were made in.

Fixes #3417.

Co-authored-by: jeremymiribung-blip <jeremymiribung-blip@users.noreply.github.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-10-20 11:21:29 +00:00
DuBearte d60b438f42 A finished sketch shows its standalone points (#3267) (#3304)
* claim: #3267

* fix(view): a finished sketch shows its standalone points (#3267)

Points were drawn only for the sketch being edited, so a point placed for a
hole vanished once the sketch was finished although it could still be picked.
The standalone points of every finished sketch shown are now drawn.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:21:26 +00:00
github-actions[bot] 2ace233cd4 Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci] 2026-10-20 11:33:12 +00:00
AlF c83446c6c9 Fix issue 3120, import of STEP file not correct (#3208) 2026-10-20 11:07:50 +00:00
bherbruck 349a6c6fc4 perf: a copy placed for showing doesn't turn its revolved faces again (#3044) (#3104)
Moving an occurrence places every body under it again, and each placed copy
searched every revolved face for where it sits to turn it away from its seam,
although its source had been turned already. On the Prusa Core One assembly
that search was most of the 12.7 s a move of the top occurrence took. A body
now records that its revolved faces are turned, and world placement
(`place_matrix`) skips the search for such a body. Modelling moves (pattern
copies, hole tools) keep `transform_matrix`, which still turns them in the
copy's own frame: where a face wraps all the way round the turn it finds
depends on those coordinates, and later booleans depend on it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:01:56 +00:00
bherbruck f6cf8ceaad perf: an instanced STEP part's first instance shares its display mesh (#2814) (#3047)
Later instances of a part showed a mesh shared through the first instance,
but the first instance meshed its own, so every repeated part was meshed
twice. The first instance of a part placed more than once now shows the
shared mesh too; a part placed once keeps its own (no second copy).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:01:50 +00:00
bherbruck 83ecf91ae7 perf: autosave writes on a worker thread (#3044) (#3139)
Autosave serialised and wrote the whole design on the window's thread: about
120 ms on the Prusa Core One assembly (its 61 MB STEP text inside), a hitch
once per interval after every change, and more once saves keep the evaluated
model. A tick now takes a snapshot of the design (its Arc and what is hidden)
and writes it on a worker; a later tick takes up the outcome, and no new write
starts while one runs. Crash replays rebase onto the autosave only when the
design is still the one written. Save and close still wait for the write, and
dropping the autosaver lets a running write end.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:00:11 +00:00
fugazister a3f28c0b89 Look At frames the sketch's geometry, not its plane's origin (#3198) (#3199)
* claim: #3198

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Look At frames the sketch's geometry, not its plane's origin (#3198)

Look At on a sketch (its menu, Ctrl+8, the navigation bar, entering a
sketch) centred the view on the sketch plane's origin and kept the zoom,
so a sketch drawn away from it - an imported part placed far from the
origin - ended up out of view. Both paths now share onto_sketch: centred on
the sketch's bounds, zoomed out when they would not fit (never in); an
empty sketch keeps the plane's origin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Register the Look At tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Look At tests: Vec3::len

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:47:26 +00:00
SS-EN ac757960dc Boolean retries: a candidate whose edges run far outside the operands is discarded unmeshed (#3020) (#3185)
The retries in boolean_whole_inner mesh every candidate to compare its
volume. A nudged union at the coarse tolerance could come out with a face
on a cone whose trim loop ran metres along the cone's continuation (edges
up to 12.7 m outside a 155 mm part); meshing it took seconds to minutes per
face and once never finished, though its volume (+-1e10 mm3) failed the
check anyway.

check() first samples the candidate's edges (16 per edge) and discards a
candidate with a point further outside the operands' mesh box than 5% of
their size (or a non-finite point): no edge of a union, cut or
intersection can lie there. The reason goes into the new
BooleanDiagnostics::rejected (omitted from JSON when empty). Results of
sound candidates are unchanged.

On the reporting design the PitchTail join drops from 18.7 s to 4.3 s with
the same result; the regression tests synthesize a cone face whose loop
runs off its surface.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:45:27 +00:00
Matthew Knutson 953fd7d5f8 Typed parameter contracts and JSON Schemas for solid.box and solid.extrude (#2829) (#2611)
* Spike: typed parameter contracts and JSON Schemas for solid.box and solid.extrude

The schema RFC's spike (plan/schema-spike.md). solid.box and solid.extrude get
typed contracts with generated JSON Schemas (2020-12), checked in front of the
unchanged handlers by a per-session level: off (the default, which changes
nothing), warn (the call runs and gains param_warnings) or strict (the call is
refused and the design left alone). Also engine.describe and the MCP tool
describe_command, `solidcraft-cli mcp --param-check off|warn|strict`, a
FeatureResult type for feature replies, and an env-var audit recorder
(SOLIDCRAFT_PARAM_AUDIT) used to check the contracts against every test.

Local spike branch; not for upstream as is (see the results report).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Parameter contracts: put the audit recorder behind a feature; drop two ignored test keys

The audit recorder is now on only in a build with the engine's `param-audit` feature
and `SOLIDCRAFT_PARAM_AUDIT` set. Its code is still compiled, linted and tested without
the feature. `scripts/param_audit.py` summarises a test run's audit logs, and the schema
module's docs give the steps for converting a further command.

Two tests passed keys their command ignores, which the audit found: `position` on
`solid.box` (`hole_face_tests.rs`) and `symmetric` on `solid.extrude` (`face_tests.rs`).
Both are removed, so the tests build what they always did. Comments no longer call
the contracts a spike.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-20 10:45:08 +00:00
Artem Sushchev ae2ab87b99 Cut: a body with a sealed void no longer comes out empty (#3175)
truck placed a face that no intersection divides (every face of a shell
that meets nothing of the other) by counting the faces a ray crosses,
taking each face's surface as stored. A revolved body's reversed faces
counted the wrong way, and an inward shell (a void, a cut's inverted
tool) was read as a solid: a cut beside a sealed void came out empty,
refused or, with a tool whose bounding box covers the target, taken with
the body gone. Intersections of such bodies came out empty as well.

In an AND the crossings now count as each face is oriented, and an
inward shell holds what lies outside it. An OR keeps truck's count.

The corrected cut let a folded offset through: an arc edge next to a
face it meets tangentially had its ends moved past its middle, and the
arc went the long way round (robustness seed 73's shell). The offset
refuses an arc whose middle crossed its chord, as it does a straight
edge that turned round.

Goldens: seeds 0, 45 and 77 had a cut refused as empty that now takes
what it should; their volumes are checked by hand in the PR.
2026-10-20 10:30:13 +00:00
Mateusz Sych 8a562f2719 Mesh faces whose boundary touches itself within tolerance (#1648) (#1850)
A face pinched to a point, where its boundary touches itself or another of its loops within
TOLERANCE (a sphere touching a box's edge leaves one on the box), had a constraint refused by the
CDT, and the fallback cut across the face: it came out with no triangles or without those at the
pinch. When a constraint is refused, the vendored truck-meshalgo now puts every boundary point
within TOLERANCE of a segment into it (at most 8192) and triangulates the face again. Faces whose
segments all go in mesh as before.

The regression tests live in crates/kernel/src/thin_face_tests.rs, and the #1648 property test in
boolean_props.rs is no longer ignored. Robustness seeds 20 and 55 are recorded with their Linux
values.

Co-authored-by: MateuszSychXpl <151755914+MateuszSychXpl@users.noreply.github.com>
2026-10-20 08:28:21 +00:00
fugazister e9b1a077d7 Delete: check what a delete breaks off the window thread (#2678) (#2679)
* claim: #2678

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* WIP: timeline.dependents shares the evaluation cache (#2678)

The check before a delete evaluated the edited design on a fresh model:
it recomputed the whole timeline and left nothing for the delete. It now
evaluates on a copy of the session's model. Adds Ahead::replies for
running such checks off the window thread (the UI part is still to come).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Delete: check what a delete breaks off the window thread (#2678)

Both checks a delete makes first (timeline.dependents for the timeline's
Delete, the selection.delete dry run for Delete on the selection) evaluate
the history after the deleted feature, on the window thread. A part with a
slow feature froze the window for as long as everything after it took
(5.9 s in the report). They now compute off the window thread like undo and
the history marker: within the app's 150 ms wait they carry on at once,
otherwise the window says it computes and the delete (or the confirmation)
follows when the report is in. A design changed meanwhile is checked again;
a second Delete meanwhile is left out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci: run the checks again (the run for 3b94e8e0 was cancelled when the PR left draft)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-20 06:37:50 +00:00
Irakli Kourtis 37a8402cae surface.blend: seam alignment, chain-topology refusal and a dialog (#764) (#2488)
* kernel: a blend runs along chains of several faces and lines closed rims up (#764)

A blend's chain may run along the boundaries of several faces (a tube's rim
along the halves of its wall): each edge continues from its own best-facing
face, read station by station. Before, a chain had to be the edges of one face,
so every rim of a revolved or cylindrical surface was refused.

Two closed chains blend from matching seams: the second chain's start (searched
by arclength, then narrowed) and direction are the ones that put each station
closest to its partner, so rims whose seams sit apart no longer twist. A
picked `seam` point on each chain fixes the start instead. A closed chain
against an open one is refused. Closed chains leave towards each other's
centroid, not a point on the rim.

Stations are read on the edges' own curves, not on the chords of the length
table (they sat up to the chord's sagitta inside a curved edge), and the
reported gap is measured to the curves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* kernel: closed blend chains run round the same way; Hermite strip and seam tests (#764)

The second of two closed chains runs round the same way as the first (by
their area vectors), so a picked seam turns the blend by just the angle
between the seams instead of folding it over. The nearer pairing decides only
when the loops stand almost at right angles.

Tests for the issue's gates: the blend between two offset box rims is the
analytic cubic Hermite strip (control points from the chord) and keeps the
top faces' normal along both seams; picked seams a quarter apart turn the
blend a quarter round.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* surface.blend: `seam` picks where each chain's stations start (#764)

A point on each chain, saved with the feature (one pending format change),
moved with the design like `align`, and refused when it is not two points.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* surface.blend: a test between two tube rims, their seams picked and saved (#764)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* Blend dialog: edges at each end, continuity and bulge per end, stitch (#764)

SURFACE › CREATE › Blend Surface opens a dialog: the edges the blend leaves
and the ones it arrives at, each end's continuity (G0–G3) and bulge, and
Stitch to both bodies. Edit Feature brings it back and keeps what the dialog
does not show (align and seam points). Labels in every locale catalog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* ROADMAP: M8 Blend runs along several faces, pairs closed rims and has a dialog (#764)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* kernel: a blend station on the vertex between two support faces is read on the neighbour (#764)

A station where a tube wall's two halves meet sits at a corner of one half's
parameters; with a leave direction that turns round the wall (tubes off each
other's axis) the shape could not be read there and the blend was refused. The
neighbouring chain edge's face holds the same point and reads it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* blend: review clean-ups (#764)

- ChainLine keeps one list of samples instead of four parallel lists.
- frames_on and boundary_gap take the chain's ChainLine instead of rebuilding it.
- A station is read on the neighbouring face only when it sits on the vertex the two edges
  share, each face once, and each face keeps its own walk.
- The seam rule (a closed chain starts at it, an open one runs from the nearer end) lives in
  ChainLine::start_at_seam; pair_with reuses the start of its both-ways search.
- support_faces projects with curveblend::project; loop_area sits with plane_through.
- surface.blend parses `align` and `seam` through one point_pair; the Blend and Ruled dialogs
  share body_and_edges.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 04:26:12 +00:00
Erlis Vidal 9e15995a74 A cut ending in a face of its target no longer leaves the hole closed by a skin (#2827)
A cut whose tool ends exactly on a face of the target (a hole cut from a pocket's floor up to the
plate's top face) could come out of the general boolean with the target's face whole over the hole
and the tool's end face under it: a skin of no thickness that keeps the hole closed. No volume
shows it and the sampled membership checks see the hole either way, so the result was kept. The
result check now refuses a body with a planar face lying back to back on another of its own planar
faces with nothing behind part of it (`coplanar::has_skin`), and the retries take the cut.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 04:15:37 +00:00
Peppe Bergqvist 926a7353a3 Sheet metal: possible to create flange on a flange (#3274) 2026-10-20 04:02:20 +00:00
Victor Santos Oliveira c400188778 fix(ci): normalize Windows Cargo metadata paths (#3164) 2026-10-20 04:01:10 +00:00
phanoob 58303ba036 Preview: nothing is drawn once the current inputs fail (#3254) (#3256)
* claim: #3254

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Preview: nothing is drawn once the current inputs fail (#3254)

The last good preview stayed up when the current inputs made the feature
fail, so the view contradicted the dialog (Extrude at 0 mm still showed
the 1 mm extrusion beside its error). Once the result for the current
inputs is an error, the preview scene is hidden and the bodies it stood
in for come back; while the next preview computes, the last good one
still stays up as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Extrude preview: a zero distance is refused at once (#3254)

The straight extrusion feedback gave up on a zero distance, so the
preview built a whole scratch design only for the kernel to refuse it,
and the old preview hung on until then. It now returns the extrusion's
own error straight away.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Phanoob <phanoob@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 03:50:52 +00:00
JIJINBEI fe3b14e798 refactor: Aabb2 and point-built Aabb3 for every hand-rolled box (#2428)
* Iterate instead of indexing in nine loops

Replace index loops that look up neighbours by position with enumerate,
zip(cycle().skip(1)) for wrap-around pairs and zip(skip(1)) for
consecutive pairs. No behaviour change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* geom: ring_edges for the edges of a closed ring

Every walk over a polygon's edges, the last back to the first, spelled
x.iter().zip(x.iter().cycle().skip(1)); eighteen of them now call
geom::ring_edges. Three redundant take(len) calls go with them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* geom: Ring, a closed ring that knows it is closed

A Ring holds items in order around a closed polygon, loop or fan; edges()
walks each with the next and the last with the first, corners() each
with both neighbours. Ring<Vec2> has signed_area and even-odd contains.

Loop2::polyline returns a Ring, as do doc's face_loops and
boundary_loops, kernel's coplanar wire samples, CapLoop's segments,
tess pieces' rings, the gizmo's plane quads and the navigation cube's
facets. Their walks take edges() or corners() instead of wrapping an
index; sketch's text-hole test and doc's poly_area become contains and
signed_area. Loop2::joints pairs each segment with the next.

Replaces the ring_edges free function.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* geom: Chain, Ring and Path; bounds for a chain of points

Ring becomes Chain<T, Closed> beside Path = Chain<T, Open>: edges()
closes a ring and stops at a path's end, Path::close makes the ring,
and bounds() (the empty box for no points) serves both. Eight
hand-rolled min/max folds over a loop's polyline call bounds(); doc's
flat_polyline returns a Ring.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* geom: area and inside on Ring for any planar point

Ring's signed_area and contains take any Planar point (Vec2 or
[f64; 2]). The cam profile in joints_cam is a Ring: its own even-odd
test goes, and its edge walks, gap and area take edges(). face_params'
loop area, the text path's winding, tess pieces' ring area and the
copies in three tests call signed_area or contains; the formulas are
the same term for term.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* doc: sheet's closed outlines go through Ring

dedup takes and returns a Ring (dropping a last point back on the first
is what makes it a ring's), near_outline walks a Ring's edges, and the
copy of it beside fold checks calls it. point_in goes: its callers ask
the Ring. Its formula anchored each crossing on the other end of the
edge, so the two can differ only for a point on an edge to the last
digit; one caller sets such points aside first, the others test a
region's interior point, a cut's midpoint or another outline's corners.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* doc: cam tests build their outlines with Ring::from

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* geom: area_vector on Ring<Vec3>; three Newell sums use it

Ring<Vec3>::area_vector is half the sum of a × b round the ring. heal's
loop_area, lip's outer-loop turning and the revolve's loop orientation
summed it by hand with a wrapped index; they take it from the Ring
(the half is exact, and two of them only read its sign).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* geom: Aabb3 from points; boxes built and asked through it

Aabb3 collects from points (skipping non-finite ones, as add does) and
gains inflated, contains and meets. Fifteen hand-rolled min/max folds
over points or boxes in kernel, doc and engine become collect or union,
and their "within tol of the box" tests become inflated(tol).contains.
blend's refused-edge boxes and naming's face index hold an Aabb3.

A NaN point is now outside every box, where the old negated
comparisons let it in; an infinite point no longer stretches a box.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* geom: Aabb2, the plane's box; 2D folds and box tuples use it

Aabb2 mirrors Aabb3 (collect, add, union, inflated, contains, meets)
and adds relative, a point's place in the box. sketch's link and
link_loops each had that formula; link_loops' boundaries, prism's
segment and loop boxes, profiles' shape boxes and grid, and ends'
segment box hold an Aabb2 instead of a (low, high) pair. Folds in the
cam, sketch bounds, sheet's perforation grid and the sketch clipboard
collect one.

Boxes are built from the same terms (a round's box keeps c - r - tol).
As with Aabb3, a NaN point is outside every box and an infinite point
no longer stretches one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* kernel: prism's segment boxes are plain Aabb2s

The SegBox alias named a (low, high) pair; Aabb2 says it itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* geom: Chain::bounds is an Aabb2; the boxes #2382 left as pairs follow

Chain::bounds returns an Aabb2. Its callers keep it: sheet's hole and
cut boxes (loop_box), letter boxes, island outlines and line-near-cut
test use meets, contains and inflated; profile's outline overlap and
max_clearance, profiles' box tests and the cam test read its corners.
flat_size and the flat pattern's JSON size are the union of the loops'
boxes (0 × 0 for no loop, where the pair fold gave -∞).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* geom: Aabb3::intersection; hand-grown and hand-met boxes use the box

Aabb3::intersection is the box both hold. ops' four overlap boxes of a
boolean's operands take it, and seven boxes grown by hand (ops' body
boxes, the distance set's bounds, coplanar's face box, eval_more's
padded mesh box, compare's group and region boxes) call inflated with
the same amount on every side.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-20 03:44:49 +00:00
leValdemar19 e27d43553c Sketch Dimension: line to circle/arc edge (#1803) (#1863)
A line and a circle or arc can now be dimensioned to the circle's edge
(ConstraintKind::LineCircleDistance, saved as line_circle_distance): the gap
while the line misses the circle (0 is tangency), or how far the edge reaches
past the line when it cuts through (stored as `inside`).

Rebased onto main as one commit (the branch's own changes; its merges of main
dropped). Conflicts resolved: the test mod lines in cmd/sketch.rs (both kept,
sorted), and README.md, whose feature list main moved to docs/capabilities.md;
the line-to-circle dimension is listed there now.



Claude-Session: https://claude.ai/code/session_01QWhMwdqJYQKQq8RCLSL1LT

Co-authored-by: jyviko <jyviko@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 03:44:16 +00:00
Thodoris Tsagkogeorgas de7fabc3bc Preferences: a Plugins page lists installed plugin folders (#2718)
* Preferences: a Plugins page lists installed plugin folders

Each folder under the per-user plugins directory that has a plugin.json
appears in Preferences. Turning one off is saved with the other settings.
The page reads the manifest and does not run any code from the folder.

* Retrigger checks after the cancelled run

The required ci check failed because the workflow was cancelled before
any job started. This commit does not change the plugins page.
2026-10-20 03:38:47 +00:00
Igor Bronovskyi 1b6c10f6e0 Translate the Ukrainian catalog messages added after #1632 (#2569) 2026-10-20 03:36:01 +00:00
fkirill 59b6eea516 Support tapered extrusions at line/arc corners (#2754) (#2788)
* claim: #2752

* claim: #2754

* claim: #2751

* claim: #2752

* claim: #2754

* claim: #2751
2026-10-20 03:31:55 +00:00
phanoob b168750fb3 Viewport: the ground grid stays when zoomed in close (#3087) (#3124)
* claim: #3087

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Viewport: the ground grid is centred where the view's axis meets the ground (#3087)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Grid: its centre stays within reach of the point under the target

At a grazing slant in perspective, where the view's axis meets the
ground runs far off, and the ground in front of the camera had no grid.
The centre now moves no further than the grid's half-size from the
point under the target (review of #3124).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Phanoob <phanoob@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 03:28:11 +00:00
Nathan Dane 25ea9e6419 icons: add the silhouette master and wire it to inspect.silhouette (#2102)
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-20 03:25:17 +00:00
Colin Faulkingham fa3ada1983 claim: #2708 (#2739)
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-20 03:25:05 +00:00
Karl Von Richter 759e22b582 Try exact local cuts after invalid empty numerical results (#1765) (#1858)
Two empty numerical cut attempts stopped the boolean retry sequence before
the exact geometric strategies ran. When the result guard refuses that empty
candidate, the exact strategies (sliced planar, pushed walls, local prism,
sphere lens, seam halves) now try the cut first; if none recovers it, the
original empty candidate and diagnostics are kept, before any displaced
numerical retry.

Rebased onto main as one commit (the branch's own changes, its merges of main
dropped). The only conflict was the new engine regression test next to
main's #1985 STEP export tests in boolean_regression_tests.rs; both are kept.
2026-10-20 03:25:02 +00:00
Irakli Kourtis 14b7529465 Loft guide rails (#201) (#3371)
* kernel: loft guide rails (#201)

A loft follows one or two guide rails between its sections: stations are added between each two
sections, each the plain loft there moved so that the point a rail guides lands where the rail
crosses the station's plane — turned about the loft's direction and scaled (or stretched across
to the rail) with one rail, carried, turned and sized between two. The sections and caps stay
exact; the smooth loft runs through sections and stations. Solid and sheet lofts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* doc, engine: solid.loft and surface.loft take guide rails (#201)

`rails` (one or two {sketch, curves}) and `rail_fit` (scale | stretch), stored on Loft and
SurfaceLoft (one pending format note), parsed by the sweep's rail parser (now shared) and built
through the kernel's railed loft. Rails on a loft to a point, or on a surface loft with edge
sections or end continuity, are refused as not supported yet. The rails' sketches are walked as
the feature's references and their components placed like a sweep's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* ui: Rail, Second Rail and Rail Fit in the Loft and Surface Loft dialogs (#201)

The rail inputs and fit row the sweep dialogs have, shared through dialogs_sweep; Edit Feature
brings the rails back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* docs(roadmap): loft guide rails (#201)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* loft rails: review clean-ups (#201)

- Sweep and loft rails share one turn-and-size map (`sweep_path::rail_frame`).
- A station's middle and the loft's direction come from one curve through the sections'
  middles (exact derivative) instead of two more plain-loft evaluations per station; at most
  33 wires, so railed faces stay light.
- Nearest points on a section use `param_on` (kept only where the search comes nearer); the
  size uses `Aabb3`; one B-spline builder; anchors keep their nudge.
- The railed and plain lofts share one path in `loft_with_rails` and `loft_sheet_with_rails`.
- The engine leaves the not-yet-supported refusals to the design's evaluation; both lofts start
  their rails from the first section's first point.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* loft rails: refuse rails on sections with holes, with a test (#201)

Main's lofts now take sections with holes (#2447); rails shape only the
outlines, so a railed loft of rings is refused until the rails carry the
holes too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 03:22:30 +00:00
github-actions[bot] bdfaf690d7 Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci] 2026-10-20 03:32:45 +00:00
cronos b4520d265e perf(ui): show sample thumbnails immediately and reuse Recent previews (#2944) 2026-10-20 02:33:35 +00:00
Rifqi Al Fatih 46a0b25280 STEP import: CJK names read back whole (#2344) (#2347)
Adjacent \X2\...\X0\ runs, as step_out writes one per character, now
decode: the backslash opening the next run was pushed as a literal, so a
name exported as 圆角 came back as 圆\X2\89D2\X0\ (also every linked
component insert, which round-trips through STEP).

STEP and IGES bytes that are not UTF-8 are decoded in their detected
legacy encoding (chardetng + encoding_rs) instead of from_utf8_lossy, so
GBK or Shift_JIS names from vendor files no longer turn into U+FFFD.

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-20 02:32:50 +00:00
HiroKenEz ca9c363a98 Sketch: a click that moves on the way places its point (#2448) (#2449)
* claim: #2448

* Sketch: a click that moves on the way places its point (#2448)

egui reports a press that moves past its click distance, or is held past its
click time, as a drag, not a click. The viewport only placed points on clicks,
so a hand that moved while clicking placed nothing with the Slot, Line, arc,
three-point, Ellipse, Spline and the other tools, and on the first point of a
Rectangle, Circle or Polygon the few pixels became the press-drag-release
gesture of #1630 and drew a sliver.

- Any tool's press that turns into a drag (no navigation, no Select Other
  hold) clicks where the button is let go.
- A Rectangle, Circle or Polygon drag let go within 24 screen points of its
  press is such a click too: the first point goes where it was let go and the
  shape goes on by clicks. A longer drag still draws the shape (#1630).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-20 02:32:23 +00:00
bherbruck d0afc31632 Save the evaluated model with every design: exact, stored once, scaled caps (#3053) (#3093)
* Model cache written by default: exact bodies, stored once, scaled caps (#3053)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* bench-open: say why bodies were not kept

* Exact planes and revolutions; budgets for large bodies (#3053)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Native B-rep: rebuild shells as the kernel made them (closed and seam edges) (#3053)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: the model cache and the exact native B-rep (#3053)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* bench-open: time a save without the cache too

* Model cache entries are Deflate-compressed (#3053)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Model cache: one that does not fit the design file is left out, never a failed save (#3053)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 02:31:08 +00:00
Artem Sushchev 80719744cf Union: when the slab past pushed-apart coplanar faces can't be cut off, trim it by editing faces (#2801) 2026-10-20 02:11:23 +00:00
JEREMY 2de99c728e Sheet metal: a corner relief no longer depends on the order the corners are cut in (#3280)
* Sheet metal: a corner relief no longer depends on the order the corners are cut in

The corner reliefs are planned against the base as it is before any of them is
cut, and the two reliefs at the ends of one edge are compared with each
other. Before, every relief was cut into the base as it went, so the corner
after it saw the relief of the corner before it: the square relief reaches
further from its corner than the round one does (4 / cos(α / 2) against 4), so
on a short edge it was left out where the round one was cut, and which of the
two corners kept its relief depended on the order the flanges were made in.

The reliefs themselves are unchanged: a round about the corner through the
angle the base fills there, or the square with that side (a kite where the
corner is not square, its cuts square to the two edges). What is new is that
they are planned first and cut together, so that a corner's relief depends on
the base and on the other reliefs, and not on the order they are cut in. Two
reliefs that would overlap are still refused, now with a note that says so.

Covered: the four relief shapes at corners of 60° and 120°, with the flanges
of equal and of unequal height, through the kernel and through the engine
(where the flat pattern and the DXF keep one clean loop).

Co-Authored-By: Claude Code <noreply@anthropic.com>

* Sheet metal: cover the corner reliefs at a corner that is not square

The engine and the UI had no test at all for a base whose corners are 60° and
120°: the flat pattern, the DXF, the unfold and the rule manager's own square
relief were all untested there. They work; this says so.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* Sheet metal: say what happens when a corner relief is left out

The guide now says that the reliefs at the corners of one edge are cut
together, and that two of them that would overlap leave the second one out.
A relief that cannot be cut is reported, not dropped.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* Sheet metal: write the corner relief's expected corners out

The engine test hard-coded the corners of the trimmed base; it works them out
instead, and the numbers the test and the sketch share are written with the
precision they need.

Co-Authored-By: Claude Code <noreply@anthropic.com>

---------

Co-authored-by: jeremymiribung-blip <jeremymiribung-blip@users.noreply.github.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-10-20 01:28:34 +00:00
bherbruck adaac4d1b6 perf: STEP import reads and orients distinct parts in parallel (#2814) (#3046)
Converting an assembly went one occurrence at a time: a part's solid was read
from the file and oriented on the calling thread, and only runs of four or
more solids in one representation used the workers. Each step now takes the
next solids across occurrences and parts (up to 64, stopping where a part not
yet oriented comes back), and the workers read and orient the new parts side
by side, each taking the next one when it is done. Later instances still only
place the part their first instance oriented.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 00:26:36 +00:00
bherbruck 0a328a3d0a perf: placed parts draw their definition's display plan, moved (#3044) (#3127)
World placement already shares a part's reference mesh between its
occurrences (#2924), but the display plan the GPU viewport draws was made
again for every occurrence from its placed B-rep: planning every face of
every instance of the part. A placement now carries the definition's plan
cells too, and a placed body's plan (and its CPU mesh) is the definition's,
moved by the placement's matrix (`TessPlan::transformed`): the part is
planned once however often it is placed.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 00:22:36 +00:00
bherbruck 74af4a3471 test: the agent chat's long-message layout check waits for agent detection (#3349)
`a_long_message_keeps_the_buttons_in_the_window` failed now and then on CI
(Send 914.5 -> 888.5, also on #2893): looking for agents runs on its own
thread, and what it finds (and the status it reports) can land between the
two measurements and change what is around the panel. The test now waits for
the search to finish and measures the buttons from the panel's bottom, which
is what the box growing upwards keeps fixed.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 00:21:38 +00:00
bherbruck 9e7a7b260b perf: Save and Save As write on a worker thread (#3044) (#3146)
Save ran on the window's thread: about 130 ms on the Prusa Core One design,
and more than a second once saves keep the evaluated model. The toolbar's
Save and Save As now run the command on a scratch copy of the session on a
worker; the design waits for it as it waits for a file picker (commands are
refused meanwhile), and the outcome is taken up as running the command here
would have left it: the name and links the save gave the design, its file,
its saved state (`Session::adopt_save`), the echo, the error status, the
recent files and the home thumbnail. A tab closing or the app quitting after
Save waits for the write, as does the app's exit. Scripts, the control
channel and the browser build still save in place.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 00:19:29 +00:00
NOWN-DEV e661e6f809 inspect.clearance: pins on one cylinder with their hole are answered or reported in seconds (#3027)
* inspect.clearance: pairs whose bodies did not change are not measured again

Each clearance check measured every close pair again, though between two
checks usually only a few bodies change or move. Results are now kept per pair,
keyed by both bodies' geometry and placement and the check's settings; a pair
whose two bodies are unchanged is taken from that cache. With 40 overlapping
parts, a check after moving one of them took 0.8 s instead of 23 s (741 of 780
pairs reused). Pairs that could not be worked out are tried again each time.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* inspect.clearance: boxes and placed keys exactly as before the reuse

The pair boxes are taken over the mesh's triangles with finite corners, as a
distance shape's are, so pruning is unchanged. A placed body's identity now
includes its model body's own frame, so two placements are never mistaken for
one another.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* inspect.clearance: a large body is meshed once for all its pairs

Each pair that touches cuts the larger body down to the pair's region with a
kernel boolean, and that boolean meshed the whole body twice: once for its
checks and once inside the intersection. A sheet with 600 faces paid about
2.5 s per pair for meshes that were the same every time.

A body can now keep its meshes for reuse (Body::keeping_meshes); its booleans
take the meshes from there, and the intersection takes its shell meshes
from there too (truck-shapeops: and_with_history_meshed). Clearance uses such
copies for one check. Results are unchanged to the bit. On a 3 x 2 m sheet
with bends and 300 holes, the clip took 0.23 s per pair instead of 2.6 s, and
24 rivets took 5.6 s instead of 16.5 s.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* inspect.clearance: pins on one cylinder with their hole are answered or reported in seconds

A pin seated line to line in its hole, on its axis, and with a blend running into its wall
(a fillet under a head) sent the boolean through minutes of retries that ended unchecked; a
pin a hair off its hole's axis was passed as clear. Clearance now asks first whether the pair
holds such a pin (from the faces' surfaces alone). On its axis the boolean gets a few tries,
which answer plain pins at once; a hair off, or when those tries fail, the shared volume is
worked out apart by coaxial_overlap, which now also takes the zero-offset, equal-radius case,
moves the hole's wall when a blend keeps the pin's from moving, and checks its band more
tightly. What neither answers is reported as unchecked, naming the pin.

The kernel gains with_attempt_budget: the booleans run inside it get a number of tries of
the kernel's own intersection between them, the same on every machine.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* inspect.clearance: the pin tests sit beside the other measured pairs, not at the file's end

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* inspect.clearance: the bodies left whole meet their booleans with their meshes kept

A body that meets several others at one size (a bracket with balls pressed into it) was
meshed again for each pair's intersect: only the clipping used the copies that keep their
meshes. The intersect, the pin work and the union check now take those copies too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Near-coaxial overlap: ten tries per intersection, enough for a flush head pulled back

#1984's headed pin made before its plate needs more than eight.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aac7mP9rthK3WsPpPRvCsF

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-20 00:09:50 +00:00
Colin Faulkingham b622a46624 Fix large modeled threads and thin crest reconstruction (#2209)
* Fix large modeled threads and thin crest reconstruction

Helix stations follow the pitch (radial chord error under 1% of it, 48 to
720 stations), and the faceted rebuild retries at the BSP's precision when
the usual weld tolerance collapses a thin crest facet.

Rebased onto main as one commit (the branch's own commits touched UI files
that later moved to #2263 and #2264). Adapted to main's UnitVec3 thread
axis: `*k` in thread_groove and `*cylinder.axis` in the new flange test.

* Fix icon review revisions for same-timestamp edits
2026-10-20 00:06:44 +00:00
fkirill 3ffd8efffc Loft: preserve holes through matching sketch sections (#2435) (#2447)
Rebase the complete PR change onto current main, preserving changes previously stored in merge commits.
2026-10-19 23:57:07 +00:00
Colin Faulkingham 26dd2548b7 Preview modeled threads using the desktop background worker (#2264)
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-19 23:57:03 +00:00
amolinari-autotex 07be3f8852 Paste saved linked designs at the viewport pointer (#2430) (#2433)
* claim: #2430

* feat: copy saved designs between documents as linked components

* fix: keep occurrence movement controls with their document tab

* test: cover linked design clipboard across tabs

* fix: place linked design pastes at the viewport pointer

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-19 23:56:48 +00:00
DuBearte 33d7a86d50 Extrude: a tapered side drawn in collinear pieces is one face too (#1788) (#2583)
#1949 joined the straight runs of a profile into one side face for straight
extrudes; tapered extrudes (extrude_tapered, extrude_tapered_between) still
sloped each piece as its own face, so a drafted mirrored profile kept the
edge. They now join the runs the same way (NumberedLoop::merged_lines), the
sloped side generated by every piece under it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-19 23:56:46 +00:00
amolinari-autotex e984e7d738 Resolve picked-face feature actions and reveal the creator in Browser (#2301) (#2431)
* claim: #2301

* fix: resolve face context actions from persistent face provenance

* test: cover feature targeting from picked faces

* fix: reveal picked face creators in the Browser

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-19 23:56:42 +00:00
NOWN-DEV 62b49f9f89 Boolean: a pin in a bore of its size is clear at every placement (#2714) (#2715)
* Boolean: a pin in a bore of its own size is clear of it, whatever its facets

A pin whose wall is in other faces than the bore's (turned from a profile: three
faces; the bore two) is faceted at other angles. Where one body's faces end on
the shared cylinder (the pin's wall through the end face of the tube, the pin's
end inside the bore) the facets cross by the facetings' error, so the sampled
shortcut doubted its own "apart" answer, every retry failed on the coincident
walls, and the result came back unverified after seconds: inspect.clearance
left such pairs unchecked ("could not be intersected there").

The shortcut now leaves out crossings that lie on a cylinder both bodies have
faces on (axes and radii within the kernel's same-surface tolerance, where both
faces run). A pin a hair off its hole's axis, or one pushed into a narrower
bore, is still not taken for apart.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Boolean: skip only the shared cylinder's own crossings, not a flat along it

The shortcut left out every crossing near a cylinder both bodies have faces
on, from any face: a pin through a D-bore whose flat is 0.022 mm inside it
came back as contact. Mesh::crosses_except now hands the predicate the two
triangles' faces, and only crossings between the faces on the shared
cylinder, or between one of them and an end face across its axis that keeps
to its body's side of the cylinder (within the facetings' error), are left
out. The D-bore's end face reaches into the cylinder to the flat, so the
pin's wall through it counts again; a flat, key or rib along the axis always
counts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Boolean: a pin in a bore of its size is clear at every placement

The sampled shortcut's check that no facet has material under it on the
wrong side of the other body still caught the facetings of a shared
cylinder. A facet of a pin's end inside the bore (or of the bore part's
end face round the pin) with all three corners on the rim has its middle
just inside the cylinder. Where the other body's chords reach further in
at that angle, the point under it lies inside the other mesh, though the
bodies only touch. Which facets do so depends on the face splits and the
placement, so the same pair read as clear at one placement and as "not
verified" at another.

Such points are no evidence now when they lie on the shared cylinder,
under a facet of one of its end faces: the end faces and band the
crossing check already leaves out. A pin 0.02 mm larger is no shared
cylinder, and its overlap is still found.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-19 23:56:40 +00:00
DuBearte c8283f2196 Thread: a hole too narrow (or a shaft too thick) says so, and what to drill (#2688) (#2853)
An M8 modelled thread in a 6 mm hole, narrower than M8's minor diameter, was
refused as "the face is too far from the thread's diameter". It now says what
is wrong and what fits, as a hole that is too wide already does (#995): "this
6 mm hole is too narrow for a modelled M8 thread (its teeth would fill the
hole); drill 6.75 mm for M8", with the thread that fits this hole when there
is one. A shaft too thick for its thread gets "make the shaft 8 mm for M8".

Fixes #2688

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-19 23:56:36 +00:00
Loohi f97a84c100 inspect.clearance: measure the 0.2 mm gap on trimmed faces (#2283) (#2337)
* claim: #2283

* inspect.clearance: a point straight past an edge's end no longer passes as on the face (#2283)

A witness point on a face's surface is kept only if it lies on the face
itself; near the face's boundary the nearest edge decides which side it is
on. A point straight past the end of an edge (on the line through it) was
meant to be left to the other edge meeting there, but "straight" was an
absolute 1e-9 of the body size. With a slightly tilted axis (1.5e-6 rad, as
a STEP round trip leaves it) a point 0.2 mm past the end of a knuckle's seam
edge is 4.5e-6 mm off that line, so its side came down to rounding and the
point on the knuckle's extended cylinder, level with the frame band's rim,
was taken as contact (0.000) at some angles.

Straight along is now relative to the distance from the edge (1e-3 of it),
so the rim edge meeting there decides. Regression test: a hinge knuckle
turned 30-80 degrees between two bands reads 0.2 mm at every angle, with
both witness points on the bodies (41 degrees read 0.0 before).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 23:56:34 +00:00
NOWN-DEV af436493a2 Joints: a point origin keeps the x it is given (#2597) (#2598)
* Joints: a point origin keeps the x it is given

`joint.origin` and `joint.create` take `x` for a point origin (a
direction, laid square to z) and the point snap saves it. Two origins
given at one point with the same z and x now meet as they are: a part
turned a quarter turn about z no longer turns back (and moves) when
rigid-joined there. A point origin without x takes its x from z alone,
as before. An x along z, or of no length, is refused.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Joint relations test: a point origin has an x too

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aac7mP9rthK3WsPpPRvCsF

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-19 23:56:32 +00:00
N3MI-DG 57b52d8a49 Browser: light the rows of what is selected or hovered in the 3D view (#3009)
* Browser model: find the placement a body is in

Model::body_path maps a placed body (by its world name) to the occurrence
path of the placement holding it, as the browser's occurrence rows nest.
Every placement is walked once per model, from the placements the
browser already caches, so asking it every frame stays cheap. The next
commits use it to mark the rows leading to a body.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Browser: rows can carry a mark for a lit item and its branch

A row's Mark draws a bar down its left edge: a solid one on a lit item's
own row (which also takes the hover colour unless it is selected), and a
thinner, fainter one on each row leading to it, so the bars trace the
item's branch through the tree. Nothing sets a mark yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Browser: light the rows of what is selected or hovered in the 3D view

The tree now follows the 3D view. Whatever is selected (in the tree or
in the view) or under the pointer in the view lights its row: the
selection colour when selected, the hover colour when hovered, with a
solid bar; each row leading to it gets a faint bar, also when the
folders are closed.

- A body lights for itself and for a selected or hovered face, edge or
  vertex of it.
- A sketch lights for its curves, points and profiles, including those
  of the sketch being edited.
- Construction planes, and the origin planes and axes, light their rows;
  the Origin folder carries the faint bar.
- The rows leading to an item are the design, the placements above it
  (by the placement path of a body; for a sketch or plane, every
  placement of its component), its folder and its group.

A row's own selected flag, which drag and the context menu go by, is
unchanged: the selection colour for things picked in the view is drawn
separately.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 23:56:28 +00:00
kezmdor d55521aa3e Parameters window: the add row lines up with the table's columns (#2920) (#2932)
* claim: #2920

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Parameters window: the add row lines up with the table's columns (#2920)

The add row was a row of its own under the User Parameters table, so
its fields did not sit under the headings, least of all before the
first user parameter, when the headings had no rows to size their
columns. It is now the table's last row. Its text boxes take the rows'
widths, since a grid cell otherwise gives them only the column's width
so far.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: kezmdor <15539254+kezmdor@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 23:56:26 +00:00
NOWN-DEV 68c7208203 sketch.edit_text: batch several text edits into one rebuild (#2793)
* sketch.edit_text: several texts in one edit, one rebuild

Re-texting the labels of a design took one sketch.edit_text per label, and
each one rebuilt every feature after the sketch (seconds per label in a large
assembly). sketch.edit_text {edits: [{link, text, ...}, ...]} changes them
together in one command: one rebuild and one undo step, all or nothing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* sketch.edit_text: a live frame takes the edits list too

The live path (live: true) still read one text's link and refused a list.
Both paths now go through one helper.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-19 23:56:22 +00:00
Matt Ruhland 91753051a1 docs(skills): multi-body primitives, mirror body names, unit suffixes, set_parameter call (#2964)
Add body_name and targets to the solid.box and solid.cylinder rows, the
bodies form of solid.mirror and the names its bodies get, unit-suffixed
parameter values, and the set_parameter argument shape.
2026-10-19 23:56:19 +00:00
amolinari-autotex b2622325a1 i18n: fill missing Italian catalog entries (#2389)
Translate 21 messages while keeping Italian partial and preserving existing translations and catalog guards.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-10-19 23:56:17 +00:00
bherbruck b93d8fa0b1 CI: Windows app startup smoke back on windows-latest (#3355)
Blacksmith's Windows runners start the app but never present a frame (no interactive desktop),
so the startup smoke failed on main after #3343. The shader job stays on Blacksmith.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 21:06:05 -04:00
Irakli Kourtis 843726a8cb CI: merge-queue groups run on Blacksmith, test shards on 16 vCPU (#3343)
* CI: merge-queue groups run on Blacksmith, test shards on 16 vCPU

The merge queue builds up to three groups at once, but every group's
checks, Clippy, doc and reference tests and all four shards ran on the one
self-hosted machine, so the groups waited for each other. Merge groups now
run on Blacksmith like pull requests (test archive built once, shards on
16 vCPU). Pushes to main keep the self-hosted runners and their warm build
directories, and a push the queue already tested still runs only the
repository checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QWhMwdqJYQKQq8RCLSL1LT

* CI: every job on Blacksmith: pushes to main and the Windows jobs too

Pushes to main ran on the one self-hosted machine and the Windows jobs on
GitHub's runners. Both move to Blacksmith (Windows Server 2025, 8 vCPU),
so nothing in ci.yml depends on a single box any more. With no
self-hosted job left, the local routing goes: no warm build directories,
every run builds the test archive once and the shards run it, and main
saves the build caches every other run restores.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QWhMwdqJYQKQq8RCLSL1LT

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 20:26:39 -04:00
HiroKenEz 65e6d9dafd Sketch: dragging part of a selection moves the whole selection (#2621) (#2720)
* claim: #2621

* Sketch: dragging part of a selection moves the whole selection (#2621)

A drag that started on a selected sketch line or point moved only that
entity; the rest of the selection was left to the solver, so geometry that
kept only its length (the reporter's 5 mm tail) trailed behind like a string
and chamfers stretched.

- sketch.drag takes `with`: the rest of a selection dragged by `entity`.
  Every free point of all of it slides by the cursor's move and the held
  solves keep them all (circles and ellipses keep their radii), so the
  selection moves as one (`plan_group`). `with` is checked and capped like
  the other parameters.
- The viewport sends the rest of the selection when the drag starts on a
  selected point or curve. A curve's own points, picked with it by a window,
  are not a group: a circle grabbed on its edge still takes its radius.
- Snapping looks at all the moving points.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 19:14:16 -04:00
Vodmordia 803ae6d5be Script replay keeps a feature name another feature has (#1990) (#1997)
A sketch and the feature built from it may share a name, and the sample and
oracle designs do. `timeline.add` refused such a recorded name, so a design's
own exported script failed to replay. `Document::insert_feature` now keeps the
name, as `add_feature` does; a blank name still gets a fresh one.

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-19 19:14:12 -04:00
weyne chen d90ec9586c Delete Face: a face that only loses whole loops keeps its own surface (#1927)
Deleting a pocket or a boss from an imported part failed with "face N cannot
close: cannot attach a plane to a wire that is not on one plane". The face
around the gap only loses an inner loop, but it was rebuilt as a new plane
through its outer boundary, and an imported boundary is often a few 1e-5 mm off
the plane: within the planar test, too far for a plane fitted to it. Such a face
now keeps its surface and only drops the loop.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-19 19:14:09 -04:00
HiroKenEz a108ffac38 Replace Face keeps its body's faces attachable (#1925)
Replace Face rebuilt its body with ModelBody::new and dropped the body's
lineage, so no face of the result was named exactly: a sketch on any face of
it, even one the replacement didn't touch, failed with "this face can't keep
an attachment". Each face it moves is now a lineage step, as in Offset Face
(which does the same geometry), and the result keeps the lineage.

The test of a body without history used Replace Face as its example of an
operation that reports none; it now lifts the block with Align instead, the
same geometry.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-19 19:14:05 -04:00
rlartot ee67ed53af STEP export no longer hangs on a box with a screw hole (#1985) (#2243)
* claim: #1985

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L15TLCjpXsQdp8ovC4ZacH

* STEP export no longer hangs on a box with a screw hole (#1985)

The export reads its own STEP text back, and meshing the read-back screw
hole never finished. On a seam cylinder the boundary walk projected points
to angles up to 5e7 rad apart and moved each by two turns at most, so the
11-point loop spanned millions of turns and the polyline closing it across
the seam had 4.3 million points. Each projected parameter is now brought to
within half a period of the point before it, whatever the gap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L15TLCjpXsQdp8ovC4ZacH

* Second #1985 regression: the reporter's flanged enclosure

The issue's reporter delta-debugged a second design whose STEP export hung
on main: a flanged enclosure with two standoffs and no screw hole. It hangs
without the get_mindiff change and reads back with 29 faces and the same
volume with it. Both tests share the read-back checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L15TLCjpXsQdp8ovC4ZacH

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-19 19:14:01 -04:00
amolinari-autotex 504d7c9488 Fix occurrence Move/Copy viewport gizmo input (#3014) (#3015)
Thanks @amolinari-autotex.
2026-10-19 18:07:32 -04:00
SiroArt 206fffdd80 Agent chat tests: the two "cap" tests no longer share one folder (#2958)
Thanks @SiroArt.
2026-10-19 18:05:37 -04:00
fkirill 54b00fa7b1 Chamfer straight lips at tangent wall junctions (#2408) (#2490)
Thanks @fkirill.
2026-10-19 18:04:24 -04:00
phanoob a9f621837f Sketch Trim: the hover shows the piece that will go, not the whole curve (#3052) (#3113)
* claim: #3052

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Sketch Trim: the hover shows the piece a click takes away (#3052)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Sketch Trim: the piece is shown in the error colour, thicker

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Trim hover: preview keeps its doc line; the piece is worked out once a frame

The new function had gone between preview's doc line and preview. The
cursor's Trim piece is now kept by design revision, sketch, curve and
cursor, so the viewport's hover and the preview no longer both intersect
the curve with every other curve each frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Phanoob <phanoob@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 17:59:39 -04:00
bherbruck 68ac9976f3 README: CI powered by Blacksmith badge (#3299)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 17:57:48 -04:00
bherbruck 9c954b46ed Trademark notice: name every CAD vendor and product we mention, not only Autodesk (#3294)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 17:54:10 -04:00
bherbruck 66951921a1 README: Blacksmith sponsor banner (#3287)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 17:45:57 -04:00
SS-EN 98c30d28f9 Joints: a replaced motion link keeps the driven joint where it is; links count in dof (#2898)
Thanks @SS-EN.
2026-10-19 17:29:40 -04:00
bherbruck 4d99d79205 README: shorter front page; details move to docs (#3275)
* README: shorter front page; details move to docs

* Downloads page: drop stray lines
2026-10-19 17:23:29 -04:00
shop-3d 44cbd7ae73 I18n danish (#2671)
I18n danish.
2026-10-19 17:23:00 -04:00
NOWN-DEV 53a9260aeb Interference: near-coaxial pins after a STEP round trip, with flush heads, in either order (#1984)
Interference: near-coaxial pins after a STEP round trip, with flush heads, in either order.
2026-10-19 17:22:56 -04:00
kezmdor e5291dc8f7 Fonts: egui's built-in Hack as the last fallback for symbols (#2956)
Fonts: egui's built-in Hack as the last fallback for symbols.
2026-10-19 17:22:50 -04:00
plexi32 a1badaed17 Convert Mesh uses the selected mesh body (#2482)
Convert Mesh uses the selected mesh body.
2026-10-19 17:22:44 -04:00
NOWN-DEV 3d105fb281 parameters: import evaluates once; parameters.change takes several names (#2777)
* parameters: import evaluates once; parameters.change takes several names

parameters.import checked every row through change_param, which cloned the
design and evaluated every parameter per row: 2000 rows took 9.5 s, 2823 in
an assembly minutes. Rows are now checked and applied in one pass with the
values carried along, then evaluated once (2000 rows: 0.016 s), still all or
nothing; a row may name a parameter defined by a later row.

parameters.change {changes: {name: expression, ...}} sets several existing
parameters together, rebuilt once and undone in one step.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* parameters.change: the params doc names the `changed` result of `changes`

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-19 17:22:37 -04:00
amolinari-autotex 0b787f8eae Show and edit standard part configurations (#2680) (#2681)
Thanks @amolinari-autotex.
2026-10-19 17:07:01 -04:00
SS-EN a6b44ab0d3 Profiles and cap attachments fall back to the best match when a sketch outline changes (#2897)
Thanks @SS-EN.
2026-10-19 17:00:55 -04:00
Logan Night b0c4b6e939 Chamfer: a manipulator for Distance 2 with Two Distances (#2711) (#2856)
Thanks @flyboy-byte.
2026-10-19 16:47:08 -04:00
jyviko edc01b19bf README: Blacksmith sponsors the CI minutes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QWhMwdqJYQKQq8RCLSL1LT
2026-10-19 16:44:19 -04:00
jyviko 69db6ee6e4 CI: fork pull requests run on Blacksmith too
The self-hosted-untrusted pool is being retired for CI, so fork PRs whose
number ends in 7, 8 or 9 no longer route there. Build tests, the test shards
and the reference tests reduce to our own runners for main and the merge
queue, Blacksmith for everything else; the build's 90-minute gVisor timeout
goes. Blacksmith VMs are ephemeral and fork runs get no secrets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QWhMwdqJYQKQq8RCLSL1LT
2026-10-19 16:44:19 -04:00
jyviko 247cf357b6 CI: steps tell where they run from the routing, not the runner's name
Blacksmith runners report runner.environment == 'self-hosted', and a list of
our machines' name prefixes breaks when a box is added or renamed. Select
checks already decides where each job runs; its local and untrusted outputs
(and the trusted-run condition in the repository checks) now drive the warm
build directory, cache and lavapipe steps. Same results as before on every
event.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QWhMwdqJYQKQq8RCLSL1LT
2026-10-19 16:44:19 -04:00
jyviko f945e79278 CI: run the GitHub-hosted Linux jobs on Blacksmith again, sized per job
Re-applies #2237 (reverted in #2277 while the Blacksmith account awaited verification) on today's
ci.yml: every ubuntu-24.04 job or fallback runs on blacksmith-<N>vcpu-ubuntu-2404, sized as #2237
did (quick-compile, new since, gets 8), and steps that asked runner.environment now tell our own
machines apart by runner name, since Blacksmith runners report 'self-hosted' too. The trusted
self-hosted routing and the fork routing are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QWhMwdqJYQKQq8RCLSL1LT
2026-10-19 16:44:19 -04:00
jyviko fd5795b5ad Contributing: catch up with main only when pushing anyway or on a conflict
From the review: the guidance said a pull request's run tests it merged with the latest main,
which is only main as of that run; a break between two pull requests that land close together
shows up in main's own run. It also forbade rebasing. Now: don't push only to catch up; when you
push a change anyway or GitHub reports a conflict, rebase onto main (--force-with-lease on your
own branch) or merge it in once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QWhMwdqJYQKQq8RCLSL1LT
2026-10-19 16:42:11 -04:00
jyviko f1b721b00c CI: Docker rebuilds for ATTRIBUTION.md; generated files follow licence inputs
From the review: the web app embeds ATTRIBUTION.md, so the Docker image's Markdown exclusion now
keeps it; and THIRD-PARTY-LICENSES.txt also reads the vendored crates' licence files and the
fonts' OFL texts, so changes there regenerate it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QWhMwdqJYQKQq8RCLSL1LT
2026-10-19 16:42:11 -04:00
jyviko c41845e6d7 CI: carry only from this pull request's runs, compare patches verbatim
From the review: git patch-id --stable ignores whitespace, so a conflict resolution that only
respaced the change (inside a string, or Python indentation) counted as no change and was
carried; --verbatim compares every byte. And a passing run is now taken only from this pull
request (by number, or for a fork, where GitHub lists none on the run, by head repository and
branch), not from another pull request with the same head commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QWhMwdqJYQKQq8RCLSL1LT
2026-10-19 16:42:11 -04:00
jyviko 2f92aaee6c CI: one tested rule for code changes; review fixes
From the review of this PR:
- What counts as code lives only in scripts/ci/affected.py now: nothing selected and not full
  means no code changed, on pull requests and pushes to main alike. The untested bash copy in
  Select checks disagreed with it. Root files and folders a crate embeds now select that crate
  (the licence texts and NOTICE: the engine; skills/: the MCP server's tests), where before a
  change to them ran no test at all. Other workflows and the bots' scripts select nothing.
- A push to main skips the native jobs only when nothing a build reads changed since the last
  push to main whose run passed. Comparing with the push just before was unsafe: a newer push
  cancels a waiting run, so that commit may never have been tested.
- Fail fast applies to pull requests only. On main, nightly and Run workflow the test build and
  the shards run regardless of the repository checks, so the action matrix and property tests
  that hang off Build tests keep their signal. Doc tests (main and nightly only now) no longer
  wait for the checks.
- tests/robustness/ turns on the goldens, so a change to goldens.json runs the seed tests.
- tests/oracle/ no longer selects the kernel, which never reads it.
- The workflow header and CONTRIBUTING describe what main and pull requests now run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QWhMwdqJYQKQq8RCLSL1LT
2026-10-19 16:42:11 -04:00
jyviko 6b4026fd19 CI: carry through consecutive merges of main
A change that merged main twice in a row ran the full set on every other push: the change before
the second merge was itself a carried run, its shards skipped, so it had no passing shards to
carry. The script now walks down the first parents through consecutive merges of main that kept
the change's patch (each equal to the next), at most 10, to the run where the shards ran.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QWhMwdqJYQKQq8RCLSL1LT
2026-10-19 16:42:11 -04:00
jyviko e9b850572d Contributing: don't bring main into an open PR, don't re-run unchanged commits
The guidance told contributors and agents to keep their branch current with every upstream merge
and to re-run CI after integrating. Over three days that meant 1,241 merges of main into pull
requests and 730 re-runs: about 45,000 of 79,800 ci runner-minutes, on runners that capped how
fast everyone's checks ran. CI already tests each pull request merged with the latest main, and
a real failure fails again on the same commit. Now: merge main in only when GitHub reports a
conflict, re-run only a job runner trouble cancelled, and maintainers add approved-for-ci when a
pull request is ready to merge and merge approved ones in groups by area.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QWhMwdqJYQKQq8RCLSL1LT
2026-10-19 16:42:11 -04:00
jyviko b045d9deae CI: a push that only merges main into a passing change carries its result
Merging main into a pull request (Merge branch 'main', or GitHub's Update branch) restarted the
whole run. Over three days 1,241 such merges started 966 runs and about 21,000 runner-minutes, and
872 of the merges brought in nothing that touched the pull request's files. Select checks now
recognises the case (scripts/ci/main_merge.py): HEAD is a merge whose other parent is on main, the
change's own patch is unchanged (git patch-id), and a ci.yml run of the change before the merge
passed with its test shards. Then only the repository checks run, the ci job names the passing run
in its summary, and main tests the merged result after it lands. Anything else, or any doubt,
runs the full set as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QWhMwdqJYQKQq8RCLSL1LT
2026-10-19 16:42:11 -04:00
jyviko 7555b34afb CI: doc tests in the pull request fast lane; heavy jobs wait for the repository checks
Doc tests move to main and nightly, beside the reference tests and the Windows jobs: on pull
requests they were the only failing job 3 times in about 7,900 runner-minutes over three days.
Build tests, the test shards and the doc tests now start only after the format and repository
checks pass; 212 attempts in those three days ran their heavy jobs beside a failed cheap check.
Also corrects the header, which still said pull requests run the Windows jobs and the reference
tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QWhMwdqJYQKQq8RCLSL1LT
2026-10-19 16:42:11 -04:00
jyviko 948c208db9 CI: run only what a change needs, on pull requests and on main
- Pushes to main use the same code/no-code rule as pull requests (`touches_code` in Select
  checks): a push changing only documentation, packaging, other workflows or the bots' scripts
  runs the repository checks only. With the merge queue off, every push to main ran the full suite
  and the Windows jobs, docs-only ones included.
- affected.py: test data under tests/ selects the packages that read it (robustness, hole matrix,
  projection, taper: solidcraft-engine; oracle: the CLI, the kernel, xtask; action matrix: the
  CLI, the engine, xtask), and a vendored crate the packages that use it (truck: the kernel and up;
  egui-wgpu: the UI and the apps), instead of the whole workspace. tests/tools and unknown vendored
  crates still select everything.
- Docker (the web app image) skips pushes touching only docs, test data or bot scripts; Pages skips
  test data and bot scripts; Generated files runs only when the manifests, lockfile, command
  registry, CLI or xtask change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QWhMwdqJYQKQq8RCLSL1LT
2026-10-19 16:42:11 -04:00
fkirill 983fbe2b14 Fix lip and groove on round objects (#2751) (#2790)
Thanks @fkirill.
2026-10-19 16:34:50 -04:00
github-actions[bot] 80dcf9de7f Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci] 2026-10-19 20:16:40 +00:00
NOWN-DEV 02c77df5f7 inspect.clearance: mesh each body once per check instead of once per pair (#2867)
* inspect.clearance: pairs whose bodies did not change are not measured again

Each clearance check measured every close pair again, though between two
checks usually only a few bodies change or move. Results are now kept per pair,
keyed by both bodies' geometry and placement and the check's settings; a pair
whose two bodies are unchanged is taken from that cache. With 40 overlapping
parts, a check after moving one of them took 0.8 s instead of 23 s (741 of 780
pairs reused). Pairs that could not be worked out are tried again each time.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* inspect.clearance: boxes and placed keys exactly as before the reuse

The pair boxes are taken over the mesh's triangles with finite corners, as a
distance shape's are, so pruning is unchanged. A placed body's identity now
includes its model body's own frame, so two placements are never mistaken for
one another.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* inspect.clearance: a large body is meshed once for all its pairs

Each pair that touches cuts the larger body down to the pair's region with a
kernel boolean, and that boolean meshed the whole body twice: once for its
checks and once inside the intersection. A sheet with 600 faces paid about
2.5 s per pair for meshes that were the same every time.

A body can now keep its meshes for reuse (Body::keeping_meshes); its booleans
take the meshes from there, and the intersection takes its shell meshes
from there too (truck-shapeops: and_with_history_meshed). Clearance uses such
copies for one check. Results are unchanged to the bit. On a 3 x 2 m sheet
with bends and 300 holes, the clip took 0.23 s per pair instead of 2.6 s, and
24 rivets took 5.6 s instead of 16.5 s.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-19 16:14:24 -04:00
takuto-NA 77c3b1e765 Sketch: preview slot outlines before committing (#2535) (#2537)
* claim: #2535

* claim: #2535

* Sketch: preview all slot sides and caps while choosing width

Fix missing temporary slot geometry for all five tools and paint every preview curve with a shared sampling budget. Preserve the arc slot construction guide. Add geometry, painting, invalid-input and interactive typed-width regression coverage.

* Sketch: honor typed slot widths below the pointer minimum

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-19 16:13:51 -04:00
Erikas 28d67fb577 Regression tests: a cut short of a face keeps its skin, and the issue's pleats mirror and build at the far end (#1669) (#1730)
* Extrude: a profile edge short of a face is not grown through the skin beyond it (#1669)

A cut or join profile edge that lies on a face of the target is grown off it by 0.5 % of the body, so that the tool's wall does not lie in the face. Whether it lies on a face was judged half a growth step either side of it: empty just outside, material just inside. An edge 0.25 mm short of a 3.75 mm wall's far face reads the same, and the growth cut through the skin it should leave (55 050 mm³ instead of 56 130 on the test's wall). Now the space just past the edge, 1e-4 of the body out, must already be as the operation wants too.

In #1669, the pleat's window built directly at the far end of a case opened through the wall at 3.75 and 4 mm, while its mirror, built without the growth, rightly left the skin and then failed to join the loft through it. Built either way, the window now leaves the skin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Regression tests: the issue's pleats mirror and build at the far end (#1669)

The issue's battery case at 3.5 mm: the mirror of the window and the pleat builds (it needed #1670's meshing of twisted faces), and at 3 mm the same window and pleat built directly at the far end of the finished case join it (they needed #1667's end rounds).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Far-end pleat test: the direct build matches the mirror's volume

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Erikas <erkexzcx@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-19 16:13:26 -04:00
Mariusz Pacek 1999e763ab Tapered extrude: arcs meeting at corners meet where their moved circles cross (#2810) (#2813)
A loop made only of arcs took the all-arcs shortcut in `offset_loop`, which
moves each arc by its radius and keeps its start and sweep angles: exact for
arcs on one circle or meeting tangentially, but where arcs of different
circles meet at a corner (a circle with lobes) the moved arcs no longer
met, and the sides were built through the wrong points without an error.

`arc_corners` moves each such corner to where the two moved circles cross,
nearest the old corner (`circles_cross`, closed form), and cuts both arcs
there. Circles that no longer cross are refused ("not supported yet"), an
arc the taper turns around closes the profile. Between the sections the
corner is a straight ruling, as for every side in `taper_loop`: on the test
profile (2.5 mm at 45°) it stays within 0.015 mm of the exact corner.

Test `tapered_extrude_of_arcs_meeting_at_corners`: the volume against the
integral of the union of the shrunk circles, 18905.42 mm³ against 18907.67
(was 18975.45).

Co-authored-by: Mariusz Pacek <pacekm90@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-19 16:12:52 -04:00
rlartot 750e08aab7 Sketch palette: a new empty sketch is not "Fully constrained" (#2906) (#3011)
* claim: #2906

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2qZQXd3qSwu9mzaYcB9yW

* Sketch palette: an empty sketch says "No geometry yet", not "Fully constrained" (#2906)

A new sketch has no degree of freedom (only the fixed origin), so the
palette read it as fully constrained. With no drawn curve or 3D curve it
now shows "No geometry yet" and not the fully-constrained styling.
Adds the message to the English, Japanese and Chinese catalogs.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2qZQXd3qSwu9mzaYcB9yW

* i18n fr: Aucune géométrie pour l'instant (#2906), à valider avant push

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2qZQXd3qSwu9mzaYcB9yW

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 16:12:10 -04:00
rlartot 7ce9c29f95 French catalog: catch up on 46 new messages (#3136)
Adds the 46 missing French messages.
2026-10-19 16:11:44 -04:00
rlartot 31b41978fc Construction geometry on model vertices follows them when a parameter moves them (#568) (#2409)
Thanks @rlartot.
2026-10-19 16:11:38 -04:00
rlartot f42c068063 Hole: size the tool by the body it drills, not the largest body (#2904) (#2975)
* claim: #2904

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2qZQXd3qSwu9mzaYcB9yW

* Hole: size the tool by the bodies its axis meets (#2904)

hole_tools_and_prisms sized the tool (reach above the face, depth of a
through hole) by the largest body of the whole model, so a large body far
away changed a countersink's outline and the volume it removed. Use the
largest body the axis meets, falling back to the model as before.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2qZQXd3qSwu9mzaYcB9yW

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 16:11:35 -04:00
Brazz 26f42ae09b Snapshot: draw a see-through appearance see-through, and show a tool body inside its target (#2329) (#2330)
* Snapshot: draw a see-through appearance see-through, and give the CLI --body, --tool and --section

A headless picture dropped a body's opacity: the scene carried only a colour, so a design with
a translucent appearance was drawn opaque by `solidcraft-cli snapshot`, by MCP screenshots and
in a design's thumbnail, although the window has always drawn it as glass. There was then no
way to look at the body a cut would remove inside the material it meets: only a volume to
measure and reason about, which hides exactly the errors a picture makes obvious (a tool that
stops short, or lands in the wrong place).

- `SceneMesh` carries the body's opacity; `view::build_scene` takes it from the design
  (`model_body_opacity`), and a selected body stays solid so the selection colour reads.
- The GPU capture routes a see-through body into the scene's glass at its own alpha, as the
  viewport does; the CPU rasterizer draws the solid bodies first (they own the depth buffer),
  then the see-through ones blended over them, far sides before near sides and without writing
  depth, so what lies inside shows and a line between the two is not hidden.
- `snapshot --body NAME:opacity=0.3,color=#rrggbb`, `--tool NAME` (that body solid in the tool
  colour, the rest see-through) and `--section z=27` run the commands the app itself runs
  (`appearance.assign`, `inspect.section`): the options are shortcuts, not a second renderer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Tests: measure the tool colour as it is drawn through a see-through target

The tool-colour filter was derived on paper and demanded a red more than twice either other
channel, which the target in front of it tints away (measured: (200, 108, 85) through opacity
0.3); and the colour check painted the tool, which a solid target hides.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fmt

* Snapshot options: strip the colour's # rather than slicing it; README

Slicing `value[1..]` on an input-derived string is what the house rules ask us not to write,
even where a length check has already made it safe. The error a failed assignment reports now
names the body rather than claiming it came from --body, since --tool reaches it too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* --tool: assign the bodies around the tool in groups, not one command at a time

Each appearance.assign is an undo step with a document snapshot behind it, so one per body
made --tool cost grow with the size of the design for no reason: the bodies around the tool
all get the same opacity, and the ones that share a colour can be named in one call. A design
whose bodies all wear the default grey now costs two commands whatever its size.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Rasterizer: say where its glass and the GPU's differ

Splitting each see-through body by facing orders its own two layers; it does not sort across
bodies, which the GPU does. Worth stating rather than leaving a reader to find it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-19 16:10:45 -04:00
NOWN-DEV f68a3dd5d5 Joints: an as-built joint at an origin on one of its parts follows it (#2600)
`joint.as_built` with `at` an origin on A or B keeps that origin on its
side (found again by its face's name on each solve) and holds the frame
where it is now on the other, so the joint follows the face or hole as
the design changes. Without `at`, or with an origin on another part,
both sides hold their frames as before.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-19 16:10:10 -04:00
vandang890615 72601e678a i18n: add Vietnamese (vi) localization support (#2915)
* i18n: add Vietnamese (vi) localization support

Add Vietnamese (vi) message catalog with 1,583 translated keys covering CAD modeling commands, UI dialogs, timeline, analysis tools, and AI agent chat.

Register VIETNAMESE catalog in ui-egui/src/i18n.rs and add unit test verifying translations, labels, and dynamic browser naming.

* chore: attribute Vietnamese locale and sort catalog

* i18n: fix corrupted and half-English strings in Vietnamese catalog

* i18n: polish residual command and UI translations in Vietnamese catalog
2026-10-19 16:09:36 -04:00
fkirill 42e991069f Fix shelling revolved barrels (#2421) (#2486)
* claim: #2421

* claim: #2421

* Fix shelling circular-meridian barrels (#2421)
2026-10-19 16:09:11 -04:00
rlartot c1f9224579 Fillet fit: an outside round concentric with a wall's inside round is no longer refused (#2852)
* Fillet fit: a round counts only what lies between its edge and its arc

The fit check refused a round when any other face of the body reached into
the wedge between its edge and its two setback lines. A round changes only
the part of that wedge between the edge and its arc: a face on the ball's
side of the arc is left as it is. An outside round concentric with the
inside round of a 3 mm wall (R18 over R15) was refused as too large
(largest 14.21 mm) although it keeps the wall 3 mm thick.

The face check now tests a round's fan of prisms from its edge to its arc
(Frame::pieces, already used for blends that cross), after the wedge as a
quick filter. Chamfers keep the wedge, which is what they change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L15TLCjpXsQdp8ovC4ZacH

* Fillet fit: a curved face clears a round's thin end pieces only by a chord (#324)

The fan test shrank each piece by the curved faces' tolerance; the thin pieces at the
arc's ends vanished, so a hole wall 0.3 mm into a round beside it was missed and the
round failed to build instead of offering the radius that fits (issue_324_tests).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-19 16:08:37 -04:00
NOWN-DEV b0b59480eb Joints: relations that tie part of a placement (#2497)
Joints can tie part of a placement: parallel, angle, point on plane and more (#2496).
2026-10-19 16:08:23 -04:00
Colin Faulkingham e1268c6ad9 Fix fillets around tiny rounded-rectangle end edges (#2506)
Fillets around tiny rounded-rectangle end edges now build (#2498).
2026-10-19 16:08:06 -04:00
NOWN-DEV 473f1e6516 Sheet metal: readable face names from the sheet itself (#2275)
Sheet metal faces get readable names (#2272).
2026-10-19 16:08:01 -04:00
NOWN-DEV 539c17b425 Joint sweep: drive a joint until parts touch, and report where (#1981)
Joint sweeps can stop where parts touch and report the contact and remaining room.
2026-10-19 16:07:44 -04:00
The Nomad d3fa22e987 3D mouse calibration in Preferences (#2326)
Adds a 3D mouse calibration section to Preferences (#1463).
2026-10-19 16:07:38 -04:00
DuBearte 45ae7c00e0 Convert Mesh asks for the mesh body and its tolerance (#3001) (#3012)
* claim: #3001

* fix(ui): Convert Mesh asks for the mesh body and its tolerance (#3001)

It had no dialog, so the toolbar ran mesh.convert with no body and it always
reported "select a mesh body". A dialog now takes the mesh body (a selected one
fills it) and the tolerance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 16:06:16 -04:00
DuBearte 24d64799d8 Update window: release-note links open in the browser (#2943) (#3004)
* claim: #2943

* fix(update): release-note links open in the browser (#2943)

The notes kept a link's text and dropped its target, so the blue text could not
be clicked. Links keep their web address, and each paragraph or item is
followed by its links as buttons that open them, as the agent chat shows links.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 16:06:06 -04:00
DuBearte a9c7616e7f Lines thinner than a pixel no longer vanish without multisampling (grid lines, #2823) (#2871)
* claim: #2823

* fix(view): lines thinner than a pixel no longer vanish without multisampling (#2823)

A line quad narrower than a pixel covers no pixel centre when it falls between
two rows, so the grid's 0.7 px lines came and went as the view zoomed where the
viewport draws without 4x MSAA (some browsers and devices). Such lines are now
drawn a pixel wide, faded by the width they lack.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 16:05:56 -04:00
CamWight d306d6e658 Two-sided extrudes: through all (or To Object) on either side, a distance on the other (#2700) (#2728)
* claim: #2700

* Two-sided extrudes: the second distance goes the other way from the first side, either way round (#2700)

extent_sides ignored `direction` when an extrude had `distance2`, so through all with
`direction: negative` and a second distance went the positive way. The second distance now goes
opposite the first side wherever that went (direction: negative, a negative distance, a To
Object below the sketch). A pending format step makes older designs' reversed two-sided
extrudes positive, which keeps their shape.

The Extrude dialog's Two Sides shows Distance 2 for every extent type (it was hidden for All and
To Object but still sent as 10 mm) and offers Reverse; the second arrow points the other way
from the first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* The two-sided arrows test checks each tip against the sketch, not just their order

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 16:05:45 -04:00
phanoob b20e693c2e Sketch text: a letter crossed by an edge is picked whole with its text (#2619)
Co-authored-by: Phanoob <phanoob@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-19 16:05:28 -04:00
DuBearte b3f205a638 Agent chat: the design bridge gets its own MCP name, so a user's solidcraft entry can't shadow it (#2592) (#2596)
* claim: #2592

* fix(agent-chat): the design bridge is named solidcraft-app (#2592)

A user's own MCP entry named `solidcraft` (as docs/mcp.md suggests) took the
bridge's place in agents that keep their own entry on a name clash, so the chat
worked on a headless session instead of the open design.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-19 16:05:17 -04:00
cronos bd4eb42c2d Pin analytic robustness and edge-contact scale regressions (#1950)
* test: pin analytic robustness and edge-contact scale regressions

* test: freeze patterned-bore reference after body grouping change
2026-10-19 16:05:00 -04:00
HiroKenEz 45d0b6053b Icon tools: hashes and palette revisions ignore CRLF checkouts, text I/O is UTF-8 (#1930) (#1931)
On Windows, Git's default core.autocrlf checks the icon masters and the
palette out with CRLF, and `cargo xtask icons` failed four ways:
- the accepted-icon hashes (lint_icons.py and the review page's
  review_state.master_hash) were taken over the files' bytes, so every
  accepted icon read as changed; both now use master_hash, which hashes
  the text with CRLF read as LF;
- icon_palette.revision hashed the bytes while save_colours hashed the
  text it read, so every palette save was refused as stale; revision now
  reads the text too;
- the tools and their tests read and wrote text in the locale's encoding
  (cp1252 can't write U+2194); they now say utf-8;
- the icon inventory listed UI files with backslashes; it writes /.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-19 16:04:49 -04:00
NOWN-DEV 99d8d382b9 Bill of materials: part properties on components and assembly.bom (#1888) (#1899)
* Bill of materials: part properties on components and assembly.bom

A component definition now carries part properties (part number,
description, revision, other tools' keys) and free text properties,
saved with the component and shared by its occurrences.
component.metadata and component.metadata.set read and change them in
one undo step; Paste New copies them; parts.insert numbers standard
parts from their standard and size, and parts.resize renumbers them.

assembly.bom counts the placed components (flat: parts at any depth
through their subassemblies; top: direct children), grouped by part
number or by definition, sorted by part number, and writes CSV (fixed,
quoted columns) or JSON. The app gets a Bill of Materials window on the
INSPECT panel that lists, edits and exports the rows.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Bill of materials: name a merged row's equal definitions once

Two standard parts with the same size are separate definitions with the
same name; their row now reads "SHCS M6x20" instead of repeating it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Window widths test: close the Bill of Materials window between commands

The test closes the floating windows a command opens before starting
the next one; the Bill of Materials window was left open and measured
against the next dialogs' limits.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Bill of materials: sub-parts in their part's row, and part properties without re-evaluating

assembly.bom takes group_children: true. A part with a part number keeps
what is placed inside it (a bolt's washer and nut) in its own row, listed
under it as children (items 1.1, 1.2, with each and count) instead of rows
of their own; bolts with different contents stay different rows, and
total_occurrences is the same either way. The window has a switch for it
and shows the children under their part; the CSV lists them after their row.

Changing part properties re-evaluated the model, re-seated every joint and
rebuilt the placed model: 361 ms per change with 30 jointed bolts. A change
that only touches components' metadata and properties now takes the same
path as a render-only change (proved on the whole Document), also for its
undo and redo: 0.6 ms.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Bill of materials: look the structure up once, not per subassembly

Deciding whether a subassembly is a part (it has bodies of its own) scanned
every body of the model for each subassembly, and each body looked up its
feature by a linear search of the timeline: subassemblies × bodies × features.
assembly.bom now indexes the placements, the body owners
(Document::body_owners: one pass over the bodies with the timeline in a map,
the same rule as model_body_component) and the numbered components once per
call. 600 bodies and features with 60 subassemblies: 8.1 ms → 1.3 ms; the
old cost grows with the cube of the design's size.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-19 16:04:39 -04:00
William Dunn d8d04c9009 Tool modifiers: shared resolve, classic defaults, preview-safe Ctrl (#1616)
* Centralise tool modifiers with classic defaults and preview-safe Ctrl.

Route sketch/selection chords through a shared resolver. Keep Alt snap
suppress and dual toggle as defaults; optional Shift add-only and Ctrl
snap mapping. Skip preview rollback while a manipulator drag is active.

* Address #1616 review: classic snap suppress, single viewport owner, docs.

Default suppress to Ctrl/Cmd or Alt; drop navigation set_suppressed; remove unused label_key; add preview rollback gate test; document sketch modifiers.

* fix(#1616): correct Modifiers primary in tests; simplify rollback fixture.

* Treat Ctrl as primary off macOS, and rustfmt the modifier files.
2026-10-19 16:04:28 -04:00
Victor Santos Oliveira 6f93e7a851 fix(examples): cut both mounting holes in the bracket (#1515)
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-19 16:04:23 -04:00
Irakli Kourtis 1cd9983c8f Surface trim: projected curves, several kept regions, mutual trim (#767) (#1466)
* kernel: trim keeps picked regions, mutual trim, curves swept along a direction (#767)

Trim moves to surface_trim.rs: TrimKeep::Side (the old behaviour) or
Regions (the connected pieces the points pick, parted where the tool runs
along an edge). mutual_trim cuts two one-face surfaces by each other's
surface, extending one that stops short when asked. SplitTool::Curves takes
a sweep direction; a closed curve's field is signed by inside/outside
instead of extending its end segments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* kernel: classify display edges of a face with a closed-edge hole without a panic

A hole cut by a closed curve is a loop of one edge that starts and ends at
one vertex; mapping the face rebuilt that edge through a check that
panics on it (the guard caught it and dropped every smooth flag of the
body). Such faces are now left out of the ruled-surface test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* surface.trim: keep several regions, trim by projected sketch curves, mutual trim (#767)

keep takes a list of points (the regions they pick); curves: {sketch,
curves?, direction?} sweeps sketch curves onto the surface (along the
surface's normal: not supported yet); with + keep_with trims two
surfaces by each other, extend? meets a short one, stitch? sews the
kept pieces. Trimmed faces carry their lineage, so they are named
after the faces they are pieces of. New fields noted in a pending format file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* Trim dialog: sketch curves as the tool, several keep points, mutual trim (#767)

The tool input takes sketch curves too; keep points are places (several
on one face add up); another surface with its keep points makes a mutual
trim with Extend to meet and Stitch toggles. Edit Feature brings it all
back. Labels in every locale catalog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* ROADMAP: M8 trim by projected curves, several regions and mutual trim (#767)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-19 16:04:02 -04:00
bherbruck f1f5298bc5 Agent instructions: one source in AGENTS.md; CLAUDE.md imports it (#3240)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 16:02:11 -04:00
bherbruck 15db51304e Agent instructions: a regression test goes into its area's test file, not a new per-bug file (#3230)
Refs #3226.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 15:53:34 -04:00
bherbruck b00d45cc7b Open: draw the Home picture after the design opens (#3053) (#3140)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 15:44:06 -04:00
bherbruck f3505f0db6 World placement on all cores (#3053) (#3123)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 15:43:22 -04:00
NOWN-DEV e4d9015f68 sketch: points placed by expressions (#2864)
* Sketch points placed by expressions over the design's parameters

A point typed as an expression ("W", "H / 2") in sketch.point, sketch.line and
the other point arguments is fixed where its expressions come to, and moves
there again before each solve when the parameters change: an outline that
follows its parameters needs no dimensions and no solve per change. The
expressions are saved with the point (one pending design-format change), listed
with the sketch's other expressions, and renamed with their parameters; one
that cannot be worked out is refused.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Sketch point expressions: accurate params doc, out-of-range regression test

The sketch.line params said every point argument takes expressions; only those that
also take a point id do (the rectangle corners, an arc's through point and the like
are plain [x, y]). PointExprs' comment called its expressions mm. A test covers a
parameter that puts a placed point out of range and a design file whose point
expression overflows: both are errors, not crashes, and the sketch recovers.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-19 13:05:28 -04:00
fkirill 3b4db52c10 Respect display units in sketch fillet fields (#2420) (#2487)
Thanks @fkirill.
2026-10-19 10:38:48 -04:00
SS-EN fb82ccb273 Timeline/MCP API: suppress lists, dotted edit keys, set_parameter expression (#2899)
Timeline and MCP edits take suppress lists, dotted edit keys and parameter expressions.
2026-10-19 10:14:23 -04:00
SS-EN 3115c50359 Chamfer: two distances and distance + angle on curved edges (#2893)
Two-distance and distance + angle chamfers work on curved edges.
2026-10-19 10:14:18 -04:00
fkirill 5bed5196c7 Fix lens-profile cuts into revolved quarter spheres (#2791)
Lens-profile cuts into revolved quarter spheres now build (#2750).
2026-10-19 10:14:12 -04:00
fkirill b4e049f9a6 Preserve equal-radius fillet mitre tangency (#2485)
Equal-radius mitres keep their endpoint tangent so a later fillet can select the chain (#2422).
2026-10-19 10:14:07 -04:00
takuto-NA b76302e850 Assembly: reuse reference meshes during rigid joint motion (#2922) (#2924)
Thanks @takuto-NA.
2026-10-19 09:15:26 -04:00
SS-EN 771f62be71 Interference: clear apart pairs from their meshes, intersect the rest in parallel, time budget (#2895)
Thanks @SS-EN.
2026-10-19 09:14:52 -04:00
fugazister aee47fca00 Messages window: keep status messages to read and copy again (#2763) (#2764)
Status messages are kept for the session in a Messages window (Help menu, or click the status chip), newest first, with Copy all.
2026-10-19 09:09:35 -04:00
The Nomad 4c30d944f5 3D mouse: only the window with focus moves (#2077) (#2212)
With two windows open, the 3D mouse now moves only the one with focus.
2026-10-19 08:48:06 -04:00
bherbruck 5f399207bf CI: a translation change tests the UI and xtask, not the whole workspace (#3061)
assets/locales/ belongs to no crate, so affected.py selected the full
workspace (every crate, all four test shards) for a catalog-only PR. Only
solidcraft-ui-egui embeds the catalogs and xtask checks them; map the folder
to those two packages and their dependents.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 08:31:49 -04:00
SS-EN 7fd51ce6a5 timeline.roll_to: accept "end", refuse other non-number positions (#2902)
A position that is not a number or "end" is now refused instead of moving the marker to the end.
2026-10-19 08:18:01 -04:00
rlartot aef13b52f4 Pattern and Mirror copies of a primitive change only the seed's targets (#2755) (#2756)
Thanks @rlartot.
2026-10-19 07:44:40 -04:00
JIJINBEI 355154fa9b refactor: geom::Grid for control nets; one surface fit through a grid of samples (#2331)
Thanks @jijinbei.
2026-10-19 07:39:22 -04:00
github-actions[bot] f1a5f4ab30 Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci] 2026-10-19 11:38:55 +00:00
takuto-NA 3a9b053f5b Fix constrained point dragging within the shared frame budget (#1976) (#1977) 2026-10-19 07:24:04 -04:00
amolinari-autotex aaa35a94d8 Relink a linked assembly source from the Browser (#2772) (#2773)
Thanks @amolinari-autotex.
2026-10-19 07:14:33 -04:00
Colin Faulkingham 852d364d5f Preserve curved face picks when reopening modeled threads (#2265)
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-19 07:14:07 -04:00
Jacques 34cb3167b6 Sketch: trim keeps the rest of a dimensioned line where it was (#2910)
Thanks @Jacquesvdberg92.
2026-10-19 06:44:50 -04:00
abteenz 1666ca7018 Fix the macOS launch hang with the 3Dconnexion driver installed (#2841)
Thanks @abteenz.
2026-10-19 05:54:43 -04:00
NOWN-DEV 768e69c4c7 Joints: a new joint between faces that already meet leaves the part as it is (#2709) (#2710)
Thanks @NOWN-DEV.
2026-10-19 05:54:21 -04:00
bherbruck 5997d28de7 STEP import: orient each instanced part once (#2814) (#2875)
Thanks @bherbruck.
2026-10-19 04:07:39 -04:00
bherbruck 5122b98af3 Parameters window: no per-frame re-evaluation on big designs (#2723)
The window listed (and so evaluated) every parameter and re-checked the add row's
expression on every frame, and laid out every row. It now lists once per design
revision, caches the add row's check, and lays out only the rows in view.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 02:16:36 -04:00
bherbruck f9342aa8b6 Malay: drop the "Gap" entry that is not in the English catalog
The catalog test rejects ids English doesn't have.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:38:57 -04:00
bherbruck d9c3fdb80f Agent chat: drops on the popped-out window attach (#2812) (#2861)
The chat in a window of its own takes the files dropped on it and shows the drop hint while files are dragged over it; a window floating inside the app counts as the chat's place for the app's drop handling.
2026-10-19 00:03:11 -04:00
bherbruck dc27988bc2 Agent chat: PDFs as documents, any other file by reference (#2812) (#2858)
A PDF goes whole to agents that take embedded documents; to the others, or past 12 MB, the message names it with its type, size and place. Any file that is not a picture, text or CAD file is attached by reference instead of refused. Chips show the type's extension; the file dialog offers All files.
2026-10-19 00:02:29 -04:00
bherbruck e3831b05d4 Agent chat: Ctrl/Cmd+V attaches copied files and pictures (#2812) (#2854)
Files copied in the file manager attach as files, a picture on the clipboard attaches as a picture, text pastes as text. A picture-only or files-only paste is seen through the V key coming up (egui-winit sends nothing when the clipboard has no text); macOS sends no key-up while Cmd is held, so there only pastes that come with text are seen and + > Paste a picture still works.
2026-10-18 23:30:21 -04:00
bherbruck 59ba87103e CI: heavy checks run after review (approved-for-ci label) (#2689)
* CI: heavy checks run after review (approved-for-ci label)

Pull requests without the approved-for-ci label run Select checks, the
format and repository checks and a quick cargo check. The label runs the
full suite, and every push after that does too. Other labels start no work
and never cancel a run. Main, merge groups, nightly and Run workflow are
unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* CI: approved fork PRs run heavy jobs on self-hosted-fast in private directories

An approved-for-ci label on a fork pull request sends Clippy, Build tests,
the test shards and Doc tests to the trusted runners, with private copies
of the build directory, Cargo home and rustup home that are deleted after
the job. Only the labeling event qualifies; later pushes use the old fork
routing. The skipped run of an unrelated label names its ci job differently.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Revert "CI: approved fork PRs run heavy jobs on self-hosted-fast in private directories"

This reverts commit 439f2f7dab.

* CI: name the ci job differently on an ignored label's skipped run

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 23:28:17 -04:00
bherbruck 19ffb0dd75 Orbit drags turn from where they began, so erratic drags don't drift (#2842)
An orbit drag now applies its whole turn so far to the camera it began
with, instead of adding each pointer step to the camera of the step
before. The view then depends only on where the pointer is: dragging
past straight up or down and back no longer loses the pitch that the
clamp swallowed, and returning to the starting point brings back the
starting view. The pivot is still picked once, when the drag begins,
and kept still on screen by the rigid turn about it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:12:55 -04:00
NOWN-DEV c66e33284b Joints: offsets, angles and values typed as expressions follow their parameters (#2385) (#2386)
Thanks @NOWN-DEV.
2026-10-18 22:13:13 -04:00
NOWN-DEV 57d210130c Assembly: place an occurrence or an inserted STEP file by a matrix (#1890) (#1901)
Thanks @NOWN-DEV.
2026-10-18 22:03:20 -04:00
SiroArt 71534117af Mesh a face thinner than the tolerance instead of leaving a hole (#2712)
Thanks @SiroArt.
2026-10-18 21:56:28 -04:00
github-actions[bot] 38f6ed76c9 Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci] 2026-10-19 01:41:22 +00:00
A.Hakim Noor 66108a6b99 Add Malay language support and update localization doc (#2188)
Thanks @a-hakim.
2026-10-18 21:34:54 -04:00
CypherNaugh_0x 8e762fee57 Add optional ambient occlusion and graphics quality presets (#1135)
Thanks @CypherNaught-0x.
2026-10-18 21:29:03 -04:00
phanoob 6b1fc16d47 Sketch projection: a solid's edges come in as their exact curves (#1508)
Thanks @phanoob.
2026-10-18 21:23:11 -04:00
bherbruck 1ea85ff231 Merge pull request #2831 from tangency-labs/ci-fast-runners-main-only
CI: repository checks keep off the fast runners for pull requests
2026-10-18 21:18:10 -04:00
bherbruck 2aa808b116 Merge pull request #2303 from tangency-labs/sketch-grid
Sketch: always show a grid on the open sketch's plane
2026-10-18 21:16:52 -04:00
CamWight aa438611a7 S command search and the new design's name: shown and matched in the current language (#2544) (#2555)
* claim: #2544

* S command search and the command line: names, places and matching in the current language (#2544)

The S box showed each command's English label and toolbar place in every language, and found
only what was typed in English; the command line matched English only too. Rows, places and
tooltips now go through the label lookup, and the query matches the translated label, tab and
panel as well as the English ones and the id. The pin tooltips get catalog entries.

A new sweep (i18n_painted_tests) runs every language, reads the text each frame paints, and
fails on English painted where the language has a translation, skipping the design's own names
and key names, so a painter call that skips the lookup can't come back unnoticed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* The painted-text sweep also covers every Preferences page and four right-click menus

Each surface must show (the Preferences title in that language, or new text for a menu), so the
sweep can't pass by painting nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* S box rows: labels stay 'static; the place's separator is drawn, level in every language

A font's "›" (and one centred on the row) sits on the baseline beside Japanese or Chinese words,
where it reads as a comma. The separator is now a drawn chevron (glyphs::breadcrumb) centred on
the ink of the panel's name. search() returns the place as (tab, panel).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* A new design's name, Untitled, shows translated in the browser too, like the other default names

The tab and the save prompt translated "Untitled" through their own special case; the browser's
top row showed it in English. It is now one of the default names the label layer translates
(Locale::default_name, with Sketch1, Body2, Component3), so every place that shows a given name
shows it translated, and the two special cases go. The painted-text sweep no longer skips a design
name that the language translates, and splits a tab's dirty mark off, so it catches a place that
shows the raw name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:16:48 -04:00
leValdemar19 86b2b97d6f Sketch Offset: constrain to the ends of an offset spline (#2424) (#2615)
* claim: #2424

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SBN24y2TcqRcm2zn8Gkj27

* Sketch Offset: constrain to the ends of an offset spline (#2424)

An associated offset spline hid all of its generated fit nodes, its two
end points included, so nothing snapped to them, and a curve drawn from
one shared the node and was refused ("invalid offset output point roles").

- An open offset's end points are shown and snapped to again; its
  interior fit nodes stay private.
- After every sketch edit, a curve that took an offset node gets its own
  point there, coincident with the node: the offset keeps sole ownership
  and the curve follows the offset when its source moves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SBN24y2TcqRcm2zn8Gkj27

* Scenario: lines snap to and close onto an offset spline's ends (#2424)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SBN24y2TcqRcm2zn8Gkj27

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:16:43 -04:00
NOWN-DEV c177adfceb Clearance: check a shared volume against the union, allowing for the bodies' measuring error (#2090)
* Clearance: check a shared volume against the bodies and their union

An intersection can come out well formed and still wrong: one of a
mirrored pair of imported parts shared three times the volume of its
twin. The shared volume is now compared with vol(a) + vol(b) - vol(a ∪ b),
on the same (cut down) bodies; when they differ by more than 1 % (plus the
bodies' own measuring error), the pair is reported unchecked with both
numbers. Where the union gives no answer, the intersection stands.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Clearance: let the union cross-check allow for the bodies' measuring error

vol(a) + vol(b) - vol(a ∪ b) is a difference of measured volumes and
carries the error of all three, so correct small overlaps on large bodies
came back unchecked (0.96 mm³ where the union implied -0.75). measure
keeps a face's fine tessellation when it is within 1 % of one with a
5 times coarser chord, so a measured volume can be off by up to 0.25 %:
the allowance is now 1 % of the larger of the two answers plus 0.25 % of
the three volumes. Past it, a union that implies nothing (within that
noise of zero, or below it) while the shared part is at most a tenth of
the smaller body cannot tell, and the shared part stands. A shared part
of 412 mm³ where bodies of a few thousand imply 141 is still caught.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Clearance: tighten the union check's measuring allowance to 1e-4

The old note cited measure's 1 % gate, which compares face areas, not
volumes, and its 0.25 % was about 100 times the disagreement the bodies'
measures actually show. The allowance is now justified from volumes: a
curved face's chord shortfall cancels between a body and the union except
where the chord changes, which stays near 2e-5 of the three volumes; 1e-4
leaves five times that. A 40 mm roller pressed 0.2 mm into a 2 m plate
exercises it: with no allowance the pair is left unchecked (7.52 mm³
measured against 8.16 implied), with it the overlap is reported.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-18 21:16:37 -04:00
JIJINBEI 7ad5130cd2 refactor: geom::UnitVec3 for unit axes and normals (#2603)
* geom: UnitVec3; a face's analytic axes and normals are unit by type

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* geom: UnitVec3 adds to and subtracts from a Vec3

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* kernel, doc, engine: cylinder axes and measured normals held as UnitVec3

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* kernel, engine: functions that take a unit axis or direction say so by type

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* doc: a face's normal goes to Plane::from_normal as is

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* kernel, doc: main's new cylinder helpers take the face's axis as a UnitVec3

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:16:31 -04:00
ealitt a3080d02d6 claim: #2561 (#2562) 2026-10-18 21:16:25 -04:00
DuBearte 17e173cb7b Construct tools ask for their picks instead of failing with "a is required" (#2474, #2494) (#2563)
* claim: #2474, #2494

* fix(ui): construct tools ask for their picks instead of failing (#2474, #2494)

The 13 construct tools without a dialog of their own ran their command with no
parameters from the toolbar and reported that `a` (or `face`, `edge`...) is
required. One pick dialog now serves them: labelled inputs per tool,
preselection fills them, and each pick becomes the construction reference the
command takes; Point Along Path keeps its position.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:16:20 -04:00
bherbruck 46bc44b706 Merge pull request #2821 from tangency-labs/ci-faster-shards
CI: faster test shards
2026-10-18 21:16:14 -04:00
bherbruck 9eb1540a34 Docs: describe SolidCraft as independent CAD (#2768)
Reword comments, docs and package descriptions so they describe SolidCraft's own behaviour instead of comparing it to another product. The trademark notice, the commercial-CAD rule and the navigation preset name stay as they were.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 21:16:11 -04:00
bherbruck 3a1e1eed39 Merge pull request #2267 from tangency-labs/ci/slow-runner-timeouts
Tests: more time for the web offline launches and the icon review server
2026-10-18 21:16:09 -04:00
fkirill 9ee2de9a0f Fix drilled hole cuts through tapered walls (#2387) (#2401)
Rebase the complete PR change onto current main, preserving changes previously stored in merge commits.
2026-10-18 21:16:06 -04:00
NOWN-DEV ed64988d8f Joints: a round origin keeps its end when the face it opens onto gets other neighbours (#2760)
A round origin's end is kept by the name of the face across the rim. On
a face in pieces (a sheet split by a groove or a bend) that name lists
the faces round the piece, and when those change (a hole nearby moves
or goes) the name matched neither rim, so the end fell back to the
nearer end of the stored pick: a pick near one end of a thin wall that
the design moved past mid-wall turned a part through 180 degrees. The
end whose rim matches the name better now wins: the face itself, a face
merged into it, then a piece of it, then the same face by its stem.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 21:16:01 -04:00
bherbruck 03e6c3d468 Version 0.16.2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:40:00 +00:00
bherbruck cbf589eb8c Revert #2142: orbit pivot choice back to 0.15.0's, as in 0.16.1 (#2832)
This reverts commit cac4bccea8. Orbiting
about the point under the cursor goes back to 0.15.0's pivot choice
(the picked face or vertex point, an edge's midpoint, or the visible
geometry's centre over empty space), matching 0.16.1.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:36:37 -04:00
bherbruck cd85efd73d CI: the repository checks also keep off the fast runners for pull requests
the format and repository checks job still took a self-hosted-fast runner for every same-repo
pull request. It now follows the same rule, and the notes at the top say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:36:35 +00:00
bherbruck 2452513b65 CI: faster test shards
- Pull requests that can change a robustness golden check every seed but the 17 slowest, in the
  same nextest run as the other tests instead of a second step after it (16-30 minutes on its own).
  The slow seeds (about four fifths of the goldens' time) still run in the merge queue, on main and
  nightly. A failed golden still prints how to re-record it, and junit.xml now holds the whole run.
- The slowest tests start first (nextest priority), so a shard no longer ends with one of them
  running alone.
- tspline::tests::hostile_tsplines_never_panic joins the heavy tests pull requests skip (up to
  12 minutes on a GitHub runner; the same kind of check as hostile_params_never_panic).
- The ci profile builds dependencies without line tables: the test archive pull requests' shards
  download goes from 3.4 GB to 2.2 GB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:36:14 +00:00
bherbruck 9c411859bc CI: self-hosted test shards build in their warm directories, no 3 GB download (#2833)
On main and in the merge queue every test shard ran on the self-hosted machine but waited for
the GitHub-hosted Build tests job and then spent about 12.5 minutes downloading its 3.2 GB
archive (four shards at once over one connection). There, each shard now builds the tests in
the runner's warm build directory (the existing build-tests one) and runs its slice directly;
Build tests runs only where the shards run on GitHub's or the untrusted runners.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:35:03 -04:00
bherbruck 22a86a744e CI: keep the fast self-hosted runners for main and the merge queue (#2830)
Same-repo pull requests ran their heavy jobs on the three fast runners too, and the merge
queue's test shards waited behind them for hours. Pull requests now use the GitHub-hosted path.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:31:22 -04:00
bherbruck 143435dc1c Format and clippy
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 23:53:43 +00:00
bherbruck 36aa4a15fe Merge remote-tracking branch 'origin/main' into sketch-grid 2026-10-18 23:52:12 +00:00
bherbruck e887792182 Fix orbit about the picked point (regression in 0.16.0) (#2753)
Orbiting about the point under the cursor (or the origin) turned the camera
about its target and then panned to put the pivot back. The pan works at the
target's depth with the parallel-view scale, so in a perspective view the
pivot slid off its pixel and the model swung about the camera.

The camera now turns rigidly about the pivot: Camera::orbit_about keeps the
pivot's coordinates in the camera's frame and rebuilds the target from the
new orientation, so the pivot stays exactly on its pixel in both projections.
The Shift snap spring and view.navigate's pivot orbit use the same.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:51:37 +00:00
rlartot dd23be12d5 UI: the Interference dialog and the continuity legend's Gap are translatable (#2443)
* UI: the Interference dialog and the continuity legend's Gap are translatable

The Interference dialog's title, its Bodies input, the Compute and Compute
again button, the checked-bodies count, "could not be checked" and the
clearance error were English in every language. They now come from the
catalogs (en, ja, zh, de, ko, fr).

The continuity legend's Gap (an opening between faces) and the sheet-metal
hem's gap shared the message "Gap", translated as the hem's gap (French
"Jeu"). The hem keeps those translations under ui.sheet_gap; the legend
gets ui.continuity_gap, and both read their message by id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L15TLCjpXsQdp8ovC4ZacH

* Polish: the sheet-metal gap is ui.sheet_gap too

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L15TLCjpXsQdp8ovC4ZacH

* Every catalog gives the continuity legend's Gap the word it showed before

The catalogs without their own ui.continuity_gap would have fallen back to
English in the legend; they now keep the translation the shared Gap gave
them. French and German keep separate words.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L15TLCjpXsQdp8ovC4ZacH

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 18:11:18 -04:00
tdslot ab22da1343 Browser sidebar: drag its width, and float it over the workspace (#2741)
* Browser sidebar: drag its right edge to size it, and remember the width

The left browser sidebar was fixed at 250 px. Its right edge is now a drag handle: the width follows the pointer, is clamped to 170-620 px, and is kept in the preferences (browser_width) so it survives a restart and Reset Defaults returns it to 250. The few pixels of grip sit inside the panel's own margin, so the tree rows and the scroll bar keep their input.

* Browser: float the tree over the workspace on a translucent card

Preferences -> General gains "Parts browser -> Float it over the workspace": the tree then draws on a card over the 3D view instead of taking a column, so the workspace keeps the whole window width. The card hugs the tree, scrolls past 70 % of the view's height, and uses the remembered browser_width. The canvas keeps every point the card does not cover, and turning the preference off docks the browser again.
2026-10-18 18:11:11 -04:00
Irakli Kourtis 011ff24107 fix(i18n): unify font baseline for CJK UI (macOS, Linux, Windows) (#2123)
In a CJK UI, the primary CJK face goes first in Proportional, so digits, units and
symbols in mixed labels share the CJK text's metrics and baseline. A Korean UI puts
the Hangul face first. The user's ~/Library/Fonts is scanned for a CJK font on macOS.

Rebased onto main as one commit (main had added the Thai fallback).

Co-authored-by: Mithril-3d <145893541+Mithril-3d@users.noreply.github.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-18 18:11:03 -04:00
Tyler Chiriboga 4a4eb59788 3MF import: read objects in other model parts (production extension) (#2089) (#2127)
* claim: #2089

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3MF import: read objects in other model parts (production extension) (#2089)

Slicers save multi-part projects with the 3MF Production extension: the
root model's objects are components with `p:path` naming another model
part of the package, which holds the meshes. The reader skipped those
components and build items, so such files did not open at all.

Components and build items now keep the part they name, and the parts
objects name are read once each (looked up through an index of the zip
entries), with the number of part names and their total size capped.
Each part's unit is applied as it is read, so parts in different units
fit together. Cycle detection keys on (part, object), so parts that
place each other in a loop are refused.

Tests: components and build items in another part, part names in any
case, a part in centimetres under a root in millimetres, and hostile
packages (missing part, a path out of the package, an empty path, an
unknown object, a part that is no model, a loop between parts, more
part names than a package can hold). The 3MF mutation fuzz also runs
on a production-extension package.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 3MF import: count every placement toward the budget (#2089)

An object that only places other objects added nothing to the triangle
budget, so a few objects each placing hundreds of components multiplied
at every level up to the depth cap (300 components, 3 levels: about 1 s;
deeper never finished). That was already true for one model part and now
reaches across parts. Every placement now costs at least one unit, so
such a file stops at the budget.

Test: six levels of 300 components over an empty object (300^6
placements) stop with a budget error in well under a second; without
the change the test does not finish.

Suggested in review by @jyviko.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Tyler Chiriboga <18270301+Tyler-Chiriboga@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-18 18:10:56 -04:00
bherbruck 207c67d1da Flatpak smoke test: wait for the viewport before the Wayland probe (#2736)
The control port opens before the first frame, so a single ui.inspect sometimes returned no viewport and the check failed within seconds. The probe now polls ui.inspect until the viewport exists, for up to 60 s, and fails with the last reply if it never does.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 18:10:49 -04:00
bherbruck ff5d96aaad Docker: accept trunk's shorter content hashes (#2734)
Trunk formats the content hash without leading zeros, so it is sometimes 15 hex characters. The immutable-cache rule and the smoke test's wasm match now accept 8 to 16.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 18:10:43 -04:00
Jackson Oursland bc68e14c3e Mutation testing: judge kernel mutants by the black-box suites (#2660)
The kernel will move from truck to solidcore (#1597). The tests that must
catch regressions across that switch are the ones that don't depend on the
kernel: the reference tests and the robustness goldens. cargo-mutants checks
whether they would notice a change in the kernel's behaviour.

- apps/solidcraft-cli/tests/oracle_cases.rs: the reference tests as a cargo
  test (ignored; CI keeps replaying them through cargo xtask oracle), run
  over one process per core.
- .cargo/mutants.toml: mutants are judged by that test and the pull-request
  sample of the robustness goldens, not by the kernel's own unit tests.
- A mutants profile (ci with incremental builds): about 15 s to rebuild after
  a kernel change instead of about 3 minutes.
- docs/mutation-testing.md: how to run it, its cost, two cargo-mutants 27.1
  problems and the pilot's results (15% of sampled kernel mutants caught).

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 18:10:36 -04:00
NOWN-DEV 6d410c342c Joints: origins on an edge of a face, at its middle or a corner (#2403) (#2404)
* Joints: a face or circle origin keeps its turn as the face tilts

A face- or circle-centre joint origin worked out its frame's x from the normal alone
(`Vec3::any_perp`), which switches axis where the normal crosses 0.9 along X: a part on a face
tilted past about 64° by a parameter spun a right angle about the face. New origins keep the x
they have when picked (`x`, component coordinates) and each solve lays it onto the face as it is
now, so the part tilts with the face without spinning. Origins saved without it are placed as
before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Joints: origins on an edge of a face, at its middle or a corner

`joint.origin` and `joint.create` take `edge: [x,y,z]` (a point on an edge of a planar face),
with `on` (which face; default the larger planar face beside it) and `corner` (the end near that
point). The origin sits at the edge's middle (z out of the face, y into it) or at the corner (x
and y along its two edges into the face, right-handed whichever way the edges run). The edge, the
face and the corner's other edge are kept by name and found again on every solve, so a part set
40 mm in from a panel's corner stays there when the panel changes size; a name that finds nothing
leaves the joint out with the reason.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-18 18:10:28 -04:00
bherbruck dded27277f Version 0.16.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:16:56 +00:00
bherbruck 46462957e4 Revert "CI: run the triage and claim bots on the trusted light runner (#2655)" (#2735)
The trusted runners' guard refuses issue and pull_request_target events by design (they carry a
write token and handle untrusted input), so the bots failed there. They go back to GitHub's runners.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 15:55:45 -04:00
bherbruck a228a5830e Release 0.16.0
Version 0.16.0; the pending design-format changes are folded into format 27.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 18:10:44 +00:00
bherbruck e4ff62c825 CI: run the triage and claim bots on the trusted light runner (#2655)
They check out main's own scripts, never pull request code, and were holding dozens of
GitHub-hosted runner slots that pull request builds need.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 13:10:23 -04:00
Artem Sushchev f5a868efda Fix red main after #2247: record its robustness seeds, and keep older designs' joins of split pieces working (#2540)
#2247 changed 16 robustness seeds (bodies N -> 1, and some seeds then build
different steps; seed 54's new shell fails on #2247's parent too). Recorded
on Linux.

Designs saved before #2247 that combined a split-off piece by name (tools
Posts, Posts (1)) failed on the missing piece and left both posts off the
case. A combine now skips a missing `X (n)` when `X` is a tool too or the
join's target, with a warning. Format note for the meaning change.
2026-10-18 13:10:09 -04:00
NOWN-DEV 8313c3468a Joint origins: resolve by the named face, keep a hole's end and the turn (#2186) (#2187)
Thanks @NOWN-DEV.
2026-10-18 13:08:23 -04:00
Rec0iL deb28dd721 Agent chat: attached pictures use the shared viewer, in any format, decoded in the background (#2554)
Thanks @Rec0iL.
2026-10-18 13:07:55 -04:00
Victor Santos Oliveira c25e101b77 docs: align contribution guidance with current capabilities and CI (#2594)
Thanks @Vittuu.
2026-10-18 12:41:33 -04:00
Yuto Nakamura e766fb14fb Preserve warm assembly caches across Save As renames (#2120)
Thanks @yutyan0119.
2026-10-18 12:11:56 -04:00
DuBearte cac4bccea8 Orbit around the point under the cursor: over empty space, pivot about the view centre (#2138) (#2142)
Thanks @DuBearte.
2026-10-18 11:57:29 -04:00
Irakli Kourtis 3762e15cd5 CI: bot script changes run only the repository checks (#2444)
Thanks @jyviko.
2026-10-18 11:35:50 -04:00
Spencer Owen b1e5a5559e Offset Plane dialog opens at 0 mm (#2327) (#2338)
Thanks @spuder.
2026-10-18 11:35:35 -04:00
Loohi 9c7afb6076 Single hole follows its sketch point (#2110) (#2336)
Thanks @Loohi.
2026-10-18 11:35:15 -04:00
N3MI-DG 3eeb20d2bd Browser: hidden items grey out, and folders keep their own visibility (#2308)
Thanks @N3MI-DG.
2026-10-18 11:34:55 -04:00
JIJINBEI a0610a6329 refactor(ui): one function per motion dialog page (#2294)
Thanks @jijinbei.
2026-10-18 10:59:57 -04:00
n0tfr3shm1lk 2c877b9f9d sketch: live preview for fillet and chamfer corners (#2044)
Thanks @n0tfr3shm1lk.
2026-10-18 10:59:55 -04:00
Rec0iL f498d23a79 Agent chat: keep Send in view for a long message, open "View attached" (#2224) (#2225)
Thanks @Rec0iL.
2026-10-18 10:53:31 -04:00
Vodmordia 55bfeb71a7 Plastic dialogs: dimension fields show their units (#1936) (#2107)
Thanks @morelandjo.
2026-10-18 10:30:52 -04:00
Cyryl Sochacki 8025966a35 i18n: add Polish (pl) UI translation (#2032)
Thanks @cyryllo.
2026-10-18 10:24:13 -04:00
HiroKenEz 1ee6611403 A surface the GPU fails to reconfigure is logged and retried, not a crash (#1935) (#1966)
Thanks @HiroKenEz.
2026-10-18 10:02:02 -04:00
fugazister 535a43ab3c Custom colour themes: theme files, Gruvbox built in (#2255) (#2405)
Thanks @fugazister.
2026-10-18 10:01:39 -04:00
Martin Pålsson 2b8e85a7c6 Control server: make the timed-out request test deterministic (#2388) (#2391)
Thanks @martinpalsson.
2026-10-18 09:42:04 -04:00
CamWight b7a6c68702 Docs: a glossary of SolidCraft's terms (#1569) (#2484)
Thanks @CamWight.
2026-10-18 09:19:22 -04:00
rlartot 5ddb94b018 Preferences: picking a font no longer folds the window (#1800) (#2221)
Thanks @rlartot.
2026-10-18 09:12:37 -04:00
Irakli Kourtis d2ccb576dd ci: draft pull requests run no CI until they're ready for review (#2517)
Drafts took runners while the queue held 100+ runs. `changes` and `checks` (which every other
job follows) and the final `ci` job now skip on a draft, and `ready_for_review` is a trigger,
so CI starts the moment a draft is marked ready, then on every push.


Claude-Session: https://claude.ai/code/session_01QWhMwdqJYQKQq8RCLSL1LT

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 09:08:36 -04:00
Irakli Kourtis d818629f38 ci: build-jobs.sh never fails the job when memory can't be read (#2512)
On the self-hosted-fast runners /proc/meminfo isn't readable, so awk exited 2 and failed every
job the sizing step runs in (Doc tests on #2509 and #2510, on minisforum-fast-2 and -3) before
compiling anything. Sizing is an optimisation: without a readable memory figure (meminfo or a
numeric cgroup limit) the script now sets nothing and Cargo keeps its default. Also drops
`set -e`, so no other probe can fail the job.


Claude-Session: https://claude.ai/code/session_01QWhMwdqJYQKQq8RCLSL1LT

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 09:02:01 -04:00
HiroKenEz 7cf8c783e6 Rectangular Pattern: a drag arrow for Spacing 2 (#2462) (#2463)
Thanks @HiroKenEz.
2026-10-18 08:46:50 -04:00
github-actions[bot] 1c18161920 Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci] 2026-10-18 12:24:02 +00:00
franco michetti a4edea9ad2 Agent chat: attach files and pictures to a message (#1744)
Thanks @onembyte.
2026-10-18 08:18:29 -04:00
bherbruck 4ebaca97e7 CI: run every robustness golden on pull requests that change geometry; spread fork PRs over both runner pools (#2492)
#2278 dropped the golden sample from pull requests, and two geometry PRs then
broke main together. Pull requests that touch the kernel, engine, doc, sketch
or geom crates now run every seed, split over the shards, with the command to
re-record in the failure output.

Fork PRs whose number ends in 7, 8 or 9 stay on the untrusted pool; the rest
build and test on GitHub's runners, which have free slots.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 08:11:58 -04:00
Irakli Kourtis 69b73af490 ci: size parallel compiles to the job's memory, not its CPUs (#2425)
Build tests on the untrusted runners (fork PRs, gVisor on shared boxes) fail intermittently with
rustc killed mid-crate ("exit status: 2", no diagnostic) on the biggest crates: solidcraft-engine,
ui-egui. Nothing limits Cargo's parallelism, so it starts one rustc per core the sandbox reports.
scripts/ci/build-jobs.sh sets CARGO_BUILD_JOBS from the memory available (and the cgroup limit,
where lower) at 3 GB per job, capped at nproc, and logs the numbers it saw.


Claude-Session: https://claude.ai/code/session_01QWhMwdqJYQKQq8RCLSL1LT

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 07:26:23 -04:00
H. Conrad 4e0601611c Fix id overflow (#1589)
Thanks @Ian-cmd-ops.
2026-10-18 06:53:57 -04:00
Brazz 07708a4e2a Extrude: a taper composes with a start offset (#2312) (#2313)
Thanks @acbrazzl.
2026-10-18 06:18:43 -04:00
Artem Sushchev a4c4b411e5 Combine: a cut keeps a body joined from separate pieces in one body (#2247)
Thanks @H1D.
2026-10-18 06:02:44 -04:00
franco michetti 5eb7640b54 Agent chat: pop out into its own window, dock back (#1745)
Thanks @onembyte.
2026-10-18 05:59:30 -04:00
Irakli Kourtis 003abc2149 Issue forms and needs-info: ask short bug reports for steps to reproduce (#2416)
* Triage bot: needs-info for short bug reports without steps

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPWBBSRPfPYWgrrhbznewR

* Issue forms for bugs and feature requests; document needs-info

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPWBBSRPfPYWgrrhbznewR

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 05:44:48 -04:00
Irakli Kourtis 136a3e9a2f Report an Issue: open issues on tangency-labs/solidcraft (#2417)
Claude-Session: https://claude.ai/code/session_01TPWBBSRPfPYWgrrhbznewR

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 05:44:23 -04:00
DuBearte e70bd52409 Extrude: a side drawn in collinear pieces is one face (#1788) (#1949)
Thanks @DuBearte.
2026-10-18 05:43:23 -04:00
NOWN-DEV 132b63d24d Kernel profile: a thread's faceted cut and the reference display mesh are timed (#1833) (#2095)
Thanks @NOWN-DEV.
2026-10-18 05:43:05 -04:00
bherbruck 72b7fc385b Fix red main: record robustness seeds 13, 67 and 94 after #2064 (#2396)
#2064 (multi-profile extrusion) merges coplanar faces the boolean used to
leave split. Seeds 67 and 94 change face/edge counts at the same volume;
seed 13 has four fewer edges before its random fillet, which then picks
different edges by index. Every step still passes the validity and
volume-direction checks. Pull request CI runs every 8th seed only, so
main's full run was the first to see these.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 05:38:41 -04:00
rlartot 292747e50b Persistent face names: keep exact history when a prism boolean meets stacked pieces of one cylinder (#1209) (#2205)
* claim: #1209

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011YdMi9BDdjWZ8twfeBHAXz

* Prism boolean: a curved edge at one level crosses the walls there only (#1209)

The prism boolean sews its result slab by slab and keeps, for each section segment, the operand
wall it was read beside; its history is built from those tags. `Stations::new` took every curved
edge between two walls as crossing them at every level, so where two pieces of one cylinder are
stacked (an arm's round end under a boss of that radius) the circle between them cleared the
wall tags of every slab. `prism_lineage::sewn` then gave up, the join or cut came out with no
history, and every face of the body was named from geometry (`exact: false`).

A curve whose control points lie at one level (a B-spline, or a NURBS curve with positive
weights, which lies in their convex hull) is at that level: it crosses the walls there only.
Any other curve still spans every level.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011YdMi9BDdjWZ8twfeBHAXz

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-18 05:33:30 -04:00
Irakli Kourtis 2e4266cb0a Triage bot and PR-based claims (#2397)
* Claim bot: a PR referencing an issue claims it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPWBBSRPfPYWgrrhbznewR

* Triage bot: label new issues and PRs, /triage, backfill

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPWBBSRPfPYWgrrhbznewR

* Docs: a draft PR is the claim; labels and triage

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPWBBSRPfPYWgrrhbznewR

* Triage bot: start a runner only for events it acts on

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPWBBSRPfPYWgrrhbznewR

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 05:17:21 -04:00
Lasse Lian b685b7301f Preferences: reverse horizontal pan direction (#2007) (#2047)
Thanks @lasselian.
2026-10-18 03:12:23 -04:00
cronos 8f768b65c8 Avoid per-frame mesh copies in both timeline layouts (#1907)
Thanks @crono2250.
2026-10-18 02:34:05 -04:00
JIJINBEI 932efbf341 Sketch drag: move only what the constraints need (#2060)
Thanks @jijinbei.
2026-10-18 02:23:24 -04:00
Karl Von Richter b8d7b42808 contributing(docs): clarify draft PR and bot claims (#2317)
Document the maintainer-confirmed workflow: open a draft PR to establish ownership, then comment /claim to mark the issue as taken. Align AGENTS.md and CONTRIBUTING.md and distinguish the existing takeover policy from the bot's automatic cleanup deadlines.

Signed-off-by: Karl Von Richter<karl@embeint.com>
2026-10-18 08:11:29 +02:00
cronos a8e8d82f90 Add regression coverage for mouse picking during 3D mouse navigation (#1786)
Thanks @crono2250.
2026-10-18 01:33:09 -04:00
DuBearte 33cd031701 Sketch: the arc's sweep box no longer covers the end-point pick (#2201) (#2242)
Thanks @DuBearte.
2026-10-18 01:10:53 -04:00
selune 6422ef84d5 Drawing: share length display formatting (#2054) (#2086)
Thanks @0xlillith.
2026-10-18 00:29:15 -04:00
selune 7c9f8a5895 Drawing: validate section geometry contracts (#2053) (#2085)
Thanks @0xlillith.
2026-10-18 00:29:12 -04:00
bherbruck 36b6496ad5 CI: give fork builds on the untrusted pool 90 minutes (#2339)
Fork Build tests jobs were hitting the 45-minute limit while uploading their archive, after
the build had finished, and every one had to start over.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 00:28:18 -04:00
amolinari-autotex 48986041ed Add complete Italian UI localization (#1941)
* Add complete Italian UI localization

Translate all 1509 English catalog entries originally, register Italiano, guard complete coverage and exercise Italian labels and UI rendering.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* i18n: Italian is a partial catalog

it.json lacks 19 messages main has gained since this branch began, so as a
complete catalog it would fail every_complete_catalog_carries_every_english_message.
Like the other community languages, Italian is now partial: its gaps fall
back to English and new labels don't require an Italian string. The docs
line goes back to main's wording, with Italian among the partial catalogs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Ben Herbruck <bherbruck@herbrucks.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:28:04 -04:00
bherbruck c39bd23d07 CI: run all four test shards on the trusted self-hosted runners (#2220)
The trusted runs' test archive is still built on GitHub's ubuntu-24.04
runners; minisforum's newer glibc runs it unchanged, so shards 3-4 can join
1-2 on the self-hosted runners.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:11:35 -04:00
bherbruck a33c5a7861 CI: restore main's Rust cache in fork pull request jobs on the untrusted runners (#2254)
Fork pull requests now run on the untrusted self-hosted runners, which start
every job empty. Let them restore main's rust-cache like GitHub's runners do
(save-if stays main only, so a fork job never writes a cache).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:11:31 -04:00
CaRLymx 5c95b0496d Spanish (es-ES): catch up with en.json (#2101)
Brings assets/locales/es.json back to full coverage of en.json: 60 new
messages translated (motion study, CAM, DXF export progress, sweep rails,
threads, grid follows the view) plus 13 terminology revisions (Loft,
Boss, timeline marker, viewport, ellipsis), all following the glossary.

It also unifies the snap label, as asked in the review of this pull
request: ui.snap (Snap) and cmd.sketch_snap (Snap Inference) now share a
single name, "Referencia a objetos".

Built on top of current main, so the three messages upstream had already
translated in es.json (Direction, In plane, Normal to sketch) are kept.

Co-authored-by: carlymx <24634598+carlymx@users.noreply.github.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-17 21:53:14 -04:00
rlartot 72aa77dabd French: catch up with main (10 messages) (#2203)
The Clearance report, Explode Text, the continuity legend, the Grid follows
the view preference and inspect.silhouette, so fr.json covers en.json again
(1518/1518).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-17 21:51:06 -04:00
DuBearte 243db6cb8f README: list the crash_reports MCP tool (#1821) (#1830)
* claim: #1821

* claim: #1821

* docs: README lists the crash_reports MCP tool (#1821)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-17 21:48:19 -04:00
Irakli Kourtis 83c31d77c4 surface.boundary: surfaces through boundary curves and curve networks (#763) (#2129)
* feat(geom): surfaces through curve networks, and spline spaces to build them

Adds spline_space (clamped B-spline spaces, Hermite interpolation, exact
embedding by Bézier degree elevation and knot insertion, a monotone cubic
for reparameterising) and network (loft, Coons and Gordon surfaces through
curves in one or two directions, with G1/G2 cross-boundary conditions
against a neighbouring face). Part of #763.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(kernel): boundary surfaces from sketch curves, 3D curves and edges

kernel::boundary_network turns sketch chains, 3D sketch polylines and edge chains
into network curves, gives G1 frames of the faces along open edges
(EdgeFaces), builds the B-spline face (sides split at chain vertices so a
join shares the body's edges) and joins it into a surface body by stitching.
G2 is refused for now (not supported yet). Part of #763.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(kernel): second derivatives of B-spline surfaces in the vendored truck-geometry

BSplineSurface::uuder and vvder multiplied the second differences by p
instead of p·(p − 1), as BSplineCurve::der2 does, so every surface of
degree p ≥ 3 (NURBS too, through its homogeneous surface) reported
1/(p − 1) of its second derivative: a cubic surface's curvature read as
half. This is why a G2 boundary surface on a cylinder measured half the
curvature it carries (#763). New tests hold both to finite differences
for degrees 2 to 5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* feat(kernel): G2 boundary surfaces against a body's face

The network always carried the face's curvature across a G2 edge; the
refusal came from measuring it with truck's halved surface second
derivatives (fixed in the previous commit). EdgeFaces now reads the
face's second fundamental form from the exact second derivatives
instead of differencing the normal. A new test holds the curvature
across a cylinder's edge to the cylinder's within 0.1%, also with a
sheared far curve; G3 is refused (a network holds up to G2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* test(kernel): a boundary patch with straight corners measures its own area

A stadium's arcs meet its lines at 180°, so the Coons patch is singular
at its corners. From such a corner the reference mesher's boundary walk
ran onto the B-spline's extrapolation past its domain (a planar spline
passes through every point of its plane there), and the face measured
289 mm² for its 158.5 (#763). The vendored mesher's bounded projection
(nearest_projection, #2043) keeps the walk on the domain; this holds it
there, by the faces, the measuring mesh and the display mesh. The
display path (#1653) was never affected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* feat(geom,kernel): three-sided boundary surfaces (one side collapsed to a point)

A network curve may be a PointCurve: the apex where the two sides beside
it meet. The curves through it cross it at the point itself, its row of
the net collapses there, and the kernel's face leaves that side out of
its loop (three edges, one vertex at the apex). One point per patch, as
a side; refused elsewhere, when it moves, or with a continuity
condition. Tests: a triangle and a quarter disc filled flat with their
analytic areas (40 mm², 25π), by geom and by the kernel's faces and
meshes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* feat(doc): boundary surfaces through curve networks and loops of three or four

FeatureKind::SurfaceNetwork saves a network as `u` and `v` curves, each
a sketch chain (planar or 3D), a chain of a body's edges with an
optional G1/G2 continuity, or a point (the apex of a three-sided
patch), and an `operation` that can join the surface into the body of
its edges. A SurfaceBoundary of three or four sketch curves, refused
until now, builds as the flat Coons patch of its loop (three collapse
the side opposite the first curve). The format change is one pending
file; feature-walk goldens record the new variant's sketches, bodies
and points.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* feat(engine): surface.boundary through loops of three or four and curve networks

The command keeps its loop form (two to four curves of one sketch; three
and four now build the flat patch of the loop, crown refused there) and
gains a network form: `u` and `v`, each curve a sketch curve id, a
{sketch?, curves} chain (planar or 3D), a {body, edges, continuity?}
edge chain (g1/g2 with the face beside it) or a {point} for a
three-sided patch, with `operation` new or join. Edits may move between
the two forms (timeline.redefine accepts the command now that it has a
dialog).

Tests: analytic areas of loop patches and of a hyperbolic paraboloid
bounded by four 3D lines (every mesh vertex on z = xy/10), a loft
across two sketches, G1 and G2 measured across a cylinder's edge with
inspect.continuity (angle < 0.1°; curvature jump 1e-16 with G2 against
0.027 with G1), 26 hostile networks refused without panicking, and u/v
added to hostile_params_never_panic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* feat(ui): Boundary Surface dialog

Curves and Cross curves take sketch curves and edges (picks that meet
end to end in one sketch or body are one chain); Continuity and
Operation (New Body or Join). Two to four sketch curves of one sketch
with nothing across are the loop patch; anything else is a network.
Edit Feature brings either back (per-side orders, crown and stations
the dialog does not show are kept). Strings in en, ja and zh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* docs: ROADMAP M8 and the bridge patch's notes for boundary networks (#763)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* test(oracle): a boundary surface through a loop, thickened (#763)

Case 83: four sketch curves (a square's three sides and a half circle
for its top) make one boundary surface, thickened 2 mm; analytic values
from tools/oracle-analytic/cases_surfaces.py (V = 2A, area 2A + 2P,
centroid by moments). Recipes gain boundary_surface, thicken and remove.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* refactor: boundary surfaces, review cleanups

Project onto the face once per derivative evaluation (the frame is
passed to natural() and reused by scale()), keep each track's
finite-difference step instead of rescanning its table, export the
kernel's edge-pick and chain caps and the default station count for
the engine, doc and dialog to share, map TooLarge spline errors to
KernelError::TooLarge, reuse the engine's operation parser, and skip
the dialog's sketch lookup when the picks are no loop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-17 21:47:38 -04:00
bherbruck a940f77447 Sketch: always show a grid on the open sketch's plane
While a sketch is open its grid is drawn on the sketch's own plane, centred
near the view target and snapped to the major step, whatever the Grid switch,
the sketch's Sketch Grid option or Grid follows the view say. Headless window
pictures follow. Outside a sketch nothing changes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 01:44:15 +00:00
bherbruck ae6a33bf96 CI: run the list checks inside CI's repository-checks job on PRs (#2292)
Lists and Packaging lint each took their own runner slot on every PR push. The list checks now run
in the existing checks job; Lists stays on main. Packaging lint skips PRs that only touch icons.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:00:34 -04:00
bherbruck 675c865431 CI: fork PRs' light jobs on GitHub, their heavy jobs on the self-hosted pool (#2284)
With Windows off pull requests, GitHub's 20 runners sat idle while every
fork job queued for the self-hosted pool. Select checks, repository
checks, Clippy, doc tests and the summary go back to GitHub's runners;
Build tests and the test shards stay on the isolated self-hosted pool.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 20:49:16 -04:00
bherbruck c59842b8e1 Merge branch 'main' into ci/slow-runner-timeouts 2026-10-17 20:26:18 -04:00
bherbruck 2f31138f9d CI: a pull request fast lane; the heavy checks run on main (#2278)
Pull requests now run the repository checks, Clippy, the affected
crates' tests in 2 shards, and the doc tests. Windows, the reference
tests and the robustness goldens run on pushes to main and in the
nightly, where a break is fixed forward. This is the split Bevy, Zed
and Ruff use, and it cuts each pull request's CI to about a third.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 20:25:13 -04:00
Irakli Kourtis 8c503ad1be Revert "CI: run untrusted and GitHub-hosted Linux jobs on Blacksmith's Ubuntu 24.04 runners (#2237)" (#2277)
Blacksmith isn't provisioning runners: its dashboard says the account
"requires additional verification", so every job labelled blacksmith-*
stays queued with no runner. Fork PRs' Linux jobs go back to the
self-hosted-untrusted pool (#2249/#2252) and the rest to GitHub's runners
until Blacksmith verifies the account; then #2237 can be re-applied.

This reverts commit dc40f051b1.


Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 20:23:57 -04:00
bherbruck a587d3339e Tests: more time for the web offline launches and the icon review server
On a busy CI machine (several fork jobs under gVisor at once) a web
offline launch took over 90 s and the icon review server took over ~2 s
to answer. Launches now get 240 s and one retry; the review server gets
up to 60 s to start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 00:11:14 +00:00
bherbruck dc40f051b1 CI: run untrusted and GitHub-hosted Linux jobs on Blacksmith's Ubuntu 24.04 runners (#2237)
Fork pull requests, the nightly run, Run workflow, the test archive and
test shards 3-4 move from ubuntu-24.04 to blacksmith-<N>vcpu-ubuntu-2404
(16 vCPU for Clippy and the test archive, 8 for test shards, doc tests,
reference tests and cross-compiles). Trusted runs keep the self-hosted
routing. Blacksmith runners count as self-hosted to GitHub, so steps now
tell our own machines apart by runner name.

TEMPORARY: this branch routes its own run like a fork pull request.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
2026-10-17 20:04:20 -04:00
bherbruck 712b427a9c CI: a fork PR's Select checks and ci jobs run on the untrusted runners too (#2252)
Every fork run waited in GitHub's shared queue just to start. The gate
and summary jobs now run on the isolated runners as well; the local
output stays false for forks so later jobs never pick the trusted pool.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 19:37:10 -04:00
bherbruck e48e553758 CI: fork PRs run their Linux jobs on the isolated self-hosted runners (#2249)
Fork PRs were waiting hours for GitHub's shared runners. Their Linux
jobs (checks, Clippy, Build tests, the test shards, Doc tests, Reference
tests) now run on the ephemeral, network-jailed self-hosted-untrusted
runners, without the trusted runners' warm build directories.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 19:27:13 -04:00
github-actions[bot] dc8edead22 Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci] 2026-10-17 23:22:01 +00:00
JIJINBEI 2acc344aa4 Sketch: show open and untied profile ends (#2000) (#2001)
Thanks @jijinbei.
2026-10-17 18:52:12 -04:00
Koschtetnix 487aa64e10 Multi profile extrusion performance (#2064)
Thanks @Koschtetnix.
2026-10-17 18:30:52 -04:00
Mikalai Kukhta 180e62c981 Macos space mouse support (#1815)
Thanks @NecroKote.
2026-10-17 18:15:15 -04:00
Karl Von Richter 11a196e6f8 sketch(feat): add dimension references and parameter tools (#1548)
Show each dimension's parameter name while creating or editing it, and let
users insert another driving dimension at the expression cursor or selection.
Use the available editor width and keep Apply aligned with the right edge.

Add a tools dropdown with Measure, named user parameter expressions, and
access to the existing parameter table for adding and editing parameters.
Preserve the unfinished dimension expression while either window is open.

Cover reference insertion, dependent updates, parameter selection, dialog
round trips, applying and cancellation with UI integration tests. Update the
parameter guide.

Signed-off-by: Karl Von Richter<karl@embeint.com>
2026-10-18 00:00:35 +02:00
Thomas Kroll a11446fd17 Web: a launch behind an expired sign-in shows the sign-in instead of the kept build (#2151)
Thanks @nofuturekid.
2026-10-17 17:54:29 -04:00
DuBearte 7a3221d2dd Updates: 0.11–0.14 must install 0.15 by hand once (#2136) (#2141)
Thanks @DuBearte.
2026-10-17 17:28:04 -04:00
bherbruck e8776d8583 Toolbar fills the available width instead of leaving a gap (#2108) (#2109)
The toolbar gives up room in small steps (labels shorten, then icons only, then panels hand buttons to their drop-down one at a time, then trailing panels go to the » menu) instead of collapsing whole panels, so medium-width windows no longer lose icons or leave a blank gap.
2026-10-17 17:08:51 -04:00
Pablo López cb857f1fa7 Rib/Web: grow along the sketch normal (#2056) (#2057)
Thanks @pablopeza.
2026-10-17 14:15:03 -04:00
bherbruck e347f820e6 Fix red main: give inspect.silhouette an icon; scroll to Colour vision in its scenario (#2134)
Preferences > Graphics grew a row (Grid follows the view, #2035), so the Colour vision
control now sits below the fold of the fixed-size page; the scenario scrolls to it.
The page itself already scrolls for real users.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:53:01 -04:00
rpromas 6597ffe072 Self-host the web app with Docker; publish the image to ghcr.io (#2033)
Thanks @rpromas.
2026-10-17 13:49:07 -04:00
Mateusz Sych 4a70c03deb Revolve: a profile that comes back to the axis along a curve revolves right, and cuts with it work (#1853) (#1866)
Thanks @SychPL.
2026-10-17 13:38:41 -04:00
fkirill f480898158 Extrude: join extensions of ribs on curved bases (#2059) (#2082)
Thanks @fkirill.
2026-10-17 13:33:21 -04:00
Moritz 9be8bb5e6b Fix Steam Frame STEP trims and spline projection (#1974) (#2043)
Thanks @moritzuehling.
2026-10-17 13:33:17 -04:00
JEREMY 843fa0dac6 Grid: hysteresis and no wall swap at the 45° crossing behind Grid follows the view (#1993) (#2050)
Thanks @jeremymiribung-blip.
2026-10-17 12:51:38 -04:00
fkirill 2c924fd385 Sweep: close circular tools before cutting a torus (#2005) (#2010)
Thanks @fkirill.
2026-10-17 12:46:21 -04:00
bherbruck 8be736c33e CI: trusted pull requests use the self-hosted runners too (#2012)
Pull requests from branches of this repository share the work with the self-hosted runners like main does; fork pull requests stay on GitHub's runners. Select checks and ci run on a light self-hosted runner; the repository checks, Clippy, doc tests, reference tests and test shards 1-2 on the fast ones. The test archive is still built on GitHub's runners (glibc).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:00:38 -04:00
selune f3494efbbb Fix complete Japanese and Chinese grid-follow-view messages (#2092)
* claim: complete Japanese and Chinese grid-follow-view messages

* fix(i18n): complete grid-follow-view messages in Japanese and Chinese
2026-10-17 17:56:39 +02:00
bherbruck 6fe16b790b Link the Discord invite directly (#2094)
Some security filters block the solidcraft.io/discord redirect.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:55:21 -04:00
bherbruck 979614e60f Release 0.15.0 (#2039)
Version 0.15.0; pending design-format changes folded into format 26.
2026-10-17 11:55:05 -04:00
Nathan Dane 8a31738fe5 docs: ribbon redesign proposal (#1439)
* docs: ribbon UX redesign proposal (mockups and notes)

Seven mockup images and a short description of the proposed ribbon shell, with floating and
compact layout options. Pictures only; no app changes. The sketch variant with tab locks is
left out.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* docs: update the UX mockups to the current command set

Adds the Sketch Pattern panel, Perforate, new joints and motion commands, Fair and Match Surface,
and the Render workspace; the workspace switcher now matches the app (Design and Render available,
Animation, Simulation, Manufacture and Drawing later). Command map covers all 389 commands.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* docs: draw the UX mockups with the reviewed icon SVGs and accents

Ribbon, browser, history and navigation icons now come from the icon system in #853, with the
Sketch tab in the Sketch green and constraints in red.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* docs: address review on the UX proposal README

Describe the current toolbar in our own terms, and list only Design and Render as the workspaces in
scope.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 11:36:43 -04:00
Irakli Kourtis f7343e9161 CI: a main push the merge queue already tested runs only repository checks (#1860)
The merge queue runs the full suite on exactly the commit it then puts on
main, and main's push run then ran the full suite again on the same files.
Select checks now asks the Actions API whether a passing merge_group CI run
exists for the pushed commit; if so the push runs only the repository checks.
A push that skipped the queue, or an API that cannot answer, still runs
everything. The nightly still runs the whole suite.

Pull requests also run less: the reference tests only when solidcraft-cli (or
anything it depends on) is affected, and doc tests only for the affected
packages that have a library. The ci check requires the reference tests only
when they were selected.


Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 10:21:08 -04:00
bherbruck 72145d4aba Grid: stay on the ground (XY) by default; following the view is a preference (#2035)
#1583 turned the grid to the upright origin plane in side views for
everyone. That is now Preferences > Grid follows the view, off by
default; headless pictures keep the ground grid.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 09:45:04 -04:00
froglemonade2 a6eeb88de0 docs: remove the Send2Orca note from the README (#1982)
Thanks @froglemonade2.
2026-10-17 09:11:31 -04:00
bherbruck 49dda52d09 i18n: Czech is not a complete catalog yet (31 messages missing) (#1999)
#1752 listed cs as complete, but it lacks messages added to en.json since,
which failed the completeness test on main. Missing messages fall back
to English until the catalog catches up.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 08:57:38 -04:00
NOWN-DEV 09b3bd0e01 Sheet metal: machine a hole feature's drills in one pass, keep cuts after a text cut (#1979) (#1980)
Thanks @NOWN-DEV.
2026-10-17 08:52:19 -04:00
Irakli Kourtis c4d2ae87ee deps: zip 3.0.0 → 9.0.4 (RUSTSEC-2026-0367, -0368, -0369) (#1971)
Thanks @jyviko.
2026-10-17 08:49:02 -04:00
CamWight 30ca43de7d io: every file reader survives deterministic mutation; glTF refuses a POSITION naming a missing accessor (#469) (#1923)
Thanks @CamWight.
2026-10-17 08:45:42 -04:00
NOWN-DEV 8a2d142333 fix: a cut takes only its sketch, a join adds only it (#1310)
Thanks @NOWN-DEV.
2026-10-17 08:42:17 -04:00
Irakli Kourtis 0a66697da8 Continuity Analysis tool, legend and theme colours on #1642's inspect.continuity; curvature combs on faces (#770) (#1247)
Thanks @jyviko.
2026-10-17 08:38:55 -04:00
bherbruck a000bd69cf Clearance: give inspect.clearance the Interference icon (#1991)
The icon check from #853 requires every command to have one; #1900 added
inspect.clearance without, which failed Format and repository checks on
main and every PR.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 08:30:52 -04:00
NOWN-DEV 40abcbc8cf Booleans: prism cuts whose walls only touch the target are exact (#1894)
Thanks @NOWN-DEV.
2026-10-17 08:00:06 -04:00
CamWight b6f9b1deac Pages links point at docs.solidcraft.io: Flatpak repository, help and troubleshooting (#1922)
Thanks @CamWight.
2026-10-17 07:26:28 -04:00
DuBearte b04e0801d6 Per-command timings for script runs and batches (#1868)
Thanks @DuBearte.
2026-10-17 07:26:20 -04:00
Mateusz Sych d1b96100d3 Split Body: a plane lying on a face of the body splits it exactly (#1510) (#1897)
Thanks @SychPL.
2026-10-17 07:21:06 -04:00
rlartot 2f24878bb0 French: catch up with main (#1676)
Thanks @rlartot.
2026-10-17 07:18:45 -04:00
github-actions[bot] b64edf15d3 Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci] 2026-10-17 11:12:34 +00:00
onenewcode 87d4eb55e5 fix(kernel): fillet mixed arc and line loop corners (#604)
Thanks @onenewcode.
2026-10-17 07:06:26 -04:00
franco michetti 78c6364b02 Saving to a network share on macOS: fall back to a plain fsync (#1741)
Thanks @onembyte.
2026-10-17 07:02:37 -04:00
franco michetti b7eac9220b inspect.silhouette: the outline of bodies seen from a view, as a mask (#1743)
Thanks @onembyte.
2026-10-17 06:56:25 -04:00
Olup 4193ea26ac Expose a JavaScript command API for the browser app (#1713)
Thanks @olup.
2026-10-17 06:54:16 -04:00
Pablo López 40bdc1132e Draw the view orthographic while a sketch is open (#1758)
Thanks @pablopeza.
2026-10-17 06:48:42 -04:00
The Nomad e052d19879 3D mouse: any HID multi-axis controller with motion axes (#1880)
Thanks @nomadsgalaxy.
2026-10-17 06:48:02 -04:00
franco michetti fbc00bb2c4 STEP import: a ball bounded only by its seam comes in whole (#1742)
Thanks @onembyte.
2026-10-17 06:46:47 -04:00
H. Conrad 6bde126a5c test: save the proptest seed for the grid union face that does not mesh (#1813)
Thanks @Ian-cmd-ops.
2026-10-17 06:23:17 -04:00
Steven Huff 7d44359cf6 Web: show the SolidCraft mark being cut from a cube while loading (#1908)
Thanks @shuff57.
2026-10-17 06:22:52 -04:00
bherbruck 29dfa6bd32 Attribution: the Czech catalog (#1952) 2026-10-17 06:19:12 -04:00
allisonChilton 8c58efdecf Fix missing viewport surfaces after a skipped startup frame (#1791)
Thanks @allisonChilton.
2026-10-17 06:10:46 -04:00
HiroKenEz 44732f8b40 Parameters: a favourite's expression takes each key once (#1767) (#1919)
Thanks @HiroKenEz.
2026-10-17 05:37:21 -04:00
NOWN-DEV 3fe6bacea0 fix: a blind cut on a long body keeps its small rounds (#1593)
Thanks @NOWN-DEV.
2026-10-17 05:37:16 -04:00
github-actions[bot] 39758b0cc4 Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci] 2026-10-17 09:31:55 +00:00
NOWN-DEV 172782705a fix: STEP import reads unclamped (periodic) B-splines on the seam (#1916)
Thanks @NOWN-DEV.
2026-10-17 05:30:13 -04:00
NOWN-DEV 186555dbe9 Clearance report: overlaps, near pairs and nearest bodies (#1900)
Thanks @NOWN-DEV.
2026-10-17 04:54:39 -04:00
DuBearte fd073620ae Sketch: Explode Text turns a text's outlines into ordinary curves (#1842) (#1921)
Thanks @DuBearte.
2026-10-17 04:52:41 -04:00
potuzai e49770bd24 Add Czech (cs) UI translation (#1752)
Thanks @potuzai.
2026-10-17 04:47:05 -04:00
bherbruck ccd0b5aab8 Robustness goldens: seeds 13, 71 and 94 build as main builds them, not as #853's branch did (#1929)
#853 recorded seeds 13, 71 and 94 with the face and edge counts its own branch built
(40/101, 19/50, 27/63). With main's kernel they still build as they did before #853
(41/103, 20/52, 26/61), so every full goldens run has failed since it landed. This
records them again; #853's seed 20 change and the other 93 seeds are unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 04:40:23 -04:00
selune 09188c4586 Merge pull request #1689 from jawler/add-spanish-translation
Add Spanish (Spain) localization (es-ES)
2026-10-17 10:38:09 +02:00
Tommaso Bianchi f4ed349410 README: link Send2Orca (SolidCraft to OrcaSlicer) (#1906)
Thanks @tommasobbianchi.
2026-10-17 04:35:56 -04:00
github-actions[bot] aa9119f922 Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci] 2026-10-17 08:23:21 +00:00
DuBearte 9c07bdd823 Fonts: a Thai fallback, so Thai text no longer draws as boxes (#1913)
Thanks @DuBearte.
2026-10-17 04:10:18 -04:00
DuBearte 67d4628753 Stand this part up? offer: handles for its buttons, and stand-up scripting (#1911)
Thanks @DuBearte.
2026-10-17 04:10:15 -04:00
Mikalai Kukhta 64235ab12a 3D mouse: invert and speed for each of the cap's six axes (#1814)
Thanks @NecroKote.
2026-10-17 04:09:55 -04:00
phanoob c9b8267b9e Action matrix: record the chamfers #1428 refuses (#1684)
Thanks @phanoob.
2026-10-17 04:09:51 -04:00
James Aita a5003da9d7 Navigation: hold Space to pan (#1754)
Thanks @styles01.
2026-10-17 04:09:48 -04:00
Mateusz Sych ff7f3204d5 Updater: recognise an MSI install outside Program Files (#1870) (#1873)
Thanks @SychPL.
2026-10-17 04:08:46 -04:00
Mateusz Sych a2245b2f51 Sketch profiles: a line passing next to a junction splits the loop it crosses (#755) (#1879)
Thanks @SychPL.
2026-10-17 04:08:43 -04:00
Mateusz Sych 3eae8c34f6 Fillet beside a hole: the suggested largest radius fits (#324) (#1885)
Thanks @SychPL.
2026-10-17 04:08:39 -04:00
NOWN-DEV c38992fc09 fix: booleans with whole-turn round faces read from STEP are exact instead of failing (#1892)
Thanks @NOWN-DEV.
2026-10-17 04:08:34 -04:00
NOWN-DEV 962070e955 Interference: a pin a hair off the axis of its hole gets its crescent (#1893)
Thanks @NOWN-DEV.
2026-10-17 04:08:31 -04:00
NOWN-DEV 713111d9df STEP export: an inserted assembly's nested parts are placed once (#1895)
Thanks @NOWN-DEV.
2026-10-17 04:08:26 -04:00
NOWN-DEV 2831426c51 solid.mirror: mirror bodies of placed components where they are shown (#1896)
Thanks @NOWN-DEV.
2026-10-17 04:08:22 -04:00
NOWN-DEV 9ce940b0b1 Evaluation: sketch and rename commands no longer slow down with the number of placed bodies (#1898)
Thanks @NOWN-DEV.
2026-10-17 04:08:18 -04:00
NOWN-DEV 9a4d936b7b fix: text cut into a large part stays on the exact 2D route (#1903)
Thanks @NOWN-DEV.
2026-10-17 04:08:12 -04:00
DuBearte cd9db57366 Look At pressed again looks from the other side (#1886) (#1904)
Thanks @DuBearte.
2026-10-17 04:08:09 -04:00
DuBearte 39850fb6ab Extrude preview: a New Body has its own colour, apart from Join (#1805) (#1861)
Thanks @DuBearte.
2026-10-17 04:08:03 -04:00
Karl Von Richter 3689f0d6e5 ui(feat): open expression suggestions with Ctrl+Space (#1852)
Thanks @Karl-VonRichter.
2026-10-17 04:07:56 -04:00
DuBearte ddc6aa6301 docs: CLAUDE.md carries the current shared agent rules (#1822) (#1859)
Thanks @DuBearte.
2026-10-17 04:07:53 -04:00
Mateusz Sych 7f2ae11a06 Regression test: a box intersect splits the end off a raked wedge after its end rounds (#1617) (#1862)
Thanks @SychPL.
2026-10-17 04:07:50 -04:00
bherbruck 15a13e2452 Merge pull request #1914 from tangency-labs/ci-hybrid-runners
CI: main and merge-queue runs share the work with the self-hosted runner
2026-10-17 04:07:48 -04:00
Irakli Kourtis 98ce6a4ee8 Sweep guide rails and two-rail sweep (#771) (#1806)
Thanks @jyviko.
2026-10-17 04:07:46 -04:00
Irakli Kourtis 7ba2d5db47 Robustness goldens: one test per seed, so CI spreads them (full runs ~33 → ~15-20 min) (#1851)
Thanks @jyviko.
2026-10-17 04:07:43 -04:00
Mateusz Sych 0292fa35e6 Sketch Fillet: pick the two lines of a corner where more lines meet (#1824) (#1826)
Thanks @SychPL.
2026-10-17 04:07:40 -04:00
Irakli Kourtis daf1dc4201 CI: Windows jobs only when what they test changes; only main saves caches (#1840)
Thanks @jyviko.
2026-10-17 04:07:36 -04:00
H. Conrad 221e841d32 Kernel/refine mesh for curved meeting (#1812)
Thanks @Ian-cmd-ops.
2026-10-17 04:07:33 -04:00
NOWN-DEV 2abf4e5a7d STEP import: read HYPERBOLA and PARABOLA edges exactly (#1832)
Thanks @NOWN-DEV.
2026-10-17 04:07:29 -04:00
Thodoris Tsagkogeorgas f3b5846e6b Escape stays in the sketch (#1804) (#1836)
Thanks @thodoristsagko.
2026-10-17 04:07:26 -04:00
Thodoris Tsagkogeorgas 337564190b Midplane asks for the two planes (#1845) (#1846)
Thanks @thodoristsagko.
2026-10-17 04:07:23 -04:00
Mateusz Sych f350c35414 Expressions: a mixed number such as 2 1/8" is 2.125" (#1802) (#1820)
Thanks @SychPL.
2026-10-17 04:07:19 -04:00
Nathan Dane 64966bab88 Implement the reviewed UI icon system and durable review workflow (#853)
Thanks @Nathan-Dane.
2026-10-17 04:07:16 -04:00
Jawler fb23bab9af Update ATTRIBUTION.md 2026-10-17 09:49:16 +02:00
bherbruck ef3bdd3433 CI: main and merge-queue runs share the work with the self-hosted runner
Clippy, the doc tests and test shards 1-2 of pushes to main and merge-queue
groups run on the self-hosted runner, alongside GitHub's runners for
everything else. Pull requests, the nightly run and Run workflow stay on
GitHub's runners. Clippy and the doc tests keep warm build directories
outside the checkout (scripts/ci/warm-target.sh).

The test archive is still built on GitHub's runners: the self-hosted
machine has a newer glibc, so binaries built there don't start on
ubuntu-24.04.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 06:47:41 +00:00
Mateusz Sych c599238653 Insert SVG: the file picker opens on SVG files (#1801) (#1827)
* claim: #1801

* Insert SVG: the file picker opens on SVG files (#1801)

Every Open went through one host picker whose filters start with designs, CAD
files and meshes, so Insert SVG (and Insert DXF) in a sketch opened on STEP,
IGES and meshes, and the SVG file had to be found under another filter.

The host's pick_open now takes the extensions the command reads and offers
them as the picker's first, default filter. Insert SVG and Insert DXF pass
their type; every other Open passes none and keeps the list it had. The
browser host ignores them (it shows its own file panel).

---------

Co-authored-by: MateuszSychXpl <151755914+MateuszSychXpl@users.noreply.github.com>
2026-10-17 00:27:11 +00:00
Karl Von Richter a6ca2e8ca1 contributing(docs): correct the Trunk project links (#1838)
Replace the incorrect trunkrs.dev landing-page link with the trunk-rs/trunk GitHub repository and add the project's documentation website so contributors can find the web build tool and its setup instructions.

Signed-off-by: Karl Von Richter<karl@embeint.com>
2026-10-17 00:27:09 +00:00
bherbruck f1183bcd21 CI: fix the workflow file (an empty env block made it invalid) (#1877)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 22:09:15 -04:00
bherbruck 81ac571fa2 CI: drop the self-hosted build directory (#1872)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:37:00 -04:00
bherbruck dece550164 CI: back to GitHub's runners while the self-hosted runner's environment is fixed (#1871)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:36:41 -04:00
bherbruck 8a03355d2b CI: all Linux jobs of main and merge-queue runs use the self-hosted runner (#1864)
Only the Windows jobs still wait for GitHub's runners on main and in the
merge queue. The Vulkan packages are preinstalled on the self-hosted
runner (no sudo), so that step runs only on GitHub-hosted runners.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:21:50 -04:00
bherbruck 31b73473e9 CI: main and merge-queue builds run on the self-hosted runner (#1847) 2026-10-16 20:10:46 -04:00
Dante 0e1cd4c178 docs: add Remade listing badge before stars (#1857) 2026-10-16 19:53:33 -04:00
Dante c28b7a6229 docs: point browser links to solidcraft.io/app (#1844) 2026-10-16 18:37:16 -04:00
github-actions[bot] b3d63004f2 Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci] 2026-10-16 22:26:31 +00:00
bherbruck 6be3a5ab90 Generated files: push with the bot's deploy key, which the merge queue lets through
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 22:16:44 +00:00
bherbruck 9ec7caf9e6 CI: run the full suite for the merge queue (#1841) 2026-10-16 18:16:08 -04:00
selune 37496198bf Merge pull request #1712 from SychPL/feat/motion-analysis
Motion analysis: chart a study's joints and points, and find the parts that collide along it
2026-10-17 00:09:37 +02:00
Irakli Kourtis 9de0b9d1f1 Merge pull request #1839 from jyviko/fix/goldens-seed-55 2026-10-16 18:07:05 -04:00
selune 649ad39a81 Merge pull request #1825 from Rec0iL/feat/space-mouse-spacenavd
3D mouse: read spacenavd on Linux so the spnavcfg settings apply (#1000)
2026-10-17 00:06:33 +02:00
selune 1ef079dc0a Merge pull request #1823 from SychPL/fix/symmetry-moves-free-line-1796
Sketch symmetry: the free symmetry line moves when both mirrored lines are fixed (#1796)
2026-10-17 00:05:19 +02:00
jyviko 8390a31531 Robustness goldens: seed 55 builds a valid body since #1670 and #1753
Main's CI has failed since 14:47 on `robustness_goldens::all_3`: seed 55 (a
filleted box cut by spheres and patterned) used to end with an invalid body and
now passes the validity check, and its volume moved by 1.3e-5 relative.

The outcome changed between 2ae21143 (main's last green run) and 7f9074d6,
where the kernel meshing change is #1670 (twisted ruled faces divided to the
chord tolerance). The volume changed between 8a055afd and 31b2ddc0, where
#1753 changed truck-meshalgo's triangulation (the faces of an edge share its
points exactly). The kernel's validity and volume checks measure those meshes.
Pull requests run every 8th seed, so only main's full run saw seed 55.

Only seed 55 is recorded, with the values main's CI printed on Linux.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM
2026-10-16 18:02:21 -04:00
Rec0iL b53f6bad52 3D mouse: read spacenavd on Linux so the spnavcfg settings apply (#1000)
SolidCraft read the HID device directly, which bypasses spacenavd and
everything set in spnavcfg (swapped and inverted axes, sensitivity, dead
zones). When the daemon's socket is there it is read instead, with a fixed
32-byte record decoder and no library; without it the hidraw path is
unchanged. The daemon sends a record only when values change, so its
readings are held until the next one rather than going stale.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 23:10:13 +02:00
MateuszSychXpl eb6abecc3d Sketch symmetry: the free symmetry line moves when both mirrored lines are fixed (#1796)
A constraint is applied with its first-picked entities held and the second
moving; when the second can't move, a first one moves instead. That fallback
was skipped as soon as ANY first entity was locked, so a free construction
line between two fixed lines was never tried, and Symmetry was refused with
"neither entity can move".

Each first entity that can move is now tried on its own, after the second.
Nothing changes where the second can move.
2026-10-16 23:02:33 +02:00
MateuszSychXpl ae92d0238d claim: #1796 2026-10-16 22:51:51 +02:00
Dante d357bd2073 docs: update public links and custom-domain Pages paths (#1780) 2026-10-16 16:39:59 -04:00
CamWight c5f3515073 Viewport: hidden edges no longer show through faces, and thin or coplanar faces stop z-fighting (#1434, #1739) (#1768)
Thanks @CamWight.
2026-10-16 16:26:19 -04:00
NOWN-DEV c7bb64dd74 feat: a slanted or curved edge across a sheet metal bend folds to a smooth curve of few pieces (#1799)
Thanks @NOWN-DEV.
2026-10-16 16:26:15 -04:00
H. Conrad bb13799246 Fix/clippy option zip (#1798)
Thanks @Ian-cmd-ops.
2026-10-16 16:26:11 -04:00
Rec0iL 04b4381b54 Agent chat: Antigravity replaces the retired Gemini CLI (#1790) (#1795)
Thanks @Rec0iL.
2026-10-16 15:26:14 -04:00
Mateusz Sych 920ea42fe1 Revolve: a partial turn's planar faces come out whole (#726) (#1718)
Thanks @SychPL.
2026-10-16 15:26:10 -04:00
Jackson Oursland 8907da5e86 Viewport picking: a BVH per body mesh and a GPU pick buffer for visible edges (#1776)
Thanks @oursland.
2026-10-16 14:26:38 -04:00
Jackson Oursland a9413fe385 Keep viewport hover and click picking while a 3D mouse moves the view (#1781)
Thanks @oursland.
2026-10-16 14:26:32 -04:00
HiroKenEz 45ae1bef95 Drag arrows take a press on their stem, not only on their tip (#1740) (#1764)
Thanks @HiroKenEz.
2026-10-16 14:26:28 -04:00
Rec0iL 6496ec86ec Agent chat: show the agent's answers as Markdown (#1778)
Thanks @Rec0iL.
2026-10-16 13:26:23 -04:00
Pablo López ff7c899840 Add Galician (gl) UI localization (#1771)
Thanks @pablopeza.
2026-10-16 13:26:18 -04:00
HiroKenEz 19be7d27e7 Tapered join on a face: sew a whole face, keep the run-on inside the body (#1534) (#1763)
Thanks @HiroKenEz.
2026-10-16 13:26:15 -04:00
Mateusz Sych 31b2ddc0cf Split Body: the face a plane split cuts takes an attachment that follows the plane (#565) (#1746)
Thanks @SychPL.
2026-10-16 13:26:11 -04:00
cronos e2667b749d Keep large STEP assemblies responsive during 3D navigation (#1762)
Thanks @crono2250.
2026-10-16 12:26:26 -04:00
Colin Faulkingham da65b4fc8c Thread: suggest cylinder size and expose metric diameter and pitch (#1759)
Thanks @cfaulkingham.
2026-10-16 12:26:23 -04:00
Pablo López 741265f214 Create Sketch shows the origin planes through the model (#1755) (#1757)
Thanks @pablopeza.
2026-10-16 12:26:17 -04:00
Mateusz Sych 74ce904142 Mesh export: the faces of an edge share its points exactly (#1753)
Thanks @SychPL.
2026-10-16 12:26:14 -04:00
bherbruck a8a3cea1d0 README: link solidcraft.io (#1769)
Thanks @bherbruck.
2026-10-16 12:14:56 -04:00
MateuszSychXpl f96aaa2a98 Motion analysis: a check that runs out before its first step says so, Contact sets offered only when one is active 2026-10-16 17:52:39 +02:00
MateuszSychXpl 53ae46e6b5 Motion analysis: modules in order after the rebase onto the animation export 2026-10-16 17:52:39 +02:00
MateuszSychXpl 3f81495514 Motion analysis review round 4: unwrap only followers within half a turn, found pairs in a set, key values kept in full 2026-10-16 17:52:39 +02:00
MateuszSychXpl c34e607f5b Motion analysis review round 3: ui.start reads a study as motion.study does, keys off the timeline refused, pairs counted once, sub-assembly copies documented 2026-10-16 17:52:39 +02:00
MateuszSychXpl 3128762014 Motion analysis review round 2: a number names a study by id first, the dialog analyses the study it made on the scratch copy, duration 0.001 s or more, volumes by the innermost member 2026-10-16 17:52:39 +02:00
MateuszSychXpl b25e9c75aa Motion Study dialog: the collision check above the chart, so it stays in view 2026-10-16 17:52:39 +02:00
MateuszSychXpl 7119d60d9d Motion analysis review round 1: unwrap only followers and past half a turn, studies by id, refuse keys between steps, count mesh placement work, duration checked in the dialog, Browser joint pick charted 2026-10-16 17:52:39 +02:00
MateuszSychXpl f5871608f4 Motion analysis: scripting handles, depth wording, one hit per member pair a sample 2026-10-16 17:52:39 +02:00
MateuszSychXpl 8fdf6a61a2 Motion analysis: plots of a study's joint values and a point's path, and the parts that collide along it
- motion.plot: joint values and a point's path sampled as the study plays (closed loops
  following), with velocities and accelerations by finite differences, per step or per
  second with a duration.
- motion.interference: the pairs of parts that pass through each other along a study, with
  the first step, the step ranges, the depth and the shared volume; occurrences, against
  or contact sets choose the pairs; bounded samples and work.
- Motion Study dialog: a Study box opens a saved study; a chart of a joint value or a
  picked point with a cursor at the step shown (click to move there); Check collisions
  with the steps shaded on the chart. ui.start motion.study takes study.
2026-10-16 17:52:38 +02:00
bherbruck 8a055afdd6 README: centred header with downloads and badges; link the Discord (#1761)
Thanks @bherbruck.
2026-10-16 11:31:07 -04:00
Mateusz Sych bf4551159e Split Body and Combine Intersect keep their faces' sources, so sketches attach (#565) (#1732)
Thanks @SychPL.
2026-10-16 11:26:17 -04:00
Mateusz Sych 6b9501f442 SpaceMouse tests: run the frame and the wheel hook at the test's own time (#1749)
Thanks @SychPL.
2026-10-16 11:26:13 -04:00
bherbruck 7f9074d65c Update check works under either repository owner (#1747)
Release pages and assets are accepted under bherbruck or tangency-labs (exact owner/repo, github.com only); the API request stays on the old name.
2026-10-16 10:47:13 -04:00
bherbruck 201e055259 Windows relaunches after an update (#1748)
After the MSI installer finishes, wait for msiexec and start the installed exe; relaunch is detached on Windows.
2026-10-16 10:40:58 -04:00
github-actions[bot] a16226288f Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci] 2026-10-16 14:39:55 +00:00
Mateusz Sych 6232fa33c2 Joints: cam and follower coupling (joint.cam) (#1734)
Thanks @SychPL.
2026-10-16 10:26:35 -04:00
DuBearte 88f76adbc2 Add Brazilian Portuguese translation (#1688) (#1735)
Thanks @DuBearte.
2026-10-16 10:26:31 -04:00
cronos bba03803b7 Accelerate current-view DXF export and keep desktop exports responsive (#1738)
Thanks @crono2250.
2026-10-16 10:26:27 -04:00
Erikas 4bacd0cae0 Fillet fit check: rounds on both sides of a wall in one feature fit as they do one after the other (#1668) (#1720)
Thanks @erkexzcx.
2026-10-16 10:26:24 -04:00
Mateusz Sych 3501fa2c54 Emboss: say how far the picked point is from a face (#392) (#1733)
Thanks @SychPL.
2026-10-16 10:26:20 -04:00
Erikas 77b33efd8f Rolling-ball blends: a strip ends on its joint where its neighbour is too short to lend sections (#1667) (#1702)
Thanks @erkexzcx.
2026-10-16 09:26:23 -04:00
phanoob 37ebe24839 SpaceMouse test: make the app before the reading, which goes stale on a slow runner (#1711)
Thanks @phanoob.
2026-10-16 09:26:20 -04:00
DuBearte 54925cdcd5 Save As: a design named after its file takes the new file's name (#1710) (#1716)
Thanks @DuBearte.
2026-10-16 09:26:15 -04:00
github-actions[bot] 77bdef2f64 Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci] 2026-10-16 13:09:37 +00:00
bherbruck 2c020dbcbc Revert #1682 (three-axis milling) (#1715)
Thanks @bherbruck.
2026-10-16 09:02:19 -04:00
github-actions[bot] 1ded8fdd27 Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci] 2026-10-16 12:44:13 +00:00
DuBearte 4a4911b473 Mesh export: twisted ruled faces are divided to the chord tolerance (#1666) (#1670)
Thanks @DuBearte.
2026-10-16 08:26:32 -04:00
CypherNaugh_0x 737c9358d0 Add three-axis milling with independent CAM and Fusion validation (#1682)
Thanks @CypherNaught-0x.
2026-10-16 08:26:28 -04:00
DuBearte dd5278a402 solid.hole: position takes length expressions and follows them (#1678) (#1683)
Thanks @DuBearte.
2026-10-16 08:26:23 -04:00
Mateusz Sych 838d0e86f2 Component rename also renames its placements (#1665) (#1685)
Thanks @SychPL.
2026-10-16 08:26:18 -04:00
Mateusz Sych d84d8c3222 Joint origin: a circle given by its centre (#393) (#1686)
Thanks @SychPL.
2026-10-16 08:26:14 -04:00
DuBearte 9194f4be09 Screenshot of a hidden window: at once, and say so (#1677) (#1707)
Thanks @DuBearte.
2026-10-16 08:26:08 -04:00
github-actions[bot] bcacb7066d Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci] 2026-10-16 11:35:58 +00:00
Mateusz Sych 2ae21143ee Motion animation export: a study or a joint drive as an animated GIF (#1680)
Thanks @SychPL.
2026-10-16 07:26:15 -04:00
midasdf 8207f29fc0 Register surface.boundary, which the 2/3 squash left unregistered (#1701)
Thanks @midasdf.
2026-10-16 07:26:12 -04:00
Jawler 45742f79e4 Update i18n.rs 2026-10-16 13:25:04 +02:00
jawler 04a0283082 Add files via upload
## Spanish (Spain) Localization 🇪🇸

This contribution adds a Spanish (Spain) translation for SolidCraft.

The translation is based on the English localization file (`en.json`) and uses terminology commonly found in professional CAD software such as Autodesk Fusion 360, SolidWorks, and FreeCAD.

### What's included

* Spanish (Spain) localization file (`es.json`).
* Translation of menus, commands, tools, settings, and interface messages.
* CAD-specific terminology adapted for Spanish-speaking users in Spain.
* Original JSON keys and dynamic placeholders preserved.

### Notes

* The translation has not yet been tested within the SolidCraft application.
* The Spanish language may need to be registered in the language selector.
* Feedback and corrections from native Spanish speakers are welcome.

I hope this contribution helps make SolidCraft more accessible to Spanish-speaking users.

Thank you for developing and maintaining this open-source CAD project!
2026-10-16 12:43:45 +02:00
github-actions[bot] 650ba567e7 Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci] 2026-10-16 10:38:44 +00:00
Igor Bronovskyi 374ec83505 Add the Ukrainian translations for the messages added after #1196 (#1632)
Thanks @BrunIF.
2026-10-16 06:26:40 -04:00
DuBearte 4d58d3bbca Extrude: to an object through the body, the automatic operation is Cut (#1633) (#1636)
Thanks @DuBearte.
2026-10-16 06:26:35 -04:00
DuBearte e93f8c1b84 Sketch: rectangles, circles and polygons draw by press, drag and release (#1630) (#1641)
Thanks @DuBearte.
2026-10-16 06:26:31 -04:00
midasdf bbd4f29ebb Class-A surfacing 3/3: blend curves, curved-edge fillets, the G2 corner and continuity analysis (#1642)
Thanks @midasdf.
2026-10-16 06:26:27 -04:00
rlartot 3126962cd4 A feature that names its body finds its named face there first (#1637) (#1643)
Thanks @rlartot.
2026-10-16 06:26:23 -04:00
phanoob 64b0b674d2 2D prism route: the same letters cut again are one exact boolean (#1644)
Thanks @phanoob.
2026-10-16 06:26:19 -04:00
Philipp Bischoff 3087363c44 test: three ignored tests pass again and run in CI (#679, #562) (#1663)
Thanks @PhilippBischoff0.
2026-10-16 06:26:13 -04:00
phanoob d5640b5efb Timeline marker, undo and redo compute off the window thread (#1675)
Thanks @phanoob.
2026-10-16 06:26:10 -04:00
bherbruck 9b2a40781c Release 0.14.0 (#1671)
Version 0.14.0; pending design-format changes folded into format 25.
2026-10-16 06:22:21 -04:00
bherbruck 9d82b94469 Robustness goldens: record seeds 31 and 83 after #1581 and #1495 (#1657)
Seed 31: #1581 refuses a fillet selection with an earlier round's smooth seam instead of re-rounding another nearby edge. Seed 83: #1316 was recorded before its branch took in #1495, whose shell keeps the wall beside a concave edge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 04:55:20 -04:00
bherbruck a9beb437b3 Boolean property tests: nightly only, and the cases they found as ignored tests (#1650)
Pushes to main skip boolean_props (random inputs); the nightly property-tests job runs it. The three shrunk cases are ignored tests with their issues (#1647, #1648, #1649), and proptest's failure file is named outright.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 04:35:53 -04:00
github-actions[bot] db1fd39139 Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci] 2026-10-16 07:38:05 +00:00
midasdf 07da7f8065 Class-A surfacing 2/3: surface match, boundary patch and CV fairing (#1487)
Thanks @midasdf.
2026-10-16 03:26:20 -04:00
DuBearte 7f79443b46 Control channel: a timed-out request says it is still running, and its result can be asked for (#1618) (#1627)
Thanks @DuBearte.
2026-10-16 03:26:16 -04:00
phanoob 9bb65b4d68 Booleans: a prism cut beside the target's other faces takes the exact 2D route in a column (#1629)
Thanks @phanoob.
2026-10-16 03:26:13 -04:00
Lasse Lian 18ec44d773 AppImage: leave the menu entry to launchers that set DESKTOPINTEGRATION (#1635)
Thanks @lasselian.
2026-10-16 03:26:09 -04:00
Karl Von Richter 9422853cf9 Feature mirrors and patterns: preserve replay and add Flip/Center placement (#1619)
Thanks @Karl-VonRichter.
2026-10-16 03:01:00 -04:00
Irakli Kourtis f57a62deb7 Intersection edge nodes advance along the curve at fine display qualities (#1615)
Thanks @jyviko.
2026-10-16 03:00:56 -04:00
DuBearte 61678654c2 Extrude: dragging the taper stops where the profile would close (#1602) (#1614)
Thanks @DuBearte.
2026-10-16 03:00:52 -04:00
DuBearte 25c89a706e Sketch: a tangent arc starts from a trimmed end (#1485) (#1604)
Thanks @DuBearte.
2026-10-16 03:00:49 -04:00
bherbruck 26bf1af93b Sketch on an origin plane faces the side you clicked (#1587)
Fixes #1586.
2026-10-16 03:00:24 -04:00
github-actions[bot] 3707a67cee Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci] 2026-10-16 06:01:11 +00:00
Mateusz Sych 571122c4ba Motion Trace: a point's path as a joint is driven or a study plays, kept as a 3D spline (#1529)
Thanks @SychPL.
2026-10-16 01:54:34 -04:00
H. Conrad dcffab09b1 Tessellation: point-in-trim tests skip runs of segments the ray canno… (#1309)
Thanks @Ian-cmd-ops.
2026-10-16 01:54:29 -04:00
DuBearte 6f270f6259 Split Body: a sketch line as the splitting tool (#1552) (#1592)
Thanks @DuBearte.
2026-10-16 01:35:15 -04:00
H. Conrad dc6eca6830 Fix/shell saddle corners (#1316)
Thanks @Ian-cmd-ops.
2026-10-16 01:19:42 -04:00
H. Conrad d29ca29578 Perf/mesh collision (#1307)
Thanks @Ian-cmd-ops.
2026-10-16 01:12:39 -04:00
github-actions[bot] 5e9285ca75 Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci] 2026-10-16 04:37:13 +00:00
Karl Von Richter 371578a06f Feat/Sketch: associative pattern tools with independent directions and Flip/Center controls (#1601)
Thanks @Karl-VonRichter.
2026-10-16 00:32:32 -04:00
JIJINBEI 44968dbfe6 Feature fields: a type for each role (Expr, BodyName, FacePick…) (#1273)
Thanks @jijinbei.
2026-10-16 00:28:12 -04:00
JIJINBEI 3333f8a62b Divide intersection edges from their neighbours' parameters (#1038) (#1412)
Thanks @jijinbei.
2026-10-16 00:27:55 -04:00
H. Conrad 42d4ebeb16 STEP export: a merged partial cylinder keeps its cylinder (silent volume loss) (#1497)
Thanks @Ian-cmd-ops.
2026-10-16 00:27:48 -04:00
DuBearte d624e0a171 Expressions: inch and foot marks (#1502) (#1578)
Thanks @DuBearte.
2026-10-16 00:27:40 -04:00
DuBearte 3fe929e91c Renderer: skip a frame the GPU won't stage instead of crashing (#1469) (#1579)
Thanks @DuBearte.
2026-10-16 00:27:33 -04:00
DuBearte c419dec76c Joint drive test: wait as long as the other preview tests (#1549) (#1582)
Thanks @DuBearte.
2026-10-16 00:27:26 -04:00
DuBearte f91103c51d Viewport: the grid faces side views (#1470) (#1583)
Thanks @DuBearte.
2026-10-16 00:27:19 -04:00
DuBearte ac3a25fb5b View keys: arrows turn the view, Ctrl+arrows pan, Ctrl+8 looks at the selection (#1541) (#1585)
Thanks @DuBearte.
2026-10-16 00:27:12 -04:00
github-actions[bot] 8f5073ff14 Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci] 2026-10-16 04:18:59 +00:00
H. Conrad c5b7009ce3 ci: nightly property tests with more cases (#1498)
Thanks @Ian-cmd-ops.
2026-10-16 00:13:48 -04:00
bherbruck f98c5e350d Shell: put shell_by_opening's doc comment back on it (#1588)
Thanks @bherbruck.
2026-10-16 00:11:30 -04:00
github-actions[bot] 2e5b7b11e4 Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci] 2026-10-16 04:08:05 +00:00
bherbruck 6c1a898e3d Check for updates at every start; show "Update to X" as a title-bar pill (#1538)
Thanks @bherbruck.
2026-10-16 00:07:41 -04:00
Mateusz Sych 4d862cd76c Joints: joint.reach (inverse kinematics), and a jointed part follows a drag (#1540)
Thanks @SychPL.
2026-10-15 23:58:34 -04:00
bherbruck baf44657c1 Agent chat: queue messages while the agent works, Send now, Stop, New chat, and a model picker (#1566, #1571) (#1566)
Thanks @bherbruck.
2026-10-15 23:58:28 -04:00
bherbruck fdb059cfed Claim bot: let me /unclaim my own issues (#1595)
An explicit /unclaim from the owner releases an owner-assigned issue; the sweep still never touches them.
2026-10-15 23:57:15 -04:00
H. Conrad 716107f0e4 Sketch cut and paste: refuse parameters that are not an object (no panic) (#1577)
Thanks @Ian-cmd-ops.
2026-10-15 23:34:18 -04:00
CamWight 6220d8f8a6 Desktop 3D mouse on Windows without a 3DxWare profile: object-mode axes, no emulated wheel (#1380) (#1528)
Thanks @CamWight.
2026-10-15 23:28:02 -04:00
bherbruck c01a7b9b37 Fix main: drop a test's use of the removed viewport.no_pixels (#1594)
Thanks @bherbruck.
2026-10-15 23:24:57 -04:00
bherbruck 556e424bb2 Fix main: drop the removed viewport.no_pixels from the edge-pick test (#1596)
#1555 removed the field; #1568's new test still set it.
2026-10-15 23:22:43 -04:00
bherbruck ce96e625b4 Contributing: kernel speed work goes to solidcore (#1597) (#1598)
Thanks @bherbruck.
2026-10-15 23:12:50 -04:00
bherbruck 5cfcf7fd03 Fix the main build: the edge-pick test used a removed field (#1590) 2026-10-15 23:09:41 -04:00
Irakli Kourtis fac3f96bdd Patch: G1/G2 to the faces beside the loop, general and N-sided loops, points (#759) (#1521)
Thanks @jyviko.
2026-10-15 23:07:24 -04:00
github-actions[bot] 77bdcc47e9 Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci] 2026-10-16 03:01:46 +00:00
DuBearte 987f0be21e Sketch: infinite construction lines (#1359) (#1547)
Thanks @DuBearte.
2026-10-15 22:54:16 -04:00
bherbruck bd17eeef0c Claim bot: /claim and /unclaim on issues (#1584)
Comment /claim on an issue to be assigned and get the in progress label; /unclaim releases it. A daily sweep frees claims with no PR after 72 h or with a PR idle for 14 days.
2026-10-15 22:54:06 -04:00
sthifen e21c5f06ac Viewport frame-rate readout (Preferences ▸ Graphics) (#1580)
Thanks @sthifen.
2026-10-15 22:50:41 -04:00
Colin Faulkingham 904bf19e83 Fix agent runtime lookup when launched from the desktop (#1551)
Thanks @cfaulkingham.
2026-10-15 22:44:13 -04:00
Colin Faulkingham 7ef78f960a Fix perpendicular fillets after connected rounds (#1581)
Thanks @cfaulkingham.
2026-10-15 22:40:09 -04:00
Erikas a422fb1a4b solid.mirror: a plane's {origin} takes length expressions and follows them (#1492) (#1501)
Thanks @erkexzcx.
2026-10-15 22:37:08 -04:00
rlartot daf618c344 Edge division: evaluate each point once (#1560)
Thanks @rlartot.
2026-10-15 22:19:51 -04:00
phanoob 5165e428cd Boolean: refuse a nudged result that meshes inconsistently (#1360) (#1455)
The last-resort nudge's result is also measured finely; a face left a hair wide that meshes as its whole plane no longer passes as a plausible cut.
2026-10-15 22:19:28 -04:00
Irakli Kourtis 1a0b33fcf3 Headless pictures draw on the GPU, with the viewport's pipelines (CPU rasterizer as last tier) (#1555)
Thanks @jyviko.
2026-10-15 22:19:26 -04:00
Giovanni 48322f6464 Join a flange flush with a bent, tapered loft's end where the retry fails (#1391) (#1556)
Thanks @gvocale.
2026-10-15 22:19:22 -04:00
Erikas 5c0351b1f4 Fillet: refuse only crossed faces the round makes, not ones the body had (#1488) (#1517)
Thanks @erkexzcx.
2026-10-15 22:19:19 -04:00
Erikas b20fd3d9d6 Booleans: when the last-resort nudge fails both ways, try two more screw axes (#1490) (#1527)
Thanks @erkexzcx.
2026-10-15 22:19:15 -04:00
H. Conrad 63accec16e STEP export: shelled revolved bodies export instead of panicking (#1496)
A scale across a revolution's axis keeps the face a surface of revolution and is written as one; any other refusal from the writer is an export error, not a panic.
2026-10-15 22:19:10 -04:00
Mythic Keeper d188eea1f4 Fix: edges between two faces hard to pick (#1568)
Thanks @MythicForge.
2026-10-15 22:19:08 -04:00
H. Conrad 2141a3802a Shell: an open face next to a concave edge no longer cuts the wall past it (#1495)
The pushed-out cavity is trimmed so that past an open face's plane it only reaches through the open face itself.
2026-10-15 22:19:04 -04:00
H. Conrad 723c7b61c4 Boolean: cut around a part's sealed voids when the whole cut fails (#1570)
A cut clear of every void cuts the outer shell alone and puts the voids back (robustness seed 15 now builds).
2026-10-15 22:18:31 -04:00
bherbruck 1a55241882 Contributing: claim an issue with a draft PR (#1565)
Check gh pr list --search "#N", then open a draft titled "<summary> (#N)" with an empty claim commit; the lowest-numbered open PR for an issue wins, and a draft with no pushes for 72 hours may be taken over after a comment. Agents on a shared machine check the load before a big build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 22:17:39 -04:00
DuBearte b562ab51e1 Agent discovery test: the fixture uses a name Windows runs (#1554) (#1575)
The search-order test wrote an extensionless codex-acp, which Windows
discovery rightly skips; it now writes the first name this platform
looks for (codex-acp.exe on Windows, codex-acp elsewhere).

Fixes #1554

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 22:05:55 -04:00
DuBearte 105a3146c0 Toolbar: a second click on a drop-down's button closes it (#1522) (#1576)
The workspace switcher's menu closed on the press (a press outside the
menu) and the click reopened it; the menu now keeps the button that
opened it, a press there is not outside, and its click toggles. A
panel's label toggles its command list instead of always opening it.

Fixes #1522

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 22:04:42 -04:00
bherbruck f08ed74617 Keep the files many PRs add to conflict-free (#1564)
Message catalogs sorted one message per line (cargo xtask lists [--fix], checked in cargo xtask ci and the Lists workflow); the language list one per line, English first then by id; THIRD-PARTY-LICENSES.txt and docs/features.md rewritten on main by the Generated files workflow instead of in PRs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:59:52 -04:00
Colin Faulkingham 342305705d Add XAI Grok to the AI panel (#1558)
Grok Build is added as one more entry in the agent discovery list, over ACP, like the others. Thanks @cfaulkingham.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 21:27:50 -04:00
Mateusz Sych a6742180a9 Join from a sketch on a body's face joins that body (#1349) (#1526)
Thanks @SychPL for the fix.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 21:27:35 -04:00
cronos abb15e4b0e Preserve circle and arc centre heights when measuring straight edges (#1546)
Thanks @crono2250 for the fix.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 21:27:29 -04:00
Karl Von Richter 584bbe923d sketch(fix): preserve rectangle constraints through fillets (#1543)
Thanks @Karl-VonRichter for the fix.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 21:27:24 -04:00
bherbruck 9a07932f20 Design format: a change is one new file, never an edit to FORMAT (#1563)
A PR that saves something new adds crates/doc/src/format/pending/<name>.rs (a note, and an upgrade step if older designs must be rewritten). The build writes the next format number while changes are pending and lists them in format_pending; scripts/fold-format-pending.sh fixes them as v<N>/ at release time. Formats 23 and 24 move into v23/ and v24/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:13:38 -04:00
bherbruck 067f2642c2 Agent chat on Windows: no terminal window flashes when agents are detected 2026-10-15 21:11:49 -04:00
bherbruck af771b9ceb CI: PRs build and test only the packages they touch (#1561)
Clippy, the test archive and the test shards cover the packages a PR changes plus their dependents. Main, nightly and manual runs stay full.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 20:58:26 -04:00
NOWN-DEV 4cbf9cf607 Sheet metal: an Edge Flange can choose its own corner relief (#1481)
An Edge Flange feature can set the shape and size of the relief at the corners its flanges make, in place of the sheet metal rule's. Design format 24.

Co-authored-by: NOWN-DEV <NOWN-DEV@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 20:22:31 -04:00
cronos c361630954 Export the current 3D view as 2D DXF with optional hidden lines (#1449)
Adds File > Export > Current View as DXF: the visible bodies projected from the current camera onto 2D LINE/LWPOLYLINE entities, with hidden edges omitted or dashed.

Co-authored-by: cronos <19868002+crono2250@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 20:04:25 -04:00
selune 845f4913cf render: add opaque CPU sampling and bounded mip chains (#1459)
Co-Authored-By: selune <0xlillith@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 19:39:30 -04:00
Jackson Oursland 78242d67d1 Import FreeCAD and Rhino files through cadmpeg (#1435)
FreeCAD .FCStd and Rhino .3dm files open, insert and drop like STEP and IGES:
cadmpeg (Apache-2.0, pinned =0.6.1, native builds only) detects the format,
decodes it under its service resource caps, validates the model and writes it
as STEP AP214, which becomes an Import feature in a new component named after
the file, with the up and names options.

Only the fcstd, rhino and step codecs are compiled in (no default features).
ACIS is left out: cadmpeg's sat codec is a ShapeManager stream decoder.
native_cad.rs is the single importer seam. Self-made FreeCAD and Rhino box
fixtures, hostile-input, ZIP-bomb and cap tests, engine tests for the
component path; the browser build reports these formats as not supported.
A note under design format 23 says an Import's file can name these formats.

Co-authored-by: Jacob Oursland <jacob.oursland@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:30:03 -04:00
bherbruck 2b45635d73 Version 0.13.1 2026-10-15 19:18:52 -04:00
bherbruck 4097ac0131 sketch.cut: refuse non-object params instead of panicking 2026-10-15 19:12:55 -04:00
cronos 96d1ad0ab2 Show optional X/Y/Z distances in measurement overlays (#1422)
Measure can show the world-axis components alongside the total distance, with a Graphics preference to turn them off.

Co-authored-by: cronos <19868002+crono2250@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 19:09:26 -04:00
Rec0iL 3d914c9f15 Agent chat gate: curve and occurrence ids named path are not files; broken tool arguments are not run (#1499)
Co-Authored-By: Rec0iL <Rec0iL@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 19:01:09 -04:00
Pablo López 1b5c2b5847 New tab: no stale fillet preview, and + always opens a tab (#1533)
Co-Authored-By: Pablo López <pablopeza@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 19:01:04 -04:00
Philipp Bischoff 7160ff7beb A sketch on an extrude's cap keeps its face when the extrude is made thin (#1532)
Thin extrude caps are certified by the curves the wall follows, so a sketch attached to a cap survives switching between solid and thin (#1519).

Co-Authored-By: Philipp Bischoff <PhilippBischoff0@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 18:55:17 -04:00
selune b400958c3e render: add isotropic texture footprint LOD reference (#1460)
Co-Authored-By: selune <0xlillith@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 18:55:14 -04:00
macrulz54 1c9d6617ad Add Turkish translation (#1520)
Registers a Turkish (tr) catalog with its attribution, docs and a test.

Co-authored-by: macrulz54 <macrulz54@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 18:47:46 -04:00
Mateusz Sych f7967b1ff6 Joints: gears, rack and pinion, and screws (#1524)
Motion links can carry a coupling (gear pair, rack and pinion, screw) whose ratio follows radii, picked round faces or pitch. Design format 23.

Co-authored-by: SychPL <SychPL@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 18:47:38 -04:00
CamWight a519f3cf74 Web app: 3D mouse navigation through the Gamepad API (#1473)
The web app reads a 3D mouse through the Gamepad API and drives the camera through navigation::device, so it follows each page's navigation capabilities (#1336).

Co-authored-by: CamWight <CamWight@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 18:42:44 -04:00
DuBearte b44ada89e5 Preferences: choose the interface font (#1408)
Adds an Interface font setting that leads the proportional family, with the default face and CJK/Hangul fallbacks kept behind it (#1290).

Co-authored-by: DuBearte <DuBearte@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 18:40:07 -04:00
bherbruck 6128a20c5f Agent chat on Windows: start npm agents through their .cmd shim (#1530) 2026-10-15 18:36:41 -04:00
cronos 73493b94c3 Measure circle and arc centres as fixed 3D points (#1514)
Picked circles and arcs keep their own centres when measured; only a cylindrical face uses its axis.

Co-authored-by: cronos <crono2250@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 18:04:17 -04:00
selune 061c0f3333 render: add perspective UV interpolation and derivatives (#1461)
Co-Authored-By: selune <0xlillith@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 18:04:09 -04:00
Erikas 3dd8e2d176 timeline.edit: refuse fields the feature's definition doesn't have (#1507)
A field the definition doesn't have is refused with the fields it does have, instead of reporting success and changing nothing (#1491).

Co-Authored-By: Erikas <erkexzcx@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 18:03:32 -04:00
bherbruck a13124acdd Import glTF 2.0 files (.glb, .gltf) as mesh bodies (#1512)
glTF files open, insert and drop like 3MF/STL/OBJ: a component named after the file, the node tree as nested rigid components, repeated meshes as occurrences, metres to mm, Y up turned to Z up by default (up: z keeps axes), materials as PBR appearances. Bounded, hostile-input tested reader; no new persisted data. Closes #1505.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 17:27:29 -04:00
bherbruck 5823900439 Release 0.13.0 (#1506)
Version 0.13.0, plus the Windows 32-bit and macOS release-build fixes from #1513.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 17:22:28 -04:00
1799 changed files with 372910 additions and 31879 deletions
+14
View File
@@ -0,0 +1,14 @@
# cargo-mutants (docs/mutation-testing.md). Mutants are judged by the black-box suites that will
# outlive the kernel backend: the reference tests (tests/oracle, through the oracle_cases test)
# and the pull-request sample of the robustness goldens (every 8th seed, as ci.yml). The
# kernel's own unit tests are left out on purpose: a mutant only they catch is a behaviour the
# backend-neutral suites would miss when the kernel is swapped.
profile = "mutants"
additional_cargo_test_args = [
"--test", "oracle_cases", "--lib", "--", "--include-ignored",
"oracle_suite",
"robustness_goldens::seed_00", "robustness_goldens::seed_08", "robustness_goldens::seed_16",
"robustness_goldens::seed_24", "robustness_goldens::seed_32", "robustness_goldens::seed_40",
"robustness_goldens::seed_48", "robustness_goldens::seed_56", "robustness_goldens::seed_64",
"robustness_goldens::seed_72", "robustness_goldens::seed_80", "robustness_goldens::seed_88",
]
+98 -8
View File
@@ -1,21 +1,25 @@
# cargo-nextest settings for CI (`cargo xtask test` does not use nextest).
# PR runs build the tests once and run them in 4 shards (.github/workflows/ci.yml).
# CI builds the tests once and runs them in one job on a 32 vCPU runner (.github/workflows/ci.yml).
[profile.ci]
# Heavy tests (one to four minutes each on a CI runner) that pull requests skip. They run on
# every push to main and nightly (`--ignore-default-filter`), and in `cargo xtask test`.
# Heavy tests (one to twelve minutes each on a CI runner) that pull requests skip. They run on
# every push to main and nightly (`--ignore-default-filter`), and in `cargo xtask test`. The
# boolean_props property tests run only nightly (ci.yml excludes them from pushes to main).
# Keep this list in sync with docs/book/src/dev/testing.md and CONTRIBUTING.md.
default-filter = """
not (
(package(solidcraft-engine) & (
test(=tests::hostile_params_never_panic)
| test(/^tests::robustness::/)
| test(/^tests::robustness_goldens::/)
| test(/^tests::robustness_goldens::seed_/)
| test(=pattern_suppress_tests::enclosure_arc_extinction_keeps_a_real_sharp_inner_lip_after_resize_and_reopen)
| test(=pattern_suppress_tests::enclosure_datums_follow_wall_height_and_board_depth_with_material_proofs)
| test(=cmd::plastic::tests::enclosure_snap_fit_follows_resized_front_wall)
))
| (package(solidcraft-kernel) & test(=tests::boolean_identity_on_tangent_primitives))
| (package(solidcraft-kernel) & test(=tspline::tests::hostile_tsplines_never_panic))
| (package(solidcraft-kernel) & binary(boolean_props))
| (package(solidcraft-engine) & binary(atomic_commands))
| (package(solidcraft-ui-egui) & binary(scenarios) & test(=acceptance_sensor_enclosure))
)
"""
@@ -29,10 +33,96 @@ slow-timeout = { period = "60s" }
[profile.ci.junit]
path = "junit.xml"
# The action matrix sample runs its designs in parallel worker processes, one per CPU. Run with
# other tests beside it (the heavy ones on main), a pi-case hole that takes 8 s alone (#952 makes
# it a real hole) went past the matrix's 90 s action limit. It runs on its own.
# The action matrix sample runs its designs in parallel workers (ACTION_MATRIX_JOBS, set to match in
# ci.yml). Its workers reserve that many of nextest's threads, so the cores are not oversubscribed
# (a pi-case hole that takes 8 s alone went past the matrix's 90 s action limit when they were),
# and it starts first, beside the other slow tests.
[[profile.ci.overrides]]
filter = "binary(action_matrix) & test(=action_matrix_sample)"
threads-required = "num-cpus"
threads-required = 12
priority = 100
# The slowest tests start first, so a shard does not end with one of them running alone on a
# core while the others sit idle (seed_81 alone takes about 20 minutes under CI load). Medians
# from the CI test timings (the junit artifacts), slowest first; a test over ~100 s belongs here.
[[profile.ci.overrides]]
filter = """
(package(solidcraft-engine) & (
test(/^tests::robustness_goldens::seed_(81|45|20|24|95|02|57|40|18|48|32|46|85|61)$/)
| test(/^tests::robustness::/)
| test(=tests::hostile_params_never_panic)
| test(=cmd::clearance::tests::distances_are_exact_on_edges_and_curved_faces)
| test(=cmd::clipboard::tests::a_pasted_coil_joins_the_pasted_body)
| test(=tests::pleat_join::the_pleats_join_at_every_wall_from_2_4_to_2_6_mm)
| test(=tests::flush_join_loft::a_flange_flush_with_the_end_of_a_bent_tapered_tube_joins)
| test(=cmd::sheet::tests::perforated_sheet_takes_a_thousand_round_holes)
| test(=boolean_regression_tests::joined_loft_frames_plan_their_display_at_every_quality)
))
| (package(solidcraft-engine) & (binary(atomic_commands) | binary(step_export_regressions)))
| (package(solidcraft-kernel) & (
test(=curveblend::tests::rolling_ball_turns_about_a_rounded_corner_of_its_radius)
| test(=tests::fillet_tabbed_plate_outer_loop_and_partial_selection_after_hole)
| test(=tspline::tests::hostile_tsplines_never_panic)
| test(=fill::tests::regular_rims_of_three_to_eight_sides_fill_with_one_face_and_close_the_cup)
| test(/^issue_788_tests::equal_bores_crossing_/)
))
"""
priority = 100
# Each nextest test has its own process/device. Concurrent cold lavapipe path
# compiles contend for CPU and can exhaust the 600 s pipeline setup budget.
# Serialize this module per CI shard; other tests retain their normal parallelism.
[test-groups.gpu-path]
max-threads = 1
[[profile.ci.overrides]]
filter = "package(=solidcraft-gpu) & test(/^path::/)"
test-group = "gpu-path"
# Full local validation opts into its own scheduling policy. Keep GitHub CI's
# worker reservation and slow-test priorities above independent of this runner.
[profile.local-ci]
inherits = "ci"
[[profile.local-ci.overrides]]
filter = "binary(action_matrix) & test(=action_matrix_sample)"
threads-required = "num-cpus"
priority = 100
[[profile.local-ci.overrides]]
filter = "(package(solidcraft-engine) & test(=tests::robustness_goldens::seed_81)) | (package(solidcraft-kernel) & test(=tspline::tests::hostile_tsplines_never_panic))"
priority = 99
[[profile.local-ci.overrides]]
filter = """
(package(solidcraft-engine) & (
test(/^tests::robustness_goldens::seed_(81|45|20|24|95|02|57|40|18|48|32|46|85|61)$/)
| test(/^tests::robustness::/)
| test(=tests::hostile_params_never_panic)
| test(=cmd::clearance::tests::distances_are_exact_on_edges_and_curved_faces)
| test(=cmd::clipboard::tests::a_pasted_coil_joins_the_pasted_body)
| test(=tests::pleat_join::the_pleats_join_at_every_wall_from_2_4_to_2_6_mm)
| test(=tests::flush_join_loft::a_flange_flush_with_the_end_of_a_bent_tapered_tube_joins)
| test(=cmd::sheet::tests::perforated_sheet_takes_a_thousand_round_holes)
| test(=boolean_regression_tests::joined_loft_frames_plan_their_display_at_every_quality)
))
| (package(solidcraft-engine) & (binary(atomic_commands) | binary(step_export_regressions)))
| (package(solidcraft-kernel) & (
test(=curveblend::tests::rolling_ball_turns_about_a_rounded_corner_of_its_radius)
| test(=tests::fillet_tabbed_plate_outer_loop_and_partial_selection_after_hole)
| test(=tspline::tests::hostile_tsplines_never_panic)
| test(=fill::tests::regular_rims_of_three_to_eight_sides_fill_with_one_face_and_close_the_cup)
| test(/^issue_788_tests::equal_bores_crossing_/)
))
"""
priority = 98
# Native Windows shader/device creation must not overlap within the local run.
[test-groups.windows-gpu]
max-threads = 1
[[profile.local-ci.overrides]]
platform = "cfg(windows)"
filter = "package(solidcraft-gpu) | package(solidcraft) | (package(solidcraft-ui-egui) & test(/(offscreen|gpu)/))"
test-group = "windows-gpu"
+41
View File
@@ -0,0 +1,41 @@
name: Bug report
description: Something in SolidCraft does the wrong thing
labels: [bug]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to report this! Inside the app, **Report an Issue** fills most of this in for you, with the command, its settings and your environment.
- type: textarea
id: what
attributes:
label: What happened
description: What did you expect, and what happened instead?
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
description: |
What you clicked or typed, in order. For a problem on a particular design, `solidcraft-cli script design.solidcraft --out repro.json` turns its history into a script that `solidcraft-cli eval repro.json` replays; attach it, trimmed to the failing step if you can.
placeholder: |
1. Sketch a circle on the XY plane
2. Extrude it 10 mm
3. Fillet the top edge with 6 mm
validations:
required: true
- type: input
id: version
attributes:
label: SolidCraft version and OS
description: Help → About shows the version.
placeholder: 0.15.0, Windows 11
validations:
required: true
- type: textarea
id: media
attributes:
label: Screenshots or recording
description: |
For anything you can see in the app, a screenshot or short recording of a small example helps the most. Please leave out private designs and personal details.
-23
View File
@@ -1,23 +0,0 @@
---
name: Bug report
about: Help reproduce and fix a SolidCraft problem
title: ''
labels: ''
assignees: ''
---
## Behavior
What did you expect, and what happened instead?
## Reproduction
Include steps, SolidCraft version/commit and relevant environment details. Prefer a small example without private design data.
When the problem shows on a particular design, `solidcraft-cli script design.solidcraft --out repro.json` turns its history into a script that `solidcraft-cli eval repro.json` replays; attach it, trimmed to the failing step if you can.
## Screenshots of the behavior (when observable)
For observable CAD, UI or model behavior, include actual descriptive screenshots or a short recording from a small reproduction. This includes kernel, geometry, assembly and backend effects that can be shown in the app. Coding agents should run the app and capture the evidence themselves, rather than asking a human for evidence they can produce. Show the actual failure/result, preferably before and after, with brief steps, expected/actual behavior and annotations where helpful.
Flowcharts and Mermaid diagrams cannot substitute for evidence of visible behavior. Use diagrams for architectural reasoning when actual screenshots cannot illustrate the point. Changes with no useful observable behavior may omit screenshot/video evidence. Exclude private designs and sensitive details; attach media or link to an accessible recording rather than committing generated review artifacts.
+7
View File
@@ -0,0 +1,7 @@
# Blank issues stay on: the app's Report an Issue opens issues/new?title=…&body=… with the report
# filled in (crates/ui-egui/src/issue_report.rs), which GitHub drops when blank issues are off.
blank_issues_enabled: true
contact_links:
- name: Questions and chat
url: https://discord.gg/FtU4NQNVDH
about: Ask questions and talk with users and contributors on the SolidCraft Discord.
+16
View File
@@ -0,0 +1,16 @@
name: Feature request
description: Something SolidCraft should do
labels: [enhancement]
body:
- type: textarea
id: goal
attributes:
label: What are you trying to do
description: The part or workflow you have in mind, and where SolidCraft gets in the way today.
validations:
required: true
- type: textarea
id: idea
attributes:
label: How you imagine it working
description: Optional. Describe the behaviour in your own words; please don't paste another product's documentation.
+11
View File
@@ -0,0 +1,11 @@
self-hosted-runner:
labels:
- self-hosted-fast
- self-hosted-light
# Blacksmith (see the top of workflows/ci.yml).
- blacksmith-2vcpu-ubuntu-2404
- blacksmith-4vcpu-ubuntu-2404
- blacksmith-8vcpu-ubuntu-2404
- blacksmith-16vcpu-ubuntu-2404
- blacksmith-32vcpu-ubuntu-2404
- blacksmith-8vcpu-windows-2025
+9 -5
View File
@@ -1,9 +1,9 @@
SolidCraft is open-source parametric 3D CAD in Rust: sketch, constrain, extrude, and change a
parameter to watch the timeline rebuild. [Documentation](https://bherbruck.github.io/solidcraft/)
· [try it in the browser](https://bherbruck.github.io/solidcraft/app/) ·
parameter to watch the timeline rebuild. [Documentation](https://docs.solidcraft.io/)
· [try it in the browser](https://solidcraft.io/app) ·
[contributing](https://github.com/bherbruck/solidcraft/blob/main/CONTRIBUTING.md) ·
[issues](https://github.com/bherbruck/solidcraft/issues). AI agents can model with it over MCP:
`solidcraft-cli mcp` ([how](https://bherbruck.github.io/solidcraft/guide/mcp.html)).
`solidcraft-cli mcp` ([how](https://docs.solidcraft.io/guide/mcp.html)).
**What's new** in SolidCraft {version} is listed below, and the
[ROADMAP](https://github.com/bherbruck/solidcraft/blob/main/ROADMAP.md#known-limitations) lists
@@ -41,16 +41,20 @@ installing SolidCraft; keep both remotes in the same user installation:
```sh
flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
flatpak remote-add --user --if-not-exists solidcraft https://bherbruck.github.io/solidcraft/flatpak/solidcraft.flatpakrepo
flatpak remote-add --user --if-not-exists solidcraft https://docs.solidcraft.io/flatpak/solidcraft.flatpakrepo
flatpak install --user solidcraft ai.storyteller.solidcraft
flatpak run ai.storyteller.solidcraft
```
A remote added from the old `bherbruck.github.io` address no longer finds updates: run
`flatpak remote-modify --user --url=https://docs.solidcraft.io/flatpak/repo/ solidcraft`, then
`flatpak update --user`.
Flatpak installs from 0.7 or earlier use the old ID `ai.storyteller.solvecraft`: `flatpak update --user`
offers to replace it with `ai.storyteller.solidcraft`, and settings move along.
Single-file `.flatpak` bundles include the runtime repository address; missing runtimes
still need an internet connection. [Linux installation details](https://bherbruck.github.io/solidcraft/guide/getting-started.html#linux-flatpak).
still need an internet connection. [Linux installation details](https://docs.solidcraft.io/guide/getting-started.html#linux-flatpak).
The builds aren't signed yet. On macOS, right-click `SolidCraft.app` in Applications and choose
**Open** the first time (or run `xattr -dr com.apple.quarantine /Applications/SolidCraft.app`).
+522 -73
View File
@@ -1,11 +1,53 @@
# Fast checks always run; native builds run for code/CI changes.
# Tests are built once and run in 4 shards. A few heavy tests (see .config/nextest.toml) are
# skipped on pull requests and run on pushes to main and nightly.
# PRs test what they touch; main and nightly test everything. On a pull request the "Select
# checks" job maps the changed files to workspace packages (scripts/ci/affected.py), adds the
# packages that depend on them, and Clippy, the test archive and the test shards cover only
# those. Test data under tests/, vendored crates under vendor/ and root files a crate embeds
# select the packages that read them. Changes to the lockfile, root manifest,
# toolchain/cargo/nextest config, this workflow, scripts/ or any file no package owns select the
# whole workspace, as do pushes to main that change code, the nightly run and Run workflow.
# Documentation, packaging, the other workflows and the GitHub bots' scripts select nothing:
# no native job runs for them, on pull requests or on main.
# Tests are built and run in one job on a big runner, slowest first, starting beside the repository
# checks (no archive to upload and download). A few heavy tests (see .config/nextest.toml) are
# skipped on pull requests and run on pushes to main and nightly. The Windows jobs (D3D12 shaders
# and app startup), the reference tests and the doc tests run on main and nightly only, not on
# pull requests (the fast lane, in Select checks); a break there is fixed forward on main. Only
# main saves the build caches; pull requests restore them. sccache is on a Blacksmith sticky disk that
# only main commits.
# A push to main runs only the repository checks when a passing merge-queue run tested that very
# commit, or when nothing a build reads changed since the last push to main whose run passed.
# Otherwise it runs everything.
# Heavy checks wait for review: a pull request without the `approved-for-ci` label runs only
# Select checks, the format and repository checks and a quick compile. Adding the label (after
# review) runs everything above, and every later push to the PR does too, except a push that only
# merges main into a change that already passed (scripts/ci/main_merge.py): it carries that
# result and runs the repository checks. Merge groups, the nightly run and Run workflow are not
# pull requests and always run the full suite.
# GitHub cannot filter `labeled` events by label name, so any other label starts a run whose jobs
# are all skipped; it gets its own concurrency group (below) so it never cancels the real run. Its
# `ci` job still runs and reports the commit's real result (see the `ci` job): the merge queue
# reads `ci` from the newest run of a commit, so a run without it would block a PR that passed.
# Cross-compilation is opt-in through Run workflow; release builds remain in release.yml.
# Every Linux job runs on Blacksmith's Ubuntu 24.04 runners (blacksmith-<N>vcpu-ubuntu-2404), sized
# per job: 32 vCPU for the tests, 16 for Clippy, the archive build (nightly) and the merge queue's, 8 for compiles, doc and reference tests, 4 for the light checks, 2 for Select checks and ci.
# The Windows shader job runs on Blacksmith's Windows Server 2025 runners (blacksmith-8vcpu-windows-2025);
# the Windows app startup smoke stays on windows-latest, which has a desktop session that presents frames.
# Pull requests, forks included, the merge queue, pushes to main and the nightly run all use them:
# the runners are ephemeral, fork runs get no secrets, and main saves the build caches that every
# other run restores read-only. Nightly and by hand, Build tests also makes an archive the matrix
# jobs run; Blacksmith's image matches ubuntu-24.04, so the archive and the recorded goldens behave the
# same.
name: CI
on:
# Draft pull requests run no CI (see `changes`, `checks` and `ci`): it starts when the PR is
# marked ready for review, and on every push after that. `labeled` is there for approved-for-ci
# only (other labels are skipped, see the top of this file).
pull_request:
types: [opened, synchronize, reopened, ready_for_review, labeled]
# The merge queue: each queued group runs the full suite before it lands on main.
merge_group:
push:
branches: [main]
schedule:
@@ -23,7 +65,10 @@ permissions:
concurrency:
# A new PR push replaces the old run. Only one run per group may wait, so a busy main would
# cancel the waiting nightly (or a Run workflow) before it starts: they get groups of their own.
group: ci-${{ github.event_name }}-${{ github.ref }}
# A `labeled` event for any label but approved-for-ci does no work and must not cancel (or queue
# behind) the PR's real run, so it gets a group of its own; approved-for-ci replaces the
# unlabelled run with the full one.
group: ci-${{ github.event_name }}-${{ github.ref }}${{ github.event.action == 'labeled' && github.event.label.name != 'approved-for-ci' && format('-ignored-{0}', github.run_id) || '' }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
@@ -33,7 +78,7 @@ jobs:
dependency-advisories:
name: Dependency advisories
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-24.04
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -45,10 +90,27 @@ jobs:
changes:
name: Select checks
runs-on: ubuntu-24.04
# Not on a draft: every other check needs this one, so they skip too. Not on a label other than
# approved-for-ci either (the same skip, for the same reason).
if: (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event.action != 'labeled' || github.event.label.name == 'approved-for-ci')
runs-on: blacksmith-2vcpu-ubuntu-2404
permissions:
contents: read
# To ask whether the merge queue already tested a commit pushed to main.
actions: read
outputs:
native: ${{ steps.paths.outputs.native }}
kernel: ${{ steps.paths.outputs.kernel }}
goldens: ${{ steps.paths.outputs.goldens }}
windows: ${{ steps.paths.outputs.windows }}
full: ${{ steps.paths.outputs.full }}
# Code changed on a pull request that has no approved-for-ci label yet: only Quick compile.
quick: ${{ steps.paths.outputs.quick }}
packages: ${{ steps.paths.outputs.packages }}
oracle: ${{ steps.paths.outputs.oracle }}
doctests: ${{ steps.paths.outputs.doctests }}
# The passing run a merge of main carries forward (its URL), when this push is only that.
carried: ${{ steps.paths.outputs.carried }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
@@ -59,83 +121,260 @@ jobs:
EVENT_NAME: ${{ github.event_name }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PUSH_SHA: ${{ github.sha }}
PR_NUMBER: ${{ github.event.pull_request.number }}
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
HEAD_REF: ${{ github.event.pull_request.head.ref }}
REPO: ${{ github.repository }}
APPROVED: ${{ contains(github.event.pull_request.labels.*.name, 'approved-for-ci') }}
GH_TOKEN: ${{ github.token }}
run: |
native=false
kernel=false
if [ "$EVENT_NAME" != pull_request ]; then
goldens=false
windows=false
full=true
packages=
# Whether the NUL-separated paths in $1 reach any code: scripts/ci/affected.py selects
# something or the whole workspace. Documentation, packaging, other workflows and the bots'
# scripts select nothing. Pull requests and pushes to main share it.
select_packages() {
python3 -I scripts/ci/affected.py --paths "$1" > "$RUNNER_TEMP/ci-affected"
cat "$RUNNER_TEMP/ci-affected"
full=$(sed -n 's/^full=//p' "$RUNNER_TEMP/ci-affected")
packages=$(sed -n 's/^packages=//p' "$RUNNER_TEMP/ci-affected")
}
if [ "$EVENT_NAME" = push ]; then
# The queue lands the very commit its merge_group run tested. If that run passed,
# testing the push again proves nothing new; otherwise (a merge that skipped the
# queue, or the API cannot answer) test everything.
# The API through curl and grep, which every runner image has.
tested=$(curl -fsS -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/$REPO/actions/workflows/ci.yml/runs?event=merge_group&head_sha=$PUSH_SHA&status=success&per_page=1" \
| grep -o '"total_count": *[0-9]*' | grep -o '[0-9]*$' || echo 0)
if [ "${tested:-0}" -gt 0 ]; then
echo "The merge queue already tested $PUSH_SHA: repository checks only."
elif tested=$(curl -fsS -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/$REPO/actions/workflows/ci.yml/runs?event=push&branch=main&status=success&per_page=1" \
| grep -o '"head_sha": *"[0-9a-f]*"' | head -n 1 | grep -o '[0-9a-f]\{40\}') \
&& [ -n "$tested" ] && git merge-base --is-ancestor "$tested" "$PUSH_SHA" \
&& git diff --no-renames --name-only -z "$tested" "$PUSH_SHA" > "$RUNNER_TEMP/ci-paths" \
&& select_packages "$RUNNER_TEMP/ci-paths" && [ "$full" != true ] && [ -z "$packages" ]; then
# Nothing a build reads changed since the last push to main whose run passed (the
# push before may never have run: a newer push cancels a waiting one).
echo "$tested..$PUSH_SHA changes no code: repository checks only."
else
# Code changed (or the push can't be compared): main runs everything.
full=true
packages=
native=true
windows=true
fi
elif [ "$EVENT_NAME" != pull_request ]; then
native=true
windows=true
else
# Compare both old and new paths of renames; unknown paths run native checks.
git diff --no-renames --name-only -z "$BASE_SHA...$HEAD_SHA" > "$RUNNER_TEMP/ci-paths"
while IFS= read -r -d '' path; do
case "$path" in crates/kernel/*|vendor/truck-*) kernel=true ;; esac
# What a pull request re-checks the goldens for: the kernel, truck and the goldens
# themselves (a re-record is checked before it is queued). The goldens are frozen, so
# on any other change they would only re-check unchanged results; the merge queue,
# main and the nightly run every seed before anything lands.
case "$path" in
.github/workflows/ci.yml) native=true ;;
*.rs|Cargo.toml|Cargo.lock|*/Cargo.toml|.cargo/*|rust-toolchain*) native=true ;;
docs/*|*.md|.github/*|packaging/*|LICENSE*|NOTICE|THIRD-PARTY-LICENSES.txt) ;;
*) native=true ;;
crates/kernel/*|vendor/truck-*|tests/robustness/*) goldens=true ;;
esac
# What the Windows D3D12 jobs exercise: the GPU crate and its shaders, the app and
# its startup, the viewport's wgpu glue, the vendored egui-wgpu, their scripts.
case "$path" in
crates/gpu/*|apps/solidcraft/*|crates/ui-egui/src/gpu.rs|vendor/egui-wgpu/*) windows=true ;;
tools/windows-*.ps1|.github/workflows/ci.yml|rust-toolchain*|.cargo/*) windows=true ;;
esac
done < "$RUNNER_TEMP/ci-paths"
# A lockfile change that moves the graphics stack (wgpu, naga, egui, winit, windows).
if [ "$windows" = false ] && grep -qxz 'Cargo.lock' "$RUNNER_TEMP/ci-paths" \
&& git diff "$BASE_SHA...$HEAD_SHA" -- Cargo.lock \
| grep -Eq '^[ +-]name = "(wgpu[a-z_-]*|naga|egui[a-z_-]*|eframe|winit|windows[a-z_-]*|d3d12)"'; then
windows=true
fi
# Pull requests build and test the touched packages and their dependents; nothing
# selected means no code changed.
select_packages "$RUNNER_TEMP/ci-paths"
if [ "$full" = true ] || [ -n "$packages" ]; then native=true; fi
# The goldens are solidcraft-engine tests: its tests must be in the archive.
if [ "$goldens" = true ] && [ "$full" != true ]; then
case " $packages " in *" solidcraft-engine "*) ;; *) packages="$packages solidcraft-engine" ;; esac
fi
fi
echo "native=$native" >> "$GITHUB_OUTPUT"
echo "kernel=$kernel" >> "$GITHUB_OUTPUT"
# The reference tests replay recipes through solidcraft-cli: needed when it, or anything
# it depends on, is selected.
oracle=$native
if [ "$EVENT_NAME" = pull_request ] && [ "$full" != true ]; then
case " $packages " in *" solidcraft-cli "*) ;; *) oracle=false ;; esac
fi
# The pull request fast lane: Windows, the reference tests and the doc tests run on main
# (and the nightly), not on every pull request push; a break there is fixed forward on
# main. Over three days of pull requests the doc tests were the only failing job 3 times
# in about 7,900 runner-minutes, and the reference tests never were.
doctests=$native
if [ "$EVENT_NAME" = pull_request ]; then windows=false; oracle=false; doctests=false; fi
# Until a maintainer adds approved-for-ci, a pull request gets a quick compile instead of
# Clippy, the tests, the doc tests and the goldens.
quick=false
if [ "$EVENT_NAME" = pull_request ] && [ "$APPROVED" != true ]; then
quick=$native
native=false
oracle=false
goldens=false
fi
# A push that only merges main into the change (Merge branch 'main', Update branch) when
# the change itself already passed: nothing new to test here, and main tests the merged
# result after it lands. Over three days, 872 of 1,241 such merges brought in nothing that
# touched the pull request's files, and each restarted the whole run.
carried=
if [ "$EVENT_NAME" = pull_request ] && [ "$native" = true ]; then
carried=$(python3 -I scripts/ci/main_merge.py --base "$BASE_SHA" --head "$HEAD_SHA" --repo "$REPO" \
--pr "$PR_NUMBER" --head-repo "$HEAD_REPO" --head-ref "$HEAD_REF" | sed -n 's/^carried=//p')
if [ -n "$carried" ]; then
echo "Only a merge of main into a change that passed in $carried: repository checks only."
native=false
goldens=false
fi
fi
{
echo "native=$native"
echo "kernel=$kernel"
echo "goldens=$goldens"
echo "windows=$windows"
echo "oracle=$oracle"
echo "doctests=$doctests"
echo "carried=$carried"
echo "full=$full"
echo "quick=$quick"
echo "packages=$packages"
} >> "$GITHUB_OUTPUT"
checks:
name: Format and repository checks
runs-on: ubuntu-24.04
if: (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event.action != 'labeled' || github.event.label.name == 'approved-for-ci')
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
# Main saves the cache; every other run restores it, read-only.
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: repo-checks
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Format
run: cargo fmt --all -- --check
- name: Asset attribution
run: cargo xtask assets
- name: UI icon standards and review tool
run: cargo xtask icons
- name: Licences
run: cargo xtask licences --check
- name: Dependency layers
run: cargo xtask layers
- name: Universal UI containers
run: |
cargo xtask ui-containers
cargo test -p xtask ui_containers --locked
- name: Sorted lists
run: cargo xtask lists
- name: Design format pending changes
run: bash scripts/test-format-pending.sh
- name: Web app offline use and updates (headless Chrome)
run: cargo xtask web-offline-test
- name: Affected-package selection self-test
run: python3 -I scripts/ci/test_affected.py
- name: Merge-of-main detection self-test
run: python3 -I scripts/ci/test_main_merge.py
- name: Claim, triage and kernel-paused bot tests
run: node --test scripts/claim/*.test.js scripts/triage/*.test.js scripts/kernel-paused/*.test.js
# Fast feedback for a pull request that has not been approved for the full CI yet.
quick-compile:
name: Quick compile
needs: changes
if: needs.changes.outputs.quick == 'true'
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Size parallel compiles to the job's memory
run: scripts/ci/build-jobs.sh
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: quick-compile
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Cargo check
run: cargo check --workspace --all-targets --locked
clippy:
name: Native Linux Clippy
needs: changes
if: needs.changes.outputs.native == 'true'
runs-on: ubuntu-24.04
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
# Main saves the cache; every other run restores it, read-only.
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: native-clippy
- run: cargo clippy --workspace --all-targets --profile ci --locked -- -D warnings
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Clippy
env:
FULL: ${{ needs.changes.outputs.full }}
PACKAGES: ${{ needs.changes.outputs.packages }}
run: |
# Pull requests lint the affected packages (scripts/ci/affected.py); everything else, all.
scope=(--workspace)
if [ "$FULL" != true ]; then scope=(); for p in $PACKAGES; do scope+=(-p "$p"); done; fi
cargo clippy "${scope[@]}" --all-targets --profile ci --locked -- -D warnings
build-tests:
name: Build tests
# The archive is for the action matrix and the property tests, which run only nightly and by
# hand. Pull requests, the merge queue and main build and run their tests in the `tests` job.
needs: changes
if: needs.changes.outputs.native == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 45
if: ${{ !cancelled() && needs.changes.result == 'success' && needs.changes.outputs.native == 'true' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') }}
runs-on: blacksmith-16vcpu-ubuntu-2404
# Fork builds start from main's cache, read-only; give them room to finish and upload.
timeout-minutes: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && 60 || 45 }}
env:
CARGO_INCREMENTAL: "0"
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
SCCACHE_IGNORE_SERVER_IO_ERROR: "1"
SCCACHE_DIR: /home/runner/.cache/sccache
SCCACHE_CACHE_SIZE: 20G
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Size parallel compiles to the job's memory
run: scripts/ci/build-jobs.sh
- uses: dtolnay/rust-toolchain@stable
# sccache lives on a Blacksmith sticky disk (an ext4 volume cloned from the last snapshot,
# no 10 GB limit and no download). Only main commits it; pull requests, the merge queue and
# forks mount a clone and never write back, so nothing they build can reach main's cache.
- name: Mount the compiler cache
uses: useblacksmith/stickydisk@3f6be1451e3cad893b778bc93495c80452a5da26 # v1.7.2
continue-on-error: true
with:
key: ${{ github.repository }}-sccache-archive
path: /home/runner/.cache/sccache
commit: ${{ github.ref == 'refs/heads/main' && 'on-change' || 'false' }}
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: native-tests
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Set up compiler cache
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
@@ -144,7 +383,14 @@ jobs:
with:
tool: cargo-nextest
- name: Compile tests into a nextest archive
run: cargo nextest archive --workspace --cargo-profile ci --locked --archive-file tests.tar.zst
env:
FULL: ${{ needs.changes.outputs.full }}
PACKAGES: ${{ needs.changes.outputs.packages }}
run: |
# Pull requests archive only the affected packages (scripts/ci/affected.py).
scope=(--workspace)
if [ "$FULL" != true ]; then scope=(); for p in $PACKAGES; do scope+=(-p "$p"); done; fi
cargo nextest archive "${scope[@]}" --cargo-profile ci --locked --archive-file tests.tar.zst
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: nextest-archive
@@ -156,50 +402,110 @@ jobs:
run: sccache --show-stats
tests:
name: Native Linux tests (${{ matrix.shard }}/4)
needs: [changes, build-tests]
runs-on: ubuntu-24.04
name: Native Linux tests
needs: changes
if: ${{ !cancelled() && needs.changes.result == 'success' && needs.changes.outputs.native == 'true' }}
# Builds the tests and runs them in one job on a big runner: there is no archive to upload
# and download (3 GB on a full run), and the job starts beside the repository checks, not
# after them. nextest hands tests to its threads as they free up, the slowest first (priority
# in .config/nextest.toml), so they start at once and no core idles at the end.
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
env:
# Pictures (snapshots, screenshots, ui.render, the viewport's offscreen tests) are drawn on
# the GPU. The runner has none: Mesa's lavapipe (software Vulkan) draws them, and a test that
# would skip for want of an adapter fails instead.
SOLIDCRAFT_GPU: software
SOLIDCRAFT_REQUIRE_GPU: "1"
ACTION_MATRIX_JOBS: "12"
CARGO_INCREMENTAL: "0"
RUSTC_WRAPPER: sccache
SCCACHE_DIR: /home/runner/.cache/sccache
SCCACHE_CACHE_SIZE: 20G
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install a software Vulkan driver (lavapipe)
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends mesa-vulkan-drivers libvulkan1
- name: Size parallel compiles to the job's memory
run: scripts/ci/build-jobs.sh
- uses: dtolnay/rust-toolchain@stable
# sccache lives on a Blacksmith sticky disk (an ext4 volume cloned from the last snapshot,
# no 10 GB limit and no download). Only main commits it; pull requests, the merge queue and
# forks mount a clone and never write back, so nothing they build can reach main's cache.
- name: Mount the compiler cache
uses: useblacksmith/stickydisk@3f6be1451e3cad893b778bc93495c80452a5da26 # v1.7.2
continue-on-error: true
with:
key: ${{ github.repository }}-sccache-tests
path: /home/runner/.cache/sccache
commit: ${{ github.ref == 'refs/heads/main' && 'on-change' || 'false' }}
# Main saves the cache; every other run restores it, read-only.
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: native-tests
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Set up compiler cache
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
version: v0.16.0
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
with:
tool: cargo-nextest
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: nextest-archive
- name: Run tests
env:
EVENT_NAME: ${{ github.event_name }}
SHARD: ${{ matrix.shard }}
GOLDENS: ${{ needs.changes.outputs.goldens }}
FULL: ${{ needs.changes.outputs.full }}
PACKAGES: ${{ needs.changes.outputs.packages }}
# The robustness golden seeds a pull request skips: each takes one to twenty minutes under
# CI load, together about four fifths of the goldens' time. Pushes to main, the merge
# queue and the nightly run every seed.
SLOW_SEEDS: 81|45|20|24|95|02|57|40|18|48|32|46|85|61|77|34|14
run: |
# Pull requests skip the heavy tests (default-filter in .config/nextest.toml).
all=()
if [ "$EVENT_NAME" != pull_request ]; then all=(--ignore-default-filter); fi
# Extract into ./target: tests that run a built binary (env!("CARGO_BIN_EXE_*")) have
# the build job's path, which is the same checkout path here.
cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite \
--profile ci --partition "slice:$SHARD/4" "${all[@]}"
- name: Robustness goldens sample (pull requests that touch the kernel)
# Every 8th seed of tests/robustness/goldens.json, a quarter per shard; pushes to main and
# the nightly run every seed in the step above.
if: github.event_name == 'pull_request' && needs.changes.outputs.kernel == 'true'
env:
SHARD: ${{ matrix.shard }}
run: |
cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite \
--profile ci --ignore-default-filter \
-E "package(solidcraft-engine) & test(=tests::robustness_goldens::sample_$((SHARD - 1)))"
# Pull requests skip the heavy tests (default-filter in .config/nextest.toml); `default()`
# below is that filter, so a pull request still runs only what it ran before.
base='default()'
if [ "$EVENT_NAME" = pull_request ]; then
# The action matrix sample (about 6 minutes on all cores) runs in the merge queue, on
# main and nightly, not on every pull request push.
base='default() & not (binary(action_matrix) & test(=action_matrix_sample))'
fi
if [ "$EVENT_NAME" != pull_request ]; then
# The boolean property tests draw new random inputs every run; they run only in the
# nightly property-tests job, so a known kernel bug cannot turn main red at random.
base='not (package(solidcraft-kernel) & binary(boolean_props))'
elif [ "$GOLDENS" = true ]; then
# A pull request that can change a robustness golden (tests/robustness/goldens.json)
# also checks every seed but the slow ones, in the same run so they share its cores.
base="(default() & not (binary(action_matrix) & test(=action_matrix_sample))) | (package(solidcraft-engine) & test(/^tests::robustness_goldens::/) & not test(/^tests::robustness_goldens::seed_($SLOW_SEEDS)\$/))"
fi
junit=${CARGO_TARGET_DIR:-target}/nextest/ci/junit.xml
rm -f "$junit"
# Pull requests test the affected packages (scripts/ci/affected.py).
scope=(--workspace)
if [ "$FULL" != true ]; then scope=(); for p in $PACKAGES; do scope+=(-p "$p"); done; fi
status=0
cargo nextest run "${scope[@]}" --cargo-profile ci --locked --profile ci --no-tests=pass \
--ignore-default-filter -E "$base" || status=1
if [ "$status" != 0 ]; then
# A failed golden gets the hint to re-record it (junit.xml lists the failures).
if [ -f "$junit" ] && tr '\n' ' ' < "$junit" | grep -q '<testcase name="tests::robustness_goldens::[^"]*"[^>]*>[[:space:]]*<failure'; then
echo "::error::A robustness golden changed. If the new result is intended, re-record it in this pull request and say why:"
echo "::error::ROBUSTNESS_GOLDENS_BLESS=\"why it changed\" cargo test --profile ci -p solidcraft-engine --lib robustness_goldens:: (see docs/robustness.md)"
fi
exit 1
fi
- name: Compiler cache statistics
if: always()
run: sccache --show-stats
- name: Upload test timings
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: junit-${{ matrix.shard }}
path: target/nextest/ci/junit.xml
name: junit
path: ${{ env.CARGO_TARGET_DIR || 'target' }}/nextest/ci/junit.xml
retention-days: 7
action-matrix:
@@ -210,7 +516,7 @@ jobs:
name: Action matrix (${{ matrix.shard }}/16)
needs: build-tests
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-24.04
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 55
strategy:
fail-fast: false
@@ -252,7 +558,7 @@ jobs:
name: Action matrix determinism
needs: build-tests
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-24.04
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 45
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -267,47 +573,122 @@ jobs:
cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite \
--profile ci --success-output final --run-ignored only -E "binary(action_matrix) & test(=action_matrix_determinism)"
property-tests:
# The boolean property tests and the atomic command tests with many cases. Pull requests skip
# them (.config/nextest.toml); pushes to main run atomic_commands with small defaults (seeds
# 0..4) and skip boolean_props, whose random inputs would fail main at random.
name: Property tests (many cases)
needs: build-tests
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 60
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
with:
tool: cargo-nextest
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: nextest-archive
- name: Boolean properties and atomic commands
# The defaults (8 cases, seeds 0..4) take 159 s and 802 s on a 2-core VM; four times the
# cases and twice the seeds are estimated at about 40 minutes there (not yet timed on CI).
env:
PROPTEST_CASES: "32"
ATOMIC_SEEDS: "0..8"
run: |
cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite \
--profile ci --ignore-default-filter --success-output final -E "binary(boolean_props) | binary(atomic_commands)"
doc-tests:
name: Doc tests
needs: changes
if: needs.changes.outputs.native == 'true'
runs-on: ubuntu-24.04
if: needs.changes.outputs.doctests == 'true'
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 45
env:
CARGO_INCREMENTAL: "0"
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
SCCACHE_IGNORE_SERVER_IO_ERROR: "1"
SCCACHE_DIR: /home/runner/.cache/sccache
SCCACHE_CACHE_SIZE: 20G
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Size parallel compiles to the job's memory
run: scripts/ci/build-jobs.sh
- uses: dtolnay/rust-toolchain@stable
# sccache lives on a Blacksmith sticky disk (an ext4 volume cloned from the last snapshot,
# no 10 GB limit and no download). Only main commits it; pull requests, the merge queue and
# forks mount a clone and never write back, so nothing they build can reach main's cache.
- name: Mount the compiler cache
uses: useblacksmith/stickydisk@3f6be1451e3cad893b778bc93495c80452a5da26 # v1.7.2
continue-on-error: true
with:
key: ${{ github.repository }}-sccache-doc
path: /home/runner/.cache/sccache
commit: ${{ github.ref == 'refs/heads/main' && 'on-change' || 'false' }}
# Main saves the cache; every other run restores it, read-only.
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: doc-tests
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Set up compiler cache
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
version: v0.16.0
# nextest does not run doc tests.
- run: cargo test --workspace --profile ci --locked --doc
- name: Doc tests
env:
FULL: ${{ needs.changes.outputs.full }}
PACKAGES: ${{ needs.changes.outputs.packages }}
run: |
# Pull requests test the affected packages that have a library (only libraries have doc
# tests); everything else, the whole workspace.
scope=(--workspace)
if [ "$FULL" != true ]; then
scope=()
libs=$(cargo metadata --no-deps --format-version 1 --locked \
| python3 -I -c 'import json, sys; print(" ".join(p["name"] for p in json.load(sys.stdin)["packages"] if any("lib" in t["kind"] for t in p["targets"])))')
for p in $PACKAGES; do
case " $libs " in *" $p "*) scope+=(-p "$p") ;; esac
done
fi
if [ ${#scope[@]} -eq 0 ]; then
echo "No affected package has a library: no doc tests to run."
exit 0
fi
cargo test "${scope[@]}" --profile ci --locked --doc
oracle:
name: Reference tests
needs: changes
if: needs.changes.outputs.native == 'true'
runs-on: ubuntu-24.04
if: needs.changes.outputs.oracle == 'true'
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 60
env:
CARGO_INCREMENTAL: "0"
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
SCCACHE_IGNORE_SERVER_IO_ERROR: "1"
SCCACHE_DIR: /home/runner/.cache/sccache
SCCACHE_CACHE_SIZE: 20G
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Size parallel compiles to the job's memory
run: scripts/ci/build-jobs.sh
- uses: dtolnay/rust-toolchain@stable
# sccache lives on a Blacksmith sticky disk (an ext4 volume cloned from the last snapshot,
# no 10 GB limit and no download). Only main commits it; pull requests, the merge queue and
# forks mount a clone and never write back, so nothing they build can reach main's cache.
- name: Mount the compiler cache
uses: useblacksmith/stickydisk@3f6be1451e3cad893b778bc93495c80452a5da26 # v1.7.2
continue-on-error: true
with:
key: ${{ github.repository }}-sccache-oracle
path: /home/runner/.cache/sccache
commit: ${{ github.ref == 'refs/heads/main' && 'on-change' || 'false' }}
# Main saves the cache; every other run restores it, read-only.
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: oracle
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Set up compiler cache
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
@@ -315,7 +696,7 @@ jobs:
- name: Build the command line
run: cargo build -p solidcraft-cli --profile ci --locked
- name: Replay tests/oracle and compare with expected.json
run: cargo xtask oracle --cli target/ci/solidcraft-cli
run: cargo xtask oracle --cli "${CARGO_TARGET_DIR:-target}/ci/solidcraft-cli"
- name: Upload the report
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
@@ -326,16 +707,19 @@ jobs:
# D3D12's older shader compiler (FXC, used where dxcompiler.dll is absent) is stricter than
# DXC, Vulkan and Metal: a shader it cannot compile once crashed the app at startup. Build the
# GPU tessellation pipeline with FXC on the runner's WARP software adapter and compare its
# results with the CPU.
# results with the CPU, and draw headless pictures with the viewport's pipelines there
# (crates/gpu/tests/capture.rs).
windows-fxc:
name: Windows D3D12 FXC shaders
needs: changes
if: needs.changes.outputs.native == 'true'
runs-on: windows-latest
if: needs.changes.outputs.windows == 'true'
runs-on: blacksmith-8vcpu-windows-2025
timeout-minutes: 45
env:
WGPU_BACKEND: dx12
WGPU_DX12_COMPILER: fxc
# WARP is a software (Cpu) adapter.
SOLIDCRAFT_GPU: software
SOLIDCRAFT_REQUIRE_GPU: "1"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -343,6 +727,7 @@ jobs:
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: windows-fxc
save-if: ${{ github.ref == 'refs/heads/main' }}
# One test at a time: WARP's shader JIT crashes now and then (a null read in
# d3d10warp.dll's JIT optimizer, about 1 run in 60) when several devices compile compute
# pipelines at once, which the parallel tests do. Minidumps showed it in WARP itself, not
@@ -355,7 +740,9 @@ jobs:
windows-startup:
name: Windows app startup
needs: changes
if: needs.changes.outputs.native == 'true'
if: needs.changes.outputs.windows == 'true'
# GitHub-hosted: the startup smoke needs an interactive desktop that presents frames; on
# Blacksmith's Windows runners the window never presents ("no frame was presented").
runs-on: windows-latest
timeout-minutes: 60
steps:
@@ -364,6 +751,7 @@ jobs:
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: windows-startup
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Backend order and probing on Windows
run: cargo test -p solidcraft --profile ci --locked -- startup::tests native_crash::tests native_frame::tests
- name: Build the app
@@ -377,17 +765,57 @@ jobs:
# Preserve the existing check name for branch protection, including docs-only PRs.
ci:
needs: [changes, checks, clippy, build-tests, tests, doc-tests, oracle, windows-fxc, windows-startup]
if: always()
runs-on: ubuntu-24.04
# On a run another label starts (every other job skipped), this job reports the result of the
# commit's real run instead: the merge queue reads `ci` from the newest run of a commit, so a
# run without a passing `ci` would block a pull request that passed (#1850, #3182, #3252).
name: ci
needs: [changes, checks, quick-compile, clippy, build-tests, tests, doc-tests, oracle, windows-fxc, windows-startup]
if: always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft)
runs-on: blacksmith-2vcpu-ubuntu-2404
permissions:
contents: read
# To read the commit's other runs of this workflow.
actions: read
steps:
- name: Report the commit's real result (a run another label started)
if: github.event.action == 'labeled' && github.event.label.name != 'approved-for-ci'
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
SHA: ${{ github.event.pull_request.head.sha }}
RUN_ID: ${{ github.run_id }}
run: |
# The other runs of ci.yml for this commit. While one is still going, wait for it (at
# most 90 minutes); then pass only when the newest one that ran its checks passed.
deadline=$((SECONDS + 5400))
while :; do
runs=$(gh api "repos/$REPO/actions/workflows/ci.yml/runs?head_sha=$SHA&event=pull_request&per_page=100" \
--jq "[.workflow_runs[] | select(.id != $RUN_ID)]")
pending=$(jq '[.[] | select(.status != "completed")] | length' <<<"$runs")
if [ "$pending" -eq 0 ] || [ "$SECONDS" -ge "$deadline" ]; then break; fi
echo "Waiting for $pending run(s) of this commit to finish."
sleep 30
done
# Runs with every job skipped (labels, before this job reported) are not a result.
latest=$(jq -r '[.[] | select(.status == "completed" and .conclusion != "skipped")]
| sort_by(.created_at) | last | "\(.conclusion) \(.html_url)"' <<<"$runs")
echo "The commit's latest run: $latest"
echo "A label started this run: \`ci\` repeats the commit's latest result ($latest)." >> "$GITHUB_STEP_SUMMARY"
test "${latest%% *}" = success
- name: Verify required checks
if: github.event.action != 'labeled' || github.event.label.name == 'approved-for-ci'
env:
CHANGES: ${{ needs.changes.result }}
CHECKS: ${{ needs.checks.result }}
NATIVE: ${{ needs.changes.outputs.native }}
QUICK: ${{ needs.changes.outputs.quick }}
QUICK_COMPILE: ${{ needs.quick-compile.result }}
WINDOWS: ${{ needs.changes.outputs.windows }}
ORACLE_RUN: ${{ needs.changes.outputs.oracle }}
DOCTESTS: ${{ needs.changes.outputs.doctests }}
CLIPPY: ${{ needs.clippy.result }}
BUILD: ${{ needs.build-tests.result }}
EVENT_NAME: ${{ github.event_name }}
TESTS: ${{ needs.tests.result }}
DOCS: ${{ needs.doc-tests.result }}
ORACLE: ${{ needs.oracle.result }}
@@ -396,20 +824,39 @@ jobs:
run: |
test "$CHANGES" = success
test "$CHECKS" = success
# An unapproved pull request: the heavy jobs are skipped on purpose.
if [ "$QUICK" = true ]; then
test "$QUICK_COMPILE" = success
fi
if [ "$NATIVE" = true ]; then
test "$CLIPPY" = success
test "$BUILD" = success
# The archive for the action matrix and property tests exists nightly and by hand only.
if [ "$EVENT_NAME" = schedule ] || [ "$EVENT_NAME" = workflow_dispatch ]; then
test "$BUILD" = success
fi
test "$TESTS" = success
fi
if [ "$DOCTESTS" = true ]; then
test "$DOCS" = success
fi
if [ "$ORACLE_RUN" = true ]; then
test "$ORACLE" = success
fi
if [ "$WINDOWS" = true ]; then
test "$WINDOWS_FXC" = success
test "$WINDOWS_STARTUP" = success
fi
- name: Note a carried result
if: needs.changes.outputs.carried != ''
env:
CARRIED: ${{ needs.changes.outputs.carried }}
run: |
echo "This push only merges main into a change that already passed; the heavy checks were not repeated. Passing run: $CARRIED" >> "$GITHUB_STEP_SUMMARY"
wasm:
name: Compile WebAssembly (manual)
if: github.event_name == 'workflow_dispatch' && inputs.cross_compile
runs-on: ubuntu-24.04
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -419,12 +866,13 @@ jobs:
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: wasm
save-if: ${{ github.ref == 'refs/heads/main' }}
- run: cargo xtask wasm
riscv64-gates:
name: Compile RISC-V (manual)
if: github.event_name == 'workflow_dispatch' && inputs.cross_compile
runs-on: ubuntu-24.04
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -440,6 +888,7 @@ jobs:
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: riscv64
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Install cross-compilation dependencies
run: |
# Ubuntu's regular archive serves amd64; RISC-V packages live in ports.
+52
View File
@@ -0,0 +1,52 @@
# `/claim` and `/unclaim` on issues (CONTRIBUTING.md, "Sending a change"), and a daily sweep that
# frees claims nobody is working on. A pull request that references an issue (`<summary> (#N)`,
# `Fixes #N`) claims it too. The logic is in scripts/claim/claim.js (tested in ci.yml).
name: Claim
on:
issue_comment:
types: [created]
# pull_request_target so PRs from forks can label and assign. It runs the base branch's
# scripts/claim and only reads the PR's title, body and author; the PR's code is never checked out.
pull_request_target:
types: [opened, reopened]
schedule:
- cron: '17 5 * * *'
workflow_dispatch:
permissions:
contents: read # to check out scripts/claim
issues: write
pull-requests: read
# One run at a time per issue, so two simultaneous /claim comments can't both win.
concurrency:
group: claim-${{ github.event.issue.number || github.event.pull_request.number || 'sweep' }}
cancel-in-progress: false
jobs:
claim:
name: Claim bot
runs-on: ubuntu-24.04
timeout-minutes: 10
if: >-
github.event_name != 'issue_comment' ||
(!github.event.issue.pull_request && github.event.comment.user.type != 'Bot' &&
(startsWith(github.event.comment.body, '/claim') || startsWith(github.event.comment.body, '/unclaim')))
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
sparse-checkout: scripts/claim
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const claim = require('./scripts/claim/claim.js');
if (context.eventName === 'issue_comment') {
await claim.handleComment({ github, context });
} else if (context.eventName === 'pull_request_target') {
const claimed = await claim.handlePull({ github, context });
core.info(`Claimed: ${claimed.join(', ') || 'none'}`);
} else {
const released = await claim.sweep({ github, context });
core.info(`Released: ${released.join(', ') || 'none'}`);
}
+110
View File
@@ -0,0 +1,110 @@
# The web app as a container image on GitHub's container registry, for self-hosting
# (packaging/docker; the book's "Self-hosting with Docker"):
#
# ghcr.io/<owner>/solidcraft-web:latest the latest published release
# ghcr.io/<owner>/solidcraft-web:0.14.0 a release (also 0.14); pre-releases get only their version
# ghcr.io/<owner>/solidcraft-web:edge main
#
# For linux/amd64 and linux/arm64. The site is built here, as Pages and Release build it, and
# passed to the Dockerfile as its `site` stage. Pull requests that touch the image build and
# smoke-test it without publishing. Releases published by an agent or maintainer trigger this;
# the nightly pre-releases (made with GITHUB_TOKEN) do not.
name: Docker
on:
push:
branches: [main]
# The image holds the web app only: documentation, test data and the GitHub bots' scripts can't
# change it, so a push touching only those builds nothing (edge stays at the last code change).
# ATTRIBUTION.md is the exception among the Markdown files: the app embeds it (Help, About).
# The last matching pattern wins.
paths: ["**", "!docs/**", "!**/*.md", "ATTRIBUTION.md", "!tests/**", "!scripts/claim/**", "!scripts/triage/**"]
release:
types: [published]
pull_request:
paths: ["packaging/docker/**", ".github/workflows/docker.yml"]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: docker-${{ github.ref }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
TRUNK_VERSION: v0.21.14
jobs:
image:
if: ${{ !github.event.repository.private }}
runs-on: ubuntu-latest
timeout-minutes: 60
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: docker-web
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Install trunk
run: |
mkdir -p "$HOME/.local/bin"
curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" | tar -xz -C "$HOME/.local/bin"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Build the web app
working-directory: apps/solidcraft-web
run: trunk build --release
- name: Image name
id: image
# Registry names are lower case; a fork's owner may not be.
run: echo "name=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/solidcraft-web" >> "$GITHUB_OUTPUT"
- uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
id: meta
with:
images: ${{ steps.image.outputs.name }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=edge,branch=main
type=ref,event=pr
labels: |
org.opencontainers.image.title=SolidCraft (web)
org.opencontainers.image.description=SolidCraft, open-source parametric CAD, in the browser: the web app served by nginx on port 8080
org.opencontainers.image.licenses=MIT OR Apache-2.0
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Build for the smoke test
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
file: packaging/docker/Dockerfile
build-contexts: site=dist/web
platforms: linux/amd64
load: true
tags: solidcraft-web:test
- name: Smoke test
run: bash packaging/docker/smoke-test.sh solidcraft-web:test
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
if: github.event_name != 'pull_request'
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and publish
if: github.event_name != 'pull_request'
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
file: packaging/docker/Dockerfile
build-contexts: site=dist/web
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
annotations: ${{ steps.meta.outputs.annotations }}
+62
View File
@@ -0,0 +1,62 @@
# Files generated from the code are rewritten here, on main, after each merge, so pull requests
# never edit them (two pull requests that each regenerated one would always conflict):
# THIRD-PARTY-LICENSES.txt (`cargo xtask licences`) and docs/features.md (`cargo xtask features`).
name: Generated files
on:
push:
branches: [main]
# THIRD-PARTY-LICENSES.txt follows the manifests, the lockfile, the vendored crates' licence
# files and the fonts' licences; docs/features.md follows the command registry (crates/engine),
# read through solidcraft-cli. Other pushes regenerate nothing.
paths:
- "Cargo.lock"
- "**/Cargo.toml"
- "vendor/**"
- "assets/fonts/**"
- "crates/engine/**"
- "apps/solidcraft-cli/**"
- "xtask/**"
- ".github/workflows/generated.yml"
workflow_dispatch:
permissions:
contents: write
# One run at a time; a burst of merges leaves one run waiting, which sees all of them.
concurrency:
group: generated-files
cancel-in-progress: false
jobs:
regenerate:
name: Regenerate and commit
runs-on: ubuntu-24.04
timeout-minutes: 60
steps:
# Always main itself, never pull request code, since this job can push.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: main
# main goes through the merge queue; this deploy key may push past it.
ssh-key: ${{ secrets.GENERATED_FILES_DEPLOY_KEY }}
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: generated-files
- name: THIRD-PARTY-LICENSES.txt
run: cargo xtask licences
- name: docs/features.md
run: cargo xtask features
- name: Commit if changed
run: |
if git diff --quiet -- THIRD-PARTY-LICENSES.txt docs/features.md; then echo "unchanged"; exit 0; fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add THIRD-PARTY-LICENSES.txt docs/features.md
git commit -m "Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci]"
for _ in 1 2 3; do
git pull --rebase origin main && git push origin HEAD:main && exit 0
sleep 10
done
exit 1
+42
View File
@@ -0,0 +1,42 @@
# While a new geometry kernel is evaluated, new issues, pull requests and discussions about kernel
# work are labelled `kernel-paused` for a maintainer to answer (CONTRIBUTING.md, "Kernel work is
# paused"). The logic is in scripts/kernel-paused/ (tested in ci.yml).
name: Kernel paused
on:
issues:
types: [opened]
# pull_request_target so PRs from forks can be labelled. It runs the base branch's
# scripts/kernel-paused and only lists the PR's changed paths; the PR's code is never checked out.
pull_request_target:
types: [opened]
discussion:
types: [created]
permissions:
contents: read # to check out scripts/kernel-paused
issues: write
pull-requests: read # to list a PR's changed paths
discussions: write
concurrency:
group: kernel-paused-${{ github.event.issue.number || github.event.pull_request.number || github.event.discussion.number }}
cancel-in-progress: false
jobs:
paused:
name: Kernel paused bot
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
sparse-checkout: scripts/kernel-paused
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const paused = require('./scripts/kernel-paused/kernel-paused.js');
const done = context.eventName === 'discussion'
? await paused.handleDiscussion({ github, context })
: await paused.handleOpened({ github, context });
core.info(done);
+33
View File
@@ -0,0 +1,33 @@
# Files many pull requests add to are kept so that additions don't conflict: one file per change
# or one sorted line per item (CONTRIBUTING.md, "Lists many changes add to"). Fast, no build.
# Pull requests get the same checks in CI's "Format and repository checks" job (one runner slot
# per PR instead of three); this copy keeps main honest.
name: Lists
on:
push:
branches: [main]
permissions:
contents: read
jobs:
format-changes:
name: Design format changes merge cleanly
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- run: bash scripts/test-format-pending.sh
sorted:
name: Lists are sorted one item per line
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: lists
- run: cargo xtask lists
+1 -1
View File
@@ -8,7 +8,7 @@ on:
branches: [main]
paths: [".github/workflows/**", "packaging/**", "assets/**"]
pull_request:
paths: [".github/workflows/**", "packaging/**", "assets/**"]
paths: [".github/workflows/**", "packaging/**", "assets/platforms/**"]
workflow_dispatch:
permissions:
+12 -3
View File
@@ -1,11 +1,13 @@
# The documentation site on GitHub Pages: the book (docs/book) at /solidcraft/ and the web app
# at /solidcraft/app/. Needs Settings → Pages → Source = GitHub Actions; skipped while the
# The documentation site on GitHub Pages: the book (docs/book) at https://docs.solidcraft.io/
# and the bundled web app at /app/. Needs Settings → Pages → Source = GitHub Actions; skipped while the
# repository is private.
name: Pages
on:
push:
branches: [main]
# The site is the book and the web app: test data and the GitHub bots' scripts change neither.
paths-ignore: ["tests/**", "scripts/claim/**", "scripts/triage/**"]
workflow_dispatch:
inputs:
release_tag:
@@ -45,11 +47,18 @@ jobs:
curl -sSL "https://github.com/rust-lang/mdBook/releases/download/${MDBOOK_VERSION}/mdbook-${MDBOOK_VERSION}-x86_64-unknown-linux-gnu.tar.gz" | tar -xz -C "$HOME/.local/bin"
curl -sSL "https://github.com/trunk-rs/trunk/releases/download/${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" | tar -xz -C "$HOME/.local/bin"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Install a software Vulkan driver (lavapipe)
# The book's pictures are `solidcraft-cli snapshot`s, drawn on the GPU; the runner has none.
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends mesa-vulkan-drivers libvulkan1
- name: Build the book
env:
SOLIDCRAFT_GPU: software
run: cargo xtask book
- name: Build the web app
working-directory: apps/solidcraft-web
run: trunk build --release --public-url /solidcraft/app/
run: trunk build --release --public-url ./
- name: Test the web app offline and across an update (headless Chrome)
run: cargo xtask web-offline-test --site dist/web
- name: Assemble the site
+2
View File
@@ -203,6 +203,8 @@ jobs:
name: Snap ${{ matrix.arch }}
needs: [version, linux]
if: needs.version.outputs.build == 'true'
# The Snap Store hasn't accepted SolidCraft yet, so a snap failure never holds up a release.
continue-on-error: true
# Repackages the Linux job's tarball, like the Flatpak. On 24.04 because the core24 base is
# Ubuntu 24.04, which lets snapcraft build on the runner itself (--destructive-mode).
runs-on: ${{ matrix.runner }}
+86
View File
@@ -0,0 +1,86 @@
# Labels new issues and PRs from fixed rules, `/triage` for maintainers, asks short bug reports
# for reproduction steps (`needs-info`, closed after 14 days unanswered), and a manual backfill
# (CONTRIBUTING.md, "Labels and triage"). The logic is in scripts/triage/ (tested in ci.yml).
name: Triage
on:
issues:
types: [opened, labeled, edited]
issue_comment:
types: [created]
# pull_request_target so PRs from forks can be labelled. It runs the base branch's
# scripts/triage and only lists the PR's changed paths; the PR's code is never checked out.
pull_request_target:
types: [opened]
schedule:
- cron: '41 5 * * *'
workflow_dispatch:
inputs:
dry_run:
description: 'Only report what would be labelled'
type: boolean
default: true
permissions:
contents: read # to check out scripts/triage
issues: write
pull-requests: write
concurrency:
group: triage-${{ github.event.issue.number || github.event.pull_request.number || 'repo' }}
cancel-in-progress: false
jobs:
triage:
name: Triage bot
runs-on: ubuntu-24.04
timeout-minutes: 30
# Filtered here so the common events (every comment, every label) start no runner.
if: >-
(github.event_name == 'issue_comment' &&
github.event.comment.user.type != 'Bot' && startsWith(github.event.comment.body, '/triage')) ||
((github.event_name == 'issue_comment' || github.event.action == 'edited') &&
contains(github.event.issue.labels.*.name, 'needs-info') &&
github.event.sender.login == github.event.issue.user.login) ||
(github.event.action == 'labeled' && startsWith(github.event.label.name, 'priority:') &&
contains(github.event.issue.labels.*.name, 'needs-triage')) ||
(github.event_name != 'issue_comment' && github.event.action != 'labeled' && github.event.action != 'edited')
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
sparse-checkout: scripts/triage
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
DRY_RUN: ${{ inputs.dry_run }}
with:
script: |
const triage = require('./scripts/triage/triage.js');
const info = require('./scripts/triage/needs-info.js');
const event = context.eventName;
const action = context.payload.action;
const esc = (t) => String(t).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
const th = (...names) => names.map((data) => ({ data, header: true }));
if (event === 'issue_comment') {
core.info(await triage.handleComment({ github, context }));
await info.handleReply({ github, context });
} else if (event === 'issues' && action === 'labeled') {
await triage.handleLabeled({ github, context });
} else if (event === 'issues' && action === 'edited') {
await info.handleReply({ github, context });
} else if (event === 'schedule') {
const closed = await info.sweep({ github, context });
core.info(`Closed for missing information: ${closed.join(', ') || 'none'}`);
} else if (event === 'workflow_dispatch') {
const dryRun = process.env.DRY_RUN !== 'false';
const plan = await triage.backfill({ github, context, dryRun });
const asks = await info.backfill({ github, context, dryRun });
await core.summary
.addHeading(dryRun ? `Backfill dry run: ${plan.length} unlabelled` : `Backfill: ${plan.length} labelled`)
.addTable([th('Item', 'Kind', 'Labels', 'Title'), ...plan.map((p) => [`#${p.number}`, p.pull ? 'PR' : 'issue', esc(p.labels.join(', ') || '—'), esc(p.title)])])
.addHeading(dryRun ? `needs-info dry run: ${asks.length} would be asked` : `needs-info: ${asks.length} asked`)
.addTable([th('Issue', 'Asks', 'Title'), ...asks.map((p) => [`#${p.number}`, esc(p.asks.join(', ')), esc(p.title)])])
.write();
} else {
core.info(`Labelled: ${(await triage.handleOpened({ github, context })).join(', ') || 'nothing'}`);
if (event === 'issues') await info.handleOpened({ github, context });
}
+4
View File
@@ -7,3 +7,7 @@
/.claude/worktrees/
/target-snapshot
__pycache__/
.vscode/
# cargo-mutants output (docs/mutation-testing.md)
mutants.out*/
+81 -22
View File
@@ -27,13 +27,17 @@ new uses of it anywhere else. Public docs/web/Flatpak deployments use project Pa
## Never crash
People trust SolidCraft with their designs; a crash loses their work. **This outranks feature work.**
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`,
`unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The three exceptions, in the app crate
(which denies it instead), have no safe API: two functions in
`unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The exceptions, in the app crate and in
`crates/threedx-client` (which deny it instead), have no safe API: the macOS-only
`crates/threedx-client/src/macos.rs` (loads the 3Dconnexion driver's client framework at run time
and calls through its function pointers), two functions in
`apps/solidcraft/src/open_documents.rs` (macOS hands Finder files only to an Objective-C
method), the Windows-only `apps/solidcraft/src/native_crash.rs` (the exception filter that
records crashes inside native code such as graphics drivers) and the Windows-only
records crashes inside native code such as graphics drivers), the Windows-only
`apps/solidcraft/src/native_frame.rs` (the window procedure that answers the resize-border hit
test of the window without OS decorations).
test of the window without OS decorations) and the Windows-only
`apps/solidcraft/src/space_mouse/windows_hid.rs` (asks Plug and Play which HID interfaces are 3D
mice, so no other device is sent anything).
- **Errors are `Result<T, E>`** through the crate's error type and `?`. An unfinished feature
returns an error ("not supported yet: …"); it never panics.
- **Input-derived numbers are hostile** (command parameters, design files, control-channel
@@ -48,6 +52,8 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
`crashes replay <id>` (or the `diagnostics.crash_reports` command / MCP `crash_reports` tool).
- **Prove it:** every crash fix lands with a regression test (see `hostile_params_never_panic`); a
crash report's `design.solidcraft` + `replay.json` is the test's starting point.
- **Where tests go:** a regression test goes into the existing test file for its area (booleans, blends,
shell, sweep, STEP, naming…), never a new file named after the bug (#3226).
- Every production crate root carries
`#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
@@ -65,8 +71,11 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
nothing from FreeCAD is linked, vendored or shipped.) The sketch solver and the blend
operations are our own.
- **Assets — absolutely essential.** SolidCraft contains **no iconography, images, fonts or
artwork from commercial CAD products — ever.** Every UI icon is drawn in collected code
(`assets/icons/ui/source/`, compiled through `crates/ui-egui/src/icons.rs`). Any file asset must be original or openly licensed and **must have a row in ATTRIBUTION.md**
artwork from commercial CAD products — ever.** Command icons are the original SVG masters in
`assets/icons/ui/design/svg/`, compiled through `crates/ui-egui/src/icon_svg.rs`.
**Before any icon, icon assignment or media edit, read `assets/AGENTS.md`,
`assets/icons/ui/AGENTS.md` and `assets/icons/ui/STYLE_GUIDE.md`.** Use the canonical
bases and named finished references there; never invent a parallel icon style. Any file asset must be original or openly licensed and **must have a row in ATTRIBUTION.md**
(`cargo xtask assets` enforces it). Screenshots of commercial CAD software are never committed.
Breaking this rule is the most serious mistake you can make in this repo.
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*`
@@ -81,22 +90,33 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
egui/eframe/winit/wgpu/rfd. **The UI crate is swappable.** Only `crates/kernel` may use `truck`.
- **The UI is thin**: panels read engine state and act through `app.run(id, params)` /
`app.start(id)`. Colours come from `theme::Tokens`.
- **One outer UI container**: all panels, floating sheets, menus, overlays and platform UI
windows use `ui_kit::Container` (directly or through `Panel`, `Window`, `Confirm`, `Menu`).
Declare the semantic `Surface`; workspace panels use its shared layout policy and chrome.
System/report windows use `ui_kit::Window`; its low-level host builder is kit-private.
Do not construct raw egui hosts, outer popup/window frames or shadows in feature modules.
`cargo xtask ui-containers` recursively checks source/examples in all current and future crates
and apps. It runs in the fast repository CI job and `cargo xtask ci`, with no caller allowances.
See `docs/ui-container-audit.md` for the inventory and legitimate content/native exceptions.
- **One set of camera controls**: every page or canvas with a camera (viewport, Render
preview, future flat pattern or drawing pages) calls `navigation::handle` with its `NavCaps`
(orbit off for flat 2D pages). New canvases must use `navigation::handle`; never hand-roll
camera input (a test rejects it).
- **Rust only.** Units are millimetres and radians internally; Z is up. The one exception is
- **Application code is Rust only.** Units are millimetres and radians internally; Z is up.
Local asset authoring, lint and review tools use Python 3.11+ as documented in
`assets/AGENTS.md`; Python is not an application runtime dependency. The application exception is
JavaScript the browser requires: the service worker `apps/solidcraft-web/sw.js` (browsers only
run service workers as JavaScript) and the small inline scripts in `apps/solidcraft-web/index.html`.
Keep them minimal; `cargo xtask web-offline-test` covers the service worker.
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests,
assets, layers). Commit after every feature arc that builds.
- **Publishing**: `origin` is github.com/bherbruck/solidcraft. Changes land through **pull requests**:
push a branch, open a PR, and let GitHub CI run. Maintainers (and agents working for them) may
merge their own PR as soon as CI is green (`gh pr merge --squash`); a trivial fix (docs, typos)
may be merged right away. Keep each PR to one topic, rebase or merge `main` into it when it
conflicts, and never force-push `main`. Never commit `plan/`, build dirs (`target*/`) or
generated reference data; check `git ls-files` before pushing.
- **Publishing**: the repository is github.com/tangency-labs/solidcraft. Changes land through
**pull requests** and the **merge queue**: push a branch and open a PR. The heavy CI jobs start
when a maintainer adds `approved-for-ci` after reviewing the code. Approved PRs go into the queue
with `gh pr merge N -R tangency-labs/solidcraft --squash --auto`; the queue runs the full suite
on each group before it lands on `main`. Keep each PR to one topic and one issue, and never
force-push `main`. Never commit `plan/`, build dirs (`target*/`) or generated reference data;
check `git ls-files` before pushing.
- **Never break wasm**: everything below the apps must build for `wasm32-unknown-unknown`
(`cargo xtask wasm`, part of `cargo xtask ci`).
@@ -115,13 +135,46 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
- Use a small public-safe example and check for private designs, paths, account details or
other sensitive content before sharing. Attach media to the issue/PR or link to an accessible
recording; do not commit generated screenshots or videos just to provide review evidence.
- Throughout the work and before handoff, check current upstream commits and the relevant
PR's merge status. Keep your branch current with upstream merges, resolve conflicts without
discarding others' work, and re-run validation when integration changes the result. Use
`git pull --rebase origin main` before pushing as above; never force-push. Report the checked
upstream/PR state, any conflicts resolved and the validation performed. If a PR remains open,
keep monitoring it while the task is active and make its pending status clear at handoff;
do not claim it merged without checking.
- Throughout the work and before handoff, check the relevant PR's merge status. Don't push to
an open PR only to catch up with `main` (no merge of `main`, no Update branch, no rebase for
that alone): every push restarts the whole CI run, and `main`'s own run catches what lands in
between, to be fixed forward. When you push a change anyway, or GitHub reports a conflict,
rebase onto the latest `main` (push your own branch with `--force-with-lease`); never merge
`main` into a PR. Resolve conflicts without discarding others' work; never force-push `main`.
Don't re-run CI on an unchanged commit: a real failure fails again. Re-run only a job that
runner trouble cancelled, with "Re-run failed jobs". Report the PR state you checked and any
conflicts resolved. If a PR remains open, keep monitoring it while the task is active and
make its pending status clear at handoff; do not claim it merged without checking.
## Before you start: look at what is already open
- Work with an authenticated GitHub CLI (`gh auth status`) and `git`; the checks below need both.
- Pull the whole list, not one search: `gh pr list -R tangency-labs/solidcraft --state open --limit 500`
(drafts included) and `gh issue list -R tangency-labs/solidcraft --state open --limit 500`. Look for
work on the same issue, the same feature under another number, and PRs that change the files
you are about to change (`gh pr list ... --json number,title,files`).
- If someone already has it, comment on their PR or issue instead of opening a second one. Two
PRs for one change can't both land, and one of them is wasted work and CI.
## Claim = a draft PR
- Before working on issue #N, check nobody has it: `gh pr list -R tangency-labs/solidcraft --state open --search "#N"`.
- If none, branch from the latest `origin/main`, `git commit --allow-empty -m "claim: #N"`, push,
and open a **draft** PR titled `<summary> (#N)` right away. **The lowest-numbered open PR for an
issue wins**; if yours lost, close it. Opening the PR is the claim: the bot assigns you and
labels the issue `in progress` (or says on your PR who holds it). Reference `#N` in the PR title
so the bot can find it. Don't claim by editing files; comment `/claim` on the issue only to stake
a claim before you have a branch.
- Push work in progress at least daily. A draft with no pushes for 72 hours may be taken over:
comment on it first, then open your own claim (you may start from its branch).
This takeover policy is separate from the bot's automatic cleanup deadlines in CONTRIBUTING.md.
- On a shared build machine, check the load before a big build (`cut -d' ' -f1 /proc/loadavg`)
and wait while it is over 24; build with `nice`.
## Design file format
A change that saves something new in a design adds ONE file, `crates/doc/src/format/pending/<name>.rs`
(a `//!` note, and `pub fn step` if older designs must be rewritten). Never edit `FORMAT`,
`JSON_STEPS`, the history in `format.rs`, another change's file or a `v<N>/` folder: the build
works out the number (the next one while anything is pending), and the release folds pending
changes into `v<N>/` with `scripts/fold-format-pending.sh`. See CONTRIBUTING.md.
## Application releases
Development pushes are not application releases. Follow `docs/releasing.md` when asked to
@@ -153,5 +206,11 @@ before the draft is verified.
## Roadmap
`ROADMAP.md` (committed) tracks status, milestones and estimates. Update it whenever a
milestone task lands. `cargo xtask features` regenerates `docs/features.md` (SolidCraft's
commands by area, from the registry) when commands are added or renamed.
milestone task lands. `docs/features.md` (SolidCraft's commands by area, from the registry) and
`THIRD-PARTY-LICENSES.txt` are regenerated on `main` after each merge by the Generated files
workflow; never regenerate them in a PR.
## Lists many PRs add to
Keep them conflict-free (CONTRIBUTING.md, "Lists many changes add to"): message catalogs sorted
(`cargo xtask lists --fix`), `mod` lines in their sorted group, the language list English first
then by id, generated files left to `main`, and a design-format change as one new file.
+511 -18
View File
@@ -14,41 +14,81 @@ Editable drawing code, native projects and shipped exports are collected under `
| File | Author | Source | Licence |
|---|---|---|---|
| examples/chebyshev-motion-trace.json | SolidCraft contributors | Original parametric Chebyshev lambda mechanism and associative-trace demo, constructed from analytic circle intersections; classical link proportions documented at https://doi.org/10.1002/rcs.70095; no external CAD geometry or artwork | MIT OR Apache-2.0 |
| examples/material-card.json | SolidCraft contributors | original analytic box and explicitly synthetic engineering card; invented illustrative constants, no external material dataset | MIT OR Apache-2.0 |
| examples/studio/author_sweep.py | SolidCraft contributors | original editable CAD scene and authoring metadata; no external assets | MIT OR Apache-2.0 |
| examples/studio/author_scene.py | SolidCraft contributors | original editable CAD scene and authoring metadata; no external assets | MIT OR Apache-2.0 |
| examples/studio/materials.json | SolidCraft contributors | original editable CAD scene and authoring metadata; no external assets | MIT OR Apache-2.0 |
| examples/studio/coffee-exploded-sweep.json | SolidCraft contributors | original editable CAD scene and authoring metadata; no external assets | MIT OR Apache-2.0 |
| examples/studio/coffee-exploded-render.json | SolidCraft contributors | original editable CAD scene and authoring metadata; no external assets | MIT OR Apache-2.0 |
| examples/studio/coffee-exploded.json | SolidCraft contributors | original editable CAD scene and authoring metadata; no external assets | MIT OR Apache-2.0 |
| examples/studio/stage1-sweep.json | SolidCraft contributors | original tangent circular-arc CAD cyclorama; no external assets | MIT OR Apache-2.0 |
| examples/studio/stage1.json | SolidCraft contributors | original editable CAD studio scene; no imported geometry or external image assets | MIT OR Apache-2.0 |
| examples/studio/stage1-render.json | SolidCraft contributors | original studio camera and lighting parameters | MIT OR Apache-2.0 |
| examples/render-studio.json | SolidCraft contributors | original procedural box/cylinder enclosure script for the integrated Render workspace | MIT OR Apache-2.0 |
| examples/render-opaque-studio.json | SolidCraft contributors | original procedural sphere study varying opaque metal fraction and roughness independently; no external assets or engineering data | MIT OR Apache-2.0 |
| crates/ui-egui/src/pbr_gpu.rs | SolidCraft contributors | original WGSL translation of SolidCraft's opaque surface and fixed studio reference, embedded as Rust source; no external shader code | MIT OR Apache-2.0 |
| assets/archive/icons/macos-v1/exports/icon.svg | SolidCraft contributors | original macOS adaptation of the code-drawn SolidCraft mark, edited and exported with VectorCraft; Apple HIG and production templates consulted as layout references only | MIT OR Apache-2.0 |
| examples/render-path-studio.json | SolidCraft contributors | original procedural opaque enclosure, teal insert and metal fasteners for progressive rendering; no imported models | MIT OR Apache-2.0 |
| crates/gpu/src/path/path.wgsl | SolidCraft contributors | original software BVH traversal and path integration using the existing SolidCraft surface/studio equations; no external shader source | MIT OR Apache-2.0 |
| crates/gpu/src/pbr.rs | SolidCraft contributors | original WGSL translation of SolidCraft's opaque surface and fixed studio reference, embedded as Rust source; no external shader code | MIT OR Apache-2.0 |
| assets/icons/app/macos/source/SolidCraft.icon/icon.json | SolidCraft contributors | approved Icon Composer composition and appearance settings for the original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/app/macos/source/SolidCraft.icon/Assets/02-grid.svg | SolidCraft contributors | original perspective grid with elliptical radial alpha, exported from VectorCraft | MIT OR Apache-2.0 |
| assets/icons/app/macos/source/SolidCraft.icon/Assets/02-grid-dark.svg | SolidCraft contributors | original blue perspective grid with elliptical radial alpha, exported from VectorCraft for the Dark appearance | MIT OR Apache-2.0 |
| assets/icons/app/macos/source/SolidCraft.icon/Assets/03-solid-blue-cutout.svg | SolidCraft contributors | original saturated blue CAD solid with transparent topology cutouts, edited and exported with VectorCraft | MIT OR Apache-2.0 |
| assets/icons/app/macos/exports/Assets.car | SolidCraft contributors | Apple actool compilation of assets/icons/app/macos/source/SolidCraft.icon for macOS; contains original artwork only | MIT OR Apache-2.0 |
| assets/archive/icons/macos-v1/source/icon.vectorcraft | SolidCraft contributors | editable VectorCraft source for assets/archive/icons/macos-v1/exports/icon.svg, with named face and background layers | MIT OR Apache-2.0 |
| assets/archive/icons/macos-v1/exports/icon.png | SolidCraft contributors | 1024 × 1024 transparent sRGB export of assets/archive/icons/macos-v1/exports/icon.svg from VectorCraft | MIT OR Apache-2.0 |
| assets/archive/icons/macos-v1/source/icon.vectorcraft | SolidCraft contributors | retained editable historical VectorCraft source, with named face and background layers; redundant exports retired | MIT OR Apache-2.0 |
| assets/icons/app/macos/exports/icon.icns | SolidCraft contributors | macOS fallback compiled by Apple's actool from the approved assets/icons/app/macos/source/SolidCraft.icon composition | MIT OR Apache-2.0 |
| assets/archive/icons/macos-v1/exports/icon.iconset/icon_16x16.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 |
| assets/archive/icons/macos-v1/exports/icon.iconset/icon_16x16@2x.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 |
| assets/archive/icons/macos-v1/exports/icon.iconset/icon_32x32.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 |
| assets/archive/icons/macos-v1/exports/icon.iconset/icon_32x32@2x.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 |
| assets/archive/icons/macos-v1/exports/icon.iconset/icon_128x128.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 |
| assets/archive/icons/macos-v1/exports/icon.iconset/icon_128x128@2x.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 |
| assets/archive/icons/macos-v1/exports/icon.iconset/icon_256x256.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 |
| assets/archive/icons/macos-v1/exports/icon.iconset/icon_256x256@2x.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 |
| assets/archive/icons/macos-v1/exports/icon.iconset/icon_512x512.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 |
| assets/archive/icons/macos-v1/exports/icon.iconset/icon_512x512@2x.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 |
| assets/models/test_boat.bin | 3DBenchy.com | 3DBenchy.com | Public Domain |
| assets/models/make_test_boat.py | SolidCraft contributors | original script that packs a model into the compact file above | MIT OR Apache-2.0 |
| docs/screenshots/class-a-blend.png | SolidCraft contributors | screenshot of SolidCraft itself: two boxes bridged by `surface.blend` (a 40x10 sheet, `solidcraft-cli snapshot` of plan/evidence script ev_blend) | MIT OR Apache-2.0 |
| docs/screenshots/class-a-crown.png | SolidCraft contributors | screenshot of SolidCraft itself: a patch crowned with `surface.patch crown=0.3` over a saddle rim (`solidcraft-cli snapshot`) | MIT OR Apache-2.0 |
| docs/screenshots/class-a-boundary.png | SolidCraft contributors | screenshot of SolidCraft itself: a patch through two sketch lines built by `surface.boundary` with `crown: 0.4` (`solidcraft-cli snapshot`) | MIT OR Apache-2.0 |
| docs/screenshots/sample-plate.png | SolidCraft contributors | screenshot of SolidCraft itself (`solidcraft --sample`) | MIT OR Apache-2.0 |
| assets/samples/exports/0.png | SolidCraft contributors | original Sample Plate from `crates/engine/src/sample.rs`, exported by the SolidCraft thumbnail renderer | MIT OR Apache-2.0 |
| assets/samples/exports/1.png | SolidCraft contributors | original Sheet Metal Bracket from `crates/engine/src/sample.rs`, exported by the SolidCraft thumbnail renderer | MIT OR Apache-2.0 |
| assets/samples/exports/2.png | SolidCraft contributors | original Hinge Assembly from `crates/engine/src/sample.rs`, exported by the SolidCraft thumbnail renderer | MIT OR Apache-2.0 |
| assets/samples/exports/3.png | SolidCraft contributors | original Plastic Enclosure from `crates/engine/src/sample.rs`, exported by the SolidCraft thumbnail renderer | MIT OR Apache-2.0 |
| assets/samples/exports/4.png | SolidCraft contributors | original Bolted Flange from `crates/engine/src/sample.rs`, exported by the SolidCraft thumbnail renderer | MIT OR Apache-2.0 |
| assets/samples/exports/5.png | SolidCraft contributors | original Vent Plate from `crates/engine/src/sample.rs`, exported by the SolidCraft thumbnail renderer | MIT OR Apache-2.0 |
| assets/samples/exports/scripts.json | SolidCraft contributors | SHA-256 fingerprints of the original sample scripts, generated with the shipped thumbnails | MIT OR Apache-2.0 |
| docs/screenshots/demo-0.8.gif | SolidCraft contributors | original screen recording of the actual SolidCraft 0.8 English desktop UI modelling an original Raspberry Pi 4 case (no logos or branding; the engraved text is set in the system's DejaVu Sans Bold), generated with examples/demo/record_ui.sh and its original animated pointer overlay (9× playback) | MIT OR Apache-2.0 |
| docs/screenshots/demo-0.8.mp4 | SolidCraft contributors | original screen recording of the actual SolidCraft 0.8 English desktop UI modelling an original Raspberry Pi 4 case (no logos or branding; the engraved text is set in the system's DejaVu Sans Bold), generated with examples/demo/record_ui.sh and its original animated pointer overlay (9× playback) | MIT OR Apache-2.0 |
| docs/screenshots/rover-build.gif | 0xlillith (model); SolidCraft contributors (recording) | screen recording of the actual SolidCraft desktop UI replaying the timeline of the SELENE rover (`Rover-recorded.solidcraft` from https://github.com/0xlillith/solidcraft-rover, an original model by 0xlillith built through SolidCraft MCP); the model's lettering follows Ubuntu Light outlines (Canonical Ltd., Ubuntu Font Licence 1.0, as noted in that repository); the emblem is the SolidCraft project icon (2× playback) | MIT OR Apache-2.0 |
| docs/screenshots/rover-build.mp4 | 0xlillith (model); SolidCraft contributors (recording) | screen recording of the actual SolidCraft desktop UI replaying the timeline of the SELENE rover (`Rover-recorded.solidcraft` from https://github.com/0xlillith/solidcraft-rover, an original model by 0xlillith built through SolidCraft MCP); the model's lettering follows Ubuntu Light outlines (Canonical Ltd., Ubuntu Font Licence 1.0, as noted in that repository); the emblem is the SolidCraft project icon | MIT OR Apache-2.0 |
| docs/design/ux-redesign/model-floating.png | SolidCraft contributors | original UX mockup (Model tab, floating layout) rendered from an HTML mockup that uses the original Technical Accent icon set; no third-party artwork, and not a screenshot of any other CAD product | MIT OR Apache-2.0 |
| docs/design/ux-redesign/model-compact.png | SolidCraft contributors | original UX mockup (Model tab, compact docked layout) rendered from an HTML mockup that uses the original Technical Accent icon set; no third-party artwork, and not a screenshot of any other CAD product | MIT OR Apache-2.0 |
| docs/design/ux-redesign/sketch.png | SolidCraft contributors | original UX mockup (Sketch tab) rendered from an HTML mockup that uses the original Technical Accent icon set; no third-party artwork, and not a screenshot of any other CAD product | MIT OR Apache-2.0 |
| docs/design/ux-redesign/sheet-metal.png | SolidCraft contributors | original UX mockup (Sheet Metal contextual tab) rendered from an HTML mockup that uses the original Technical Accent icon set; no third-party artwork, and not a screenshot of any other CAD product | MIT OR Apache-2.0 |
| docs/design/ux-redesign/start-page.png | SolidCraft contributors | original UX mockup (start page (File view)) rendered from an HTML mockup that uses the original Technical Accent icon set; no third-party artwork, and not a screenshot of any other CAD product | MIT OR Apache-2.0 |
| docs/design/ux-redesign/workspaces.png | SolidCraft contributors | original UX mockup (workspace switcher) rendered from an HTML mockup that uses the original Technical Accent icon set; no third-party artwork, and not a screenshot of any other CAD product | MIT OR Apache-2.0 |
| docs/design/ux-redesign/command-map.png | SolidCraft contributors | original UX mockup (ribbon command map) rendered from an HTML mockup that uses the original Technical Accent icon set; no third-party artwork, and not a screenshot of any other CAD product | MIT OR Apache-2.0 |
| assets/branding/web/source/og.png | SolidCraft contributors | original 1280 × 640 social preview card (the SolidCraft mark, name and tagline), served by the docs site for link previews | MIT OR Apache-2.0 |
| docs/book/src/generated/sample-plate.png | SolidCraft contributors | copy of docs/screenshots/sample-plate.png made by `cargo xtask book` (gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/bracket.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot examples/bracket.json`; gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/enclosure.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/enclosure.json`; gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/revolve.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/revolve.json`; gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/sheet-metal.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/sheet-metal.json`; gitignored) | MIT OR Apache-2.0 |
| assets/fonts/ibm-plex-mono/IBMPlexMono-Regular.ttf | IBM Corp. (IBM Plex Mono) | https://github.com/IBM/plex (via google/fonts), unmodified; licence text in assets/fonts/ibm-plex-mono/OFL.txt; the interface monospace font | SIL OFL 1.1 |
| assets/fonts/ibm-plex-sans/IBMPlexSans-Variable.ttf | IBM Corp. (IBM Plex Sans, variable wdth and wght) | https://github.com/IBM/plex (via google/fonts), unmodified; licence text in assets/fonts/ibm-plex-sans/OFL.txt; the default interface font | SIL OFL 1.1 |
| docs/book/src/generated/tool-preview.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/tool-preview.json`; gitignored) | MIT OR Apache-2.0 |
| assets/fonts/liberation-sans/LiberationSans-Regular.ttf | Red Hat, Inc. (Liberation Fonts 2.x; digitized data Google) | https://github.com/liberationfonts (Debian fonts-liberation), unmodified; licence text in assets/fonts/liberation-sans/OFL.txt; used for sketch Text outlines | SIL OFL 1.1 |
| assets/fonts/biz-udp-gothic/BIZUDPGothic-Regular.ttf | The BIZ UDGothic Project Authors | https://github.com/googlefonts/morisawa-biz-ud-gothic (Google Fonts), unmodified; licence text in assets/fonts/biz-udp-gothic/OFL.txt; used for web UI Japanese font | SIL OFL 1.1 |
| tests/step-conformance/cone_point.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
| tests/step-conformance/cone_point_vertex.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
| tests/step-conformance/cylinder.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
| tests/step-conformance/cylinder_inch.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
| tests/step-conformance/cylinder_nurbs.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
| tests/step-conformance/cylinder_placed.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
| tests/step-conformance/cylinder_seam.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
| tests/step-conformance/cylinder_twice.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
| tests/step-conformance/dome.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
| tests/step-conformance/dome_nurbs.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
| tests/step-conformance/far_cylinder.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
| tests/step-conformance/grooved_shaft.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
| tests/step-conformance/plate_100_holes.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
| tests/step-conformance/sheet_disc.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
| tests/step-conformance/tiny_pin.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
| tests/step-conformance/torus_ring_nurbs.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
| tests/step-conformance/washer.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
| tests/oracle/17-loft/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/17-loft/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/23-bracket-multifeature/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/23-bracket-multifeature/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/28-blind-pocket-fillets/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/28-blind-pocket-fillets/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
@@ -69,9 +109,11 @@ Editable drawing code, native projects and shipped exports are collected under `
| tests/oracle/61-shaft-keyway/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/61-shaft-keyway/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/63-hinge-knuckle/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/63-hinge-knuckle/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/65-sketch-gear-tooth/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/65-sketch-gear-tooth/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| assets/appearances/library.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/appearances/starter_pack.rs | SolidCraft contributors | original illustrative optical recipes, authored scalar values and future procedural resource descriptors; no measured or external material dataset | MIT OR Apache-2.0 |
| assets/fonts/biz-udp-gothic/OFL.txt | The BIZ UDGothic Project Authors | Unmodified accompanying font licence | SIL OFL 1.1 |
| assets/fonts/ibm-plex-mono/OFL.txt | IBM Corp. | Unmodified accompanying font licence | SIL OFL 1.1 |
| assets/fonts/ibm-plex-sans/OFL.txt | IBM Corp. | Unmodified accompanying font licence | SIL OFL 1.1 |
| assets/fonts/liberation-sans/OFL.txt | Red Hat, Inc.; Liberation Fonts contributors | Unmodified accompanying font licence | SIL OFL 1.1 |
| assets/icons/app/linux/exports/hicolor/128x128/apps/ai.storyteller.solidcraft.png | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
| assets/icons/app/linux/exports/hicolor/16x16/apps/ai.storyteller.solidcraft.png | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
@@ -81,23 +123,39 @@ Editable drawing code, native projects and shipped exports are collected under `
| assets/icons/app/linux/exports/hicolor/48x48/apps/ai.storyteller.solidcraft.png | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
| assets/icons/app/linux/exports/hicolor/512x512/apps/ai.storyteller.solidcraft.png | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
| assets/icons/app/linux/exports/hicolor/64x64/apps/ai.storyteller.solidcraft.png | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
| assets/icons/app/shared/source/app_icon.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/icons/app/shared/source/app_icon.rs | SolidCraft contributors | Original SolidCraft drawing code: the borderless mark, drawn from the faces and gradients of assets/icons/app/macos/source/SolidCraft.icon/Assets/03-solid-blue-cutout.svg | MIT OR Apache-2.0 |
| assets/icons/app/shared/source/windows_ico.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/icons/app/windows/exports/solidcraft-executable.ico | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
| assets/icons/app/windows/exports/solidcraft.ico | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
| assets/icons/ui/source/glyphs.rs | SolidCraft / VectorCraft contributors | Original glyphs; caption geometry adapted from VectorCraft titlebar.rs (https://github.com/storytold/vectorcraft) | MIT OR Apache-2.0 |
| assets/icons/ui/source/icons.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/icons/ui/source/markers.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/icons/ui/source/navigation.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/icons/ui/source/pointer.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/locales/cs.json | potuzai | Czech translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/da.json | SolidCraft contributors | Original Danish translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
| assets/locales/de.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/locales/en-GB.json | SolidCraft contributors | English (UK): the English catalog's messages whose spelling differs, in UK spelling | MIT OR Apache-2.0 |
| assets/locales/en.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/locales/es.json | jawler | Spanish (Spain) translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/fr.json | rlartot | French translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/gl.json | Pablo López (pablopeza) | Galician translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/hu.json | SolidCraft contributors | Original Hungarian translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
| assets/locales/id.json | SolidCraft contributors | Original Indonesian translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
| assets/locales/it.json | SolidCraft contributors | Original Italian translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
| assets/locales/ja.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/locales/ko.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/locales/ms.json | A.Hakim (a-hakim) | Malay translation of the English message catalog | MIT OR Apache-2.0 |
| assets/locales/nl.json | SolidCraft contributors | Original Dutch translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
| assets/locales/pl.json | cyryllo | Polish translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/ru.json | Alex Pro (del000) | Russian translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/sv.json | SolidCraft contributors | Original Swedish translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
| assets/locales/pt-BR.json | DuBearte | Brazilian Portuguese translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/ro.json | SolidCraft contributors | Original Romanian translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
| assets/locales/tr.json | macrulz54 | Turkish translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/uk.json | Igor Bronovskyi (BrunIF) | Ukrainian translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/vi.json | vandang890615 | Vietnamese translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/zh.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/locales/zh-TW.json | SolidCraft contributors | Original Traditional Chinese (Taiwan) translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
| assets/materials/library.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/platforms/linux/ai.storyteller.solidcraft.desktop | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
| assets/platforms/linux/ai.storyteller.solidcraft.metainfo.xml.in | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
@@ -108,9 +166,340 @@ Editable drawing code, native projects and shipped exports are collected under `
| assets/platforms/windows/solidcraft.wxs | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
| assets/shaders/viewport.wgsl | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/styles/ui/palettes.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/styles/ui/themes/gruvbox-dark.json | SolidCraft contributors | original SolidCraft theme file mapping SolidCraft's UI colours to the Gruvbox palette's colour values (Gruvbox by Pavel Pertsev, https://github.com/morhetz/gruvbox, MIT) | MIT OR Apache-2.0 |
| assets/styles/ui/themes/gruvbox-dark-hard.json | SolidCraft contributors | original SolidCraft theme file mapping SolidCraft's UI colours to the Gruvbox palette's colour values (Gruvbox by Pavel Pertsev, https://github.com/morhetz/gruvbox, MIT) | MIT OR Apache-2.0 |
| assets/styles/ui/themes/gruvbox-dark-soft.json | SolidCraft contributors | original SolidCraft theme file mapping SolidCraft's UI colours to the Gruvbox palette's colour values (Gruvbox by Pavel Pertsev, https://github.com/morhetz/gruvbox, MIT) | MIT OR Apache-2.0 |
| assets/styles/ui/themes/gruvbox-light.json | SolidCraft contributors | original SolidCraft theme file mapping SolidCraft's UI colours to the Gruvbox palette's colour values (Gruvbox by Pavel Pertsev, https://github.com/morhetz/gruvbox, MIT) | MIT OR Apache-2.0 |
| assets/styles/ui/themes/gruvbox-light-hard.json | SolidCraft contributors | original SolidCraft theme file mapping SolidCraft's UI colours to the Gruvbox palette's colour values (Gruvbox by Pavel Pertsev, https://github.com/morhetz/gruvbox, MIT) | MIT OR Apache-2.0 |
| assets/styles/ui/themes/gruvbox-light-soft.json | SolidCraft contributors | original SolidCraft theme file mapping SolidCraft's UI colours to the Gruvbox palette's colour values (Gruvbox by Pavel Pertsev, https://github.com/morhetz/gruvbox, MIT) | MIT OR Apache-2.0 |
| assets/styles/ui/themes/solidcraft-95.json | SolidCraft contributors | original SolidCraft theme file | MIT OR Apache-2.0 |
| assets/styles/ui/themes/platinum-9.json | SolidCraft contributors | original SolidCraft theme file | MIT OR Apache-2.0 |
| assets/styles/ui/themes/cupertino.json | SolidCraft contributors | original SolidCraft theme file | MIT OR Apache-2.0 |
| assets/styles/ui/themes/green-phosphor.json | SolidCraft contributors | original SolidCraft theme file | MIT OR Apache-2.0 |
| assets/styles/ui/themes/amber-phosphor.json | SolidCraft contributors | original SolidCraft theme file | MIT OR Apache-2.0 |
| assets/styles/ui/themes/blueprint.json | SolidCraft contributors | original SolidCraft theme file | MIT OR Apache-2.0 |
| assets/styles/ui/themes/one-dark.json | SolidCraft contributors | original SolidCraft theme file with colours adapted from the One Dark palette (Atom One Dark by GitHub Atom, https://github.com/atom/atom, MIT) | MIT OR Apache-2.0 |
| assets/styles/ui/themes/tokyo-night.json | SolidCraft contributors | original SolidCraft theme file adapting the colours of a published palette (Tokyo Night by enkia, https://github.com/enkia/tokyo-night-vscode-theme, MIT) | MIT OR Apache-2.0 |
| assets/styles/ui/themes/solarized-light.json | SolidCraft contributors | original SolidCraft theme file adapting the colours of a published palette (Solarized by Ethan Schoonover, https://github.com/altercation/solarized, MIT) | MIT OR Apache-2.0 |
| assets/styles/ui/themes/nord.json | SolidCraft contributors | original SolidCraft theme file adapting the colours of a published palette (Nord by Sven Greb and the Nord contributors, https://github.com/nordtheme/nord, MIT) | MIT OR Apache-2.0 |
| assets/styles/ui/themes/high-contrast.json | SolidCraft contributors | original SolidCraft theme file | MIT OR Apache-2.0 |
| assets/styles/ui/themes/graphite.json | SolidCraft contributors | original SolidCraft theme file | MIT OR Apache-2.0 |
| assets/styles/ui/themes/nosferatu.json | SolidCraft contributors | original SolidCraft theme file adapting the colours of a published palette (Dracula by Zeno Rocha and the Dracula Theme contributors, https://github.com/dracula/dracula-theme, MIT) | MIT OR Apache-2.0 |
| assets/styles/ui/themes/catppuccin-mocha.json | SolidCraft contributors | original SolidCraft theme file adapting the colours of a published palette (Catppuccin by the Catppuccin organisation, https://github.com/catppuccin/catppuccin, MIT) | MIT OR Apache-2.0 |
| assets/styles/web/loading-logo.svg | SolidCraft contributors | Animation of the SolidCraft mark, built from the paths and gradients of assets/icons/app/macos/source/SolidCraft.icon/Assets/03-solid-blue-cutout.svg; original animation | MIT OR Apache-2.0 |
| assets/styles/web/loading.css | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| crates/io/src/native_cad/fixtures/block.FCStd | SolidCraft contributors | original 10 × 20 × 30 mm test box saved by FreeCAD 1.1.4 run as an external program (`make_block_fcstd.py` beside it); no third-party geometry | MIT OR Apache-2.0 |
| crates/io/src/native_cad/fixtures/block.3dm | SolidCraft contributors | original 10 × 20 × 30 mm test box written with McNeel's MIT-licensed rhino3dm Python package (`make_block_3dm.py` beside it); no third-party geometry | MIT OR Apache-2.0 |
| assets/icons/ui/design/aliases.toml | SolidCraft contributors | icon ids that share a master, original SolidCraft data | MIT OR Apache-2.0 |
| assets/icons/ui/design/palette.toml | SolidCraft contributors | icon colour roles (light and dark) and accents, original SolidCraft data | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/bug_report.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/chevron.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/chevron_right.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/close.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/disclosure_closed.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/disclosure_open.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/fit.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/folder.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/help.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/home.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/lookat.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/moon.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/nav_settings.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/orbit.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/plus.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/pan.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/perspective.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/redo.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/save.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/settings.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/settings_keyboard.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/settings_monitor.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/settings_mouse.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/settings_palette.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/settings_plug.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/spin.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/style.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/undo.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/zoom.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/accessibility.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/activate.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/align.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/appearance.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/arc3.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/arc_center.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/arc_slot.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/arc_tangent.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/as_built.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/auto_constrain.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/axis.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/bendtable.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/blend.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/blend_surface.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/body.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/boss.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/boundary.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/boundary_fill.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/box.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/break.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/c_coincident.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/c_collinear.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/c_concentric.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/c_equal.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/c_fix.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/c_hv.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/c_midpoint.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/c_parallel.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/c_perpendicular.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/c_polygon.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/c_smooth.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/c_symmetry.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/c_tangent.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/camera.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/canvas.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/capture.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/center_of_mass.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/centerline.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/chain.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/chamfer.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/checkpoint.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/circle.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/circle2.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/circle3.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/circle_tt.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/circle_ttt.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/coil.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/combine.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/component.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/compute.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/configuration_table.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/conic.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/constrainer.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/construction.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/convert_sheet.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/copy.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/curvature_comb.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/curvature_map.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/cylinder.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/decal.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/delete.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/dimension.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/dots.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/draft.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/draft_analysis.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/drive.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/dxf.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/edge_continuity.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/edit.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/ellipse.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/emboss.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/environment_map.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/explode.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/export.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/extend.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/extrude.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/eye.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/fastener.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/fillet.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/filter.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/finish.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/fit_section.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/flange.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/flat.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/fold.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/folder.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/graph.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/hem.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/hole.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/import.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/include.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/inspect.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/interference.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/intersect.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/intersection_curve.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/iso_analysis.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/iso_curve.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/joint.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/joint_origin.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/library.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/line.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/line_mid.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/lip.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/list.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/loft.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/material.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/measure.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/min_radius.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/mirror.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/motion_link.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/move.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/new.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/offset.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/offset_face.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/offset_surface.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/open.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/origin.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/params.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/paste.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/patch.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/pattern.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/pattern_circ.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/pattern_path.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/pattern_rect.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/perforate.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/pipe.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/silhouette.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/assistant.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/agent_link.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/offset_two_sides.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/offset_symmetric.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/import_tray.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/export_tray.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/send.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/stop.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/shield.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/status_recovery.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/frame_member.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/placeholder.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/plane.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/plane_angle.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/plastic_assign.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/plastic_rule.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/play.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/point.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/polygon.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/presspull.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/project.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/project_surface.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/rect.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/rect3.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/rect_center.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/rect_rounded.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/refold.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/rename.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/render_image.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/render_scene.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/reorder.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/rest.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/restore.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/reverse_normal.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/revert.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/revolve.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/rib.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/rigid_group.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/rollback.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/ruled.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/scale.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/section.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/select.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/sheet.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/sheet_rules.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/shell.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/sketch.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/sketch_chamfer.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/sketch_fillet.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/sketch_mirror.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/slot.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/snap.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/sphere.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/spline.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/spline_cv.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/split_body.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/split_face.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/spun.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/star.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/status_error.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/status_warning.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/stitch.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/suppress.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/surface.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/svg.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/sweep.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/text.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/thicken.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/thread.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/torus.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/trim.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/unfold.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/unstitch.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/untrim.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/update.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/zebra.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/small/as_built.svg | SolidCraft contributors | small-slot icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/small/component.svg | SolidCraft contributors | small-slot icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/small/curvature_comb.svg | SolidCraft contributors | small-slot icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/small/interference.svg | SolidCraft contributors | small-slot icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/small/iso_analysis.svg | SolidCraft contributors | small-slot icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/small/joint.svg | SolidCraft contributors | small-slot icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/small/pattern.svg | SolidCraft contributors | small-slot icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/small/section.svg | SolidCraft contributors | small-slot icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/workspace/ws_animate.svg | SolidCraft contributors | workspace icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/polygon_circumscribed.svg | SolidCraft contributors | original sketch variant icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/polygon_edge.svg | SolidCraft contributors | original sketch variant icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/slot_overall.svg | SolidCraft contributors | original sketch variant icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/slot_center.svg | SolidCraft contributors | original sketch variant icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/arc_slot_center.svg | SolidCraft contributors | original sketch variant icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/sketch_chamfer_angle.svg | SolidCraft contributors | original sketch variant icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/sketch_chamfer_two_distances.svg | SolidCraft contributors | original sketch variant icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/gear.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/rack_pinion.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/screw.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/cam.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/trace.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/agent_chat.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/normal.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/infinite.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/pattern_flip.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/pattern_center.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/reach.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/cut.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/compare.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/web.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/replace_face.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/bounding_solid.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/delete_face.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/snap_fit.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/contact_sets.svg | SolidCraft contributors | original contact icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/contact_all.svg | SolidCraft contributors | original contact icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/contact_off.svg | SolidCraft contributors | original contact icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/contact_new.svg | SolidCraft contributors | original contact icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/axis_through_cylinder.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/axis_normal_to_face.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/axis_two_planes.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/axis_two_points.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/axis_edge.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/point_vertex.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/point_two_edges.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/point_three_planes.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/point_center.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/point_edge_plane.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/point_along_path.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/plane_midplane.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/plane_two_edges.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/plane_three_points.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/plane_tangent.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/plane_along_path.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/plane_perpendicular.svg | SolidCraft contributors | original construction icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/workspace/ws_design.svg | SolidCraft contributors | workspace icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/workspace/ws_drawing.svg | SolidCraft contributors | workspace icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/workspace/ws_render.svg | SolidCraft contributors | workspace icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/references.toml | SolidCraft contributors | original icon authoring or review tool resource | MIT OR Apache-2.0 |
| assets/icons/ui/design/tools/author/bases.py | SolidCraft contributors | original icon authoring or review tool resource | MIT OR Apache-2.0 |
| assets/icons/ui/design/tools/author/new_icon.py | SolidCraft contributors | original icon authoring or review tool resource | MIT OR Apache-2.0 |
| assets/icons/ui/design/tools/review/index.html | SolidCraft contributors | original icon authoring or review tool resource | MIT OR Apache-2.0 |
| assets/icons/ui/design/tools/review/guide.html | SolidCraft contributors | original icon authoring or review tool resource | MIT OR Apache-2.0 |
| assets/icons/ui/design/tools/review/review.css | SolidCraft contributors | original icon authoring or review tool resource | MIT OR Apache-2.0 |
| assets/icons/ui/design/tools/review/review.js | SolidCraft contributors | original icon authoring or review tool resource | MIT OR Apache-2.0 |
| assets/icons/ui/design/tools/icon_palette.py | SolidCraft contributors | Original shared semantic icon palette resolver for repository tools | MIT OR Apache-2.0 |
| assets/icons/ui/design/tools/icon_status.py | SolidCraft contributors | icon lint, status page or preview tool (standard-library Python), original SolidCraft code | MIT OR Apache-2.0 |
| assets/icons/ui/design/tools/lint_icons.py | SolidCraft contributors | icon lint, status page or preview tool (standard-library Python), original SolidCraft code | MIT OR Apache-2.0 |
| assets/icons/ui/design/tools/preview.py | SolidCraft contributors | icon lint, status page or preview tool (standard-library Python), original SolidCraft code | MIT OR Apache-2.0 |
| assets/icons/ui/design/tools/review_state.py | SolidCraft contributors | icon review state helper (standard-library Python), original SolidCraft code | MIT OR Apache-2.0 |
| assets/icons/ui/design/requested_icons.txt | SolidCraft contributors | Runtime icon requests collected by the headless menu audit, original SolidCraft data | MIT OR Apache-2.0 |
| assets/icons/ui/design/review.json | SolidCraft contributors | icon review state (accepted icons and notes), original SolidCraft data | MIT OR Apache-2.0 |
| assets/icons/ui/design/tools/author/lib.py | SolidCraft contributors | icon authoring script (standard-library Python), original SolidCraft code | MIT OR Apache-2.0 |
| docs/book/src/og.png | SolidCraft contributors | copy of assets/branding/web/source/og.png staged by cargo xtask book (gitignored) | MIT OR Apache-2.0 |
| docs/sponsors/blacksmith-ci-powered.png | Blacksmith (blacksmith.sh) | "CI powered by Blacksmith" sponsor banner supplied by Blacksmith for the sponsorship credit; used only to credit the sponsor, not part of the application | © Blacksmith, used with permission |
## Code adapted from sibling projects
@@ -139,6 +528,27 @@ work cooperative and adds checked run lengths, zero-run rejection, width validat
header/input/radiance bounds and cancellation. Original licence texts are retained
in THIRD-PARTY-LICENSES.txt; no images or artwork from that project are copied.
## File-format crates
| Crate | Used for | Licence |
|---|---|---|
| gltf 1.4.1, gltf-json 1.4.1, gltf-derive 1.4.1 (https://github.com/gltf-rs/gltf) | glTF 2.0 import (crates/io/src/gltf_in.rs): the JSON document model and its reference checks; the GLB container, buffers and accessors are read by SolidCraft's own code | MIT OR Apache-2.0 |
| inflections 1.1.1 (build-time, through gltf-derive) | case conversion in gltf's derive macros | MIT |
| lazy_static 1.5.1 (through gltf-json) | | MIT OR Apache-2.0 |
The glTF test files are built by the tests themselves (crates/io/src/gltf_in_tests.rs,
crates/engine/src/tests/gltf_import.rs); no third-party models are committed.
## FreeCAD and Rhino import library
FreeCAD `.FCStd` and Rhino `.3dm` files are read with [cadmpeg](https://github.com/cadmpeg/cadmpeg)
0.6.3 (Apache-2.0), linked as the crates `cadmpeg-core`, `cadmpeg-ir`, `cadmpeg-registry`,
`cadmpeg-container`, `cadmpeg-codec-freecad`, `cadmpeg-codec-rhino` and `cadmpeg-codec-step`
in native and browser builds (`crates/io/src/native_cad.rs`). Only those open-format codecs are built: no
default features, so cadmpeg's codecs for commercial CAD formats and its ShapeManager/ACIS
codec are not compiled in. Its licence text is in THIRD-PARTY-LICENSES.txt; no code from it is
copied into SolidCraft.
## Recovered contributions
| SolidCraft file | Original contribution | Licence |
@@ -149,9 +559,16 @@ in THIRD-PARTY-LICENSES.txt; no images or artwork from that project are copied.
| Directory | Source | Licence | Changes |
|---|---|---|---|
| examples/studio/author_sweep.py | SolidCraft contributors | original editable CAD scene and authoring metadata; no external assets | MIT OR Apache-2.0 |
| examples/studio/author_scene.py | SolidCraft contributors | original editable CAD scene and authoring metadata; no external assets | MIT OR Apache-2.0 |
| examples/studio/materials.json | SolidCraft contributors | original editable CAD scene and authoring metadata; no external assets | MIT OR Apache-2.0 |
| examples/studio/coffee-exploded-sweep.json | SolidCraft contributors | original editable CAD scene and authoring metadata; no external assets | MIT OR Apache-2.0 |
| examples/studio/coffee-exploded-render.json | SolidCraft contributors | original editable CAD scene and authoring metadata; no external assets | MIT OR Apache-2.0 |
| examples/studio/coffee-exploded.json | SolidCraft contributors | original editable CAD scene and authoring metadata; no external assets | MIT OR Apache-2.0 |
| vendor/truck-meshalgo | truck-meshalgo 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | meshing: bounded parameter searches, collapsed-boundary projection, subdivision and provisional fixed-grid previews (vendor/README.md) |
| vendor/truck-stepio | truck-stepio 0.3.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | STEP writer: intersection-curve surfaces written at unique ids (vendor/README.md) |
| vendor/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; degenerate intersection-curve rejection; face and edge history of booleans; trace macro; corner choice at vertices several intersection curves end on (vendor/README.md) |
| vendor/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; degenerate intersection-curve rejection; face and edge history of booleans; trace macro; corner choice at vertices several intersection curves end on; reuse of an operand's meshes (vendor/README.md) |
| vendor/truck-geometry | truck-geometry 0.5.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | intersection curves divided from their neighbours' surface parameters; surface second derivatives (vendor/README.md) |
| vendor/matext4cgmath | matext4cgmath 0.1.0, https://github.com/ricosjp/matext4cgmath (RICOS Co. Ltd.) | Apache-2.0 | root refinement stops after 100 Newton steps (vendor/README.md) |
## Vendored renderer compatibility fix
@@ -162,3 +579,79 @@ its original `egui.wgsl` shader are retained; no images or other artwork are cop
SolidCraft's narrow patch selects a safe 1x MSAA path on the chosen OpenGL adapter
and shares that effective count with the surface attachments and CAD callbacks.
Vulkan, DirectX 12, Metal and WebGPU retain their requested multisampling.
A frame whose meshes the GPU refuses to stage (device lost or out of memory) is
skipped with a warning instead of panicking (#1469).
Paint callbacks are prepared only after a drawable is acquired, so a skipped
surface frame cannot strand cached meshes without their GPU initialization.
## Runtime studio lighting mathematics
`crates/gpu/src/path/path.wgsl` implements the power-heuristic estimator from the
mathematical description in [PBRT fourth edition, section 13.4](https://pbr-book.org/4ed/Light_Transport_I_Surface_Reflection/A_Better_Path_Tracer).
No PBRT code or assets were copied. `crates/render/src/job/lighting.rs` evaluates
[Krzysztof Narkowicz's published 2016 ACES rational fit](https://knarkowicz.wordpress.com/2016/01/06/aces-filmic-tone-mapping-curve/)
using its published coefficients (author licenses the fit under CC0/MIT).
These original implementations are under SolidCraft's MIT OR Apache-2.0 licence.
## Original coffee framing variant
| File | Author | Provenance | Licence |
|---|---|---|---|
| examples/studio/author_coffee_framing.py | SolidCraft contributors | original editable composition, camera and documentation; no external assets | MIT OR Apache-2.0 |
| examples/studio/coffee-framing.json | SolidCraft contributors | original editable composition, camera and documentation; no external assets | MIT OR Apache-2.0 |
| examples/studio/coffee-framing-sweep.json | SolidCraft contributors | original editable composition, camera and documentation; no external assets | MIT OR Apache-2.0 |
| examples/studio/coffee-framing-render.json | SolidCraft contributors | original editable composition, camera and documentation; no external assets | MIT OR Apache-2.0 |
| examples/studio/coffee-framing.md | SolidCraft contributors | original editable composition, camera and documentation; no external assets | MIT OR Apache-2.0 |
## Original cylindrical coffee housing variant
| File | Author | Provenance | Licence |
|---|---|---|---|
| examples/studio/author_coffee_cylinder.py | SolidCraft contributors | original parametric cylindrical CAD construction and editable command recipes; no external assets | MIT OR Apache-2.0 |
| examples/studio/coffee-cylinder.json | SolidCraft contributors | original cylindrical housing on the original exploded coffee composition; no external assets | MIT OR Apache-2.0 |
| examples/studio/coffee-cylinder-sweep.json | SolidCraft contributors | original cylindrical housing and continuous CAD cyclorama; no external assets | MIT OR Apache-2.0 |
| examples/studio/coffee-cylinder-render.json | SolidCraft contributors | original camera, studio lighting and optical settings retained from the framing variant | MIT OR Apache-2.0 |
| examples/studio/coffee-cylinder.md | SolidCraft contributors | original dimensional recipe, provenance and validation limitations | MIT OR Apache-2.0 |
## Runtime environment sampling
`crates/render/src/lighting/environment.rs` and the environment/sun portions of
`crates/gpu/src/path/path.wgsl` are original SolidCraft implementations of
solid-angle importance sampling and MIS, informed by the mathematical description
in [PBRT fourth edition, sections 12.5](https://pbr-book.org/4ed/Light_Sources/Infinite_Area_Lights)
and [13.4](https://pbr-book.org/4ed/Light_Transport_I_Surface_Reflection/A_Better_Path_Tracer).
The procedural gradient and inline RGBE test fixtures are original mathematical
colours, not downloaded imagery. No third-party environment images or shader code
are included. Licence: MIT OR Apache-2.0.
## Original tumbler showcase assets
The CAD room, tumblers, window opening, wood/weave patterns and RGBE sky below
are original SolidCraft contributor work. No third-party scene, photograph, HDRI,
texture, or AI-generated artwork was used. Python authoring sources are included.
All listed files use the repository MIT OR Apache-2.0 licence.
| File | Source / author | Use | License |
| --- | --- | --- | --- |
| examples/studio/author_showcase.py | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
| examples/studio/author_tumblers.py | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
| examples/studio/textures/ATTRIBUTION.md | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
| examples/studio/textures/author_textures.py | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
| examples/studio/textures/environment-manifest.json | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
| examples/studio/textures/manifest.json | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
| examples/studio/tumblers-hdr-textures-blocked-render.json | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
| examples/studio/tumblers-hdr-textures-render.json | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
| examples/studio/tumblers-materials.json | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
| examples/studio/tumblers-render.json | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
| examples/studio/tumblers-sun-render.json | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
| examples/studio/tumblers-sun-textures-blocked-render.json | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
| examples/studio/tumblers-sun-textures-render.json | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
| examples/studio/tumblers-sun.json | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
| examples/studio/tumblers-textures-blocked-render.json | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
| examples/studio/tumblers-textures-render.json | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
| examples/studio/tumblers.json | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
The original studio generators produce local ignored PNG maps and an RGBE gradient.
Generated image bytes are not source assets; their authorship, licences and expected
hashes are documented under `examples/studio/textures`. Rendered evidence belongs
on the results branch. No photographs, scans or external image assets are included.
+2 -129
View File
@@ -1,130 +1,3 @@
# SolidCraft — instructions for agents
@AGENTS.md
SolidCraft is a clean-room, open-source, pure-Rust parametric 3D CAD application:
sketches with a constraint solver, a parametric timeline of features, a B-rep
kernel, and a desktop UI that agents can drive. Projects published by
[storytold](https://github.com/storytold) can serve as references for implementation conventions.
Any code adapted from other projects (MIT OR Apache-2.0) is noted in ATTRIBUTION.md.
## Product name
SolidCraft everywhere: crates `solidcraft-*`, binaries `solidcraft` and `solidcraft-cli`,
`.solidcraft` designs, the `solidcraft/N` format tag, `SOLIDCRAFT_*` environment variables,
settings and recovery folders, the MCP server name, docs, CI and packaging. The old name
survives only in the explicit compatibility shims listed in `docs/compatibility.md`; don't add
new uses of it anywhere else. Public docs/web/Flatpak deployments use project Pages
`/solidcraft/`; do not add permanent old-name Pages aliases.
## Start every session here
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md`
and the relevant `plan/architecture.md` section.
2. Kernel and library decisions: `plan/adr/0001-geometry-kernel.md` (what we may depend on, what
is rejected for licence reasons).
3. Follow the autonomous operation protocol (`plan/execution-plan.md` §6). Don't stop to ask
unless it lists the decision as the owner's.
`plan/` is gitignored (local only).
## Never crash
People trust SolidCraft with their designs; a crash loses their work. **This outranks feature work.**
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`,
`unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The three exceptions, in the app crate
(which denies it instead), have no safe API: two functions in
`apps/solidcraft/src/open_documents.rs` (macOS hands Finder files only to an Objective-C
method), the Windows-only `apps/solidcraft/src/native_crash.rs` (the exception filter that
records crashes inside native code such as graphics drivers) and the Windows-only
`apps/solidcraft/src/native_frame.rs` (the window procedure that answers the resize-border hit
test of the window without OS decorations).
- **Errors are `Result<T, E>`** through the crate's error type and `?`. An unfinished feature
returns an error ("not supported yet: …"); it never panics.
- **Input-derived numbers are hostile** (command parameters, design files, control-channel
input): `get()` not `[i]`, finite checks, caps on sizes and loop counts.
- **Third-party kernel calls are guarded:** everything that calls `truck` goes through
`solidcraft_kernel::guard`, which turns a panic into `KernelError::Internal`.
- **Last-resort guard:** `Session::execute` runs every command under `catch_unwind`; an escaped
panic restores the design and reports `EngineError::Internal`.
- **Crash reports:** the desktop app writes every panic and every unclean exit to a local crash folder,
with the design and the commands that reproduce it (`solidcraft_engine::crash`, docs/crash-reports.md).
When someone says the app crashed, look there first: `solidcraft-cli crashes`, `crashes show <id>`,
`crashes replay <id>` (or the `diagnostics.crash_reports` command / MCP `crash_reports` tool).
- **Prove it:** every crash fix lands with a regression test (see `hostile_params_never_panic`); a
crash report's `design.solidcraft` + `replay.json` is the test's starting point.
- Every production crate root carries
`#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
## Non-negotiables
- **Independent.** SolidCraft is independent. Behaviour is designed from public documentation,
general CAD conventions and user feedback. Reference tests use analytic values and open-source
tools. Never copy another product's code, icons, artwork, help text, tooltips, templates or
materials. File formats come from public specifications.
- **No commercial CAD products.** Agents must not drive, script, automate or extract data from
commercial CAD products (Fusion, SolidWorks, Onshape, …) for this project: no test data,
measurements, command lists, ids or files produced with them.
- **Never read, copy or link GPL/LGPL/AGPL code**: OpenCASCADE, FreeCAD (incl. planegcs),
SolveSpace, CadQuery/OCP, LibreCAD, QCAD — don't even read their sources. (`tools/freecad-oracle/`
runs FreeCAD as a separate program through its public scripting API to compute reference values;
nothing from FreeCAD is linked, vendored or shipped.) The sketch solver and the blend
operations are our own.
- **Assets — absolutely essential.** SolidCraft contains **no iconography, images, fonts or
artwork from commercial CAD products — ever.** Every icon is drawn in code
(`crates/ui-egui/src/icons.rs`). Any file asset must be original or openly licensed and **must have a row in ATTRIBUTION.md**
(`cargo xtask assets` enforces it). Screenshots of commercial CAD software are never committed.
Breaking this rule is the most serious mistake you can make in this repo.
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*`
(`CommandSpec`: id = our own dotted, lower-case id `<area>.<thing>[.<variant>]` such as
`solid.extrude` or `sketch.rectangle.two_point`, never another product's command id; label;
toolbar tab and panel; icon; shortcut; params doc; `enabled`; JSON `run`) + tests. The toolbar,
dialogs, sketch tools, palette, scripts, CLI and control channel all reach the same commands.
- **Programmatic calls never open dialogs.** `engine.execute` runs the JSON form; only toolbar
clicks (`ui.start`) start interactive tools and dialogs, which then run commands.
- **Layering** is enforced by `cargo xtask layers`: L0 `geom` → L1 `sketch`, `kernel`, `render`
→ L2 `doc` → L3 `io` → L4 `engine` → L5 `ui-egui` → apps. Nothing below L5 depends on
egui/eframe/winit/wgpu/rfd. **The UI crate is swappable.** Only `crates/kernel` may use `truck`.
- **The UI is thin**: panels read engine state and act through `app.run(id, params)` /
`app.start(id)`. Colours come from `theme::Tokens`.
- **Rust only.** Units are millimetres and radians internally; Z is up. The one exception is
JavaScript the browser requires: the service worker `apps/solidcraft-web/sw.js` (browsers only
run service workers as JavaScript) and the small inline scripts in `apps/solidcraft-web/index.html`.
Keep them minimal; `cargo xtask web-offline-test` covers the service worker.
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests,
assets, layers). Commit after every feature arc that builds.
- **Publishing**: `origin` is github.com/bherbruck/solidcraft. Changes land through **pull requests**:
push a branch, open a PR, and let GitHub CI run. Maintainers (and agents working for them) may
merge their own PR as soon as CI is green (`gh pr merge --squash`); a trivial fix (docs, typos)
may be merged right away. Keep each PR to one topic, rebase or merge `main` into it when it
conflicts, and never force-push `main`. Never commit `plan/`, build dirs (`target*/`) or
generated reference data; check `git ls-files` before pushing.
- **Never break wasm**: everything below the apps must build for `wasm32-unknown-unknown`
(`cargo xtask wasm`, part of `cargo xtask ci`).
## Application releases
Development pushes are not application releases. Follow `docs/releasing.md` when asked to
prepare or ship a release: choose the version from the actual changes, update Cargo.toml and
Cargo.lock, pass the quality gates, advance `release` without force-pushing, and verify the
resulting draft and its artifacts. When shipping is authorized, publish that verified draft
and confirm its tag and downstream deployment. Do not auto-release every push to `main`, infer
version bumps solely from commit prefixes, overwrite published versions, or push release tags
before the draft is verified.
## Running and looking at the app
- `cargo run --release -p solidcraft -- --sample --control PORT` (sample design + control channel).
Pick a free port. Without a display, run it under `Xvfb :99` with `DISPLAY=:99`.
- Drive it with JSON lines on `127.0.0.1:PORT` (see `docs/control-protocol.md`). The first
request on a connection carries the launch's token as `"token"`: read it from
`~/.local/share/solidcraft/control/PORT.token`, or start the app with
`SOLIDCRAFT_CONTROL_TOKEN=$(openssl rand -hex 32)` and send that.
- `{"id":1,"method":"engine.execute","params":{"command":"solid.extrude","params":{"distance":20}}}`
- `{"id":2,"method":"ui.start","params":{"command":"sketch.create"}}` then `ui.click {x, y}`
- `{"id":3,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}` then read the PNG.
- **For UI work, look at the result** (screenshot, read the PNG). Without a window use
`ui.render` or `solidcraft-cli snapshot`.
- Headless: `solidcraft-cli run script.json --out part.step`, `solidcraft-cli eval design.solidcraft`,
`solidcraft-cli snapshot script.json --out shot.png`, `solidcraft-cli commands`.
- Reference tests: `cargo xtask oracle` replays `tests/oracle/*/recipe.json` and compares with
each case's `expected.json` (analytic or FreeCAD values; writes `docs/oracle.md`). Expected
values never come from a commercial CAD product.
- Build with `CARGO_TARGET_DIR=<repo>/target`. Windows: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solidcraft`.
## Roadmap
`ROADMAP.md` (committed) tracks status, milestones and estimates. Update it whenever a
milestone task lands. `cargo xtask features` regenerates `docs/features.md` (SolidCraft's
commands by area, from the registry) when commands are added or renamed.
The agent instructions for this repo live in AGENTS.md; edit that file only.
+181 -39
View File
@@ -10,13 +10,28 @@ Help is welcome, whether that's a one-line fix or owning a whole area. This page
build it, how the code is laid out, the few rules that aren't negotiable, and where help is
needed most.
Before choosing a task, check the [command inventory](docs/features.md) and
[roadmap](ROADMAP.md) for implemented capabilities and current priorities.
Questions, ideas or want to say what you're working on? Join the [Discord](https://discord.gg/FtU4NQNVDH).
## Kernel work is paused
We're evaluating a new geometry kernel. Until that's decided, please don't open issues, pull
requests or discussions about kernel work: booleans, fillets, chamfers, shell, offsets, sweeps and
lofts, threads and holes, STEP/IGES import and export, tessellation, or anything else that changes
`crates/kernel/`. The pinned issue on the tracker explains it. New ones get the `kernel-paused`
label (`.github/workflows/kernel-paused.yml`) and a maintainer answers them. Crashes and data loss
are still taken as issues, and everything outside the kernel carries on as normal.
## Get it running (about 10 minutes on a fast machine)
You need stable Rust 1.95 or newer (required by egui/eframe 0.36). For the web
build you also need `rustup target add wasm32-unknown-unknown` and [`trunk`](https://trunkrs.dev).
build you also need `rustup target add wasm32-unknown-unknown` and [`trunk`](https://github.com/trunk-rs/trunk)
([documentation](https://trunk-rs.github.io/trunk/)).
```sh
git clone https://github.com/bherbruck/solidcraft && cd solidcraft
git clone https://github.com/tangency-labs/solidcraft && cd solidcraft
cargo run --release -p solidcraft -- --sample # desktop app with a sample part
cargo xtask test # unit, engine and UI-scenario tests
cargo xtask ci # everything a PR must pass (see below)
@@ -66,7 +81,7 @@ desktop integration and how to distinguish an old executable on `PATH`.
## How the code is laid out
The [README](README.md#architecture) has the layer table. The short version:
The [architecture page](docs/architecture.md) has the layer table. The short version:
- `crates/geom`: vectors, planes, meshes, measures.
- `crates/sketch`: our own constraint solver, plus profile finding.
@@ -98,8 +113,8 @@ The [README](README.md#architecture) has the layer table. The short version:
MIT OR Apache-2.0, and its solver and blends are written from the maths. (Running an
open-source application to get reference values for a test is fine; reading its source is
not.) Permissive
dependencies are fine. Note which one you're adding and why in the PR, and run
`cargo xtask licences` so THIRD-PARTY-LICENSES.txt lists it (CI checks that it is current).
dependencies are fine. Note which one you're adding and why in the PR. THIRD-PARTY-LICENSES.txt
lists it once the change lands: the Generated files workflow rewrites it on `main`.
Nightly/manual CI also scans advisories, bans and sources with cargo-deny; see
[the local command and coverage limits](docs/book/src/dev/testing.md#dependency-advisories).
3. **Every asset is original or openly licensed and has a row in
@@ -107,9 +122,10 @@ The [README](README.md#architecture) has the layer table. The short version:
`cargo xtask assets` enforces this.
4. **Never crash.** People trust a CAD app with their work.
- Non-test code never uses `unwrap`, `expect`, `panic!` or indexing on input-derived data.
There's no `unsafe` outside `apps/solidcraft/src/open_documents.rs` (macOS file-open events)
and `apps/solidcraft/src/native_crash.rs` (Windows native crash capture), and the compiler
denies it.
There's no `unsafe` outside `apps/solidcraft/src/open_documents.rs` (macOS file-open events),
`apps/solidcraft/src/native_crash.rs` (Windows native crash capture) and
`crates/threedx-client/src/macos.rs` (the 3Dconnexion driver's client API on macOS), and the
compiler denies it.
- An unsupported case returns an error ("not supported yet: …"); it never panics.
- Every call into truck goes through `solidcraft_kernel::guard`.
- Every crash fix comes with a regression test.
@@ -123,6 +139,10 @@ The [README](README.md#architecture) has the layer table. The short version:
`navigation::handle`; never read drags, the wheel or pinch to move the camera yourself. The
`hand_rolled_camera_input_is_rejected` test fails if a page does.
Labels, parameter docs, messages and docs use the terms in the
[glossary](https://docs.solidcraft.io/reference/glossary.html): one term for each idea. A change that adds a new
idea adds its term there.
[AGENTS.md](AGENTS.md) has the long form of these rules. It's written for AI agents, but it's
the most complete description of the house style.
@@ -134,6 +154,12 @@ the most complete description of the house style.
| UI scenarios | `crates/ui-egui/tests/scenarios/*.json` | part of `cargo test` |
| Reference tests | `cargo xtask oracle` | `tests/oracle/` (see below) |
A geometry change that changes the robustness goldens re-records them in the same pull request:
`ROBUSTNESS_GOLDENS_BLESS="why it changed" cargo test --profile ci -p solidcraft-engine --lib robustness_goldens::`
(see [docs/robustness.md](docs/robustness.md)); CI runs every seed on pull requests that touch geometry.
[docs/mutation-testing.md](docs/mutation-testing.md) shows how to find kernel behaviour these
black-box suites miss.
**UI scenarios** drive the real app headless: start a tool, click a plane or a sketch point,
press keys, then assert on the result.
@@ -163,18 +189,31 @@ in the PR. `cargo xtask test` builds the tests with it and then runs every test
`cargo xtask test -- shell`. Tests run beside each other on a busy machine, so a test must not
depend on wall-clock timing.
**CI shards the tests.** One job builds them into a cargo-nextest archive and four jobs run a
quarter each, so every test runs in its own process: don't rely on state left by another test.
Pull requests skip a few heavy tests (one to four minutes each), which run on every push to
`main` and nightly; check the `main` run after merging. They are listed in
**Ready pull requests test what they touch.** CI maps changed files to workspace packages and
runs Clippy, the test build and tests for those and their dependents. The
[package selector](scripts/ci/affected.py) and [CI workflow](.github/workflows/ci.yml) define
which changes require native checks; documentation, packaging, other-workflow and bot-script-only
changes run the repository checks only, on pull requests and on `main`. Workspace-wide changes,
merge-queue groups, nightly runs and Run workflow select the full workspace. A push to `main`
reuses a successful merge-queue run for the same commit, and runs only the repository checks
when no code changed since the last push to `main` that passed; otherwise it runs the full
suite. Doc tests, reference tests and the Windows jobs run on `main` and nightly only.
**CI shards the tests.** One job builds them into a cargo-nextest archive; two shards run on
pull requests and four during full validation. Every test runs in its own process: don't rely
on state left by another test.
Pull requests skip a few heavy tests (one to twelve minutes each), which run in full validation
(the merge queue, nightly, manual runs and pushes to `main` not already tested by the queue).
Check that validation after merging. They are listed in
`.config/nextest.toml` and the [testing page](docs/book/src/dev/testing.md):
- `solidcraft-engine`: `tests::hostile_params_never_panic`, everything in `tests::robustness` and
`tests::robustness_goldens` (pull requests that touch `crates/kernel` run its `sample_*` part),
the seeds of `tests::robustness_goldens` (`seed_00` … `seed_95`; pull requests that change the kernel, truck or the goldens run all but the slowest, `SLOW_SEEDS` in `ci.yml`),
`pattern_suppress_tests::enclosure_arc_extinction_keeps_a_real_sharp_inner_lip_after_resize_and_reopen`,
`pattern_suppress_tests::enclosure_datums_follow_wall_height_and_board_depth_with_material_proofs`,
`cmd::plastic::tests::enclosure_snap_fit_follows_resized_front_wall`
- `solidcraft-kernel`: `tests::boolean_identity_on_tangent_primitives`
`cmd::plastic::tests::enclosure_snap_fit_follows_resized_front_wall`, the `atomic_commands` contract tests
- `solidcraft-kernel`: `tests::boolean_identity_on_tangent_primitives`, `tspline::tests::hostile_tsplines_never_panic`, and the `boolean_props` property tests
(these run only in the nightly "Property tests" job: their inputs are random, so main skips them too)
- `solidcraft-ui-egui` scenarios: `acceptance_sensor_enclosure`
**The action matrix** tries every ordinary action on every face, edge, vertex and body of a
@@ -182,28 +221,96 @@ corpus of designs against a checked-in ratchet ([docs/action-matrix.md](docs/act
A pull request that makes an action pass should record it with `cargo xtask action-matrix --bless`;
one that makes a passing action fail fails the sample every CI run checks.
**The reference tests** are 76 parts in [`tests/oracle/`](tests/oracle/), each a `recipe.json`
**The reference tests** live in [`tests/oracle/`](tests/oracle/), each a `recipe.json`
in our own format with an `expected.json` beside it. SolidCraft rebuilds the recipe and compares
body count, volume, area, centre of mass and bounding box (and, where there is a `reference.stl`,
the surface itself). Expected values are either worked out in closed form
(`tools/oracle-analytic/`) or computed by building the same recipe in FreeCAD, run as a separate
program (`tools/freecad-oracle/`); each file says which. [docs/oracle.md](docs/oracle.md) has
the current results, and CI runs `cargo xtask oracle` on every pull request. A case that fails
because of a known bug names its issue in `expected.json` (`"known_failure": "#123"`); never
change an expected value to match SolidCraft's output.
program (`tools/freecad-oracle/`); each file says which. The generated
[reference test report](docs/oracle.md) has the current case count and results. The reference-test
CI job runs `cargo xtask oracle` during full validation; pull-request runs skip it. A case that
fails because of a known bug names its issue in `expected.json` (`"known_failure": "#123"`);
never change an expected value to match SolidCraft's output.
## Changing the design file format
A design file says which format it is in (`"format": "solidcraft/N"`). If your change saves
something new in a design (a field, a feature type, a new kind of name), or makes older designs
mean something else, it needs a format change. **Add one file, and edit nothing shared:**
1. Create `crates/doc/src/format/pending/<what-it-is>.rs` (lowercase, dashes). Start it with a
`//!` note: what is saved, what older designs get without it, and that the last release asks
for an update instead of meeting it.
2. If older designs must be rewritten as they open, put `pub fn step(v: &mut Value) ->
crate::Result<()>` in the same file. Pending steps run in file-name order, after the older
formats' steps.
3. Don't touch `FORMAT`, `JSON_STEPS`, the history in `crates/doc/src/format.rs`, another
change's file, or any `v<N>/` folder: those are fixed once released.
That's all. The build counts the files: while any are pending it writes the next format number,
which no release reads, so a release refuses a design that has your change instead of losing it.
A file also lists the pending changes it has (`format_pending`), so a later development build
runs only the steps that file is missing. Two pull requests that each add a change merge without
a conflict. `crates/doc/src/format/pending/README.md` has an example.
**At release time** the maintainer runs `scripts/fold-format-pending.sh`, which moves the pending
files into `crates/doc/src/format/v<N>/` and fixes that number for good (see
[docs/releasing.md](docs/releasing.md)). Never add a file to a `v<N>/` folder afterwards.
## Lists many changes add to
Some files gain an item in many pull requests at once. They are kept so that two changes that
each add an item only meet when their items are neighbours, which spares everyone a conflict
and a fresh CI run:
- **Message catalogs** (`assets/locales/*.json`): one message per line, sorted by id. Add your
message anywhere and run `cargo xtask lists --fix`; `cargo xtask lists` (in `cargo xtask ci`
and the Lists workflow) fails on an unsorted catalog or an id that appears twice.
- **`mod` lines**: one per line, in the sorted group they belong to (`cargo fmt` keeps each group
sorted). Add a new module into the existing group, not as a new group at the end.
- **Languages** (`crates/ui-egui/src/i18n.rs`): one per line, English first, then by id (a test
checks it).
- **Generated files**: `THIRD-PARTY-LICENSES.txt` and `docs/features.md` are rewritten on `main`
after each merge by the Generated files workflow (`cargo xtask licences`, `cargo xtask
features`). Don't regenerate or edit them in a pull request.
- **Design format**: one file per change, never an edit to a shared number (above).
## Sending a change
1. Work is tracked in [GitHub issues](https://github.com/bherbruck/solidcraft/issues), labelled
by area (`area:kernel`, `area:sketch`, `area:doc`, `area:ui`, `area:infra`). Before starting,
comment on the issue so it gets the `in progress` label and two people don't build the same
thing. For anything bigger than a bug fix that has no issue yet, open one first.
1. Work is tracked in [GitHub issues](https://github.com/tangency-labs/solidcraft/issues), labelled
by area (`area:kernel`, `area:sketch`, `area:doc`, `area:ui`, `area:infra`). For anything
bigger than a bug fix that has no issue yet, open one first. **Claim the issue with a draft
PR** before you start, so two people don't build the same thing:
- Check nobody has it: `gh pr list -R tangency-labs/solidcraft --state open --search "#N"`.
- Branch from the latest `main`, commit `git commit --allow-empty -m "claim: #N"`, push, and
open a **draft** PR titled `<summary> (#N)`, e.g. `Fillet: variable radius along a chain (#123)`.
- **The lowest-numbered open PR for an issue wins.** If yours lost, close it (or offer to help
on the other one).
- The bot records the claim when the PR is opened: it assigns you, adds `in progress` and
says so on the issue. It reads `#N` in the PR title and `Fixes #N` in the body. If someone
already holds the issue, or an older open PR references it, it says so on your PR instead.
- Want to stake a claim before you have a branch? Comment `/claim` on the issue (below).
- Push work in progress at least daily. A draft with no pushes for 72 hours may be taken over:
comment on it first, then open your own claim.
- Mark the PR ready for review when it is done.
2. Branch from `main` and keep the PR to one topic.
3. Run `cargo xtask ci` before pushing. It runs fmt, the asset, licence and layer checks,
clippy (`-D warnings`), then the tests with the wasm build check alongside. A PR has to pass it.
Heavy CI runs after review: a pull request gets the formatting and repository checks and a
quick compile (`cargo check`) when you push. Once a maintainer has reviewed it they add the
`approved-for-ci` label when the PR is ready to merge, which runs the full CI (build, tests,
goldens, Clippy); every push after that runs the full set again. Maintainers merge approved
PRs in groups by area, so PRs that touch the same code meet a conflict at most once.
4. Reference the issue (`Fixes #12`). In the PR, say what you tested and how. For geometry work, include the measured numbers (and
the expected ones).
Commenting `/claim` assigns you, adds the `in progress` label, and replies with its automatic cleanup
deadline. It frees a claim with no linked PR after 72 hours, or a claim with a PR after 14 days
without pushes. These automatic cleanup deadlines are separate from the daily progress and
72-hour takeover policy above. A draft PR that references the issue claims it the same way.
`/unclaim` releases the bot claim early (only you or a maintainer can). Issues a maintainer has
taken are never freed automatically.
Help reviewers see what the words mean. For observable CAD, UI or model behavior, please
include actual descriptive screenshots or a short recording in the issue or PR. This evidence
is required for changes or bugs that can be demonstrated in the app, including kernel,
@@ -218,22 +325,48 @@ Changes with no useful observable behavior do not need screenshot or video evide
small example without private designs or sensitive details; attach the media or link to an
accessible recording rather than committing generated review artifacts.
Keep checking upstream commits and the relevant PR's merge status throughout the work and
before handoff. Integrate upstream merges, resolve conflicts without losing others' changes,
and validate the integrated result. Before pushing, run `git pull --rebase origin main`, re-run
CI if it brought changes, and push without force. Report the upstream/PR state you checked,
any conflicts resolved and validation results. While a task remains active, follow an open
PR through its status changes; at handoff, clearly identify anything still awaiting merge.
Keep an eye on your PR's status, but don't push to it only to catch up with `main` (no merge of
`main`, no Update branch button, no rebase for that alone): every push restarts the whole CI
run for everyone waiting behind it, and `main`'s own run catches what lands in between. When you
push a change anyway, or GitHub reports a conflict, rebase onto the latest `main` (push your
branch with `--force-with-lease`) or merge `main` in once, and resolve conflicts without losing
others' changes. Don't re-run CI on an unchanged commit either: a real failure fails again.
Re-run only a job that runner trouble cancelled, with "Re-run failed jobs". While a task remains
active, follow an open PR through its status changes; at handoff, clearly identify anything
still awaiting merge.
Much of SolidCraft so far was written by AI coding agents (Claude) working in parallel, each
owning one area, with a human setting direction and reporting bugs. Their working notes are in
[AGENTS.md](AGENTS.md). Human contributors are just as welcome. Use whatever tools you like, as
long as the change meets the rules above.
## Labels and triage
The triage bot labels every new issue and PR (`.github/workflows/triage.yml`). It only adds labels;
correct them freely and it won't put them back.
- **PRs** get `area:*` from the paths they change (`crates/kernel` and `crates/geom` →
`area:kernel`, `crates/ui-egui` and the apps → `area:ui`, `.github/`, `scripts/`, `docs/` →
`area:infra`, and so on).
- **Issues** get `bug` (in-app reports and the bug template), `enhancement` (feature requests),
`priority:P1` for crashes, and an `area:*` from the title or the reported command. Until it has
a priority, an issue carries `needs-triage`; setting a priority removes it.
- **Maintainers** comment `/triage area:kernel P1` (any existing label; `P0`–`P3` for a
priority, which replaces the old one; `-label` removes one) to sort an issue or PR.
- **Short bug reports without steps to reproduce** get `needs-info` and one comment asking for
the steps (and the version, when it's missing). An app report that arrived empty is asked to
paste the report the app copied to the clipboard. The reporter's reply, or an edit that adds the
steps, removes the label; an issue that stays in `needs-info` for 14 days is closed, and can be
reopened any time. Feature requests, app reports with their details, and maintainers' issues
are never asked.
- `needs-triage` is the queue: [open issues with it](https://github.com/tangency-labs/solidcraft/issues?q=is%3Aopen+label%3Aneeds-triage).
## Where help is wanted most
See the [`help wanted`](https://github.com/bherbruck/solidcraft/labels/help%20wanted) and
[`good first issue`](https://github.com/bherbruck/solidcraft/labels/good%20first%20issue) labels.
**Kernel speed (slow fillets, booleans, STEP):** please don't rewrite the current kernel code for speed; it is being replaced by [solidcore](https://github.com/bherbruck/solidcore) ([progress](https://tangency-labs.github.io/solidcore/)). Report slow cases with a file under the `kernel-perf` label, and put kernel effort into solidcore instead. See [#1597](https://github.com/tangency-labs/solidcraft/issues/1597).
See the [`help wanted`](https://github.com/tangency-labs/solidcraft/labels/help%20wanted) and
[`good first issue`](https://github.com/tangency-labs/solidcraft/labels/good%20first%20issue) labels.
In order of how much they'd help users:
1. **Fillet and blend robustness.** Fillets are our own code (truck has no fillet operation).
@@ -242,15 +375,24 @@ In order of how much they'd help users:
faces, and fillets that consume neighbouring faces.
2. **Boolean robustness.** A random-design fuzzer currently gets about half of its designs
through without a kernel error. Each failing seed is a reproducible bug.
3. **Y-up / Z-up.** SolidCraft is Z-up only, so most STEP files (from SolidWorks, Onshape, or
Fusion's default) open lying on their backs. Fusion solves this with a "default modeling
orientation" preference.
4. **Imported appearances.** Colours from STEP files are lost when a feature changes the
imported body. They should become design appearances on import.
5. **Drawings, CAM and mesh tools.** These are out of scope for now (see
3. **Drawings, CAM and mesh tools.** These are out of scope for now (see
[ROADMAP.md](ROADMAP.md)), but anyone who wants to own one is welcome.
## Updating the generated feature list
After adding or renaming commands, run `cargo xtask features`. The registry is the
source of truth for `docs/features.md`; do not hand-edit the generated rows.
The generator sorts commands by ID and omits global and per-section counters,
so additions to unrelated areas no longer rewrite shared summary lines.
If two branches still conflict in this file (for example, adjacent additions in
the same area), first resolve the command registry and source conflicts, then run
`cargo xtask features` again and stage the regenerated file. Do not use Git's
`union` merge driver: it can silently retain stale or duplicate command rows.
The generator tests cover stable ordering and additions that leave existing lines
unchanged. The initial switch to sorted rows is a one-time larger diff.
## Licence
By contributing you agree that your contribution is licensed as MIT OR Apache-2.0, like the rest
of SolidCraft. SolidCraft isn't affiliated with Autodesk. Fusion is a trademark of Autodesk, Inc.
of SolidCraft. SolidCraft isn't affiliated with any CAD vendor; product names are trademarks of their owners (see NOTICE).
Generated
+665 -65
View File
File diff suppressed because it is too large Load Diff
+42 -2
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["vendor"]
[workspace.package]
version = "0.13.0"
version = "0.21.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -23,8 +23,10 @@ solidcraft-doc = { path = "crates/doc" }
solidcraft-io = { path = "crates/io" }
solidcraft-engine = { path = "crates/engine" }
solidcraft-ui-egui = { path = "crates/ui-egui" }
solidcraft-space-mouse = { path = "crates/space-mouse" }
solidcraft-mcp = { path = "crates/mcp" }
solidcraft-derive = { path = "crates/derive" }
solidcraft-3dx-client = { path = "crates/threedx-client" }
# foundation
thiserror = "2"
@@ -34,6 +36,9 @@ proc-macro2 = "1"
quote = "1"
syn = { version = "2", features = ["full"] }
serde_json = { version = "1", features = ["float_roundtrip"] }
# command parameter and result schemas
schemars = "1.2"
serde_path_to_error = "0.1"
log = "0.4"
sha2 = "0.10"
moxcms = { version = "0.8.1", default-features = false, features = ["extended_range"] }
@@ -51,15 +56,40 @@ spade = { version = "2.15", default-features = false, features = ["std"] }
rayon = "1.12"
# packages (3MF)
zip = { version = "3", default-features = false, features = ["deflate"] }
zip = { version = "9", default-features = false, features = ["deflate"] }
quick-xml = "0.41"
# STEP text in a legacy encoding (GBK, Shift_JIS, …) instead of Part 21 escapes (#2344): detect
# and decode it. Both MIT OR Apache-2.0 (encoding_rs adds BSD-3-Clause data); encoding_rs is
# already in the tree through cadmpeg-codec-step.
chardetng = "0.1"
encoding_rs = "0.8"
# glTF 2.0 import (MIT OR Apache-2.0): the document model and GLB container only; buffers are
# read by crates/io itself, so no image decoding or file access comes with it.
gltf = { version = "1.4", default-features = false, features = ["names"] }
# FreeCAD and Rhino import (Apache-2.0, pure Rust; native and browser builds).
# Pinned: its IR and codec APIs change between minor versions. Open formats only: no default
# features, so cadmpeg's codecs for commercial formats (f3d, sldprt, inventor, catia, creo, nx)
# and its ShapeManager/ACIS `sat` codec are not compiled in. `step` is the writer we restate through.
cadmpeg-core = "=0.6.3"
cadmpeg-ir = "=0.6.3"
cadmpeg-registry = { version = "=0.6.3", default-features = false, features = ["fcstd", "rhino", "step"] }
# fonts (sketch text outlines; MIT OR Apache-2.0, pure Rust)
ttf-parser = { version = "0.25", default-features = false, features = ["std"] }
# Ends a process group with the system call (MIT; already in the tree through zip).
nix = { version = "0.31", default-features = false, features = ["signal"] }
# images
image = { version = "0.25", default-features = false, features = ["png", "hdr"] }
png = "0.18.1"
# LZW for GIF animations (MIT OR Apache-2.0, pure Rust; already in the tree through image's tiff).
weezl = "0.1.12"
# rasterises the UI icon SVGs (BSD-3-Clause, pure Rust, builds for wasm)
tiny-skia = { version = "0.12", default-features = false, features = ["std", "simd"] }
# build scripts: reads the icon palette (MIT OR Apache-2.0)
toml = "1"
# ui
egui = "0.36"
@@ -104,6 +134,9 @@ incremental = false
[profile.ci.package."*"]
opt-level = 2
# No line tables for dependencies: they were over a third of the test archive every CI shard
# downloads. Our own crates keep theirs, so a backtrace still names file:line in them.
debug = false
[profile.release]
opt-level = 3
@@ -115,6 +148,7 @@ debug = "line-tables-only"
# truck-shapeops with SolidCraft fixes (Apache-2.0, see vendor/truck-shapeops and ATTRIBUTION.md).
[patch.crates-io]
truck-geometry = { path = "vendor/truck-geometry" }
truck-shapeops = { path = "vendor/truck-shapeops" }
truck-stepio = { path = "vendor/truck-stepio" }
truck-meshalgo = { path = "vendor/truck-meshalgo" }
@@ -122,3 +156,9 @@ matext4cgmath = { path = "vendor/matext4cgmath" }
# Keep OpenGL pipelines, resolve textures and CAD callbacks at the same safe sample count.
egui-wgpu = { path = "vendor/egui-wgpu" }
# Mutation testing (cargo-mutants, docs/mutation-testing.md): the ci profile with incremental
# builds, since every mutant rebuilds the crates above the one it changes.
[profile.mutants]
inherits = "ci"
incremental = true
+5 -2
View File
@@ -29,5 +29,8 @@ for sketch text, BIZ UDPGothic for web Japanese display) are licensed under SIL
per-file list is in ATTRIBUTION.md.
SolidCraft is an independent project and is not affiliated with, sponsored by or endorsed by
Autodesk, Inc. Autodesk and Fusion are trademarks of Autodesk, Inc.; they are used here only to
describe compatibility goals.
Autodesk, Dassault Systèmes, PTC, Siemens, Blender or any other CAD vendor. Fusion, AutoCAD,
Inventor, Alias and Tinkercad are trademarks of Autodesk, Inc.; SolidWorks is a trademark of
Dassault Systèmes; Onshape and Creo are trademarks of PTC; Blender is a trademark of the Blender
Foundation. Product names appear only to describe compatibility, such as the mouse navigation
presets in Preferences › Navigation and file formats from public specifications.
+124 -237
View File
@@ -1,16 +1,17 @@
# SolidCraft
<p align="center">
<img src="assets/icons/app/linux/exports/hicolor/256x256/apps/ai.storyteller.solidcraft.png" width="96" height="96" alt="SolidCraft" />
</p>
**An open-source parametric 3D CAD application built in pure Rust.**
<h1 align="center">SolidCraft</h1>
Sketch, constrain, and build solids with a parametric feature timeline. Change a dimension
and watch your model rebuild. Run it on your desktop, in your browser, or let AI agents
model through the built-in MCP server.
<p align="center">
<strong>An open-source parametric 3D CAD application built in pure Rust.</strong><br />
Sketch, constrain, and build solids with a parametric feature timeline. Change a dimension
and watch your model rebuild. Run it on your desktop, in your browser, or let AI agents
model through the built-in MCP server.
</p>
![SolidCraft 0.8: model a Raspberry Pi 4 case from an empty design: a sketch dimensioned with parameters, extrude, fillets and shell, a rim rounded in one pick, standoffs patterned on the board's mounting holes, port cut-outs, vent slots, "Pi 4" engraved in a font, a PETG material with its mass in Properties, and a height change in Parameters](docs/screenshots/demo-0.8.gif)
*A Raspberry Pi 4 case from an empty design: a sketch dimensioned with the board's parameters, extrude, fillet, shell, the rim rounded in one pick, an M2.5 standoff patterned on the 58 × 49 mm hole rectangle, port cut-outs, vent slots, "Pi 4" engraved in DejaVu Sans Bold and a PETG material defined in the design, with its mass in Properties, then height 24 → 32 mm in Parameters and it all rebuilds. 9× playback. [Full video](https://github.com/bherbruck/solidcraft/raw/refs/heads/main/docs/screenshots/demo-0.8.mp4), recorded with the [demo script](examples/demo/record_ui.sh).*
## Download
<div align="center">
[![Download for Windows][badge-windows]][windows-msi]
[![Download for macOS][badge-macos]][macos-dmg]
@@ -18,240 +19,126 @@ model through the built-in MCP server.
[![Get it from the Snap Store][badge-snap]][snap-store]
[![Try it in your browser][badge-web]][web-app]
- **Windows:** download and run the [installer][windows-msi].
Also for [ARM64][windows-arm64-msi] and [32-bit][windows-x86-msi] PCs, or as a
[portable zip][windows-portable] that runs without installing.
With [WinGet](https://learn.microsoft.com/windows/package-manager/winget/):
`winget install bherbruck.SolidCraft`.
- **macOS:** open the [disk image][macos-dmg] and drag SolidCraft into Applications
(Apple silicon and Intel).
- **Linux:** download the [AppImage][linux-appimage], allow it to run as a program and open it.
Or install the [.deb][linux-deb] (Ubuntu, Debian, Mint) or [.rpm][linux-rpm] (Fedora,
openSUSE), or use [Flatpak](https://bherbruck.github.io/solidcraft/guide/getting-started.html#linux-flatpak).
With Snap: `sudo snap install solidcraft` ([Snap Store][snap-store], or the [.snap file][linux-snap]).
For ARM64: [AppImage][linux-arm64-appimage], [.deb][linux-arm64-deb], [.rpm][linux-arm64-rpm].
</div>
<p align="center">
<a href="https://solidcraft.io">Website</a> ·
<a href="https://solidcraft.io/download">Download</a> ·
<a href="https://solidcraft.io/app">Try it in the browser</a> ·
<a href="https://docs.solidcraft.io/">Documentation</a> ·
<a href="https://www.blacksmith.sh"><img src="https://img.shields.io/badge/CI-Blacksmith-000000?logo=githubactions&logoColor=white" alt="CI powered by Blacksmith" /></a>
<a href="https://discord.gg/FtU4NQNVDH">Discord</a> ·
<a href="https://github.com/bherbruck/solidcore">SolidCore</a>
</p>
<p align="center">
<a href="https://github.com/tangency-labs/solidcraft/actions/workflows/ci.yml?query=branch%3Amain"><img src="https://github.com/tangency-labs/solidcraft/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI" /></a>
<a href="https://github.com/tangency-labs/solidcraft/releases/latest"><img src="https://img.shields.io/github/v/release/tangency-labs/solidcraft?label=release" alt="Latest release" /></a>
<a href="https://github.com/tangency-labs/solidcraft/releases"><img src="https://img.shields.io/github/downloads/tangency-labs/solidcraft/total?label=downloads" alt="Downloads" /></a>
<a href="LICENSE"><img src="https://img.shields.io/github/license/tangency-labs/solidcraft" alt="Licence" /></a>
<a href="https://discord.gg/FtU4NQNVDH"><img src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://remade.wiki/projects/solidcraft/"><img src="https://remade.wiki/badges/listed-on-remade-classic.svg" alt="Listed on Remade" /></a>
<a href="https://github.com/tangency-labs/solidcraft/stargazers"><img src="https://img.shields.io/github/stars/tangency-labs/solidcraft?style=flat" alt="Stars" /></a>
</p>
<p align="center">
<a href="https://trendshift.io/repositories/296178?utm_source=trendshift-badge&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-296178" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/296178/daily" alt="tangency-labs%2Fsolidcraft | Trendshift" width="250" height="55"/></a>
<a href="https://trendshift.io/repositories/296178?utm_source=trendshift-badge&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-296178" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/296178/daily?language=Rust" alt="tangency-labs%2Fsolidcraft | Trendshift (Rust)" width="250" height="55"/></a>
</p>
![SolidCraft 0.8: model a Raspberry Pi 4 case from an empty design: a sketch dimensioned with parameters, extrude, fillets and shell, a rim rounded in one pick, standoffs patterned on the board's mounting holes, port cut-outs, vent slots, "Pi 4" engraved in a font, a PETG material with its mass in Properties, and a height change in Parameters](docs/screenshots/demo-0.8.gif)
*A Raspberry Pi 4 case from an empty design: a sketch dimensioned with the board's parameters, extrude, fillet, shell, the rim rounded in one pick, an M2.5 standoff patterned on the 58 × 49 mm hole rectangle, port cut-outs, vent slots, "Pi 4" engraved in DejaVu Sans Bold and a PETG material defined in the design, with its mass in Properties, then height 24 → 32 mm in Parameters and it all rebuilds. 9× playback. [Full video](https://github.com/tangency-labs/solidcraft/raw/refs/heads/main/docs/screenshots/demo-0.8.mp4), recorded with the [demo script](examples/demo/record_ui.sh).*
## Download
- **Windows:** the [installer][windows-msi], or `winget install bherbruck.SolidCraft`.
- **macOS:** the [disk image][macos-dmg] (Apple silicon and Intel).
- **Linux:** the [AppImage][linux-appimage], or `sudo snap install solidcraft` ([Snap Store][snap-store]).
- **Web:** [try it in your browser][web-app], no install.
The downloads aren't signed yet. On Windows, if SmartScreen warns you, choose **More info**, then
**Run anyway**. On macOS, the first time, right-click SolidCraft in Applications and choose **Open**.
ARM64 and 32-bit builds, portable zip, .deb, .rpm, Flatpak and the other packages are on the
[downloads page](docs/downloads.md). The downloads aren't signed yet: on Windows choose
**More info**, then **Run anyway** in SmartScreen; on macOS right-click SolidCraft and choose **Open**
the first time. On SolidCraft 0.11 to 0.14, download once by hand; see
[updating](https://docs.solidcraft.io/guide/updates.html#updating-from-011-to-014).
Older versions, checksums and other formats are on the [releases page](https://github.com/bherbruck/solidcraft/releases).
Install details, upgrading from earlier builds and building from source are in
[Getting started](https://bherbruck.github.io/solidcraft/guide/getting-started.html).
## What works
Early but usable for simple parts: 75 of 76 public reference parts match their analytic or
FreeCAD-computed volume, area, centre of mass and bounding box ([oracle](docs/oracle.md)); the
other is a tracked bug.
- **Sketches and constraints:** lines, arcs, splines, text and more, solved by our own constraint solver, with parameter-driven dimensions.
- **Features:** extrude, revolve, sweep, loft, fillet, chamfer, shell, draft, holes, threads, patterns, mirror, split and combine on an editable parametric timeline.
- **Parameters and configurations** with unit-aware expressions.
- **Assemblies** with joints, motion studies, contact sets, interference and mass properties.
- **Sheet metal** with flanges, hems, fold/unfold and flat patterns, and plastic enclosure features.
- **Files:** STEP, IGES, 3MF, STL, OBJ and glTF in and out where supported, URDF export, `.solidcraft` designs.
- **Desktop app, browser app, headless CLI and MCP server**, all running the same commands.
The full list is in [docs/capabilities.md](docs/capabilities.md) and [docs/features.md](docs/features.md);
status and plans are in [ROADMAP.md](ROADMAP.md). See also [what people built](docs/showcase.md),
[3D mouse support](docs/3d-mouse.md) and the [architecture](docs/architecture.md).
## Build from source
```sh
cargo run --release -p solidcraft -- --sample # desktop app with a sample part
cargo run --release -p solidcraft-cli -- commands # the command registry
cargo run --release -p solidcraft-cli -- run examples/bracket.json --out bracket.step
cargo xtask ci # fmt, clippy, tests, icons, assets, layers, wasm
```
More commands, the Windows cross-build and the browser build are in [docs/building.md](docs/building.md).
## AI agents (MCP)
`solidcraft-cli mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server. Headless it
runs its own design session, or with `--connect` it drives the running app so you can watch.
Claude Code: `claude mcp add solidcraft -- solidcraft-cli mcp`. Tools, access rules and an
example session: [docs/mcp.md](docs/mcp.md).
## Scripts
A script is a JSON list of the same commands the UI runs, executed with `solidcraft-cli run`.
Format and example: [docs/scripts.md](docs/scripts.md). Pages can also be driven through the
[browser command API](docs/browser-api.md).
## Contributing
Help is welcome. [CONTRIBUTING.md](CONTRIBUTING.md) covers how to build and test, the code map,
the clean-room and licence rules, and where help is wanted most. Open work is in
[GitHub issues](https://github.com/tangency-labs/solidcraft/issues); chat is on
[Discord](https://discord.gg/FtU4NQNVDH).
## Sponsors
[![CI powered by Blacksmith](docs/sponsors/blacksmith-ci-powered.png)](https://www.blacksmith.sh)
SolidCraft's continuous integration runs on [Blacksmith](https://www.blacksmith.sh), which
sponsors its CI minutes.
## Licence
MIT OR Apache-2.0, at your option. Developed under the
[clean-room contribution rules](AGENTS.md); third-party code and assets retain their
documented licences and attribution.
SolidCraft is independent. Behaviour is designed from public documentation, general CAD
conventions and user feedback. Reference tests use analytic values and open-source tools.
SolidCraft is not affiliated with, sponsored by or endorsed by Autodesk, Dassault Systèmes, PTC,
Siemens or any other CAD vendor. Fusion, AutoCAD, Inventor, SolidWorks, Onshape, Creo, Blender and
Tinkercad are trademarks of their respective owners, named only to describe compatibility
(see [NOTICE](NOTICE)).
[badge-windows]: https://img.shields.io/badge/Windows-Download-0078D6?style=for-the-badge&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0id2hpdGUiPjxwYXRoIGQ9Ik0yIDJoOS41djkuNUgyek0xMi41IDJIMjJ2OS41aC05LjV6TTIgMTIuNWg5LjVWMjJIMnpNMTIuNSAxMi41SDIyVjIyaC05LjV6Ii8%2BPC9zdmc%2B
[badge-macos]: https://img.shields.io/badge/macOS-Download-333333?style=for-the-badge&logo=apple&logoColor=white
[badge-linux]: https://img.shields.io/badge/Linux-AppImage-E95420?style=for-the-badge&logo=linux&logoColor=white
[badge-snap]: https://img.shields.io/badge/Snap-Snap_Store-82BEA0?style=for-the-badge&logo=snapcraft&logoColor=white
[badge-web]: https://img.shields.io/badge/Web-Try_in_browser-2EA44F?style=for-the-badge&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJ3aGl0ZSIgc3Ryb2tlLXdpZHRoPSIyIj48Y2lyY2xlIGN4PSIxMiIgY3k9IjEyIiByPSIxMCIvPjxwYXRoIGQ9Ik0yIDEyaDIwTTEyIDJhMTUgMTUgMCAwIDEgMCAyME0xMiAyYTE1IDE1IDAgMCAwIDAgMjAiLz48L3N2Zz4%3D
[windows-msi]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-windows-x64.msi
[windows-arm64-msi]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-windows-arm64.msi
[windows-x86-msi]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-windows-x86.msi
[windows-portable]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-windows-x64-portable.zip
[macos-dmg]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-macos-universal.dmg
[linux-appimage]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-linux-x86_64.AppImage
[linux-deb]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-linux-x86_64.deb
[linux-rpm]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-linux-x86_64.rpm
[linux-arm64-appimage]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-linux-aarch64.AppImage
[linux-arm64-deb]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-linux-aarch64.deb
[linux-arm64-rpm]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-linux-aarch64.rpm
[linux-snap]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-linux-x86_64.snap
[windows-msi]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-windows-x64.msi
[macos-dmg]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-macos-universal.dmg
[linux-appimage]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-linux-x86_64.AppImage
[snap-store]: https://snapcraft.io/solidcraft
[web-app]: https://bherbruck.github.io/solidcraft/app/
**Documentation:** the [SolidCraft book](https://bherbruck.github.io/solidcraft/) (user guide,
developer guide and command reference; sources in [docs/book](docs/book)).
**Status: early but usable for simple parts.** 75 of 76 public reference parts
match their analytic or FreeCAD-computed volume, area, centre of mass and bounding box
([docs/oracle.md](docs/oracle.md)); the other is a tracked bug.
These checks do not establish complete geometric or workflow equivalence. The commands SolidCraft has
are listed by area in [docs/features.md](docs/features.md); status and plans are in [ROADMAP.md](ROADMAP.md).
Open work and known bugs are in [GitHub issues](https://github.com/bherbruck/solidcraft/issues),
and help is welcome: see [CONTRIBUTING.md](CONTRIBUTING.md).
## Built with SolidCraft
<a href="https://github.com/0xlillith/solidcraft-rover"><img src="docs/screenshots/rover-build.gif" alt="SELENE, a scientific rover by 0xlillith, assembling itself as the timeline plays through its 3,323 steps in SolidCraft: tub, wheels, electronics, suspension, sampling arm, solar wings, dish and camera turret" loading="lazy"></a>
*SELENE, a scientific rover built entirely through SolidCraft's MCP server by
[@0xlillith](https://github.com/0xlillith/solidcraft-rover): 3,323 editable timeline steps that
rebuild with no errors, replayed here at 2× speed.
[Full video](https://github.com/bherbruck/solidcraft/raw/refs/heads/main/docs/screenshots/rover-build.mp4)
· [download the editable model](https://github.com/0xlillith/solidcraft-rover/releases/tag/v1.0.0).*
## What works
- **Sketches** on origin planes, offset planes or planar faces: lines, rectangles, circles,
arcs, polygons, slots, ellipses, fit/control splines, conics and text; construction geometry,
projection, trimming, fillets and chamfers, including corners at the origin.
New face attachments retain supported construction identities and chosen occurrences;
missing sources expose an explicit plane repair workflow. See [face attachments](docs/face-attachments.md).
Explicitly closed fit splines and closed associative offsets use periodic C2 interpolation.
Supported convex line-and-arc offsets retain their identity when an inset consumes corner
arcs, and linked extrusions follow the current boundary. Breaking the association freezes
the boundary and keeps existing extrusions usable.
Curve drawing and picking adapt together to zoom and display scale, including small circles
at Fit and sketches in placed components.
- **Constraints and dimensions** solved by our own solver. Points the constraints fix relative
to known geometry are placed exactly by construction (loci intersections and rigid-cluster
decomposition, after Owen 1991 and Fudos–Hoffmann 1997); Levenberg–Marquardt solves the rest,
and the rank of its Jacobian gives the remaining degrees of freedom. Constraints: coincident,
point on curve, horizontal/vertical, parallel, perpendicular, tangent, curvature (G2), equal,
concentric, collinear, midpoint, symmetry, fix; linear, aligned, horizontal, vertical,
point-to-line, radius, diameter, angle, arc sweep, arc length and slot length dimensions driven
by parameters. Over-constraining edits are rejected; fully constrained geometry turns black.
Dragging solves the sketch every frame and rebuilds the model once, when the drag ends.
Select a line or its two endpoints, type its dimension and press Enter to commit one undo
step; with Sketch Dimension it's D, click, type, Enter. Fit keeps sketch geometry clear of the palette and navigation controls.
- **Profiles** found automatically, including regions split by lines and holes inside outlines.
Newly captured complete adaptive boundaries retain their outer and hole source identities
when spline span counts change. Partial or ambiguous boundaries do not qualify.
- **Features** on a parametric timeline: extrude, revolve, sweep, loft, fillet/chamfer,
shell, draft, holes, threads, patterns, mirror, split, combine, move and construction geometry.
The timeline rebuilds incrementally; features can be edited, suppressed, renamed, reordered
and deleted. Supported geometry and current limitations are described in [ROADMAP.md](ROADMAP.md).
New revolves preserve symmetric and two-sided angular placement across reversed axes;
older designs retain their saved convention when reopened or edited.
- **Parameters and configurations** with unit-aware expressions (`2 * width + 5 mm`,
`angle / 2`), ordered rules, feature suppression and per-row body materials/appearances.
- **Assemblies** with nested components and occurrences, joints, motion studies, contact sets,
interference and material-aware mass properties. Recorded body transfers preserve modelling
history and world placement; 3D sketch wires retain their coordinate frames through edits.
- **Sheet metal** with flanges, hems, fold/unfold/refold, material cutouts, flat patterns,
retained shallow prismatic pockets, exact line/arc/circle DXF outlines and bend tables;
plastic enclosure features and rules. Pocket depth and side survive unfold/refold and appear
separately from through-hole outlines in the flat preview and DXF.
- **Files**: design containers (`.solidcraft`) with lossless binary mesh payloads;
plain JSON remains supported for opening, Save As and export. Desktop Open can show a saved
provisional picture from warm geometry caches while rebuilding before editing; caches never
replace the parametric design. Export to STEP (AP242 with body names, colours and
components as assemblies; revolved faces as native planes, cylinders, cones, spheres and tori),
IGES 5.3 (manifold solid B-reps with names and colours), 3MF, STL (binary/ASCII) and OBJ, and
assemblies with joints as URDF for robot simulators (docs/urdf.md);
3MF, STL and OBJ import as mesh bodies (they render, measure, move and export; solid features
need B-rep bodies). The `mesh.convert` command reconstructs supported closed constant-section parts
as parametric solids with planar and circular cylindrical faces;
STEP import (AP203/AP214/AP242 solids, assemblies, units, names, colours) and IGES import
(manifold solid B-reps, or trimmed and bounded surfaces sewn into solids) as a base feature
that later features build on — open a `.step`/`.stp`/`.igs`/`.iges` file, insert one into a
design, or drop it on the window ([docs/step-import.md](docs/step-import.md)).
- **Render:** opaque surfaces can use metal fraction, roughness and dielectric IOR in the
integrated product viewport and CPU preview PNGs. Existing simple colors remain supported.
Fixed direct lighting is a preview; HDRI, refraction and texture maps are not implemented.
See [opaque surfaces](docs/render-opaque-pbr.md).
- **Display meshes** from our own tessellation planner: each face becomes a plan of (u, v)
points and triangles that shares its edge nodes with its neighbours, so the mesh is watertight.
Facets follow the chord tolerance, the facet angle and a longest side, with cells at most 2:1
along straight directions. Box-bounded and four-cornered faces mesh as structured grids.
Trimmed faces are cut into four-sided patches: squares around holes, butterflies in round
faces, motorcycle traces from concave corners (after Lyon et al. 2019). Each patch then meshes
as a Coons grid. Intersection edges are evaluated by Newton on both surfaces. A compute shader
evaluates the plans for the whole scene in one dispatch, with a CPU fallback when the GPU
can't build it. Planning and evaluating the 78 reference bodies takes about 65 ms, 50–65×
faster than the previous mesher.
- **Desktop app** (egui + wgpu): toolbar with workspace tabs, browser, timeline, 3D viewport with
axis view and navigation bar, sketch tools, feature dialogs, command palette, and a JSON-lines
control channel so agents and tests can drive it.
Feature errors and Help can open an editable **Create Issue** draft with relevant context
and an optional reviewed screenshot; opening GitHub does not submit the report.
- **Headless CLI** `solidcraft-cli`: run command scripts, measure (volume, area, centre of mass,
face/edge/vertex counts), export, render PNG snapshots and replay reference recipes.
- **MCP server** `solidcraft-cli mcp`: AI agents model, measure and look at parts headless or
in the running app (see below).
## Build and run
```sh
cargo run --release -p solidcraft -- --sample # desktop app with a sample part
cargo run --release -p solidcraft-cli -- commands # the command registry
cargo run --release -p solidcraft-cli -- run examples/bracket.json --out bracket.step
cargo run --release -p solidcraft-cli -- snapshot examples/bracket.json --out bracket.png
cargo run --release -p solidcraft-cli -- render examples/render-studio.json --out product.png
cargo run --release -p solidcraft -- part.step # open a STEP file as a new design
cargo run --release -p solidcraft-cli -- eval part.step # measure an imported STEP file
cargo xtask ci # fmt, clippy, tests, assets, layers, wasm
```
Windows builds: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solidcraft`.
On Windows and Linux, SolidCraft detects a connected 3Dconnexion SpaceNavigator or SpaceMouse through its
multi-axis HID interface. Move the cap to pan and zoom, and tilt or twist it to rotate the 3D view.
While editing a sketch, the cap pans and zooms without changing the sketch view. The Navigation
page in Preferences has an on/off switch and a speed control for the 3D mouse.
On Linux SolidCraft reads `/dev/hidrawN` directly, so the logged-in user needs access to the
device. A udev rule that adds the `uaccess` tag does that, for example
`/etc/udev/rules.d/70-3d-mouse.rules` for devices with 3Dconnexion's vendor id:
```
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="256f", TAG+="uaccess"
```
Older SpaceNavigators use Logitech's vendor id `046d`; add a rule with their product id (see `lsusb`)
instead of matching every Logitech device. Reload with `sudo udevadm control --reload && sudo udevadm trigger`, then replug the device. The Flatpak
sandbox does not expose hidraw devices, so the 3D mouse only works with native builds there for now.
Devices that send all six axes in HID report 1 (some homemade ones do) are recognised from their
report descriptor, so they work as well.
In the browser: `cd apps/solidcraft-web && trunk build --release --public-url ./` (WebGPU, or
WebGL2 with `?webgl`; `?sample` opens the sample part).
## Scripts
A script is a JSON list of commands, the same ones the UI runs:
```json
{"commands": [
{"command": "sketch.create", "params": {"plane": "XY"}},
{"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [40, 30]}},
{"command": "solid.extrude", "params": {"distance": 20}},
{"command": "solid.fillet", "params": {"edges": [[0, 0, 10]], "radius": 3}}
]}
```
## AI agents (MCP)
`solidcraft-cli mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server on
stdio. Headless it runs its own design session; with `--connect 127.0.0.1:PORT` it drives the app
started with `solidcraft --control PORT`, so you can watch the agent model. Tools:
`list_commands`, `execute`, `batch`, `inspect_design`, `measure`, `body_topology`,
`set_parameter`, `screenshot` (returns the image), `export`, `undo`, `redo`, `new_design`, `open`,
`save`. Tool calls reach files only in the open design's folder (when it lies inside your home
folder, never home itself or a hidden folder), SolidCraft's recovery folder and folders given with
`--allow-path DIR`. Claude Code: `claude mcp add solidcraft -- solidcraft-cli mcp`. Details, client
configuration and an example session: [docs/mcp.md](docs/mcp.md).
## Architecture
<!-- ANCHOR: architecture -->
| Layer | Crates | Role |
|---|---|---|
| L0 | `geom` | vectors, planes, profiles, meshes and their measures |
| L1 | `sketch`, `kernel`, `render` | constraint solver and profiles; the B-rep kernel boundary (truck); view math and CPU rasterizer |
| L2 | `doc` | parameters, expressions, the feature timeline and its incremental evaluation |
| L3 | `io` | design files, STL/OBJ/STEP/3MF export, STEP and 3MF/STL import features |
| L4 | `engine` | the session and the command registry (everything is a command) |
| L5 | `gpu`, `ui-egui`, `mcp` | compute-shader evaluation of display tessellation plans; the swappable desktop front end; the MCP server (headless or bridged to the app) |
| apps | `solidcraft`, `solidcraft-cli` | desktop app, headless CLI |
<!-- ANCHOR_END: architecture -->
## Contributing
Coming from Autodesk Fusion? SolidCraft uses familiar parametric CAD workflows: constrained
sketches, solid features, an editable timeline, parameters and assemblies. See the
[roadmap](ROADMAP.md) for supported operations and current limitations.
See [CONTRIBUTING.md](CONTRIBUTING.md): how to build and test, the code map, the clean-room
and licence rules, and where help is wanted most.
## Licence
MIT OR Apache-2.0, at your option. Developed under the
[clean-room contribution rules](CLAUDE.md); third-party code and assets retain their
documented licences and attribution.
SolidCraft is independent. Behaviour is designed from public documentation, general CAD
conventions and user feedback. Reference tests use analytic values and open-source tools.
SolidCraft is not affiliated with, sponsored by or endorsed by Autodesk. Autodesk and
Fusion are trademarks of Autodesk, Inc.
[web-app]: https://solidcraft.io/app
+122 -19
View File
@@ -7,13 +7,17 @@ features → parametric timeline) as an open, pure-Rust, agent-drivable applicat
| Question | Answer |
|---|---|
| Where are we? | **M0 and M13 done; M1, M2, M5–M7, M9, M10, M14 in progress.** Sketch with solver and the full SKETCH create/modify set; extrude, revolve (two sides, symmetric, to object), fillet/chamfer (variable radius, chord length, a radius per edge, corner patches), holes, threads (cosmetic and modelled), patterns, mirror, shell (inside/outside/both), draft, loft, sweep, coil, split body and split face, delete face, construction planes, axes and points; persistent naming of faces and edges; parameters and configurations; components, joints, motion studies, exploded views; sheet metal and a plastic enclosure subset; STEP/IGES/3MF/STL/OBJ/DXF; autosave and crash recovery; desktop and browser (wasm) app, CLI, MCP server. |
| Where are we? | **M0 and M13 done; M1, M2, M5–M7, M9, M10, M14 in progress.** Sketch with solver and the full SKETCH create/modify set; extrude, revolve (two sides, symmetric, to object), fillet/chamfer (variable radius, chord length, a radius per edge, corner patches), holes, threads (cosmetic and modelled), patterns, mirror, shell (inside/outside/both), draft, loft, sweep, coil, split body and split face, delete face, construction planes, axes and points; persistent naming of faces and edges; parameters and configurations; components, joints, motion studies, exploded views; sheet metal and a plastic enclosure subset; STEP/IGES/3MF/STL/OBJ/DXF; FreeCAD/Rhino import; autosave and crash recovery; desktop and browser (wasm) app, CLI, MCP server. |
| Features | Every registered command by area — [docs/features.md](docs/features.md) (`cargo xtask features`); registry breadth does not establish workflow or geometric depth |
| Reference tests | **82 / 82 parts match** — [docs/oracle.md](docs/oracle.md): public cases in `tests/oracle/` with analytic (61) or FreeCAD-computed (21) expected values, run by CI on every PR. |
| Reference tests | [Current case count and results](docs/oracle.md): public cases in `tests/oracle/` with analytic or FreeCAD-computed expected values, checked by the reference-test job in [CI](.github/workflows/ci.yml). |
| Tests | Solver, profiles, kernel booleans/blends/measures, expressions, timeline, persistent naming, file formats and migrations, assemblies, sheet metal, engine end-to-end, hostile-input fuzz over every command and headless UI scenarios. `cargo xtask ci` reports the current passing counts. |
| Gates | `cargo xtask ci`: fmt, clippy -D warnings, tests, asset attribution, layering, wasm32 build — green |
| Time to a useful alpha (M0–M6) | ≈ **82 agent hours** remain (sum of the M1–M6 estimates below) |
Optional GPU viewport ambient occlusion is available in View settings and Graphics preferences,
with persistent settings, Performance/Quality graphics presets (Quality combines AO, Fine tessellation and adaptive 2× supersampling; manual combinations show Custom), and the
`view.ambient_occlusion` and `view.graphics_preset` commands. See [display settings](docs/ambient-occlusion.md).
## Scope (owner decision, 2026-10-07)
The issue batch adds preservation of blind pockets in through cuts, associative projected
@@ -46,32 +50,68 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
| # | Milestone | Status | Hours left |
|---|---|---|---|
| M0 | Vertical slice | done | — |
| M1 | Sketch depth: command coverage implemented — projection/intersect/include as linked geometry (auto-project on faces, lost-reference warnings, break link), full SKETCH create/modify set (slots, polygons, tangent arcs/circles, ellipse, fit/control splines, conic, text, trim/extend/break, fillet/chamfer, offset, mirror, patterns, move/scale, blend curve, centerline), constraints incl. curvature and polygon with over-constraint refusal, driven/arc-length/linear-diameter dimensions, AutoConstrain, DXF/SVG insert, 3D sketch curves (Project To Surface, Intersection Curve, Isoparametric Curve, Spun Profile), exact free-form profiles (B-spline/NURBS edges, any spline degree, curves cut by crossings), sweeps and pipes along 3D sketch curves, canvases and decals, curvature combs. Exact free-form Extend and certified associative offsets now preserve geometry and IDs through edits, deletion and reopen. Occurrence-frame 3D wires now store explicit coordinate spaces and retain points and analytic fits through moves, refresh, undo and reopen. Constructive sketch solving (Owen 1991, Fudos & Hoffmann 1997) ahead of the numerical solver: points and rigid clusters are placed on the branch the sketch had, so dimension edits and drags keep their solution; edits that would turn a sketch over are refused; drags re-solve only the sketch per frame. Remaining workflow repairs: mixed offset junctions, legacy natural-spline seams, and damaged legacy wire links lacking frame provenance | in progress | 4 |
| M2 | Robust modelling: coplanar booleans in the kernel, holes (placed on faces and on sketch points), threads (cosmetic, then modelled), patterns, mirror, shell, draft, split, construction geometry | in progress | 16 |
| M3 | General fillets and chamfers (curved edges, chains, vertex blends); saddle rolling-ball surfaces now use continuous seam sections and arc-length fitting with shared contact boundaries and bounded accuracy checks | in progress | 30 |
| M4 | Files: STEP import (done — [docs/step-import.md](docs/step-import.md)), 3MF export and 3MF/STL import as mesh bodies (done), 3MF with B-rep face tags ([docs/formats/3mf-face-tags.md](docs/formats/3mf-face-tags.md), done), constant-section parametric mesh reconstruction, DXF sketches, OBJ import | in progress | 6 |
| M1 | Sketch depth: command coverage implemented — projection/intersect/include as linked geometry (auto-project on faces, lost-reference warnings, break link), full SKETCH create/modify set (slots, polygons, tangent arcs/circles, ellipse, fit/control splines, conic, text, trim/extend/break, fillet/chamfer, offset, mirror, patterns, move/scale, blend curve, centerline), constraints incl. curvature and polygon with over-constraint refusal, Shift-picked line midpoints in Horizontal/Vertical using persisted points and ordinary constraints (#2087), driven/arc-length/linear-diameter dimensions, AutoConstrain, DXF/SVG insert, 3D sketch curves (Project To Surface, Intersection Curve, Isoparametric Curve, Spun Profile), exact free-form profiles (B-spline/NURBS edges, any spline degree, curves cut by crossings), sweeps and pipes along 3D sketch curves, canvases and decals, curvature combs. Exact free-form Extend and certified associative offsets now preserve geometry and IDs through edits, deletion and reopen. Occurrence-frame 3D wires now store explicit coordinate spaces and retain points and analytic fits through moves, refresh, undo and reopen. Constructive sketch solving (Owen 1991, Fudos & Hoffmann 1997) ahead of the numerical solver: points and rigid clusters are placed on the branch the sketch had, so dimension edits and drags keep their solution; edits that would turn a sketch over are refused; drags re-solve only the sketch per frame. Remaining workflow repairs: mixed offset junctions, legacy natural-spline seams, and damaged legacy wire links lacking frame provenance | in progress | 4 |
| M2 | Robust modelling: coplanar booleans in the kernel, holes (placed on faces, at distances from reference edges, and on sketch points), threads (cosmetic, then modelled), patterns, mirror, shell, draft, split, construction geometry | in progress | 16 |
| M3 | General fillets and chamfers (curved edges, chains, vertex blends); saddle rolling-ball surfaces now use continuous seam sections and arc-length fitting with shared contact boundaries and bounded accuracy checks; tiny rim edges remain selected instead of nearby wall seams when filleting (#2498) | in progress | 30 |
| M4 | Files: STEP import (done — [docs/step-import.md](docs/step-import.md)), FreeCAD/Rhino solids through cadmpeg (done, desktop, browser and CLI), 3MF export and 3MF/STL import as mesh bodies (done), 3MF with B-rep face tags ([docs/formats/3mf-face-tags.md](docs/formats/3mf-face-tags.md), done), constant-section parametric mesh reconstruction, DXF sketches, OBJ import | in progress | 6 |
| M5 | Persistent naming of faces and edges (docs/book/src/dev/naming.md): faces named from their exact kernel-reported history (sketch curve sides, caps, revolve sections, primitive sides, blends, shell inner walls, pattern/mirror copies; split pieces told apart by the faces around them, lumps of a cut body named after their faces), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. kernel-reported face history: done (merged faces answer to every name; operations without history yet fall back to geometric names, flagged unverified; format 14 keeps older designs' geometric names). Left: history through sheet metal, plastic, surface and emboss features; FaceSource certificates through history | in progress | 20 |
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, dependency-checked single and multi-item reorder, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names; optional compact named left feature history with rollback bar, multi-selection and persistent folders above the parts browser), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; preferences | in progress | 6 |
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, dependency-checked single and multi-item reorder, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names; responsive feature history with width-driven icons/names/title, snapping width resize, bottom/left/browser-stack docking, shared-column collapse, rollback bar, multi-selection and persistent folders), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator, extrude taper), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; preferences | in progress | 6 |
| M7 | Thin extrude (walls along open or closed curves: centre, inside, outside or two sides; mitred corners, crossing walls joined; #721), sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 |
| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane or a face), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis. Proposed, in order: surface Extrude/Revolve/Sweep/Loft from open and closed curves (#757), Offset surface (#758), Unstitch and Reverse Normal (#766), Delete Face without healing (#768); Extend curved faces and edges (#760), mutual trim and trim by projected curves (#767), Untrim (#761), Thicken every surface kind (#765); Ruled (#762), Boundary surface and curve networks with G0/G1/G2 (#763), Patch with G1/G2 and general loops (#759), Blend surface (#764), sweep guide rails and two-rail sweeps (#771; orientation, twist and end scale done; loft rails #201), fillet between surface bodies (#769); continuity analysis, surface curvature combs and isocurves (#770) | deferred | — |
| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, closed kinematic loops (four-bar, slider-crank: the loop's other joints follow the driven one on its branch, and driving stops where a loop can no longer close), As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure; URDF export for robot simulators (links with meshes, mass and inertia; joint chains whose values match Drive Joints; docs/urdf.md). Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done. PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line); flanges meeting at a corner closed at any angle with the rule's gap; edge flanges with their own angle and height per edge in one feature; corner reliefs (notch, round, rectangular, tear) chosen by the rule; Convert to Sheet Metal from a solid or shelled body by its bend edges (flat faces, sharp bends) | in progress | 36 |
| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0, plus `crown`), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane, a face or sketch curves swept onto the surface, keeping a side or the picked regions; mutual trim of two surfaces, extended to meet and stitched, #767), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis, Surface Extrude/Revolve/Sweep/Loft from open and closed curves, Offset, Unstitch and Reverse Normal; the continuity-driven core (`kernel/src/bridge.rs`, G0–G3 Hermite rows) with **Blend** (`surface.blend`, #764: edge chains along one or several faces, closed rims paired seam to seam or from picked seams, a dialog), **Match** (`surface.match`), **Boundary** (`surface.boundary`, #763 — two sketch curves exact with G0–G3, three or four as the flat patch of their loop, and curve networks: Coons and Gordon surfaces through sketch, 3D-sketch or edge curves, a point for a three-sided side, G1/G2 against the faces along one direction's edges, joined into their body on request; G1/G2 on two sides that meet at a corner waits for the Gregory-type corner), **Fair** (`surface.fair`, CV fairing), **continuity analysis** (`inspect.continuity`, #770; a toolbar tool with edge markers and a legend, and curvature combs along face isocurves) and a multi-section **loft** with per-end continuity and crown, and guide rails on solid and surface lofts (one or two, scaling or stretching the sections between; #201). Also in the Class-A pass: G1…G4 blend curves in the sketch, chord and variable-radius fillets on curved edges (with honest refusals where the fit cannot hold), and a G2 corner patch where filleted edges meet (kernel; the command refuses until the patch's sections can be sewn to the blends' contacts). Left: bias/form-factor fillet sections (design recorded), a Gregory-type corner, surface Fillet between separate surface bodies (#769), Ruled (#762), Untrim (#761), curved-face Extend (#760), and every remaining #757–#772 item | deferred | — |
| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, closed kinematic loops (four-bar, slider-crank: the loop's other joints follow the driven one on its branch, and driving stops where a loop can no longer close), cams (`joint.cam`: a revolute cam drives a slider or revolute follower along a closed sketch profile or the cam's edges, knife, roller or flat followers, worked out again when the profile changes; drives, studies, closed loops, contact sets), As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), motion analysis (joint values and a point's path charted over a study as value, velocity and acceleration; the parts that collide along a study, with the steps where they do), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure; URDF export for robot simulators (links with meshes, mass and inertia; joint chains whose values match Drive Joints; docs/urdf.md). Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link, Cam and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done. PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line); flanges meeting at a corner closed at any angle with the rule's gap; edge flanges with their own angle and height per edge in one feature; corner reliefs (notch, round, rectangular, tear) chosen by the rule; Convert to Sheet Metal from a solid or shelled body by its bend edges (flat faces, sharp bends) | in progress | 36 |
| M11 | 2D drawings | deferred | — |
| M12 | Release: cross-platform packages, verification and deliberate publication implemented; original current-UI demo refreshed. Trusted distribution signing and broader performance work remain | in progress | 16 |
| M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (config.configure, config.table, config.column/row/activate). Per-row body materials and appearances (library looks, custom colour/opacity and clearing overrides), undoable activation and save/reopen: done. Ordered typed configuration rules, atomic validation and structured rule editing: done. Component appearance and certified semantic face style columns, atomic preview/apply and independent-copy remapping: done. Exact constructed sketch-side and immutable imported-face lineage now preserve legacy names and reject retargeting. Left: ambiguous shifted/two-sided caps | in progress | 4 |
| M13 | Never lose work: atomic saves (temporary file, flush, rename; a crash at any moment leaves the old file or the new one) with the replaced version kept as `<file>.bak`; autosave of unsaved changes to the recovery folder every 5 minutes (preference) and after big operations, only when something changed; "Recover unsaved design?" on the next launch (`doc.recovery_list`, `doc.recover`, `doc.recovery_discard`); a kill-mid-save test (process killed while saving a 0.5 MB design 25 times). File format: `"format": "solidcraft/12"`, older formats upgraded step by step (`doc::format`), newer ones refused with a message, out-of-range numbers and broken placements refused; a corpus of designs saved by M0, components-era, joints-era and current builds (apps/solidcraft-cli/tests/corpus) that must open and evaluate as saved; damaged and hostile variants (truncation, bit flips, hostile values, removed keys, repeated features, deep nesting) never panic | done | 6 |
| M15 | Frames and weldments (#4003): structural members along 2D and 3D sketch lines, butt and mitre corners, trim to faces and bodies, end caps, gussets, cosmetic welds and a cut list. Done: the profile library (`frame.profile.list`, `frame.profile.properties`, `frame.profile.insert`): bar, round bar, rectangular and round tube, angle, channel and I-beam, each SolidCraft's own parametric definition with exact fillet arcs and exact section properties (area, centroid, second moments), tested against hand-computed values; members (`frame.member`): one body per straight line of a 2D or 3D frame sketch, following parameter edits, with a built-in profile (its dimensions parameter expressions) or a profile sketch (its drawn material by alternating nested loops), anchor, angle and mirror; each member's name kept with its line. Left: corners, trims, end caps, gussets, welds, cut list, FRAME tab and dialogs | in progress | 60 |
M6 navigation preferences include **Touchpad**: Shift + pointer movement pans, Alt/Option +
pointer movement orbits without a mouse button, and vertical scrolling zooms. Other presets
retain their saved two-finger scroll preference; camera motion is available through
`view.navigate` and modifier-aware `ui.move` automation.
With the **Fusion** preset and two-finger pan enabled, two fingers pan and Shift + two fingers
orbit freely, with pinch zoom and mouse bindings unchanged. The shared navigation handler
applies the same gestures to model and Render views and blocks orbit on flat pages.
UI redesign (#2016): the File/start view (#2027) uses shared light/dark navigation,
action tiles, recent rows and sample cards, with recovery review and Save As/Export
entry points. Document loading, sample caches and recovery safety retain their
existing command paths.
M11 precursor: File → Export Current View as DXF exports shown placed bodies and sketches from the current
camera as a 2D millimetre drawing, with hidden edges omitted or dashed. Vector occlusion splits
partly hidden edges and includes curved silhouettes. Repeated part meshes and triangle
hierarchies are shared; bounded desktop preparation and occlusion use up to eight workers.
Curves are approximated; drawing sheets,
annotations and exact projected curves remain deferred. See [view DXF](docs/view-dxf.md).
Drawing foundations: [RFC #860](https://github.com/tangency-labs/solidcraft/issues/860)
and the [foundation ledger](docs/drawing-foundations.md) track the bounded implementation
packages. The first geometry increment defines validated section records in a plane-local
frame; the ledger separates that contract from section producers and the Drawing workspace.
Desktop view DXF export runs in the file-operation worker with five-stage progress and
cooperative cancellation. Curve tolerance (0.0001–1 mm, default 0.01 mm) is remembered alongside
the hidden-edge setting. Cancelled exports preserve the model and the previous destination;
completed drawings publish atomically. The browser still exports synchronously.
## Interactive modelling
- Modelled threads preview their cut before OK using the background worker on desktop.
Browser users apply the cut with OK to avoid blocking on every edit.
- Thread resolves a suggested metric size when a cylindrical face is picked. Diameter and
pitch selectors expose the existing metric designation parameter; custom designations stay
editable, and Automatic size returns to the face's suggestion. Cosmetic/modelled and
full/partial-length options remain available. This does not add tolerance classes or
left-hand threads.
- New Body extrusion and face-offset previews show completed intermediate geometry while a
drag continues, then catch up to the exact final value. Changes to the selected geometry,
operation or feature still discard obsolete results. Distance arrows and their value boxes
follow the current frame's input.
- Large fine modelled threads use pitch-based helix resolution and tighter faceted
reconstruction fallbacks, preserving thin crests and closed shells on large shafts.
## Physical material data
@@ -103,6 +143,12 @@ retain their saved two-finger scroll preference; camera motion is available thro
## Render foundation
- Original starter appearances (#1722): 34 opaque recipes through existing commands, with six explicitly unavailable glass recipes and preserved legacy looks; see [the contract](docs/render-starter-appearances.md).
- Bounded linear film accumulation (#1696) supplies caller-admitted mean/variance storage and complete-pass views; actual progressive consumers remain separate. See [the film contract](docs/render-film.md).
- Definition-local visual mapping (#1214) supplies a bounded numerical frame, physical repeat coordinates and a deterministic one-time grain-axis suggestion. It adds no saved record or texture consumer; see [the mapping contract](docs/render-visual-mapping.md).
- Explicit texture normalization (#1218) produces completed float RGBA with
role-aware linear color/data semantics, bounded matrix/TRC ICC conversion and
opt-in EXIF orientation. It retains high-precision raw samples and records lossy
@@ -198,6 +244,9 @@ retain their saved two-finger scroll preference; camera motion is available thro
Type a value and press Enter to commit one undo step; rejected values remain editable and
cancellation leaves the sketch unchanged. Fit keeps geometry clear of the Sketch Palette
and navigation controls, including enlarged displays and nested pending placements.
- Sketch Dimension takes a line and a circle or arc (#1803): it measures from the line to the
circle's edge (the gap, 0 when tangent, or how far the edge reaches past a line that cuts
it), drives it like any other dimension and keeps the circle on its side of the line.
- Sketch annotations, live drawing lengths and Measure use document units. Inches show
three decimals and retain a meaningful fourth digit. Bare length dimension entry captures
the current design unit; changing units leaves existing geometry and expressions intact.
@@ -223,12 +272,13 @@ retain their saved two-finger scroll preference; camera motion is available thro
`diagnostics.crash_reports`, the MCP `crash_reports` tool or the CLI. Nothing is sent
anywhere. See [crash reports](docs/crash-reports.md).
- Agent chat (off by default, desktop): a side panel that drives a coding agent the user
already has (Claude Code through its ACP adapter, Codex, Gemini CLI, OpenCode, …) over the
already has (Claude Code through its ACP adapter, Codex, Gemini CLI, OpenCode, Grok Build, …) over the
Agent Client Protocol, under the user's own login; SolidCraft stores no keys. The agent works
through SolidCraft's MCP tools on a gated endpoint of its own: file actions wait for Allow or
Deny, crash reports are off limits, 24 changes per message, one undo step per message, and
the agent's whole process group ends with the panel or the app. Docks left or right,
minimises to a strip. See [agent chat](docs/agent-chat.md).
minimises to a strip. Grok Build discovery includes `~/.grok/bin` and reuses the user's
Grok login. See [agent chat](docs/agent-chat.md).
- Command scripts from designs (#886): `timeline.export_script` (CLI `solidcraft-cli script`)
writes a design's history as a script that `eval` and `run` replay into the same design —
user parameters, units, sheet metal and plastic rules and materials as their own commands,
@@ -256,6 +306,25 @@ retain their saved two-finger scroll preference; camera motion is available thro
## 0.2.1 critical fixes
- Separated multi-profile Join/Cut extrusions into one solid target batch their tools using
conservative construction bounds, avoiding a Boolean rebuild for every profile. Live
extrusion previews build only their tools; confirmation validates the final Boolean.
- Coplanar selected body faces share one linked sketch and one extrusion. Automatic Join/Cut
keeps the evaluator's participant scope, including parts of an empty imported component. Desktop OK/Enter
computes on a scratch session in the background, with atomic acceptance and cancellation.
Planar section stitching projects lines/arcs analytically and removes duplicate endpoints.
- Imported rotated face groups can check all full start-face seams in one guarded
construction. Separated tools may batch a Join when each meets the same single body;
nearby uninvolved bodies retain the original contact decisions. Regression comparisons
cover all four extrusion operations.
- Inward multi-face cuts retain every eligible target and subtract separated tools together.
Safe interior classification handles roundoff at shared imported line/arc endpoints.
Straight extrusion feedback caches existing face/profile triangles and updates the display
directly, including a newly opened STEP's single identity component placement; other
extents, moved or repeated components and curved sketch profiles keep the Engine preview.
- The extrusion fixes retain tracked Boolean history and the current `solidcraft` packages.
Confirmation and tool previews use main's shared background calculation. Main's collapsed
blend-wall refusal and its tests are retained, including smaller valid radii and mirrored parts.
- Axis view and navigation controls stay anchored to the viewport when feature dialogs or
the command palette open, at normal and enlarged display scales. Fit still accounts for
dialog coverage; covered controls do not receive clicks through dialogs.
@@ -275,8 +344,25 @@ retain their saved two-finger scroll preference; camera motion is available thro
- Saved projections from a removed strip retain their geometry and report the lost
reference. Explicitly reselect the wall to repair that projection.
## Workspace layouts (#2029)
- General preferences save Floating or Compact using the universal UI containers.
Compact joins the workspace selector and ribbon, docks the content-hugging Browser
flush beneath them with a square join, and opens tool sheets below the compass.
Sheets remain draggable, rounding all corners away from their right dock; footer
backgrounds follow the same corners. A separate preference hides ribbon icon labels
for a shorter ribbon in either layout.
Assistant keeps the outermost edge and History keeps its chosen dock. Switching
retains the active tool, camera, selection and design; system windows and menus
keep their existing behavior.
## View navigation
- Navigation has an opt-in Free orbit preference for mouse and 3D-mouse rotation through
both poles with roll. It defaults off, preserving existing preset snapping and pole limits.
The SolidWorks face menu offers Normal to Selection (`view.normal_to`), shared with Look At
and Ctrl+8, including repeat-to-flip, preserving scale and projection.
- Desktop Ctrl/Cmd +/- and 0 change or reset the saved UI scale. The scale slider keeps
its layout stable during a drag and applies the selected scale on release.
@@ -296,12 +382,17 @@ retain their saved two-finger scroll preference; camera motion is available thro
- Associative shell-face sketches require a unique certified planar source. Nested shell
attachments and ambiguous coincident sources remain unsupported.
- G2 fillets: exact quintic blends of straight edges between planar faces, with one perpendicular
planar end face at each end. The UI and `solid.fillet` expose `curvature` (alias `g2`); the size
is an equivalent circular setback. Curved faces/edges, sloped ends and corner junctions remain unsupported.
- G2 fillets: exact quintic blends of straight edges between planar faces, with one planar
end face at each end, including sloped ends (#2708). The UI and `solid.fillet` expose
`curvature` (alias `g2`); the size is an equivalent circular setback. Curved faces/edges,
multi-face ends and corner junctions remain unsupported.
- Fillets and chamfers: constant, variable-radius and chord-length fillets; a radius per edge,
rounds next to earlier rounds of another radius, and smooth corner patches where three rounds
meet (a sphere for equal radii); straight edges (convex or concave) between planar faces with perpendicular
meet (a sphere for equal radii); perpendicular corner fillets after a closed ring of earlier
rounds preserve the ring's radii and the enclosed planar face (#1557);
mitres between separate rounds of the same radius keep their endpoint tangent, so a later
fillet can select an upright and its adjoining bottom mitre as one chain (#2422);
straight edges (convex or concave) between planar faces with perpendicular
planar end faces (equal-distance chamfers also meet sloped planar end faces, including inner
basin rims within the rim width); convex fillets also meet sloped planar ends and reflex
corners around pocket rims, with unequal wall slopes and extensions confined to the face;
@@ -310,7 +401,9 @@ retain their saved two-finger scroll preference; camera motion is available thro
bevel faces, splitting their shared seam; picks on the same tangent chain are applied once (#1303);
whole smooth loops of a planar face, picked whole or by one edge (tangent
chain), including circles and walls at any angle (boss bases and tops, bores, pocket floors,
plate outlines), loops with sharp corners between straight edges (the blends meet in mitres), runs of
plate outlines), loops with sharp corners between straight or mixed arc/line edges (straight
blends meet in mitres; perpendicular curved walls use offset intersections, with D-rim removal
checked within 0.1% of analytic offset slices), runs of
neighbouring edges along a planar face's loop (two top edges of a box: mitred, ending square;
with the edge between them too, the corner becomes a sphere octant),
rounded corners rounded again (sphere octants, tori at concave corners); every edge of a
@@ -363,6 +456,9 @@ retain their saved two-finger scroll preference; camera motion is available thro
A sweep with a twist, an end scale or a parallel or direction orientation (#771) follows its
path that way too, even along sketch lines and arcs: B-spline walls through stations, ruled
walls through denser stations where the path has corners or the profile has holes.
Guide rails (one or two, #771) do the same: each station meets every rail once, and the rail
turns the profile and scales it (or stretches it across to the rail) against the start; two
rails carry it between them. Rails along a path with sharp corners are refused for now.
- Extend intersects exact ellipses and rational/high-degree splines without moving the line or
circular support. Associative free-form offsets have a whole-span 0.002 mm normal-distance
certificate and refine within bounded node limits while retaining existing IDs.
@@ -404,10 +500,13 @@ retain their saved two-finger scroll preference; camera motion is available thro
and author occurrence paths retain the chosen placements; missing/split faces refuse rather
than retarget. General free-form profiles, seams, stepped cylinders and other curved surfaces
remain unsupported. STEP round-trip and independent radial/volume checks cover this subset.
- Shell and offset faces work on bodies of planes, cylinders, cones and spheres (convex or not:
- Shell and offset faces work on bodies of planes, cylinders, cones, spheres and tori (convex or not:
filleted boxes, bosses, domes, countersinks; corners where four or more planes meet, such as a
floor falling to a drain, as long as the corner's edges are all convex or all concave); draft on convex planar bodies and on walls (planes, cylinders → cones) between caps; lofts through three or more sections are smooth (B-spline sides through every section), two
sections ruled.
sections ruled. Circular-meridian barrels retain curved inner walls through either axial
opening (#2421). Solid lofts preserve holes through sections with matching hole counts
(#2435), including inner walls and caps with openings; a hole cannot appear or disappear
between sections, and sections with holes cannot collapse to a point.
- Sketch corner tools: fillet and all three chamfer modes pick a connected corner even when
its endpoint overlaps the fixed origin or a loose point. Ordinary drawing still snaps to
@@ -494,6 +593,10 @@ retain their saved two-finger scroll preference; camera motion is available thro
continuous collision detection; local thin walls and small details remain limitations.
See [contact-set limits and recovery](docs/contact-sets.md).
## Assembly dragging
The design's first part is grounded when it is made, and a left-button drag repositions a free part (`occurrence.drag`: one undo step, exact occurrence paths including repeated jointed definitions, any floating-chain member carries the chain, grounded parts and joints hold where present, Escape cancels).
## 3D sketch bridges
3D sketch drawing snaps to points on other visible sketch planes without flattening them onto
+1797 -360
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -21,5 +21,11 @@ path = "src/alias.rs"
[dependencies]
solidcraft-engine = { workspace = true }
solidcraft-gpu = { workspace = true }
solidcraft-mcp = { workspace = true }
log = { workspace = true }
serde_json = { workspace = true }
[dev-dependencies]
# Snapshot tests decode the pictures to compare them with a tolerance.
image = { workspace = true }
@@ -0,0 +1,102 @@
//! Reproducible evidence through the production command/job interfaces.
//! Usage: studio_evidence RECIPE.json RENDER-PARAMS.json OUTPUT.png
//! Writes the editable design, command timings and actual adapter/job metadata beside PNG.
use serde_json::{Value, json};
use solidcraft_engine::{
Session,
render::job::{CpuHost, ErrorCode, JobId, Phase},
};
use std::{
io::Read,
path::Path,
time::{Duration, Instant},
};
fn read_json(path: &str) -> Result<Value, String> {
let mut bytes = Vec::new();
std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?.take(16_777_217).read_to_end(&mut bytes).map_err(|e| e.to_string())?;
if bytes.len() > 16_777_216 {
return Err("evidence input exceeds 16 MiB".into());
}
serde_json::from_slice(&bytes).map_err(|e| format!("{path}: {e}"))
}
fn run() -> Result<(), String> {
let args: Vec<String> = std::env::args().skip(1).collect();
let [recipe_path, params_path, output_path] = args.as_slice() else {
return Err("usage: studio_evidence RECIPE.json RENDER-PARAMS.json OUTPUT.png".into());
};
let output = Path::new(output_path);
if !output.extension().is_some_and(|e| e.eq_ignore_ascii_case("png")) {
return Err("output must have a .png extension".into());
}
let recipe = read_json(recipe_path)?;
let params = read_json(params_path)?;
let mut session = Session::default();
eprintln!("Generating original CAD scene from {recipe_path}");
let generation_start = Instant::now();
let (commands, command_timings) = session.run_script_timed(&recipe, false).map_err(|e| e.to_string())?;
let generation_seconds = generation_start.elapsed().as_secs_f64();
let design_path = output.with_extension("solidcraft");
session.execute("file.save_as", &json!({"path":design_path})).map_err(|e| e.to_string())?;
let scene = solidcraft_engine::product_scene::extract(&session).map_err(|e| e.to_string())?;
let bodies: Vec<Value> = scene
.meshes
.iter()
.map(|mesh| json!({"name":mesh.name,"vertices":mesh.mesh.positions.len(),"triangles":mesh.mesh.triangles.len()}))
.collect();
drop(scene);
let policy = solidcraft_gpu::select::Policy::from_env().map_err(|e| e.to_string())?;
let (adapter, adapter_report) = solidcraft_gpu::select::pick(policy).ok_or("no adapter satisfies the requested GPU policy")?;
let host_start = Instant::now();
session.set_render_job_host(Some(CpuHost::for_platform().map_err(|e| e.to_string())?)).map_err(|e| e.to_string())?;
session.register_render_job_host(solidcraft_gpu::path::host_on_adapter(&adapter).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
let host_seconds = host_start.elapsed().as_secs_f64();
let preflight = session.execute("render.preflight", &params).map_err(|e| e.to_string())?;
let start = Instant::now();
let started = session.execute("render.start", &params).map_err(|e| e.to_string())?;
let id: JobId = serde_json::from_value(started.get("job").cloned().ok_or("missing job id")?).map_err(|e| e.to_string())?;
let handle = session.render_job_handle(id).map_err(|e| e.to_string())?;
eprintln!("Rendering through production gpu-path host: {adapter_report:?}");
let status = loop {
match handle.wait_timeout(Duration::from_secs(10)) {
Ok(status) => break status,
Err(error) if error.code == ErrorCode::TimedOut && start.elapsed() < Duration::from_secs(3600) => {
let status = handle.status().map_err(|e| e.to_string())?;
eprintln!(
"Render progress: {:?} {}/{} after {:.1}s",
status.progress.phase,
status.progress.done,
status.progress.total,
start.elapsed().as_secs_f64()
);
}
Err(error) => {
let _ = handle.cancel();
return Err(error.to_string());
}
}
};
let render_and_encode_seconds = start.elapsed().as_secs_f64();
if status.progress.phase != Phase::Completed {
return Err(status.error.map_or_else(|| "render did not complete".into(), |e| e.to_string()));
}
let exported = session.execute("render.export", &json!({"job":id,"path":output_path,"overwrite":true})).map_err(|e| e.to_string())?;
let report = json!({
"recipe":recipe_path,"render_params":params,"adapter":adapter_report,
"environment":{"SOLIDCRAFT_GPU":std::env::var("SOLIDCRAFT_GPU").ok(),"WGPU_BACKEND":std::env::var("WGPU_BACKEND").ok()},
"cad_generation_seconds":generation_seconds,"gpu_host_setup_seconds":host_seconds,
"render_and_encode_seconds":render_and_encode_seconds,
"timing_scope":"wall time from render.start through completed PNG encoding; includes job preparation and readback, excludes CAD generation and initial GPU pipeline setup",
"bodies":bodies,"commands":commands,"command_timings":command_timings,
"preflight":preflight,"export":exported,"editable_design":design_path
});
std::fs::write(output.with_extension("json"), serde_json::to_vec_pretty(&report).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
println!("{}", serde_json::to_string_pretty(&report).map_err(|e| e.to_string())?);
Ok(())
}
fn main() -> Result<(), String> {
run()
}
+2 -1
View File
@@ -36,8 +36,9 @@ impl Script {
);
}
/// A part placed after it is built: free, even as the design's first part (#2804).
fn component(&mut self, name: &str) {
self.c("component.create", json!({"name": name}));
self.c("component.create", json!({"name": name, "grounded": false}));
}
fn root(&mut self) {
+700
View File
@@ -0,0 +1,700 @@
//! `bench-assistant`: the standard Assistant model test (v1, #4851), run against a desktop app.
//!
//! The app is already running with `--control PORT`; everything goes through its control channel
//! (`docs/control-protocol.md`), so the command needs no window code of its own. For each task it
//! starts a new design, runs the task's prescribed setup (a box, for some), adds the endpoint as an
//! API agent with All permissions, starts a new chat, sends the prompt once and waits for the turn
//! to end. The result is checked on the design (`document.inspect` with measurements), never on
//! what the model says, except for the one question whose answer is the reply. A failed task is
//! put in one of the issue's failure categories from the conversation's items.
//!
//! ```text
//! solidcraft-cli bench-assistant --connect 127.0.0.1:PORT --base-url URL --model NAME
//! [--token T] [--tasks 1,2,5] [--repeat N] [--timeout-s 300]
//! [--json out.json] [--markdown out.md]
//! ```
//! prints a table of the results; `--markdown` writes the issue's comment template filled in,
//! `--json` every task's rules, conversation and design before and after.
use std::io::{BufRead, BufReader, Write};
use std::net::{SocketAddr, TcpStream};
use std::time::{Duration, Instant};
use serde_json::{Value, json};
/// Standard test v1, as #4851 states it. Changing a prompt or a rule makes it another test.
const TASKS: &str = r#"[
{"id": "01-cube", "prompt": "Make a 20 mm cube.", "check": {"bodies": 1, "volume": 8000, "bbox_size": [20, 20, 20]}},
{"id": "02-cylinder", "prompt": "Make a cylinder 30 mm in diameter and 50 mm tall.", "check": {"bodies": 1, "volume": 35342.92, "bbox_size": [30, 30, 50]}},
{"id": "03-block", "prompt": "Create a block 60 mm long, 40 mm wide and 10 mm thick.", "check": {"bodies": 1, "volume": 24000, "bbox_size": [10, 40, 60]}},
{"id": "04-sketch-extrude", "prompt": "Sketch a 50 by 30 mm rectangle on the XY plane and extrude it 15 mm.", "check": {"bodies": 1, "volume": 22500, "bbox_size": [15, 30, 50], "feature_kinds": ["Extrude"]}},
{"id": "05-plate-hole", "prompt": "Make a plate 80 x 60 x 10 mm with a 20 mm diameter hole straight through the middle.", "check": {"bodies": 1, "volume": 44858.41, "bbox_size": [10, 60, 80], "note": "80*60*10 - pi*10^2*10"}},
{"id": "06-fillet-box", "prompt": "Round all the edges of the box with a 3 mm radius.", "setup": [{"command": "solid.box", "params": {"length": 40, "width": 40, "height": 40}}], "check": {"bodies": 1, "volume": 63108.53, "tol": 0.003, "feature_kinds": ["Fillet"], "note": "all 12 edges of a 40 mm cube rounded r=3"}},
{"id": "07-move-body", "prompt": "Move the box 50 mm along the X axis.", "setup": [{"command": "solid.box", "params": {"length": 20, "width": 20, "height": 20}}], "check": {"bodies": 1, "volume": 8000, "bbox_min": [50, 0, 0]}},
{"id": "08-rename-body", "prompt": "Rename the body to Bracket.", "setup": [{"command": "solid.box", "params": {"length": 30, "width": 20, "height": 10}}], "check": {"bodies": 1, "volume": 6000, "body_name": "Bracket"}},
{"id": "09-volume-question", "prompt": "What is the volume of the part?", "setup": [{"command": "solid.box", "params": {"length": 30, "width": 20, "height": 10}}], "check": {"bodies": 1, "volume": 6000, "unchanged": true, "reply_number": "6[ ,. ]?000(?![0-9])|6(\\.0+)? ?cm"}},
{"id": "10-two-holes", "prompt": "Make a 100 x 50 x 5 mm plate and drill two 10 mm holes through it, each 15 mm in from the short ends and centred across the width.", "check": {"bodies": 1, "volume": 24214.6, "bbox_size": [5, 50, 100], "note": "100*50*5 - 2*pi*5^2*5"}},
{"id": "11-any-change", "prompt": "can you use the software to make a change to the current shape, any change at all", "setup": [{"command": "solid.box", "params": {"length": 30, "width": 20, "height": 10}}], "check": {"changed": true, "note": "verbatim user prompt from #4846; pass = the design changed"}}
]"#;
/// The agent the test adds. A fixed id replaces it on every run instead of adding another.
const AGENT_ID: &str = "api-bench-assistant";
const AGENT_NAME: &str = "bench";
/// Longest control reply read (the channel's own request limit).
const MAX_REPLY: usize = 16 << 20;
/// Most result data kept for `--json`.
const MAX_RESULTS: usize = 64 << 20;
/// Deadline of the requests that only set up or read (each is answered at once by the app).
const REQUEST_TIMEOUT: Duration = Duration::from_secs(120);
/// How often a running turn is polled.
const POLL: Duration = Duration::from_millis(250);
/// After this long, an idle chat with no reply yet counts as a turn that ended without one.
const START_GRACE: Duration = Duration::from_secs(30);
/// Absolute tolerance of the lowest corner, in millimetres.
const MIN_CORNER_TOL: f64 = 0.05;
/// A JSON-lines connection to the app. Requests are sent once; a lost reply is never retried,
/// so nothing runs twice.
struct Control {
addr: SocketAddr,
token: String,
reader: BufReader<TcpStream>,
id: u64,
}
impl Control {
fn connect(addr: &str, token: Option<&str>) -> Result<Self, String> {
let addr: SocketAddr = addr.parse().map_err(|_| format!("--connect: `{addr}` is not an IP address and port"))?;
if !addr.ip().is_loopback() || addr.port() == 0 {
return Err("--connect requires a loopback address and a nonzero port (the app listens on 127.0.0.1)".into());
}
let token = solidcraft_engine::control_auth::client_token(addr.port(), token)
.ok_or("no control token: pass --token, set SOLIDCRAFT_CONTROL_TOKEN, or run on the app's computer (its token file)")?;
if token.len() > 4096 {
return Err("the control token is longer than 4096 bytes".into());
}
Self::open(addr, token)
}
fn open(addr: SocketAddr, token: String) -> Result<Self, String> {
let stream = TcpStream::connect_timeout(&addr, Duration::from_secs(5)).map_err(|e| format!("connecting to {addr}: {e}"))?;
Ok(Self { addr, token, reader: BufReader::new(stream), id: 0 })
}
/// A new connection to the same app: a request that outlived its deadline keeps the old one
/// busy until it is answered.
fn reopen(&self) -> Result<Self, String> {
Self::open(self.addr, self.token.clone())
}
fn call(&mut self, method: &str, params: Value, deadline: Instant) -> Result<Value, String> {
let left = || deadline.checked_duration_since(Instant::now()).filter(|d| !d.is_zero()).ok_or_else(|| "timeout".to_string());
let io = |e: std::io::Error| match e.kind() {
std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock => "timeout".to_string(),
_ => format!("control channel: {e}"),
};
self.reader.get_ref().set_write_timeout(Some(left()?)).map_err(io)?;
self.id += 1;
// The first request on a connection carries the token; sending it on every one is allowed.
let request = json!({"id": self.id, "method": method, "params": params, "token": self.token});
writeln!(self.reader.get_mut(), "{request}").map_err(io)?;
let mut line = Vec::new();
loop {
self.reader.get_ref().set_read_timeout(Some(left()?)).map_err(io)?;
let bytes = self.reader.fill_buf().map_err(io)?;
if bytes.is_empty() {
return Err("the app closed the control connection".into());
}
let newline = bytes.iter().position(|b| *b == b'\n');
let used = newline.map_or(bytes.len(), |n| n + 1);
if line.len() + used > MAX_REPLY {
return Err("a control reply is larger than 16 MiB".into());
}
line.extend_from_slice(bytes.get(..used).unwrap_or_default());
self.reader.consume(used);
if newline.is_some() {
break;
}
}
let reply: Value = serde_json::from_slice(&line).map_err(|e| format!("control reply: {e}"))?;
if reply["id"].as_u64() != Some(self.id) {
return Err("control reply for another request".into());
}
if reply["ok"] != true {
return Err(format!("{method}: {}", reply["error"].as_str().unwrap_or("failed")));
}
reply.get("result").cloned().ok_or_else(|| format!("{method}: reply without a result"))
}
fn execute(&mut self, command: &str, params: Value) -> Result<Value, String> {
self.call("engine.execute", json!({"command": command, "params": params, "animate": false}), Instant::now() + REQUEST_TIMEOUT)
}
fn chat(&mut self, params: Value) -> Result<Value, String> {
self.call("ui.agent_chat", params, Instant::now() + REQUEST_TIMEOUT)
}
fn inspect(&mut self) -> Result<Value, String> {
let design = self.call("document.inspect", json!({"measure": true}), Instant::now() + REQUEST_TIMEOUT)?;
if design.is_object() { Ok(design) } else { Err("document.inspect returned no design".into()) }
}
}
fn tasks() -> Result<Vec<Value>, String> {
serde_json::from_str(TASKS).map_err(|e| format!("embedded tasks: {e}"))
}
fn triple(value: &Value) -> Option<[f64; 3]> {
match value.as_array()?.as_slice() {
[x, y, z] => {
let n = |v: &Value| v.as_f64().filter(|n| n.is_finite());
Some([n(x)?, n(y)?, n(z)?])
}
_ => None,
}
}
/// What the model said (thoughts left out).
fn reply(items: &[Value]) -> String {
items.iter().filter(|i| i["thought"] != true).filter_map(|i| i["agent"].as_str()).collect::<Vec<_>>().join("\n")
}
/// v1's reply pattern `6[ ,. ]?000(?![0-9])|6(\.0+)? ?cm`: "6000", "6 000", "6,000", "6.000" not
/// followed by a digit, or "6 cm", "6.0cm", … (a narrow no-break space also separates thousands).
fn volume_reply(text: &str) -> bool {
text.match_indices('6').any(|(at, _)| {
let tail = text.get(at + 1..).unwrap_or_default();
let thousands = |t: &str| t.strip_prefix("000").is_some_and(|rest| !rest.starts_with(|c: char| c.is_ascii_digit()));
let separated = tail.strip_prefix([' ', ',', '.', '\u{202f}']).is_some_and(thousands);
let cm = {
let t = tail.strip_prefix(".0").map_or(tail, |t| t.trim_start_matches('0'));
t.strip_prefix(' ').unwrap_or(t).starts_with("cm")
};
thousands(tail) || separated || cm
})
}
/// The design as the user sees it, without what reading or saving changes (path, saved state,
/// revision, selection, undo depth, evaluation timings).
fn content(design: &Value) -> Value {
let mut v = design.clone();
if let Some(o) = v.as_object_mut() {
for key in ["path", "dirty", "revision", "selection", "undo", "redo"] {
o.remove(key);
}
}
for feature in v.get_mut("timeline").and_then(Value::as_array_mut).into_iter().flatten() {
if let Some(o) = feature.as_object_mut() {
o.remove("ms");
o.remove("boolean_diagnostics");
}
}
v
}
/// Whether the design changed: its content, or a step on the undo stack (a colour, say, that the
/// inspection does not list).
fn changed(before: &Value, after: &Value) -> bool {
content(before) != content(after) || after["undo"].as_u64() > before["undo"].as_u64()
}
/// The rules a task's result breaks (none: it passed). `before` and `after` are
/// `document.inspect {measure: true}` before the prompt and after the turn.
pub(super) fn check(rule: &Value, before: &Value, after: &Value, items: &[Value]) -> Vec<String> {
let Some(all) = after["bodies"].as_array() else { return vec!["the design could not be read".into()] };
// Bodies the user sees; a hidden one (a tool body, a hidden original) is not the result.
let bodies: Vec<&Value> = all.iter().filter(|b| b["visible"] != false).collect();
let mut errors = Vec::new();
if let Some(n) = rule["bodies"].as_u64()
&& bodies.len() as u64 != n
{
errors.push(format!("{} bodies, expected {n}", bodies.len()));
}
if let Some(expected) = rule["volume"].as_f64() {
let tol = rule["tol"].as_f64().unwrap_or(0.01);
let volumes: Vec<f64> = bodies.iter().filter_map(|b| b["volume_mm3"].as_f64()).filter(|v| v.is_finite()).collect();
let total: f64 = volumes.iter().sum();
if volumes.len() != bodies.len() || (total - expected).abs() > expected * tol {
errors.push(format!("volume {total:.1} mm³, expected {expected} mm³ ±{}%", tol * 100.0));
}
}
let first = bodies.first().copied().unwrap_or(&Value::Null);
let min = triple(&first["bbox"]["min"]);
if let Some(mut expected) = triple(&rule["bbox_size"]) {
expected.sort_by(f64::total_cmp);
let size = min.zip(triple(&first["bbox"]["max"])).map(|(lo, hi)| {
let mut size = [hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]];
size.sort_by(f64::total_cmp);
size
});
if !size.is_some_and(|s| s.iter().zip(expected).all(|(a, e)| (a - e).abs() <= 0.01 * e.max(1.0))) {
errors.push(format!("size {size:?}, expected {expected:?} in any orientation"));
}
}
if let Some(expected) = triple(&rule["bbox_min"])
&& !min.is_some_and(|m| m.iter().zip(expected).all(|(a, e)| (a - e).abs() <= MIN_CORNER_TOL))
{
errors.push(format!("lowest corner {min:?}, expected {expected:?}"));
}
if let Some(name) = rule["body_name"].as_str()
&& !bodies.iter().any(|b| b["name"] == name)
{
errors.push(format!("no body named {name}"));
}
for kind in rule["feature_kinds"].as_array().into_iter().flatten().filter_map(Value::as_str) {
let live = |f: &&Value| f["suppressed"] != true && f["rolled_back"] != true && f["error"].is_null();
if !after["timeline"].as_array().into_iter().flatten().filter(live).any(|f| f["type"].as_str().is_some_and(|t| t.contains(kind))) {
errors.push(format!("no {kind} in the timeline"));
}
}
if rule["changed"] == true || rule["unchanged"] == true {
let changed = before.is_object() && changed(before, after);
if rule["changed"] == true && !changed {
errors.push("the design did not change".into());
}
if rule["unchanged"] == true && (changed || !before.is_object()) {
errors.push("the design changed".into());
}
}
if rule["reply_number"].is_string() && !volume_reply(&reply(items)) {
errors.push("the reply does not give the volume".into());
}
errors
}
/// A tool call written into the reply as JSON: `{"name": …`, `{"command": …`, `{"function": …`,
/// `{"tool": …`.
fn call_as_text(text: &str) -> bool {
text.match_indices('{').any(|(at, _)| {
let rest = text.get(at + 1..).unwrap_or_default().trim_start();
["\"name\"", "\"command\"", "\"function\"", "\"tool\""]
.iter()
.any(|key| rest.strip_prefix(key).is_some_and(|r| r.trim_start().starts_with(':')))
})
}
/// How a failed task failed, in the issue's words, from the conversation's items.
pub(super) fn classify(items: &[Value], timed_out: bool) -> &'static str {
if timed_out {
return "timeout";
}
let tools: Vec<&Value> = items.iter().filter(|i| i.get("tool").is_some()).collect();
// A failed call, or a batch whose answer reports a failed step (`"ok": false`).
let failed: Vec<String> = tools
.iter()
.map(|t| t["output"].as_str().unwrap_or_default())
.zip(&tools)
.filter(|(output, t)| t["status"] == "Failed" || serde_json::from_str::<Value>(output).is_ok_and(|v| v["ok"] == false))
.map(|(output, _)| output.to_lowercase())
.collect();
let notices: Vec<String> = items.iter().filter_map(|i| i["notice"].as_str()).map(str::to_lowercase).collect();
let answer = reply(items);
if notices.iter().any(|n| n.contains("http 400")) {
return "the endpoint refused tools (HTTP 400)";
}
if notices.iter().any(|n| n.contains("max_turn_requests")) || answer.contains("requests for one message") {
return "hit the request limit";
}
if call_as_text(&answer) {
return "wrote the call as text";
}
if failed.iter().any(|o| o.contains("unknown command") || o.starts_with("unknown tool")) {
return "wrong or unknown command";
}
if failed.iter().any(|o| o.contains("invalid parameters") || o.starts_with("not run:") || o.contains("not valid json")) {
return "wrong arguments";
}
if !failed.is_empty() || notices.iter().any(|n| n.starts_with("http ")) || items.iter().any(|i| i["error"] == true) {
return "gave up after errors";
}
if tools.is_empty() {
return if answer.trim().is_empty() { "an empty reply" } else { "only talked" };
}
"wrong result"
}
/// One run's results in the issue's comment template (`run`: which repetition).
pub(super) fn markdown(model: &str, version: &str, results: &[Value], run: u64) -> String {
let clean = |s: &str| s.replace(['\n', '\r', '|'], " ");
let rows: Vec<&Value> = results.iter().filter(|r| r["repeat"] == run).collect();
let passed = rows.iter().filter(|r| r["passed"] == true).count();
let mut out = format!(
"### {} (<quant>, <download size>)\nRuntime: <Ollama/LM Studio/... version>, context <num_ctx if changed>\nSolidCraft: {}\nResult: {passed}/11\n| # | pass/fail | how it failed |\n|---|---|---|\n",
clean(model),
clean(version)
);
for n in 1..=11 {
match rows.iter().find(|r| r["task"] == n) {
Some(r) if r["passed"] == true => out.push_str(&format!("| {n} | pass | |\n")),
Some(r) => out.push_str(&format!("| {n} | fail | {} |\n", clean(r["failure"].as_str().unwrap_or_default()))),
None => out.push_str(&format!("| {n} | | not run |\n")),
}
}
out.push_str(if rows.len() == 11 {
"Notes: <anything odd, e.g. very slow, needed a retry>\n"
} else {
"Notes: partial run; the tasks marked \"not run\" were left out.\n"
});
out
}
fn write(path: &str, text: &str) -> Result<(), String> {
solidcraft_engine::io::vfs::write(path, text.as_bytes()).map_err(|e| format!("{path}: {e}"))
}
struct Options<'a> {
connect: &'a str,
token: Option<&'a str>,
base_url: &'a str,
model: &'a str,
tasks: Vec<usize>,
repeat: u64,
timeout: Duration,
json: Option<&'a str>,
markdown: Option<&'a str>,
}
fn options(args: &[String]) -> Result<Options<'_>, String> {
const FLAGS: [&str; 9] = ["--connect", "--token", "--base-url", "--model", "--tasks", "--repeat", "--timeout-s", "--json", "--markdown"];
let mut given = std::collections::BTreeMap::new();
let mut args = args.iter();
while let Some(flag) = args.next() {
let Some(flag) = FLAGS.iter().find(|f| **f == flag.as_str()) else { return Err(format!("unknown bench-assistant option: {flag}")) };
let value = args
.next()
.filter(|v| !v.starts_with("--") && !v.is_empty() && v.len() <= 4096)
.ok_or_else(|| format!("{flag} needs a value (at most 4096 bytes)"))?;
if given.insert(*flag, value.as_str()).is_some() {
return Err(format!("{flag} given twice"));
}
}
let required = |key: &str| given.get(key).copied().ok_or_else(|| format!("bench-assistant needs {key}"));
let number = |key: &str, default: u64, max: u64| -> Result<u64, String> {
given
.get(key)
.map_or(Ok(default), |s| s.parse::<u64>().ok().filter(|n| (1..=max).contains(n)).ok_or_else(|| format!("{key} must be 1 to {max}")))
};
let tasks = match given.get("--tasks") {
None => (1..=11).collect(),
Some(list) => {
let mut tasks = Vec::new();
for n in list.split(',') {
let n = n
.trim()
.parse::<usize>()
.ok()
.filter(|n| (1..=11).contains(n))
.ok_or("--tasks takes task numbers 1 to 11, separated by commas")?;
if tasks.contains(&n) {
return Err(format!("--tasks lists task {n} twice"));
}
tasks.push(n);
}
tasks
}
};
Ok(Options {
connect: required("--connect")?,
token: given.get("--token").copied(),
base_url: required("--base-url")?,
model: required("--model")?,
tasks,
repeat: number("--repeat", 1, 100)?,
timeout: Duration::from_secs(number("--timeout-s", 300, 3600)?),
json: given.get("--json").copied(),
markdown: given.get("--markdown").copied(),
})
}
/// Waits for the turn the prompt started to end. Returns the chat's last state and whether the
/// deadline passed first.
fn wait_for_turn(control: &mut Control, mut chat: Value, began: Instant, end: Instant) -> Result<(Value, bool), String> {
let mut seen_busy = false;
loop {
let state = chat["state"].as_str().unwrap_or_default();
let turn = !chat["turn"].is_null();
let queued = chat["pending"].as_array().is_some_and(|p| !p.is_empty());
seen_busy |= state == "busy" || turn;
let idle = !matches!(state, "busy" | "starting") && !turn && !queued;
// A turn can start and end between two polls: then the conversation has its answer.
let answered = chat["items"].as_array().into_iter().flatten().any(|i| i.get("user").is_none());
if idle && (seen_busy || answered || matches!(state, "exited" | "auth" | "off") || began.elapsed() >= START_GRACE) {
return Ok((chat, false));
}
let Some(left) = end.checked_duration_since(Instant::now()).filter(|d| !d.is_zero()) else { return Ok((chat, true)) };
std::thread::sleep(POLL.min(left));
match control.call("ui.agent_chat", json!({}), end) {
Ok(next) => chat = next,
Err(e) if e == "timeout" => return Ok((chat, true)),
Err(e) => return Err(e),
}
}
}
pub fn run(args: &[String]) -> Result<(), String> {
let o = options(args)?;
let tasks = tasks()?;
let mut control = Control::connect(o.connect, o.token)?;
let help = control.call("ui.help", json!({}), Instant::now() + REQUEST_TIMEOUT)?;
let version = help["version"].as_str().filter(|s| !s.is_empty() && s.len() <= 256).ok_or("the app reported no version")?.to_string();
let config = control.chat(json!({
"features": true,
"add_agent": {"id": AGENT_ID, "name": AGENT_NAME, "kind": "api", "base_url": o.base_url, "model": o.model},
}))?;
if !config["agents"].as_array().into_iter().flatten().any(|a| a["id"] == AGENT_ID) {
return Err("the app did not add the benchmark's agent".into());
}
// Put the user's permission setting back afterwards.
let permissions = config["permissions"].as_str().map(str::to_string);
let result = run_tasks(&mut control, &o, &tasks, &version);
let _ = control.chat(json!({"stop": true}));
if let Some(p) = permissions {
let _ = control.chat(json!({"permissions": p}));
}
result
}
fn run_tasks(control: &mut Control, o: &Options, tasks: &[Value], version: &str) -> Result<(), String> {
let mut results: Vec<Value> = Vec::new();
let mut kept = 0;
let mut stdout = std::io::stdout().lock();
let mut say = |line: String| writeln!(stdout, "{line}").map_err(|e| format!("stdout: {e}"));
say(format!("bench-assistant v1: {} on SolidCraft {version}", o.model))?;
say("| run | # | task | pass/fail | how it failed | seconds |\n|---|---|---|---|---|---|".into())?;
for run in 1..=o.repeat {
for &n in &o.tasks {
let task = tasks.get(n - 1).ok_or("missing embedded task")?;
// A fresh design and a fresh chat, with nothing of the last task still running.
control.chat(json!({"stop": true}))?;
control.execute("file.new", json!({"name": "Benchmark"}))?;
for step in task["setup"].as_array().into_iter().flatten() {
control.execute(step["command"].as_str().ok_or("embedded setup without a command")?, step["params"].clone())?;
}
control.chat(json!({"agent": AGENT_ID, "permissions": "all", "new_chat": true, "attach_view": false}))?;
let before = control.inspect()?;
let began = Instant::now();
let end = began + o.timeout;
let (chat, timed_out) = match control.call("ui.agent_chat", json!({"send": task["prompt"]}), end) {
Ok(chat) => wait_for_turn(control, chat, began, end)?,
Err(e) if e == "timeout" => (json!({}), true),
Err(e) => return Err(e),
};
if timed_out {
// The late reply would arrive on the old connection; stop the model on a new one
// before reading the design.
*control = control.reopen()?;
}
// Stop the agent before reading the result, so nothing changes the design meanwhile.
let stopped = control.chat(json!({"stop": true}))?;
let items = stopped["items"].as_array().or(chat["items"].as_array()).cloned().unwrap_or_default();
let after = control.inspect()?;
let failed_rules = check(&task["check"], &before, &after, &items);
let passed = failed_rules.is_empty();
let failure = if passed { "" } else { classify(&items, timed_out) };
let seconds = began.elapsed().as_secs_f64();
say(format!(
"| {run} | {n} | {} | {} | {failure} | {seconds:.0} |",
task["id"].as_str().unwrap_or_default(),
if passed { "pass" } else { "fail" }
))?;
let result = json!({
"task": n, "id": task["id"], "prompt": task["prompt"], "repeat": run, "passed": passed, "failure": failure,
"failed_rules": failed_rules, "timed_out": timed_out, "seconds": seconds, "check": task["check"],
"items": items, "before": before, "after": after,
});
kept += result.to_string().len();
if kept > MAX_RESULTS {
return Err("the results passed 64 MiB; run fewer tasks or repetitions".into());
}
results.push(result);
// Written after every task, so an interrupted run keeps what it measured.
if let Some(path) = o.json {
let all = json!({"test": "v1", "model": o.model, "base_url": o.base_url, "solidcraft": version, "results": results});
write(path, &serde_json::to_string_pretty(&all).map_err(|e| e.to_string())?)?;
}
if let Some(path) = o.markdown {
write(path, &(1..=run).map(|r| markdown(o.model, version, &results, r)).collect::<Vec<_>>().join("\n"))?;
}
}
}
for run in 1..=o.repeat {
let rows: Vec<&Value> = results.iter().filter(|r| r["repeat"] == run).collect();
say(format!("run {run}: {}/{} passed", rows.iter().filter(|r| r["passed"] == true).count(), rows.len()))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn design(bodies: Value, timeline: Value) -> Value {
json!({"name": "Benchmark", "params": [], "timeline": timeline, "bodies": bodies, "sketches": [], "undo": 1, "revision": 3, "dirty": true})
}
fn body(name: &str, volume: f64, min: [f64; 3], max: [f64; 3]) -> Value {
json!({"name": name, "volume_mm3": volume, "bbox": {"min": min, "max": max}, "visible": true})
}
#[test]
fn the_embedded_tasks_are_v1() {
let tasks = tasks().unwrap();
assert_eq!(tasks.len(), 11);
assert_eq!(tasks[0]["prompt"], "Make a 20 mm cube.");
assert_eq!(tasks[10]["prompt"], "can you use the software to make a change to the current shape, any change at all");
assert_eq!(tasks[5]["check"]["tol"], 0.003);
assert_eq!(tasks[6]["check"]["bbox_min"], json!([50, 0, 0]));
let boxes: Vec<_> = tasks.iter().filter(|t| t["setup"].is_array()).map(|t| t["id"].as_str().unwrap()).collect();
assert_eq!(boxes, ["06-fillet-box", "07-move-body", "08-rename-body", "09-volume-question", "11-any-change"]);
}
#[test]
fn pass_rules() {
let tasks = tasks().unwrap();
let rule = |n: usize| tasks[n - 1]["check"].clone();
let empty = design(json!([]), json!([]));
let pass = |n: usize, before: &Value, after: &Value, items: &[Value]| check(&rule(n), before, after, items);
// 3: any orientation, within 1%.
let block = design(json!([body("Body1", 24_100.0, [0.0; 3], [40.0, 10.0, 60.2])]), json!([{"type": "Box"}]));
assert!(pass(3, &empty, &block, &[]).is_empty());
let thick = design(json!([body("Body1", 24_000.0, [0.0; 3], [40.0, 12.0, 60.0])]), json!([]));
assert_eq!(pass(3, &empty, &thick, &[]).len(), 1);
let two = design(json!([body("A", 12_000.0, [0.0; 3], [60.0, 40.0, 5.0]), body("B", 12_000.0, [0.0; 3], [60.0, 40.0, 5.0])]), json!([]));
assert!(!pass(3, &empty, &two, &[]).is_empty());
// A hidden body is not part of the result.
let mut hidden = two.clone();
hidden["bodies"][1]["visible"] = json!(false);
hidden["bodies"][0]["volume_mm3"] = json!(24_000.0);
hidden["bodies"][0]["bbox"]["max"] = json!([60.0, 40.0, 10.0]);
assert!(pass(3, &empty, &hidden, &[]).is_empty());
// 4: the timeline needs a working Extrude.
let plate = |kind: Value| design(json!([body("Body1", 22_500.0, [0.0; 3], [50.0, 30.0, 15.0])]), kind);
assert!(pass(4, &empty, &plate(json!([{"type": "Sketch"}, {"type": "Extrude"}])), &[]).is_empty());
assert!(!pass(4, &empty, &plate(json!([{"type": "Box"}])), &[]).is_empty());
for flag in [json!({"suppressed": true}), json!({"rolled_back": true}), json!({"error": "failed"})] {
let mut feature = json!({"type": "Extrude"});
feature.as_object_mut().unwrap().extend(flag.as_object().unwrap().clone());
assert!(!pass(4, &empty, &plate(json!([feature])), &[]).is_empty(), "{flag}");
}
// 6: the fillet's tolerance is 0.3%.
let start = design(json!([body("Body1", 64_000.0, [0.0; 3], [40.0; 3])]), json!([{"type": "Box"}]));
let filleted = |v: f64| design(json!([body("Body1", v, [0.0; 3], [40.0; 3])]), json!([{"type": "Box"}, {"type": "Fillet"}]));
assert!(pass(6, &start, &filleted(63_108.53 * 1.002), &[]).is_empty());
assert!(!pass(6, &start, &filleted(63_108.53 * 1.004), &[]).is_empty());
// 7: the lowest corner.
let moved = |x: f64| design(json!([body("Body1", 8000.0, [x, 0.0, 0.0], [x + 20.0, 20.0, 20.0])]), json!([]));
assert!(pass(7, &empty, &moved(50.0), &[]).is_empty());
assert!(!pass(7, &empty, &moved(0.0), &[]).is_empty());
// 8: the name.
let named = |name: &str| design(json!([body(name, 6000.0, [0.0; 3], [30.0, 20.0, 10.0])]), json!([]));
assert!(pass(8, &empty, &named("Bracket"), &[]).is_empty());
assert!(!pass(8, &empty, &named("Body1"), &[]).is_empty());
// 9: unchanged, and the reply gives the volume.
let start = named("Body1");
let said = |text: &str| [json!({"user": "What is the volume of the part?"}), json!({"agent": text, "thought": false})];
let mut read = start.clone();
read["revision"] = json!(7);
read["dirty"] = json!(false);
read["selection"] = json!([{"body": "Body1"}]);
assert!(pass(9, &start, &read, &said("The volume is 6000 mm³.")).is_empty());
assert!(!pass(9, &start, &read, &said("About 60 cm³.")).is_empty());
assert!(!pass(9, &start, &named("Bracket"), &said("6 000 mm³")).is_empty());
let mut undone = start.clone();
undone["undo"] = json!(2);
assert!(!pass(9, &start, &undone, &said("6000 mm³")).is_empty());
// 11: any change counts; reading does not.
assert!(pass(11, &start, &named("Bracket"), &[]).is_empty());
assert!(pass(11, &start, &undone, &[]).is_empty());
assert!(!pass(11, &start, &read, &[]).is_empty());
// An unreadable design passes nothing.
assert!(!pass(11, &start, &json!({}), &[]).is_empty());
}
#[test]
fn the_volume_reply() {
for yes in ["6000", "6 000 mm³", "6,000", "6.000", "6\u{202f}000", "6 cm³", "6.00cm3", "is 6000."] {
assert!(volume_reply(yes), "{yes}");
}
for no in ["60000", "6,0001", "60 cm³", "600", "6.01 cm³", ""] {
assert!(!volume_reply(no), "{no}");
}
}
#[test]
fn failure_categories() {
for (items, expected) in [
(
json!([{"user": "Make a 20 mm cube."}, {"agent": "{\"name\": \"execute\", \"arguments\": {}}", "thought": false}]),
"wrote the call as text",
),
(
json!([{"notice": "HTTP 400: registry.ollama.ai/library/gemma3:4b does not support tools", "error": true}]),
"the endpoint refused tools (HTTP 400)",
),
(json!([{"user": "Make a 20 mm cube."}, {"agent": "", "thought": false}]), "an empty reply"),
(json!([{"agent": "Let me think", "thought": true}]), "an empty reply"),
(json!([{"agent": "Sure, here is how to make a cube.", "thought": false}]), "only talked"),
(
json!([{"tool": "1", "status": "Failed", "output": "unknown command `solid.cube` (did you mean `solid.box`?)"}]),
"wrong or unknown command",
),
(json!([{"tool": "1", "status": "Failed", "output": "unknown tool `make_cube`"}]), "wrong or unknown command"),
(json!([{"tool": "1", "status": "Failed", "output": "invalid parameters for `solid.box`: `length` is required"}]), "wrong arguments"),
(json!([{"tool": "1", "status": "Failed", "output": "Not run: the tool arguments were not valid JSON"}]), "wrong arguments"),
(json!([{"tool": "1", "status": "Failed", "output": "no body `Box`"}]), "gave up after errors"),
(
json!([{"tool": "1", "status": "Completed", "output": "{\"ok\": false, \"failed\": {\"error\": \"unknown command `solid.delete`\"}}"}]),
"wrong or unknown command",
),
(
json!([{"tool": "1", "status": "Completed"}, {"agent": "\n(stopped after 24 requests for one message; send another message to go on)"}]),
"hit the request limit",
),
(json!([{"tool": "1", "status": "Completed", "output": "{}"}]), "wrong result"),
] {
let items = items.as_array().unwrap();
assert_eq!(classify(items, false), expected, "{items:?}");
assert_eq!(classify(items, true), "timeout");
}
}
#[test]
fn markdown_is_the_comment_template() {
let rows: Vec<Value> =
(1..=11).map(|n| json!({"task": n, "repeat": 1, "passed": n != 2, "failure": if n == 2 { "an empty reply" } else { "" }})).collect();
let report = markdown("qwen2.5:7b", "0.19.0", &rows, 1);
assert!(report.starts_with(
"### qwen2.5:7b (<quant>, <download size>)\nRuntime: <Ollama/LM Studio/... version>, context <num_ctx if changed>\nSolidCraft: 0.19.0\nResult: 10/11\n| # | pass/fail | how it failed |\n|---|---|---|\n| 1 | pass | |\n| 2 | fail | an empty reply |\n"
));
assert!(report.ends_with("| 11 | pass | |\nNotes: <anything odd, e.g. very slow, needed a retry>\n"));
let partial = markdown("m|x", "v", &rows[..1], 1);
assert!(partial.starts_with("### m x ("));
assert!(partial.contains("Result: 1/11") && partial.contains("| 2 | | not run |") && partial.contains("partial run"));
}
#[test]
fn options_are_checked_before_connecting() {
for extra in [
&["--repeat", "0"][..],
&["--repeat", "101"],
&["--timeout-s", "3601"],
&["--tasks", "0"],
&["--tasks", "12"],
&["--tasks", "1,1"],
&["--model", "again"],
&["--unexpected", "1"],
&["--token"],
] {
let mut args: Vec<String> =
["--connect", "127.0.0.1:9", "--model", "m", "--base-url", "http://127.0.0.1:11434/v1"].iter().map(|s| s.to_string()).collect();
args.extend(extra.iter().map(|s| s.to_string()));
assert!(options(&args).is_err(), "{extra:?}");
}
let args: Vec<String> =
["--connect", "127.0.0.1:9", "--model", "m", "--base-url", "u", "--tasks", "2, 5"].iter().map(|s| s.to_string()).collect();
let o = options(&args).unwrap();
assert_eq!((o.tasks, o.repeat, o.timeout), (vec![2, 5], 1, Duration::from_secs(300)));
assert!(Control::connect("10.0.0.1:9271", Some("t")).is_err());
assert!(Control::connect("localhost", Some("t")).is_err());
}
}
+6 -3
View File
@@ -6,11 +6,14 @@ use std::sync::Mutex;
use serde_json::{Value, json};
use solidcraft_engine::crash;
const USAGE: &str = "usage: solidcraft-cli crashes [list [--json] | show <id> | replay <id> | dir | clear [<id>]]";
/// The command's usage, from the same lines the top-level usage lists.
fn usage() -> String {
crate::command_usage("crashes")
}
pub fn run(args: &[String]) -> Result<(), String> {
let dir = crash::default_dir().ok_or("no crash folder on this system")?;
let id = || args.get(1).map(String::as_str).ok_or_else(|| USAGE.to_string());
let id = || args.get(1).map(String::as_str).ok_or_else(usage);
match args.first().map(String::as_str) {
None | Some("list") => {
let reports = crash::list(&dir);
@@ -43,7 +46,7 @@ pub fn run(args: &[String]) -> Result<(), String> {
println!("deleted {} report(s)", crash::clear(&dir, args.get(1).map(String::as_str)));
Ok(())
}
_ => Err(USAGE.to_string()),
_ => Err(usage()),
}
}
+10
View File
@@ -16,9 +16,16 @@ fn flag<T: std::str::FromStr>(args: &[String], name: &str, default: T) -> T {
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).and_then(|v| v.parse().ok()).unwrap_or(default)
}
/// The options `fuzz` takes, and the ones that stand alone.
const OPTIONS: &[&str] = &["--from", "--count", "--jobs", "--timeout", "--steps", "--out", "--report"];
const SWITCHES: &[&str] = &["--minimise"];
/// One seed: a design and a boolean case, as JSON on stdout (progress on stderr).
pub fn one(args: &[String]) -> Result<(), String> {
let seed: u64 = args.first().and_then(|s| s.parse().ok()).ok_or("fuzz-one <seed> [--steps N] [--minimise]")?;
// The seed is the first argument; the rest are options, and an option `fuzz-one` doesn't
// take is refused rather than ignored.
crate::known_args("fuzz-one", &args[1..], &["--steps"], SWITCHES, false)?;
let steps = flag(args, "--steps", 8usize);
let minimise = args.iter().any(|a| a == "--minimise");
// Panics caught inside the kernel: harmless natively, fatal in the web build.
@@ -64,6 +71,9 @@ struct Job {
/// Run seeds `--from`…`--from + --count` in `--jobs` processes, `--timeout` seconds each;
/// write failing scripts to `--out` and a summary to `--report`.
pub fn run(args: &[String]) -> Result<(), String> {
// An option `fuzz` doesn't take is refused before a seed runs: it would otherwise be
// ignored, and the run would go on for minutes with the wrong settings.
crate::known_args("fuzz", args, OPTIONS, SWITCHES, false)?;
let from = flag(args, "--from", 0u64);
let count = flag(args, "--count", 100u64);
let last = from.checked_add(count.saturating_sub(1)).ok_or("seed range exceeds u64::MAX: reduce --from or --count")?;
+49
View File
@@ -0,0 +1,49 @@
//! What the engine logs (`log::info!`, `log::debug!`: why a body has no display plan, why a save
//! left its model cache out), written to stderr. Off unless asked for, so the CLI's output and
//! the scripts reading it stay as they are: `--debug` shows SolidCraft's own debug messages (and
//! other crates' warnings, whose debug output is large), `RUST_LOG=error|warn|info|debug|trace`
//! sets one level for every crate.
use log::{LevelFilter, Log, Metadata, Record};
struct Stderr {
/// The level for SolidCraft's crates.
ours: LevelFilter,
/// The level for every other crate.
others: LevelFilter,
}
impl Log for Stderr {
fn enabled(&self, m: &Metadata) -> bool {
m.level() <= if m.target().starts_with("solidcraft") { self.ours } else { self.others }
}
fn log(&self, r: &Record) {
if self.enabled(r.metadata()) {
use std::io::Write as _;
// A closed stderr is no reason to stop.
let _ = writeln!(std::io::stderr().lock(), "[{} {}] {}", r.level().as_str().to_ascii_lowercase(), r.target(), r.args());
}
}
fn flush(&self) {}
}
/// Removes `--debug` from `args`, wherever it is (every subcommand takes it), and installs the
/// logger when it or `RUST_LOG` asks for one.
pub fn init(args: &mut Vec<String>) {
let before = args.len();
args.retain(|a| a != "--debug");
let debug = args.len() != before;
let env = std::env::var("RUST_LOG").ok().and_then(|v| v.trim().parse::<LevelFilter>().ok());
let (ours, others) = match (debug, env) {
(false, Some(all)) => (all, all),
(true, Some(all)) => (all.max(LevelFilter::Debug), all),
(true, None) => (LevelFilter::Debug, LevelFilter::Warn),
(false, None) => return,
};
// Installed once, for the life of the process.
if log::set_logger(Box::leak(Box::new(Stderr { ours, others }))).is_ok() {
log::set_max_level(ours.max(others));
}
}
+384 -69
View File
@@ -1,28 +1,38 @@
//! SolidCraft headless command line.
//!
//! ```text
//! solidcraft-cli run <script.json|design.solidcraft|part.step|mesh.3mf> [--in design|part.step|mesh.3mf] [--out FILE]... [--save FILE] [--quiet] [--results] [--json-out FILE] [--allow-errors]
//! solidcraft-cli run <script.json|design.solidcraft|part.step|mesh.3mf> [--in design|part.step|mesh.3mf] [--out FILE]... [--save FILE] [--quiet] [--results] [--timings] [--profile] [--json-out FILE] [--allow-errors]
//! solidcraft-cli eval <script.json|design.solidcraft|part.step> [--json-out FILE] (alias: inspect) measurements as JSON
//! solidcraft-cli script <design.solidcraft> [--out script.json] the design's history as a command script
//! solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only] [--display LEVEL [--edit NAME EXPR]] loading timings as JSON lines
//! solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only] [--save OUT.solidcraft] [--display LEVEL [--edit NAME EXPR]] [--ops script.json] loading timings as JSON lines
//! solidcraft-cli bench-agent [--scale F] [--save F] [--script-out F] a ~1,300-body design built command by command, timed
//! solidcraft-cli bench-assistant --connect 127.0.0.1:PORT [--token T] --base-url URL --model NAME [--tasks 1,2,5] [--repeat N]
//! [--timeout-s 300] [--json out.json] [--markdown out.md]
//! the standard Assistant model test (#4851) on a running app
//! solidcraft-cli snapshot <script|design> [--in design|part.step] --out shot.png [--width W] [--height H] [--view iso|front|top|…|NAME | --camera JSON] [--no-grid] [--no-axes]
//! [--body NAME:opacity=0.3,color=#rrggbb]... [--tool NAME]... [--section z=27]
//! solidcraft-cli exec <command> [json-params] run one command on an empty design
//! solidcraft-cli commands the command registry as JSON
//! solidcraft-cli recipe <recipe.json> translate an oracle recipe to a script
//! solidcraft-cli oracle <case-dir>... replay recipes and compare with expected.json
//! solidcraft-cli mcp [--in design|script] [--connect HOST:PORT [--token T]] [--allow-path DIR]... [--allow-any-path]
//! solidcraft-cli mcp [--headless] [--in design|script] [--connect HOST:PORT [--token T]] [--allow-path DIR]... [--allow-any-path]
//! [--param-check off|warn|strict]
//! MCP server on stdio (docs/mcp.md)
//! solidcraft-cli licences SolidCraft's licence and the third-party licences
//! solidcraft-cli icon --out F.png|F.ico [--size N] the app icon (packaging; drawn, not a file)
//!
//! --debug (with any command) SolidCraft's debug log on stderr (or RUST_LOG=LEVEL)
//! ```
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
mod bench_agent;
mod bench_assistant;
mod crashes;
mod fuzz;
mod logger;
mod oracle;
mod preview;
mod recipe;
mod render;
mod spatial;
@@ -34,47 +44,107 @@ use serde_json::{Value, json};
mod app_icon;
use solidcraft_engine::Session;
use solidcraft_engine::render::render_png;
use solidcraft_engine::view::Guides;
const USAGE: &str = "usage:
solidcraft-cli run <script.json|design.solidcraft|part.step> [--in design.solidcraft|part.step] [--out FILE]... [--save FILE] [--quiet] [--results] [--json-out FILE] [--allow-errors]
solidcraft-cli run --in <design.solidcraft|part.step> [--out FILE]...
(a timeline feature that fails fails the run and nothing is written; --allow-errors writes anyway)
solidcraft-cli eval <script.json|design.solidcraft|part.step> [--json-out FILE] (alias: inspect; or --in FILE)
(--json-out FILE writes the JSON that run (with --results, else the measurement) and eval print to FILE instead of stdout,
as plain UTF-8: a shell's > can re-encode it, e.g. Windows PowerShell adds a byte-order mark)
solidcraft-cli script <design.solidcraft|script.json> [--out script.json]
(the design's history as a command script that eval and run replay)
solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only] [--display LEVEL [--edit NAME EXPR]]
solidcraft-cli bench-agent [--scale F] [--save design.solidcraft] [--script-out script.json]
solidcraft-cli snapshot <script|design> [--in design.solidcraft|part.step] --out shot.png [--width W] [--height H] [--view NAME | --camera JSON] [--no-grid] [--no-axes]
solidcraft-cli snapshot --in <design.solidcraft|part.step> --out shot.png ...
(as with run, --in starts from a design and the script runs on top of it; an option snapshot doesn't take is an error;
--view: iso, front, back, top, bottom, left, right, or the name of a view saved in the design with view.save;
--camera: {target: [x,y,z], yaw, pitch, distance, fov?} in mm and radians, as the MCP screenshot takes it (docs/mcp.md);
--no-grid and --no-axes leave the ground grid (on Z 0) and the origin's axes out of the picture)
solidcraft-cli render <script|design> --out image.png [--setup NAME] [--width W] [--height H] [--transparent] [--overwrite] [--timeout SECONDS]
solidcraft-cli exec <command> [json-params]
solidcraft-cli commands
solidcraft-cli recipe <recipe.json>
solidcraft-cli oracle <case-dir>... [--json]
solidcraft-cli fuzz [--from N] [--count N] [--jobs J] [--timeout S] [--steps N] [--minimise] [--out DIR] [--report FILE]
solidcraft-cli mcp [--in design.solidcraft|script.json] [--connect 127.0.0.1:PORT [--token T]] [--allow-path DIR]... [--allow-any-path]
solidcraft-cli crashes [list [--json] | show <id> | replay <id> | dir | clear [<id>]]
solidcraft-cli licences
solidcraft-cli icon --out icon.png [--size 256] | --out icon.ico
";
/// One command the CLI takes: the name to type, the usage it prints for `--help`,
/// and whether the top-level usage lists it (an alias answers to another command's name).
struct Command {
name: &'static str,
usage: &'static str,
listed: bool,
}
/// `run`'s lines in the usage.
const USAGE_RUN: &str = " solidcraft-cli run <script.json|design.solidcraft|part.step> [--in design.solidcraft|part.step] [--out FILE]... [--save FILE] [--quiet] [--results] [--timings] [--profile] [--json-out FILE] [--allow-errors]\n solidcraft-cli run --in <design.solidcraft|part.step> [--out FILE]...\n (a timeline feature that fails fails the run and nothing is written; --allow-errors writes anyway)\n (--timings prints {timings: [{index, command, ms}], total_ms} for a script, with --results its results too;\n --profile adds each command's profile: {boolean_ms, tessellate_ms, other_ms}, the kernel's share)\n";
/// `eval`'s lines in the usage.
const USAGE_EVAL: &str = " solidcraft-cli eval <script.json|design.solidcraft|part.step> [--json-out FILE] (alias: inspect; or --in FILE)\n (--json-out FILE writes the JSON that run (with --results, else the measurement) and eval print to FILE instead of stdout,\n as plain UTF-8: a shell's > can re-encode it, e.g. Windows PowerShell adds a byte-order mark)\n";
/// `script`'s lines in the usage.
const USAGE_SCRIPT: &str = " solidcraft-cli script <design.solidcraft|script.json> [--out script.json]\n (the design's history as a command script that eval and run replay)\n";
/// `bench-open`'s lines in the usage.
const USAGE_BENCH_OPEN: &str = " solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only] [--save OUT.solidcraft] [--display LEVEL [--edit NAME EXPR]] [--ops script.json]\n (--ops: after opening, run each command of the script and undo it, timing the command, the\n evaluations it caused and the view it leaves: placing and meshing what changed)\n";
/// `bench-agent`'s lines in the usage.
const USAGE_BENCH_AGENT: &str = " solidcraft-cli bench-agent [--scale F] [--save design.solidcraft] [--script-out script.json]\n";
/// `bench-assistant`'s lines in the usage.
const USAGE_BENCH_ASSISTANT: &str = " solidcraft-cli bench-assistant --connect 127.0.0.1:PORT [--token T] --base-url URL --model NAME [--tasks 1,2,5] [--repeat N] [--timeout-s 300] [--json out.json] [--markdown out.md]\n";
/// `snapshot`'s lines in the usage.
const USAGE_SNAPSHOT: &str = " solidcraft-cli snapshot <script|design> [--in design.solidcraft|part.step] --out shot.png [--width W] [--height H] [--view NAME | --camera JSON] [--no-grid] [--no-axes]\n [--body NAME:opacity=0.3,color=#rrggbb]... [--tool NAME]... [--section z=27]\n solidcraft-cli snapshot --in <design.solidcraft|part.step> --out shot.png ...\n (as with run, --in starts from a design and the script runs on top of it; an option snapshot doesn't take is an error;\n --view: iso, front, back, top, bottom, left, right, or the name of a view saved in the design with view.save;\n --camera: {target: [x,y,z], yaw, pitch, distance, fov?} in mm and radians, as the MCP screenshot takes it (docs/mcp.md);\n --no-grid and --no-axes leave the ground grid (on Z 0) and the origin's axes out of the picture;\n --body: how one body is drawn, as appearance.assign takes it: opacity 0-1 (below 1 is see-through, so what is inside\n it shows) and color #rrggbb, either or both, once per body;\n --tool: draw this body solid in the tool colour and every other body see-through, so a cut's or a join's tool body\n is visible inside the material it meets; a later --body overrides it;\n --section: cut the model as inspect.section does, so the inside shows: z=27 (the plane through that world\n coordinate, keeping the smaller side), add ,flip to keep the other side, or XY, XZ, YZ, or its own JSON)\n";
/// `render`'s lines in the usage.
const USAGE_RENDER: &str = " solidcraft-cli render <script|design> --out image.png [--setup NAME] [--width W] [--height H] [--transparent] [--overwrite] [--timeout SECONDS]\n [--progressive] [--samples N] [--max-depth N] [--seed N] [--exposure EV] [--lighting FILE.json] [--environment FILE.json] [--tone-map reinhard|aces_fitted]\n";
/// `exec`'s lines in the usage.
const USAGE_EXEC: &str = " solidcraft-cli exec <command> [json-params]\n";
/// `commands`'s lines in the usage.
const USAGE_COMMANDS: &str = " solidcraft-cli commands\n";
/// `recipe`'s lines in the usage.
const USAGE_RECIPE: &str = " solidcraft-cli recipe <recipe.json>\n";
/// `oracle`'s lines in the usage.
const USAGE_ORACLE: &str = " solidcraft-cli oracle <case-dir>... [--json]\n";
/// `fuzz`'s lines in the usage.
const USAGE_FUZZ: &str =
" solidcraft-cli fuzz [--from N] [--count N] [--jobs J] [--timeout S] [--steps N] [--minimise] [--out DIR] [--report FILE]\n";
/// `mcp`'s lines in the usage.
const USAGE_MCP: &str = " solidcraft-cli mcp [--headless] [--in design.solidcraft|script.json] [--connect 127.0.0.1:PORT [--token T]] [--allow-path DIR]... [--allow-any-path]\n [--param-check off|warn|strict]\n";
/// `crashes`'s lines in the usage.
const USAGE_CRASHES: &str = " solidcraft-cli crashes [list [--json] | show <id> | replay <id> | dir | clear [<id>]]\n";
/// `licences`'s lines in the usage.
const USAGE_LICENCES: &str = " solidcraft-cli licences\n";
/// `gpu`'s lines in the usage.
const USAGE_GPU: &str = " solidcraft-cli gpu [--policy auto|hardware|software|cpu]\n (the GPU adapters a headless render can open, best first, the one the policy picks and the tier it\n lands on: gpu, gpu-software, or the CPU rasterizer (auto with no adapter, or cpu); the policy defaults\n to SOLIDCRAFT_GPU, else auto; exits with an error when hardware or software finds no such adapter)\n";
/// `icon`'s lines in the usage.
const USAGE_ICON: &str = " solidcraft-cli icon --out icon.png [--size 256] | --out icon.ico\n";
/// `fuzz-one`'s lines in the usage: a seed and the flags of one fuzz run.
const USAGE_FUZZ_ONE: &str = " solidcraft-cli fuzz-one <seed> [--steps N] [--minimise]\n";
/// `bench-edit`'s lines in the usage: the parameter a script changes.
const USAGE_BENCH_EDIT: &str = " solidcraft-cli bench-edit <script.json|recipe.json> <param> <expr>\n";
/// `save-stress`'s lines in the usage: the file saved over and over.
const USAGE_SAVE_STRESS: &str = " solidcraft-cli save-stress <path> <rounds>\n";
/// The flags every command takes, at the end of the top-level usage.
const USAGE_DEBUG: &str = " --debug, with any command: what SolidCraft logs (why a body has no display plan, why a save left its\n model cache out), on stderr; RUST_LOG=error|warn|info|debug|trace sets one level for every crate\n";
/// The commands the CLI takes, in the order the top-level usage lists them.
const COMMANDS: &[Command] = &[
Command { name: "run", usage: USAGE_RUN, listed: true },
Command { name: "eval", usage: USAGE_EVAL, listed: true },
Command { name: "inspect", usage: USAGE_EVAL, listed: false },
Command { name: "script", usage: USAGE_SCRIPT, listed: true },
Command { name: "bench-open", usage: USAGE_BENCH_OPEN, listed: true },
Command { name: "bench-agent", usage: USAGE_BENCH_AGENT, listed: true },
Command { name: "bench-assistant", usage: USAGE_BENCH_ASSISTANT, listed: true },
Command { name: "snapshot", usage: USAGE_SNAPSHOT, listed: true },
Command { name: "render", usage: USAGE_RENDER, listed: true },
Command { name: "exec", usage: USAGE_EXEC, listed: true },
Command { name: "commands", usage: USAGE_COMMANDS, listed: true },
Command { name: "recipe", usage: USAGE_RECIPE, listed: true },
Command { name: "oracle", usage: USAGE_ORACLE, listed: true },
Command { name: "fuzz", usage: USAGE_FUZZ, listed: true },
Command { name: "fuzz-one", usage: USAGE_FUZZ_ONE, listed: false },
Command { name: "mcp", usage: USAGE_MCP, listed: true },
Command { name: "crashes", usage: USAGE_CRASHES, listed: true },
Command { name: "licences", usage: USAGE_LICENCES, listed: true },
Command { name: "licenses", usage: USAGE_LICENCES, listed: false },
Command { name: "gpu", usage: USAGE_GPU, listed: true },
Command { name: "icon", usage: USAGE_ICON, listed: true },
Command { name: "bench-edit", usage: USAGE_BENCH_EDIT, listed: false },
Command { name: "save-stress", usage: USAGE_SAVE_STRESS, listed: false },
];
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
let mut args: Vec<String> = std::env::args().skip(1).collect();
logger::init(&mut args);
let r = match args.first().map(String::as_str) {
// Asked-for help is not an error: the command's usage on stdout, exit 0. It is read
// before the command runs, so `--help` never starts a run (a fuzz run, a server).
Some(cmd) if wants_help(&args[1..]) && is_command(cmd) => {
use std::io::Write as _;
let _ = std::io::stdout().lock().write_all(command_usage(cmd).as_bytes());
Ok(())
}
Some("run") => cmd_run(&args[1..]),
Some("eval" | "inspect") => cmd_eval(&args[1..]),
Some("script") => cmd_script(&args[1..]),
Some("snapshot") => cmd_snapshot(&args[1..]),
Some("render") => render::run(&args[1..]),
Some("exec") => cmd_exec(&args[1..]),
Some("gpu") => cmd_gpu(&args[1..]),
Some("commands") => {
use std::io::Write as _;
let mut s = Session::default();
@@ -88,7 +158,7 @@ fn main() -> ExitCode {
}
})
}
Some("recipe") => args.get(1).ok_or_else(|| USAGE.to_string()).and_then(|p| {
Some("recipe") => args.get(1).ok_or_else(usage).and_then(|p| {
let v = read_json(p)?;
recipe::to_script(&v).map(|s| println!("{}", pretty(&s)))
}),
@@ -111,11 +181,19 @@ fn main() -> ExitCode {
Some("bench-open") => cmd_bench_open(&args[1..]),
// A ~1,300-body design built command by command, as an agent builds one over MCP.
Some("bench-agent") => bench_agent::run(&args[1..]),
// The standard Assistant model test (#4851), run against a desktop app over its control channel.
Some("bench-assistant") => bench_assistant::run(&args[1..]),
Some("--version" | "-V") => {
println!("solidcraft-cli {}", env!("CARGO_PKG_VERSION"));
Ok(())
}
_ => Err(USAGE.to_string()),
// Asked-for help is not an error: usage on stdout, exit 0.
Some("--help" | "-h" | "help") => {
use std::io::Write as _;
let _ = std::io::stdout().lock().write_all(usage().as_bytes());
Ok(())
}
_ => Err(usage()),
};
match r {
Ok(()) => ExitCode::SUCCESS,
@@ -130,6 +208,35 @@ fn pretty(v: &Value) -> String {
serde_json::to_string_pretty(v).unwrap_or_default()
}
/// The whole usage: every command's lines under one `usage:` line. The lines come from the
/// same place a command's own `--help` prints, so the two cannot drift apart.
fn usage() -> String {
let mut s = String::from("usage:\n");
for c in COMMANDS.iter().filter(|c| c.listed) {
s.push_str(c.usage);
}
s.push_str(USAGE_DEBUG);
s
}
/// A command's own usage: its lines under a `usage:` line.
fn command_usage(cmd: &str) -> String {
match COMMANDS.iter().find(|c| c.name == cmd) {
Some(c) => format!("usage:\n{}", c.usage),
None => usage(),
}
}
/// Whether the name is a command the CLI takes.
fn is_command(cmd: &str) -> bool {
COMMANDS.iter().any(|c| c.name == cmd)
}
/// Whether the arguments ask for help.
fn wants_help(args: &[String]) -> bool {
args.iter().any(|a| a == "--help" || a == "-h")
}
pub fn read_json(path: &str) -> Result<Value, String> {
// Through the engine's file access, which refuses anything that is not a regular file and
// reads no more than the cap: a character device or a FIFO would otherwise never end.
@@ -139,7 +246,14 @@ pub fn read_json(path: &str) -> Result<Value, String> {
/// A session from a design file, a STEP/3MF/STL file, a command script, or an oracle recipe.
pub fn load(path: &str) -> Result<Session, String> {
load_checked(path, solidcraft_engine::schema::ParamCheck::Off)
}
/// `load`, with scripts and recipes run at the parameter-check level `check`, which the
/// session keeps.
fn load_checked(path: &str, check: solidcraft_engine::schema::ParamCheck) -> Result<Session, String> {
let mut s = Session::default();
s.param_check = check;
if solidcraft_engine::io::is_step_path(path) || solidcraft_engine::io::is_mesh_path(path) {
s.execute("doc.open", &json!({"path": path})).map_err(|e| e.to_string())?;
return Ok(s);
@@ -149,16 +263,22 @@ pub fn load(path: &str) -> Result<Session, String> {
let doc = solidcraft_engine::io::read_design(&bytes).map_err(|e| format!("{path}: {e}"))?;
let mut session = Session::new(doc);
session.path = Some(path.to_owned());
session.param_check = check;
return Ok(session);
}
let v: Value = serde_json::from_slice(&bytes).map_err(|e| format!("{path}: {e}"))?;
if v.get("format").and_then(Value::as_str).is_some_and(|f| solidcraft_engine::doc::format::version_of(f).is_some()) {
s.execute("doc.open", &json!({"path": path})).map_err(|e| e.to_string())?;
} else if v.get("features").is_some() && v.get("commands").is_none() {
let script = recipe::to_script(&v)?;
s.run_script(&script).map_err(|e| e.to_string())?;
} else {
s.run_script(&v).map_err(|e| e.to_string())?;
let script = if v.get("features").is_some() && v.get("commands").is_none() { recipe::to_script(&v)? } else { v };
// Warn level: each step's `param_warnings` as it completes, even when a later step fails
// (stderr, so stdout stays the caller's).
let mut warn = |step: usize, r: &Value| {
for w in r.get("param_warnings").and_then(Value::as_array).into_iter().flatten() {
eprintln!("{path}: step {}: {}", step + 1, w.as_str().unwrap_or_default());
}
};
s.run_script_each(&script, &mut warn).map_err(|e| e.to_string())?;
}
Ok(s)
}
@@ -176,7 +296,11 @@ fn positional(args: &[String]) -> Option<&String> {
let mut i = 0;
while let Some(a) = args.get(i) {
if a.starts_with("--") {
i += if matches!(a.as_str(), "--quiet" | "--results" | "--allow-errors" | "--no-grid" | "--no-axes") { 1 } else { 2 };
i += if matches!(a.as_str(), "--quiet" | "--results" | "--timings" | "--profile" | "--allow-errors" | "--no-grid" | "--no-axes") {
1
} else {
2
};
continue;
}
return Some(a);
@@ -213,21 +337,35 @@ fn print_json(v: &Value, file: Option<&str>) -> Result<(), String> {
}
fn cmd_run(args: &[String]) -> Result<(), String> {
install_renderer();
let json_out = json_out(args)?;
let base = flag(args, "--in");
let path = positional(args);
if path.is_none() && base.is_none() {
return Err(USAGE.to_string());
return Err(usage());
}
let (mut s, results) = if args.iter().any(|a| a == "--results") {
// Collect every command's result (scripts only), then process requested files too.
let want_results = args.iter().any(|a| a == "--results");
// `--profile` times each command and splits out its kernel time; it implies `--timings`.
let profile = args.iter().any(|a| a == "--profile");
let timings = profile || args.iter().any(|a| a == "--timings");
let (mut s, results) = if want_results || timings {
// Collect every command's result or time (scripts only), then process requested files too.
let mut s = match base {
Some(b) => load(b)?,
None => Session::default(),
};
let v = read_json(path.ok_or("--results needs a script")?)?;
let r = s.run_script(&v).map_err(|e| e.to_string())?;
(s, Some(Value::Array(r)))
let needs = if want_results { "--results needs a script" } else { "--timings needs a script" };
let v = read_json(path.ok_or(needs)?)?;
let out = if timings {
let (r, mut t) = s.run_script_timed(&v, profile).map_err(|e| e.to_string())?;
if want_results && let Some(o) = t.as_object_mut() {
o.insert("results".into(), Value::Array(r));
}
t
} else {
Value::Array(s.run_script(&v).map_err(|e| e.to_string())?)
};
(s, Some(out))
} else {
(open(base, path)?, None)
};
@@ -264,13 +402,14 @@ fn open(base: Option<&str>, path: Option<&String>) -> Result<Session, String> {
}
(Some(b), None) => load(b),
(None, Some(p)) => load(p),
(None, None) => Err(USAGE.to_string()),
(None, None) => Err(usage()),
}
}
/// Refuses an option `cmd` doesn't take, and one missing its value: a typo, or another
/// command's option, would otherwise be ignored without a word.
fn known_flags(cmd: &str, args: &[String], valued: &[&str], switches: &[&str]) -> Result<(), String> {
/// command's option, would otherwise be ignored without a word. `positionals` says whether the
/// command takes a word of its own (a file, a seed): `fuzz` takes none, and would start a run.
fn known_args(cmd: &str, args: &[String], valued: &[&str], switches: &[&str], positionals: bool) -> Result<(), String> {
let mut i = 0;
while let Some(a) = args.get(i) {
if valued.contains(&a.as_str()) {
@@ -278,7 +417,9 @@ fn known_flags(cmd: &str, args: &[String], valued: &[&str], switches: &[&str]) -
Some(v) if !v.starts_with("--") => i += 2,
_ => return Err(format!("{cmd}: {a} needs a value")),
}
} else if a.starts_with("--") && !switches.contains(&a.as_str()) {
} else if switches.contains(&a.as_str()) {
i += 1;
} else if a.starts_with("--") || !positionals {
return Err(format!("{cmd} doesn't take {a} (it takes {})", [valued, switches].concat().join(", ")));
} else {
i += 1;
@@ -302,7 +443,7 @@ fn timeline_ok(s: &Session) -> Result<(), String> {
fn cmd_eval(args: &[String]) -> Result<(), String> {
let json_out = json_out(args)?;
let path = flag(args, "--in").or_else(|| positional(args).map(String::as_str)).ok_or_else(|| USAGE.to_string())?;
let path = flag(args, "--in").or_else(|| positional(args).map(String::as_str)).ok_or_else(usage)?;
let mut s = load(path)?;
let m = s.execute("inspect.measure", &json!({})).map_err(|e| e.to_string())?;
let d = s.execute("document.inspect", &json!({})).map_err(|e| e.to_string())?;
@@ -320,7 +461,7 @@ fn cmd_eval(args: &[String]) -> Result<(), String> {
/// A design's history as a command script (`timeline.export_script`): printed, or written to
/// `--out`; what the script leaves out goes to stderr.
fn cmd_script(args: &[String]) -> Result<(), String> {
let path = positional(args).ok_or_else(|| USAGE.to_string())?;
let path = positional(args).ok_or_else(usage)?;
let s = &mut load(path)?;
let out = flag(args, "--out");
let r = s.execute("timeline.export_script", &json!({"path": out})).map_err(|e| e.to_string())?;
@@ -334,18 +475,73 @@ fn cmd_script(args: &[String]) -> Result<(), String> {
Ok(())
}
/// `gpu`: the headless adapter survey and the policy's pick.
fn cmd_gpu(args: &[String]) -> Result<(), String> {
use solidcraft_gpu::select::{Policy, survey};
let policy = match args {
[] => Policy::from_env()?,
[flag, v] if flag == "--policy" => Policy::parse(v).ok_or_else(|| format!("--policy must be auto, hardware, software or cpu, not `{v}`"))?,
_ => return Err("usage: solidcraft-cli gpu [--policy auto|hardware|software|cpu]".into()),
};
let s = survey(policy);
let chosen = s.chosen.and_then(|i| s.adapters.get(i)).cloned();
// The tier pictures land on: a hardware GPU, a software adapter, or the CPU rasterizer.
let renderer = s.lands_on.map(|l| l.renderer());
println!(
"{}",
pretty(&json!({
"policy": policy.name(),
"backends": s.backends,
"adapters": s.adapters,
"chosen": chosen,
"lands_on": s.lands_on,
"renderer": renderer,
}))
);
match s.lands_on {
Some(solidcraft_gpu::select::Landing::Cpu) => {
eprintln!("warning: {}", solidcraft_engine::capture::CPU_WARNING);
Ok(())
}
Some(_) => Ok(()),
None => Err(format!("no adapter qualifies under the {} policy", policy.name())),
}
}
/// Draw this process's pictures (snapshots, MCP screenshots, saved thumbnails) on the GPU with the
/// viewport's pipelines: the headless device the `SOLIDCRAFT_GPU` policy picks, opened on the first
/// picture; under `auto` with no qualifying adapter (or `cpu`), the CPU rasterizer, and the
/// picture says so. Under `hardware` or `software` with no such adapter a picture fails with an
/// error that names the policy.
fn install_renderer() {
solidcraft_engine::capture::install(std::sync::Arc::new(|scene, cam, w, h| {
let (rgba, renderer) = solidcraft_gpu::capture::headless_rgba(scene, cam, w, h)?;
Ok(solidcraft_engine::capture::Picture { rgba, renderer })
}));
}
fn cmd_snapshot(args: &[String]) -> Result<(), String> {
known_flags("snapshot", args, &["--in", "--out", "--width", "--height", "--view", "--camera"], &["--no-grid", "--no-axes"])?;
install_renderer();
known_args(
"snapshot",
args,
&["--in", "--out", "--width", "--height", "--view", "--camera", "--body", "--tool", "--section"],
&["--no-grid", "--no-axes"],
true,
)?;
if positional(args).is_none() && flag(args, "--in").is_none() {
return Err(USAGE.to_string());
return Err(usage());
}
let out = flag(args, "--out").ok_or("snapshot needs --out FILE.png")?;
let size = |name: &str, default: usize| match flag(args, name) {
let size = |name: &str, default: u32| match flag(args, name) {
None => Ok(default),
Some(x) => x.parse().ok().filter(|n| (16..=8192).contains(n)).ok_or_else(|| format!("{name} must be 16 to 8192 pixels, not `{x}`")),
};
let (w, h) = (size("--width", 1280)?, size("--height", 800)?);
let mut s = open(flag(args, "--in"), positional(args))?;
// How the bodies are drawn and where the model is cut, through the commands the app's
// Appearance dialog and Section Analysis run (`preview`).
preview::apply(&mut s, &flags(args, "--body"), &flags(args, "--tool"), flag(args, "--section"))?;
// The camera choices of the MCP `screenshot`, through the same command (`view.apply`): a
// standard view, a view saved in the design, or an explicit camera. A script that applies a
// view itself sets the camera too; without any, the iso view framing the model.
@@ -364,9 +560,14 @@ fn cmd_snapshot(args: &[String]) -> Result<(), String> {
let cam = s.camera.unwrap_or_else(|| solidcraft_engine::view::home_camera(&s));
let guides = Guides { grid: !args.iter().any(|a| a == "--no-grid"), axes: !args.iter().any(|a| a == "--no-axes") };
let scene = solidcraft_engine::view::scene_with_guides(&s, &cam, s.section, guides);
let png = render_png(&scene, &cam, w, h).ok_or("render failed")?;
std::fs::write(out, &png).map_err(|e| format!("{out}: {e}"))?;
println!("{}", pretty(&json!({"path": out, "width": w, "height": h, "bytes": png.len()})));
let png = solidcraft_engine::capture::png(&scene, &cam, w, h)?;
std::fs::write(out, &png.bytes).map_err(|e| format!("{out}: {e}"))?;
let mut reply = json!({"path": out, "width": w, "height": h, "bytes": png.bytes.len(), "renderer": png.renderer});
if let Some(w) = solidcraft_engine::capture::warning(png.renderer) {
eprintln!("warning: {w}");
reply["warning"] = json!(w);
}
println!("{}", pretty(&reply));
Ok(())
}
@@ -412,7 +613,8 @@ fn ico(images: &[(usize, Vec<u8>)]) -> Result<Vec<u8>, String> {
}
fn cmd_exec(args: &[String]) -> Result<(), String> {
let id = args.first().ok_or_else(|| USAGE.to_string())?;
install_renderer();
let id = args.first().ok_or_else(usage)?;
let p: Value = match args.get(1) {
Some(t) => serde_json::from_str(t).map_err(|e| format!("params: {e}"))?,
None => json!({}),
@@ -427,6 +629,7 @@ fn cmd_exec(args: &[String]) -> Result<(), String> {
/// a running app's control port. stdout carries only protocol messages. Files the agent's tool
/// calls reach are confined to `solidcraft_mcp::agent_scope` unless `--allow-any-path` is given.
fn cmd_mcp(args: &[String]) -> Result<(), String> {
install_renderer();
let scope = (!args.iter().any(|a| a == "--allow-any-path"))
.then(|| solidcraft_mcp::agent_scope(flags(args, "--allow-path").into_iter().map(std::path::PathBuf::from)));
if let Some(s) = &scope {
@@ -442,6 +645,17 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> {
eprintln!("solidcraft mcp: the design's folder is left out because {why}; add it with --allow-path if you mean it");
}
}
// How a headless session treats calls that break a command's parameter contract (docs/mcp.md).
let param_check = match flag(args, "--param-check") {
None if args.iter().any(|a| a == "--param-check") => return Err("mcp: --param-check wants off, warn or strict".into()),
None => solidcraft_engine::schema::ParamCheck::Off,
Some(v) => {
solidcraft_engine::schema::ParamCheck::parse(v).ok_or_else(|| format!("mcp: --param-check wants off, warn or strict, not `{v}`"))?
}
};
if param_check != solidcraft_engine::schema::ParamCheck::Off && flag(args, "--connect").is_some() {
eprintln!("solidcraft mcp: --param-check applies to headless sessions; calls to the app are not checked yet");
}
let backend: Box<dyn solidcraft_mcp::Backend> = match (flag(args, "--connect"), flag(args, "--in")) {
(Some(_), Some(_)) => return Err("mcp: use either --connect or --in, not both".into()),
(Some(addr), None) => {
@@ -458,14 +672,38 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> {
}
Box::new(remote)
}
(None, Some(path)) => Box::new(solidcraft_mcp::Headless::new(load(path)?)),
(None, None) => Box::new(solidcraft_mcp::Headless::default()),
(None, Some(path)) => Box::new(solidcraft_mcp::Headless::new(load_checked(path, param_check)?)),
// No arguments: the running desktop app, found through the discovery file it wrote
// (docs/control-protocol.md); none running (or `--headless`): a session of its own.
(None, None) => {
if !args.iter().any(|a| a == "--headless")
&& let Some(port) = discover_app()
{
eprintln!("solidcraft mcp: bridged to the running app at 127.0.0.1:{port} (--headless starts a session of its own)");
return serve_mcp(Box::new(solidcraft_mcp::Remote::new(&format!("127.0.0.1:{port}"), flag(args, "--token"))), scope);
}
let mut headless = solidcraft_mcp::Headless::default();
headless.session.param_check = param_check;
Box::new(headless)
}
};
serve_mcp(backend, scope)
}
fn serve_mcp(backend: Box<dyn solidcraft_mcp::Backend>, scope: Option<solidcraft_engine::io::vfs::PathScope>) -> Result<(), String> {
let stdin = std::io::stdin();
let stdout = std::io::stdout();
solidcraft_mcp::Server::new(backend).with_path_scope(scope).serve(stdin.lock(), stdout.lock()).map_err(|e| e.to_string())
}
/// The port of the running desktop app: the one its discovery file names, if something listens
/// there (a file left by an app that crashed names a closed port).
fn discover_app() -> Option<u16> {
let port = solidcraft_engine::control_auth::read_current_port()?;
let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port));
std::net::TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(500)).ok().map(|_| port)
}
/// `save-stress <path> <rounds>`: save a design of a few hundred kilobytes `rounds` times, its
/// parameter `save_round` set to the round number each time.
fn cmd_save_stress(args: &[String]) -> Result<(), String> {
@@ -518,6 +756,36 @@ fn cmd_bench_edit(args: &[String]) -> Result<(), String> {
Ok(())
}
/// `bench-open --ops`: each command of `script` (a JSON array of `{command, params}`), then its
/// undo, each timed as the command and the view it leaves (the placed model, its meshes warmed
/// as the desktop does after a change).
fn bench_ops(mut s: Session, script: &str) -> Result<Session, String> {
let text = std::fs::read_to_string(script).map_err(|e| format!("{script}: {e}"))?;
let ops: Value = serde_json::from_str(&text).map_err(|e| format!("{script}: {e}"))?;
let ops = ops.as_array().ok_or("--ops: a JSON array of {command, params}")?;
let time = |s: &mut Session, id: &str, p: &Value| -> Result<Value, String> {
let t = std::time::Instant::now();
let r = s.execute(id, p).map_err(|e| format!("{id}: {e}"))?;
let ms = t.elapsed().as_secs_f64() * 1000.0;
let t = std::time::Instant::now();
s.world_state().warm_meshes(|| true);
let view_ms = t.elapsed().as_secs_f64() * 1000.0;
println!("{}", json!({"step": "op", "command": id, "ms": ms, "view_ms": view_ms}));
Ok(r)
};
for op in ops {
let id = op.get("command").and_then(Value::as_str).ok_or("--ops: each step needs a `command`")?;
let p = op.get("params").cloned().unwrap_or(Value::Null);
let steps = s.undo.len();
time(&mut s, id, &p)?;
// Commands that are no undo step (showing and hiding) have nothing to undo.
if op.get("undo").and_then(Value::as_bool) != Some(false) && s.undo.len() > steps {
time(&mut s, "edit.undo", &json!({}))?;
}
}
Ok(s)
}
/// Separate file reading, JSON decoding/repair, timeline evaluation and display meshing.
pub(crate) fn cmd_bench_open(args: &[String]) -> Result<(), String> {
let path = args.first().ok_or("usage: solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only]")?;
@@ -530,37 +798,84 @@ pub(crate) fn cmd_bench_open(args: &[String]) -> Result<(), String> {
let t = std::time::Instant::now();
// The file as read, held until the timeline has evaluated it (as the import command does).
let mut read = None;
let doc = if step {
let (doc, saved) = if step {
let imported = solidcraft_engine::io::step_import_feature(&bytes, path).map_err(|e| e.to_string())?;
let mut doc = solidcraft_engine::doc::Document::new(&imported.name);
doc.add_feature(imported.kind, Some(&imported.name)).map_err(|e| e.to_string())?;
read = Some(imported.import);
doc
(doc, None)
} else {
solidcraft_engine::io::read_design(&bytes).map_err(|e| e.to_string())?
// As Open reads it: with the model the file keeps.
solidcraft_engine::io::read_design_file(path, None::<fn(solidcraft_engine::io::DesignPreview)>).map_err(|e| e.to_string())?
};
let parse_ms = t.elapsed().as_secs_f64() * 1000.0;
println!("{}", json!({"step": "parse", "ms": parse_ms, "features": doc.features.len()}));
println!("{}", json!({"step": "parse", "ms": parse_ms, "features": doc.features.len(), "saved_model": saved.is_some()}));
if args.iter().any(|a| a == "--parse-only") {
return Ok(());
}
let t = std::time::Instant::now();
let s = Session::new(doc);
let s = Session::opened(doc, saved.as_ref());
drop(read);
println!("{}", json!({"step": "evaluate", "ms": t.elapsed().as_secs_f64() * 1000.0}));
// Taken from the file: nothing evaluated (SOLIDCRAFT_NO_MODEL_CACHE evaluates anyway), or
// only the features whose state a thinned file left out.
let adopted = saved.is_some() && s.model.last_recomputed == 0;
println!("{}", json!({"step": "evaluate", "ms": t.elapsed().as_secs_f64() * 1000.0, "adopted": adopted, "recomputed": s.model.last_recomputed}));
for r in &s.model.results {
println!("{}", json!({"step": "feature", "id": r.id, "name": r.name, "ms": r.ms, "error": r.error}));
}
let t = std::time::Instant::now();
let st = s.world_state();
let place_ms = t.elapsed().as_secs_f64() * 1000.0;
// As the desktop app prepares a design it opened.
st.warm_meshes(|| true);
let triangles: usize = st.bodies.iter().map(|b| b.mesh().triangles.len()).sum();
println!("{}", json!({"step": "mesh", "ms": t.elapsed().as_secs_f64() * 1000.0, "bodies": st.bodies.len(), "triangles": triangles}));
println!(
"{}",
json!({"step": "mesh", "ms": t.elapsed().as_secs_f64() * 1000.0, "place_ms": place_ms, "bodies": st.bodies.len(), "triangles": triangles})
);
let flag = |name: &str| args.iter().position(|a| a == name);
// `--save PATH`: save the opened design without its model cache (to PATH.plain.solidcraft) and
// as the app does, with it, then read the cache back.
let s = match flag("--save").and_then(|i| args.get(i + 1)) {
Some(out) => {
let mut s = s;
// Without the cache first, for comparison.
let plain = format!("{out}.plain.solidcraft");
let t = std::time::Instant::now();
s.execute("file.save_as", &json!({"path": plain, "cache": false})).map_err(|e| e.to_string())?;
let plain_ms = t.elapsed().as_secs_f64() * 1000.0;
let plain_bytes = std::fs::metadata(&plain).map(|m| m.len()).unwrap_or(0);
let t = std::time::Instant::now();
s.execute("file.save_as", &json!({"path": out})).map_err(|e| e.to_string())?;
let save_ms = t.elapsed().as_secs_f64() * 1000.0;
let bytes = std::fs::read(out).map_err(|e| format!("{out}: {e}"))?;
let t = std::time::Instant::now();
let cached = solidcraft_engine::io::read_design_geometry(&bytes).map(|m| m.bodies.len());
let read_ms = t.elapsed().as_secs_f64() * 1000.0;
// Why bodies could not be kept exactly, when the cache was left out.
let mut refused = std::collections::BTreeMap::<String, usize>::new();
if cached.is_none() {
for b in &s.model.state().bodies {
if let Err(e) = solidcraft_engine::kernel::encode_native_body(&b.body) {
*refused.entry(e.to_string()).or_default() += 1;
}
}
}
println!(
"{}",
json!({"step": "save", "ms": save_ms, "bytes": bytes.len(), "plain_ms": plain_ms, "plain_bytes": plain_bytes, "cached_bodies": cached, "cache_read_ms": read_ms, "refused": refused})
);
s
}
None => s,
};
// `--display LEVEL`: the display plans the viewport draws at that display quality, made as
// the GPU viewport makes them; `--edit NAME EXPR` then changes a parameter and times the
// evaluation and the plans of the bodies it changed.
let flag = |name: &str| args.iter().position(|a| a == name);
let s = match flag("--ops").and_then(|i| args.get(i + 1)) {
Some(ops) => bench_ops(s, ops)?,
None => s,
};
let Some(level) = flag("--display").and_then(|i| args.get(i + 1)) else { return Ok(()) };
let q = solidcraft_engine::doc::display_quality::DisplayQuality::parse(level).ok_or("--display: coarse, normal, fine or ultra")?;
let plans = |st: &solidcraft_engine::doc::ModelState| {
+286
View File
@@ -0,0 +1,286 @@
//! What a `snapshot` draws see-through, what it draws in a colour of its own, and where it cuts
//! the model: the `--body`, `--tool` and `--section` options.
//!
//! None of this is a renderer of its own. Each option turns into the command the app itself runs
//! — `appearance.assign` for the Appearance dialog, `inspect.section` for Section Analysis — so
//! a picture of a tool body inside a translucent target is the picture the window would show
//! with the same appearances assigned, and a script can do the same thing without the options.
use serde_json::{Value, json};
use solidcraft_engine::Session;
/// How see-through the bodies around a `--tool` are drawn: enough to read the tool through them,
/// solid enough to keep the shape of the material it meets.
pub const TARGET_OPACITY: f64 = 0.3;
/// A `--tool` body's colour: the hue the viewport's feature preview uses for removed material,
/// so a cut's tool looks in a picture the way the preview looks in the window.
pub const TOOL_COLOR: &str = "#e8542c";
/// The colour of a body with no appearance of its own ([`solidcraft_engine::view::colors::BODY`]),
/// as `appearance.assign` takes it. Giving a body an opacity means giving it a look, and a look
/// carries a colour: this keeps the one it is already drawn in.
fn default_body_hex() -> String {
let c = solidcraft_engine::view::colors::BODY;
format!("#{:02x}{:02x}{:02x}", c.0, c.1, c.2)
}
/// How one body is drawn: `--body NAME:opacity=0.3,color=#cc3333`. Either key alone is enough;
/// the other keeps what the design already says.
#[derive(Debug, PartialEq)]
pub struct BodyLook {
pub name: String,
pub opacity: Option<f64>,
pub color: Option<String>,
}
/// `NAME:key=value[,key=value]`, or `NAME` on its own (which says nothing and is an error:
/// a body is named here to change how it is drawn).
pub fn parse_body(spec: &str) -> Result<BodyLook, String> {
let (name, rest) = spec
.split_once(':')
.ok_or_else(|| format!("--body `{spec}` must be NAME:key=value (`Target:opacity=0.3`, `Tool:color=#cc3333`, keys separated by commas)"))?;
let name = name.trim();
if name.is_empty() {
return Err(format!("--body `{spec}` names no body"));
}
let mut look = BodyLook { name: name.to_owned(), opacity: None, color: None };
for part in rest.split(',') {
let part = part.trim();
if part.is_empty() {
continue;
}
let (key, value) = part.split_once('=').ok_or_else(|| format!("--body {name}: `{part}` must be key=value (opacity, color)"))?;
let value = value.trim();
match key.trim().to_ascii_lowercase().as_str() {
"opacity" => {
let o: f64 = value.parse().map_err(|_| format!("--body {name}: opacity must be a number 0 to 1, not `{value}`"))?;
if !o.is_finite() || !(0.0..=1.0).contains(&o) {
return Err(format!("--body {name}: opacity must be 0 to 1, not `{value}`"));
}
look.opacity = Some(o);
}
"color" => {
let digits = value.strip_prefix('#').filter(|h| h.len() == 6 && h.chars().all(|c| c.is_ascii_hexdigit()));
let digits = digits.ok_or_else(|| format!("--body {name}: color must be #rrggbb, not `{value}`"))?;
look.color = Some(format!("#{}", digits.to_ascii_lowercase()));
}
other => return Err(format!("--body {name}: unknown key `{other}` (opacity, color)")),
}
}
if look.opacity.is_none() && look.color.is_none() {
return Err(format!("--body `{spec}` asks for nothing: give opacity, color, or both"));
}
Ok(look)
}
/// The `inspect.section` parameters a `--section` spec asks for: `z=27` (the plane through that
/// world coordinate, its normal along +Z), `XY` (a named plane through the origin), either with
/// `,flip` to keep the other side, or the command's own JSON object.
pub fn parse_section(spec: &str) -> Result<Value, String> {
let spec = spec.trim();
if spec.starts_with('{') {
return serde_json::from_str(spec).map_err(|e| format!("--section: not JSON: {e}"));
}
let mut parts = spec.split(',').map(str::trim);
let plane = parts.next().unwrap_or_default();
let mut flip = false;
for p in parts {
match p.to_ascii_lowercase().as_str() {
"" => {}
"flip" => flip = true,
other => return Err(format!("--section: unknown `{other}` (only `flip` follows the plane)")),
}
}
// A named plane through the world origin, as `inspect.section` takes it.
let mut params = match plane.to_ascii_uppercase().as_str() {
p @ ("XY" | "XZ" | "YZ") => json!({"plane": p}),
_ => {
// AXIS=VALUE: the plane whose normal is that axis, cutting at that world coordinate.
let (axis, at) = plane.split_once('=').ok_or_else(|| {
format!("--section `{spec}` must be z=27 (a world coordinate on an axis), XY, XZ, YZ, or the JSON inspect.section takes")
})?;
let at: f64 = at.trim().parse().map_err(|_| format!("--section {axis}: `{}` is not a number", at.trim()))?;
if !at.is_finite() {
return Err(format!("--section {axis}: `{at}` is not a finite coordinate"));
}
// The plane normal to each axis: Z is XY, Y is XZ, X is YZ.
let named = match axis.trim().to_ascii_lowercase().as_str() {
"x" => "YZ",
"y" => "XZ",
"z" => "XY",
other => return Err(format!("--section: unknown axis `{other}` (x, y or z)")),
};
json!({"plane": named, "at": at})
}
};
if flip {
params["flip"] = json!(true);
}
Ok(params)
}
/// The design's body names, for an error that says which ones there are.
fn body_names(s: &Session) -> Vec<String> {
s.model.state().bodies.iter().map(|b| b.name.clone()).collect()
}
/// The colour and opacity a body is drawn with now, as `appearance.assign` takes them: its own
/// appearance, else its component's or material's, else the default body colour.
fn current_look(s: &mut Session, name: &str) -> Result<(String, f64), String> {
let reply = s.execute("appearance.list", &json!({"body": name})).map_err(|e| e.to_string())?;
let body = reply.get("body");
let look = body.and_then(|b| b.get("appearance")).filter(|a| !a.is_null());
let hex = look
.and_then(|a| a.get("color"))
.and_then(Value::as_str)
.map(str::to_owned)
// No appearance, but an imported colour: `body.color` is that colour, 0..1 per channel.
.or_else(|| {
let c = body?.get("color")?.as_array()?;
let ch = |i: usize| -> Option<u8> { Some((c.get(i)?.as_f64()?.clamp(0.0, 1.0) * 255.0).round() as u8) };
Some(format!("#{:02x}{:02x}{:02x}", ch(0)?, ch(1)?, ch(2)?))
})
.unwrap_or_else(default_body_hex);
let opacity = look.and_then(|a| a.get("opacity")).and_then(Value::as_f64).filter(|o| o.is_finite()).unwrap_or(1.0);
Ok((hex, opacity))
}
/// Draw `name` with `look`, keeping whichever of colour and opacity it does not give.
fn assign(s: &mut Session, look: &BodyLook) -> Result<(), String> {
let name = &look.name;
if !body_names(s).iter().any(|b| b == name) {
return Err(format!("no body `{name}` in the design (it has {})", body_list(s)));
}
let (hex, opacity) = current_look(s, name)?;
let params = json!({
"bodies": [name],
"color": look.color.clone().unwrap_or(hex),
"opacity": look.opacity.unwrap_or(opacity),
});
s.execute("appearance.assign", &params).map(|_| ()).map_err(|e| format!("drawing `{name}`: {e}"))
}
/// The design's bodies, named, for an error message.
fn body_list(s: &Session) -> String {
let names = body_names(s);
if names.is_empty() { "no bodies".to_owned() } else { names.join(", ") }
}
/// Apply the options to the session, in the order a later one overrides an earlier: the `--tool`
/// preview first (it speaks for every body), then each `--body`, then the section.
pub fn apply(s: &mut Session, bodies: &[&str], tools: &[&str], section: Option<&str>) -> Result<(), String> {
// Everything is read before anything is drawn, so a misspelt option is an error rather than
// half a picture.
let looks: Vec<BodyLook> = bodies.iter().map(|spec| parse_body(spec)).collect::<Result<_, _>>()?;
let section = section.map(parse_section).transpose()?;
if !tools.is_empty() {
let names = body_names(s);
for t in tools {
if !names.iter().any(|b| b == t) {
return Err(format!("--tool: no body `{t}` in the design (it has {})", body_list(s)));
}
}
// Everything but the tool see-through, in the colour it already has, so the tool reads
// against the material it meets. Bodies that share a colour are assigned together: a
// design of many bodies then costs a command or two rather than one for each.
let mut around: std::collections::BTreeMap<String, Vec<String>> = std::collections::BTreeMap::new();
for name in names.iter().filter(|n| !tools.contains(&n.as_str())) {
around.entry(current_look(s, name)?.0).or_default().push(name.clone());
}
for (hex, sharing_it) in around {
let params = json!({"bodies": sharing_it, "color": hex, "opacity": TARGET_OPACITY});
s.execute("appearance.assign", &params).map_err(|e| format!("--tool: {e}"))?;
}
// Then the tool itself, solid in the tool colour.
let params = json!({"bodies": tools, "color": TOOL_COLOR, "opacity": 1.0});
s.execute("appearance.assign", &params).map_err(|e| format!("--tool: {e}"))?;
}
for look in &looks {
assign(s, look)?;
}
if let Some(params) = section {
s.execute("inspect.section", &params).map_err(|e| format!("--section: {e}"))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
/// `--body` names a body and says how it is drawn. Either key alone is enough.
#[test]
fn a_body_spec_is_a_name_and_the_keys_appearance_assign_takes() {
let look = |s: &str| parse_body(s).unwrap();
assert_eq!(look("Target:opacity=0.3"), BodyLook { name: "Target".into(), opacity: Some(0.3), color: None });
assert_eq!(look("Tool:color=#CC3333"), BodyLook { name: "Tool".into(), opacity: None, color: Some("#cc3333".into()) });
assert_eq!(look("Tool: opacity = 1 , color = #00ff00 "), BodyLook { name: "Tool".into(), opacity: Some(1.0), color: Some("#00ff00".into()) });
// A body name with a space in it, as the browser shows them.
assert_eq!(look("Bracket arm:opacity=0").name, "Bracket arm");
assert_eq!(look("A:opacity=0").opacity, Some(0.0));
}
/// Every way of getting `--body` wrong is an error that says what it takes, never a panic
/// and never a silently ignored option.
#[test]
fn a_bad_body_spec_says_what_it_takes() {
for (spec, expect) in [
("Target", "NAME:key=value"),
(":opacity=0.3", "names no body"),
("Target:", "asks for nothing"),
("Target:opacity", "key=value"),
("Target:opacity=high", "0 to 1"),
("Target:opacity=1.5", "0 to 1"),
("Target:opacity=-0.2", "0 to 1"),
("Target:opacity=NaN", "0 to 1"),
("Target:opacity=inf", "0 to 1"),
("Target:color=red", "#rrggbb"),
("Target:color=#ccc", "#rrggbb"),
("Target:color=#gggggg", "#rrggbb"),
("Target:alpha=0.3", "unknown key `alpha`"),
] {
let e = parse_body(spec).err().unwrap_or_else(|| panic!("{spec} was accepted"));
assert!(e.contains(expect), "{spec}: {e}");
}
}
/// A `--section` spec is the plane `inspect.section` cuts with: an axis and a world
/// coordinate, a named plane, `flip` for the other side, or the command's own JSON.
#[test]
fn a_section_spec_is_the_plane_inspect_section_takes() {
let p = |s: &str| parse_section(s).unwrap();
// The plane normal to each axis, cutting at that coordinate.
assert_eq!(p("z=27"), json!({"plane": "XY", "at": 27.0}));
assert_eq!(p("y=-5.5"), json!({"plane": "XZ", "at": -5.5}));
assert_eq!(p("X=0"), json!({"plane": "YZ", "at": 0.0}));
assert_eq!(p("z=27,flip"), json!({"plane": "XY", "at": 27.0, "flip": true}));
// A named plane through the origin, and the command's own parameters.
assert_eq!(p("XY"), json!({"plane": "XY"}));
assert_eq!(p("yz,flip"), json!({"plane": "YZ", "flip": true}));
assert_eq!(p("{\"plane\": \"XY\", \"offset\": 4}"), json!({"plane": "XY", "offset": 4}));
}
#[test]
fn a_bad_section_spec_says_what_it_takes() {
for (spec, expect) in [
("27", "z=27"),
("w=4", "unknown axis `w`"),
("z=high", "is not a number"),
("z=inf", "not a finite coordinate"),
("z=27,upside-down", "unknown `upside-down`"),
("{\"plane\":", "not JSON"),
] {
let e = parse_section(spec).err().unwrap_or_else(|| panic!("{spec} was accepted"));
assert!(e.contains(expect), "{spec}: {e}");
}
}
/// The tool colour is the one the options document, in the form `appearance.assign` takes.
#[test]
fn the_tool_colour_is_a_hex_colour_the_command_takes() {
assert_eq!(parse_body(&format!("Tool:color={TOOL_COLOR}")).unwrap().color, Some(TOOL_COLOR.to_owned()));
assert!((0.0..1.0).contains(&TARGET_OPACITY), "a target must be see-through: {TARGET_OPACITY}");
assert_eq!(default_body_hex(), "#b0bac6");
}
}
+18
View File
@@ -423,6 +423,24 @@ pub fn to_script(recipe: &Value) -> Result<Value, String> {
"sketch": profile_sketch(f), "profiles": profiles_of(f), "path_sketch": path.get("sketch"), "path": path.get("curves"),
"operation": op(f), "name": name, "body_names": bodies}}));
}
// A boundary surface: a loop of one sketch's curves (`sketch`, `curves`), or a network
// (`u`, `v`), as `surface.boundary` takes them.
Some("boundary_surface") => {
let mut p = json!({"name": name});
for k in ["sketch", "curves", "continuity", "u", "v", "operation"] {
if let Some(v) = f.get(k) {
p[k] = v.clone();
}
}
if let Some(b) = bodies.as_array().and_then(|a| a.first()) {
p["body_name"] = b.clone();
}
out.push(json!({"command": "surface.boundary", "params": p}));
}
Some("thicken") => out.push(json!({"command": "surface.thicken", "params": {
"bodies": f.get("bodies"), "thickness": f.get("thickness"), "symmetric": f.get("symmetric").cloned().unwrap_or(json!(false)),
"operation": op(f), "name": name}})),
Some("remove") => out.push(json!({"command": "solid.remove", "params": {"bodies": f.get("bodies"), "name": name}})),
Some("shell") => {
let faces: Vec<Value> = f.get("faces_removed").and_then(Value::as_array).into_iter().flatten().filter_map(|x| x.get("point").cloned()).collect();
out.push(json!({"command": "solid.shell", "params": {"faces": faces, "thickness": f.get("inside_thickness"), "name": name}}));
+38 -2
View File
@@ -11,15 +11,25 @@ pub fn run(args: &[String]) -> Result<(), String> {
let mut setup = None;
let mut width = None;
let mut height = None;
let mut progressive = false;
let mut samples = None;
let mut max_depth = None;
let mut seed = None;
let mut exposure = None;
let mut lighting = None;
let mut tone_map = None;
let mut environment = None;
let mut transparent = false;
let mut overwrite = false;
let mut timeout = 120u64;
let mut i = 1;
while let Some(arg) = args.get(i) {
match arg.as_str() {
"--progressive" => progressive = true,
"--transparent" => transparent = true,
"--overwrite" => overwrite = true,
"--out" | "--setup" | "--width" | "--height" | "--timeout" => {
"--out" | "--setup" | "--width" | "--height" | "--timeout" | "--samples" | "--max-depth" | "--seed" | "--exposure" | "--lighting"
| "--tone-map" | "--environment" => {
i += 1;
let value = args.get(i).ok_or_else(|| format!("missing value for {arg}"))?;
match arg.as_str() {
@@ -27,6 +37,26 @@ pub fn run(args: &[String]) -> Result<(), String> {
"--setup" => setup = Some(value.clone()),
"--width" => width = Some(value.parse::<u32>().map_err(|_| "invalid width")?),
"--height" => height = Some(value.parse::<u32>().map_err(|_| "invalid height")?),
"--samples" => samples = Some(value.parse::<u32>().map_err(|_| "invalid samples")?),
"--max-depth" => max_depth = Some(value.parse::<u32>().map_err(|_| "invalid depth")?),
"--seed" => seed = Some(value.parse::<u32>().map_err(|_| "invalid seed")?),
"--exposure" => exposure = Some(value.parse::<f64>().map_err(|_| "invalid exposure")?),
"--environment" => {
let text =
solidcraft_engine::io::vfs::read_to_string(value, 65536).map_err(|e| format!("could not read environment: {e}"))?;
environment = Some(serde_json::from_str::<serde_json::Value>(&text).map_err(|e| e.to_string())?);
}
"--lighting" => {
let text = solidcraft_engine::io::vfs::read_to_string(value, 65536).map_err(|e| format!("could not read lighting: {e}"))?;
lighting = Some(serde_json::from_str::<solidcraft_engine::render::job::StudioLighting>(&text).map_err(|e| e.to_string())?);
}
"--tone-map" => {
tone_map = Some(match value.as_str() {
"reinhard" => solidcraft_engine::render::job::ToneMap::Reinhard,
"aces_fitted" => solidcraft_engine::render::job::ToneMap::AcesFitted,
_ => return Err("tone-map must be reinhard or aces_fitted".into()),
})
}
_ => timeout = value.parse::<u64>().map_err(|_| "invalid timeout")?,
}
}
@@ -40,6 +70,9 @@ pub fn run(args: &[String]) -> Result<(), String> {
let out = out.ok_or("render requires --out FILE.png")?;
let mut session = crate::load(input)?;
session.set_render_job_host(Some(CpuHost::for_platform().map_err(|e| e.to_string())?)).map_err(|e| e.to_string())?;
if progressive {
session.register_render_job_host(solidcraft_gpu::path::host().map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
}
session.execute("render.scene", &json!({})).map_err(|e| e.to_string())?;
let mut settings = if let Some(name) = setup {
session.doc.render.setups.get(&name).cloned().ok_or("no saved render setup with that name")?
@@ -58,7 +91,10 @@ pub fn run(args: &[String]) -> Result<(), String> {
if let Some(height) = height {
settings.height = height;
}
let params = json!({"settings":settings,"transparent_background":transparent});
if let Some(exposure) = exposure {
settings.exposure_ev = exposure;
}
let params = json!({"settings":settings,"transparent_background":transparent,"progressive":progressive,"samples":samples,"max_depth":max_depth,"seed":seed,"lighting":lighting,"tone_map":tone_map,"environment":environment});
let preflight = session.execute("render.preflight", &params).map_err(|e| e.to_string())?;
let started = session.execute("render.start", &params).map_err(|e| e.to_string())?;
let id: JobId = serde_json::from_value(started["job"].clone()).map_err(|e| e.to_string())?;
+2 -5
View File
@@ -350,7 +350,7 @@ fn actions(s: &Session) -> Vec<Action> {
// The surface's normal at `p` (either way round): exact on analytic faces.
let normal_at = |f: u32, p: Vec3| -> Option<Vec3> {
match face_surface(&b.body, f as usize) {
Some(FaceSurface::Plane { normal, .. }) => Some(normal),
Some(FaceSurface::Plane { normal, .. }) => Some(*normal),
Some(FaceSurface::Cylinder { origin, axis, .. }) => {
let v = p - origin;
(v - axis * v.dot(axis)).normalized()
@@ -1412,7 +1412,7 @@ fn a_sketch_on_a_patterned_boss_top() {
fn construction_geometry_and_holes_on_a_moved_component() {
let moved = || {
vec![
json!({"command": "component.create", "params": {"name": "Arm"}}),
json!({"command": "component.create", "params": {"name": "Arm", "grounded": false}}),
json!({"command": "solid.box", "params": {"corner": [20, 0, 0], "length": 10, "width": 4, "height": 4, "body_name": "ArmBody"}}),
json!({"command": "component.activate", "params": {"component": 0}}),
json!({"command": "occurrence.move", "params": {"occurrence": "Arm:1", "translate": [0, 50, 0]}}),
@@ -1519,7 +1519,6 @@ fn sketches_on_loft_and_sweep_faces() {
}
#[test]
#[ignore = "fails until #562 is fixed"]
fn press_pull_on_the_pi_case_floor() {
reproduces(
Some("tests/action_matrix/designs/pi-case.solidcraft"),
@@ -1560,7 +1559,6 @@ fn a_sketch_on_the_rim_a_shell_leaves() {
}
#[test]
#[ignore = "fails until #565 is fixed"]
fn sketches_after_split_body_and_intersect() {
reproduces(
None,
@@ -1667,7 +1665,6 @@ fn a_tangent_plane_on_a_drill_tip() {
}
#[test]
#[ignore = "fails until #568 is fixed"]
fn a_plane_on_a_patterned_counterbore_after_a_thickness_change() {
let mut c = vec![json!({"command": "parameters.change", "params": {"name": "thick", "expression": "10 mm"}})];
c.extend(rectangle(json!("XY"), [0., 0.], [80., 30.]));
@@ -0,0 +1,62 @@
//! The bracket example must cut two through holes, rather than successfully cutting empty
//! space beside the body. Check the shipped script and rebuild its saved design from scratch.
use std::f64::consts::PI;
use serde_json::Value;
use solidcraft_engine::geom::Vec3;
use solidcraft_engine::{Session, io, kernel};
fn assert_two_through_holes(session: &Session, stage: &str) {
let errors: Vec<_> = session.model.results.iter().filter_map(|r| r.error.as_ref().map(|e| (&r.name, e))).collect();
assert!(errors.is_empty(), "{stage}: every example feature evaluates: {errors:?}");
let cut = session.doc.features.last().expect("the example ends with the hole cut");
let before = session.model.state_before(cut.id);
let after = session.model.state();
assert_eq!(before.bodies.len(), 1, "{stage}: one bracket before cutting the holes");
assert_eq!(after.bodies.len(), 1, "{stage}: the holes leave one bracket");
let uncut = &before.body("Bracket").expect("the uncut bracket").body;
let bracket = &after.body("Bracket").expect("the finished bracket").body;
// Measure the actual state before the final cut so the existing edge fillet cancels out.
// Both holes have diameter 8 mm and pass through the 6 mm base. Kernel measurements are
// tessellated, so the relative tolerance applies to the removed material, not the body.
let removed = kernel::measure(uncut).unwrap().volume - kernel::measure(bracket).unwrap().volume;
let expected = 2.0 * PI * 4.0_f64.powi(2) * 6.0;
assert!((removed - expected).abs() < expected * 1e-3, "{stage}: the two holes remove {removed} mm³, expected {expected} mm³");
let before_mesh = uncut.tessellate(0.01).unwrap();
let after_mesh = bracket.tessellate(0.01).unwrap();
let down = Vec3::new(0.0, 0.0, -1.0);
for y in [-15.0, -45.0] {
let center = Vec3::new(25.0, y, 1.0);
assert_eq!(before_mesh.ray_crossings(center, down), 2, "{stage}: hole at y={y} starts over the solid base");
// Rays through each opening must miss the entire bracket, including its top and
// bottom caps. Points away from the centre guard against a narrow slit or shifted cut.
for (dx, dy) in [(0.0, 0.0), (3.0, 0.0), (-3.0, 0.0), (0.0, 3.0), (0.0, -3.0)] {
let ray = center + Vec3::new(dx, dy, 0.0);
assert_eq!(after_mesh.ray_crossings(ray, down), 0, "{stage}: the hole at y={y} is open through both caps at ({dx}, {dy})");
}
// Material just outside each radius remains, so neither opening is an edge notch.
for (dx, dy) in [(5.0, 0.0), (-5.0, 0.0), (0.0, 5.0), (0.0, -5.0)] {
let ray = center + Vec3::new(dx, dy, 0.0);
assert_eq!(after_mesh.ray_crossings(ray, down), 2, "{stage}: material remains around the hole at y={y}, offset ({dx}, {dy})");
}
}
}
#[test]
fn bracket_example_cuts_two_through_holes_and_reopens() {
let script: Value = serde_json::from_str(include_str!("../../../examples/bracket.json")).unwrap();
let mut session = Session::default();
session.run_script(&script).unwrap();
assert_two_through_holes(&session, "script");
// No geometry cache is supplied: reopening the container re-evaluates the saved timeline.
let saved = io::write_design_container(&session.doc).unwrap();
let document = io::read_design(&saved).unwrap();
assert_eq!(document.features, session.doc.features, "saving preserves the example's feature parameters");
let reopened = Session::new(document);
assert_two_through_holes(&reopened, "reopened");
}
@@ -1,6 +1,6 @@
{"commands": [
{"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10, "body_name": "Base"}},
{"command": "component.create", "params": {"name": "Arm"}},
{"command": "component.create", "params": {"name": "Arm", "grounded": false}},
{"command": "solid.box", "params": {"corner": [20, 0, 0], "length": 10, "width": 4, "height": 4, "body_name": "ArmBody"}},
{"command": "solid.fillet", "params": {"edges": [[25, 4, 4]], "radius": 1}},
{"command": "component.activate", "params": {"component": 0}},
+1 -1
View File
@@ -27,7 +27,7 @@ const KNOWN: [(&str, &str, &str); 2] = [
(
"m0-revolve-primitives",
"Body1 bbox",
"partial revolves turn toward the sketch normal since ac43365 (as Fusion's do); M0 turned them the other way",
"partial revolves turn toward the sketch normal since ac43365 (as most parametric CAD tools do); M0 turned them the other way",
),
("components-arm", "body_count", "Measure counted only the root component's bodies then; it measures every placed body now"),
];
+159
View File
@@ -0,0 +1,159 @@
//! An explicit help request prints usage to stdout and succeeds; an unknown command still fails.
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
/// Every command the CLI takes, with the name its own usage names: `inspect` is `eval`, and
/// `licenses` is `licences`.
const COMMANDS: &[(&str, &str)] = &[
("run", "run"),
("eval", "eval"),
("inspect", "eval"),
("script", "script"),
("bench-open", "bench-open"),
("bench-agent", "bench-agent"),
("snapshot", "snapshot"),
("render", "render"),
("exec", "exec"),
("commands", "commands"),
("recipe", "recipe"),
("oracle", "oracle"),
("fuzz", "fuzz"),
("fuzz-one", "fuzz-one"),
("mcp", "mcp"),
("crashes", "crashes"),
("licences", "licences"),
("licenses", "licences"),
("gpu", "gpu"),
("icon", "icon"),
("bench-edit", "bench-edit"),
("save-stress", "save-stress"),
];
/// The commands the usage lists: the aliases answer to another command's name.
const LISTED: &[&str] = &[
"run",
"eval",
"script",
"bench-open",
"bench-agent",
"snapshot",
"render",
"exec",
"commands",
"recipe",
"oracle",
"fuzz",
"mcp",
"crashes",
"licences",
"gpu",
"icon",
];
/// The command names a text mentions after `solidcraft-cli `, each one only once.
fn mentioned(text: &str) -> Vec<&str> {
let mut names: Vec<&str> = text.split("solidcraft-cli ").skip(1).filter_map(|rest| rest.split_whitespace().next()).collect();
names.sort_unstable();
names.dedup();
names
}
/// Runs the binary and waits at most `limit` for it to finish. `None` means it was still
/// running and was killed: the fuzz run `fuzz --help` used to start goes on for minutes.
fn run_within(limit: Duration, args: &[&str]) -> Option<(Option<i32>, String, String)> {
let mut child = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).args(args).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
let started = Instant::now();
loop {
match child.try_wait().unwrap() {
Some(status) => {
let out = child.wait_with_output().unwrap();
return Some((status.code(), String::from_utf8_lossy(&out.stdout).into_owned(), String::from_utf8_lossy(&out.stderr).into_owned()));
}
None if started.elapsed() >= limit => {
let _ = child.kill();
let _ = child.wait();
return None;
}
None => std::thread::sleep(Duration::from_millis(25)),
}
}
}
#[test]
fn help_prints_usage_and_succeeds() {
for arg in ["--help", "-h", "help"] {
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).arg(arg).output().unwrap();
assert!(output.status.success(), "{arg}: {}", String::from_utf8_lossy(&output.stderr));
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.starts_with("usage:"), "{arg}: {stdout}");
assert!(stdout.contains("solidcraft-cli run"), "{arg}: {stdout}");
assert!(output.stderr.is_empty(), "{arg}: {}", String::from_utf8_lossy(&output.stderr));
}
}
#[test]
fn unknown_command_still_fails_with_usage() {
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).arg("--not-a-command").output().unwrap();
assert!(!output.status.success());
assert!(output.stdout.is_empty());
assert!(String::from_utf8_lossy(&output.stderr).starts_with("error: usage:"));
}
#[test]
fn every_command_prints_its_own_usage() {
for (cmd, named) in COMMANDS {
for flag in ["--help", "-h"] {
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).args([*cmd, flag]).output().unwrap();
assert!(output.status.success(), "{cmd} {flag}: {}", String::from_utf8_lossy(&output.stderr));
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.starts_with("usage:"), "{cmd} {flag}: {stdout}");
assert_eq!(mentioned(&stdout), vec![*named], "{cmd} {flag} printed another command's usage: {stdout}");
assert!(output.stderr.is_empty(), "{cmd} {flag}: {}", String::from_utf8_lossy(&output.stderr));
}
}
}
#[test]
fn the_top_level_usage_still_lists_every_command() {
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).arg("--help").output().unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
for cmd in LISTED {
assert!(stdout.contains(&format!("solidcraft-cli {cmd}")), "{cmd}: {stdout}");
}
}
#[test]
fn fuzz_help_does_not_start_a_fuzz_run() {
let Some((code, stdout, stderr)) = run_within(Duration::from_secs(20), &["fuzz", "--help"]) else {
panic!("`fuzz --help` kept running: it started a fuzz run instead of printing the usage");
};
assert_eq!(code, Some(0), "{stderr}");
assert!(stdout.starts_with("usage:"), "{stdout}");
assert_eq!(mentioned(&stdout), vec!["fuzz"], "{stdout}");
assert!(stderr.is_empty(), "{stderr}");
}
#[test]
fn fuzz_refuses_an_argument_it_does_not_take() {
for arg in ["--not-a-flag", "stray"] {
let Some((code, _stdout, stderr)) = run_within(Duration::from_secs(20), &["fuzz", arg]) else {
panic!("`fuzz {arg}` kept running: it started a fuzz run instead of refusing the argument");
};
assert_ne!(code, Some(0), "`fuzz {arg}` succeeded");
assert!(stderr.contains(arg), "`fuzz {arg}`: {stderr}");
}
}
#[test]
fn render_help_keeps_the_registered_gpu_request_controls() {
for args in [vec!["--help"], vec!["render", "--help"]] {
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).args(&args).output().unwrap();
assert!(output.status.success(), "{args:?}: {}", String::from_utf8_lossy(&output.stderr));
assert!(output.stderr.is_empty());
let stdout = String::from_utf8_lossy(&output.stdout);
for flag in ["--progressive", "--samples", "--max-depth", "--seed", "--exposure", "--lighting", "--environment", "--tone-map"] {
assert!(stdout.contains(flag), "{args:?} lost {flag}: {stdout}");
}
}
}
+72 -2
View File
@@ -9,8 +9,16 @@ use serde_json::{Value, json};
#[test]
fn mcp_connect_serves_before_the_app_runs_and_reaches_it_once_it_does() {
// A port that was free a moment ago: nothing listens on it.
let port = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
// This test needs the port closed until its second phase. A port obtained
// with bind(:0) and released can immediately be reused by another parallel
// test's mock server. Stay below the usual ephemeral ranges,
// with a process-specific starting point, and skip any occupied ports.
let port = (0..512u32)
.find_map(|offset| {
let port = 16000 + (std::process::id() + offset) % 16000;
TcpListener::bind(("127.0.0.1", port as u16)).ok().map(|listener| listener.local_addr().unwrap().port())
})
.expect("an unused non-ephemeral test port");
let addr = format!("127.0.0.1:{port}");
let control_dir = std::env::temp_dir().join(format!("solidcraft-mcp-connect-{}-{port}", std::process::id()));
std::fs::create_dir_all(&control_dir).unwrap();
@@ -81,3 +89,65 @@ fn mcp_connect_without_a_port_is_refused_at_start() {
assert!(stderr.contains("HOST:PORT"), "{addr}: {stderr}");
}
}
/// `mcp` with no arguments finds the running app through the discovery file and uses that
/// launch's token; with no app behind the file it stays a headless session.
#[test]
fn mcp_without_arguments_finds_the_running_app() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let control_dir = std::env::temp_dir().join(format!("solidcraft-mcp-discover-{}-{port}", std::process::id()));
std::fs::create_dir_all(&control_dir).unwrap();
let token = "fedcba9876543210".repeat(4);
std::fs::write(control_dir.join(format!("{port}.token")), &token).unwrap();
std::fs::write(control_dir.join("current.port"), format!("{port}\n")).unwrap();
let app = std::thread::spawn(move || {
// The discovery probe connects and leaves without a request; the tool call follows.
let (stream, line) = loop {
let (stream, _) = listener.accept().unwrap();
let mut line = String::new();
BufReader::new(stream.try_clone().unwrap()).read_line(&mut line).unwrap();
if !line.is_empty() {
break (stream, line);
}
};
let req: Value = serde_json::from_str(&line).unwrap();
let ok = req["token"] == json!(token);
let reply = json!({"id": req["id"], "ok": ok, "result": {"bodies": [], "method": req["method"]}, "error": "unauthorized"});
writeln!(&stream, "{reply}").unwrap();
});
let mut child = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
.args(["mcp", "--allow-any-path"])
.env("SOLIDCRAFT_CONTROL_DIR", &control_dir)
.env_remove("SOLIDCRAFT_CONTROL_TOKEN")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let mut stdin = child.stdin.take().unwrap();
let mut stdout = BufReader::new(child.stdout.take().unwrap());
writeln!(stdin, "{}", json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "measure", "arguments": {}}})).unwrap();
let mut line = String::new();
stdout.read_line(&mut line).unwrap();
let r: Value = serde_json::from_str(&line).unwrap_or_else(|e| panic!("{e}: {line:?}"));
assert_ne!(r["result"]["isError"], true, "{r}");
assert!(r["result"]["content"][0]["text"].as_str().unwrap_or_default().contains("engine.execute"), "{r}");
app.join().unwrap();
drop(stdin);
assert!(child.wait().unwrap().success());
// The file names a port nothing listens on (the app crashed): a headless session instead.
let closed = TcpListener::bind("127.0.0.1:0").unwrap();
let dead = closed.local_addr().unwrap().port();
drop(closed);
std::fs::write(control_dir.join("current.port"), format!("{dead}\n")).unwrap();
let out = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
.args(["mcp", "--allow-any-path"])
.env("SOLIDCRAFT_CONTROL_DIR", &control_dir)
.stdin(Stdio::null())
.output()
.unwrap();
assert!(!String::from_utf8_lossy(&out.stderr).contains("bridged to the running app"), "{}", String::from_utf8_lossy(&out.stderr));
std::fs::remove_dir_all(&control_dir).ok();
}
@@ -0,0 +1,50 @@
//! `mcp --in script --param-check <level>`: the level applies to the script the session starts
//! from too, so a misspelled key in it is refused (strict) or reported on stderr (warn) instead of
//! silently ignored.
use std::process::{Command, Stdio};
#[test]
fn param_check_applies_to_the_starting_script() {
let dir = std::env::temp_dir().join(format!("solidcraft-mcp-param-check-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let script = dir.join("typo.json");
let box_ = r#"{"command": "solid.box", "params": {"length": 5, "width": 5, "height": 5, "operaton": "cut"}}"#;
std::fs::write(&script, format!(r#"{{"commands": [{box_}]}}"#)).unwrap();
let run = |level: &str| {
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
.args(["mcp", "--in", script.to_str().unwrap(), "--param-check", level])
.stdin(Stdio::null())
.output()
.unwrap()
};
let strict = run("strict");
let stderr = String::from_utf8_lossy(&strict.stderr);
assert!(!strict.status.success(), "{stderr}");
assert!(stderr.contains("unknown key `operaton`; did you mean `operation`?"), "{stderr}");
// Warn: the script runs, and its warnings go to stderr.
let warn = run("warn");
let stderr = String::from_utf8_lossy(&warn.stderr);
assert!(warn.status.success(), "{stderr}");
assert!(stderr.contains("step 1: unknown key `operaton`; did you mean `operation`?"), "{stderr}");
// A warned step's warning is printed even when a later step fails.
let failing = dir.join("typo_then_failure.json");
std::fs::write(&failing, format!(r#"{{"commands": [{box_}, {{"command": "no.such_command"}}]}}"#)).unwrap();
let out = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
.args(["mcp", "--in", failing.to_str().unwrap(), "--param-check", "warn"])
.stdin(Stdio::null())
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(!out.status.success(), "{stderr}");
assert!(stderr.contains("step 1: unknown key `operaton`") && stderr.contains("step 2"), "{stderr}");
// Off: the script runs as before, and the server ends with its input.
let off = run("off");
assert!(off.status.success(), "{}", String::from_utf8_lossy(&off.stderr));
assert!(!String::from_utf8_lossy(&off.stderr).contains("operaton"));
// A bare flag is an error, not off.
let bare = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).args(["mcp", "--param-check"]).stdin(Stdio::null()).output().unwrap();
assert!(!bare.status.success());
assert!(String::from_utf8_lossy(&bare.stderr).contains("--param-check wants off, warn or strict"));
std::fs::remove_dir_all(&dir).ok();
}
+290
View File
@@ -0,0 +1,290 @@
//! A design opened from the model its file keeps is the design evaluated again: the same
//! results, the same bodies (to the bit), the same face names and volumes after every feature,
//! and the same after an edit made to both.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use serde_json::{Value, json};
use solidcraft_engine::Session;
use solidcraft_engine::doc::{ModelBody, ModelState, SavedModel};
fn corpus() -> Vec<PathBuf> {
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/corpus");
let mut v: Vec<PathBuf> =
std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "solidcraft")).collect();
v.sort();
v
}
fn temp(name: &str) -> PathBuf {
std::env::temp_dir().join(format!("solidcraft-model-cache-{}-{name}.solidcraft", std::process::id()))
}
fn open(path: &Path) -> Session {
let mut s = Session::default();
s.execute("doc.open", &json!({"path": path.to_string_lossy()})).unwrap_or_else(|e| panic!("{}: {e}", path.display()));
s
}
fn body(name: &str, b: &ModelBody) -> Value {
let measured = solidcraft_engine::kernel::measure(&b.body).ok();
json!({
"name": b.name, "feature": b.feature, "component": b.component, "source": b.source_name, "transferred": b.transferred,
"brep": solidcraft_engine::kernel::encode_native_body(&b.body).map(|bytes| format!("{:x}", sha(&bytes))).unwrap_or_else(|e| format!("{name}: {e}")),
"faces": *solidcraft_engine::doc::naming::face_names(b),
"volume": measured.as_ref().map(|m| m.volume.to_bits()), "area": measured.as_ref().map(|m| m.area.to_bits()),
"look": b.imported.as_ref().map(|l| format!("{l:?}")),
})
}
fn sha(bytes: &[u8]) -> u128 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
bytes.hash(&mut h);
let a = h.finish();
bytes.len().hash(&mut h);
(u128::from(a) << 64) | u128::from(h.finish())
}
fn state(name: &str, st: &Arc<ModelState>) -> Value {
json!({
"bodies": st.bodies.iter().map(|b| body(name, b)).collect::<Vec<_>>(),
"sketches": st.sketches.iter().map(|s| json!([s.feature, s.name, s.plane, s.sketch, s.report, s.profiles.len()])).collect::<Vec<_>>(),
"counter": st.body_counter, "sheets": st.sheets, "threads": st.threads, "construct": format!("{:?}", st.construct),
})
}
/// Everything a model holds that the rest of the app reads.
fn model(name: &str, s: &Session) -> Value {
let results: Vec<Value> = s
.model
.results
.iter()
.map(|r| json!({"id": r.id, "name": r.name, "error": r.error, "warning": r.warning, "skipped": r.skipped, "state": state(name, &r.state)}))
.collect();
json!({"results": results, "world": state(name, &s.world_state())})
}
/// The same edit on both: a parameter's value when the design has one, else a new body.
fn edit(s: &mut Session) {
let param = s.doc.params.first().map(|p| (p.name.clone(), p.expr.clone()));
match param {
Some((name, expr)) => {
s.execute("parameters.change", &json!({"name": name, "expression": format!("({expr}) * 1.05")})).unwrap();
}
None => {
s.execute("solid.box", &json!({"length": 7, "width": 8, "height": 9, "operation": "new"})).unwrap();
}
}
}
fn check(name: &str, mut built: Session) {
let path = temp(name);
built.execute("file.save_as", &json!({"path": path.to_string_lossy()})).unwrap();
let bytes = std::fs::read(&path).unwrap();
assert!(solidcraft_engine::io::read_design_geometry(&bytes).is_some(), "{name}: the save keeps the model");
let mut cached = open(&path);
assert_eq!(cached.model.last_recomputed, 0, "{name}: opened without evaluating");
let mut rebuilt = Session::new(solidcraft_engine::io::read_design(&bytes).unwrap());
assert!(rebuilt.model.last_recomputed > 0);
assert_eq!(model(name, &cached), model(name, &rebuilt), "{name}: opened from the file's model");
edit(&mut cached);
edit(&mut rebuilt);
assert_eq!(model(name, &cached), model(name, &rebuilt), "{name}: after the same edit");
cached.execute("edit.undo", &json!({})).unwrap();
rebuilt.execute("edit.undo", &json!({})).unwrap();
assert_eq!(model(name, &cached), model(name, &rebuilt), "{name}: after undoing it");
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(format!("{}.bak", path.display()));
}
#[test]
fn corpus_designs_open_from_their_model_as_evaluated() {
for p in corpus() {
let name = p.file_stem().unwrap_or_default().to_string_lossy().to_string();
let s = open(&p);
// Names saved before format 14 are found again through names worked out from geometry on
// demand, which a saved model does not keep.
let legacy = s.doc.to_json().contains(&format!("\"{}", solidcraft_engine::doc::naming::LEGACY_MARK));
if s.model.results.iter().any(|r| r.error.is_some()) || legacy {
// Not kept: a model with errors, or names saved before format 14.
let path = temp(&name);
let mut s = s;
s.execute("file.save_as", &json!({"path": path.to_string_lossy()})).unwrap();
assert!(solidcraft_engine::io::read_design_geometry(&std::fs::read(&path).unwrap()).is_none(), "{name}");
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(format!("{}.bak", path.display()));
continue;
}
check(&name, s);
}
}
#[test]
fn sample_designs_open_from_their_model_as_evaluated() {
for sample in solidcraft_engine::sample::samples() {
let mut s = Session::default();
for c in (sample.script)()["commands"].as_array().unwrap() {
s.execute(c["command"].as_str().unwrap(), &c["params"]).unwrap_or_else(|e| panic!("{}: {e}", sample.name));
}
check(sample.name, s);
}
}
#[test]
fn imported_assemblies_open_from_their_model_as_evaluated() {
// A STEP assembly of the hinge sample: parts placed several times.
let mut source = Session::default();
let hinge = solidcraft_engine::sample::samples().into_iter().find(|s| s.name == "Hinge Assembly").unwrap();
for c in (hinge.script)()["commands"].as_array().unwrap() {
source.execute(c["command"].as_str().unwrap(), &c["params"]).unwrap();
}
let step = std::env::temp_dir().join(format!("solidcraft-model-cache-{}-hinge.step", std::process::id()));
source.execute("file.export", &json!({"path": step.to_string_lossy()})).unwrap();
let mut s = Session::default();
s.execute("file.insert_step", &json!({"path": step.to_string_lossy()})).unwrap();
s.execute("file.insert_step", &json!({"path": step.to_string_lossy(), "matrix": [1, 0, 0, 200, 0, 1, 0, 0, 0, 0, 1, 0]})).unwrap();
let _ = std::fs::remove_file(&step);
check("imported hinge", s);
}
/// `built` saved with `saved`, a model of it that keeps only some states (as a file without room
/// for all of them does, #3220), opens as the design evaluated again: evaluating just the
/// features whose state the file left out, then after suppressing a feature at, before or after
/// each state it kept, after undoing that, and with the marker moved there and back.
fn check_thinned(name: &str, built: &Session, saved: &SavedModel) {
let design = built.design();
let bytes =
solidcraft_engine::io::write_design_container_with_cache(&design, solidcraft_engine::io::DesignCompression::Fast, None, Some(saved)).unwrap();
assert!(solidcraft_engine::io::read_design_geometry(&bytes).is_some(), "{name}: the file keeps the final state");
let path = temp(&name.replace(' ', "-"));
std::fs::write(&path, &bytes).unwrap();
let mut cached = open(&path);
let mut rebuilt = Session::new(solidcraft_engine::io::read_design(&bytes).unwrap());
let features = saved.records["features"].as_array().unwrap();
let unkept = features.iter().filter(|f| f["skipped"] == false && f.get("state").is_none()).count();
assert!(unkept > 0, "{name}: some states left out");
assert_eq!(cached.model.recomputed, unkept, "{name}: evaluated just the features whose state the file left out");
assert_eq!(model(name, &cached), model(name, &rebuilt), "{name}: opened from the file's model");
let n = cached.doc.features.len();
let mut at: Vec<usize> = features
.iter()
.enumerate()
.filter(|(_, f)| f["skipped"] == false && f.get("state").is_some())
.flat_map(|(k, _)| [k.saturating_sub(1), k, k + 1])
.filter(|i| *i < n)
.collect();
at.dedup();
for i in at {
let edit = json!({"feature": cached.doc.features[i].id, "suppressed": true});
let (a, b) = (cached.execute("timeline.suppress", &edit), rebuilt.execute("timeline.suppress", &edit));
assert_eq!(a.is_ok(), b.is_ok(), "{name}: suppressing feature {i}");
assert_eq!(model(name, &cached), model(name, &rebuilt), "{name}: feature {i} suppressed");
if a.is_ok() {
cached.execute("edit.undo", &json!({})).unwrap();
rebuilt.execute("edit.undo", &json!({})).unwrap();
assert_eq!(model(name, &cached), model(name, &rebuilt), "{name}: suppressing feature {i} undone");
}
for marker in [json!({"position": i}), json!({})] {
cached.execute("timeline.rollback", &marker).unwrap();
rebuilt.execute("timeline.rollback", &marker).unwrap();
assert_eq!(model(name, &cached), model(name, &rebuilt), "{name}: the marker at {marker}, from feature {i}");
}
}
let _ = std::fs::remove_file(&path);
}
/// A model too big for its file keeps the final state, then the slowest features' states as fit
/// (#3220). Both the smallest such model (the final state, and states that add no body to it)
/// and one that keeps more open as the design evaluated again. The placed components' design has
/// a cut whose fingerprint reads where the bodies before it lie, which a state left out does not
/// say until it is evaluated.
#[test]
fn thinned_models_open_as_evaluated() {
let corpus = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/corpus/components-arm.solidcraft");
for sample in ["Sample Plate", "Plastic Enclosure", "components-arm"] {
let built = match solidcraft_engine::sample::samples().into_iter().find(|s| s.name == sample) {
Some(s) => {
let mut built = Session::default();
for c in (s.script)()["commands"].as_array().unwrap() {
built.execute(c["command"].as_str().unwrap(), &c["params"]).unwrap();
}
built
}
None => open(&corpus),
};
let saved = built.model.saved(&built.design()).unwrap();
// Every body a megabyte (the records fit in the room left over): each budget keeps one more
// body's worth of states.
let body = 1 << 20;
let pieces: Vec<Vec<(usize, usize)>> = (0..saved.bodies.len()).map(|i| vec![(i, body)]).collect();
let thinned: Vec<SavedModel> =
(0..=saved.bodies.len()).filter_map(|n| saved.thinned(&pieces, n * body + body - 1).map(|(m, _, _)| m)).collect();
let kept = |m: &SavedModel| m.records["features"].as_array().unwrap().iter().filter(|f| f.get("state").is_some()).count();
let smallest = thinned.first().unwrap();
assert!(smallest.records["states"].as_array().unwrap().len() < saved.records["states"].as_array().unwrap().len(), "{sample}");
let more = thinned.iter().find(|m| kept(m) > kept(smallest) && kept(m) < built.doc.features.len()).unwrap();
check_thinned(&format!("{sample} smallest"), &built, smallest);
check_thinned(&format!("{sample} thinned"), &built, more);
}
}
/// Opened from its model, a design evaluates only from the first feature an edit changes: the
/// saved states before it are kept as they were read (the same states, not evaluated again).
#[test]
fn an_edit_after_opening_evaluates_from_the_feature_it_changes() {
let plate = solidcraft_engine::sample::samples().into_iter().find(|s| s.name == "Sample Plate").unwrap();
let mut built = Session::default();
for c in (plate.script)()["commands"].as_array().unwrap() {
built.execute(c["command"].as_str().unwrap(), &c["params"]).unwrap();
}
let path = temp("incremental");
built.execute("file.save_as", &json!({"path": path.to_string_lossy()})).unwrap();
let n = built.doc.features.len();
assert!(n >= 4);
let mut s = open(&path);
assert_eq!(s.model.last_recomputed, 0);
let read: Vec<Arc<ModelState>> = s.model.results.iter().map(|r| r.state.clone()).collect();
// How many of the first features still have the states read from the file.
let kept = |s: &Session| s.model.results.iter().zip(&read).take_while(|(r, st)| Arc::ptr_eq(&r.state, st)).count();
// The marker moved back and forth: the features before it are as read.
s.execute("timeline.rollback", &json!({"position": n - 2})).unwrap();
assert_eq!(kept(&s), n - 2, "rolled back");
s.execute("timeline.rollback", &json!({})).unwrap();
assert_eq!(kept(&s), n, "rolled forward: the features after the marker take their saved results again");
// Suppressing the second-to-last feature keeps every state before it.
let k = s.doc.features[n - 2].id;
s.execute("timeline.suppress", &json!({"feature": k, "suppressed": true})).unwrap();
assert_eq!(kept(&s), n - 2, "evaluated from the suppressed feature on");
s.execute("edit.undo", &json!({})).unwrap();
// A new feature at the end starts from the final state read from the file.
let mut s = open(&path);
let read: Vec<Arc<ModelState>> = s.model.results.iter().map(|r| r.state.clone()).collect();
s.execute("solid.box", &json!({"length": 3, "width": 3, "height": 3, "operation": "new"})).unwrap();
assert_eq!(s.model.results.len(), n + 1);
assert!(s.model.results.iter().zip(&read).all(|(r, st)| Arc::ptr_eq(&r.state, st)), "only the new feature evaluated");
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(format!("{}.bak", path.display()));
}
/// An edit made after opening and undone finds the saved results again: undo evaluates nothing.
#[test]
fn undoing_an_edit_after_opening_reuses_the_saved_results() {
let plate = solidcraft_engine::sample::samples().into_iter().find(|s| s.name == "Sample Plate").unwrap();
let mut built = Session::default();
for c in (plate.script)()["commands"].as_array().unwrap() {
built.execute(c["command"].as_str().unwrap(), &c["params"]).unwrap();
}
let path = temp("undo");
built.execute("file.save_as", &json!({"path": path.to_string_lossy()})).unwrap();
let mut s = open(&path);
let read: Vec<Arc<ModelState>> = s.model.results.iter().map(|r| r.state.clone()).collect();
let first = s.doc.features[1].id;
s.execute("timeline.suppress", &json!({"feature": first, "suppressed": true})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.model.last_recomputed, 0, "taken from the saved results");
assert!(s.model.results.iter().zip(&read).all(|(r, st)| Arc::ptr_eq(&r.state, st)), "the very states read from the file");
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(format!("{}.bak", path.display()));
}
+66
View File
@@ -0,0 +1,66 @@
//! The reference tests (tests/oracle, docs/oracle.md) as a `cargo test`, so tools that drive
//! `cargo test` (cargo-mutants) can use them: every case with expected values and no
//! `known_failure` mark must pass, as `cargo xtask oracle` requires.
use std::path::Path;
use std::process::Command;
use serde_json::Value;
#[test]
#[ignore = "run by cargo-mutants (.cargo/mutants.toml); CI replays the oracle through cargo xtask oracle"]
fn oracle_suite() {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let dir = root.join("tests/oracle");
let mut cases: Vec<String> = std::fs::read_dir(&dir)
.unwrap()
.flatten()
.map(|e| e.path())
.filter(|p| p.join("recipe.json").is_file())
.map(|p| format!("tests/oracle/{}", p.file_name().unwrap().to_string_lossy()))
.collect();
cases.sort();
assert!(cases.len() >= 50, "oracle cases in {}", dir.display());
// One process per core, cases dealt round-robin so the slow ones spread out.
let n = std::thread::available_parallelism().map_or(4, |n| n.get()).min(cases.len());
let children: Vec<_> = (0..n)
.map(|i| {
let chunk: Vec<&String> = cases.iter().skip(i).step_by(n).collect();
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
.current_dir(&root)
.arg("oracle")
.arg("--json")
.args(chunk)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap()
})
.collect();
let mut results: Vec<Value> = Vec::new();
for child in children {
let out = child.wait_with_output().unwrap();
let part: Vec<Value> =
serde_json::from_slice(&out.stdout).unwrap_or_else(|e| panic!("oracle JSON: {e}\n{}", String::from_utf8_lossy(&out.stderr)));
results.extend(part);
}
assert_eq!(results.len(), cases.len());
let mut failed = Vec::new();
for r in &results {
let case = r["case"].as_str().unwrap_or("");
let want: Value =
std::fs::read_to_string(dir.join(case).join("expected.json")).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default();
if want.is_null() || want["known_failure"].is_string() || r["pass"].as_bool().unwrap_or(false) {
continue;
}
let checks: Vec<String> = r["checks"]
.as_array()
.into_iter()
.flatten()
.filter(|c| !c["ok"].as_bool().unwrap_or(false))
.map(|c| format!("{} got {} want {}", c["check"].as_str().unwrap_or(""), c["got"], c["want"]))
.collect();
failed.push(format!("{case}: {} {}", r["error"].as_str().unwrap_or(""), checks.join("; ")));
}
assert!(failed.is_empty(), "oracle cases failed:\n{}", failed.join("\n"));
}
+44 -5
View File
@@ -151,11 +151,20 @@ fn snapshot_runs_its_script_on_the_in_design() {
json_output(cli().arg("snapshot").args(args).arg("--out").arg(&png).args(["--width", "64", "--height", "48"]).output().unwrap());
std::fs::read(&png).unwrap()
};
let alone = shot("alone.png", &[shifted.as_os_str()]);
let on_base = shot("on_base.png", &[shifted.as_os_str(), "--in".as_ref(), base.as_os_str()]);
let base_only = shot("base.png", &["--in".as_ref(), base.as_os_str()]);
assert_ne!(alone, on_base, "the script ran on the --in design");
assert_ne!(base_only, on_base);
// Pictures are drawn on the GPU: without an adapter only the option checks below run.
match solidcraft_gpu::capture::shared() {
Ok(_) => {
let alone = shot("alone.png", &[shifted.as_os_str()]);
let on_base = shot("on_base.png", &[shifted.as_os_str(), "--in".as_ref(), base.as_os_str()]);
let base_only = shot("base.png", &["--in".as_ref(), base.as_os_str()]);
assert_ne!(alone, on_base, "the script ran on the --in design");
assert_ne!(base_only, on_base);
}
Err(e @ solidcraft_gpu::GpuError::NoAdapter(_)) if std::env::var_os("SOLIDCRAFT_REQUIRE_GPU").is_none() => {
eprintln!("pictures left out: {e}")
}
Err(e) => panic!("{e}"),
}
for (bad, says) in [(["--frobnicate", "1"], "doesn't take --frobnicate"), (["--width", "wide"], "--width must be 16 to 8192")] {
let output = cli().arg("snapshot").arg(&base).arg("--out").arg(dir.0.join("x.png")).args(bad).output().unwrap();
assert_eq!(output.status.code(), Some(1));
@@ -170,3 +179,33 @@ fn unknown_command_suggests_the_closest_id() {
assert_eq!(output.status.code(), Some(1));
assert!(String::from_utf8_lossy(&output.stderr).contains("did you mean `doc.open`?"), "{}", String::from_utf8_lossy(&output.stderr));
}
/// #1833: `--timings` reports each command's time instead of the measurement, `--profile` adds
/// the kernel's share, `--results` puts the results beside them, and files are still written.
#[test]
fn timings_and_profile_report_each_command() {
let dir = Scratch::new();
let saved = dir.0.join("timed.solidcraft");
let t = json_output(cli().arg("run").arg(dir.script()).arg("--timings").arg("--save").arg(&saved).output().unwrap());
let rows = t["timings"].as_array().unwrap();
assert_eq!(rows.len(), 1);
assert_eq!((rows[0]["index"].as_u64(), rows[0]["command"].as_str()), (Some(0), Some("solid.box")));
assert!(rows[0]["ms"].as_f64().is_some() && t["total_ms"].as_f64().is_some(), "{t}");
assert!(rows[0].get("profile").is_none() && t.get("results").is_none(), "{t}");
assert_eq!(body_count(&saved), 1);
// --profile alone implies --timings; a flag before the script doesn't eat it.
let t = json_output(cli().arg("run").arg("--profile").arg(dir.script()).arg("--results").output().unwrap());
let p = &t["timings"][0]["profile"];
for k in ["boolean_ms", "tessellate_ms", "other_ms"] {
assert!(p[k].as_f64().is_some_and(|ms| ms >= 0.0), "{k}: {t}");
}
assert_eq!(t["results"].as_array().map(Vec::len), Some(1), "{t}");
// --results alone stays a plain array.
assert!(json_output(cli().arg("run").arg(dir.script()).arg("--results").output().unwrap()).is_array());
let output = cli().arg("run").arg("--in").arg(&saved).arg("--timings").output().unwrap();
assert!(!output.status.success());
assert!(String::from_utf8_lossy(&output.stderr).contains("--timings needs a script"));
}
@@ -84,3 +84,42 @@ fn corpus_designs_replay_from_their_scripts() {
}
assert!(replayed >= 7, "replayed {replayed} corpus designs");
}
/// #1990: a sketch and its extrude may share a name, and the design's script still replays,
/// keeping the names: the replayed design's own script has the same steps.
#[test]
fn a_design_naming_a_sketch_and_its_extrude_alike_replays_from_its_script() {
let dir = std::env::temp_dir().join(format!("solidcraft-shared-names-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let cli = |args: &[&std::ffi::OsStr]| {
let out = std::process::Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).args(args).output().unwrap();
assert!(
out.status.success(),
"{}: {}",
args.iter().map(|a| a.to_string_lossy()).collect::<Vec<_>>().join(" "),
String::from_utf8_lossy(&out.stderr)
);
};
let script = dir.join("mini.json");
let saved = dir.join("mini.solidcraft");
let exported = dir.join("rt.json");
let replayed = dir.join("rt.solidcraft");
let again = dir.join("rt2.json");
let commands = json!({"commands": [
{"command": "sketch.create", "params": {"plane": "XY", "name": "Block"}},
{"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [20, 10]}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"distance": "5", "name": "Block"}},
]});
std::fs::write(&script, commands.to_string()).unwrap();
cli(&["run".as_ref(), script.as_ref(), "--save".as_ref(), saved.as_ref()]);
cli(&["script".as_ref(), saved.as_ref(), "--out".as_ref(), exported.as_ref()]);
cli(&["run".as_ref(), exported.as_ref(), "--save".as_ref(), replayed.as_ref()]);
cli(&["script".as_ref(), replayed.as_ref(), "--out".as_ref(), again.as_ref()]);
let first: Value = serde_json::from_str(&std::fs::read_to_string(&exported).unwrap()).unwrap();
let second: Value = serde_json::from_str(&std::fs::read_to_string(&again).unwrap()).unwrap();
let _ = std::fs::remove_dir_all(&dir);
let names: Vec<&str> = first["commands"].as_array().unwrap().iter().filter_map(|c| c["params"]["feature"]["name"].as_str()).collect();
assert_eq!(names, ["Block", "Block"], "{first:#}");
assert_eq!(second["commands"], first["commands"], "the replayed design's script has the same steps");
}
@@ -0,0 +1,221 @@
//! `snapshot --body`, `--tool` and `--section`: a picture of a tool body inside the material it
//! meets, which is the one picture that says whether a cut reaches through.
//!
//! Each option is a shortcut for the command the app itself runs (`appearance.assign`,
//! `inspect.section`), so every test here asserts that the option's picture is the picture of a
//! script that runs those commands — not that the pixels match a stored image. Pictures are
//! compared with a tolerance, and the tests run on whatever renderer the machine has (a GPU, a
//! software adapter, or the CPU rasterizer), because they only ever compare two pictures this
//! same CLI drew.
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::atomic::{AtomicUsize, Ordering};
use serde_json::{Value, json};
static NEXT: AtomicUsize = AtomicUsize::new(0);
const SIZE: (u32, u32) = (200, 140);
/// A PNG's size and RGBA pixels.
fn decode(png: &[u8]) -> (u32, u32, Vec<u8>) {
let img = image::load_from_memory_with_format(png, image::ImageFormat::Png).unwrap().to_rgba8();
(img.width(), img.height(), img.into_raw())
}
/// Pixels of two same-sized pictures that differ by more than a little in some channel.
fn differing(a: &[u8], b: &[u8]) -> usize {
let ((wa, ha, a), (wb, hb, b)) = (decode(a), decode(b));
assert_eq!((wa, ha), (wb, hb), "pictures of different sizes");
a.as_chunks::<4>().0.iter().zip(b.as_chunks::<4>().0).filter(|(p, q)| p.iter().zip(q.iter()).any(|(x, y)| x.abs_diff(*y) > 16)).count()
}
/// The same picture: a renderer's output may differ by a few levels or a stray edge pixel.
#[track_caller]
fn assert_same(a: &[u8], b: &[u8], what: &str) {
let (n, total) = (differing(a, b), (SIZE.0 * SIZE.1) as usize);
assert!(n * 500 <= total, "{what}: {n} of {total} pixels differ");
}
/// Different pictures: more than stray pixels differ.
#[track_caller]
fn assert_differs(a: &[u8], b: &[u8], what: &str) {
let n = differing(a, b);
assert!(n >= 100, "{what}: only {n} pixels differ");
}
/// How much of a picture is the tool colour (`preview::TOOL_COLOR`, #e8542c): a red neither
/// the default body grey nor the background has, still recognisable once a see-through body in
/// front of it has tinted it (measured on this machine: (200, 108, 85) through a target at
/// opacity 0.3, against (164, 173, 184) for the plain grey).
fn tool_pixels(png: &[u8]) -> usize {
let (_, _, px) = decode(png);
px.as_chunks::<4>().0.iter().filter(|p| p[0] > 140 && p[0] > p[1].saturating_add(50) && p[0] > p[2].saturating_add(50)).count()
}
struct Scratch(PathBuf);
impl Scratch {
fn new() -> Self {
let dir =
std::env::temp_dir().join(format!("solidcraft-snapshot-appearance-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
std::fs::create_dir(&dir).unwrap();
Self(dir)
}
/// A plate with a pin standing in it: the tool body a cut would remove, reaching 6 mm short
/// of the far face, which is exactly what a picture is supposed to show and a volume is not.
/// `more` runs on top.
fn script(&self, name: &str, more: &[Value]) -> PathBuf {
let mut steps = vec![
json!({"command": "solid.box", "params": {"length": 50, "width": 34, "height": 20, "center": [0, 0, 10], "body_name": "Target"}}),
json!({"command": "solid.cylinder", "params": {"diameter": 12, "height": 14, "base": [0, 0, 0], "operation": "new_body", "body_name": "Tool"}}),
];
steps.extend(more.iter().cloned());
let path = self.0.join(name);
std::fs::write(&path, json!({"commands": steps}).to_string()).unwrap();
path
}
fn run(&self, script: &Path, out: &str, extra: &[&str]) -> Output {
let (w, h) = (SIZE.0.to_string(), SIZE.1.to_string());
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
.arg("snapshot")
.arg(script)
.arg("--out")
.arg(self.0.join(out))
.args(["--width", &w, "--height", &h, "--no-grid", "--no-axes"])
.args(extra)
.output()
.unwrap()
}
/// The PNG `snapshot` writes.
fn snapshot(&self, script: &Path, out: &str, extra: &[&str]) -> Vec<u8> {
let output = self.run(script, out, extra);
assert!(output.status.success(), "{extra:?}: {}", String::from_utf8_lossy(&output.stderr));
let png = std::fs::read(self.0.join(out)).unwrap();
assert!(png.starts_with(b"\x89PNG"), "{extra:?}");
png
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// `--body NAME:opacity=…` draws that body see-through, and the picture is the one a script
/// gets by assigning the same appearance itself: the option is the command, not a second way
/// of drawing.
#[test]
fn a_body_drawn_see_through_is_the_picture_of_the_same_appearance_assigned() {
let dir = Scratch::new();
let plain = dir.script("plain.json", &[]);
let assigned =
dir.script("assigned.json", &[json!({"command": "appearance.assign", "params": {"bodies": ["Target"], "color": "#b0bac6", "opacity": 0.3}})]);
let solid = dir.snapshot(&plain, "solid.png", &[]);
let see_through = dir.snapshot(&plain, "see-through.png", &["--body", "Target:opacity=0.3"]);
assert_differs(&see_through, &solid, "--body opacity was ignored");
assert_same(&see_through, &dir.snapshot(&assigned, "by-script.png", &[]), "the same appearance assigned by a script");
// The option's colour is the design's too. It names the target, not the tool: the tool is
// inside the target, so a solid target hides whatever colour the tool is given.
let painted =
dir.script("painted.json", &[json!({"command": "appearance.assign", "params": {"bodies": ["Target"], "color": "#00aa33", "opacity": 1.0}})]);
let by_option = dir.snapshot(&plain, "painted.png", &["--body", "Target:color=#00aa33"]);
assert_differs(&by_option, &solid, "--body color was ignored");
assert_same(&by_option, &dir.snapshot(&painted, "painted-script.png", &[]), "the same colour assigned by a script");
}
/// `--tool NAME` is the whole point: the tool solid in the tool colour, every other body
/// see-through, so the tool shows inside the material it meets. The tool's colour covers more
/// of the picture than it does without the option, because the target no longer hides it.
#[test]
fn a_tool_shows_through_the_bodies_around_it() {
let dir = Scratch::new();
let script = dir.script("part.json", &[]);
let plain = dir.snapshot(&script, "plain.png", &["--view", "front"]);
let preview = dir.snapshot(&script, "preview.png", &["--view", "front", "--tool", "Tool"]);
assert_differs(&preview, &plain, "--tool was ignored");
// Seen from the front the plate hides the pin completely; the preview shows all of it.
assert_eq!(tool_pixels(&plain), 0, "nothing is the tool colour before the option");
assert!(tool_pixels(&preview) > 200, "the tool shows through the target: {} pixels", tool_pixels(&preview));
// It is the two commands it stands for, run in that order.
let by_script = dir.script(
"by-script.json",
&[
json!({"command": "appearance.assign", "params": {"bodies": ["Target"], "color": "#b0bac6", "opacity": 0.3}}),
json!({"command": "appearance.assign", "params": {"bodies": ["Tool"], "color": "#e8542c", "opacity": 1.0}}),
],
);
assert_same(&preview, &dir.snapshot(&by_script, "script.png", &["--view", "front"]), "the appearances --tool assigns");
// A later --body overrides what --tool said.
let overridden = dir.snapshot(&script, "override.png", &["--view", "front", "--tool", "Tool", "--body", "Tool:color=#2255cc"]);
assert!(tool_pixels(&overridden) < 50, "--body after --tool decides the tool's colour");
}
/// `--section` cuts the model where `inspect.section` would, and the same spec written as the
/// command's own JSON, or as the command in a script, gives the same picture.
#[test]
fn a_section_cuts_the_model_as_inspect_section_does() {
let dir = Scratch::new();
let script = dir.script("part.json", &[]);
let whole = dir.snapshot(&script, "whole.png", &[]);
let cut = dir.snapshot(&script, "cut.png", &["--section", "z=12"]);
assert_differs(&cut, &whole, "--section was ignored");
assert_same(&cut, &dir.snapshot(&script, "cut-json.png", &["--section", r#"{"plane": "XY", "at": 12}"#]), "the command's own JSON");
let by_script = dir.script("sectioned.json", &[json!({"command": "inspect.section", "params": {"plane": "XY", "at": 12}})]);
assert_same(&cut, &dir.snapshot(&by_script, "by-script.png", &[]), "inspect.section in a script");
// The other side of the same plane is a different picture.
let flipped = dir.snapshot(&script, "flipped.png", &["--section", "z=12,flip"]);
assert_differs(&flipped, &cut, "flip keeps the other side");
// A section through a see-through target still shows the tool inside it.
let both = dir.snapshot(&script, "both.png", &["--section", "z=12", "--tool", "Tool"]);
assert_differs(&both, &cut, "--tool and --section together");
assert!(tool_pixels(&both) > 100, "the tool is in the cut picture");
}
/// Every way of getting the options wrong is an error that says what it takes, and writes no
/// picture: a snapshot that quietly ignored an option would be a lie about the design.
#[test]
fn a_body_tool_or_section_that_cannot_be_used_is_an_error_and_writes_nothing() {
let dir = Scratch::new();
let script = dir.script("part.json", &[]);
let cases: [(&[&str], &str); 8] = [
(&["--body", "Nowhere:opacity=0.3"], "no body `Nowhere`"),
(&["--body", "Target"], "NAME:key=value"),
(&["--body", "Target:opacity=3"], "0 to 1"),
(&["--body", "Target:alpha=0.3"], "unknown key `alpha`"),
(&["--body", "Target:color=red"], "#rrggbb"),
(&["--tool", "Nowhere"], "no body `Nowhere`"),
(&["--section", "q=1"], "unknown axis `q`"),
(&["--section", "nonsense"], "z=27"),
];
for (extra, expect) in cases {
let output = dir.run(&script, "never.png", extra);
let stderr = String::from_utf8_lossy(&output.stderr);
assert_eq!(output.status.code(), Some(1), "{extra:?}: {stderr}");
assert!(stderr.contains(expect), "{extra:?}: {stderr}");
assert!(output.stdout.is_empty() && !dir.0.join("never.png").exists(), "{extra:?}");
}
// An error naming a body says which bodies the design has.
let stderr = String::from_utf8_lossy(&dir.run(&script, "never.png", &["--tool", "Nowhere"]).stderr).to_string();
assert!(stderr.contains("Target") && stderr.contains("Tool"), "{stderr}");
}
/// The usage text says what the options do.
#[test]
fn the_usage_names_the_body_tool_and_section_options() {
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).arg("snapshot").output().unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
for word in ["--body", "--tool", "--section", "appearance.assign", "inspect.section", "see-through"] {
assert!(stderr.contains(word), "usage lacks {word}: {stderr}");
}
}
+217 -22
View File
@@ -1,6 +1,8 @@
//! `snapshot` frames its picture like the MCP `screenshot`: from a camera, a view saved in the
//! design or one the script applies itself, and without the ground grid and the origin's axes
//! when asked (the grid lies on Z 0 and cuts through a part that straddles it).
//! when asked (the grid lies on Z 0 and cuts through a part that straddles it). Pictures are drawn
//! on the GPU and compared with a tolerance; without a GPU adapter the tests that draw are skipped
//! with the reason printed (`SOLIDCRAFT_REQUIRE_GPU=1` makes that a failure).
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
@@ -12,7 +14,52 @@ use solidcraft_mcp::{Headless, Server};
static NEXT: AtomicUsize = AtomicUsize::new(0);
const SIZE: (usize, usize) = (160, 100);
/// Whether pictures can be drawn here; installs the renderer the CLI installs, so the MCP
/// `screenshot` in this process draws as `solidcraft-cli mcp` does.
fn gpu() -> bool {
match solidcraft_gpu::capture::shared() {
Ok(c) => {
solidcraft_engine::capture::install(std::sync::Arc::new(move |scene, cam, w, h| {
c.rgba(scene, cam, w, h).map(|rgba| solidcraft_engine::capture::Picture { rgba, renderer: c.renderer() }).map_err(|e| e.to_string())
}));
true
}
Err(e @ solidcraft_gpu::GpuError::NoAdapter(_)) if std::env::var_os("SOLIDCRAFT_REQUIRE_GPU").is_none() => {
eprintln!("skipping: {e}");
false
}
Err(e) => panic!("{e}"),
}
}
/// A PNG's size and RGBA pixels.
fn decode(png: &[u8]) -> (u32, u32, Vec<u8>) {
let img = image::load_from_memory_with_format(png, image::ImageFormat::Png).unwrap().to_rgba8();
(img.width(), img.height(), img.into_raw())
}
/// Pixels of two same-sized pictures that differ by more than a little in some channel.
fn differing(a: &[u8], b: &[u8]) -> usize {
let ((wa, ha, a), (wb, hb, b)) = (decode(a), decode(b));
assert_eq!((wa, ha), (wb, hb), "pictures of different sizes");
a.as_chunks::<4>().0.iter().zip(b.as_chunks::<4>().0).filter(|(p, q)| p.iter().zip(q.iter()).any(|(x, y)| x.abs_diff(*y) > 16)).count()
}
/// The same picture: GPU output may differ by a few levels or a stray edge pixel between drivers.
#[track_caller]
fn assert_same(a: &[u8], b: &[u8], what: &str) {
let (n, total) = (differing(a, b), (SIZE.0 * SIZE.1) as usize);
assert!(n * 500 <= total, "{what}: {n} of {total} pixels differ");
}
/// Different pictures: more than stray pixels differ.
#[track_caller]
fn assert_differs(a: &[u8], b: &[u8], what: &str) {
let n = differing(a, b);
assert!(n >= 20, "{what}: only {n} pixels differ");
}
const SIZE: (u32, u32) = (160, 100);
fn camera() -> Value {
json!({"target": [20, 0, 0], "yaw": 0.6, "pitch": 0.25, "distance": 100})
@@ -88,32 +135,38 @@ impl Drop for Scratch {
/// not the home view the option used to be ignored for.
#[test]
fn a_camera_frames_the_picture_as_the_mcp_screenshot_does() {
if !gpu() {
return;
}
let dir = Scratch::new();
let script = dir.script("part.json", &[]);
let home = dir.snapshot(&script, "home.png", &[]);
let json = camera().to_string();
let shot = dir.snapshot(&script, "camera.png", &["--camera", &json]);
assert_ne!(shot, home, "--camera was ignored");
assert_eq!(shot, dir.mcp(&script, "mcp-camera.png", json!({"camera": camera()})));
assert_differs(&shot, &home, "--camera was ignored");
assert_same(&shot, &dir.mcp(&script, "mcp-camera.png", json!({"camera": camera()})), "the MCP screenshot");
// A perspective camera too, and a different eye gives a different picture.
let mut near = camera();
near["fov"] = json!(0.6);
let perspective = dir.snapshot(&script, "perspective.png", &["--camera", &near.to_string()]);
assert_ne!(perspective, shot);
assert_eq!(perspective, dir.mcp(&script, "mcp-perspective.png", json!({"camera": near})));
assert_differs(&perspective, &shot, "perspective");
assert_same(&perspective, &dir.mcp(&script, "mcp-perspective.png", json!({"camera": near})), "the MCP perspective screenshot");
}
/// `--view` also takes a view saved in the design, which is the camera it was saved with; a
/// standard view still looks as it did.
#[test]
fn a_saved_view_is_its_camera_and_a_standard_view_is_unchanged() {
if !gpu() {
return;
}
let dir = Scratch::new();
let script = dir.script("part.json", &[]);
let json = camera().to_string();
let by_camera = dir.snapshot(&script, "camera.png", &["--camera", &json]);
let saved = dir.snapshot(&script, "saved.png", &["--view", "Detail"]);
assert_eq!(saved, by_camera);
assert_eq!(saved, dir.mcp(&script, "mcp-saved.png", json!({"view": "Detail"})));
assert_same(&saved, &by_camera, "the saved view");
assert_same(&saved, &dir.mcp(&script, "mcp-saved.png", json!({"view": "Detail"})), "the MCP saved view");
// A standard view frames the model from its direction, as before: the home camera turned.
let top = dir.snapshot(&script, "top.png", &["--view", "top"]);
let mut s = Session::default();
@@ -121,32 +174,38 @@ fn a_saved_view_is_its_camera_and_a_standard_view_is_unchanged() {
let mut cam = solidcraft_engine::view::home_camera(&s);
cam.set_view(solidcraft_engine::render::StandardView::Top);
let scene = solidcraft_engine::view::scene(&s, &cam);
assert_eq!(top, solidcraft_engine::render::render_png(&scene, &cam, SIZE.0, SIZE.1).unwrap());
assert_eq!(top, dir.mcp(&script, "mcp-top.png", json!({"view": "top"})));
assert_ne!(top, saved);
assert_same(&top, &solidcraft_engine::capture::png(&scene, &cam, SIZE.0, SIZE.1).unwrap().bytes, "the home camera turned to the top");
assert_same(&top, &dir.mcp(&script, "mcp-top.png", json!({"view": "top"})), "the MCP top view");
assert_differs(&top, &saved, "top and the saved view");
let iso = dir.snapshot(&script, "iso.png", &["--view", "iso"]);
assert_eq!(iso, dir.snapshot(&script, "home.png", &[]));
assert_ne!(iso, top);
assert_same(&iso, &dir.snapshot(&script, "home.png", &[]), "iso is home");
assert_differs(&iso, &top, "iso and top");
}
/// A script that applies a view itself (as an MCP `execute` of `view.apply` does) sets the camera
/// of its snapshot; `--view` and `--camera` still decide when given.
#[test]
fn a_script_can_carry_its_camera() {
if !gpu() {
return;
}
let dir = Scratch::new();
let script = dir.script("carried.json", &[json!({"command": "view.apply", "params": {"camera": camera()}})]);
let plain = dir.script("plain.json", &[]);
let carried = dir.snapshot(&script, "carried.png", &[]);
assert_eq!(carried, dir.snapshot(&plain, "by-camera.png", &["--camera", &camera().to_string()]));
assert_ne!(carried, dir.snapshot(&plain, "home.png", &[]));
assert_same(&carried, &dir.snapshot(&plain, "by-camera.png", &["--camera", &camera().to_string()]), "the carried camera");
assert_differs(&carried, &dir.snapshot(&plain, "home.png", &[]), "the carried camera and home");
let top = dir.snapshot(&script, "top.png", &["--view", "top"]);
assert_eq!(top, dir.snapshot(&plain, "plain-top.png", &["--view", "top"]));
assert_same(&top, &dir.snapshot(&plain, "plain-top.png", &["--view", "top"]), "--view decides");
}
/// The grid and the axes each go when asked, and a render without both from any side shows the
/// model alone: an empty design is then the same blank picture from every camera.
#[test]
fn the_grid_and_the_axes_can_be_left_out() {
if !gpu() {
return;
}
let dir = Scratch::new();
let script = dir.script("part.json", &[]);
let json = camera().to_string();
@@ -161,7 +220,7 @@ fn the_grid_and_the_axes_can_be_left_out() {
let different = [&all, &no_grid, &no_axes, &bare];
for (i, a) in different.iter().enumerate() {
for b in &different[i + 1..] {
assert_ne!(a, b, "{view:?}: two pictures are the same");
assert_differs(a, b, &format!("{view:?}: two of the pictures"));
}
}
// The same as the MCP's `grid` and `axes`.
@@ -173,15 +232,151 @@ fn the_grid_and_the_axes_can_be_left_out() {
a.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
dir.mcp(&script, out, a)
};
assert_eq!(no_grid, mcp(json!({"grid": false}), "mcp-no-grid.png"));
assert_eq!(no_axes, mcp(json!({"axes": false}), "mcp-no-axes.png"));
assert_eq!(bare, mcp(json!({"grid": false, "axes": false}), "mcp-bare.png"));
assert_same(&no_grid, &mcp(json!({"grid": false}), "mcp-no-grid.png"), "the MCP's grid: false");
assert_same(&no_axes, &mcp(json!({"axes": false}), "mcp-no-axes.png"), "the MCP's axes: false");
assert_same(&bare, &mcp(json!({"grid": false, "axes": false}), "mcp-bare.png"), "the MCP's bare picture");
}
let empty = dir.0.join("empty.json");
std::fs::write(&empty, json!({"commands": []}).to_string()).unwrap();
let blank = |view: &str, out: &str| dir.snapshot(&empty, out, &["--view", view, "--no-grid", "--no-axes"]);
assert_eq!(blank("top", "blank-top.png"), blank("front", "blank-front.png"));
assert_ne!(blank("top", "blank-top.png"), dir.snapshot(&empty, "top.png", &["--view", "top"]));
assert_same(&blank("top", "blank-top.png"), &blank("front", "blank-front.png"), "blank from every side");
assert_differs(&blank("top", "blank-top.png"), &dir.snapshot(&empty, "top.png", &["--view", "top"]), "blank and the grid");
}
/// A snapshot of a box shows its silhouette where the camera puts it: the pixels that differ
/// from an empty design's picture (same camera, no grid or axes) span the box's corners projected
/// to pixels, within the edge and silhouette lines' width.
#[test]
fn a_snapshot_shows_the_part_where_the_camera_puts_it() {
if !gpu() {
return;
}
let dir = Scratch::new();
let part = dir.0.join("box.json");
let empty = dir.0.join("empty.json");
let corner = [10.0, -20.0, -15.0];
let size = [60.0, 40.0, 30.0];
std::fs::write(
&part,
json!({"commands": [{"command": "solid.box", "params": {"length": size[0], "width": size[1], "height": size[2], "corner": corner}}]})
.to_string(),
)
.unwrap();
std::fs::write(&empty, json!({"commands": []}).to_string()).unwrap();
for cam in [
camera(),
json!({"target": [40, 0, 0], "yaw": 0.0, "pitch": 0.0, "distance": 160}),
json!({"target": [40, 0, 0], "yaw": -0.4, "pitch": 0.5, "distance": 150, "fov": 0.6}),
] {
let args = ["--camera", &cam.to_string(), "--no-grid", "--no-axes"];
let (_, _, with) = decode(&dir.snapshot(&part, "part.png", &args));
let (_, _, without) = decode(&dir.snapshot(&empty, "empty.png", &args));
// The part's pixels: where the picture differs from the empty one.
let (mut lo, mut hi) = ([u32::MAX; 2], [0u32; 2]);
for (i, (p, q)) in with.as_chunks::<4>().0.iter().zip(without.as_chunks::<4>().0).enumerate() {
if p.iter().zip(q.iter()).any(|(x, y)| x.abs_diff(*y) > 24) {
let (x, y) = (i as u32 % SIZE.0, i as u32 / SIZE.0);
lo = [lo[0].min(x), lo[1].min(y)];
hi = [hi[0].max(x), hi[1].max(y)];
}
}
assert!(lo[0] <= hi[0], "{cam}: the part is not in the picture");
// Where the camera puts the corners.
let mut s = solidcraft_engine::Session::default();
s.execute("view.apply", &json!({"camera": cam})).unwrap();
let c = s.camera.unwrap();
let vp = c.view_proj(f64::from(SIZE.0) / f64::from(SIZE.1), 1000.0);
let (mut elo, mut ehi) = ([f64::MAX; 2], [f64::MIN; 2]);
for k in 0..8 {
let p = solidcraft_engine::geom::Vec3::new(
corner[0] + size[0] * f64::from(k & 1),
corner[1] + size[1] * f64::from((k >> 1) & 1),
corner[2] + size[2] * f64::from((k >> 2) & 1),
);
let q = vp.apply(p);
let (x, y) = ((q[0] / q[3] + 1.0) * 0.5 * f64::from(SIZE.0), (1.0 - q[1] / q[3]) * 0.5 * f64::from(SIZE.1));
elo = [elo[0].min(x), elo[1].min(y)];
ehi = [ehi[0].max(x), ehi[1].max(y)];
}
let near = |got: u32, want: f64| (f64::from(got) - want).abs() <= 2.5;
assert!(
near(lo[0], elo[0]) && near(lo[1], elo[1]) && near(hi[0] + 1, ehi[0]) && near(hi[1] + 1, ehi[1]),
"{cam}: the part spans pixels {lo:?}..={hi:?}, its corners project to {elo:?}..{ehi:?}"
);
}
}
/// `snapshot` under a `SOLIDCRAFT_GPU` policy: its reply and stderr.
fn snapshot_under(dir: &Scratch, script: &Path, out: &str, policy: &str) -> Output {
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
.env("SOLIDCRAFT_GPU", policy)
.arg("snapshot")
.arg(script)
.arg("--out")
.arg(dir.0.join(out))
.args(["--width", &SIZE.0.to_string(), "--height", &SIZE.1.to_string(), "--view", "top"])
.output()
.unwrap()
}
/// The last tier: `SOLIDCRAFT_GPU=cpu` draws with the CPU rasterizer whatever adapters there are,
/// says so (`renderer: cpu` and a warning that overlays are not drawn), and the picture is the
/// rasterizer's own.
#[test]
fn a_forced_cpu_snapshot_is_the_cpu_rasterizers_and_says_so() {
let dir = Scratch::new();
let script = dir.script("part.json", &[]);
let output = snapshot_under(&dir, &script, "cpu.png", "cpu");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(output.status.success(), "{stderr}");
let reply: Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(reply["renderer"], "cpu", "{reply}");
assert!(reply["warning"].as_str().is_some_and(|w| w.contains("not drawn")), "{reply}");
assert!(stderr.contains("CPU rasterizer"), "{stderr}");
let mut s = Session::default();
s.run_script(&serde_json::from_slice(&std::fs::read(&script).unwrap()).unwrap()).unwrap();
let mut cam = solidcraft_engine::view::home_camera(&s);
cam.set_view(solidcraft_engine::render::StandardView::Top);
let scene = solidcraft_engine::view::scene(&s, &cam);
let cpu = solidcraft_engine::render::render_png(&scene, &cam, SIZE.0 as usize, SIZE.1 as usize).unwrap();
assert_eq!(std::fs::read(dir.0.join("cpu.png")).unwrap(), cpu);
}
/// `hardware` (and `software`) never fall to the CPU rasterizer: without such an adapter the
/// snapshot fails, naming the policy, and writes nothing; with one it says it was drawn on the GPU.
/// `auto` lands where `solidcraft-cli gpu` says it does.
#[test]
fn a_strict_policy_never_falls_to_the_cpu() {
let dir = Scratch::new();
let script = dir.script("part.json", &[]);
let survey = |policy: &str| {
let o = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).args(["gpu", "--policy", policy]).output().unwrap();
(o.status.success(), serde_json::from_slice::<Value>(&o.stdout).unwrap_or(Value::Null))
};
for policy in ["hardware", "software"] {
let (found, report) = survey(policy);
let output = snapshot_under(&dir, &script, &format!("{policy}.png"), policy);
let stderr = String::from_utf8_lossy(&output.stderr);
if found {
assert!(output.status.success(), "{policy}: {stderr}");
let reply: Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(reply["renderer"], report["renderer"], "{policy}: {reply}");
assert_ne!(reply["renderer"], "cpu");
} else {
assert_eq!(report["lands_on"], Value::Null, "{policy}: {report}");
assert_eq!(output.status.code(), Some(1), "{policy}: {stderr}");
assert!(stderr.contains(&format!("`{policy}` policy")) && stderr.contains("solidcraft-cli gpu"), "{policy}: {stderr}");
assert!(!dir.0.join(format!("{policy}.png")).exists());
}
}
let (found, report) = survey("auto");
assert!(found, "auto always lands somewhere: {report}");
let output = snapshot_under(&dir, &script, "auto.png", "auto");
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
let reply: Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(reply["renderer"], report["renderer"], "{reply} vs {report}");
let (_, cpu) = survey("cpu");
assert_eq!((cpu["lands_on"].clone(), cpu["renderer"].clone()), (json!("cpu"), json!("cpu")), "{cpu}");
}
#[test]
+2 -1
View File
@@ -29,5 +29,6 @@ web-sys = { version = "0.3", features = [
"Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "Navigator", "StorageManager",
"FileSystemHandle", "FileSystemDirectoryHandle", "FileSystemFileHandle", "FileSystemGetFileOptions", "FileSystemWritableFileStream",
"WritableStream", "File", "FileList", "Blob", "BlobPropertyBag", "Url", "HtmlAnchorElement", "HtmlInputElement", "Storage",
"BeforeUnloadEvent", "Event", "EventTarget", "Response", "MediaQueryList",
"IdbFactory", "IdbDatabase", "IdbObjectStore", "IdbOpenDbRequest", "IdbRequest", "IdbTransaction", "IdbTransactionMode",
"BeforeUnloadEvent", "Event", "EventTarget", "KeyboardEvent", "Response", "MediaQueryList", "Gamepad", "Performance",
] }
+5 -4
View File
@@ -9,20 +9,21 @@
<meta property="og:site_name" content="SolidCraft">
<meta property="og:title" content="SolidCraft: try it in your browser">
<meta property="og:description" content="Open-source parametric CAD built in Rust. Desktop, browser, and MCP for AI agents.">
<meta property="og:url" content="https://bherbruck.github.io/solidcraft/app/">
<meta property="og:image" content="https://bherbruck.github.io/solidcraft/og.png">
<meta property="og:url" content="https://solidcraft.ai/">
<meta property="og:image" content="https://docs.solidcraft.io/og.png">
<meta property="og:image:width" content="1280">
<meta property="og:image:height" content="640">
<meta property="og:image:alt" content="SolidCraft: open-source parametric CAD built in Rust">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="SolidCraft: try it in your browser">
<meta name="twitter:description" content="Open-source parametric CAD built in Rust. Desktop, browser, and MCP for AI agents.">
<meta name="twitter:image" content="https://bherbruck.github.io/solidcraft/og.png">
<meta name="twitter:image" content="https://docs.solidcraft.io/og.png">
<meta name="twitter:image:alt" content="SolidCraft: open-source parametric CAD built in Rust">
<link data-trunk rel="rust" data-bin="solidcraft-web" data-wasm-opt="z" data-weak-refs />
<link data-trunk rel="copy-file" href="../../assets/fonts/biz-udp-gothic/BIZUDPGothic-Regular.ttf" data-target-path="fonts" />
<link data-trunk rel="copy-file" href="../../assets/fonts/biz-udp-gothic/OFL.txt" data-target-path="fonts" />
<link data-trunk rel="css" href="../../assets/styles/web/loading.css" />
<link data-trunk rel="copy-file" href="../../assets/styles/web/loading-logo.svg" />
<!-- Installable as an app (manifest.webmanifest); the icons are the Linux exports of the app mark. -->
<meta name="theme-color" content="#343840">
<link rel="manifest" href="manifest.webmanifest">
@@ -35,7 +36,7 @@
</head>
<body>
<canvas id="solidcraft_canvas"></canvas>
<div id="solidcraft_loading">Loading SolidCraft&hellip;</div>
<div id="solidcraft_loading" role="status"><img src="loading-logo.svg" alt=""><span class="sr-only">Loading SolidCraft&hellip;</span></div>
<script>
// Offline use and updates (sw.js; written into release builds by `cargo xtask web-offline`).
if ("serviceWorker" in navigator) {
+131
View File
@@ -0,0 +1,131 @@
//! Promise-based access to the running app's existing control channel.
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::mpsc::{Receiver, Sender, TryRecvError};
use std::time::Duration;
use serde_json::{Value, json};
use solidcraft_ui_egui::ControlRequest;
use wasm_bindgen::{JsValue, closure::Closure};
const MAX_PENDING: usize = 256;
const MAX_JSON_BYTES: usize = 16 * 1024 * 1024;
struct Pending {
reply: Receiver<Value>,
resolve: js_sys::Function,
reject: js_sys::Function,
}
#[derive(Clone)]
pub struct Api {
sender: Sender<ControlRequest>,
pending: Rc<RefCell<Vec<Pending>>>,
ctx: egui::Context,
}
fn params(value: &JsValue) -> Result<Value, String> {
if value.is_undefined() || value.is_null() {
return Ok(json!({}));
}
let text = js_sys::JSON::stringify(value).ok().and_then(|v| v.as_string()).ok_or("params must be JSON serializable")?;
if text.len() > MAX_JSON_BYTES {
return Err("params exceed 16 MiB".into());
}
serde_json::from_str(&text).map_err(|e| format!("invalid params: {e}"))
}
fn error(message: &str) -> JsValue {
js_sys::Error::new(message).into()
}
impl Api {
pub fn install(sender: Sender<ControlRequest>, ctx: egui::Context) -> Result<Self, JsValue> {
let api = Self { sender, pending: Rc::default(), ctx };
let object = js_sys::Object::new();
let request = api.clone();
let execute = api.clone();
let commands = api.clone();
let inspect = api.clone();
for (name, function) in [
(
"request",
Closure::<dyn Fn(String, JsValue) -> js_sys::Promise>::new(move |method: String, value: JsValue| {
request.request(&method, params(&value))
})
.into_js_value(),
),
(
"execute",
Closure::<dyn Fn(String, JsValue) -> js_sys::Promise>::new(move |command: String, value: JsValue| {
execute.request("engine.execute", params(&value).map(|p| json!({"command": command, "params": p})))
})
.into_js_value(),
),
("commands", Closure::<dyn Fn() -> js_sys::Promise>::new(move || commands.request("engine.commands", Ok(json!({})))).into_js_value()),
(
"inspect",
Closure::<dyn Fn(JsValue) -> js_sys::Promise>::new(move |value: JsValue| inspect.request("document.inspect", params(&value)))
.into_js_value(),
),
] {
js_sys::Reflect::set(&object, &name.into(), &function)?;
}
let window = web_sys::window().ok_or_else(|| error("no window"))?;
js_sys::Reflect::set(&window, &"solidcraft".into(), &object)?;
Ok(api)
}
fn request(&self, method: &str, params: Result<Value, String>) -> js_sys::Promise {
let params = match params {
Ok(params) => params,
Err(message) => return js_sys::Promise::reject(&error(&message)),
};
if self.pending.borrow().len() >= MAX_PENDING {
return js_sys::Promise::reject(&error("too many pending SolidCraft requests (maximum 256)"));
}
js_sys::Promise::new(&mut |resolve, reject| {
let (request, reply) = ControlRequest::new(method, params.clone());
if self.sender.send(request).is_err() {
let _ = reject.call1(&JsValue::UNDEFINED, &error("SolidCraft is not running"));
return;
}
self.pending.borrow_mut().push(Pending { reply, resolve, reject });
self.ctx.request_repaint();
})
}
pub fn poll(&self) {
let mut completed = Vec::new();
self.pending.borrow_mut().retain_mut(|pending| {
let result = match pending.reply.try_recv() {
Ok(reply) => {
if reply.get("ok").and_then(Value::as_bool) == Some(true) {
js_sys::JSON::parse(&reply.get("result").unwrap_or(&Value::Null).to_string())
} else {
Err(error(reply.get("error").and_then(Value::as_str).unwrap_or("SolidCraft request failed")))
}
}
Err(TryRecvError::Empty) => return true,
Err(TryRecvError::Disconnected) => Err(error("SolidCraft request dropped")),
};
completed.push((pending.resolve.clone(), pending.reject.clone(), result));
false
});
// Release the queue before calling JavaScript, which can submit another request.
for (resolve, reject, result) in completed {
match result {
Ok(value) => {
let _ = resolve.call1(&JsValue::UNDEFINED, &value);
}
Err(error) => {
let _ = reject.call1(&JsValue::UNDEFINED, &error);
}
}
}
if !self.pending.borrow().is_empty() {
self.ctx.request_repaint_after(Duration::from_millis(16));
}
}
}
+637
View File
@@ -0,0 +1,637 @@
//! Designs on the computer's own disk, through the File System Access API (Chrome, Edge, Opera):
//! Save As and Open show the browser's file dialogs, and the chosen file's handle is kept (in
//! memory, and in IndexedDB so Save still reaches the file after a reload) so Save writes back to
//! it. To the engine such a design is the file `/disk/<id>/<name>`: it saves there like anywhere,
//! and [`flush`](crate::files::flush) forwards what it wrote to the handle. Browsers without the
//! API (Firefox, Safari) keep designs in the origin private file system (`files.rs`).
//!
//! The path logic is plain Rust and tested natively; the browser calls are wasm-only.
#![cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
/// Where a design on the computer's disk appears to the engine.
pub const PREFIX: &str = "/disk/";
/// Where designs kept in the browser's private storage (OPFS) appear to the engine.
pub const OPFS_PREFIX: &str = "/opfs/";
/// Paths written here are handed to the user as downloads.
pub const DOWNLOADS_PREFIX: &str = "/downloads/";
/// Where what a command wrote to an engine path goes.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WriteTo {
/// The browser's private storage: the design (or recovery file) keeps its name there.
Opfs,
/// A file on the computer, through its handle.
Disk,
/// A download.
Download,
/// Kept only in memory (uploads).
Memory,
}
/// Route a write by its path. OPFS paths always go to OPFS, whatever the browser can do: a design
/// opened from the browser's storage keeps saving there until the user picks Save As.
pub fn route(path: &str) -> WriteTo {
if path.starts_with(OPFS_PREFIX) {
WriteTo::Opfs
} else if parse(path).is_some() {
WriteTo::Disk
} else if path.starts_with(DOWNLOADS_PREFIX) {
WriteTo::Download
} else {
WriteTo::Memory
}
}
/// The engine path of the design `name` kept under handle `id`.
pub fn path(id: &str, name: &str) -> String {
format!("{PREFIX}{id}/{name}")
}
/// The handle id and file name of an engine path made by [`path`].
pub fn parse(path: &str) -> Option<(&str, &str)> {
let (id, name) = path.strip_prefix(PREFIX)?.split_once('/')?;
(!id.is_empty() && !name.is_empty() && !name.contains('/')).then_some((id, name))
}
/// A handle id from the time and a random number.
pub fn make_id(ms: u64, random: u64) -> String {
format!("{ms:x}-{random:x}")
}
/// How long a remembered file may go unused, and unreferenced by the recent designs and the
/// autosave, before the browser's copy of its handle is dropped.
pub const HANDLE_MAX_AGE_MS: f64 = 90.0 * 24.0 * 3600.0 * 1000.0;
/// What to do with the remembered handles.
#[derive(Debug, Default, PartialEq)]
pub struct PrunePlan {
/// Handles to forget.
pub delete: Vec<String>,
/// Handles seen unused for the first time: start their clock.
pub stamp: Vec<String>,
}
/// Decide which of the remembered handles `ids` (id, when it was last stamped) to forget: those
/// that no recent design or autosave (`keep`) names and that have gone unused for `max_age`. One
/// never stamped starts its clock now, so a file another tab has just saved is never dropped.
pub fn plan_prune(ids: &[(String, Option<f64>)], keep: &std::collections::HashSet<String>, now: f64, max_age: f64) -> PrunePlan {
let mut plan = PrunePlan::default();
for (id, stamped) in ids.iter().filter(|(id, _)| !keep.contains(id)) {
match stamped {
None => plan.stamp.push(id.clone()),
Some(at) if now - at > max_age => plan.delete.push(id.clone()),
Some(_) => {}
}
}
plan
}
/// Where a design saved with Save As goes.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SaveTarget {
/// A file the user picks on their computer.
Disk,
/// The browser's private storage (OPFS): what browsers without the API have always done.
Browser,
/// Not a design (an export): handed over as a download.
Download,
}
/// Choose where a Save As goes: designs to the disk when the browser can pick a file, else into
/// the browser's storage; anything else is a download.
pub fn save_target(api: bool, design: bool) -> SaveTarget {
match (design, api) {
(false, _) => SaveTarget::Download,
(true, true) => SaveTarget::Disk,
(true, false) => SaveTarget::Browser,
}
}
/// Does a picker for `extensions` (what the command reads; `&[]`: anything) belong to a design?
/// Only those go through the disk dialog; inserting a STEP or mesh keeps using the file window.
pub fn opens_design(extensions: &[&str]) -> bool {
extensions.is_empty() || extensions.iter().any(|e| solidcraft_engine::io::DESIGN_EXTS.contains(e))
}
/// The file type a Save As dialog offers for `name`: (description, MIME type, extension).
pub fn save_type(name: &str) -> (&'static str, &'static str, &'static str) {
let json = std::path::Path::new(name).extension().is_some_and(|e| e.eq_ignore_ascii_case("json"));
if json { ("Plain JSON design", "application/json", ".json") } else { ("SolidCraft design", "application/octet-stream", ".solidcraft") }
}
/// Is this a cancelled dialog (the browser's `AbortError`)? Not an error to show.
pub fn is_abort(error_name: &str) -> bool {
error_name == "AbortError"
}
#[cfg(target_arch = "wasm32")]
pub use web::*;
#[cfg(target_arch = "wasm32")]
mod web {
use std::cell::{Cell, RefCell};
use std::collections::{HashMap, VecDeque};
use std::rc::Rc;
use js_sys::{Array, Function, Object, Promise, Reflect};
use solidcraft_engine::io::vfs;
use wasm_bindgen::{JsCast as _, JsValue, closure::Closure};
use wasm_bindgen_futures::{JsFuture, spawn_local};
use web_sys::{FileSystemFileHandle, IdbDatabase, IdbObjectStore, IdbOpenDbRequest, IdbRequest, IdbTransactionMode};
use super::{HANDLE_MAX_AGE_MS, is_abort, make_id, opens_design, parse, path, plan_prune, save_type};
use crate::files::{Files, err, read_handle};
const DB: &str = "solidcraft-disk-handles";
const STORE: &str = "handles";
thread_local! {
/// Handles picked or looked up in this session, by id.
static HANDLES: RefCell<HashMap<String, FileSystemFileHandle>> = RefCell::new(HashMap::new());
/// Writes waiting for their turn (a file takes one writer at a time), latest per id.
static QUEUE: RefCell<VecDeque<(String, Vec<u8>)>> = const { RefCell::new(VecDeque::new()) };
static BUSY: Cell<bool> = const { Cell::new(false) };
}
/// Why a dialog gave no file.
enum Pick {
Cancelled,
Failed(String),
}
fn failed(e: JsValue) -> Pick {
let name = Reflect::get(&e, &"name".into()).ok().and_then(|n| n.as_string()).unwrap_or_default();
if is_abort(&name) { Pick::Cancelled } else { Pick::Failed(err(e)) }
}
/// Can this page show the browser's file dialogs and write to the files they pick?
pub fn supported() -> bool {
let Some(window) = web_sys::window() else { return false };
let has = |name: &str| Reflect::get(&window, &name.into()).is_ok_and(|f| f.is_function());
window.is_secure_context() && has("showSaveFilePicker") && has("showOpenFilePicker")
}
fn types(description: &str, mime: &str, extension: &str) -> Array {
let accept = Object::new();
let _ = Reflect::set(&accept, &mime.into(), &Array::of1(&extension.into()));
let entry = Object::new();
let _ = Reflect::set(&entry, &"description".into(), &description.into());
let _ = Reflect::set(&entry, &"accept".into(), &accept);
Array::of1(&entry)
}
/// Start a dialog. This must run in the click that asked for it (the browser wants a user
/// gesture), so it is called before anything is awaited.
fn start_picker(function: &str, options: &Object) -> Result<Promise, Pick> {
let window = web_sys::window().ok_or_else(|| Pick::Failed("no window".into()))?;
let f: Function = Reflect::get(&window, &function.into())
.map_err(failed)?
.dyn_into()
.map_err(|_| Pick::Failed("this browser can't open file dialogs".into()))?;
f.call1(&window, options).map_err(failed)?.dyn_into().map_err(|_| Pick::Failed("no answer from the file dialog".into()))
}
/// Remember a picked file: in memory now, in IndexedDB for the next session.
fn register(handle: &FileSystemFileHandle) -> String {
let id = make_id(js_sys::Date::now() as u64, (js_sys::Math::random() * 1e15) as u64);
HANDLES.with(|h| h.borrow_mut().insert(id.clone(), handle.clone()));
let (key, kept, name) = (id.clone(), handle.clone(), handle.name());
spawn_local(async move {
if let Err(e) = remember(&key, &kept).await {
log::warn!("the file's handle isn't kept for the next session: {e}");
}
});
path(&id, &name)
}
fn save_options(name: &str) -> Object {
let options = Object::new();
let _ = Reflect::set(&options, &"suggestedName".into(), &name.into());
let (description, mime, extension) = save_type(name);
let _ = Reflect::set(&options, &"types".into(), &types(description, mime, extension));
options
}
/// "Save to computer…" for a design kept in the browser: write a copy of `bytes` to a file the
/// user picks (call in the click), or download it where the browser can't pick one. The design
/// itself stays where it is.
pub fn save_copy(files: &Rc<Files>, name: &str, bytes: Vec<u8>) {
if !supported() {
if let Err(e) = crate::files::download(name, &bytes) {
files.say(format!("{name}: {e}"), true);
}
return;
}
let promise = start_picker("showSaveFilePicker", &save_options(name));
let files = files.clone();
spawn_local(async move {
let r = async {
let h: FileSystemFileHandle =
JsFuture::from(promise?).await.map_err(failed)?.dyn_into().map_err(|_| Pick::Failed("not a file".into()))?;
write_handle(&h, &bytes).await.map_err(Pick::Failed)?;
Ok::<String, Pick>(h.name())
};
match r.await {
Ok(saved) => files.say(format!("Saved a copy to {saved}"), false),
Err(Pick::Cancelled) => {}
Err(Pick::Failed(e)) => files.say(format!("save to computer: {e}"), true),
}
});
}
/// Save As, step one: show the browser's save dialog (call in the click). The answer is the
/// engine path the design is saved to, `None` when the dialog was closed.
pub fn pick_save(files: &Rc<Files>, name: &str) -> impl std::future::Future<Output = Option<String>> + use<> {
let promise = start_picker("showSaveFilePicker", &save_options(name));
let files = files.clone();
async move {
let handle = match promise {
Ok(p) => JsFuture::from(p)
.await
.map_err(failed)
.and_then(|h| h.dyn_into::<FileSystemFileHandle>().map_err(|_| Pick::Failed("not a file".into()))),
Err(e) => Err(e),
};
match handle {
Ok(h) => Some(register(&h)),
Err(Pick::Cancelled) => None,
Err(Pick::Failed(e)) => {
files.say(format!("save: {e}"), true);
None
}
}
}
}
/// Open, step one: show the browser's open dialog for a design (call in the click). The
/// answer is the engine path of the file, read into the engine's files; a design keeps its
/// handle, so Save writes back to the file. `None`: closed, or it could not be read.
pub fn pick_open(files: &Rc<Files>) -> impl std::future::Future<Output = Option<String>> + use<> {
let promise = start_picker("showOpenFilePicker", &open_options());
let files = files.clone();
async move {
let r = async {
let p = promise?;
let handles: Array = JsFuture::from(p).await.map_err(failed)?.dyn_into().map_err(|_| Pick::Failed("no file".into()))?;
let h: FileSystemFileHandle = handles.get(0).dyn_into().map_err(|_| Pick::Cancelled)?;
let bytes = read_handle(&h).await.map_err(Pick::Failed)?;
Ok((h, bytes))
};
match r.await {
Ok((h, bytes)) => Some(arrive(&h, bytes)),
Err(Pick::Cancelled) => None,
Err(Pick::Failed(e)) => {
files.say(format!("open: {e}"), true);
None
}
}
}
}
/// The options of the open dialog: a single file, designs and CAD files listed first.
pub fn open_options() -> Object {
let options = Object::new();
let _ = Reflect::set(&options, &"multiple".into(), &JsValue::FALSE);
let extensions = Array::new();
for e in [".solidcraft", ".solvecraft", ".json", ".step", ".stp", ".3mf", ".stl", ".obj", ".glb", ".gltf", ".fcstd", ".3dm"] {
extensions.push(&e.into());
}
let accept = Object::new();
let _ = Reflect::set(&accept, &"application/octet-stream".into(), &extensions);
let entry = Object::new();
let _ = Reflect::set(&entry, &"description".into(), &"Designs and CAD files".into());
let _ = Reflect::set(&entry, &"accept".into(), &accept);
let _ = Reflect::set(&options, &"types".into(), &Array::of1(&entry));
options
}
/// A file read from a dialog's handle: a design lives at its disk path from now on (and
/// keeps the handle); anything else is an upload to open or insert.
pub fn arrive(handle: &FileSystemFileHandle, bytes: Vec<u8>) -> String {
let name = handle.name();
let p = if solidcraft_engine::io::is_design_path(&name) { register(handle) } else { format!("{}{name}", crate::files::UPLOAD) };
vfs::insert(&p, bytes);
p
}
/// Does an Open for `extensions` use the browser's open dialog?
pub fn handles_open(extensions: &[&str]) -> bool {
supported() && opens_design(extensions)
}
// ---- writing ------------------------------------------------------------------------
/// Queue what a command wrote to a disk path. Writes go one at a time; a newer write of the
/// same file replaces one still waiting.
pub fn enqueue(files: &Rc<Files>, path: &str, bytes: Vec<u8>) {
QUEUE.with(|q| {
let mut q = q.borrow_mut();
q.retain(|(p, _)| p != path);
q.push_back((path.to_string(), bytes));
});
if BUSY.with(|b| b.replace(true)) {
return;
}
let files = files.clone();
spawn_local(async move {
while let Some((path, bytes)) = QUEUE.with(|q| q.borrow_mut().pop_front()) {
if let Err(e) = write(&path, &bytes).await {
files.failed_disk(&path, &e);
}
}
BUSY.with(|b| b.set(false));
});
}
async fn write(path: &str, bytes: &[u8]) -> Result<(), String> {
let (id, name) = parse(path).ok_or("not a file on the computer")?;
let handle = handle_for(id).await.map_err(|e| format!("{name}: {e}"))?;
write_handle(&handle, bytes).await.map_err(|e| format!("{name}: {e}"))
}
async fn write_handle(handle: &FileSystemFileHandle, bytes: &[u8]) -> Result<(), String> {
ensure_readwrite(handle).await?;
let w: web_sys::FileSystemWritableFileStream =
JsFuture::from(handle.create_writable()).await.map_err(err)?.dyn_into().map_err(|_| "no writable stream".to_string())?;
JsFuture::from(w.write_with_u8_array(bytes).map_err(err)?).await.map_err(err)?;
JsFuture::from(w.close()).await.map_err(err)?;
Ok(())
}
async fn handle_for(id: &str) -> Result<FileSystemFileHandle, String> {
if let Some(h) = HANDLES.with(|h| h.borrow().get(id).cloned()) {
return Ok(h);
}
let h: FileSystemFileHandle = recall(id)
.await?
.and_then(|v| v.dyn_into().ok())
.ok_or("the browser no longer remembers where this design was saved: use Save As to choose the file again")?;
HANDLES.with(|m| m.borrow_mut().insert(id.to_string(), h.clone()));
Ok(h)
}
/// Ask for write access if the browser doesn't hold it any more (after a reload): one prompt,
/// from the click that asked for the save.
async fn ensure_readwrite(handle: &FileSystemFileHandle) -> Result<(), String> {
let options = Object::new();
let _ = Reflect::set(&options, &"mode".into(), &"readwrite".into());
for method in ["queryPermission", "requestPermission"] {
let Some(f) = Reflect::get(handle, &method.into()).ok().and_then(|f| f.dyn_into::<Function>().ok()) else {
// A browser without the permission calls hands out writable handles as they are.
return Ok(());
};
let answer = f.call1(handle, &options).map_err(err)?;
let state = JsFuture::from(Promise::resolve(&answer)).await.map_err(err)?;
if state.as_string().as_deref() == Some("granted") {
return Ok(());
}
}
Err("the browser wasn't given permission to write the file: use Save As to choose it again".into())
}
// ---- IndexedDB: the handles, by id --------------------------------------------------
/// The result of an IndexedDB request.
async fn result(req: &IdbRequest) -> Result<JsValue, String> {
let promise = Promise::new(&mut |resolve, reject| {
let r = req.clone();
let ok = Closure::once_into_js(move || {
let _ = resolve.call1(&JsValue::NULL, &r.result().unwrap_or(JsValue::UNDEFINED));
});
let bad = Closure::once_into_js(move || {
let _ = reject.call1(&JsValue::NULL, &"IndexedDB failed".into());
});
req.set_onsuccess(Some(ok.unchecked_ref()));
req.set_onerror(Some(bad.unchecked_ref()));
});
JsFuture::from(promise).await.map_err(err)
}
async fn open_db() -> Result<IdbDatabase, String> {
let factory = web_sys::window().ok_or("no window")?.indexed_db().map_err(err)?.ok_or("this browser has no IndexedDB")?;
let request: IdbOpenDbRequest = factory.open_with_u32(DB, 1).map_err(err)?;
let target = request.clone();
let upgrade = Closure::once_into_js(move || {
if let Ok(db) = target.result().and_then(|r| r.dyn_into::<IdbDatabase>()) {
let _ = db.create_object_store(STORE);
}
});
request.set_onupgradeneeded(Some(upgrade.unchecked_ref()));
result(&request).await?.dyn_into().map_err(|_| "no database".to_string())
}
fn store(db: &IdbDatabase, mode: IdbTransactionMode) -> Result<IdbObjectStore, String> {
db.transaction_with_str_and_mode(STORE, mode).map_err(err)?.object_store(STORE).map_err(err)
}
/// Key of the time a handle was last used (the handle's own key is its id).
fn stamp_key(id: &str) -> String {
format!("at:{id}")
}
async fn remember(id: &str, handle: &FileSystemFileHandle) -> Result<(), String> {
let db = open_db().await?;
let store = store(&db, IdbTransactionMode::Readwrite)?;
result(&store.put_with_key(handle, &id.into()).map_err(err)?).await?;
result(&store.put_with_key(&js_sys::Date::now().into(), &stamp_key(id).into()).map_err(err)?).await.map(|_| ())
}
/// Note that the file was used now.
async fn touch(id: &str) {
let Ok(db) = open_db().await else { return };
if let Ok(store) = store(&db, IdbTransactionMode::Readwrite)
&& let Ok(req) = store.put_with_key(&js_sys::Date::now().into(), &stamp_key(id).into())
{
let _ = result(&req).await;
}
}
/// The ids of every remembered file.
pub async fn known_ids() -> Result<Vec<String>, String> {
let db = open_db().await?;
let req = store(&db, IdbTransactionMode::Readonly)?.get_all_keys().map_err(err)?;
let keys: Array = result(&req).await?.dyn_into().map_err(|_| "no keys".to_string())?;
Ok(keys.iter().filter_map(|k| k.as_string()).filter(|k| !k.starts_with("at:")).collect())
}
async fn forget(ids: &[String]) -> Result<(), String> {
let db = open_db().await?;
let store = store(&db, IdbTransactionMode::Readwrite)?;
for id in ids {
result(&store.delete(&id.as_str().into()).map_err(err)?).await?;
result(&store.delete(&stamp_key(id).into()).map_err(err)?).await?;
HANDLES.with(|h| h.borrow_mut().remove(id));
}
Ok(())
}
/// Forget the remembered files nothing refers to any more (`keep`: the ids the recent designs
/// and the autosave name), once they have gone unused for a long while.
pub fn prune(keep: std::collections::HashSet<String>) {
spawn_local(async move {
let r = async {
// A transaction ends when the page awaits anything but its own requests, so each
// step opens its own.
let known = known_ids().await?;
let db = open_db().await?;
let reading = store(&db, IdbTransactionMode::Readonly)?;
let mut ids = Vec::new();
for id in known {
let at = result(&reading.get(&stamp_key(&id).into()).map_err(err)?).await?.as_f64();
ids.push((id, at));
}
let plan = plan_prune(&ids, &keep, js_sys::Date::now(), HANDLE_MAX_AGE_MS);
for id in &plan.stamp {
touch(id).await;
}
forget(&plan.delete).await
};
if let Err(e) = r.await {
log::warn!("remembered files weren't tidied: {e}");
}
});
}
/// Open a design remembered from an earlier session (a recent one, after a reload): ask once
/// for access to the file (call in the click), read it into the engine's files and queue it
/// to open. A file that is gone is forgotten and dropped from the recent designs.
pub fn restore(files: &Rc<Files>, path: &str) {
let Some((id, name)) = parse(path) else { return };
let (files, path, id, name) = (files.clone(), path.to_string(), id.to_string(), name.to_string());
spawn_local(async move {
let r = async {
let handle = handle_for(&id).await?;
ensure_readwrite(&handle).await?;
read_handle(&handle).await
};
match r.await {
Ok(bytes) => {
vfs::insert(&path, bytes);
files.opened.borrow_mut().push(path);
files.wake();
touch(&id).await;
}
Err(e) => {
let gone = e.contains("NotFoundError") || e.contains("could not be found") || e.contains("no longer remembers");
if gone {
let _ = forget(std::slice::from_ref(&id)).await;
files.disk_known.borrow_mut().remove(&id);
files.gone.borrow_mut().push(path);
files.say(format!("{name} isn't where it was saved any more"), true);
} else {
files.say(format!("{name}: {e}"), true);
}
}
}
});
}
async fn recall(id: &str) -> Result<Option<JsValue>, String> {
let db = open_db().await?;
let req = store(&db, IdbTransactionMode::Readonly)?.get(&id.into()).map_err(err)?;
Ok(Some(result(&req).await?).filter(|v| !v.is_undefined()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_disk_path_round_trips_its_handle_id_and_name() {
let p = path("18f-2a", "Bracket v2.solidcraft");
assert_eq!(p, "/disk/18f-2a/Bracket v2.solidcraft");
assert_eq!(parse(&p), Some(("18f-2a", "Bracket v2.solidcraft")));
}
#[test]
fn only_disk_paths_parse() {
for not in [
"/opfs/a.solidcraft",
"/downloads/a.step",
"/upload/a.step",
"/disk/",
"/disk/id",
"/disk/id/",
"/disk//a",
"/disk/id/x/y",
"a.solidcraft",
] {
assert_eq!(parse(not), None, "{not}");
}
}
/// Designs already stored in OPFS (and the recovery files) keep saving to OPFS under their own
/// names, whether or not the browser can write to the disk.
#[test]
fn writes_to_opfs_paths_still_go_to_opfs() {
for p in
["/opfs/Bracket.solidcraft", "/opfs/Bracket (2).solidcraft", "/opfs/recovery.solidcraft", "/opfs/recovery.json", "/opfs/old.solvecraft"]
{
assert_eq!(route(p), WriteTo::Opfs, "{p}");
}
// A design opened from OPFS saves to its own path; only a Save As can pick the disk.
assert_eq!(save_target(false, true), SaveTarget::Browser);
assert_eq!(route(&format!("{OPFS_PREFIX}Bracket.solidcraft")), WriteTo::Opfs);
assert_eq!(route("/disk/18f-2a/Bracket.solidcraft"), WriteTo::Disk);
assert_eq!(route("/downloads/part.step"), WriteTo::Download);
assert_eq!(route("/upload/part.step"), WriteTo::Memory);
}
#[test]
fn only_unreferenced_handles_unused_for_long_are_forgotten() {
let keep: std::collections::HashSet<String> = ["recent".to_string()].into();
let day = 24.0 * 3600.0 * 1000.0;
let now = 1000.0 * day;
let ids = vec![
("recent".to_string(), Some(now - 200.0 * day)),
("old".to_string(), Some(now - 100.0 * day)),
("fresh".to_string(), Some(now - 5.0 * day)),
("unstamped".to_string(), None),
];
let plan = plan_prune(&ids, &keep, now, HANDLE_MAX_AGE_MS);
assert_eq!(plan, PrunePlan { delete: vec!["old".to_string()], stamp: vec!["unstamped".to_string()] });
assert_eq!(plan_prune(&[], &keep, now, HANDLE_MAX_AGE_MS), PrunePlan::default());
}
#[test]
fn handle_ids_differ_by_time_and_by_chance() {
assert_ne!(make_id(1, 5), make_id(2, 5));
assert_ne!(make_id(1, 5), make_id(1, 6));
assert!(!make_id(0, 0).contains('/'));
}
/// Save As: a design goes to the disk where the browser can pick files and to the browser's
/// storage where it cannot (today's behaviour); an export is a download either way.
#[test]
fn save_as_chooses_between_the_disk_the_browsers_storage_and_a_download() {
assert_eq!(save_target(true, true), SaveTarget::Disk);
assert_eq!(save_target(false, true), SaveTarget::Browser);
assert_eq!(save_target(true, false), SaveTarget::Download);
assert_eq!(save_target(false, false), SaveTarget::Download);
}
#[test]
fn only_design_opens_use_the_disk_dialog() {
assert!(opens_design(&[]));
assert!(opens_design(&["solidcraft"]));
assert!(opens_design(&["solvecraft"]));
assert!(!opens_design(&["step", "stp"]));
assert!(!opens_design(&["json"]));
}
#[test]
fn the_save_dialog_offers_the_type_of_the_name() {
assert_eq!(save_type("Plate.solidcraft").2, ".solidcraft");
assert_eq!(save_type("Plate.JSON").2, ".json");
assert_eq!(save_type("Plate").2, ".solidcraft");
}
#[test]
fn closing_a_dialog_is_not_an_error() {
assert!(is_abort("AbortError"));
assert!(!is_abort("NotAllowedError"));
assert!(!is_abort("SecurityError"));
}
}
+72 -28
View File
@@ -2,22 +2,26 @@
//! the engine's in-memory files (`solidcraft_engine::io::vfs`) at start, written back whenever a
//! command writes one. Files come in from the computer through a file picker (the File System
//! Access API where the browser has it, an `<input type=file>` otherwise) or a drop on the page,
//! and go out as downloads (exports, and "Download design"). An autosave of unsaved work is kept
//! in OPFS too and offered again after a reload or a crash.
//! and go out as downloads (exports, and "Download design"). Where the File System Access API
//! exists a design also lives on the computer's disk, saved and opened through the browser's file
//! dialogs (`disk.rs`). An autosave of unsaved work is kept in OPFS and offered again after a
//! reload or a crash.
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use js_sys::{Array, Function, Object, Reflect, Uint8Array};
use solidcraft_engine::io::vfs;
use crate::disk;
use wasm_bindgen::{JsCast as _, JsValue, closure::Closure};
use wasm_bindgen_futures::{JsFuture, spawn_local};
use web_sys::{FileSystemDirectoryHandle, FileSystemFileHandle, FileSystemGetFileOptions, FileSystemWritableFileStream};
/// Where OPFS files appear to the engine.
pub const OPFS: &str = "/opfs/";
pub const OPFS: &str = disk::OPFS_PREFIX;
/// Paths written here are handed to the user as downloads.
pub const DOWNLOADS: &str = "/downloads/";
pub const DOWNLOADS: &str = disk::DOWNLOADS_PREFIX;
/// Files brought in from the computer.
pub const UPLOAD: &str = "/upload/";
/// The autosave of unsaved work (in OPFS: `recovery.solidcraft` and `recovery.json`).
@@ -51,6 +55,14 @@ pub struct Files {
pub entries: RefCell<Vec<Entry>>,
/// Files read from the computer: (name, bytes, what to do).
pub incoming: RefCell<Vec<(String, Vec<u8>, Intent)>>,
/// Designs opened from the computer's disk: their paths in the engine's files.
pub opened: RefCell<Vec<String>>,
/// The ids of the files remembered from earlier sessions (see `disk::restore`).
pub disk_known: RefCell<std::collections::HashSet<String>>,
/// Recent designs whose file is gone: to drop from the recent list.
pub gone: RefCell<Vec<String>>,
/// Disk designs whose last write failed: they are not saved.
pub unsaved: RefCell<Vec<String>>,
/// Messages for the status line (errors and confirmations).
pub messages: RefCell<Vec<(String, bool)>>,
/// The stored designs are loaded (the app may open them).
@@ -64,11 +76,17 @@ pub struct Files {
}
impl Files {
fn say(&self, text: impl Into<String>, error: bool) {
pub fn say(&self, text: impl Into<String>, error: bool) {
self.messages.borrow_mut().push((text.into(), error));
self.wake();
}
/// Writing the design at disk `path` failed: say so, and take its "saved" back.
pub fn failed_disk(&self, path: &str, error: &str) {
self.unsaved.borrow_mut().push(path.to_string());
self.say(format!("Not saved to your computer: {error}"), true);
}
/// A file arrived from the computer.
fn arrive(&self, name: String, bytes: Vec<u8>, intent: Intent) {
self.incoming.borrow_mut().push((name, bytes, intent));
@@ -76,7 +94,7 @@ impl Files {
}
/// Run a frame (the app sleeps until something happens).
fn wake(&self) {
pub fn wake(&self) {
if let Some(c) = self.ctx.borrow().as_ref() {
c.request_repaint();
}
@@ -127,7 +145,7 @@ pub fn local_only() -> bool {
LOCAL.with(Cell::get)
}
fn err(e: JsValue) -> String {
pub(crate) fn err(e: JsValue) -> String {
e.as_string().or_else(|| Reflect::get(&e, &"message".into()).ok().and_then(|m| m.as_string())).unwrap_or_else(|| format!("{e:?}"))
}
@@ -150,7 +168,7 @@ async fn root() -> Result<FileSystemDirectoryHandle, String> {
dir.dyn_into::<FileSystemDirectoryHandle>().map_err(|_| "this browser has no private file storage (OPFS)".to_string())
}
async fn read_handle(h: &FileSystemFileHandle) -> Result<Vec<u8>, String> {
pub(crate) async fn read_handle(h: &FileSystemFileHandle) -> Result<Vec<u8>, String> {
let file: web_sys::File = JsFuture::from(h.get_file()).await.map_err(err)?.dyn_into().map_err(|_| "not a file".to_string())?;
blob_bytes(&file).await
}
@@ -253,6 +271,10 @@ pub fn start(files: &Rc<Files>) {
}
let files = files.clone();
spawn_local(async move {
match disk::known_ids().await {
Ok(ids) => files.disk_known.borrow_mut().extend(ids),
Err(e) => log::info!("no remembered files: {e}"),
}
match read_all().await {
Ok(all) => {
if local_only() {
@@ -295,26 +317,32 @@ pub fn start(files: &Rc<Files>) {
/// Store what commands wrote: OPFS paths into OPFS, download paths as downloads.
pub fn flush(files: &Rc<Files>) {
for (path, bytes) in vfs::take_writes() {
if let Some(name) = path.strip_prefix(OPFS) {
let name = name.to_string();
{
let mut e = files.entries.borrow_mut();
e.retain(|x| x.name != name);
if name != RECOVERY && name != RECOVERY_META {
e.push(Entry { name: name.clone(), size: bytes.len() as u64, modified: js_sys::Date::now() });
e.sort_by_key(|a| a.name.to_lowercase());
match disk::route(&path) {
disk::WriteTo::Opfs => {
let name = path.strip_prefix(OPFS).unwrap_or(&path).to_string();
{
let mut e = files.entries.borrow_mut();
e.retain(|x| x.name != name);
if name != RECOVERY && name != RECOVERY_META {
e.push(Entry { name: name.clone(), size: bytes.len() as u64, modified: js_sys::Date::now() });
e.sort_by_key(|a| a.name.to_lowercase());
}
}
let files = files.clone();
spawn_local(async move {
if let Err(e) = write_file(&name, &bytes).await {
files.say(format!("{name}: {e}"), true);
}
});
}
let files = files.clone();
spawn_local(async move {
if let Err(e) = write_file(&name, &bytes).await {
disk::WriteTo::Disk => disk::enqueue(files, &path, bytes),
disk::WriteTo::Download => {
let name = path.strip_prefix(DOWNLOADS).unwrap_or(&path);
if let Err(e) = download(name, &bytes) {
files.say(format!("{name}: {e}"), true);
}
});
} else if let Some(name) = path.strip_prefix(DOWNLOADS)
&& let Err(e) = download(name, &bytes)
{
files.say(format!("{name}: {e}"), true);
}
disk::WriteTo::Memory => {}
}
}
}
@@ -427,8 +455,15 @@ pub fn pick_from_disk(files: &Rc<Files>, intent: Intent) {
let picker = Reflect::get(&window, &"showOpenFilePicker".into()).ok().and_then(|f| f.dyn_into::<Function>().ok());
match picker {
Some(f) => {
let opts = Object::new();
let _ = Reflect::set(&opts, &"multiple".into(), &JsValue::FALSE);
// A design from the computer's disk keeps its file, so Save writes back to it.
let on_disk = intent == Intent::Open && disk::supported();
let opts = if on_disk {
disk::open_options()
} else {
let opts = Object::new();
let _ = Reflect::set(&opts, &"multiple".into(), &JsValue::FALSE);
opts
};
let promise = f.call1(&window, &opts);
let files = files.clone();
spawn_local(async move {
@@ -438,7 +473,13 @@ pub fn pick_from_disk(files: &Rc<Files>, intent: Intent) {
for h in handles.iter() {
let h: FileSystemFileHandle = h.dyn_into().map_err(|_| "not a file".to_string())?;
let bytes = read_handle(&h).await?;
files.arrive(h.name(), bytes, intent);
if on_disk && solidcraft_engine::io::is_design_path(&h.name()) {
let path = disk::arrive(&h, bytes);
files.opened.borrow_mut().push(path);
files.wake();
} else {
files.arrive(h.name(), bytes, intent);
}
}
Ok::<(), String>(())
};
@@ -458,7 +499,10 @@ fn input_picker(files: &Rc<Files>, intent: Intent) {
let Some(doc) = web_sys::window().and_then(|w| w.document()) else { return };
let Ok(input) = doc.create_element("input").map(|e| e.unchecked_into::<web_sys::HtmlInputElement>()) else { return };
input.set_type("file");
input.set_accept(".solidcraft,.solvecraft,.json,.step,.stp,.3mf,.stl");
// Everything the browser build imports, from the format registry: cadmpeg is pure Rust, so
// FreeCAD and Rhino files are part of it.
let accept = solidcraft_engine::io::formats::open_extensions(true).iter().map(|e| format!(".{e}")).collect::<Vec<_>>().join(",");
input.set_accept(&accept);
// Attached (hidden) while it is open: some browsers ignore a click on a detached input.
let _ = input.set_attribute("style", "display:none");
if let Some(body) = doc.body() {
+226 -40
View File
@@ -6,10 +6,24 @@
//! `?webgpu`), `?sample` opens the sample part.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(target_arch = "wasm32")]
mod api;
#[cfg(any(target_arch = "wasm32", test))]
mod disk;
#[cfg(target_arch = "wasm32")]
mod files;
#[cfg(target_arch = "wasm32")]
mod install;
#[cfg(any(target_arch = "wasm32", test))]
mod space_mouse;
/// The browser switches tabs with Ctrl+Tab and Ctrl+Shift+Tab, so the page leaves those keys to
/// it (#5227); plain Tab and Shift+Tab still move between the app's fields. (Ctrl+PageUp and
/// Ctrl+PageDown already reach the browser.)
#[cfg(any(target_arch = "wasm32", test))]
fn browser_switches_tabs(key: &str, ctrl: bool) -> bool {
ctrl && key == "Tab"
}
#[cfg(target_arch = "wasm32")]
mod web {
@@ -17,14 +31,44 @@ mod web {
use solidcraft_engine::Session;
use solidcraft_engine::io::vfs;
use solidcraft_ui_egui::{Services, SolveApp};
use solidcraft_ui_egui::{Services, SolveApp, ui_kit};
use wasm_bindgen::JsCast as _;
use crate::api::Api;
use crate::disk::{self, SaveTarget};
use crate::files::{self, Files, Intent};
use crate::space_mouse::SpaceMouse;
const CANVAS_ID: &str = "solidcraft_canvas";
const LOADING_ID: &str = "solidcraft_loading";
const DEPTH_BITS: u8 = 24;
/// A 32-bit float depth buffer (no stencil): WebGPU and WebGL2 both have it, and the viewport
/// draws it with reversed depth, whose precision follows the distance from the eye (#1434).
const DEPTH_BITS: u8 = 32;
/// The loading animation (assets/styles/web/loading-logo.svg) settles into the finished mark
/// about 3.2 s after the page starts; this adds a short hold. The app doesn't land before it.
const LOADING_MIN_MS: f64 = 3600.0;
/// The fade from the loading screen into the app.
const LOADING_FADE_MS: i32 = 300;
/// Take the loading screen away once the animation has played to the finished mark: wait out
/// what is left of `LOADING_MIN_MS`, fade it, then remove it.
fn dismiss_loading(el: web_sys::Element) {
let Some(window) = web_sys::window() else {
el.remove();
return;
};
let now = window.performance().map_or(LOADING_MIN_MS, |p| p.now());
let wait = (LOADING_MIN_MS - now).max(0.0) as i32;
let fade = wasm_bindgen::closure::Closure::once_into_js(move || {
let _ = el.set_attribute("class", "done");
let gone = wasm_bindgen::closure::Closure::once_into_js(move || el.remove());
if let Some(window) = web_sys::window() {
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(gone.unchecked_ref(), LOADING_FADE_MS);
}
});
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(fade.unchecked_ref(), wait);
}
/// Seconds between autosaves of unsaved work.
const AUTOSAVE_S: f64 = 30.0;
@@ -48,12 +92,27 @@ mod web {
on_unload.forget();
}
/// eframe cancels the browser's action for every Tab it gets, so the browser couldn't switch
/// tabs while the app has the keyboard. Its tab-switching keys stop here, on the way down to
/// the canvas, before eframe sees them; the browser then acts on them as usual.
fn leave_tab_switching_to_browser(window: &web_sys::Window) {
let on_key = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::KeyboardEvent)>::new(|e: web_sys::KeyboardEvent| {
if crate::browser_switches_tabs(&e.key(), e.ctrl_key()) {
e.stop_propagation();
}
});
let _ = window.add_event_listener_with_callback_and_bool("keydown", on_key.as_ref().unchecked_ref(), true);
// The listener lives as long as the page.
on_key.forget();
}
const JAPANESE_FONT_PATH: &str = "./fonts/BIZUDPGothic-Regular.ttf";
type FontLoadResult = Rc<std::cell::RefCell<Option<Result<Vec<u8>, String>>>>;
struct Shell {
app: SolveApp,
api: Api,
files: Rc<Files>,
/// When the last autosave was (seconds), and the revision it holds (none stored: `None`).
autosaved_at: f64,
@@ -67,6 +126,12 @@ mod web {
/// On-demand CJK font: fetched only when a CJK language is selected.
font: solidcraft_ui_egui::fonts::CjkFontLoader,
font_result: FontLoadResult,
/// The fetched CJK font's bytes: kept so a language change can re-order the fallbacks.
cjk_bytes: Option<Vec<u8>>,
/// The language the fonts were last set up for (a CJK UI leads with the CJK face).
font_language: String,
space_mouse: SpaceMouse,
wheel_gestures: solidcraft_ui_egui::WebWheelGestures,
}
impl Shell {
@@ -82,10 +147,36 @@ mod web {
return;
}
files::flush(&self.files);
// A write to the computer's disk that failed: the design is not saved after all.
for path in self.files.unsaved.borrow_mut().drain(..) {
if self.app.session.path.as_deref() == Some(path.as_str()) {
self.app.session.mark_unsaved();
}
}
// Designs opened from the computer's disk (the files window): open them.
let opened: Vec<_> = self.files.opened.borrow_mut().drain(..).collect();
for path in opened {
self.app.open_path(&path);
self.files.panel.set(false);
}
// Recent designs whose file is gone are dropped from the list.
for path in self.files.gone.borrow_mut().drain(..) {
self.app.home.recent.retain(|r| *r != path);
self.app.home.opened_at.remove(&path);
}
for (text, error) in self.files.messages.borrow_mut().drain(..) {
self.app.set_status(text, error);
}
let incoming: Vec<_> = self.files.incoming.borrow_mut().drain(..).collect();
let mut incoming: Vec<_> = self.files.incoming.borrow_mut().drain(..).collect();
// A glTF file's buffers picked with it are kept next to it, not opened.
incoming.retain_mut(|(name, bytes, _)| {
let companion =
std::path::Path::new(name.as_str()).extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("bin"));
if companion {
vfs::insert(&format!("{}{name}", files::UPLOAD), std::mem::take(bytes));
}
!companion
});
for (name, bytes, intent) in incoming {
let design = solidcraft_engine::io::is_design_path(&name);
let path = if design && intent == Intent::Open {
@@ -140,7 +231,9 @@ mod web {
let mut entries = self.files.entries.borrow().clone();
entries.sort_by(|a, b| b.modified.total_cmp(&a.modified));
let recent = &mut self.app.home.recent;
recent.retain(|p| vfs::exists(p));
let known = self.files.disk_known.borrow().clone();
// A design on the computer's disk stays listed while its file is remembered.
recent.retain(|p| vfs::exists(p) || disk::parse(p).is_some_and(|(id, _)| known.contains(id)));
for e in entries.iter().filter(|e| solidcraft_engine::io::is_design_path(&e.name)) {
let p = format!("{}{}", files::OPFS, e.name);
if !recent.contains(&p) {
@@ -148,6 +241,10 @@ mod web {
}
}
recent.truncate(solidcraft_ui_egui::home::MAX_RECENT);
// Remembered files that no recent design or autosave names are forgotten in time.
let recovered = self.files.recovery.borrow().as_ref().and_then(|(_, p)| p.clone());
let keep = recent.iter().chain(recovered.as_ref()).filter_map(|p| disk::parse(p)).map(|(id, _)| id.to_string()).collect();
disk::prune(keep);
}
/// The stored designs: open, insert, rename, delete, download; open from the computer.
@@ -155,27 +252,27 @@ mod web {
if !self.files.panel.get() {
return;
}
let mut open = true;
let mut act: Option<(&'static str, String)> = None;
egui::Window::new("Files in this browser")
.open(&mut open)
.collapsible(false)
.resizable(true)
.default_width(520.0)
.pivot(egui::Align2::CENTER_CENTER)
.default_pos(ctx.content_rect().center())
.show(ctx, |ui| {
let closed = solidcraft_ui_egui::ui_kit::Window::new(egui::Id::new("sc_browser_files"), "folder", "Files in this browser")
.cards(3.0)
.show_body(ctx, |ui| {
ui.horizontal(|ui| {
if ui.button("Open from computer…").clicked() {
if ui_kit::button(ui, "Open from computer…", ui_kit::Button::Normal).clicked() {
files::pick_from_disk(&self.files, Intent::Open);
}
if ui.button("Insert from computer…").on_hover_text("STEP, 3MF or STL into the open design").clicked() {
if ui_kit::button(ui, "Insert from computer…", ui_kit::Button::Normal)
.on_hover_text("STEP, 3MF or STL into the open design")
.clicked()
{
files::pick_from_disk(&self.files, Intent::Insert);
}
if ui.button("Download open design").clicked() {
if ui_kit::button(ui, "Download open design", ui_kit::Button::Normal).clicked() {
act = Some(("download_open", String::new()));
}
});
if !disk::supported() {
ui.weak("This browser can't save to a folder: designs stay in this browser. Download design saves a file.");
}
ui.separator();
if !self.files.ready.get() {
ui.horizontal(|ui| {
@@ -193,7 +290,7 @@ mod web {
for e in &entries {
match &mut self.renaming {
Some((from, to)) if *from == e.name => {
let r = ui.add(egui::TextEdit::singleline(to).desired_width(200.0));
let r = ui_kit::cell_editor(ui, ui.make_persistent_id(("stored_file_name", from.as_str())), to, "", false);
if std::mem::take(&mut self.rename_focus) {
r.request_focus();
}
@@ -211,21 +308,31 @@ mod web {
}
ui.label(format!("{:.1} KB", e.size as f64 / 1024.0));
ui.horizontal(|ui| {
if ui.small_button("Open").clicked() {
if ui_kit::button(ui, "Open", ui_kit::Button::Small).clicked() {
act = Some(("open", e.name.clone()));
}
let design = solidcraft_engine::io::is_design_path(&e.name);
if !design && ui.small_button("Insert").on_hover_text("Into the open design (STEP, 3MF, STL)").clicked() {
if !design
&& ui_kit::button(ui, "Insert", ui_kit::Button::Small)
.on_hover_text("Into the open design (STEP, 3MF, STL)")
.clicked()
{
act = Some(("insert", e.name.clone()));
}
if ui.small_button("Rename").clicked() {
if ui_kit::button(ui, "Rename", ui_kit::Button::Small).clicked() {
self.renaming = Some((e.name.clone(), e.name.clone()));
self.rename_focus = true;
}
if ui.small_button("Download").clicked() {
if ui_kit::button(ui, "Save to computer…", ui_kit::Button::Small)
.on_hover_text("A copy in a file you choose; the stored design stays here")
.clicked()
{
act = Some(("save_copy", e.name.clone()));
}
if ui_kit::button(ui, "Download", ui_kit::Button::Small).clicked() {
act = Some(("download", e.name.clone()));
}
if ui.small_button("Delete").clicked() {
if ui_kit::button(ui, "Delete", ui_kit::Button::Small).clicked() {
act = Some(("delete", e.name.clone()));
}
});
@@ -234,7 +341,7 @@ mod web {
});
});
});
if !open {
if closed {
self.files.panel.set(false);
self.renaming = None;
}
@@ -257,6 +364,10 @@ mod web {
}
Err(e) => self.app.set_status(e.to_string(), true),
},
"save_copy" => match vfs::read(&path, solidcraft_engine::io::MAX_DESIGN_BYTES) {
Ok(b) => disk::save_copy(&self.files, &name, b),
Err(e) => self.app.set_status(e.to_string(), true),
},
"download_open" => {
let d = &self.app.session.doc;
let result = self
@@ -298,20 +409,19 @@ mod web {
fn recovery_prompt(&mut self, ctx: &egui::Context) {
let Some((name, path)) = self.files.recovery.borrow().clone() else { return };
let mut answer = None;
egui::Window::new("Recover unsaved work?").collapsible(false).resizable(false).anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]).show(
ctx,
|ui| {
let _ = solidcraft_ui_egui::ui_kit::Window::new(egui::Id::new("sc_browser_recovery"), "restore", "Recover unsaved work?")
.required()
.show_body(ctx, |ui| {
ui.label(format!("“{name}” had unsaved changes when this page last closed."));
ui.horizontal(|ui| {
if ui.button("Recover").clicked() {
if ui_kit::button(ui, "Recover", ui_kit::Button::Normal).clicked() {
answer = Some(true);
}
if ui.button("Discard").clicked() {
if ui_kit::button(ui, "Discard", ui_kit::Button::Normal).clicked() {
answer = Some(false);
}
});
},
);
});
let Some(recover) = answer else { return };
*self.files.recovery.borrow_mut() = None;
if recover {
@@ -331,7 +441,10 @@ mod web {
let done = self.font_result.borrow_mut().take();
if let Some(res) = done {
let installed = match res {
Ok(bytes) => solidcraft_ui_egui::fonts::install_cjk_font(ctx, bytes),
Ok(bytes) => {
self.cjk_bytes = Some(bytes);
true
}
Err(err) => {
log::warn!("failed to fetch CJK font from {JAPANESE_FONT_PATH}: {err}");
false
@@ -343,11 +456,29 @@ mod web {
log::warn!("CJK font unavailable; showing English for this session");
}
self.font.finished(installed, &mut self.app.preferences.language);
self.apply_fonts(ctx);
ctx.request_repaint();
}
if self.font.tick(&mut self.app.preferences.language) {
self.fetch_font(ctx);
}
// The CJK face leads only a CJK UI: its Cyrillic glyphs are full-width, so switching
// to Ukrainian (or English) must put the Latin face back in front.
if self.app.preferences.language != self.font_language {
self.apply_fonts(ctx);
}
}
/// Set up the UI fonts for the live language, keeping the fetched CJK font as a fallback
/// (it leads only for a CJK UI, see [`solidcraft_ui_egui::fonts::install_cjk_font_for`]).
fn apply_fonts(&mut self, ctx: &egui::Context) {
match self.cjk_bytes.clone() {
Some(bytes) => {
solidcraft_ui_egui::fonts::install_cjk_font_for(ctx, bytes, &self.app.preferences.language);
}
None => solidcraft_ui_egui::fonts::setup_fonts(ctx),
}
self.font_language.clone_from(&self.app.preferences.language);
}
/// CJK text on screen with no CJK font yet (an English UI showing a Japanese name):
@@ -373,11 +504,19 @@ mod web {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.tick_font(ctx);
self.app.logic(ctx);
self.api.poll();
self.space_mouse.logic(&mut self.app, ctx);
self.files_tick(ctx);
UNSAVED.with(|u| u.set(solidcraft_ui_egui::documents::any_dirty(&self.app)));
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
let now = raw.time.unwrap_or_else(|| ctx.input(|i| i.time));
if let Some(delay) = self.wheel_gestures.update(raw, now) {
ctx.request_repaint_after(delay);
}
files::take_drops(&self.files, raw);
// Before the app's own hook, which adds the control channel's events.
self.space_mouse.raw_input_hook(ctx, self.app.preferences.space_mouse_enabled, raw);
self.app.raw_input_hook(raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
@@ -441,6 +580,7 @@ mod web {
wasm_bindgen_futures::spawn_local(async {
let Some(window) = web_sys::window() else { return };
ask_before_unload(&window);
leave_tab_switching_to_browser(&window);
let Some(document) = window.document() else { return };
let Some(canvas) = document.get_element_by_id(CANVAS_ID).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok()) else {
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
@@ -460,22 +600,47 @@ mod web {
solidcraft_ui_egui::fonts::setup_fonts(&cc.egui_ctx);
let files = Rc::new(Files::default());
files::start(&files);
let panel = files.clone();
let (panel, opener, saver, shown) = (files.clone(), files.clone(), files.clone(), files.clone());
let (restorable, restorer) = (files.clone(), files.clone());
let services = Services {
// Open shows the browser's files (and the computer's, from there).
pick_open: Some(Box::new(move || {
// Open shows the browser's open dialog for a design where it has one
// (the file keeps its handle, so Save writes back to it); otherwise,
// and for inserting STEP or meshes, the browser's files window.
pick_open: Some(Box::new(move |exts: &[&str]| {
if disk::handles_open(exts) {
return Box::pin(disk::pick_open(&opener));
}
panel.panel.set(true);
Box::pin(std::future::ready(None))
})),
// Designs are kept in the browser; everything else is downloaded.
pick_save: Some(Box::new(|name: &str, exts: &[&str]| {
// A design is saved to a file the user picks, or kept in the browser
// where the browser can't pick one; everything else is downloaded.
pick_save: Some(Box::new(move |name: &str, exts: &[&str]| {
let design = exts.contains(&solidcraft_engine::io::DESIGN_EXT);
Box::pin(std::future::ready(Some(format!("{}{name}", if design { files::OPFS } else { files::DOWNLOADS }))))
match disk::save_target(disk::supported(), design) {
SaveTarget::Disk => Box::pin(disk::pick_save(&saver, name)),
SaveTarget::Browser => {
saver.say("This browser can't save to a folder: the design is kept in this browser. File > Download design saves a file.", false);
Box::pin(std::future::ready(Some(format!("{}{name}", files::OPFS))))
}
SaveTarget::Download => Box::pin(std::future::ready(Some(format!("{}{name}", files::DOWNLOADS)))),
}
})),
// A recent design on the disk, from an earlier session, opens by asking
// the browser for its remembered file.
can_restore: Some(Box::new(move |path: &str| {
disk::parse(path).is_some_and(|(id, _)| restorable.disk_known.borrow().contains(id))
})),
restore_file: Some(Box::new(move |path: &str| disk::restore(&restorer, path))),
download_design: Some(Box::new(files::download)),
browser_files: Some(Box::new(move || shown.panel.set(true))),
install_app: Some(crate::install::service(&cc.egui_ctx)),
..Default::default()
};
let mut app = SolveApp::new(Session::default(), services);
// Thumbnails of saves and start-page cards.
solidcraft_ui_egui::gpu::install_cpu_picture_renderer();
let (sender, receiver) = std::sync::mpsc::channel();
let mut app = SolveApp::new(Session::default(), services).with_control(receiver);
if let Some(p) = cc.storage.and_then(|s| solidcraft_ui_egui::prefs::stored(|k| s.get_string(k))) {
app.load_prefs(&p);
}
@@ -497,8 +662,11 @@ mod web {
let rev = app.session.revision;
app.home.show(rev);
}
let api = Api::install(sender, cc.egui_ctx.clone()).map_err(|e| format!("SolidCraft browser API: {e:?}"))?;
let font_language = app.preferences.language.clone();
Ok(Box::new(Shell {
app,
api,
files,
autosaved_at: f64::NEG_INFINITY,
autosaved: None,
@@ -507,13 +675,17 @@ mod web {
recents_synced: false,
font: Default::default(),
font_result: Rc::new(std::cell::RefCell::new(None)),
cjk_bytes: None,
font_language,
space_mouse: SpaceMouse::default(),
wheel_gestures: solidcraft_ui_egui::WebWheelGestures::default(),
}))
}),
)
.await;
if let Some(el) = document.get_element_by_id(LOADING_ID) {
match result {
Ok(()) => el.remove(),
Ok(()) => dismiss_loading(el),
Err(e) => {
el.set_inner_html(&format!("<p>SolidCraft failed to start: {e:?}</p><p>A browser with WebGPU or WebGL2 is required.</p>"))
}
@@ -532,3 +704,17 @@ fn main() {
fn main() {
eprintln!("solidcraft-web only runs in the browser: build it with `trunk build --release` in apps/solidcraft-web");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_ctrl_tab_is_left_to_the_browser() {
// Ctrl+Tab and Ctrl+Shift+Tab (the event's key is "Tab" either way) switch browser tabs.
assert!(browser_switches_tabs("Tab", true));
// Tab and Shift+Tab move between the app's fields; Ctrl with other keys stays the app's.
assert!(!browser_switches_tabs("Tab", false));
assert!(!browser_switches_tabs("s", true));
}
}
+367
View File
@@ -0,0 +1,367 @@
//! The 3D mouse in the browser (#1336), through the Gamepad API.
//!
//! Chromium browsers list a 3Dconnexion device itself, with its six axes scaled to -1..1 from the
//! device's ±350. Firefox lists only joysticks and gamepads (a 3D mouse is a HID multi-axis
//! controller), but 3DxWare's virtual joystick carries the same six axes once 3DxWare's profile
//! for Firefox sends them there. Both come in the device's own order and signs, so the shared
//! navigation takes them with 3DxWare's object-mode mapping, as the Linux app does without a
//! profile.
use solidcraft_ui_egui::space_mouse::{Axes, Calibration, FULL_SCALE, calibrated};
/// Logitech's vendor id, which older 3Dconnexion devices carry, and those devices' product ids
/// (usb.ids). Logitech's own gamepads and wheels carry the same vendor id.
const LOGITECH: u16 = 0x046d;
const LOGITECH_3D_MICE: [u16; 11] = [0xc603, 0xc605, 0xc606, 0xc621, 0xc623, 0xc625, 0xc626, 0xc627, 0xc628, 0xc629, 0xc62b];
/// 3Dconnexion's own vendor id. The browser lists only its 3D mice as gamepads: its mice, and the
/// mouse side of its receivers, are not gamepads.
const THREEDCONNEXION: u16 = 0x256f;
/// 3DxWare's virtual joystick.
const VIRTUAL_JOYSTICK: (u16, u16) = (0xbeef, 0x046d);
/// What a gamepad the browser lists is, when it is a 3D mouse. A device is read before the
/// joystick: in a Chromium browser both are listed, and the joystick stays at rest.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum Source {
Device,
VirtualJoystick,
}
/// The vendor and product ids in a gamepad's id: `Name (Vendor: 046d Product: c62b)` in Chromium
/// browsers, `046d-c62b-Name` in Firefox.
fn vendor_product(id: &str) -> Option<(u16, u16)> {
fn hex(digits: &str) -> Option<u16> {
if (1..=4).contains(&digits.len()) && digits.bytes().all(|b| b.is_ascii_hexdigit()) { u16::from_str_radix(digits, 16).ok() } else { None }
}
if let Some((_, ids)) = id.rsplit_once("Vendor: ") {
let (vendor, rest) = ids.split_once(" Product: ")?;
let product = rest.split(|c: char| !c.is_ascii_hexdigit()).next()?;
return Some((hex(vendor)?, hex(product)?));
}
let mut parts = id.splitn(3, '-');
let (vendor, product, _name) = (parts.next()?, parts.next()?, parts.next()?);
Some((hex(vendor)?, hex(product)?))
}
/// Whether a gamepad is a 3D mouse, from the id the browser gives it.
pub fn source(id: &str) -> Option<Source> {
match vendor_product(id)? {
VIRTUAL_JOYSTICK => Some(Source::VirtualJoystick),
(THREEDCONNEXION, _) => Some(Source::Device),
(LOGITECH, product) if LOGITECH_3D_MICE.contains(&product) => Some(Source::Device),
_ => None,
}
}
/// The six axes in the device's raw units, from a gamepad's axes (-1..1). Fewer than six: not a
/// 3D mouse. A value that isn't a number reads as rest.
pub fn axes(values: &[f64]) -> Option<Axes> {
let raw = |i: usize| values.get(i).map(|&v| if v.is_finite() { (v.clamp(-1.0, 1.0) * f64::from(FULL_SCALE)).round() as i16 } else { 0 });
Some(Axes { translation: [raw(0)?, raw(1)?, raw(2)?], rotation: [raw(3)?, raw(4)?, raw(5)?] })
}
/// What a calibration of the gamepad with this id is saved under: `web` and its vendor and
/// product ids.
pub fn device_key(id: &str) -> Option<String> {
vendor_product(id).map(|(vendor, product)| format!("web {vendor:04x}:{product:04x}"))
}
/// The 3D mouse to read among the ones listed: the first device, else the first joystick.
pub fn choose<T>(found: impl IntoIterator<Item = (Source, T)>) -> Option<T> {
found.into_iter().min_by_key(|(source, _)| *source).map(|(_, reading)| reading)
}
/// Whether the cap is moving as the next frame will see it: through the device's calibration
/// (as of the last frame) when it has one. The idle page wakes for a short-range device's push,
/// and not for a device resting off zero; the wheel filter goes by the same.
pub fn moving(axes: Axes, calibration: Option<Calibration>) -> bool {
calibrated(axes, calibration).moving()
}
/// The calibration as of the last frame, and the device it was for.
pub type Cached = (Option<String>, Option<Calibration>);
/// Whether the cap of `device` is moving, judged with the calibration kept from the last frame:
/// `None` when that was for another device, so another's calibration never judges this one. The
/// waker then lets a frame look, which brings the cache up to date.
pub fn moving_as_cached(device: &Option<String>, axes: Axes, cached: &Cached) -> Option<bool> {
(*device == cached.0).then(|| moving(axes, cached.1))
}
/// Whether the idle page's timer redraws: when the cap is moving as the last frame's calibration
/// judges it, when the device read isn't the one that calibration was for (a frame then judges
/// it with its own), and once when the device the last frame read is gone, so the page shows
/// that. With no device, and none last frame, it stays quiet.
pub fn wakes(reading: Option<(Option<String>, Axes)>, cached: &Cached) -> bool {
match reading {
Some((device, axes)) => moving_as_cached(&device, axes, cached).unwrap_or(true),
None => cached.0.is_some(),
}
}
#[cfg(target_arch = "wasm32")]
pub use browser::SpaceMouse;
#[cfg(target_arch = "wasm32")]
mod browser {
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use solidcraft_ui_egui::SolveApp;
use solidcraft_ui_egui::space_mouse::{Axes, DriverMapping, calibration, navigate_frame};
use wasm_bindgen::JsCast as _;
use wasm_bindgen::closure::Closure;
use solidcraft_ui_egui::space_mouse::WheelGuard;
use super::{Cached, axes, choose, device_key, moving_as_cached, source, wakes};
/// How often the page looks for the cap starting to move while nothing else redraws.
const WAKE_MS: i32 = 50;
/// The 3D mouse the browser lists, if any: its calibration key and its axes.
fn read() -> Option<(Option<String>, Axes)> {
let pads = web_sys::window()?.navigator().get_gamepads().ok()?;
choose(pads.iter().filter_map(|pad| {
let pad = pad.dyn_into::<web_sys::Gamepad>().ok().filter(web_sys::Gamepad::connected)?;
let source = source(&pad.id())?;
let values: Vec<f64> = pad.axes().iter().map(|v| v.as_f64().unwrap_or(f64::NAN)).collect();
Some((source, (device_key(&pad.id()), axes(&values)?)))
}))
}
/// Reads the 3D mouse once per frame and moves the view with it.
#[derive(Default)]
pub struct SpaceMouse {
axes: Axes,
/// The 3D mouse being read, as `SolveApp::space_mouse` keeps it.
device: Option<String>,
/// The previous frame's time and the step since it (seconds).
last: Option<f64>,
seconds: f64,
wheel: WheelGuard,
/// Listening for gamepads (from the first frame with navigation on).
listening: bool,
/// The timer that wakes the app when the cap starts moving runs (from when a 3D mouse
/// is first listed).
waking: bool,
/// Navigation is on (the waker reads it).
enabled: Rc<Cell<bool>>,
/// The calibration as of the last frame and its device (the waker and the wheel filter read it).
cached: Rc<RefCell<Cached>>,
}
impl SpaceMouse {
/// At the start of a frame: reads the cap and drops 3DxWare's emulated wheel from the
/// frame's input, before the app sees it.
pub fn raw_input_hook(&mut self, ctx: &egui::Context, enabled: bool, raw: &mut egui::RawInput) {
let now = raw.time.unwrap_or_else(|| js_sys::Date::now() / 1000.0);
self.seconds = self.last.map_or(0.0, |last| now - last);
self.last = Some(now);
self.enabled.set(enabled);
self.axes = Axes::default();
if !enabled {
return;
}
if !self.listening {
self.listening = true;
listen(ctx);
}
let reading = read();
if reading.is_some() && !self.waking {
self.waking = wake(ctx, &self.enabled, &self.cached);
}
// As read: calibration needs the readings at rest too, and `navigate_frame` leaves a
// cap resting inside the dead zone alone.
(self.device, self.axes) = reading.unwrap_or_default();
let moving = moving_as_cached(&self.device, self.axes, &self.cached.borrow()).unwrap_or_else(|| self.axes.moving());
self.wheel.filter(moving, now, &mut raw.events);
}
pub fn logic(&self, app: &mut SolveApp, ctx: &egui::Context) {
app.space_mouse.device.clone_from(&self.device);
*self.cached.borrow_mut() = (self.device.clone(), calibration(app));
navigate_frame(app, ctx, DriverMapping::object_mode(), self.axes, self.seconds);
}
}
/// A 3D mouse is listed only once it has been moved on the page: redraw then, so the frame
/// finds it without waiting for the pointer.
fn listen(ctx: &egui::Context) {
let Some(window) = web_sys::window() else { return };
let ctx = ctx.clone();
let on_connect = Closure::<dyn FnMut()>::new(move || ctx.request_repaint());
let _ = window.add_event_listener_with_callback("gamepadconnected", on_connect.as_ref().unchecked_ref());
// The listener lives as long as the page.
on_connect.forget();
}
/// The browser sends no event when a gamepad moves, so a timer looks while the app is idle and
/// redraws when the cap starts moving; the frames that follow keep going on their own.
fn wake(ctx: &egui::Context, enabled: &Rc<Cell<bool>>, cached: &Rc<RefCell<Cached>>) -> bool {
let Some(window) = web_sys::window() else { return false };
let (ctx, enabled, cached) = (ctx.clone(), Rc::clone(enabled), Rc::clone(cached));
let tick = Closure::<dyn FnMut()>::new(move || {
if enabled.get() && wakes(read(), &cached.borrow()) {
ctx.request_repaint();
}
});
let started = window.set_interval_with_callback_and_timeout_and_arguments_0(tick.as_ref().unchecked_ref(), WAKE_MS).is_ok();
// The timer lives as long as the page.
tick.forget();
started
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recognises_3d_mice_in_each_browsers_ids() {
// As Edge and Firefox list them (Windows 11, SpaceMouse Pro, 3DxWare 10).
assert_eq!(source("SpaceMouse Pro (Vendor: 046d Product: c62b)"), Some(Source::Device));
assert_eq!(source("Unknown Gamepad (Vendor: beef Product: 046d)"), Some(Source::VirtualJoystick));
assert_eq!(source("beef-046d-Unknown Gamepad"), Some(Source::VirtualJoystick));
// 3Dconnexion's own vendor id, any product; the older devices under Logitech's.
assert_eq!(source("SpaceMouse Compact (Vendor: 256f Product: c635)"), Some(Source::Device));
assert_eq!(source("256f-c652-3Dconnexion Universal Receiver"), Some(Source::Device));
assert_eq!(source("46d-c626-SpaceNavigator"), Some(Source::Device));
assert_eq!(source("SpaceNavigator (STANDARD GAMEPAD Vendor: 046d Product: c626)"), Some(Source::Device));
for product in LOGITECH_3D_MICE {
assert_eq!(source(&format!("x (Vendor: 046d Product: {product:04x})")), Some(Source::Device));
assert_eq!(source(&format!("046d-{product:04X}-x")), Some(Source::Device));
}
}
#[test]
fn a_calibration_is_saved_under_the_ids_whichever_browser_lists_it() {
assert_eq!(device_key("SpaceMouse Pro (Vendor: 046d Product: c62b)").as_deref(), Some("web 046d:c62b"));
assert_eq!(device_key("46d-c62b-SpaceMouse Pro").as_deref(), Some("web 046d:c62b"));
assert_eq!(device_key("Xbox Wireless Controller"), None);
}
#[test]
fn the_idle_page_wakes_for_motion_as_calibrated_not_as_read() {
let axis =
|source, rest, full| solidcraft_ui_egui::space_mouse::AxisCalibration { source, reversed: false, rest, dead_zone: 0, full: [full; 2] };
// A short-range device: 15 of its 50 is under the raw dead zone, but 105 of 350 calibrated.
let short = Calibration { dead_zone_share: 0.0, kind: Default::default(), axes: std::array::from_fn(|r| axis(r, 0, 50)) };
let push = Axes { translation: [15, 0, 0], rotation: [0; 3] };
assert!(!push.moving() && moving(push, Some(short)));
// A device resting at 100 on every axis: past the raw dead zone, but at rest calibrated.
let offset = Calibration { dead_zone_share: 0.0, kind: Default::default(), axes: std::array::from_fn(|r| axis(r, 100, 350)) };
let resting = Axes { translation: [100; 3], rotation: [100; 3] };
assert!(resting.moving() && !moving(resting, Some(offset)));
// Without a calibration, as read.
assert!(!moving(push, None) && moving(resting, None));
// Kept from the last frame: only for the device it was for.
let (a, b) = (Some("web 256f:c63a".to_string()), Some("web 1234:5678".to_string()));
assert_eq!(moving_as_cached(&b, push, &(b.clone(), Some(short))), Some(true));
assert_eq!(moving_as_cached(&b, push, &(a.clone(), Some(offset))), None, "A's calibration doesn't judge B");
assert_eq!(moving_as_cached(&b, push, &(None, None)), None);
assert_eq!(moving_as_cached(&a, resting, &(a.clone(), None)), Some(true), "A was read without a calibration");
}
#[test]
fn the_idle_page_wakes_once_for_a_device_change_and_stays_quiet_without_one() {
let (a, b) = (Some("web 256f:c63a".to_string()), Some("web 1234:5678".to_string()));
let still = Axes::default();
let last_frame_read_a: Cached = (a.clone(), None);
assert!(!wakes(Some((a.clone(), still)), &last_frame_read_a), "A, at rest");
assert!(wakes(Some((b.clone(), still)), &last_frame_read_a), "A to B: a frame looks at B");
assert!(wakes(None, &last_frame_read_a), "A to none: a frame shows it's gone");
assert!(!wakes(None, &(None, None)), "none to none: quiet");
}
#[test]
fn gamepads_and_other_logitech_devices_are_not_3d_mice() {
for id in [
"Logitech Dual Action (STANDARD GAMEPAD Vendor: 046d Product: c216)",
"046d-c21d-Logitech Gamepad F310",
"G29 Driving Force Racing Wheel (Vendor: 046d Product: c24f)",
"046d-c215-Logitech Extreme 3D",
"NuLOOQ navigator (Vendor: 046d Product: c640)",
"Xbox 360 Controller (XInput STANDARD GAMEPAD)",
"45e-28e-Xbox 360 Wired Controller",
"beef-046e-Unknown Gamepad",
"beee-046d-Unknown Gamepad",
"",
"-",
"--",
"256f-",
"256f-c635",
"Vendor: 256f",
"Vendor: 256f Product: ",
"x (Vendor: 256fff Product: c635)",
"x (Vendor: zz6f Product: c635)",
"12345-c635-x",
"256f--x",
"SpaceMouse Pro",
] {
assert_eq!(source(id), None, "{id:?}");
}
}
#[test]
fn ids_never_panic() {
// Hostile and odd ids: multi-byte text around the markers, markers repeated or cut short.
for id in [
"Vendor: é Product: ü",
"Vendor: Vendor: 256f Product: c635)",
"é-ü-ö",
"Ⅰ (Vendor: 256f Product: c6Ⅰ)",
"\u{0}-\u{0}-\u{0}",
"Vendor: 256f Product: c635 Vendor: ",
] {
let _ = source(id);
}
let mut seed = 0x2545_f491_4f6c_dd1d_u64;
let alphabet: Vec<char> = "0123456789abcdefABCDEF- ():VendorProductéⅠ\u{0}".chars().collect();
for _ in 0..20_000 {
let len = (seed % 40) as usize;
let id: String = (0..len)
.map(|_| {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
alphabet[(seed % alphabet.len() as u64) as usize]
})
.collect();
if let Some(Source::Device) = source(&id) {
let (vendor, product) = vendor_product(&id).expect("recognised ids have both");
assert!(vendor == THREEDCONNEXION || (vendor == LOGITECH && LOGITECH_3D_MICE.contains(&product)), "{id:?}");
}
}
}
#[test]
fn gamepad_axes_are_the_devices_raw_units() {
// Full deflection is the desktop's 350, and the browser's scale keeps the device's steps.
let axes = axes(&[1.0, -1.0, 0.5, -0.5, 2.0 / 700.0, 0.0]).expect("six axes");
assert_eq!(axes, Axes { translation: [350, -350, 175], rotation: [-175, 1, 0] });
for raw in -350_i16..=350 {
let value = f64::from(raw) / 350.0;
assert_eq!(super::axes(&[value; 6]).map(|a| a.translation[0]), Some(raw));
}
// Past full deflection is full deflection; anything that isn't a number is rest.
let hostile = super::axes(&[f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 1e300, -1e300, f64::MIN_POSITIVE]).expect("six axes");
assert_eq!(hostile, Axes { translation: [0, 0, 0], rotation: [350, -350, 0] });
// A gamepad with fewer than six axes is not a 3D mouse; extra axes are ignored.
assert_eq!(super::axes(&[1.0; 5]), None);
assert_eq!(super::axes(&[]), None);
assert_eq!(super::axes(&[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0]), Some(Axes::default()));
}
#[test]
fn a_device_is_read_before_the_virtual_joystick() {
let moving = Axes { translation: [100, 0, 0], rotation: [0; 3] };
let other = Axes { translation: [0, 200, 0], rotation: [0; 3] };
assert_eq!(choose([(Source::VirtualJoystick, other), (Source::Device, moving)]), Some(moving));
assert_eq!(choose([(Source::Device, moving), (Source::VirtualJoystick, other)]), Some(moving));
assert_eq!(choose([(Source::VirtualJoystick, other)]), Some(other));
// Two devices: the first listed.
assert_eq!(choose([(Source::Device, moving), (Source::Device, other)]), Some(moving));
assert_eq!(choose::<Axes>([]), None);
}
}
+12 -4
View File
@@ -13,8 +13,10 @@
// unchanged, the kept build starts; when a new build is deployed, it is downloaded first
// (waiting at most UPDATE_TIMEOUT_MS; the download goes on in the background after that) and
// started. When the site has no `build.json` (the app was removed), launches go to the network,
// the kept build answering when that fails. Each page then gets its files from the build it
// started (remembered in PAGES), never from another build with a file of the same name.
// the kept build answering when that fails. So they do when `build.json` is redirected or
// refused (a sign-in in front of the app): the browser then shows the sign-in. Each page then
// gets its files from the build it started (remembered in PAGES), never from another build
// with a file of the same name.
//
// A cache is only deleted when nothing uses it: a download holds its lock (Web Locks, shared,
// in any tab or worker; the browser releases it when the download ends or its worker stops), and
@@ -158,9 +160,14 @@ function within(ms, promise) {
/** No app is deployed here any more (the server says `build.json` doesn't exist). */
const GONE = "gone";
/** The deployed build's version and files, GONE, or null (unknown: offline, failing, invalid). */
/** The site wants a sign-in first (`build.json` is redirected, often to another origin, or refused). */
const SIGN_IN = "sign-in";
/** The deployed build's version and files, GONE, SIGN_IN, or null (unknown: offline, failing, invalid). */
async function deployed() {
const response = await fetch(url("build.json"), { cache: "no-cache" });
// `manual`: a redirect to another origin would only fail (CORS), looking like being offline.
const response = await fetch(url("build.json"), { cache: "no-cache", redirect: "manual" });
if (response.type === "opaqueredirect" || response.status === 401 || response.status === 403) return SIGN_IN;
if (response.status === 404 || response.status === 410) return GONE;
const build = response.ok ? await response.json() : null;
const valid = build && typeof build.version === "string" && Array.isArray(build.files);
@@ -182,6 +189,7 @@ async function launch(event) {
if (!kept) return [await fetch(event.request), NETWORK]; // Nothing kept yet (the first visit).
extend(event, sweep());
const build = await within(NAVIGATE_TIMEOUT_MS, deployed());
if (build === SIGN_IN) return [await fetch(event.request), NETWORK];
if (build === GONE) {
const page = await fetch(event.request).catch(() => null);
return page && page.ok ? [page, NETWORK] : [kept, CACHE];
+14 -2
View File
@@ -23,6 +23,7 @@ neg_cmp_op_on_partial_ord = "allow"
[dependencies]
solidcraft-engine = { workspace = true }
solidcraft-ui-egui = { workspace = true }
solidcraft-space-mouse = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links"] }
egui = { workspace = true }
serde_json = { workspace = true }
@@ -40,7 +41,7 @@ rustls = { version = "0.23", default-features = false, features = ["ring", "std"
# Keychain on macOS, Credential Manager on Windows, the Secret Service (pure-Rust D-Bus) elsewhere.
keyring = { version = "3.6", default-features = false, features = ["apple-native", "windows-native", "async-secret-service", "async-io", "crypto-rust"] }
[target.'cfg(any(target_os = "windows", target_os = "linux"))'.dependencies]
[target.'cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))'.dependencies]
quick-xml = { workspace = true }
[target.'cfg(target_os = "windows")'.dependencies]
@@ -49,8 +50,13 @@ hidapi = { version = "2.6.7", default-features = false, features = ["windows-nat
raw-window-handle = "0.6"
# Native crash capture (src/native_crash.rs): the exception filter, the faulting module, a minidump.
# The window's resize border and DPI size (src/native_frame.rs): the window procedure, hit test, DPI.
# Finding 3D mice through Plug and Play without asking other devices anything (src/space_mouse/windows_hid.rs).
windows-sys = { version = "0.61", features = [
"Win32_Devices_DeviceAndDriverInstallation",
"Win32_Devices_HumanInterfaceDevice",
"Win32_Devices_Properties",
"Win32_Foundation",
"Win32_Graphics_Gdi",
"Win32_Security",
"Win32_Storage_FileSystem",
"Win32_System_Diagnostics_Debug",
@@ -64,10 +70,16 @@ windows-sys = { version = "0.61", features = [
] }
[target.'cfg(target_os = "macos")'.dependencies]
muda = { version = "0.21.2", default-features = false }
# The 3D mouse through the 3Dconnexion driver, which keeps the device to itself (src/space_mouse.rs).
solidcraft-3dx-client = { workspace = true }
# The 3D mouse (src/space_mouse.rs), in the shared-device open mode so that a running 3DxWare
# driver does not shut the app out.
hidapi = { version = "2.6.7", default-features = false, features = ["macos-shared-device"] }
# Files opened from Finder and the Dock (src/open_documents.rs); all already come with eframe and rfd.
objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSNotification", "NSOperation", "NSString", "NSURL"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSNotification", "NSOperation", "NSProcessInfo", "NSString", "NSURL"] }
block2 = "0.6"
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
+453 -23
View File
@@ -3,13 +3,15 @@
use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc::{Receiver, Sender, channel};
use std::time::Duration;
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::mpsc::{Receiver, Sender, TryRecvError, channel};
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
use std::time::{Duration, Instant};
use serde_json::{Value, json};
use solidcraft_engine::MAX_LINE;
use solidcraft_engine::control_auth;
use solidcraft_engine::control_pending;
use solidcraft_ui_egui::ControlRequest;
/// Connections served at once, so a flood must not be able to spawn threads without end.
@@ -18,7 +20,7 @@ const MAX_CONNECTIONS: usize = 16;
const MAX_BAD_LINES: usize = 8;
static OPEN: AtomicUsize = AtomicUsize::new(0);
/// The token files this launch wrote (its `--control` port, the agent chat's endpoint), removed
/// The token files this launch wrote (its control port, the agent chat's endpoint), removed
/// when the app ends.
static TOKEN_FILES: std::sync::Mutex<Vec<std::path::PathBuf>> = std::sync::Mutex::new(Vec::new());
@@ -36,14 +38,42 @@ pub fn remove_token_file() {
let _ = std::fs::remove_file(p);
}
}
if let Some(port) = APP_PORT.lock().ok().and_then(|p| *p) {
control_auth::remove_current_port(port);
}
}
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
/// The port the app's own channel listens on, for removing the discovery file at exit.
static APP_PORT: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
pub fn start(listen_on: control_auth::Listen, ctx: egui::Context) -> Receiver<ControlRequest> {
let (tx, rx) = channel::<ControlRequest>();
// Every connection must show the token before anything runs (docs/control-protocol.md).
match control_auth::env_token().or_else(new_token) {
Some(token) => {
listen(port, token, tx, ctx, false);
let listener = match listen_on {
control_auth::Listen::Exact(port) => TcpListener::bind(("127.0.0.1", port)),
control_auth::Listen::Default => control_auth::bind_with_fallback(control_auth::DEFAULT_PORT, control_auth::PORT_TRIES),
};
match listener {
Ok(l) => {
if let Some(port) = listen_on_listener(l, token, tx, ctx, false) {
if matches!(listen_on, control_auth::Listen::Default) {
eprintln!("solidcraft: control channel on the default port 127.0.0.1:{port} (Preferences > Assistant turns it off)");
}
// The discovery file: where `solidcraft-cli mcp` finds this app with no arguments.
match control_auth::write_current_port(port) {
Ok(_) => {
if let Ok(mut p) = APP_PORT.lock() {
*p = Some(port);
}
}
Err(e) => eprintln!("solidcraft: could not write the control discovery file ({e})"),
}
}
}
Err(e) => eprintln!("solidcraft: control server failed to bind 127.0.0.1: {e}"),
}
}
None => eprintln!("solidcraft: control server not started: no random token available"),
}
@@ -59,20 +89,20 @@ pub fn listen_agent(tx: Sender<ControlRequest>, ctx: egui::Context) -> Option<u1
eprintln!("solidcraft: agent chat endpoint not started: no random token available");
return None;
};
listen(0, token, tx, ctx, true)
}
/// Listen on `port` (0: a free port the system picks) with `token`; the port it listens on.
/// `patient`: every request may wait as long as a batch (a person decides on it).
fn listen(port: u16, token: String, tx: Sender<ControlRequest>, ctx: egui::Context, patient: bool) -> Option<u16> {
let listener = match TcpListener::bind(("127.0.0.1", port)) {
let listener = match TcpListener::bind(("127.0.0.1", 0)) {
Ok(l) => l,
Err(e) => {
eprintln!("solidcraft: control server failed to bind 127.0.0.1:{port}: {e}");
eprintln!("solidcraft: control server failed to bind 127.0.0.1: {e}");
return None;
}
};
let port = listener.local_addr().map(|a| a.port()).unwrap_or(port);
listen_on_listener(listener, token, tx, ctx, true)
}
/// Serve `listener` (loopback) with `token`; the port it listens on.
/// `patient`: every request may wait as long as a batch (a person decides on it).
fn listen_on_listener(listener: TcpListener, token: String, tx: Sender<ControlRequest>, ctx: egui::Context, patient: bool) -> Option<u16> {
let port = listener.local_addr().ok()?.port();
match control_auth::token_path(port) {
Some(path) => match control_auth::write_token_file(&path, &token) {
Ok(()) => {
@@ -160,12 +190,192 @@ fn refuse(out: &mut TcpStream, reader: &mut BufReader<TcpStream>) {
let _ = std::io::copy(&mut reader.by_ref().take(64 * 1024), &mut std::io::sink());
}
/// How long a request waits for the app's reply: most requests, and a batch (or every request,
/// on a patient connection).
#[derive(Clone, Copy)]
struct Waits {
short: Duration,
long: Duration,
}
const WAITS: Waits = Waits { short: Duration::from_secs(60), long: Duration::from_secs(3600) };
/// A request that timed out but is still running in the app (#1618): its id and method, and where
/// its reply will land.
struct Pending {
id: Value,
method: String,
/// The request in messages ("`engine.execute` (solid.fillet)").
what: String,
reply: Receiver<Value>,
}
/// Every request the app has been sent and not answered yet, from every connection, for
/// `control.running` (#3654). Each connection's own thread answers that, so it never waits behind
/// a running request.
struct Running {
list: Mutex<Vec<InFlight>>,
connections: AtomicU64,
keys: AtomicU64,
}
/// What `control.running` says about a request: never its parameters beyond the command id.
struct InFlight {
key: u64,
connection: u64,
id: Value,
method: String,
command: Option<&'static str>,
steps: Option<usize>,
since: Instant,
started: Arc<AtomicBool>,
}
/// The app's requests, from both of its listeners.
static RUNNING: Running = Running::new();
impl Running {
const fn new() -> Self {
Running { list: Mutex::new(Vec::new()), connections: AtomicU64::new(0), keys: AtomicU64::new(0) }
}
fn lock(&self) -> MutexGuard<'_, Vec<InFlight>> {
self.list.lock().unwrap_or_else(PoisonError::into_inner)
}
/// List `req`, sent on `connection`, until the returned ticket is dropped.
fn add(&'static self, connection: u64, id: &Value, req: &ControlRequest) -> Ticket {
let key = self.keys.fetch_add(1, Ordering::Relaxed);
// Ids and methods come from the client: only short ones are repeated to the others.
let id = match id {
Value::Number(_) => id.clone(),
Value::String(s) if s.len() <= 64 => id.clone(),
_ => Value::Null,
};
let p = &req.params;
let command = match req.method.as_str() {
"engine.execute" | "command" => p.get("command").and_then(Value::as_str).and_then(solidcraft_engine::find_command).map(|c| c.id),
_ => None,
};
let steps = match req.method.as_str() {
"engine.batch" | "engine.script" => p.get("commands").and_then(Value::as_array).map(Vec::len),
_ => None,
};
let method = req.method.chars().take(64).collect();
let started = req.started.clone();
self.lock().push(InFlight { key, connection, id, method, command, steps, since: Instant::now(), started });
Ticket { running: self, key }
}
/// The `control.running` result, as `asker`'s connection sees it.
fn result(&self, asker: u64) -> Value {
let rows: Vec<Value> = self
.lock()
.iter()
.map(|e| {
let mut r = json!({
"connection": e.connection,
"id": e.id,
"method": e.method,
"running_s": (e.since.elapsed().as_secs_f64() * 10.0).round() / 10.0,
"queued": !e.started.load(Ordering::Relaxed),
});
if let Some(c) = e.command {
r["command"] = json!(c);
}
if let Some(n) = e.steps {
r["steps"] = json!(n);
}
if e.connection == asker {
r["self"] = json!(true);
}
r
})
.collect();
json!({"running": rows})
}
}
/// A listed request; dropped when its reply arrives.
struct Ticket {
running: &'static Running,
key: u64,
}
impl Drop for Ticket {
fn drop(&mut self) {
let key = self.key;
self.running.lock().retain(|e| e.key != key);
}
}
/// The reply to a timed-out request, received on a thread of its own so the request stays listed
/// until the app answers, also once its connection has closed (#3654).
fn watch(reply: Receiver<Value>, ticket: Ticket) -> Receiver<Value> {
let (late_tx, late_rx) = channel();
// A small thread per timed-out request (each connection has at most one). If none can be
// made, the reply is lost and `control.pending` says the app ended the request without one.
// The reply is passed on before the request leaves the list: once `control.running` no longer
// shows it, its connection's `control.pending` has it.
let _ = std::thread::Builder::new().stack_size(64 * 1024).spawn(move || {
if let Ok(v) = reply.recv() {
let _ = late_tx.send(v);
}
drop(late_tx);
drop(ticket);
});
late_rx
}
/// The reply text for a request that outlasts its wait. A request that only reads changes
/// nothing when it finishes; an export or a save still writes its file, and any other request
/// still applies, as it was not cancelled.
fn timeout_message(what: &str, effect: control_pending::Effect) -> String {
match effect {
control_pending::Effect::ReadsOnly => {
format!("timeout: {what} is still running in the app ({}); ask `control.pending` for its answer", effect.when_done())
}
control_pending::Effect::WritesFile => format!(
"timeout: {what} is still running in the app and will still write the file when it finishes (it was not cancelled: \
don't send it again; the design does not change); ask `control.pending` for its result"
),
control_pending::Effect::Changes => format!(
"timeout: {what} is still running in the app and will still apply when it finishes (it was not cancelled: don't send it \
again); ask `control.pending` for its result"
),
}
}
/// The reply text for a request refused because an earlier one on the connection still runs.
fn busy_message(p: &Pending, method: &str) -> String {
let method: String = method.chars().take(128).collect();
format!(
"busy: request {} ({}) is still running, so `{method}` was not run; ask `control.pending` for that request's result, then \
send this one again",
p.id, p.what
)
}
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context, patient: bool) {
serve_with(stream, token, tx, ctx, patient, WAITS, &RUNNING);
}
fn serve_with(
stream: TcpStream,
token: &str,
tx: Sender<ControlRequest>,
ctx: egui::Context,
patient: bool,
waits: Waits,
running: &'static Running,
) {
let connection = running.connections.fetch_add(1, Ordering::Relaxed) + 1;
let Ok(read) = stream.try_clone() else { return };
let mut out = stream;
let mut reader = BufReader::new(read);
let mut bad = 0usize;
let mut authenticated = false;
let mut pending: Option<Pending> = None;
loop {
let line = match read_line(&mut reader, MAX_LINE) {
Ok(Some(l)) => String::from_utf8_lossy(&l).into_owned(),
@@ -193,14 +403,52 @@ fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::
let id = msg.get("id").cloned().unwrap_or(Value::Null);
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
let params = msg.get("params").cloned().unwrap_or(json!({}));
// A batch replaces many short requests: the whole of it may take long (#743).
let wait = Duration::from_secs(if patient || method == "engine.batch" { 3600 } else { 60 });
let (req, rrx) = ControlRequest::new(method, params);
if tx.send(req).is_err() {
break;
}
ctx.request_repaint();
let mut r = rrx.recv_timeout(wait).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
// A request that timed out may have finished since (#1618). `control.running` only
// looks, so it leaves that reply where it is.
let late = pending.as_ref().filter(|_| method != "control.running").and_then(|p| match p.reply.try_recv() {
Ok(reply) => Some(reply),
Err(TryRecvError::Disconnected) => Some(json!({"ok": false, "error": "the app ended the request without a reply"})),
Err(TryRecvError::Empty) => None,
});
let mut r = if method == "control.running" {
json!({"ok": true, "result": running.result(connection)})
} else if method == "control.pending" {
match (pending.take(), late) {
(None, _) => json!({"ok": true, "result": {"pending": null}}),
(Some(p), Some(reply)) => json!({"ok": true, "result": {"pending": p.id, "method": p.method, "done": true, "reply": reply}}),
(Some(p), None) => {
let r = json!({"ok": true, "result": {"pending": p.id, "method": p.method, "done": false}});
pending = Some(p);
r
}
}
} else if let Some(p) = pending.as_ref().filter(|_| late.is_none()) {
// Nothing more is queued behind it, to run unseen when it ends.
json!({"ok": false, "busy": true, "error": busy_message(p, &method)})
} else {
if late.is_some() {
// Finished, and the client moved on without asking: it is dropped.
pending = None;
}
// A batch replaces many short requests: the whole of it may take long (#743).
let wait = if patient || method == "engine.batch" { waits.long } else { waits.short };
let what = control_pending::describe(&method, &params);
let effect = control_pending::effect(&method, &params);
let (req, rrx) = ControlRequest::new(method.clone(), params);
let ticket = running.add(connection, &id, &req);
if tx.send(req).is_err() {
break;
}
ctx.request_repaint();
match rrx.recv_timeout(wait) {
Ok(r) => r,
Err(_) => {
let r = json!({"ok": false, "running": true, "error": timeout_message(&what, effect)});
pending = Some(Pending { id: id.clone(), method, what, reply: watch(rrx, ticket) });
r
}
}
};
if let Some(o) = r.as_object_mut() {
o.insert("id".into(), id);
}
@@ -228,6 +476,188 @@ fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::
mod tests {
use super::*;
/// #1618: a request that outlasts its wait says it is still running; the connection keeps its
/// reply for `control.pending`, and refuses other requests as `busy` until it lands, so none
/// is queued to run unseen behind it.
#[test]
fn a_timed_out_request_can_be_asked_for_later() {
let token = "0123456789abcdef0123456789abcdef";
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = channel::<ControlRequest>();
// The slow request is held until the test has seen it time out, so the test does not
// race the clock on a loaded machine.
let (gate_tx, gate_rx) = channel::<()>();
let ran = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
let seen = ran.clone();
std::thread::spawn(move || {
for req in rx {
seen.lock().unwrap().push(req.method.clone());
if req.method == "slow" {
let _ = gate_rx.recv();
}
let _ = req.reply.send(json!({"ok": true, "result": req.method}));
}
});
let waits = Waits { short: Duration::from_millis(100), long: Duration::from_millis(100) };
static RUNNING: Running = Running::new();
std::thread::spawn(move || {
if let Some(stream) = listener.incoming().flatten().next() {
serve_with(stream, token, tx, egui::Context::default(), false, waits, &RUNNING);
}
});
let s = TcpStream::connect(addr).unwrap();
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
let mut out = s.try_clone().unwrap();
let mut lines = BufReader::new(s);
let mut ask = |v: Value| -> Value {
writeln!(out, "{v}").unwrap();
let mut l = String::new();
lines.read_line(&mut l).unwrap();
serde_json::from_str(&l).unwrap()
};
let r = ask(json!({"id": 1, "method": "slow", "token": token}));
assert!(r["ok"] == false && r["running"] == true && r["error"].as_str().is_some_and(|e| e.starts_with("timeout")), "{r}");
let r = ask(json!({"id": 2, "method": "ui.view"}));
assert!(r["busy"] == true && r["id"] == 2, "{r}");
let r = ask(json!({"id": 3, "method": "control.pending"}));
assert_eq!((&r["result"]["pending"], &r["result"]["done"]), (&json!(1), &json!(false)), "{r}");
gate_tx.send(()).unwrap();
let deadline = std::time::Instant::now() + Duration::from_secs(10);
let r = loop {
let r = ask(json!({"id": 4, "method": "control.pending"}));
if r["result"]["done"] == true || std::time::Instant::now() > deadline {
break r;
}
std::thread::sleep(Duration::from_millis(10));
};
assert_eq!(
(&r["result"]["pending"], &r["result"]["done"], &r["result"]["reply"]["result"]),
(&json!(1), &json!(true), &json!("slow")),
"{r}"
);
let r = ask(json!({"id": 5, "method": "control.pending"}));
assert_eq!(r["result"]["pending"], Value::Null, "{r}");
let r = ask(json!({"id": 6, "method": "ui.view"}));
assert_eq!((&r["ok"], &r["result"]), (&json!(true), &json!("ui.view")), "{r}");
assert_eq!(*ran.lock().unwrap(), ["slow", "ui.view"], "the busy request never reached the app");
}
/// #3654: `control.running` answers on every connection, at once, with what any connection
/// has the app running or queued (command ids, never parameters); a timed-out request stays
/// listed until the app answers it, and an idle app lists nothing.
#[test]
fn every_connection_sees_what_the_app_is_running() {
static RUNNING: Running = Running::new();
let token = "0123456789abcdef0123456789abcdef";
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = channel::<ControlRequest>();
// The window: one request at a time, as `drain_control` takes them; the export is held
// until the test lets it go.
let (gate_tx, gate_rx) = channel::<()>();
std::thread::spawn(move || {
for req in rx {
req.started.store(true, Ordering::Relaxed);
if req.method == "engine.execute" {
let _ = gate_rx.recv();
}
let _ = req.reply.send(json!({"ok": true, "result": req.method}));
}
});
let waits = Waits { short: Duration::from_millis(100), long: Duration::from_secs(60) };
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
let tx = tx.clone();
std::thread::spawn(move || serve_with(stream, token, tx, egui::Context::default(), false, waits, &RUNNING));
}
});
let open = || {
let s = TcpStream::connect(addr).unwrap();
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
let mut out = s.try_clone().unwrap();
let mut lines = BufReader::new(s);
move |v: Value| -> Value {
writeln!(out, "{v}").unwrap();
let mut l = String::new();
lines.read_line(&mut l).unwrap();
serde_json::from_str(&l).unwrap()
}
};
let running = |ask: &mut dyn FnMut(Value) -> Value, until: &dyn Fn(&[Value]) -> bool| {
let deadline = Instant::now() + Duration::from_secs(10);
loop {
let r = ask(json!({"id": 0, "method": "control.running", "token": token}));
let rows = r["result"]["running"].as_array().cloned().unwrap_or_default();
if until(&rows) || Instant::now() > deadline {
return (r, rows);
}
std::thread::sleep(Duration::from_millis(10));
}
};
let mut watcher = open();
let (r, rows) = running(&mut watcher, &|_| true);
assert!(r["ok"] == true && rows.is_empty(), "idle: {r}");
let mut export = open();
let path = "/private/part.step";
let params = json!({"command": "file.export", "params": {"path": path}});
let r = export(json!({"id": 7, "method": "engine.execute", "params": params, "token": token}));
assert_eq!(r["running"], true, "{r}");
// A batch from another connection waits behind the export.
let mut batch = open();
let queued = std::thread::spawn(move || {
let steps = json!([{"command": "solid.box"}, {"command": "solid.box"}]);
batch(json!({"id": "b", "method": "engine.batch", "params": {"commands": steps}, "token": token}))
});
// The window may not have taken the export yet, even though it timed out.
let (r, rows) = running(&mut watcher, &|rows| rows.len() == 2 && rows[0]["queued"] == false);
assert_eq!(rows.len(), 2, "{r}");
let (e, b) = (&rows[0], &rows[1]);
assert_eq!(
(&e["id"], &e["method"], &e["command"], &e["queued"]),
(&json!(7), &json!("engine.execute"), &json!("file.export"), &json!(false)),
"{r}"
);
assert!(e["running_s"].as_f64().is_some_and(|s| s >= 0.1), "{r}");
assert_eq!((&b["id"], &b["method"], &b["steps"], &b["queued"]), (&json!("b"), &json!("engine.batch"), &json!(2), &json!(true)), "{r}");
assert_ne!(e["connection"], b["connection"], "{r}");
assert!(rows.iter().all(|row| row.get("self").is_none()), "{r}");
assert!(!r.to_string().contains(path), "parameters stay with their connection: {r}");
// The connection whose request timed out may ask too, and sees its own marked.
let r = export(json!({"id": 8, "method": "control.running"}));
let own: Vec<&Value> = r["result"]["running"].as_array().unwrap().iter().filter(|row| row["self"] == true).collect();
assert_eq!(own.len(), 1, "{r}");
assert_eq!(own[0]["command"], "file.export", "{r}");
gate_tx.send(()).unwrap();
assert_eq!(queued.join().unwrap()["result"], "engine.batch");
let (r, rows) = running(&mut watcher, &|rows| rows.is_empty());
assert!(rows.is_empty(), "done: {r}");
// Asking `control.running` did not take the export's reply from its connection.
let r = export(json!({"id": 9, "method": "control.pending"}));
assert_eq!((&r["result"]["pending"], &r["result"]["done"]), (&json!(7), &json!(true)), "{r}");
}
/// A timed-out request that only reads is not said to "still apply", nor is one that only
/// writes a file (an export, #1989); a change is, by name.
#[test]
fn timeout_text_tells_reads_from_changes() {
let says = |method: &str, p: Value| timeout_message(&control_pending::describe(method, &p), control_pending::effect(method, &p));
let read = says("ui.inspect", json!({}));
assert!(read.starts_with("timeout") && read.contains("only reads") && !read.contains("will still apply"), "{read}");
let read = says("engine.execute", json!({"command": "sketch.inspect"}));
assert!(read.contains("only reads") && read.contains("sketch.inspect"), "{read}");
let change = says("engine.execute", json!({"command": "solid.fillet"}));
assert!(change.starts_with("timeout") && change.contains("will still apply") && change.contains("solid.fillet"), "{change}");
let export = says("engine.execute", json!({"command": "file.export", "params": {"path": "/tmp/wall.step"}}));
assert!(export.contains("will still write the file") && !export.contains("apply") && export.contains("file.export"), "{export}");
let save = says("engine.execute", json!({"command": "file.save"}));
assert!(save.contains("will still write the file") && !save.contains("apply"), "{save}");
let render = says("ui.render", json!({"path": "/tmp/a.png"}));
assert!(render.contains("will still write the file"), "{render}");
}
/// #205: a connection runs nothing until a request carries the launch's token; a wrong or
/// missing token, or garbage, is refused and the connection closed. Once shown, the token
/// need not be repeated on that connection.
+91
View File
@@ -0,0 +1,91 @@
//! The graphics device was lost after the window opened: the driver was reset (on Windows a TDR,
//! "Display driver stopped responding and has recovered") or the GPU went away (#4172). A lost
//! device can't draw again, so the window can't show anything more, not even a message. The
//! painter stops drawing (`egui_wgpu::DeviceLost`); the app then closes, keeping unsaved changes
//! for recovery as a forced quit does, and starts again ([`restart`]), and the new start says what
//! happened ([`note`]). A device lost again soon after such a restart starts the next one on the
//! software renderer, which doesn't depend on the graphics driver.
use crate::startup;
/// Set on the start after a lost device: when the device was lost (seconds since 1970).
pub const LOST_ENV: &str = "SOLIDCRAFT_DEVICE_LOST";
/// A loss this recent (seconds) counts for the start after it: its note, and a second loss
/// within it starts on the software renderer. Inherited by later starts, an older stamp is
/// ignored.
const RECENT_SECS: u64 = 600;
/// Seconds since 1970.
pub fn now_secs() -> u64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs())
}
/// Whether `stamp` ([`LOST_ENV`]) names a loss at most [`RECENT_SECS`] before `now`.
pub fn recent(stamp: Option<&str>, now: u64) -> bool {
stamp.and_then(|s| s.trim().parse::<u64>().ok()).is_some_and(|at| at <= now && now - at <= RECENT_SECS)
}
/// Whether the start after a lost device uses the software renderer: when this launch already
/// followed a recent loss (the driver keeps losing it), or ran on it anyway.
pub fn restart_on_software(after_recent_loss: bool, cpu_renderer: bool) -> bool {
after_recent_loss || cpu_renderer
}
/// What the start after a lost device tells the user, `software` when it runs on the software
/// renderer.
pub fn note(software: bool) -> String {
let mut note = String::from(
"The graphics device was reset (the graphics driver stopped responding), so SolidCraft closed and started again. Unsaved changes were kept for recovery.",
);
if software {
note.push_str(" This start uses software rendering, which doesn't depend on the graphics driver.");
}
note
}
/// Start SolidCraft again with the same arguments, the loss stamped at `lost_at`, on the software
/// renderer when `software`.
pub fn restart(software: bool, lost_at: u64) {
let Ok(exe) = std::env::current_exe() else { return };
let mut command = std::process::Command::new(exe);
command.args(std::env::args_os().skip(1)).env(LOST_ENV, lost_at.to_string());
if software {
command.env(startup::RENDERER_ENV, "cpu");
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
// Not tied to this process: its own process group, no console.
command.creation_flags(0x0000_0200 | 0x0000_0008);
}
if let Err(e) = command.spawn() {
log::error!("graphics: starting again after the device was lost: {e}");
}
}
#[cfg(test)]
mod tests {
/// Only a recent, well-formed stamp counts: a later start that inherited it says nothing.
#[test]
fn only_a_recent_loss_counts() {
let now = 1_000_000;
assert!(super::recent(Some("999990"), now));
assert!(super::recent(Some(" 999400 "), now));
assert!(!super::recent(Some("999399"), now), "older than ten minutes");
assert!(!super::recent(Some("1000001"), now), "from the future");
assert!(!super::recent(Some("soon"), now));
assert!(!super::recent(None, now));
}
/// The first loss starts again on the GPU; a second one soon after, on the software renderer.
#[test]
fn a_second_loss_in_a_row_starts_on_the_software_renderer() {
assert!(!super::restart_on_software(false, false));
assert!(super::restart_on_software(true, false));
assert!(super::restart_on_software(false, true));
assert!(super::note(true).contains("software rendering"));
assert!(!super::note(false).contains("software rendering"));
assert!(super::note(false).contains("kept for recovery"));
}
}
+326 -58
View File
@@ -6,10 +6,11 @@
//! the software renderer; see `startup` and docs/book/src/guide/troubleshooting.md.
//!
//! A `.step`/`.stp` file opens as a new design holding the file's bodies (an Import feature); a
//! `.3mf`/`.stl` file as a new design holding mesh bodies.
//! `.3mf`/`.stl`/`.obj`/`.glb`/`.gltf` file as a new design holding mesh bodies.
//!
//! `--control <port>` (or `SOLIDCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control
//! server: `{"id":1,"method":"engine.execute","params":{"command":"solid.box","params":{…}}}`.
//! Without them the channel still opens, on the default port, unless Preferences turns it off.
//! See `solidcraft_ui_egui::control` and docs/control-protocol.md.
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
@@ -19,42 +20,102 @@
#[path = "../../../assets/icons/app/shared/source/app_icon.rs"]
mod app_icon;
mod control_server;
mod device_lost;
#[cfg(target_os = "windows")]
mod native_crash;
#[cfg(target_os = "windows")]
mod native_frame;
#[cfg(target_os = "macos")]
mod native_menu;
#[cfg(target_os = "macos")]
mod open_documents;
#[cfg(any(target_os = "windows", target_os = "linux"))]
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
mod space_mouse;
mod startup;
#[cfg(any(target_os = "windows", test))]
mod window_fit;
use solidcraft_engine::{Session, io};
use solidcraft_ui_egui::{Services, SolveApp};
/// Windows: put the restored window back inside the monitor's work area if it is out of reach
/// (a gone monitor, another scale, a taskbar edge). Sizes are in points, the work area in pixels.
#[cfg(target_os = "windows")]
fn fit_window_to_work_area(ctx: &egui::Context, frame: &eframe::Frame) {
use window_fit::Area;
let Some((x, y, w, h)) = native_frame::work_area(frame) else { return };
let (outer, inner, scale, maximized) = ctx.input(|i| {
let v = i.viewport();
(v.outer_rect, v.inner_rect, v.native_pixels_per_point, v.maximized.unwrap_or(false) || v.fullscreen.unwrap_or(false))
});
let (Some(outer), Some(inner), Some(scale)) = (outer, inner, scale.filter(|s| s.is_finite() && *s > 0.0)) else { return };
if maximized {
return;
}
let work = Area::new(x as f32 / scale, y as f32 / scale, w as f32 / scale, h as f32 / scale);
let now = Area::new(outer.min.x, outer.min.y, outer.width(), outer.height());
let fitted = window_fit::fit(now, work, (960.0, 600.0));
if fitted != now {
// The frame (title bar, borders) keeps its size; only the inner size follows.
let inner_size = egui::vec2((inner.width() - (now.w - fitted.w)).max(1.0), (inner.height() - (now.h - fitted.h)).max(1.0));
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(inner_size));
ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(egui::pos2(fitted.x, fitted.y)));
}
}
/// `SOLIDCRAFT_PROFILE_STARTUP=1` prints the time since launch at each startup step.
fn mark(step: &str) {
static START: std::sync::OnceLock<(std::time::Instant, bool)> = std::sync::OnceLock::new();
let (start, on) = START.get_or_init(|| (std::time::Instant::now(), solidcraft_engine::geom::env::var_os("PROFILE_STARTUP").is_some()));
if *on {
eprintln!("startup {:>6.0} ms {step}", start.elapsed().as_secs_f64() * 1000.0);
}
}
struct App {
solve: SolveApp,
/// The settings folder whose start marker the first frame clears (`startup::started`).
starting: Option<std::path::PathBuf>,
/// Frames begun; the second one means the first was drawn (startup is over).
frames: u32,
#[cfg(any(target_os = "windows", target_os = "linux"))]
/// Started over the control channel: the window doesn't ask for the foreground.
take_focus_unwanted: bool,
/// The graphics device: lost, it can't draw again (`App::watch_device`, #4172).
gpu: Option<LostWatch>,
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
space_mouse: space_mouse::SpaceMouse,
#[cfg(target_os = "macos")]
native_menu: Option<native_menu::NativeMenu>,
}
impl eframe::App for App {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
if self.frames < 2 {
self.frames += 1;
// Before the window is shown: its title bar must be within reach (window_fit.rs).
#[cfg(target_os = "windows")]
if self.frames == 1 {
fit_window_to_work_area(ctx, _frame);
}
mark(if self.frames == 1 { "first frame begins" } else { "second frame begins (first presented)" });
// A second frame right away, which means the first was presented: the app is up.
ctx.request_repaint();
if self.frames == 2 {
// The window is shown only once its first frame is presented, and Windows then
// keeps a window of a process started from a shortcut or an installer behind the
// others. Ask for the foreground once, now that it is on screen; never again, so
// later frames don't take the focus from whatever the user moved to. Automation
// over the control channel keeps the focus where it is.
if !self.take_focus_unwanted {
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
}
solidcraft_engine::crash::phase(solidcraft_engine::crash::RUNNING);
if let Some(dir) = self.starting.take() {
startup::started(&dir);
}
}
}
self.watch_device();
// The design on screen is the one whose commands a crash report replays.
self.solve.session.crash_journal = solidcraft_engine::crash::enabled();
// The OS closing the window (Alt+F4, Cmd+Q, the window manager): ask about unsaved
@@ -67,8 +128,12 @@ impl eframe::App for App {
for f in if self.solve.file_picker_busy() { Vec::new() } else { open_documents::take() } {
self.solve.open_path_async(&f);
}
#[cfg(target_os = "macos")]
if let Some(menu) = &mut self.native_menu {
menu.tick(&mut self.solve, ctx);
}
self.solve.logic(ctx);
#[cfg(any(target_os = "windows", target_os = "linux"))]
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
if !self.solve.file_picker_busy() {
self.space_mouse.tick(&mut self.solve, ctx);
}
@@ -76,7 +141,17 @@ impl eframe::App for App {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
#[cfg(target_os = "macos")]
if let Some(menu) = &mut self.native_menu {
menu.raw_input_hook(&mut self.solve, ctx, raw);
}
// Before the app's own hook, which adds the control channel's events.
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
self.space_mouse.raw_input_hook(self.solve.preferences.space_mouse_enabled, raw);
// The 3D mouse's buttons, before the app reads the frame: Esc and held modifiers join its input.
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
solidcraft_ui_egui::space_mouse::buttons_hook(&mut self.solve, ctx, self.space_mouse.buttons(), raw);
self.solve.raw_input_hook(raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
@@ -88,52 +163,109 @@ impl eframe::App for App {
fn on_exit(&mut self) {
// The agent chat's agent and everything it started end with the app.
solidcraft_ui_egui::agent_chat::shutdown(&mut self.solve);
// A save still being written ends before the app does.
solidcraft_ui_egui::save_job::tick(&mut self.solve, true);
solidcraft_ui_egui::recovery_ui::close(&mut self.solve);
solidcraft_engine::crash::end_session();
if let Some(lost_at) = self.gpu.as_ref().and_then(|g| g.lost_at) {
device_lost::restart(self.gpu.as_ref().is_some_and(|g| g.restart_on_software), lost_at);
}
// An update asked to restart into the new version or to run its installer.
if let Some((program, args)) = solidcraft_ui_egui::update::take_launch(&mut self.solve)
&& let Err(e) = std::process::Command::new(&program).args(&args).spawn()
{
log::error!("update: starting {}: {e}", program.display());
if let Some((program, args)) = solidcraft_ui_egui::update::take_launch(&mut self.solve) {
let mut command = std::process::Command::new(&program);
command.args(&args);
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
// Not tied to this process: its own process group, and no console (the PowerShell
// that waits for an installer is hidden; the app itself is detached).
let hidden_shell = program.file_stem().is_some_and(|n| n.eq_ignore_ascii_case("powershell"));
command.creation_flags(0x0000_0200 | if hidden_shell { 0x0800_0000 } else { 0x0000_0008 });
}
if let Err(e) = command.spawn() {
log::error!("update: starting {}: {e}", program.display());
}
}
}
}
/// The window's graphics device, watched for its loss.
struct LostWatch {
lost: eframe::egui_wgpu::DeviceLost,
/// Where the next start runs once this device is lost ([`device_lost::restart_on_software`]).
restart_on_software: bool,
/// When the loss was seen: the app is closing to start again.
lost_at: Option<u64>,
}
impl App {
/// The graphics device was lost (#4172): the window can't draw again, so close, keeping
/// unsaved changes for recovery as a forced quit does (the window can't ask about them), and
/// start again once closed ([`device_lost::restart`]).
fn watch_device(&mut self) {
let Some(gpu) = self.gpu.as_mut().filter(|g| g.lost_at.is_none() && g.lost.is_lost()) else { return };
let reason = gpu.lost.reason().unwrap_or_default();
log::error!("graphics: the device was lost ({reason}); closing to start again");
eprintln!("SolidCraft graphics: the device was lost ({reason}); starting again");
solidcraft_engine::crash::phase(&format!("graphics device lost: {reason}"));
gpu.lost_at = Some(device_lost::now_secs());
self.solve.quit_requested = true;
}
}
/// Linux file dialogs match patterns by case, so each extension goes in as lower and upper
/// case, plus FreeCAD's own mixed casing.
fn case_variants(exts: &[&str]) -> Vec<String> {
let mut all: Vec<String> = Vec::new();
for e in exts {
let mut forms = vec![e.to_ascii_lowercase(), e.to_ascii_uppercase()];
if e.eq_ignore_ascii_case("fcstd") {
forms.push("FCStd".into());
}
for v in forms {
if !all.contains(&v) {
all.push(v);
}
}
}
all
}
fn services(cc: &eframe::CreationContext<'_>) -> Services {
let parent = rfd::AsyncFileDialog::new().set_parent(cc);
let open_parent = parent.clone();
let program_parent = parent.clone();
let file_parent = parent.clone();
Services {
pick_open: Some(Box::new(move || {
let future = open_parent
.clone()
.add_filter(
"Designs, STEP, IGES and meshes",
&[
io::DESIGN_EXT,
io::LEGACY_DESIGN_EXT,
"json",
"step",
"stp",
"STEP",
"STP",
"igs",
"iges",
"IGS",
"IGES",
"3mf",
"3MF",
"stl",
"STL",
],
)
.add_filter("SolidCraft design (container or JSON)", &[io::DESIGN_EXT, io::LEGACY_DESIGN_EXT, "json"])
.add_filter("STEP", &["step", "stp", "STEP", "STP"])
.add_filter("IGES", &["igs", "iges", "IGS", "IGES"])
.add_filter("Mesh (3MF, STL)", &["3mf", "3MF", "stl", "STL"])
.add_filter("Sketch drawings (DXF, SVG)", &["dxf", "DXF", "svg", "SVG"])
.add_filter("All files", &["*"])
.pick_file();
pick_open: Some(Box::new(move |first: &[&str]| {
// The file type the command reads comes first, so the picker opens on it (#1801).
let mut dialog = open_parent.clone();
if !first.is_empty() {
let label = first.iter().map(|e| e.to_uppercase()).collect::<Vec<_>>().join(", ");
dialog = dialog.add_filter(label, &case_variants(first));
}
// The groups come from the format registry, so a new format joins this dialog by itself.
dialog = dialog.add_filter("Designs, CAD files and meshes", &case_variants(&io::formats::open_extensions(true)));
for (label, exts) in io::formats::import_filters(true) {
dialog = dialog.add_filter(label, &case_variants(&exts));
}
let future = dialog.add_filter("All files", &["*"]).pick_file();
Box::pin(async move { future.await.map(|p| p.path().to_string_lossy().into_owned()) })
})),
// One kind of file (the agent chat's attachments): only the types it names, as given and in
// lower and upper case (Linux dialogs match case; macOS allows just the listed types).
pick_file: Some(Box::new(move |label: &str, exts: &[&str]| {
let mut all: Vec<String> = Vec::new();
for e in exts {
for v in [e.to_string(), e.to_ascii_lowercase(), e.to_ascii_uppercase()] {
if !all.contains(&v) {
all.push(v);
}
}
}
let all: Vec<&str> = all.iter().map(String::as_str).collect();
// Any other file attaches by reference, so the list is not the only choice.
let future = file_parent.clone().add_filter(label, &all).add_filter("All files", &["*"]).pick_file();
Box::pin(async move { future.await.map(|p| p.path().to_string_lossy().into_owned()) })
})),
pick_program: Some(Box::new(move || {
@@ -157,10 +289,15 @@ fn services(cc: &eframe::CreationContext<'_>) -> Services {
Box::pin(async move { future.await.map(|p| p.path().to_string_lossy().into_owned()) })
})),
graphics_path: graphics_path().map(|p| p.to_string_lossy().to_string()),
window_system: window_system_now(),
http_get: Some(std::sync::Arc::new(http_get)),
http_download: Some(std::sync::Arc::new(http_download)),
// Already an installed app: only the browser build offers to install.
install_app: None,
can_restore: None,
restore_file: None,
download_design: None,
browser_files: None,
agent_http: Some(std::sync::Arc::new(agent_http)),
secrets: Some(std::sync::Arc::new(Keychain)),
show_in_folder: Some(Box::new(|path| {
@@ -378,6 +515,18 @@ fn backend_preference() -> Option<String> {
graphics().and_then(|v| v.get("backend").and_then(serde_json::Value::as_str).map(str::to_string))
}
/// The viewport scene's sample count: the app's anti-aliasing (the window's pass is 1x), clamped
/// by the backend's policy (OpenGL renders 1x, see `effective_msaa_samples`). A count the device
/// cannot render with falls back to the supported default.
fn scene_samples(backend: eframe::wgpu::Backend, samples: u32, supported: &[u32]) -> u32 {
let requested = eframe::egui_wgpu::effective_msaa_samples(backend, samples.max(1));
if requested <= 1 || supported.is_empty() || supported.contains(&requested) {
requested
} else {
supported.iter().copied().filter(|&n| n <= solidcraft_ui_egui::prefs::Prefs::default().msaa).max().unwrap_or(1)
}
}
/// Anti-aliasing samples from Preferences (4 when unset).
fn msaa() -> u16 {
graphics()
@@ -392,6 +541,10 @@ fn msaa() -> u16 {
const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
const DEPTH_BITS: u8 = 24;
/// A 32-bit float depth buffer, drawn with reversed depth (1 near, 0 far) so its precision follows
/// the distance from the eye (#1434). Only where every adapter offers it with a stencil
/// ([`startup::float_depth_everywhere`]); else [`DEPTH_BITS`]. Not in safe mode.
const FLOAT_DEPTH_BITS: u8 = 32;
/// The section cap marks where it shows in the stencil.
const STENCIL_BITS: u8 = 8;
const MSAA: u16 = 4;
@@ -420,6 +573,9 @@ fn app_viewport() -> Result<egui::ViewportBuilder, eframe::Error> {
const APP_ID: &str = solidcraft_engine::io::APP_ID;
fn main() -> eframe::Result {
#[cfg(target_os = "macos")]
objc2_foundation::NSProcessInfo::processInfo().setProcessName(objc2_foundation::ns_string!("SolidCraft"));
mark("main");
adopt_legacy_settings();
let mut safe_mode = startup::env_on(std::env::var(startup::SAFE_MODE_ENV).ok().as_deref());
let mut control_port: Option<u16> = solidcraft_engine::geom::env::var("CONTROL_PORT").and_then(|p| p.parse().ok());
@@ -469,10 +625,17 @@ fn main() -> eframe::Result {
notes.push(format!("The last {failed_starts} launches stopped before the window opened, so this one is in safe mode."));
}
let cpu_renderer = safe_mode || startup::cpu_renderer_env(std::env::var(startup::RENDERER_ENV).ok().as_deref());
// The launch before this one closed because its graphics device was lost (`App::watch_device`).
let after_loss = device_lost::recent(std::env::var(device_lost::LOST_ENV).ok().as_deref(), device_lost::now_secs());
if after_loss {
notes.push(device_lost::note(cpu_renderer));
}
if safe_mode {
notes.push("Safe mode: default settings (yours are kept for the next normal start), no recovery offer, software rendering.".into());
}
let samples = if safe_mode { MSAA } else { msaa() };
let wgpu_options = wgpu_options(cpu_renderer, if safe_mode { None } else { backend_preference() }.as_deref());
let depth_bits = if !safe_mode && startup::float_depth_everywhere(&wgpu_options) { FLOAT_DEPTH_BITS } else { DEPTH_BITS };
// Crashes leave a report in the crash folder (solidcraft_engine::crash, docs/crash-reports.md).
let crashed = solidcraft_engine::crash::default_dir()
.map(|dir| {
@@ -492,9 +655,11 @@ fn main() -> eframe::Result {
solidcraft_engine::crash::phase("window");
let options = eframe::NativeOptions {
viewport: app_viewport()?,
depth_buffer: DEPTH_BITS,
stencil_buffer: STENCIL_BITS,
multisampling: samples,
// The window's pass (egui's) has no depth and one sample: the viewport draws its scene
// offscreen with its own depth and MSAA, then blits it, so the UI pass pays for neither.
depth_buffer: 0,
stencil_buffer: 0,
multisampling: 1,
// Kept with the other settings, not in a folder named after the app id. Safe mode reads
// and writes a scratch copy, leaving the user's settings as they were.
persistence_path: if safe_mode {
@@ -505,18 +670,30 @@ fn main() -> eframe::Result {
settings.as_ref().map(|d| d.join("app.ron"))
},
persist_window: !safe_mode,
wgpu_options: wgpu_options(cpu_renderer, if safe_mode { None } else { backend_preference() }.as_deref()),
wgpu_options,
..Default::default()
};
#[cfg(all(unix, not(target_os = "macos")))]
let mut options = options;
#[cfg(all(unix, not(target_os = "macos")))]
if prefer_x11(
std::env::var_os("DISPLAY").is_some(),
std::env::var_os("WAYLAND_DISPLAY").is_some(),
std::env::var("XDG_CURRENT_DESKTOP").ok().as_deref(),
solidcraft_engine::geom::env::var("WAYLAND").as_deref(),
) {
let x11 = {
// SOLIDCRAFT_WAYLAND wins over Preferences › Graphics › Window system (#3083).
let environment = solidcraft_engine::geom::env::var("WAYLAND");
let preference = window_system_preference();
let wayland_override = environment.as_deref().or(window_system_override(preference.as_deref()));
let wayland_display = std::env::var_os("WAYLAND_DISPLAY").is_some();
let x11 = prefer_x11(
std::env::var_os("DISPLAY").is_some(),
wayland_display,
std::env::var("XDG_CURRENT_DESKTOP").ok().as_deref(),
wayland_override,
);
// Without a Wayland socket winit opens on X11 anyway.
let _ = WINDOW_SYSTEM.set(if x11 || !wayland_display { "x11" } else { "wayland" });
x11
};
#[cfg(all(unix, not(target_os = "macos")))]
if x11 {
options.event_loop_builder = Some(Box::new(|b| {
use winit::platform::x11::EventLoopBuilderExtX11;
b.with_x11();
@@ -528,13 +705,17 @@ fn main() -> eframe::Result {
let created = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let created_in_app = created.clone();
let starting = settings.clone();
mark("run_native");
let result = eframe::run_native(
"SolidCraft",
options,
Box::new(move |cc| {
created_in_app.store(true, std::sync::atomic::Ordering::SeqCst);
solidcraft_engine::crash::phase("app setup");
solidcraft_ui_egui::fonts::setup_fonts(&cc.egui_ctx);
mark("window and device ready");
solidcraft_ui_egui::fonts::setup_bundled_fonts(&cc.egui_ctx);
solidcraft_ui_egui::fonts::load_system_fonts_in_background(&cc.egui_ctx);
mark("fonts");
let mut app = SolveApp::new(Session::default(), services(cc));
app.apply_off_thread = Some(solidcraft_ui_egui::dialogs::APPLY_AT_ONCE);
app.custom_titlebar = CUSTOM_TITLEBAR;
@@ -552,10 +733,18 @@ fn main() -> eframe::Result {
if let Some(p) = cc.storage.and_then(|s| solidcraft_ui_egui::prefs::stored(|k| s.get_string(k))).filter(|_| !safe_mode) {
app.load_prefs(&p);
}
mark("app state and prefs");
let gpu = cc.wgpu_render_state.as_ref().map(|rs| LostWatch {
lost: rs.device_lost.clone(),
restart_on_software: device_lost::restart_on_software(after_loss, cpu_renderer),
lost_at: None,
});
if let Some(rs) = &cc.wgpu_render_state {
eprintln!("SolidCraft graphics: {:?}, MSAA {}x (requested {}x)", rs.adapter.get_info().backend, rs.msaa_samples, samples);
let scene_samples = scene_samples(rs.adapter.get_info().backend, u32::from(samples), &rs.msaa_supported);
eprintln!("SolidCraft graphics: {:?}, MSAA {scene_samples}x (requested {}x)", rs.adapter.get_info().backend, samples);
solidcraft_engine::crash::phase("viewport pipelines (shader compile)");
app.set_wgpu(rs, DEPTH_BITS, STENCIL_BITS, rs.msaa_samples);
app.set_wgpu_split(rs, depth_bits, STENCIL_BITS, scene_samples);
solidcraft_ui_egui::gpu::install_picture_renderer(rs);
// The graphics driver is loaded now; it may have put its own exception filter
// in front of ours.
#[cfg(target_os = "windows")]
@@ -564,12 +753,15 @@ fn main() -> eframe::Result {
solidcraft_ui_egui::prefs::check_msaa(&mut app, u32::from(samples));
let info = rs.adapter.get_info();
solidcraft_engine::crash::set_environment(&format!(
"Renderer: {:?} {} ({}), MSAA {}x",
info.backend, info.name, info.driver, rs.msaa_samples
"Renderer: {:?} {} ({}), MSAA {scene_samples}x",
info.backend, info.name, info.driver
));
}
if let Some(port) = control_port {
let rx = control_server::start(port, cc.egui_ctx.clone());
mark("viewport pipelines");
// `--control`/the environment name a port; otherwise the preference (on by default)
// opens the default one (docs/control-protocol.md).
if let Some(listen_on) = solidcraft_engine::control_auth::plan(control_port, app.preferences.agent_control) {
let rx = control_server::start(listen_on, cc.egui_ctx.clone());
app = app.with_control(rx);
}
// The agent chat's MCP bridge reaches the app through an endpoint of its own, started
@@ -618,15 +810,32 @@ fn main() -> eframe::Result {
if !notes.is_empty() {
app.set_status(notes.join(" "), true);
}
#[cfg(any(target_os = "windows", target_os = "linux"))]
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
let space_mouse = space_mouse::SpaceMouse::start(&cc.egui_ctx);
#[cfg(target_os = "macos")]
let native_menu = match native_menu::NativeMenu::new(&app, &cc.egui_ctx) {
Ok(menu) => Some(menu),
Err(error) => {
app.set_status(format!("System menu: {error}"), true);
None
}
};
#[cfg(target_os = "macos")]
{
app.native_file_menu = native_menu.is_some();
}
mark("app created");
solidcraft_engine::crash::phase("first frame");
Ok(Box::new(App {
solve: app,
starting,
frames: 0,
#[cfg(any(target_os = "windows", target_os = "linux"))]
take_focus_unwanted: control_port.is_some(),
gpu,
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
space_mouse,
#[cfg(target_os = "macos")]
native_menu,
}))
}),
);
@@ -660,6 +869,13 @@ fn wgpu_options(cpu: bool, preference: Option<&str>) -> eframe::egui_wgpu::WgpuC
if cpu {
setup.native_adapter_selector = Some(std::sync::Arc::new(startup::pick_cpu_adapter));
}
// A float depth buffer with a stencil, wherever the adapter has it (see `FLOAT_DEPTH_BITS`).
let base = setup.device_descriptor.clone();
setup.device_descriptor = std::sync::Arc::new(move |adapter| {
let mut d = base(adapter);
d.required_features |= adapter.features() & startup::FLOAT_DEPTH_STENCIL;
d
});
}
options
}
@@ -687,6 +903,36 @@ fn no_window(error: eframe::Error, tried_cpu: bool, settings: Option<&std::path:
Err(error)
}
/// Linux: the window system this run opened with (`wayland` or `x11`), for Preferences (#3083).
#[cfg(all(unix, not(target_os = "macos")))]
static WINDOW_SYSTEM: std::sync::OnceLock<&'static str> = std::sync::OnceLock::new();
/// The window system this run uses, where there is a choice (Linux).
fn window_system_now() -> Option<&'static str> {
#[cfg(all(unix, not(target_os = "macos")))]
return WINDOW_SYSTEM.get().copied();
#[cfg(not(all(unix, not(target_os = "macos"))))]
None
}
/// Preferences › Graphics › Window system (`graphics.json`'s `window_system`), read before the
/// window opens.
#[cfg(all(unix, not(target_os = "macos")))]
fn window_system_preference() -> Option<String> {
graphics().and_then(|v| v.get("window_system").and_then(serde_json::Value::as_str).map(str::to_string))
}
/// The window system preference as the `SOLIDCRAFT_WAYLAND` value it stands for: `wayland` is
/// `1`, `x11` is `0`, and `auto` (or anything else) leaves the choice to [`prefer_x11`].
#[cfg(all(unix, not(target_os = "macos")))]
fn window_system_override(preference: Option<&str>) -> Option<&'static str> {
match preference.map(str::trim) {
Some("wayland") => Some("1"),
Some("x11") => Some("0"),
_ => None,
}
}
/// Whether to run through XWayland rather than native Wayland. winit has no file drag-and-drop
/// on Wayland (only on X11), so dropping files from the file manager showed a "no" cursor: use
/// XWayland when it's there. Except on niri, whose XWayland (xwayland-satellite) breaks window
@@ -724,6 +970,19 @@ mod backend_tests {
assert!(prefer_x11(true, true, Some("niri"), Some("0")));
assert!(!prefer_x11(false, true, Some("niri"), Some("0")));
}
/// Preferences › Graphics › Window system stands for the same values (#3083).
#[test]
fn the_window_system_preference_maps_to_the_override() {
use super::window_system_override as over;
assert_eq!(
(over(Some("wayland")), over(Some("x11")), over(Some("auto")), over(None), over(Some("junk"))),
(Some("1"), Some("0"), None, None, None)
);
// Wayland chosen on GNOME keeps native Wayland; X11 chosen on niri goes through XWayland.
assert!(!prefer_x11(true, true, Some("GNOME"), over(Some("wayland"))));
assert!(prefer_x11(true, true, Some("niri"), over(Some("x11"))));
}
}
#[cfg(test)]
@@ -781,4 +1040,13 @@ mod msaa_tests {
}
}
}
#[test]
fn the_scene_follows_the_backend_policy_too() {
use eframe::wgpu::Backend;
assert_eq!(super::scene_samples(Backend::Gl, 4, &[1, 4]), 1);
assert_eq!(super::scene_samples(Backend::Gl, 4, &[]), 1);
assert_eq!(super::scene_samples(Backend::Vulkan, 4, &[1, 4]), 4);
assert_eq!(super::scene_samples(Backend::Dx12, 8, &[1, 2, 4]), 4);
}
}
+21
View File
@@ -26,6 +26,7 @@ use std::sync::atomic::{AtomicIsize, Ordering};
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
use solidcraft_ui_egui::titlebar;
use windows_sys::Win32::Foundation::{HWND, LPARAM, LRESULT, RECT, SIZE, WPARAM};
use windows_sys::Win32::Graphics::Gdi::{GetMonitorInfoW, MONITOR_DEFAULTTONEAREST, MONITORINFO, MonitorFromWindow};
use windows_sys::Win32::UI::HiDpi::{GetDpiForWindow, GetSystemMetricsForDpi};
use windows_sys::Win32::UI::WindowsAndMessaging::{
CallWindowProcW, GWL_STYLE, GWLP_WNDPROC, GetClientRect, GetWindowLongPtrW, GetWindowRect, HTBOTTOM, HTBOTTOMLEFT, HTBOTTOMRIGHT, HTLEFT,
@@ -33,6 +34,26 @@ use windows_sys::Win32::UI::WindowsAndMessaging::{
WM_NCDESTROY, WM_NCHITTEST, WNDPROC, WS_THICKFRAME,
};
/// The work area (the monitor without the taskbar) of the monitor nearest the window, in physical
/// pixels: left, top, width, height.
pub fn work_area(window: &impl HasWindowHandle) -> Option<(i32, i32, i32, i32)> {
let handle = window.window_handle().ok()?;
let RawWindowHandle::Win32(w) = handle.as_raw() else { return None };
let hwnd = w.hwnd.get() as HWND;
// SAFETY: `hwnd` is the live main window; `info` is a MONITORINFO with its size set, as
// GetMonitorInfoW requires, and the monitor handle comes from the call before it.
let rc = unsafe {
let monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST);
let mut info: MONITORINFO = std::mem::zeroed();
info.cbSize = std::mem::size_of::<MONITORINFO>() as u32;
if monitor.is_null() || GetMonitorInfoW(monitor, &mut info) == 0 {
return None;
}
info.rcWork
};
Some((rc.left, rc.top, rc.right - rc.left, rc.bottom - rc.top))
}
/// The window procedure ours replaced (winit's), as an address; 0 before [`install`].
static PREVIOUS: AtomicIsize = AtomicIsize::new(0);
+189
View File
@@ -0,0 +1,189 @@
//! macOS system menus, owned and updated on eframe's main thread.
use std::sync::mpsc::{Receiver, sync_channel};
use muda::{
AboutMetadata, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu,
accelerator::{Accelerator, Code, Modifiers},
};
use solidcraft_ui_egui::{SolveApp, toolbar::FileAction};
pub struct NativeMenu {
menu: Menu,
actions: Vec<(MenuItem, FileAction)>,
help_actions: Vec<(MenuItem, &'static str)>,
events: Receiver<MenuEvent>,
recent: Submenu,
recent_paths: Vec<String>,
}
impl NativeMenu {
pub fn new(app: &SolveApp, ctx: &egui::Context) -> muda::Result<Self> {
let menu = Menu::new();
let application = Submenu::new("SolidCraft", true);
let file = Submenu::new(app.tr("ui.file"), true);
let edit = Submenu::new(app.tr_label("Edit"), true);
let recent = Submenu::new(app.tr("ui.open_recent"), true);
let window = Submenu::new("Window", true);
let help = Submenu::new(app.tr("ui.help"), true);
let mut help_actions = Vec::new();
let mut actions = Vec::new();
let mut add = |parent: &Submenu, label: &str, action: FileAction, shortcut: Option<Accelerator>| -> muda::Result<()> {
let item = MenuItem::new(label, Self::action_enabled(&action, app, ctx), shortcut);
parent.append(&item)?;
actions.push((item, action));
Ok(())
};
application.append(&PredefinedMenuItem::about(
Some("About SolidCraft"),
Some(AboutMetadata { name: Some("SolidCraft".into()), version: Some(env!("CARGO_PKG_VERSION").into()), ..Default::default() }),
))?;
application.append(&PredefinedMenuItem::separator())?;
add(
&application,
&format!("{}\u{2026}", app.tr("ui.preferences")),
FileAction::Settings,
Some(Accelerator::new(Modifiers::META, Code::Comma)),
)?;
add(&application, &format!("{}\u{2026}", app.tr("ui.keyboard_shortcuts")), FileAction::Shortcuts, None)?;
application.append(&PredefinedMenuItem::separator())?;
application.append(&PredefinedMenuItem::services(None))?;
application.append(&PredefinedMenuItem::separator())?;
application.append_items(&[
&PredefinedMenuItem::hide(Some("Hide SolidCraft")),
&PredefinedMenuItem::hide_others(None),
&PredefinedMenuItem::show_all(None),
&PredefinedMenuItem::separator(),
])?;
add(&application, &format!("{} SolidCraft", app.tr("ui.quit")), FileAction::Quit, Some(Accelerator::new(Modifiers::META, Code::KeyQ)))?;
add(&file, app.tr("ui.new_design"), FileAction::New, Some(Accelerator::new(Modifiers::META, Code::KeyN)))?;
add(&file, &format!("{}\u{2026}", app.tr("ui.open")), FileAction::Open, Some(Accelerator::new(Modifiers::META, Code::KeyO)))?;
file.append(&recent)?;
file.append(&PredefinedMenuItem::separator())?;
for (label, command) in
[("Insert STEP\u{2026}", "file.insert_step"), ("Insert Mesh\u{2026}", "file.insert_mesh"), ("Insert Design", "component.insert_design")]
{
add(&file, app.tr_label(label), FileAction::Command(command), None)?;
}
file.append(&PredefinedMenuItem::separator())?;
add(&file, app.tr("ui.save"), FileAction::Save, Some(Accelerator::new(Modifiers::META, Code::KeyS)))?;
add(
&file,
&format!("{}\u{2026}", app.tr("ui.save_as")),
FileAction::SaveAs,
Some(Accelerator::new(Modifiers::META | Modifiers::SHIFT, Code::KeyS)),
)?;
add(&file, app.tr_label("Save As JSON\u{2026}"), FileAction::SaveJson, None)?;
file.append(&PredefinedMenuItem::separator())?;
for (label, action) in [
("Export STL\u{2026}", FileAction::ExportMesh("stl")),
("Export 3MF\u{2026}", FileAction::ExportMesh("3mf")),
("Export STEP\u{2026}", FileAction::Export("step")),
("Export Current View as DXF\u{2026}", FileAction::Command("file.export_view_dxf")),
("Export OBJ\u{2026}", FileAction::Export("obj")),
("Export URDF\u{2026}", FileAction::Export("urdf")),
("Export Design as JSON\u{2026}", FileAction::Export("json")),
] {
add(&file, app.tr_label(label), action, None)?;
}
file.append(&PredefinedMenuItem::separator())?;
add(&file, app.tr_label("Design Properties"), FileAction::Command("document.metadata"), None)?;
add(&edit, app.tr("ui.undo"), FileAction::Undo, Some(Accelerator::new(Modifiers::META, Code::KeyZ)))?;
add(&edit, app.tr("ui.redo"), FileAction::Redo, Some(Accelerator::new(Modifiers::META | Modifiers::SHIFT, Code::KeyZ)))?;
add(&window, app.tr_label("Agent Chat"), FileAction::AgentChat, None)?;
window.append(&PredefinedMenuItem::separator())?;
window.append_items(&[
&PredefinedMenuItem::minimize(None),
&PredefinedMenuItem::maximize(None),
&PredefinedMenuItem::fullscreen(None),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::bring_all_to_front(None),
])?;
for (action, label) in [
("docs", "Documentation"),
("shortcuts", "Keyboard Shortcuts"),
("messages", "Messages\u{2026}"),
("report", "Report an Issue\u{2026}"),
("crashes", "Crash Reports\u{2026}"),
] {
let item = MenuItem::new(app.tr_label(label), true, None);
help.append(&item)?;
help_actions.push((item, action));
}
menu.append_items(&[&application, &file, &edit, &window, &help])?;
let (sender, events) = sync_channel(64);
let ctx = ctx.clone();
MenuEvent::set_event_handler(Some(move |event| {
let _ = sender.try_send(event);
ctx.request_repaint();
}));
menu.init_for_nsapp();
let mut native = Self { menu, actions, help_actions, events, recent, recent_paths: Vec::new() };
native.update_recent(app)?;
Ok(native)
}
pub fn raw_input_hook(&mut self, app: &mut SolveApp, ctx: &egui::Context, raw: &mut egui::RawInput) {
for event in self.events.try_iter().take(64) {
if let Some((_, action)) = self.help_actions.iter().find(|(item, _)| item.id() == &event.id) {
solidcraft_ui_egui::help::run(app, ctx, action);
}
if let Some((_, action)) = self.actions.iter().find(|(item, _)| item.id() == &event.id) {
if matches!(action, FileAction::Undo | FileAction::Redo) && ctx.egui_wants_keyboard_input() {
raw.events.push(egui::Event::Key {
key: egui::Key::Z,
physical_key: None,
pressed: true,
repeat: false,
modifiers: egui::Modifiers { command: true, mac_cmd: true, shift: matches!(action, FileAction::Redo), ..Default::default() },
});
} else {
action.run(app);
}
}
}
}
fn action_enabled(action: &FileAction, app: &SolveApp, ctx: &egui::Context) -> bool {
action.enabled(app)
|| (matches!(action, FileAction::Undo | FileAction::Redo) && ctx.egui_wants_keyboard_input() && FileAction::Settings.enabled(app))
}
pub fn tick(&mut self, app: &mut SolveApp, ctx: &egui::Context) {
for (item, action) in &self.actions {
let enabled = Self::action_enabled(action, app, ctx);
if item.is_enabled() != enabled {
item.set_enabled(enabled);
}
}
if !self.recent_paths.iter().eq(app.home.recent.iter().take(32))
&& let Err(error) = self.update_recent(app)
{
app.set_status(format!("Recent designs menu: {error}"), true);
}
}
fn update_recent(&mut self, app: &SolveApp) -> muda::Result<()> {
while self.recent.remove_at(0).is_some() {}
self.actions.retain(|(_, action)| !matches!(action, FileAction::OpenRecent(_) | FileAction::ClearRecent));
self.recent_paths = app.home.recent.iter().take(32).cloned().collect();
for path in &self.recent_paths {
let action = FileAction::OpenRecent(path.clone());
let item = MenuItem::new(path, action.enabled(app), None);
self.recent.append(&item)?;
self.actions.push((item, action));
}
self.recent.append(&PredefinedMenuItem::separator())?;
let clear = MenuItem::new(app.tr("ui.clear_recent"), !self.recent_paths.is_empty(), None);
self.recent.append(&clear)?;
self.actions.push((clear, FileAction::ClearRecent));
Ok(())
}
}
impl Drop for NativeMenu {
fn drop(&mut self) {
MenuEvent::set_event_handler(None::<fn(MenuEvent)>);
self.menu.remove_for_nsapp();
}
}
File diff suppressed because it is too large Load Diff
@@ -1,253 +0,0 @@
//! Just enough of a HID report descriptor reader to recognise a 3D mouse and to see how it packs
//! its motion axes. Written from the public USB HID specification. Malformed input never panics;
//! it simply yields less information.
/// Generic Desktop usage page, and the usages in it that matter here.
const PAGE_GENERIC_DESKTOP: u32 = 0x01;
const USAGE_MULTI_AXIS_CONTROLLER: u32 = 0x08;
const FIRST_AXIS_USAGE: u32 = 0x30; // X, Y, Z, Rx, Ry, Rz are 0x30..=0x35
const AXIS_COUNT: u32 = 6;
/// Motion reports are report id 1 (translation, or everything) and report id 2 (rotation).
const MOTION_REPORT_ID: u8 = 1;
/// Descriptors are small (hidapi reserves 4 KiB). Anything past this is ignored.
const MAX_DESCRIPTOR_BYTES: usize = 4096;
/// Bound on the usages one main item can declare through a usage range.
const MAX_USAGES_PER_ITEM: u32 = 256;
/// How a device spreads its six motion axes over input reports.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Layout {
/// Report 1 carries translation and report 2 carries rotation, three 16-bit values each.
/// This is how 3Dconnexion devices report, and it is the layout assumed when nothing is known.
#[default]
Split,
/// Report 1 carries all six axes: translation, then rotation. Homemade devices do this.
Combined,
}
#[derive(Clone, Copy)]
struct Item {
/// 0 main, 1 global, 2 local (3 is reserved).
kind: u8,
tag: u8,
value: u32,
}
/// Walks the short items of a descriptor. Long items are skipped; a truncated tail ends the walk.
fn items(descriptor: &[u8]) -> impl Iterator<Item = Item> + '_ {
let descriptor = descriptor.get(..MAX_DESCRIPTOR_BYTES).unwrap_or(descriptor);
let mut pos = 0_usize;
std::iter::from_fn(move || {
loop {
let prefix = *descriptor.get(pos)?;
pos += 1;
if prefix == 0xFE {
// Long item: a data length byte, a tag byte, then the data.
let length = usize::from(*descriptor.get(pos)?);
pos = pos.checked_add(2 + length)?;
continue;
}
let size = match prefix & 0b11 {
0 => 0,
1 => 1,
2 => 2,
_ => 4,
};
let data = descriptor.get(pos..pos + size)?;
pos += size;
let value = data.iter().enumerate().fold(0_u32, |value, (index, byte)| value | (u32::from(*byte) << (8 * index)));
return Some(Item { kind: (prefix >> 2) & 0b11, tag: prefix >> 4, value });
}
})
}
/// What a descriptor says about a device, as far as 3D mouse support needs to know.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Descriptor {
multi_axis_controller: bool,
/// Distinct axes (X, Y, Z, Rx, Ry, Rz as bits 0..=5) that input report 1 carries.
motion_report_axes: u8,
}
impl Descriptor {
pub fn parse(descriptor: &[u8]) -> Self {
let mut summary = Self::default();
let mut page = 0_u32;
let mut report_id = 0_u8;
let mut depth = 0_u32;
// Local items belong to the next main item only.
let mut usages: Vec<u32> = Vec::new();
let mut usage_min = None;
for item in items(descriptor) {
match (item.kind, item.tag) {
// Global: usage page, report id.
(1, 0) => page = item.value,
(1, 8) => report_id = u8::try_from(item.value).unwrap_or(0),
// Local: usage, usage minimum, usage maximum.
(2, 0) => usages.push(Self::full_usage(page, item)),
(2, 1) => usage_min = Some(Self::full_usage(page, item)),
(2, 2) => {
if let Some(min) = usage_min.take() {
let max = Self::full_usage(page, item);
let count = max.saturating_sub(min).min(MAX_USAGES_PER_ITEM - 1);
usages.extend((0..=count).map(|offset| min.saturating_add(offset)));
}
}
// Main: input, collection, end collection.
(0, 8) => {
if report_id == MOTION_REPORT_ID || (report_id == 0 && depth > 0) {
summary.note_axes(&usages);
}
}
(0, 0xA) => {
// An application collection (1) at the top level names the device.
if depth == 0 && item.value == 1 && usages.first() == Some(&((PAGE_GENERIC_DESKTOP << 16) | USAGE_MULTI_AXIS_CONTROLLER)) {
summary.multi_axis_controller = true;
}
depth += 1;
}
(0, 0xC) => depth = depth.saturating_sub(1),
_ => {}
}
if item.kind == 0 {
usages.clear();
usage_min = None;
}
}
summary
}
/// Usage items of up to two bytes name a usage on the current page, four bytes carry the page.
fn full_usage(page: u32, item: Item) -> u32 {
if item.value > 0xFFFF { item.value } else { (page << 16) | item.value }
}
fn note_axes(&mut self, usages: &[u32]) {
for usage in usages {
let axis = usage.wrapping_sub((PAGE_GENERIC_DESKTOP << 16) | FIRST_AXIS_USAGE);
if axis < AXIS_COUNT {
self.motion_report_axes |= 1 << axis;
}
}
}
/// True for a top-level Generic Desktop "Multi-Axis Controller" collection. This tells a
/// SpaceMouse's motion interface apart from its keyboard interface and from ordinary mice.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub fn is_multi_axis_controller(&self) -> bool {
self.multi_axis_controller
}
/// `Combined` when input report 1 declares all six axes, otherwise `Split`.
pub fn layout(&self) -> Layout {
if self.motion_report_axes.count_ones() == AXIS_COUNT { Layout::Combined } else { Layout::Split }
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Report 1 = X Y Z, report 2 = Rx Ry Rz, each as three signed 16-bit values.
const SPLIT: &[u8] = &[
0x05, 0x01, // usage page: generic desktop
0x09, 0x08, // usage: multi-axis controller
0xA1, 0x01, // collection: application
0x85, 0x01, // report id 1
0x16, 0xA2, 0xFE, // logical minimum -350
0x26, 0x5E, 0x01, // logical maximum 350
0x75, 0x10, // report size 16
0x95, 0x03, // report count 3
0x09, 0x30, 0x09, 0x31, 0x09, 0x32, // X Y Z
0x81, 0x06, // input: data, variable, relative
0x85, 0x02, // report id 2
0x09, 0x33, 0x09, 0x34, 0x09, 0x35, // Rx Ry Rz
0x81, 0x06, // input
0xC0, // end collection
];
/// Report 1 = X Y Z Rx Ry Rz.
const COMBINED: &[u8] = &[
0x05, 0x01, 0x09, 0x08, 0xA1, 0x01, 0x85, 0x01, // multi-axis controller, report id 1
0x16, 0xA2, 0xFE, 0x26, 0x5E, 0x01, 0x75, 0x10, 0x95, 0x06, // 6 x 16 bit, -350..350
0x09, 0x30, 0x09, 0x31, 0x09, 0x32, 0x09, 0x33, 0x09, 0x34, 0x09, 0x35, // X Y Z Rx Ry Rz
0x81, 0x06, 0xC0,
];
/// The same six axes declared as a usage range, with report id 1 after the collection opens.
const COMBINED_RANGE: &[u8] = &[
0x05, 0x01, 0x09, 0x08, 0xA1, 0x01, 0x85, 0x01, 0x75, 0x10, 0x95, 0x06, //
0x19, 0x30, 0x29, 0x35, // usage minimum X, usage maximum Rz
0x81, 0x06, 0xC0,
];
/// A boot keyboard-like interface: usage page 7 usages must not count as axes.
const KEYBOARD: &[u8] =
&[0x05, 0x01, 0x09, 0x06, 0xA1, 0x01, 0x05, 0x07, 0x19, 0xE0, 0x29, 0xE7, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x08, 0x81, 0x02, 0xC0];
/// An ordinary mouse: Generic Desktop X, Y but top-level usage is Mouse (2).
const MOUSE: &[u8] = &[
0x05, 0x01, 0x09, 0x02, 0xA1, 0x01, 0x09, 0x01, 0xA1, 0x00, 0x09, 0x30, 0x09, 0x31, 0x15, 0x81, 0x25, 0x7F, 0x75, 0x08, 0x95, 0x02, 0x81,
0x06, 0xC0, 0xC0,
];
#[test]
fn split_descriptor_is_a_multi_axis_controller_with_three_axes_in_report_one() {
let descriptor = Descriptor::parse(SPLIT);
assert!(descriptor.is_multi_axis_controller());
assert_eq!(descriptor.motion_report_axes, 0b000_111);
assert_eq!(descriptor.layout(), Layout::Split);
}
#[test]
fn six_axes_in_report_one_are_a_combined_layout() {
for bytes in [COMBINED, COMBINED_RANGE] {
let descriptor = Descriptor::parse(bytes);
assert!(descriptor.is_multi_axis_controller());
assert_eq!(descriptor.layout(), Layout::Combined);
}
}
#[test]
fn other_interfaces_are_not_3d_mice() {
for bytes in [KEYBOARD, MOUSE, &[][..]] {
let descriptor = Descriptor::parse(bytes);
assert!(!descriptor.is_multi_axis_controller());
assert_eq!(descriptor.layout(), Layout::Split);
}
}
#[test]
fn four_byte_usages_carry_their_own_page() {
// Usage (4 bytes) 0x0007_0030 is on page 7, not an axis.
let descriptor = Descriptor::parse(&[0x05, 0x01, 0x09, 0x08, 0xA1, 0x01, 0x85, 0x01, 0x0B, 0x30, 0x00, 0x07, 0x00, 0x81, 0x06, 0xC0]);
assert!(descriptor.is_multi_axis_controller());
assert_eq!(descriptor.motion_report_axes, 0);
}
#[test]
fn truncated_and_hostile_descriptors_never_panic() {
for end in 0..=COMBINED.len() {
let _ = Descriptor::parse(&COMBINED[..end]);
}
// Long item with a length running past the end, huge usage ranges, deep nesting.
let _ = Descriptor::parse(&[0xFE, 0xFF, 0x00, 0x01]);
let _ = Descriptor::parse(&[0x05, 0x01, 0x1B, 0, 0, 0, 0, 0x2B, 0xFF, 0xFF, 0xFF, 0xFF, 0x81, 0x06]);
let _ = Descriptor::parse(&[0xC0; 64]);
let _ = Descriptor::parse(&[0xA1; 64]);
let mut state = 0x2545_F491_4F6C_DD1D_u64;
for _ in 0..200 {
let bytes: Vec<u8> = (0..64)
.map(|_| {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
state.to_le_bytes()[0]
})
.collect();
let _ = Descriptor::parse(&bytes);
}
}
}
+127 -24
View File
@@ -1,57 +1,160 @@
//! Finding a 3D mouse on Linux. The kernel exposes every HID interface as `/dev/hidrawN`, with
//! its identity and report descriptor under `/sys/class/hidraw/hidrawN/device/`. Reading both is
//! plain file I/O, so no udev or hidapi library is needed.
//! its report descriptor and ids under `/sys/class/hidraw/hidrawN/device/`. Reading them is plain
//! file I/O, so no udev or hidapi library is needed.
use std::io::Read;
use std::path::{Path, PathBuf};
use std::time::Duration;
use super::hid_descriptor::{Descriptor, Layout};
use solidcraft_space_mouse::{Descriptor, Layout};
const SYSFS: &str = "/sys/class/hidraw";
const DEV: &str = "/dev";
/// `O_NONBLOCK` on Linux's common architectures (x86, ARM, RISC-V). The device is opened with it so
/// a read with nothing to read returns at once and the reader can see whether it should stop.
pub const O_NONBLOCK: i32 = 0o4000;
/// Opens a hidraw node the way the Direct reader reads it: read-only and non-blocking.
pub fn open(path: &Path) -> std::io::Result<std::fs::File> {
use std::os::unix::fs::OpenOptionsExt;
std::fs::OpenOptions::new().read(true).custom_flags(O_NONBLOCK).open(path)
}
/// How long the reader waits when the device has nothing to say, which is also the most a report
/// can be delayed.
const IDLE_POLL: Duration = Duration::from_millis(5);
pub struct Device {
pub path: PathBuf,
pub layout: Layout,
/// Its vendor and product ids, when the kernel gives them.
pub ids: Option<(u16, u16)>,
}
/// The vendor id from `HID_ID=<bus>:<vendor>:<product>` (all hexadecimal) in a sysfs `uevent`.
fn vendor_id(uevent: &str) -> Option<u16> {
let id = uevent.lines().find_map(|line| line.strip_prefix("HID_ID="))?;
let vendor = id.split(':').nth(1)?;
u32::from_str_radix(vendor.trim(), 16).ok().and_then(|vendor| u16::try_from(vendor).ok())
}
/// The first hidraw interface that is a 3D mouse's multi-axis controller.
/// The first hidraw interface that is a multi-axis controller with motion axes, whoever made it.
pub fn find() -> Option<Device> {
let mut names: Vec<_> = std::fs::read_dir(SYSFS).ok()?.filter_map(Result::ok).map(|entry| entry.file_name()).collect();
names.sort();
names.into_iter().find_map(|name| probe(&Path::new(SYSFS).join(&name), Path::new(DEV).join(&name)))
}
fn probe(sysfs: &Path, path: PathBuf) -> Option<Device> {
let uevent = std::fs::read_to_string(sysfs.join("device/uevent")).ok()?;
if !super::is_3d_mouse_vendor(vendor_id(&uevent)?) {
return None;
/// Hands every report to `publish` until the device is gone (end of file or an error) or `stop`
/// says so. `source` is non-blocking: when it has nothing, `stop` is asked and the reader waits
/// [`IDLE_POLL`]. Reports are read and handed over exactly as they were with blocking reads.
pub fn read_reports(mut source: impl Read, mut publish: impl FnMut(&[u8]), mut stop: impl FnMut() -> bool) {
let mut report = [0_u8; super::REPORT_BUFFER_BYTES];
loop {
match source.read(&mut report) {
Ok(0) => return,
Ok(size) => publish(report.get(..size).unwrap_or_default()),
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
if stop() {
return;
}
std::thread::sleep(IDLE_POLL);
}
Err(error) => {
log::info!("3D mouse disconnected: {error}");
return;
}
}
}
}
fn probe(sysfs: &Path, path: PathBuf) -> Option<Device> {
let descriptor = Descriptor::parse(&std::fs::read(sysfs.join("device/report_descriptor")).ok()?);
descriptor.is_multi_axis_controller().then(|| Device { path, layout: descriptor.layout() })
// A controller that declares no motion axis (buttons only, say) isn't read.
let layout = descriptor.is_multi_axis_controller().then(|| descriptor.layout()).flatten()?;
let ids = std::fs::read_to_string(sysfs.join("device/uevent")).ok().as_deref().and_then(ids);
Some(Device { path, layout, ids })
}
/// The vendor and product ids in a HID device's uevent: `HID_ID=bus:vendor:product`, in hex.
fn ids(uevent: &str) -> Option<(u16, u16)> {
let id = uevent.lines().find_map(|line| line.strip_prefix("HID_ID="))?;
let mut hex = id.split(':').skip(1).map(|part| u32::from_str_radix(part, 16).ok().and_then(|v| u16::try_from(v).ok()));
Some((hex.next()??, hex.next()??))
}
#[cfg(test)]
mod tests {
use super::*;
/// A real EACCES, as a user without the udev rule gets: a file of the user's own with no permission
/// bits. Skipped for root, which can open anything.
#[test]
fn reads_the_vendor_from_the_hid_id() {
let uevent = "DRIVER=hid-generic\nHID_ID=0003:0000256F:0000C631\nHID_NAME=Some Device\n";
assert_eq!(vendor_id(uevent), Some(0x256f));
assert_eq!(vendor_id("HID_ID=0003:0000046D:0000C077"), Some(0x046d));
fn a_device_without_permission_is_denied_and_the_daemon_takes_over_within_the_backoff() {
use std::os::unix::fs::{MetadataExt, PermissionsExt};
if std::fs::metadata("/proc/self").is_ok_and(|meta| meta.uid() == 0) {
return;
}
let dir = std::env::temp_dir().join(format!("sc-hidraw-denied-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let node = dir.join("hidraw0");
std::fs::write(&node, [0_u8; 4]).unwrap();
std::fs::set_permissions(&node, std::fs::Permissions::from_mode(0o000)).unwrap();
let denied = open(&node);
std::fs::set_permissions(&node, std::fs::Permissions::from_mode(0o600)).unwrap();
assert!(open(&node).is_ok(), "the same node opens once it is readable");
std::fs::remove_dir_all(&dir).ok();
assert_eq!(denied.err().map(|error| error.kind()), Some(std::io::ErrorKind::PermissionDenied));
// The failure is the choice's `since_failed`, with an injected clock: inside the backoff the
// daemon is read, whichever driver was asked for; after it the device is tried again.
use super::super::{Pick, SpaceMouseDriver, UNREADABLE_RETRY, choose, direct_usable};
for wanted in [SpaceMouseDriver::Automatic, SpaceMouseDriver::Spacenavd, SpaceMouseDriver::Direct] {
for ago in [Duration::ZERO, Duration::from_secs(29)] {
assert_eq!(choose(wanted, true, direct_usable(true, Some(ago), UNREADABLE_RETRY)), Some(Pick::Spacenavd), "{wanted:?} {ago:?}");
}
}
assert_eq!(choose(SpaceMouseDriver::Direct, true, direct_usable(true, Some(UNREADABLE_RETRY), UNREADABLE_RETRY)), Some(Pick::Direct));
// And the daemon's own reader delivers over a fake connection.
let (mut daemon, client) = std::os::unix::net::UnixStream::pair().unwrap();
let mut record = [0_u8; 32];
record[4..8].copy_from_slice(&100_i32.to_ne_bytes());
std::io::Write::write_all(&mut daemon, &record).unwrap();
drop(daemon);
let mut seen = 0;
super::super::spacenavd::read(client, |_| seen += 1, |_, _| {}, || false, Duration::from_secs(3600));
assert_eq!(seen, 1);
}
#[test]
fn malformed_ids_are_rejected() {
for uevent in ["", "HID_NAME=x", "HID_ID=", "HID_ID=0003", "HID_ID=0003:zzzz:0001", "HID_ID=0003:FFFFFFFF:0001"] {
assert_eq!(vendor_id(uevent), None, "{uevent:?}");
}
fn reports_come_through_idle_gaps_until_the_device_ends_or_a_stop() {
use crate::space_mouse::fake::Script;
let mut script = Script::new();
script.timeout(std::io::ErrorKind::WouldBlock).data(&[1, 2, 3]).timeout(std::io::ErrorKind::WouldBlock).data(&[4]).end();
let (mut seen, mut asked) = (Vec::new(), 0);
read_reports(
script,
|report| seen.push(report.to_vec()),
|| {
asked += 1;
false
},
);
assert_eq!(seen, [vec![1, 2, 3], vec![4]]);
assert_eq!(asked, 2);
let mut script = Script::new();
script.data(&[1]).timeout(std::io::ErrorKind::WouldBlock).data(&[2]);
let mut seen = Vec::new();
read_reports(script, |report| seen.push(report.to_vec()), || true);
assert_eq!(seen, [vec![1]], "a stop at an idle moment ends the read");
// An unplugged device is an error, which ends the read as before.
let mut script = Script::new();
script.data(&[1]).fail(std::io::ErrorKind::BrokenPipe).data(&[2]);
let mut seen = Vec::new();
read_reports(script, |report| seen.push(report.to_vec()), || false);
assert_eq!(seen, [vec![1]]);
}
#[test]
fn ids_come_from_the_uevent() {
let wireless = "DRIVER=hid-generic\nHID_ID=0005:0000256F:0000C63A\nHID_NAME=SpaceMouse Wireless BT\n";
assert_eq!(ids(wireless), Some((0x256f, 0xc63a)));
assert_eq!(ids("HID_ID=0003:0000046D\n"), None);
assert_eq!(ids("HID_ID=0003:0001046D:0000C62B\n"), None, "a vendor past 16 bits isn't a USB id");
assert_eq!(ids("HID_NAME=keyboard\n"), None);
}
}
@@ -0,0 +1,276 @@
//! Reading a 3D mouse through `spacenavd` on Linux. The daemon owns the device and applies what
//! `spnavcfg` sets (swapped and inverted axes, sensitivity, dead zones) before it hands the motion
//! to its clients, so a program that reads the HID device itself bypasses that setup. Over its
//! socket the daemon sends fixed 32-byte records: eight native-endian `i32`, the first the kind of
//! record (0 for motion, then x y z rx ry rz and the milliseconds since the last record).
//! Button records (1 pressed, 2 released, then the button's number from 0) are read too; other
//! records (from newer daemons also configuration changes) are ignored.
//!
//! The socket is plain file I/O, so no library is needed, and it needs no udev rule: the daemon
//! is what has access to the device.
use std::io::Read;
use std::os::unix::net::UnixStream;
use std::path::Path;
use std::time::{Duration, Instant};
use solidcraft_ui_egui::space_mouse::{Axes, FULL_SCALE};
/// Where the daemon listens. `/var/run` is a link to `/run` on current systems.
const SOCKETS: [&str; 2] = ["/var/run/spnav.sock", "/run/spnav.sock"];
const RECORD_BYTES: usize = 32;
/// What the daemon reports at full deflection (seen with a SpaceMouse Compact and a default
/// sensitivity). Our axes are in the raw HID units of [`FULL_SCALE`].
const DAEMON_FULL_SCALE: i64 = 500;
/// Whether the daemon's socket is there, without connecting to it.
pub fn exists() -> bool {
SOCKETS.iter().any(|path| Path::new(path).exists())
}
pub fn connect() -> Option<UnixStream> {
SOCKETS.iter().find_map(|path| connect_to(Path::new(path)))
}
fn connect_to(path: &Path) -> Option<UnixStream> {
UnixStream::connect(path).ok()
}
/// The axes of one record, or `None` for a record that is not motion.
///
/// The daemon's frame is right-handed with x to the right, y away from the user and z up; ours is
/// the device's raw one, x right, y towards the user and z down, so y and z (and the rotations
/// about them) change sign. With nothing set in `spnavcfg` the two give the same motion; what
/// the user swaps or inverts there then carries over.
fn decode(record: &[u8; RECORD_BYTES]) -> Option<Axes> {
let mut data = [0_i32; 8];
for (slot, bytes) in data.iter_mut().zip(record.as_chunks::<4>().0) {
*slot = i32::from_ne_bytes(*bytes);
}
let [kind, x, y, z, rx, ry, rz, _period] = data;
(kind == 0).then(|| Axes {
translation: [unit(x), unit(-i64::from(y)), unit(-i64::from(z))],
rotation: [unit(rx), unit(-i64::from(ry)), unit(-i64::from(rz))],
})
}
/// The button a button record is about, from 0, and whether it went down; `None` for any other
/// record, or a button past the 32 a bitmask holds.
fn button(record: &[u8; RECORD_BYTES]) -> Option<(u32, bool)> {
let mut data = [0_i32; 2];
for (slot, bytes) in data.iter_mut().zip(record.as_chunks::<4>().0) {
*slot = i32::from_ne_bytes(*bytes);
}
let [kind, number] = data;
let number = u32::try_from(number).ok().filter(|&n| n < 32)?;
match kind {
1 => Some((number, true)),
2 => Some((number, false)),
_ => None,
}
}
/// A daemon value in our raw units. Out-of-range values stay in `i16`: they are input from another
/// process.
fn unit(value: impl Into<i64>) -> i16 {
let scaled = value.into() * i64::from(FULL_SCALE) / DAEMON_FULL_SCALE;
i16::try_from(scaled.clamp(i64::from(i16::MIN), i64::from(i16::MAX))).unwrap_or_default()
}
/// Hands every motion record to `publish` and every button record to `pressed` until the daemon
/// closes the connection or fails, or `stop` says so. A stream with a read timeout
/// (`set_read_timeout`) times out when the daemon is quiet, which is no record and no disconnect:
/// `stop` is asked, and the read goes on, with what of a record had arrived kept. `stop` is also asked
/// at least every `check_every` while records keep arriving, so a daemon that never pauses for that
/// long (a moving cap sends every few milliseconds) does not keep the reader from noticing that the
/// device it prefers is back. A partial record at the end is dropped.
pub fn read(
mut stream: impl Read,
mut publish: impl FnMut(Axes),
mut pressed: impl FnMut(u32, bool),
mut stop: impl FnMut() -> bool,
check_every: Duration,
) {
let mut record = [0_u8; RECORD_BYTES];
let mut filled = 0;
let mut asked = Instant::now();
loop {
if asked.elapsed() >= check_every {
asked = Instant::now();
if stop() {
return;
}
}
match stream.read(record.get_mut(filled..).unwrap_or_default()) {
Ok(0) => {
log::info!("3D mouse: spacenavd connection ended");
return;
}
Ok(size) => {
filled += size;
if filled == RECORD_BYTES {
filled = 0;
if let Some(axes) = decode(&record) {
publish(axes);
} else if let Some((number, down)) = button(&record) {
pressed(number, down);
}
}
}
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
Err(error) if matches!(error.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => {
asked = Instant::now();
if stop() {
return;
}
}
Err(error) => {
log::info!("3D mouse: spacenavd connection ended: {error}");
return;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn record(data: [i32; 8]) -> [u8; RECORD_BYTES] {
let mut bytes = [0_u8; RECORD_BYTES];
for (chunk, value) in bytes.as_chunks_mut::<4>().0.iter_mut().zip(data) {
*chunk = value.to_ne_bytes();
}
bytes
}
#[test]
fn motion_records_become_axes_in_the_devices_raw_frame() {
// Full deflection of the daemon's x, y and z is the device's full scale, and y and z flip.
assert_eq!(decode(&record([0, 500, 500, 500, 0, 0, 0, 8])), Some(Axes { translation: [350, -350, -350], rotation: [0; 3] }));
// Rotations follow translations: right-handed in both frames, so ry and rz flip as y and z do.
assert_eq!(decode(&record([0, 0, 0, 0, 250, 250, 250, 8])), Some(Axes { translation: [0; 3], rotation: [175, -175, -175] }));
// Rest is a motion record of zeros.
assert_eq!(decode(&record([0, 0, 0, 0, 0, 0, 0, 1200])), Some(Axes::default()));
}
#[test]
fn what_spnavcfg_swaps_reaches_the_view() {
// The daemon sends a push away as y. With Y and Z swapped in spnavcfg it sends the same push
// as z, and the view gets a lift (raw z) instead of the zoom (raw y).
let before = decode(&record([0, 0, 500, 0, 0, 0, 0, 8])).map(|axes| axes.translation);
let after = decode(&record([0, 0, 0, 500, 0, 0, 0, 8])).map(|axes| axes.translation);
assert_eq!(before, Some([0, -350, 0]));
assert_eq!(after, Some([0, 0, -350]));
}
#[test]
fn other_records_and_hostile_values_are_harmless() {
// A button record (kind 1, press) and one of an unknown kind are not motion.
assert_eq!(decode(&record([1, 0, 1, 0, 0, 0, 0, 0])), None);
// Nor are hostile button numbers a button.
assert_eq!(button(&record([1, -1, 0, 0, 0, 0, 0, 0])), None);
assert_eq!(button(&record([1, i32::MAX, 0, 0, 0, 0, 0, 0])), None);
assert_eq!(button(&record([7, 3, 0, 0, 0, 0, 0, 0])), None);
assert_eq!(decode(&record([7, 500, 500, 500, 500, 500, 500, 0])), None);
// Nothing overflows on the extremes of `i32`.
let extreme = decode(&record([0, i32::MIN, i32::MIN, i32::MAX, i32::MAX, i32::MIN, i32::MIN, -1]));
assert_eq!(extreme.map(|axes| axes.translation[0]), Some(i16::MIN));
assert_eq!(extreme.map(|axes| axes.translation[1]), Some(i16::MAX));
assert_eq!(extreme.map(|axes| axes.rotation[0]), Some(i16::MAX));
}
#[test]
fn a_stream_is_read_record_by_record_until_it_ends() {
let (mut daemon, client) = UnixStream::pair().expect("a socket pair");
let mut bytes = Vec::new();
bytes.extend(record([0, 100, 0, 0, 0, 0, 0, 8]));
bytes.extend(record([1, 4, 0, 0, 0, 0, 0, 0])); // button 4 down
bytes.extend(record([2, 4, 0, 0, 0, 0, 0, 0])); // and up
bytes.extend(record([1, 40, 0, 0, 0, 0, 0, 0])); // past the 32 a bitmask holds: skipped
bytes.extend(record([0, 0, 0, 0, 0, 0, 0, 900]));
bytes.extend([1, 2, 3]); // the daemon went away mid-record
daemon.write_all(&bytes).expect("written");
drop(daemon);
let (mut seen, mut buttons) = (Vec::new(), Vec::new());
read(client, |axes| seen.push(axes.translation), |number, down| buttons.push((number, down)), || false, Duration::from_secs(3600));
assert_eq!(seen, [[70, 0, 0], [0, 0, 0]]);
assert_eq!(buttons, [(4, true), (4, false)]);
}
#[test]
fn a_quiet_daemon_times_out_without_losing_the_record_it_was_sending() {
use crate::space_mouse::fake::Script;
use std::io::ErrorKind::{TimedOut, WouldBlock};
let bytes = record([0, 100, 0, 0, 0, 0, 0, 8]);
let mut script = Script::new();
script.timeout(WouldBlock).data(&bytes[..10]).timeout(TimedOut).timeout(WouldBlock).data(&bytes[10..]).timeout(WouldBlock).end();
let (mut seen, mut asked) = (Vec::new(), 0);
read(
script,
|axes| seen.push(axes.translation),
|_, _| {},
|| {
asked += 1;
false
},
Duration::from_secs(3600),
);
assert_eq!(seen, [[70, 0, 0]]);
assert_eq!(asked, 4, "each timeout asks whether to stop");
}
#[test]
fn a_stop_ends_the_read_at_the_next_timeout_only() {
use crate::space_mouse::fake::Script;
let bytes = record([0, 100, 0, 0, 0, 0, 0, 8]);
let mut script = Script::new();
script.data(&bytes).timeout(std::io::ErrorKind::WouldBlock).data(&bytes);
let mut seen = 0;
read(script, |_| seen += 1, |_, _| {}, || true, Duration::from_secs(3600));
assert_eq!(seen, 1, "the record before the timeout arrived; the one after never was read");
}
/// A daemon that never goes quiet (a moving cap sends every few milliseconds) is still asked whether to
/// stop, at the interval given, so the reader can go back to a device that returned.
#[test]
fn a_busy_daemon_is_asked_whether_to_stop_on_a_timer() {
// Ends by itself after half a second, so a read that never asks fails the assertion below
// instead of hanging the test.
struct Busy(Instant);
impl Read for Busy {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
if self.0.elapsed() > Duration::from_millis(500) {
return Ok(0);
}
std::thread::sleep(Duration::from_millis(2));
let bytes = record([0, 100, 0, 0, 0, 0, 0, 8]);
let size = bytes.len().min(buf.len());
buf[..size].copy_from_slice(&bytes[..size]);
Ok(size)
}
}
let (mut seen, mut asked) = (0, 0);
read(
Busy(Instant::now()),
|_| seen += 1,
|_, _| {},
|| {
asked += 1;
asked == 3
},
Duration::from_millis(10),
);
assert_eq!(asked, 3, "asked on the timer although no read ever timed out");
assert!(seen > 3, "records kept coming meanwhile: {seen}");
}
#[test]
fn no_daemon_means_no_connection() {
let missing = std::env::temp_dir().join(format!("sc-no-spnav-{}.sock", std::process::id()));
assert!(connect_to(&missing).is_none());
}
}
@@ -0,0 +1,172 @@
//! Windows: finding 3D mice without asking any other device anything (#2614).
//!
//! hidapi's enumeration opens every HID interface and reads its serial number, manufacturer and
//! product strings: on USB, three control requests to every keyboard, mouse and touchscreen. The
//! reader repeated it every two seconds while no 3D mouse was plugged in, and on some machines the
//! keyboard then kept dropping off the bus and the cursor jumped. Plug and Play already names each
//! interface's top-level usage in its hardware IDs (`HID_DEVICE_UP:0001_U:0008`: Generic Desktop,
//! Multi-Axis Controller), so the reader asks it, which sends nothing to any device, and opens only
//! the interfaces that are 3D mice.
//!
//! The configuration manager has no safe API, hence the `unsafe_code` allowance on this module
//! (the app crate denies it; see `native_crash.rs`).
#![allow(unsafe_code)]
use std::ffi::CString;
use std::ptr::{null, null_mut};
use windows_sys::Win32::Devices::DeviceAndDriverInstallation::{
CM_GET_DEVICE_INTERFACE_LIST_PRESENT, CM_Get_DevNode_PropertyW, CM_Get_Device_Interface_List_SizeW, CM_Get_Device_Interface_ListW,
CM_Get_Device_Interface_PropertyW, CM_LOCATE_DEVNODE_NORMAL, CM_Locate_DevNodeW, CONFIGRET, CR_BUFFER_SMALL, CR_SUCCESS,
};
use windows_sys::Win32::Devices::HumanInterfaceDevice::GUID_DEVINTERFACE_HID;
use windows_sys::Win32::Devices::Properties::{
DEVPKEY_Device_HardwareIds, DEVPKEY_Device_InstanceId, DEVPROP_TYPE_STRING, DEVPROP_TYPE_STRING_LIST, DEVPROPTYPE,
};
use windows_sys::Win32::Foundation::DEVPROPKEY;
/// The hardware ID Windows gives a top-level collection with the Generic Desktop page's
/// Multi-Axis Controller usage.
const MULTI_AXIS_CONTROLLER: &str = "HID_DEVICE_UP:0001_U:0008";
/// Largest property read, in bytes: hardware IDs are a few hundred.
const MAX_PROPERTY_BYTES: u32 = 64 * 1024;
/// Tries at reading the interface list, which can grow between asking its size and reading it.
const LIST_TRIES: usize = 4;
/// The paths of the present HID interfaces whose top-level collection is a multi-axis controller.
pub fn multi_axis_controllers() -> Vec<CString> {
interfaces()
.into_iter()
.filter(|path| {
let Some(instance) = interface_strings(path, &DEVPKEY_Device_InstanceId).and_then(|ids| ids.into_iter().next()) else {
return false;
};
devnode_strings(&instance, &DEVPKEY_Device_HardwareIds).is_some_and(|ids| is_multi_axis(&ids))
})
.filter_map(|path| CString::new(path).ok())
.collect()
}
/// Whether a device with these hardware IDs is a multi-axis controller.
fn is_multi_axis(hardware_ids: &[String]) -> bool {
hardware_ids.iter().any(|id| id.eq_ignore_ascii_case(MULTI_AXIS_CONTROLLER))
}
/// The strings of a nul-separated list (`REG_MULTI_SZ`, or one string), without empty ones.
fn split_multi_sz(buffer: &[u16]) -> Vec<String> {
buffer.split(|c| *c == 0).filter(|s| !s.is_empty()).map(String::from_utf16_lossy).collect()
}
/// `s` as a nul-terminated wide string.
fn wide(s: &str) -> Vec<u16> {
s.encode_utf16().chain(std::iter::once(0)).collect()
}
/// The paths of the present HID interfaces.
fn interfaces() -> Vec<String> {
for _ in 0..LIST_TRIES {
let mut len = 0u32;
// SAFETY: `len` is a valid place for the size; the class GUID is a static; no device filter.
let sized = unsafe { CM_Get_Device_Interface_List_SizeW(&mut len, &GUID_DEVINTERFACE_HID, null(), CM_GET_DEVICE_INTERFACE_LIST_PRESENT) };
if sized != CR_SUCCESS || len == 0 {
return Vec::new();
}
let mut buffer = vec![0u16; len as usize];
// SAFETY: `buffer` holds `len` wide characters, the length the call is given.
let listed =
unsafe { CM_Get_Device_Interface_ListW(&GUID_DEVINTERFACE_HID, null(), buffer.as_mut_ptr(), len, CM_GET_DEVICE_INTERFACE_LIST_PRESENT) };
match listed {
CR_SUCCESS => return split_multi_sz(&buffer),
CR_BUFFER_SMALL => continue,
_ => return Vec::new(),
}
}
Vec::new()
}
/// A string or string-list property, read by `get` (property type, buffer, its size in bytes) in
/// two calls: the size, then the value.
fn property_strings(get: impl Fn(*mut DEVPROPTYPE, *mut u8, *mut u32) -> CONFIGRET) -> Option<Vec<String>> {
let mut kind: DEVPROPTYPE = 0;
let mut size = 0u32;
if get(&mut kind, null_mut(), &mut size) != CR_BUFFER_SMALL || size == 0 || size > MAX_PROPERTY_BYTES {
return None;
}
let mut buffer = vec![0u16; (size as usize).div_ceil(2)];
let mut size = (buffer.len() * 2) as u32;
if get(&mut kind, buffer.as_mut_ptr().cast(), &mut size) != CR_SUCCESS || (kind != DEVPROP_TYPE_STRING && kind != DEVPROP_TYPE_STRING_LIST) {
return None;
}
Some(split_multi_sz(&buffer))
}
/// A string property of the device interface at `path`.
fn interface_strings(path: &str, key: &DEVPROPKEY) -> Option<Vec<String>> {
let path = wide(path);
property_strings(|kind, buffer, size| {
// SAFETY: `path` is nul-terminated and outlives the call; `buffer` is null (asking the size)
// or holds `*size` bytes, as `property_strings` sets up.
unsafe { CM_Get_Device_Interface_PropertyW(path.as_ptr(), key, kind, buffer, size, 0) }
})
}
/// A string property of the device with instance ID `instance`.
fn devnode_strings(instance: &str, key: &DEVPROPKEY) -> Option<Vec<String>> {
let instance = wide(instance);
let mut devnode = 0u32;
// SAFETY: `instance` is nul-terminated and outlives the call; `devnode` is a valid place for the result.
if unsafe { CM_Locate_DevNodeW(&mut devnode, instance.as_ptr(), CM_LOCATE_DEVNODE_NORMAL) } != CR_SUCCESS {
return None;
}
property_strings(|kind, buffer, size| {
// SAFETY: `buffer` is null (asking the size) or holds `*size` bytes, as `property_strings` sets up.
unsafe { CM_Get_DevNode_PropertyW(devnode, key, kind, buffer, size, 0) }
})
}
#[cfg(test)]
mod tests {
use super::*;
/// The hardware IDs Windows gives a wired 3Dconnexion device's motion interface, and a
/// keyboard's: only the first is a multi-axis controller.
#[test]
fn only_the_multi_axis_usage_is_a_3d_mouse() {
let ids = |list: &[&str]| list.iter().map(|s| s.to_string()).collect::<Vec<_>>();
let space_mouse = ids(&["HID\\VID_256F&PID_C635&REV_0437&MI_00", "HID\\VID_256F&UP:0001_U:0008", "HID_DEVICE_UP:0001_U:0008", "HID_DEVICE"]);
let keyboard = ids(&[
"HID\\VID_046D&PID_C31C&REV_6400&MI_00",
"HID\\VID_046D&UP:0001_U:0006",
"HID_DEVICE_SYSTEM_KEYBOARD",
"HID_DEVICE_UP:0001_U:0006",
"HID_DEVICE",
]);
let vendor = ids(&["HID\\VID_256F&UP:FF00_U:0001", "HID_DEVICE_UP:FF00_U:0001", "HID_DEVICE_UPR:FF00-FFFF", "HID_DEVICE"]);
assert!(is_multi_axis(&space_mouse));
assert!(is_multi_axis(&ids(&["hid_device_up:0001_u:0008"])), "IDs compare without regard to case");
assert!(!is_multi_axis(&keyboard) && !is_multi_axis(&vendor) && !is_multi_axis(&[]));
}
#[test]
fn nul_separated_lists_split_into_their_strings() {
let buffer: Vec<u16> = "a\\b\0HID_DEVICE\0\0".encode_utf16().collect();
assert_eq!(split_multi_sz(&buffer), ["a\\b", "HID_DEVICE"]);
assert_eq!(split_multi_sz(&wide("one")), ["one"]);
assert!(split_multi_sz(&[0, 0]).is_empty() && split_multi_sz(&[]).is_empty());
}
/// The scan asks Plug and Play only: on any machine it returns, and every path it returns is a
/// present HID interface whose hardware IDs name the multi-axis usage.
#[test]
fn the_scan_returns_only_multi_axis_interfaces() {
let found = multi_axis_controllers();
let all = interfaces();
for path in &found {
let path = path.to_str().unwrap_or_default();
assert!(all.iter().any(|p| p == path), "{path}");
let instance = interface_strings(path, &DEVPKEY_Device_InstanceId).and_then(|ids| ids.into_iter().next()).unwrap_or_default();
assert!(devnode_strings(&instance, &DEVPKEY_Device_HardwareIds).is_some_and(|ids| is_multi_axis(&ids)), "{path}");
}
eprintln!("{} HID interfaces present, {} multi-axis controllers", all.len(), found.len());
}
}
+18 -1
View File
@@ -157,6 +157,23 @@ pub fn choose_backends(
Some(WINDOWS_ORDER.into_iter().find(|b| has_hardware(*b)).unwrap_or(Backends::DX12))
}
/// The device feature for a 32-bit float depth buffer with a stencil (reversed depth, #1434).
pub const FLOAT_DEPTH_STENCIL: eframe::wgpu::Features = eframe::wgpu::Features::DEPTH32FLOAT_STENCIL8;
/// Whether every adapter `options` could start offers a float depth buffer with a stencil. eframe
/// makes the window's depth buffer from the depth bits before it picks the adapter, so the window
/// asks for a float one only when whichever it picks has it.
pub fn float_depth_everywhere(options: &eframe::egui_wgpu::WgpuConfiguration) -> bool {
let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &options.wgpu_setup else { return false };
let mut desc = eframe::wgpu::InstanceDescriptor::new_without_display_handle_from_env();
desc.backends = setup.instance_descriptor.backends;
let instance = eframe::wgpu::Instance::new(desc);
let adapters = pollster::block_on(instance.enumerate_adapters(setup.instance_descriptor.backends));
let all = !adapters.is_empty() && adapters.iter().all(|a| a.features().contains(FLOAT_DEPTH_STENCIL));
log::info!("graphics: float depth with stencil on every adapter: {all}");
all
}
/// The order backends are tried in on Windows.
pub const WINDOWS_ORDER: [eframe::wgpu::Backends; 3] = [eframe::wgpu::Backends::DX12, eframe::wgpu::Backends::VULKAN, eframe::wgpu::Backends::GL];
@@ -182,7 +199,7 @@ pub fn no_window_message(error: &str, settings: Option<&Path>) -> String {
- Over Remote Desktop or in a virtual machine, enable 3D acceleration or run it on the machine itself.\n\
- Start it once with `solidcraft --safe-mode`.\n\
- Rename the settings folder: {folder}\n\n\
More: https://bherbruck.github.io/solidcraft/guide/troubleshooting.html"
More: https://docs.solidcraft.io/guide/troubleshooting.html"
)
}
+117
View File
@@ -0,0 +1,117 @@
//! Keeping the restored window reachable.
//!
//! The window comes back where it was left. After a monitor was unplugged, the scale changed or
//! the settings came from a larger screen, that can put its title bar above the top edge or
//! under the taskbar, out of reach of the mouse. [`fit`] pulls a window back into the monitor's
//! work area (the screen without the taskbar), in whatever unit the caller measures both, as
//! long as it is the same one.
/// A rectangle: left, top, width, height.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Area {
pub x: f32,
pub y: f32,
pub w: f32,
pub h: f32,
}
impl Area {
pub const fn new(x: f32, y: f32, w: f32, h: f32) -> Self {
Area { x, y, w, h }
}
fn right(self) -> f32 {
self.x + self.w
}
fn bottom(self) -> f32 {
self.y + self.h
}
fn sane(self) -> bool {
[self.x, self.y, self.w, self.h].iter().all(|v| v.is_finite()) && self.w > 0.0 && self.h > 0.0
}
}
/// How much of the title bar must stay over the work area at least, to grab it.
pub const REACHABLE: f32 = 100.0;
/// The size a window gets when its saved geometry is unusable, before it is fitted to the screen.
pub const DEFAULT_SIZE: (f32, f32) = (1600.0, 1000.0);
/// `window` moved and shrunk so that it can be reached on `work`: no larger than the work area
/// (but not below `min`), the title bar not above the work area's top, the window not below its
/// bottom, and at least [`REACHABLE`] of it over the work area horizontally. A window that is
/// fine is returned unchanged. Geometry that makes no sense (not finite, empty), or a work
/// area that does not, gives the default size centred on the work area.
pub fn fit(window: Area, work: Area, min: (f32, f32)) -> Area {
if !work.sane() {
return window;
}
let min = (min.0.min(work.w), min.1.min(work.h));
if !window.sane() {
let w = DEFAULT_SIZE.0.min(work.w * 0.9).max(min.0);
let h = DEFAULT_SIZE.1.min(work.h * 0.9).max(min.1);
return Area::new(work.x + (work.w - w) / 2.0, work.y + (work.h - h) / 2.0, w, h);
}
let w = window.w.clamp(min.0, work.w);
let h = window.h.clamp(min.1, work.h);
let reach = REACHABLE.min(w);
// A window that had to shrink goes fully onto the screen; one that fits may keep straddling two.
let x = if w < window.w { window.x.max(work.x).min(work.right() - w) } else { window.x.max(work.x + reach - w).min(work.right() - reach) };
let y = window.y.min(work.bottom() - h).max(work.y);
Area::new(x, y, w, h)
}
#[cfg(test)]
mod tests {
use super::*;
const WORK: Area = Area::new(0.0, 0.0, 2560.0, 1400.0);
const MIN: (f32, f32) = (960.0, 600.0);
#[test]
fn a_window_on_screen_is_left_alone() {
let w = Area::new(300.0, 120.0, 1600.0, 1000.0);
assert_eq!(fit(w, WORK, MIN), w);
}
#[test]
fn a_title_bar_above_the_top_or_under_the_taskbar_comes_back() {
let fitted = fit(Area::new(300.0, -400.0, 1600.0, 1000.0), WORK, MIN);
assert_eq!((fitted.y, fitted.h), (0.0, 1000.0));
let fitted = fit(Area::new(300.0, 1300.0, 1600.0, 1000.0), WORK, MIN);
assert_eq!(fitted.y + fitted.h, 1400.0);
// A taskbar at the top moves the work area down.
let below_bar = Area::new(0.0, 48.0, 2560.0, 1352.0);
assert_eq!(fit(Area::new(300.0, 0.0, 1600.0, 1000.0), below_bar, MIN).y, 48.0);
}
#[test]
fn a_window_from_a_monitor_that_is_gone_keeps_a_grabbable_part_on_screen() {
let right = fit(Area::new(5000.0, 100.0, 1600.0, 1000.0), WORK, MIN);
assert_eq!(right.x, 2560.0 - REACHABLE);
let left = fit(Area::new(-4000.0, 100.0, 1600.0, 1000.0), WORK, MIN);
assert_eq!(left.x + left.w, REACHABLE);
}
#[test]
fn a_window_larger_than_the_screen_shrinks_to_it() {
let small = Area::new(0.0, 0.0, 1280.0, 720.0);
let fitted = fit(Area::new(-20.0, -30.0, 3200.0, 2000.0), small, MIN);
assert_eq!(fitted, small);
// Never below the minimum, unless the screen itself is smaller.
assert_eq!(fit(Area::new(0.0, 0.0, 100.0, 100.0), WORK, MIN).w, 960.0);
assert_eq!(fit(Area::new(0.0, 0.0, 100.0, 100.0), Area::new(0.0, 0.0, 800.0, 500.0), MIN).h, 500.0);
}
#[test]
fn unusable_geometry_is_centred_at_a_sensible_size() {
for bad in [Area::new(f32::NAN, 0.0, 1600.0, 1000.0), Area::new(0.0, 0.0, 0.0, 0.0), Area::new(0.0, 0.0, f32::INFINITY, 10.0)] {
let fitted = fit(bad, WORK, MIN);
assert_eq!(fitted, Area::new(480.0, 200.0, 1600.0, 1000.0));
}
let small = Area::new(100.0, 50.0, 1366.0, 728.0);
let fitted = fit(Area::new(f32::NAN, 0.0, 1.0, 1.0), small, MIN);
assert!(fitted.w <= small.w && fitted.h <= small.h && fitted.x >= small.x && fitted.y >= small.y);
// No work area to go by: nothing to change.
let w = Area::new(1.0, 2.0, 3.0, 4.0);
assert_eq!(fit(w, Area::new(0.0, 0.0, 0.0, 0.0), MIN), w);
}
}
+22
View File
@@ -0,0 +1,22 @@
# Resource work — mandatory
Read `assets/README.md` before changing media, fonts, artwork or their consumers.
For **any UI icon, icon assignment or review-tool change**, also read
`assets/icons/ui/AGENTS.md` and `assets/icons/ui/STYLE_GUIDE.md` before editing.
These files are the specification; chat history and old previews are not required.
- Keep one editable authority per resource. UI SVGs are masters, not exports of a
second competing drawing library. `source/` retains genuine native source and
the separately compiled Rust viewport glyphs.
- Keep only production inputs, necessary shipped exports, authoring tools and
provenance here. Put screenshots, contact sheets, audits, caches, test output,
review inbox files and package staging under ignored `target/`.
- Do not add historical alternatives, duplicate resolutions that packaging does
not consume, copied screenshots, `node_modules`, Python bytecode or generated
inventory CSVs to this collection. Preserve native editable source documents.
- Add an exact `ATTRIBUTION.md` row for every non-Markdown asset, including tool
code, web UI sources and metadata. Remove rows for intentionally retired files.
- Never copy or derive iconography from commercial CAD products. Use original
SolidCraft artwork, analytic geometry and the approved local references.
- Run `cargo xtask assets` and `cargo xtask icons`. Full repository gates still
apply before a commit. Never refresh acceptance hashes to bypass a failed check.
+8 -5
View File
@@ -13,7 +13,9 @@ assets/
macos/exports/ # paired Assets.car and icon.icns
windows/exports/ # executable DIB ICO and installer PNG ICO
linux/exports/hicolor/ # Linux/FreeBSD package images, 16–512 px
ui/source/ # original drawing library and other UI glyphs
ui/design/svg/ # authoritative original UI icon masters
ui/design/tools/ # authoring bases, checks and included review app
ui/source/ # Rust viewport glyphs, markers and navigation
branding/web/source/og.png # shared social preview card
styles/ui/palettes.rs # light/dark defaults
styles/web/loading.css # web loading/canvas styles
@@ -25,14 +27,15 @@ assets/
platforms/{macos,linux,windows}/ # bundle, freedesktop and WiX templates
archive/icons/macos-v1/
source/icon.vectorcraft # earlier editable work, retained
exports/ # all historical SVG, PNG and iconset exports
# redundant historical exports are retired
```
## Editing and regeneration
[UI artwork](icons/ui/README.md) identifies the drawing masters and their consumers.
Edit geometry and IDs together in the original Rust source and review changes in
both application themes. Palettes and model presets are also editable Rust data.
Read the mandatory [UI style guide](icons/ui/STYLE_GUIDE.md) before editing an icon.
Edit SVG geometry and its command/UI assignments together; inspect both themes
and small sizes. Temporary media and generated reports belong in ignored target/. Palettes and model presets are also editable Rust data.
The current macOS master is `icons/app/macos/source/SolidCraft.icon/`, including
its linked SVG layers and appearance settings. The earlier VectorCraft document
@@ -81,7 +84,7 @@ The CLI still supports arbitrary PNG/ICO exports with `solidcraft-cli icon`.
## Code and provenance
Resources remain compiled by their owning layer through existing APIs:
`ui-egui::icons::paint` and `Tokens` at L5, model presets in L2 `doc`, and sketch
`ui-egui::icons::paint_icon` and `Tokens` at L5, model presets in L2 `doc`, and sketch
fonts at L1. State, projection, interaction, commands and GPU bindings stay in
their current modules. Embedded data uses `CARGO_MANIFEST_DIR`; application code
needs no runtime resource registry or repository lookup.
+292
View File
@@ -0,0 +1,292 @@
//! Original starter visual data, MIT OR Apache-2.0. No external material library.
use crate::appearance_pack::{Category, GlassBoundary, GlassRecipe, OpaqueRecipe, TextureAxis, TextureRecipe, VisualPreset};
const WOOD: &str = "Uniform opaque preview only; grain resources/mapping are not applied";
const SOLAR: &str = "Uniform opaque preview only; no cell grid, busbars, electricity or efficiency claim";
const REGOLITH: &str = "Uniform opaque preview only; no granular displacement or measured lunar optical data";
const OPAQUE: &str = "Illustrative opaque isotropic PBR; no measured alloy, polymer or engineering properties";
const fn look(color: [u8; 3], metallic: f64, roughness: f64, dielectric_ior: f64) -> OpaqueRecipe {
OpaqueRecipe { color, metallic, roughness, dielectric_ior }
}
const fn maps(id: &'static str, repeat_mm: [f64; 2], grain: bool) -> Option<TextureRecipe> {
Some(TextureRecipe { id, repeat_mm, grain_axis: if grain { Some(TextureAxis::V) } else { None } })
}
const fn glass(transmission_linear_rgb: [f64; 3], ior: f64, reference_path_mm: f64, boundary: GlassBoundary) -> GlassRecipe {
GlassRecipe { transmission_linear_rgb, dielectric_ior: ior, reference_path_mm, boundary }
}
pub const STARTER_PACK: [VisualPreset; 40] = [
VisualPreset::opaque(
"starter.wood.oak-honey",
"Wood - Honey Oak Uniform Preview",
Category::Wood,
look([190, 145, 88], 0.0, 0.64, 1.5),
WOOD,
maps("starter.wood.oak-honey.maps-v1", [120.0, 240.0], true),
),
VisualPreset::opaque(
"starter.wood.oak-smoked",
"Wood - Smoked Oak Uniform Preview",
Category::Wood,
look([91, 72, 55], 0.0, 0.68, 1.5),
WOOD,
maps("starter.wood.oak-smoked.maps-v1", [120.0, 240.0], true),
),
VisualPreset::opaque(
"starter.wood.walnut-dark",
"Wood - Dark Walnut Uniform Preview",
Category::Wood,
look([99, 66, 43], 0.0, 0.60, 1.5),
WOOD,
maps("starter.wood.walnut-dark.maps-v1", [100.0, 220.0], true),
),
VisualPreset::opaque(
"starter.wood.maple-light",
"Wood - Light Maple Uniform Preview",
Category::Wood,
look([221, 192, 141], 0.0, 0.57, 1.5),
WOOD,
maps("starter.wood.maple-light.maps-v1", [100.0, 200.0], true),
),
VisualPreset::opaque(
"starter.wood.cherry-warm",
"Wood - Warm Cherry Uniform Preview",
Category::Wood,
look([164, 99, 65], 0.0, 0.58, 1.5),
WOOD,
maps("starter.wood.cherry-warm.maps-v1", [110.0, 220.0], true),
),
VisualPreset::opaque(
"starter.wood.ash-pale",
"Wood - Pale Ash Uniform Preview",
Category::Wood,
look([209, 186, 146], 0.0, 0.70, 1.5),
WOOD,
maps("starter.wood.ash-pale.maps-v1", [120.0, 260.0], true),
),
VisualPreset::opaque(
"starter.wood.birch-cream",
"Wood - Cream Birch Uniform Preview",
Category::Wood,
look([228, 206, 163], 0.0, 0.63, 1.5),
WOOD,
maps("starter.wood.birch-cream.maps-v1", [100.0, 200.0], true),
),
VisualPreset::opaque(
"starter.wood.teak-golden",
"Wood - Golden Teak Uniform Preview",
Category::Wood,
look([167, 122, 66], 0.0, 0.61, 1.5),
WOOD,
maps("starter.wood.teak-golden.maps-v1", [110.0, 250.0], true),
),
VisualPreset::opaque(
"starter.metal.carbon-steel-polished",
"Metal - Polished Carbon Steel",
Category::Metal,
look([161, 166, 173], 1.0, 0.16, 1.5),
OPAQUE,
None,
),
VisualPreset::opaque(
"starter.metal.stainless-soft-satin",
"Metal - Soft Satin Stainless",
Category::Metal,
look([187, 193, 201], 1.0, 0.36, 1.5),
OPAQUE,
None,
),
VisualPreset::opaque(
"starter.metal.aluminum-bead-blast",
"Metal - Bead Blast Aluminum",
Category::Metal,
look([195, 201, 210], 1.0, 0.58, 1.5),
OPAQUE,
None,
),
VisualPreset::opaque(
"starter.metal.aluminum-silver-polish",
"Metal - Silver Polish Aluminum",
Category::Metal,
look([218, 224, 232], 1.0, 0.13, 1.5),
OPAQUE,
None,
),
VisualPreset::opaque(
"starter.metal.copper-aged-warm",
"Metal - Warm Aged Copper",
Category::Metal,
look([171, 95, 57], 1.0, 0.48, 1.5),
OPAQUE,
None,
),
VisualPreset::opaque(
"starter.metal.brass-champagne",
"Metal - Champagne Satin Brass",
Category::Metal,
look([205, 173, 102], 1.0, 0.32, 1.5),
OPAQUE,
None,
),
VisualPreset::opaque(
"starter.metal.bronze-dark-cast",
"Metal - Dark Cast Bronze",
Category::Metal,
look([133, 95, 58], 1.0, 0.66, 1.5),
OPAQUE,
None,
),
VisualPreset::opaque(
"starter.metal.titanium-matte",
"Metal - Matte Gray Titanium",
Category::Metal,
look([147, 149, 157], 1.0, 0.53, 1.5),
OPAQUE,
None,
),
VisualPreset::opaque(
"starter.metal.gold-warm",
"Metal - Warm Reflective Gold",
Category::Metal,
look([235, 190, 82], 1.0, 0.18, 1.5),
OPAQUE,
None,
),
VisualPreset::opaque(
"starter.metal.silver-cool",
"Metal - Cool Reflective Silver",
Category::Metal,
look([225, 230, 237], 1.0, 0.12, 1.5),
OPAQUE,
None,
),
VisualPreset::opaque(
"starter.plastic.abs-charcoal",
"Plastic - Charcoal ABS",
Category::Plastic,
look([43, 45, 48], 0.0, 0.44, 1.5),
OPAQUE,
None,
),
VisualPreset::opaque(
"starter.plastic.abs-warm-white",
"Plastic - Warm White ABS",
Category::Plastic,
look([232, 229, 220], 0.0, 0.40, 1.5),
OPAQUE,
None,
),
VisualPreset::opaque("starter.plastic.pla-coral", "Plastic - Coral PLA", Category::Plastic, look([218, 99, 83], 0.0, 0.43, 1.5), OPAQUE, None),
VisualPreset::opaque(
"starter.plastic.petg-cyan-opaque",
"Plastic - Opaque Cyan PETG",
Category::Plastic,
look([36, 148, 167], 0.0, 0.29, 1.5),
OPAQUE,
None,
),
VisualPreset::opaque(
"starter.plastic.nylon-ivory",
"Plastic - Ivory Nylon",
Category::Plastic,
look([220, 213, 190], 0.0, 0.62, 1.5),
OPAQUE,
None,
),
VisualPreset::opaque(
"starter.plastic.pc-graphite-opaque",
"Plastic - Opaque Graphite PC",
Category::Plastic,
look([70, 76, 84], 0.0, 0.27, 1.5),
OPAQUE,
None,
),
VisualPreset::opaque(
"starter.plastic.hdpe-natural-opaque",
"Plastic - Opaque Natural HDPE",
Category::Plastic,
look([221, 222, 207], 0.0, 0.48, 1.5),
OPAQUE,
None,
),
VisualPreset::opaque("starter.plastic.pom-black", "Plastic - Black POM", Category::Plastic, look([29, 31, 35], 0.0, 0.32, 1.5), OPAQUE, None),
VisualPreset::opaque(
"starter.plastic.tpu-matte-black",
"Plastic - Matte Black TPU",
Category::Plastic,
look([31, 30, 32], 0.0, 0.83, 1.5),
OPAQUE,
None,
),
VisualPreset::opaque(
"starter.plastic.peek-amber-opaque",
"Plastic - Opaque Amber PEEK",
Category::Plastic,
look([157, 119, 62], 0.0, 0.51, 1.5),
OPAQUE,
None,
),
VisualPreset::glass(
"starter.glass.clear-soda-lime",
"Glass Recipe - Clear Soda Lime",
glass([0.92, 0.98, 0.96], 1.52, 3.0, GlassBoundary::Closed),
),
VisualPreset::glass("starter.glass.low-iron-clear", "Glass Recipe - Low Iron Clear", glass([0.98, 0.99, 1.0], 1.50, 4.0, GlassBoundary::Closed)),
VisualPreset::glass("starter.glass.smoky-neutral", "Glass Recipe - Smoky Neutral", glass([0.35, 0.38, 0.42], 1.52, 3.0, GlassBoundary::Closed)),
VisualPreset::glass("starter.glass.amber-tint", "Glass Recipe - Amber Tint", glass([0.84, 0.38, 0.08], 1.51, 3.0, GlassBoundary::Closed)),
VisualPreset::glass("starter.glass.cobalt-blue", "Glass Recipe - Cobalt Blue", glass([0.06, 0.14, 0.76], 1.53, 3.0, GlassBoundary::Closed)),
VisualPreset::glass(
"starter.glass.thin-clear",
"Glass Recipe - Thin Clear Sheet",
glass([0.95, 0.98, 0.99], 1.50, 2.0, GlassBoundary::Thin { thickness_mm: 2.0 }),
),
VisualPreset::opaque(
"starter.solar.mono-blue",
"Solar Panel - Mono Blue Uniform Preview",
Category::Solar,
look([20, 41, 85], 0.0, 0.20, 1.5),
SOLAR,
maps("starter.solar.mono-blue.maps-v1", [160.0, 160.0], false),
),
VisualPreset::opaque(
"starter.solar.thin-film-dark",
"Solar Panel - Dark Thin Film Uniform Preview",
Category::Solar,
look([28, 33, 42], 0.0, 0.28, 1.5),
SOLAR,
maps("starter.solar.thin-film-dark.maps-v1", [100.0, 200.0], false),
),
VisualPreset::opaque(
"starter.regolith.mare-charcoal",
"Regolith - Mare Charcoal Uniform Preview",
Category::Regolith,
look([56, 56, 55], 0.0, 0.97, 1.5),
REGOLITH,
maps("starter.regolith.mare-charcoal.maps-v1", [200.0, 200.0], false),
),
VisualPreset::opaque(
"starter.regolith.highland-pale",
"Regolith - Highland Pale Uniform Preview",
Category::Regolith,
look([116, 113, 106], 0.0, 0.93, 1.5),
REGOLITH,
maps("starter.regolith.highland-pale.maps-v1", [200.0, 200.0], false),
),
VisualPreset::opaque(
"starter.regolith.dust-neutral",
"Regolith - Neutral Dust Uniform Preview",
Category::Regolith,
look([86, 85, 81], 0.0, 1.0, 1.5),
REGOLITH,
maps("starter.regolith.dust-neutral.maps-v1", [150.0, 150.0], false),
),
VisualPreset::opaque(
"starter.regolith.agglutinate-dark",
"Regolith - Dark Agglutinate Uniform Preview",
Category::Regolith,
look([66, 63, 60], 0.0, 0.89, 1.5),
REGOLITH,
maps("starter.regolith.agglutinate-dark.maps-v1", [200.0, 200.0], false),
),
];
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

@@ -1,63 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1024" height="1024" viewBox="0 0 1024 1024">
<defs>
<filter id="filter-1" filterUnits="userSpaceOnUse" x="64.5" y="64.5" width="895" height="895" data-vc-blend="multiply" color-interpolation-filters="sRGB"><feGaussianBlur in="SourceAlpha" stdDeviation="6.5"/><feOffset dx="0" dy="10" result="shadow"/><feFlood flood-color="#243047" flood-opacity="0.16"/><feComposite in2="shadow" operator="in" result="paint"/><feMerge><feMergeNode in="paint"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
<linearGradient id="linear-gradient-1" x1="512" y1="96" x2="512" y2="928" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#fcfdfe"/>
<stop offset="1" stop-color="#dce3ed"/>
</linearGradient>
<clipPath id="clip-path-1">
<path d="M320 96 L704 96 C858 96 928 166 928 320 L928 704 C928 858 858 928 704 928 L320 928 C166 928 96 858 96 704 L96 320 C96 166 166 96 320 96 Z"/>
</clipPath>
<radialGradient id="radial-gradient-1" cx="0" cy="0" r="568.512" gradientTransform="matrix(1 0 0 0.5 512 697.379)" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#507eaa"/>
<stop offset="0.3" stop-color="#507eaa" stop-opacity="0.9"/>
<stop offset="0.65" stop-color="#507eaa" stop-opacity="0.35"/>
<stop offset="1" stop-color="#507eaa" stop-opacity="0"/>
</radialGradient>
<linearGradient id="linear-gradient-2" x1="409.324" y1="139.358" x2="520.928" y2="511" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#8dc4fc"/>
<stop offset="1" stop-color="#5d9feb"/>
</linearGradient>
<linearGradient id="linear-gradient-3" x1="264.239" y1="342.478" x2="442.805" y2="882.642" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#438feb"/>
<stop offset="1" stop-color="#2b72cf"/>
</linearGradient>
<linearGradient id="linear-gradient-4" x1="610.212" y1="420.601" x2="732.976" y2="882.642" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#2869bd"/>
<stop offset="1" stop-color="#1e4d98"/>
</linearGradient>
<linearGradient id="linear-gradient-5" x1="599.051" y1="431.761" x2="738.557" y2="588.007" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#83baf6"/>
<stop offset="1" stop-color="#5597e6"/>
</linearGradient>
<linearGradient id="linear-gradient-6" x1="599.051" y1="242.034" x2="660.434" y2="418.369" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#306cbc"/>
<stop offset="1" stop-color="#22529c"/>
</linearGradient>
<clipPath id="clip-path-2">
<path d="M512 139.358 L672.71 231.99 L672.71 394.877 L694.445 406.957 L693.395 430.291 L833.42 511 L833.42 697.379 L512 882.642 L351.29 790.01 L351.29 603.631 L190.58 511 L190.58 325.737 Z"/>
</clipPath>
</defs>
<title>SolidCraft macOS application icon</title>
<g id="Layer_1" data-name="Layer 1">
<g filter="url(#filter-1)">
<path id="Porcelain___legacy_macOS_silhouette" data-name="Porcelain • legacy macOS silhouette" d="M320 96 L704 96 C858 96 928 166 928 320 L928 704 C928 858 858 928 704 928 L320 928 C166 928 96 858 96 704 L96 320 C96 166 166 96 320 96 Z" fill="url(#linear-gradient-1)"/>
</g>
</g>
<g id="Experiment___faint_origin_grid" data-name="Experiment • faint origin grid" clip-path="url(#clip-path-1)">
<path id="Grid___elliptical_radial_alpha_beneath_solid" data-name="Grid • elliptical radial alpha beneath solid" d="M80 -200.041 L944 297.959 M80 297.959 L944 -200.041 M80 -107.41 L944 390.59 M80 390.59 L944 -107.41 M80 -14.778 L944 483.222 M80 483.222 L944 -14.778 M80 77.853 L944 575.853 M80 575.853 L944 77.853 M80 170.485 L944 668.485 M80 668.485 L944 170.485 M80 263.116 L944 761.116 M80 761.116 L944 263.116 M80 355.748 L944 853.748 M80 853.748 L944 355.748 M80 541.01 L944 1039.01 M80 1039.01 L944 541.01 M80 633.642 L944 1131.642 M80 1131.642 L944 633.642 M80 726.273 L944 1224.273 M80 1224.273 L944 726.273 M80 818.905 L944 1316.905 M80 1316.905 L944 818.905 M80 911.536 L944 1409.536 M80 1409.536 L944 911.536 M80 1004.168 L944 1502.168 M80 1502.168 L944 1004.168" fill="none" stroke="url(#radial-gradient-1)" stroke-width="3.2" stroke-miterlimit="10" opacity="0.28"/>
<path id="Origin_axes___shared_elliptical_radial_alpha" data-name="Origin axes • shared elliptical radial alpha" d="M80 448.379 L944 946.379 M80 946.379 L944 448.379" fill="none" stroke="url(#radial-gradient-1)" stroke-width="3.6" stroke-miterlimit="10" opacity="0.28"/>
</g>
<g id="SolidCraft___machined_solid" data-name="SolidCraft • machined solid">
<path id="Solid_silhouette" data-name="Solid silhouette" d="M512 139.358 L672.71 231.99 L672.71 418.369 L833.42 511 L833.42 697.379 L512 882.642 L351.29 790.01 L351.29 603.631 L190.58 511 L190.58 325.737 Z" fill="#2864b8"/>
<path id="Top___extruded_profile" data-name="Top • extruded profile" d="M190.58 325.737 L512 139.358 L672.71 231.99 L512 325.737 L672.71 418.369 L512 511 Z" fill="url(#linear-gradient-2)"/>
<path id="Left___stepped_face" data-name="Left • stepped face" d="M190.58 325.737 L512 511 L512 882.642 L351.29 790.01 L351.29 603.631 L190.58 511 Z" fill="url(#linear-gradient-3)"/>
<path id="Right___milled_recess" data-name="Right • milled recess" d="M512 511 L672.71 418.369 L672.71 603.631 L833.42 511 L833.42 697.379 L512 882.642 Z" fill="url(#linear-gradient-4)"/>
<path id="Top___lower_step" data-name="Top • lower step" d="M672.71 418.369 L833.42 511 L672.71 603.631 Z" fill="url(#linear-gradient-5)"/>
<path id="Inner___upper_step" data-name="Inner • upper step" d="M672.71 231.99 L672.71 418.369 L512 325.737 Z" fill="url(#linear-gradient-6)"/>
<g clip-path="url(#clip-path-2)">
<path id="Cut_edges___original_SolidCraft_topology" data-name="Cut edges • original SolidCraft topology" d="M190.58 325.737 L512 511 L512 882.642 M512 511 L672.71 418.369 M672.71 231.99 L512 325.737 L672.71 418.369 L672.71 603.631 L833.42 511" fill="none" stroke="#edf5ff" stroke-width="23" stroke-miterlimit="10" opacity="0.88"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 629 B

After

Width:  |  Height:  |  Size: 660 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 944 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Some files were not shown because too many files have changed in this diff Show More