Compare commits

...

1211 Commits

Author SHA1 Message Date
bherbruck ee6b85605a Release 0.28.0 2026-10-30 19:50:14 -04:00
bherbruck 045cfed6a7 Release 0.28.0 (#9766)
Fold the pending design format changes (browser-nested-groups, point-cloud-properties, print-faces) into format 45.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 19:50:02 -04:00
github-actions[bot] 5f60d78658 Regenerate generated files and fold pending files [skip ci] 2026-10-30 23:45:03 +00:00
Dahak Builds 646eb97eaf motion.list gives study keys in motion.study units (#9741) (#9760)
* claim: #9741

* motion.list gives study keys in motion.study units

Angle keys came back in radians while motion.study takes degrees, so an
agent that listed a study and wrote it back changed every angle by a
factor of 57.3, silently. Convert key values on the way out like
joint.list does (degrees for angles, mm for distances), give each key a
unit and its joint's name, and add a round-trip regression test.

---------

Co-authored-by: Dahak Builds <dahakbuilds@users.noreply.github.com>
2026-10-30 23:03:40 +00:00
PaddleStroke 2cf8df2ef6 Section caps: a body touching another along a curved face keeps its own cap (#9407) (#9665)
* claim: #9407

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

* Section caps: a body touching another along a curved face keeps its own cap (#9407)

Seen through a cut, a far wall and another body's face lying on it (a shaft
in its bore, a ring in its housing) are the same surface faceted two ways, so
the marking pass let the other body's face win in streaks. The section's
passes now first count the faces behind the cut in the stencil (back +1,
front -1): where the count says the cut runs through a body but a front face
won, the back faces are drawn again pulled toward the eye by a few pixels and
take the cap back.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:01:28 +00:00
PaddleStroke 0096e694dd Parameters: an option to apply edits together, when you choose (#9279) (#9400)
* claim: #9279

* Parameters: an option to hold edits until Apply or OK (#9279)

"Apply each change as you leave its field" (on by default, remembered in
the preferences as parameters_apply_live). Off: an expression edit is
checked when its field is left but waits, without a preview; Apply or OK
sets every held edit in one parameters.change {changes}, so the design
rebuilds once and one undo takes them back. Cancel, the close button and
Escape drop them. Turning the option back on applies what waits.

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

* Docs: the parameters_apply_live preference in ui.prefs (#9279)

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

* Parameters: the hint says edits wait for Apply or OK when they do (#9279)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:57:45 +00:00
github-actions[bot] c29bd18190 Regenerate generated files and fold pending files [skip ci] 2026-10-30 23:16:06 +00:00
Badger B 8e9e270027 Add German translations, batch 6 (#7237) (#9759)
Adds 130 German translations for messages that exist in en.json but are
missing from de.json, in a new pending file as described in
assets/locales/pending/README.md. Covers the remaining CPU lighting and
reflection controls, render diagnostics, lens and film effects, GPU path
tracer options, HDR source details, the resource library and the raw
linear EXR options.
2026-10-30 22:51:12 +00:00
fkirill 1b927421f6 Restore Browser and source-body context during Extrude editing (#5573) (#5576)
* claim: #5573

* Keep Browser and source bodies in the Extrude edit context (#5573)

* fix: keep Browser feature rows after manual rollback

---------

Co-authored-by: Kirill Frolov <k.a.frolov@vk.team>
2026-10-30 22:03:03 +00:00
PaddleStroke 8d22b3a41a DXF export: the full R2000 file structure strict readers need (#9144) (#9662)
* claim: #9144

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

* DXF export: the full release 2000 file structure strict readers need (#9144)

The view and flat pattern DXF left out parts of the AC1015 file that a
strict reader expects: the CLASSES section, the VPORT, VIEW, UCS, APPID
and DIMSTYLE tables (with *Active, ACAD and Standard) and the ACAD_GROUP
dictionary under the root dictionary. They are written now, along with
$DWGCODEPAGE; the active viewport looks at the whole drawing.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:01:27 +00:00
PaddleStroke edcf02c0d4 Crash reports: a window opened after startup no longer labels the run 'during startup' (#8776) (#9661)
* claim: #8776

* Crash reports: a window opened after startup no longer labels the run "during startup" (#8776)

egui-wgpu reports "window surface" and "surface configure" for every native
window, also the ones opened after startup, such as the agent chat in its own
window. Each one overwrote the journal's "running" phase, so a later unclean
exit was reported as "during startup (surface configure, N s after launch)".
crash::startup_step records a startup step only until the app runs, and the
app's egui-wgpu hook calls it.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:00:19 +00:00
rugbymauri b85d5a665c Browser: the recover-unsaved-work card always shows Recover and Discard (#9472) (#9636)
* claim: #9472

Signed-off-by: Maurizio Monticelli <maurizio@whatwedo.ch>

* Browser: the recover-unsaved-work card always shows Recover and Discard

A window's scrolling body was only offered the height the card had the
frame before. A card first laid out while the page was still tiny (a
browser canvas not sized yet) kept a body of no height for good, so only
its title showed and the required choice could not be made. The body is
now offered the room the screen leaves and shrinks to its content, which
also covers the desktop recovery card and every other system window.

Fixes #9472

Signed-off-by: Maurizio Monticelli <maurizio@whatwedo.ch>

---------

Signed-off-by: Maurizio Monticelli <maurizio@whatwedo.ch>
2026-10-30 21:41:29 +00:00
github-actions[bot] a232df7751 Regenerate generated files and fold pending files [skip ci] 2026-10-30 22:20:32 +00:00
Badger B 4757c3cee6 Add German translations, batch 5 (#7237) (#9697)
Adds 130 German translations for messages that exist in en.json but are
missing from de.json, in a new pending file as described in
assets/locales/pending/README.md. Covers the quick measurement, a few
Preferences labels and a large part of the Render panel (adaptive
sampling, surface aging, camera background, CPU lighting and environment).
2026-10-30 21:27:08 +00:00
github-actions[bot] 736f334301 Regenerate generated files and fold pending files [skip ci] 2026-10-30 21:50:16 +00:00
semmelrinde e721004f62 Croatian: translate the 14 messages main gained after batch 8, new Preferences included (batch 9) (#9540)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 21:00:37 +00:00
rugbymauri 88d87729d4 Browser History: drag rows to reorder features (#9565) (#9599)
* claim: #9565

Signed-off-by: Maurizio Monticelli <maurizio@whatwedo.ch>

* feat(browser): reorder features by dragging rows of the History in the browser

Let users drag features in the History shown in the browser to move them, with the same drop bar and checks as the History at the left, while the rollback marker still drags on its own.

refs: #9565
Signed-off-by: Maurizio Monticelli <maurizio@whatwedo.ch>

---------

Signed-off-by: Maurizio Monticelli <maurizio@whatwedo.ch>
2026-10-30 21:00:29 +00:00
ABDULLAH c7597ea66f Arabic: render term in the CPU resource profile size notes, pan and tilt verbs in the 3D mouse sketch orbit note (#9693) 2026-10-30 21:00:22 +00:00
github-actions[bot] f3389c3af7 Regenerate generated files and fold pending files [skip ci] 2026-10-30 21:19:55 +00:00
Janiz-zzz a0a52f6657 Add Finnish (fi) UI localization (#9626) (#9631)
* Add Finnish (fi) UI localization (#9626)

A fi catalog ("Suomi") translated from en.json, all 4801 messages,
registered in LOCALES between es and fr, with
finnish_catalog_is_registered_and_translates_keys, the language listed
in docs/localization.md (registered and partial) with its terms, and a
pending attribution row.

Fixes #9626

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

* Finnish: the 3D mouse Restore defaults button gets its own label

It shared its text with Restore defaults elsewhere on the page, so the layout test found
the wrong button.

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

---------

Co-authored-by: Janiz-zzz <267742026+Janiz-zzz@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-30 20:46:03 +00:00
github-actions[bot] cc9787f80e Regenerate generated files and fold pending files [skip ci] 2026-10-30 20:50:14 +00:00
fkirill 8e80d98610 Pre-highlight whole bodies for body selection inputs (#5572) (#5575)
* claim: #5572

* Highlight whole bodies for body selection inputs (#5572)
2026-10-30 20:24:45 +00:00
semmelrinde f8a8114843 Malay: translate the 35 messages main gained after batch 8, new Preferences included (batch 9) (#9535)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 20:24:22 +00:00
rugbymauri 98bfe1af9c Viewport: a docked Assistant beside an inverted view area no longer crashes (#9492) (#9499)
* claim: #9492

Signed-off-by: Maurizio Monticelli <maurizio@whatwedo.ch>

* fix(viewport): keep running when the view area comes out inside out

Make the layout beside the docked Assistant give way instead of closing the app when a window reconfigure leaves the view area inverted.

refs: #9492
Signed-off-by: Maurizio Monticelli <maurizio@whatwedo.ch>

---------

Signed-off-by: Maurizio Monticelli <maurizio@whatwedo.ch>
2026-10-30 20:06:19 +00:00
Alex Lytvynenko ffbf9836cc Agent chat: support ACP configuration options and OpenCode V2 (#3305) (#3867)
* claim: #3305

* fix(agent-chat): support ACP configuration options (#3305)

Handle current select options for model, thinking effort and mode, with confirmed snapshots and legacy compatibility. Full quality gate attempted; draft delivery authorized with unrelated geometry/property/timing failures documented in PR #3867.

* fix(agent-chat): keep configuration selectors inline (#3305)

Use compact value pickers and bounded wrapping to preserve conversation space; verified at normal and narrow widths with the installed OpenCode V2 server.

* style(agent-chat): format tool events
2026-10-30 20:00:26 +00:00
Homero Thompson 806adb6c46 A sketch curve id that breaks a name is refused (#5858) (#9606)
* claim: #5858

* fix(#5858): a curve id that cannot be written into a name is refused
2026-10-30 19:53:51 +00:00
phanoob 250ed2f266 Parameters: the expression field opens wide while you edit it (#9358) (#9384)
* claim: #9358

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

* Parameters: the expression field opens wide while you edit it (#9358)

A parameter's expression that is too long for its cell opens over the
cells to its right while it has focus, wraps instead of scrolling, and
shows the live value or error under it. Enter still applies it and the
field returns to its column; a short expression stays in its cell. The
add row's expression field does the same.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 19:47:44 +00:00
bherbruck 56f07a5598 Hover test: allow the first hover half of the index time, not a third (#9638)
On a loaded runner the hover alone has taken 70 ms against a 190 ms index, which failed
unrelated pull requests. Waiting for the index would still cost all of it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 19:41:24 +00:00
rlartot dfdad35d08 Extrude: a sketch picked in the Browser or History takes all its profiles (#6106) (#6154)
* claim: #6106

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

* Extrude: a sketch picked in the Browser or History takes all its profiles (#6106)

With Extrude, Revolve or Emboss open, a plain click on a sketch row in the
Browser or on a sketch in the History now fills Profiles with every profile of
that sketch (the island inside another profile's hole is left out). Before, the
click only changed the selection, which the dialog never reads.

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 19:29:34 +00:00
github-actions[bot] 1cef0d83df Regenerate generated files and fold pending files [skip ci] 2026-10-30 19:48:48 +00:00
PaddleStroke a99c57ffc7 3D mouse: optional orbit while a sketch is open (#2227) (#9406)
* claim: #2227

* 3D mouse: optional orbit while a sketch is open (#2227)

A new Navigation preference, "Turn sketches with the 3D mouse"
(space_mouse_sketch_orbit, off by default), lets the cap and a device
app turn the view while a sketch is open and seen square on, as the
mouse already can. Off keeps today's behaviour: pan and zoom until the
view is tilted away from the sketch (#6687). Restore defaults on the
3D mouse section resets it.

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

* 3D mouse: keep the settings list in order after the rebase

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-30 19:22:58 +00:00
Colin Faulkingham cdffdcbbef Selection mode: the Modelling SELECT panel shows and clears component picking (#9382) (#9485)
* claim: #9382

* Show component picking in the Modelling Select panel (#9382)
2026-10-30 19:03:10 +00:00
ABDULLAH 4f05716f69 Arabic: follow the shortened English of the quick measurement preference note (#9604) 2026-10-30 19:03:07 +00:00
semmelrinde f0567d6a48 Hungarian: translate the 35 messages main gained after batch 8, new Preferences included (batch 9) (#9528)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 18:57:35 +00:00
semmelrinde 6e9ffe83d2 Romanian: translate the 35 messages main gained after batch 8, new Preferences included (batch 9) (#9531)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 18:57:28 +00:00
semmelrinde 2647ec51fc Ukrainian: translate the 95 messages the catalog lacks (#9591)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 18:57:17 +00:00
AsTechEvolves 23587433e2 Assistant: say when a Claude reply hit the length limit or was declined, not that the model lacks tools (#9495) (#9500)
* claim: #9495

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

* Assistant: say when a reply hit its length limit or was declined, not that the model lacks tools (#9495)

The API client never read why an answer stopped, so a Claude reply cut
off at max_tokens while thinking (its thinking text is empty by default),
or a refusal, ended the turn as normal with nothing said. The chat then
blamed tool support: "The model didn't use SolidCraft's tools".

Reply now carries the stop reason: Anthropic stop_reason max_tokens and
refusal, or OpenAI finish_reason length and content_filter and a message
refusal. Any other value is a normal end. The turn ends with that reason,
which already keeps the tool-calling notice away, and the chat shows
what happened in place of the raw "(max_tokens)", for ACP agents too. A
tool call cut off at the length limit has incomplete input, so it is
neither run nor kept in the history.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 18:56:14 +00:00
Daniel Dimitrov 0414de7b98 Hole: each dropdown option shows the hole it makes while hovered (#9577)
* Hole: each dropdown option shows the hole it makes while hovered

Extents, Hole Type, Hole Tap Type and Drill Point list names only, so seeing
what an option makes meant picking it. Hovering an option now shows a small
section of that hole beside the list, drawn by the dialog's own section
painter from a copy of the form with the option applied.

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

* Hole: give the dropdown previews a gallery entry

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

---------

Co-authored-by: danieldimitrov86 <danieldimitrovdev@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-30 18:54:18 +00:00
Kevin Ravensberg 295bd0e165 Render: fit the product inside the picture's frame, after the saved setup loads (#5763) (#5764)
While Frame shows the picture's frame, the preview projects into that frame
rather than the viewport, but Fit Product and the fit on entering Render
framed the product for the viewport's free area, so a picture taller than
wide cut the product at its sides. The fit now uses the viewport layout
seen from the frame (the frame as the viewport, the free area inside it
with the usual 16-point margin).

Entering Render fitted before the saved setup replaced the draft, so a cold
workspace with a portrait setup fitted for the old draft's aspect; the fit
now runs once the setup in effect is loaded.

render.camera.fit takes the picture's aspect (0.000001 to 1000000) through
Camera::fit_aspect, which publishes only a valid fitted camera: an aspect
that would divide the distance into infinity is refused and the previous
camera, document and undo stay as they were.
2026-10-30 18:30:45 +00:00
github-actions[bot] 0b34d3590d Regenerate generated files and fold pending files [skip ci] 2026-10-30 19:12:20 +00:00
austin62jake aedbf0b1d4 Preferences: an option to keep value boxes out of the view (#9479) (#9547)
* Claim #9479

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

* Preferences: an option to keep value boxes out of the view (#9479)

Design > "Show values beside their handles in the view", on by default.
Off, a command's value box (Extrude's Distance, Fillet's Radius, a hole's
reference distances...) isn't drawn over the model; the command window's
value field takes the keyboard that opening the window or a pick gives the
box, its text selected, so a number typed after a pick still sets it. The
handles stay and still drag the value.

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

* Preferences: the value box option in Arabic, Hebrew and Vietnamese too (#9479)

The complete catalogs must carry every English message.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-30 18:30:04 +00:00
semmelrinde a13e0e0ceb Danish: translate the 1055 messages the catalog lacks outside the Render workspace (catch-up 1 of 2) (#9589)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 18:29:51 +00:00
semmelrinde 6f05c19c42 Danish: translate the Render workspace's 1245 messages (catch-up 2 of 2) (#9590)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 18:29:44 +00:00
HiroKenEz db0c25522e Render: a size the CPU profile can't render names the profile that can (#8941) (#8943)
* claim: #8941

* Render: a size the CPU methods refuse names the profile that renders it (#8941)

The Shots strip offers 4K UHD whatever the method. Under the default CPU
raster, and under CPU ray tracing's default Compact profile, Render was then
refused with English engine text that didn't say how to render that size.

The refusal is now translated and names the smallest CPU resource profile that
admits the size (with CPU ray tracing, for raster, which has no profiles). The
size lists mark such presets. Nothing is switched: method, profile and
resolution still change only when chosen, and GPU path tracing is unaffected.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 18:16:36 +00:00
CaRLymx e6c999bbcd Spanish (es-ES): catch up with en.json (2026-10-30) (#9569)
- Base: origin/main (71b3a70, post #9448)
- en.json: 4,778 keys → es.json: 4,778 keys (100% complete)
- 1,033 reusable translations from previous catch-up branch
- 88 newly translated keys (construction lines, point cloud, print face, quick measure, recovery, UI hints, update checks)
- Terminology per glossary: 'Servicio de API', 'Desactivar/Reactivar', 'alma/ala', 'Ángulo de plegado/Ángulo incluido', 'vinculación de superficie', 'toma/escenario/estudio/configuración', 'X-Líneas' (construction lines), 'cara de impresión', 'bisectriz', 'tangente común'
- 0 placeholder mismatches, catalog sorted alphabetically
- cargo xtask i18n --check ✅, cargo xtask lists ✅, cargo xtask assets ✅

Co-authored-by: carlymx <24634598+carlymx@users.noreply.github.com>
2026-10-30 18:07:52 +00:00
PaddleStroke 180a725c0e Parameters: sort the list by name within each section (#8563) (#8725)
* claim: #8563

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

* Parameters: sort each section by name (#8563)

The Name heading of every Parameters table steps through A to Z, Z to A and the
listed order. Each section sorts on its own: Favorites, User Parameters and each
model group (the groups keep the design's order). Names compare in natural order
(d2 before d10, letters ignoring case). The choice is kept in egui's persisted
memory like a section's open state. While sorted, a user parameter's move arrows
are disabled. The sortable heading and natural_cmp live in ui_kit::table.

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

* Parameters sort: gallery entry for the sortable heading, natural_cmp private

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 18:02:06 +00:00
Steven Waters 4ba6a9744b Browser: allow nesting groups and re-ordering groups & components (#9109) (#9116)
* Browser: nest groups and reorder components

Groups can hold other groups (up to 8 levels) and be reordered with the
items around them. Sub-components can be reordered too, through a new
`components` list in `browser_order` that is not drawn as a folder.

Document: `BrowserGroup` gains `parent`; group items and `browser_order`
lists hold `BrowserEntry` values (a number is a group, a string an item).
One pending format file; older designs read unchanged. Loading and
refreshing drop what the lists name that is gone, and cut cycles and
over-deep nesting to the top level.

Engine: `browser.group` takes `parent`; `browser.move`, `browser.order`
and `browser.ungroup` handle groups. A change to only the browser's
groups and order no longer re-evaluates the model.

UI: nested rows, dragging a group row (top of a row: before it, the rest:
into it), New Group inside a group, and dropping a placement on the top
of a sibling to reorder it.

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

* Fixed formatting

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

* Fixed unintended side effect in render scene browser test

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

* Preserve group order in render workspace

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

* Increased test coverage

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

* Updated clipboard test from upstream main for group item type

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 17:40:33 +00:00
Nathan Dane ae998906ff Viewport: fold three status items into a summary badge (#8549) (#8558) 2026-10-30 17:33:23 +00:00
phanoob 634f0a2308 Sketch text: number parameters in the words, and = for a formula in Edit Text (#9281) (#9386)
* claim: #9281

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

* Sketch text: a number parameter in the words shows its value (#9281)

A text's words expression can now name a number parameter: `'W' + Width`
reads "W85 mm" (lengths in the design's units, angles in degrees, up to
three decimals). `number(Width)` drops the unit and `number(Width, 2)`
fixes the decimals. The words and the cut follow a parameter edit in the
same rebuild, a rename reaches inside number(), and deleting the parameter
is refused while a text shows it.

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

* Sketch text: a Text starting with = builds its words from parameters (#9281)

In Edit Text, a Text that starts with = is the words' expression
(='W' + number(Width)): OK keeps it, the dialog shows it again with its =,
and an untouched OK no longer turns it into typed words. Typed words still
replace it, and a saved text that happens to start with = stays typed
until it is edited. The field's tooltip says how.

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

* Sketch text: the formula hint in every complete catalog (#9281)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 17:28:31 +00:00
phanoob 306b78ce16 Expressions: Down arrow in an empty field lists the parameters (#9246) (#9385)
* claim: #9246

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

* Expressions: Down arrow in an empty field lists every named parameter (#9246)

In an empty expression field, Down opens the completion list with every
named parameter (not just the first eight), armed on the first one; Up and
Down walk it, the list scrolls to keep the chosen row in view, and Enter
inserts it. A field that already holds text keeps Down for itself.

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

* Expressions: Preferences caps the Down-arrow parameter list (#9246)

Down arrow in an empty field lists the parameters up to "Parameters
listed by Down arrow" in Preferences > Design (default 50, 5 to 200),
favourites and the design's own parameters first, so a large design
does not build a list of hundreds of rows.

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

* Expressions: a label that fits for the parameter list cap (#9246)

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

* Expressions: the parameter list cap in every complete catalog (#9246)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 17:27:56 +00:00
Badger B 678da425da Add German translations, batch 4 (#7237) (#9554)
Claude-Session: https://claude.ai/code/session_01YVbV9BQxn5xzooBo8yEt9a

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 17:27:13 +00:00
froglemonade2 fc499441e2 Lost GPU: the old process exits instead of hanging in the driver, after the settings are saved (#9044) (#9054)
After a lost device the next start began from on_exit, while eframe was
still writing app.ron, and the old process then hung in
vkDestroySwapchainKHR on NVIDIA. Start it from Drop for App instead, after
eframe has joined the settings save and before it drops the window's GPU
surface, and end the process there after a loss. The update restart moves
there too. The loss reason is passed to the next start and shown in its
note.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 17:26:33 +00:00
github-actions[bot] 1cf5f83924 Regenerate generated files and fold pending files [skip ci] 2026-10-30 18:38:33 +00:00
bherbruck 6a465abee9 Quick measurement: stop the off-thread test failing when the thread is fast (#9566)
The test asserted the readout was still empty right after the first poll, but the measuring thread can finish first, so it failed now and then in merge groups and on unrelated pull requests. It now asserts only that the measurement arrives.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 14:37:42 -04:00
github-actions[bot] 89a3e89d62 Regenerate generated files and fold pending files [skip ci] 2026-10-30 18:31:05 +00:00
Cyryl Sochacki 58c0fc396f i18n(pl): translate 1155 missing UI messages (#9102)
Everything en.json gained since the last catch-up except the render.*
messages: Preferences, navigation bar, assistant settings, materials,
hole presets, joint move, edit in place, point clouds, view angles,
tool help texts and error hints. Added as one pending locale file.
2026-10-30 17:19:37 +00:00
GavinSchmidt 0952686c1b Sheet metal: personal Sheet Metal Rules, saved in the profile and offered by every design's rule manager (#9346) (#9520)
* Sheet metal: personal rules library in the profile, offered by every design's rule manager

Adds personal_sheet_rules.rs and its tests, the personal_sheet_rules preference, and the rule manager wiring.

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

* Sheet metal: pending strings for personal rules

English and the complete catalogs (ar, he, ja, vi, zh), plus de and ko.

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

* Docs: personal rules in the sheet metal guide

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

* Docs: glossary entry for personal sheet metal rule

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 17:12:12 +00:00
semmelrinde 64b506dccf Sketch: the coordinate readout starts right of the pointer arrow (#9480) (#9484)
* Sketch: the coordinate readout starts right of the pointer arrow (#9480)

The readout of a sketch tool's point (2D and 3D sketches) started 10
points below and right of the snapped point, under the system arrow,
which hangs about 12 points wide below the hot spot and covered the
first value. It now starts 18 points right and 10 below the pointer
(the point snaps away from it), from the snapped point only when there
is no pointer over the view.

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

* Sketch: the readout clears the next larger pointer too (#9480)

The reporter uses the system's pointer size 2, whose arrow is about
18 × 28 points: start the readout 26 points right of the hot spot
instead of 18, and test against that arrow.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 16:57:45 +00:00
semmelrinde 61195235d8 Layout: three more edge clamps give way in an inside-out view (#9504)
The clamp that crashed #9492 (f32::clamp between a view's edges, which
panics once the view comes out inside out) also sits in the room tool
windows get beside the docked Assistant, the Render selection bar's
anchor and a left-anchored panel's anchor. Use shell::within there,
the clamp that gives the lower edge for an empty range (#9271).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 16:57:38 +00:00
semmelrinde 6241decaf9 Galician: translate the 35 messages main gained after batch 8, new Preferences included (batch 9) (#9537)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 16:57:22 +00:00
semmelrinde 3288adf3dd Czech: translate the 35 messages main gained after batch 8, new Preferences included (batch 9) (#9525)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 16:50:24 +00:00
semmelrinde 5b53a1354a French: translate the 35 messages main gained after batch 8, new Preferences included (batch 9) (#9527)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 16:50:17 +00:00
semmelrinde 579dddaea0 Italian: translate the 35 messages main gained after batch 8, new Preferences included (batch 9) (#9529)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 16:50:10 +00:00
semmelrinde 9052b69e81 Dutch: translate the 31 messages main gained after batch 8, new Preferences included (batch 9) (#9530)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 16:50:04 +00:00
semmelrinde 2c5d875ab7 Swedish: translate the 35 messages main gained after batch 8, new Preferences included (batch 9) (#9532)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 16:49:57 +00:00
semmelrinde 04efc68578 Turkish: translate the 32 messages main gained after batch 8, new Preferences included (batch 9) (#9533)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 16:49:51 +00:00
semmelrinde 0e137b947f Traditional Chinese: translate the 35 messages main gained after batch 8, new Preferences included (batch 9) (#9534)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 16:49:44 +00:00
github-actions[bot] 97a24941d2 Regenerate generated files and fold pending files [skip ci] 2026-10-30 17:34:50 +00:00
github-actions[bot] c2a19bf375 Regenerate generated files and fold pending files [skip ci] 2026-10-30 17:27:38 +00:00
semmelrinde e40d7aaee7 Indonesian: translate the 35 messages main gained after batch 8, new Preferences included (batch 9) (#9536)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 16:49:30 +00:00
semmelrinde 2b18b68307 Norwegian Bokmål: translate the 35 messages main gained after batch 8, new Preferences included (batch 9) (#9538)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 16:49:23 +00:00
semmelrinde b9ae8814d4 Bosnian: translate the 14 messages main gained after batch 8, new Preferences included (batch 9) (#9539)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 16:49:16 +00:00
semmelrinde f3a4c62240 Serbian: translate the 14 messages main gained after batch 8, new Preferences included (batch 9) (#9541)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 16:49:10 +00:00
semmelrinde dbf38d84ef Russian: translate the 14 messages main gained after batch 8, new Preferences included (batch 9) (#9542)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 16:49:03 +00:00
github-actions[bot] f913a2a459 Regenerate generated files and fold pending files [skip ci] 2026-10-30 17:00:07 +00:00
Nathan Dane f504c82216 Viewport: integrate Section status with current UI changes (#8548) (#8557) 2026-10-30 16:23:37 +00:00
SilentWolf75 df18823d0a Start: tell same-named recent designs apart (#9081) (#9174)
* claim: #9081

Co-authored-by: Cursor <cursoragent@cursor.com>

* Tell same-named recent designs apart (#9081)

Show the file name with extension, ellipsize long paths in the middle so the end stays visible, and label imports by kind (STEP, STL, …) instead of always Design.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Start: Recent paths take their ink from the theme, and the middle-ellipsis helper is exported for tests only

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 16:21:26 +00:00
austin62jake 0396b2d00a Model cache: a design with mesh bodies keeps its saved model (#9170) (#9218)
* claim: #9170

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

* Model cache: a design with mesh bodies keeps its saved model (#9170)

One mesh body (an inserted STL or 3MF, or a STEP part shown as a mesh of
its faces) left the model cache out for the whole design, so opening it
evaluated every feature again. The doc records refused mesh bodies, and
the native B-rep codec the io cache stores bodies with refuses them too.

Kernel work is paused, so the kernel is left alone: the io cache stores a
mesh body as its own slice kind (SCMESH01, a JSON header with the counts,
colour and paint, then the vertices as f64 and triangles as u32), read
back through mesh_body, with_color and with_paint. A mesh body is kept
only when its slice reads back as the same vertices bit for bit, the same
triangles, colour and paint. The decoder checks the counts against the
bytes before allocating. The picture shown while opening draws a mesh
body with its own triangles (native_preview_mesh refuses mesh bodies).

Cache version 4 -> 5; docs/design-container.md describes the mesh slice.

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

* Model cache: decode mesh slices with as_chunks, as clippy asks

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-30 16:17:27 +00:00
AsTechEvolves b1c132bc9a Assistant: drop earlier thinking blocks when history is trimmed, so Claude requests stop failing (#9417) (#9448)
* claim: #9417

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

* Assistant: drop earlier thinking blocks when history is trimmed (#9417)

Claude signs each thinking block against the conversation before it. Once
drop_old_views swaps an older view for a note, or trim_history drops the
oldest messages, every later block stops matching and the API refuses the
request with HTTP 400 "Invalid signature in thinking block". For the
Anthropic protocol, a trim now also removes the thinking and
redacted_thinking blocks from earlier replies, which the API allows. A
reply that was only thinking keeps a short note. Requests where nothing
was trimmed are sent unchanged.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 16:13:35 +00:00
github-actions[bot] 71b3a70254 Regenerate generated files and fold pending files [skip ci] 2026-10-30 16:24:57 +00:00
github-actions[bot] bcdecd19f4 Regenerate generated files and fold pending files [skip ci] 2026-10-30 16:17:21 +00:00
austin62jake 3b3f860adc Sketch: Project takes a construction axis, not only X, Y and Z (#9324) (#9471)
* claim: #9324

* Sketch: Project takes a construction axis, not only X, Y and Z (#9324)

sketch.project with {"axis": "Axis1"} upper-cased the name, and the axis link knew only the
origin axes, so a construction axis failed with "unknown axis `AXIS1`". X, Y and Z (any case)
are still the origin axes; any other name must be a construction axis, kept as its feature names
it. The link finds the axis where the timeline before the sketch puts it (construction_axis_in,
as Revolve does) and projects it as an origin axis: its line in the sketch plane, or a point
when the axis stands square to it. Renaming the axis renames the link, as for a construction
plane's projection.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-30 15:51:57 +00:00
PaddleStroke f0d5666a74 Closing: stop the Home picture builds instead of waiting for every cold recent file (#8398) (#9416)
* claim: #8398

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

* Closing: stop the Home picture builds instead of waiting for them (#8398)

Dropping SolveApp waited up to 10 s for the Home picture threads to end
(#6063). The thumbnails thread imports and rebuilds every cold recent
file (STEP and mesh imports, designs without a stored picture) one after
another, and its channel only closes once all are done, so closing the
window froze until every cold recent was built or the 10 s ran out.

The picture threads now build under a shared progress watch that
finish_pictures cancels first: the design being built stops at its next
feature (a STEP import at its next step), the remaining cold files are
skipped, and a build stopped part way draws and caches no picture.
Pictures already on the GPU are still waited for, so the driver crash
#6063 fixed stays fixed.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 15:46:06 +00:00
Kai Evans f1c2f67d63 Quick measurement: the selection's length, area or distance in the view's corner (#7000) (#7513)
* Quick measurement: the selection's length, area or distance in the view's corner (#7000)

With edges, faces, sketch curves or points selected, the view's bottom right corner above History
shows what they measure, as the Measure tool lists it: an edge's length (Ø and R for a round
one), a face's area, a point's position; two items' distance and angle; several edges' or faces'
count and total. It is computed off the window thread from a copy of the session whenever the
selection or the design changes, and a click copies it. Preferences > Interface turns it off. The
Measure dialog's rows come from the same function.

* Quick measurement: off by default, turned on under Preferences > Interface (#7000)

The quick_measure preference now defaults to false, so nothing changes until it is turned on.
Its tests turn it on, and a new test checks that nothing shows until it is.
2026-10-30 15:46:01 +00:00
Homero Thompson 32900556e0 Control server: a subscribed connection is told of design changes (#8315) (#8333)
* claim: #8315

* fix(control-server): release the watch seat deterministically (#8315)

The seat a subscribed connection holds on the DesignWatch lived in the
events thread alone, so it left the watch only when that thread ended:
on the first write a closed client refuses, which needs the design to
move, or when the thread noticed the end of the stream, which waits for
the connection's own thread to come out of its blocking read. A closed
connection could therefore keep counting on the watch for a second or
more, and the test that asks for the count to be gone could lose its
race with the ten seconds it waits.

The seat is now shared: it leaves the watch once, whichever thread sees
the connection's end first, the connection's own thread at the end of
the stream or the events thread at the first refused write. The count
of subscribed connections is right as soon as the connection is gone,
and the events a subscribed connection is told of do not change.
2026-10-30 15:45:51 +00:00
Frank bf14bf3678 Inspect Sketch: clicking it shows a Sketch report window (#6262) (#6263)
* claim: #6262

* Inspect Sketch: the ribbon button opens a Sketch report window (#6262)

sketch.inspect gets a place on the Sketch tab (Inspect panel). ui.start opens a
read-only Sketch report: solver status, degrees of freedom left, counts of
curves, points, constraints and profiles, the curves and points that are not
fully constrained, and Copy JSON. engine.execute still only returns the JSON.

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

* Inspect Sketch: a Sketch Report button on the Sketch tab while a sketch is open (#6262)

SKETCH/INSPECT placed it in the Inspect tab's Surface Quality drop-down, which
is hidden while a sketch is edited. It now has its own Inspect panel on the
Sketch tab, labelled Sketch Report; the test clicks the ribbon button.

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

* Sketch Report: strings in a pending locale file, help for sketch.inspect (#6262)

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

* Sketch Report: the Sketch tab fits at 1366 px; the width test closes the report (#6262)

The panels left of the Finish group keep the gap before it when they are fitted,
so they collapse before they reach it.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Frank <plexi32@gmail.com>
2026-10-30 15:39:25 +00:00
DuBearte f0a5fc5016 Open: a DWG file says what it is and what to do instead (#9328) (#9487)
Opening a DWG stopped with "design file is not UTF-8 JSON". It is told
apart by its signature, whatever the file is called, and the error points
to STEP or DXF.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 15:18:40 +00:00
github-actions[bot] ad1652683b Regenerate generated files and fold pending files [skip ci] 2026-10-30 15:48:25 +00:00
github-actions[bot] 0d778e074b Regenerate generated files and fold pending files [skip ci] 2026-10-30 15:39:35 +00:00
PaddleStroke 0b2d51d301 Sketch: a projected face follows a resize that moves its corners far (#9001) (#9453)
* claim: #9001

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

* Sketch: a projected face follows a resize that moves its corners far (#9001)

A sketch on the face of an angle kept its projected face boundary (and
reported "projected face boundary identity is ambiguous") when the wall
thickness changed: the inner corner moved by more than a tenth of the face,
past the nearest-point match, and the same-order fallback refused faces.

Accept the same-order layout when, for every point, the point read in that
order is also clearly the nearest one on the same loop with lines the same
way. Rims and holes, and rotated readings of a rectangle, stay refused.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 15:05:36 +00:00
Frank bf7979d4a7 Insert Part: seat a fastener in several holes at once (#9341) (#9342)
* claim: #9341

* Insert Part: seat a fastener in several holes at once (#9341)

parts.insert / parts.fastener take `at` as one pick or a list of picks.
The part is built once as one component; each hole gets its own
placement and rigid seat joint. All placements are made before any is
seated, since joint.create takes the part's seating point where the
placement is. The insert now runs on a scratch session like
parts.resize, so it is one undo step, and a pick off every body fails
it before anything is made.

The Insert Part dialog's hole input is multi-select ("Holes
(optional)") and OK passes every pick.

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

* Insert Part: picks on one hole's rim seat one part (#9341)

A hole's rim is drawn as two half-circle edges, and one click on it
picks both, so four clicks gave eight picks and two screws per hole.
Picks whose wall point lies level on the same hole axis, on the same
body placement, are one hole.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 14:37:02 +00:00
DuBearte 5439bfc0b5 Expressions: a decimal comma inside plain parentheses (#9408) (#9447)
A comma separates arguments only directly in a function call, so plain
parentheses take a decimal comma like the rest of an expression:
`-(width-0,5)/2`. The UI no longer rewrites commas before wrapping a value
in the design unit's parentheses.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 14:34:56 +00:00
austin62jake c2ea096abb Assistant on Windows: find Node in its install folder when the launcher's PATH lacks it (#9297) (#9458)
* claim: #9297

* Assistant on Windows: find Node in its install folder when the launcher's PATH lacks it (#9297)

An npm-installed agent starts on Windows through its .cmd shim, which runs `node`, with the PATH
search_dirs makes: SolidCraft's own PATH, then npm's and other installers' folders. Node's own
install folder was not among them, so started from a launcher that leaves it off the PATH (the
ArtCraft launcher), the agent stopped with "'node' is not recognized". The search now also takes
%ProgramFiles%\nodejs, %ProgramFiles(x86)%\nodejs and nvm-windows' %NVM_SYMLINK%, after
everything it took before. search_dirs reads its variables through search_dirs_in, so a test can
give it a launcher's environment.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-30 14:27:10 +00:00
Colin Faulkingham 085853315d Sketch: copy completed sketches between designs (#5427) (#8962)
* claim: #5427

* Sketch: paste completed sketches across design tabs
2026-10-30 14:09:37 +00:00
Mario Geiger 332f96c26a Point clouds: colour and filter by any property the file carries (#8816) (#8819)
* Point clouds: colour and filter by any property of a PLY file, on range tracks (#5151)

A PLY file's other vertex properties are kept as named numbers on the
points (whole-numbered for integer types; a comment `property: 0 name,
1 name` names a label's values). A cloud is coloured by any of them, a
ramp or a palette for whole numbers, and filtered by up to eight, each a
range or a set of named values. The GPU colours and filters the points
itself, so editing a range uploads nothing.

In the Edit Point Cloud card each range is a track, as in an audio
editor: a histogram of the property over what the other filters keep,
stacked by colour, under an overview strip. Ranges are drawn, moved,
snapped to colour changes, typed (Start, End, Length, relative values)
and played (Space or Play). The automatic colour range is exactly the
values the property takes over what the filters keep.

pointcloud.insert and pointcloud.edit take color_by: <property>,
color_range and filters; pointcloud.list reports them with the
properties. docs/point-clouds.md describes it all.

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

* Point clouds: note the saved properties, colouring and filters as a pending format change (#8816)

A design now saves a cloud's per-point columns and their value names, colouring by a property and
filters; an older release must refuse it instead of dropping them.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 14:02:29 +00:00
PaddleStroke 56e752c182 Toasts: a close (×) with a countdown ring instead of a Close button; a shorter 3D mouse driver label (#7971) (#8763)
* claim: #7971

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

* Toasts: a close (×) with a countdown ring instead of a Close button (#7971)

Every toast the user may close (the status, the 3D mouse offer and the
stand-up note) has a close (×) at its top right, handle toast:<name>:close,
in place of a Close button; it does what the button did, is a kit-drawn
button with an accessible name and hover text, reachable by Tab, and dead
behind an issue report. Toasts that go by themselves draw a thin ring
around it that shrinks over their time; pointing at the offer or the note
starts their time again, as before, so their ring is full while pointed at.

The 3D mouse offer keeps three buttons, Calibrate…, Use Direct Driver (the
detail in its tooltip) and Never show again, on one row: the corner
toasts are 400 points wide, as the row's estimated widths need 356.

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

* Toasts: lay buttons out at their drawn widths; corner toasts 360 wide (#7971)

The toast's button rows used an estimate of each button's width that ran
about 40 points over the kit button as drawn, which wrapped the 3D mouse
offer's three buttons and left the main action short of the right edge.
Each button's drawn width is now remembered and the rows laid out with it
(the estimate stands in for the one frame before a button was first
drawn), so the main action is flush right; the corner width is the
smallest round one that fits the offer's three buttons in a row: 360.

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

* Toasts: keep the analysis status test whole after rebasing onto main

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-30 13:48:29 +00:00
github-actions[bot] 014ccf5ff4 Regenerate generated files and fold pending files [skip ci] 2026-10-30 15:12:22 +00:00
github-actions[bot] ab309f2219 Regenerate generated files and fold pending files [skip ci] 2026-10-30 15:05:46 +00:00
TaeHeon Kim 6c9afc6f7e Start the window on OCLP Macs with SYSTEM_VERSION_COMPAT=1 (#3080) (#8930)
On Macs patched with OpenCore Legacy Patcher the OS reports a newer
macOS version than the GPU's Metal compiler supports, so wgpu picks a
shader standard the compiler refuses (`invalid value 'metal3.2'`) and
the window never draws.

The variable has to be set before the process starts, and Rust 2024
makes `set_var` unsafe (this crate denies unsafe code), so main()
now re-launches the binary with SYSTEM_VERSION_COMPAT=1 when the OCLP
NVRAM key (OCLP-Version) is present, and exits with the child's status.
A process that already has the variable set never re-launches, so
there is no loop and a user's own value is respected. If the check or
the spawn fails, startup continues normally.

Tested on an OCLP Mac: one re-launch message, graphics render
correctly; with SYSTEM_VERSION_COMPAT=1 set beforehand it does not
re-launch.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 13:48:19 +00:00
SvenCarstensen aff8106205 Help: check for updates and show results in the selected language (#8896)
* Help: add manual update checks and status window

* Test manual update checks and localized results

* Add update check to the native Help menu

* Localize Help update checks in all app languages

* Document manual update checks from Help

* Document ui.help updates control action
2026-10-30 13:41:00 +00:00
semmelrinde 8ced905843 macOS: quitting on a Mac with a Touch Bar no longer crashes (#7535) (#7762)
* claim: #7535

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

* Vendor accesskit_macos 0.26.3 as published (#7535)

The published package, unchanged, so the next commit shows the fix as a diff.

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

* macOS: quitting on a Mac with a Touch Bar no longer crashes (#7535)

eframe's accessibility adapter (accesskit_macos 0.26.3) swaps the window's
view to a subclass of its own. When AppKit's Touch Bar observes the first
responder (nextResponder), key-value observing subclasses the view again.
Dropping the adapter on quit reset the view's class, which threw away KVO's
subclass, so AppKit's removal of its observer raised NSRangeException ("not
registered as an observer") and the app ended with EXC_BREAKPOINT.

accesskit_macos 0.27.2 fixes this (AccessKit/accesskit#835), but egui 0.36
is on accesskit 0.24, so vendor 0.26.3 with 0.27.2's src/subclass.rs and its
tests: the adapter leaves the class alone when KVO subclassed the view.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 13:35:37 +00:00
SilentWolf75 21340ba019 Web: Chinese UI loads a Chinese font (#8894) (#8981)
* claim: #8894

Co-authored-by: Cursor <cursoragent@cursor.com>

* Web: load Simplified/Traditional Chinese fonts for zh and zh-TW (#8894)

BIZ UDPGothic only covers Japanese, so Chinese UI showed tofu. Ship locale-glyph Craft UI SC/TC subsets and fetch the matching face on demand.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-30 13:33:23 +00:00
Colin Faulkingham 8b25fa0ae4 Configurations: reject invalid parameter expressions when saving rows (#9088) (#9414)
* claim: #9088

* Configurations: reject invalid row expressions (#9088)
2026-10-30 13:32:50 +00:00
ReDead c0b0369aa2 Sketch: hide constraint glyphs when zoomed out (#8980) (#9023)
* claim: #8980

Hide constraint glyphs when zoomed out (claim).

Co-authored-by: ReDead <ReDead@users.noreply.github.com>

* Sketch: hide constraint glyphs when zoomed out (#8980)

Fixed-size constraint glyphs stayed visible at every zoom and cluttered the
view when far out. Hide them below a sketch-plane scale threshold, with the
old always-on behaviour behind hide_constraint_glyphs_zoomed_out.

Co-authored-by: ReDead <ReDead@users.noreply.github.com>

* i18n: complete-catalog strings for zoom-hide preference (#8980)

Add ar/he/vi translations for prefs.hide_constraint_glyphs_zoomed_out
(and its note) so cargo xtask i18n --check no longer reports 6 gaps in
complete catalogs.

Co-authored-by: ReDead <ReDead@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: ReDead <ReDead@users.noreply.github.com>
2026-10-30 13:31:09 +00:00
Frank 44a9f9ee42 GPU path tracer: the path shader compiles on NVIDIA's Vulkan driver (#6705) (#6850)
Native compilers inline every call, and `main` inlined 33 BVH walks: three
sample_path calls, and a shadow walk per light source family. NVIDIA's
compiler (driver 610.88) crashed in nvgpucomp64.dll or ran out of memory.

- main settles the adaptive budget first, then samples at one call site.
- direct_light takes over direct_studio, visible and the fixed-light loop:
  one loop over the sources with one shadow-ray call site, drawing random
  numbers in the same order.
- next_vertex (a select) replaces (a+1u)%3u: after a BVH walk that index
  crashed NVIDIA's compiler within seconds.

main now inlines 5 BVH walks; main_inlines_few_bvh_walks counts them with
naga, so the limit holds without an NVIDIA GPU.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 13:28:57 +00:00
kaltenbrunnermarkus f331a0740b Model cache: write each set of sketch keys once, and build the states on threads (#9010)
Problem: every state of a saved model wrote the evaluation key of each sketch before it, so a
design of many sketches and many features after them repeated the same map over and over. In
the SELENE rover (5,484 features, 2,116 bodies) that was 27 of the 33.5 MB manifest: the
manifest hit its 32 MiB limit, the save thinned the cache (#3220) to 2,140 of 5,484 states, and
every open evaluated the 3,344 left-out features again. Reading the kept states one after
another took a few seconds more.

Solution: as for sheet bodies (#6152), the records keep a list of sketch-key sets, each distinct
set written once, as the keys it sets and drops relative to the set written before it; a state
names its set by place. A thinned model keeps the whole list and counts it once against its
budget. The reader counts every set's keys, and each state's, towards MAX_HELD before it copies
them (a short record can stand for a large set), then builds the states on threads
(kernel::map_on_threads) once the shared records are read. The cache format is now /5: an older
cache is a miss and the design is evaluated.

SELENE's manifest is 15.9 MB with every state kept; opening it evaluates nothing. Parse and
evaluate on opening, release build, median of 5 on a shared machine: 7.36 s -> 1.14 s. The
evaluated bodies are identical (inspect.measure of all 2,116 bodies).

Claude-Session: https://claude.ai/code/session_013WUFq6QFBqmZjxwaKy2J1M

Co-authored-by: Markus Kaltenbrunner <kaltenbrunnermarkus@users.noreply.github.com>
2026-10-30 13:28:16 +00:00
fkirill 12636b67be Select hidden geometry with Select Other and fit the list to the window (#5574) (#5577)
* claim: #5574

* Pick hidden geometry near long clicks in a five-row list (#5574)

* fix: make Select Other configurable and fit its list to the window

---------

Co-authored-by: Kirill Frolov <k.a.frolov@vk.team>
2026-10-30 13:25:56 +00:00
semmelrinde 9f5ac92faa Saved model cache: keep fast opening after an update (#7168) (#7354)
* claim: #7168

* Saved model cache: keep fast opening after an update (#7168)

The model cache was read only by the exact build that wrote it (same app
version and commit), so every update evaluated every design again on its
first open. It is now keyed by the evaluation source instead: a digest of
the committed source the model is computed from (doc and the workspace
crates it is built from, the patched truck crates, locked external
dependencies, workspace build settings without the version, and the
cache reader), with the platform. An update that changed none of that
opens designs from their cache; one that changed any of it evaluates
them again, as before.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 13:25:42 +00:00
PaddleStroke 6e39efd4ac Theme: System follows the desktop's dark/light setting on Linux GNOME (#7944) (#8123)
* claim: #7944

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

* Theme: System follows the desktop's dark/light setting on Linux (#7944)

On GNOME (Wayland, or X11 through XWayland) the window toolkit never reports
the desktop's dark or light preference, nor its changes, so Theme: System kept
its colours. The Linux app now reads org.freedesktop.appearance color-scheme
through the XDG Desktop Portal's Settings interface (then GNOME's color-scheme
and gtk-theme when it has no preference) on a thread of its own, follows the
portal's SettingChanged signal, and falls back to gsettings or a slow poll.
The window reads the answer through Services::desktop_dark; the decision from
the raw values is prefs::desktop_prefers_dark. Windows and macOS keep the
window toolkit's theme.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 13:25:23 +00:00
C.Vengels 3c40edd12a Print face: Send to Slicer lays a body on its marked face (#8429) (#8433)
* claim: #8429

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

* Print face: Send to Slicer lays a body on its marked face (#8429)

A body can have one flat print face (Document::print_faces, held by the
certified target a sketch on a face holds). body.print_face {body, point?}
marks or clears it. Send to Slicer, Save Mesh with a slicer and EasyPrint
turn and move the exported copy so the face lies on the bed at Z = 0 with
the centre of mass over the origin; a face that can't be found is a
warning and that body is exported as it lies. Move/Copy and Mirror give a
copy its source's print face. One pending format file; the command
has its own icon (print_face).

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

* Print face: Ctrl+B, the face menu and the Send to Slicer row (#8429)

Ctrl+B and the face context menu mark the selected flat face as its
body's print face, or clear it. Send to Slicer shows the print face of
the one body being sent in a Print face input, takes a flat face picked
in the viewport and clears it when emptied.

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

* Print face: a light tint in the viewport and its strength in Preferences (#8429)

Each visible body's print face is tinted towards the theme's section
colour and outlined, at the strength set under Preferences > General >
Slicer (print_face_highlight, 0 to 1, 20 % by default; 0 hides it). The
user guide describes the print face, and the known limitation after a
shell of a shelled body is listed.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 13:25:18 +00:00
github-actions[bot] 81b620d7aa Regenerate generated files and fold pending files [skip ci] 2026-10-30 14:36:14 +00:00
SixSolid 9af2ae9a0f Agent chat window: an off-screen chat window no longer stalls the main window (#9118) (#9119)
* claim: #9118

* Agent chat window: an off-screen chat window no longer stalls the main window (#9118)

The chat popped out into its own window is an immediate viewport: eframe draws
and presents it inside the main window's frame. Both surfaces waited for vsync,
and a Wayland compositor sends no frame callbacks to a window it isn't showing,
so the main window stalled behind the chat window's present.

A surface other than the main window's is now configured with Mailbox, else
Immediate, when the configured mode waits for vsync and the surface offers one
(viewport_present_mode in the vendored egui-wgpu). The main window keeps vsync
and still paces both.
2026-10-30 13:24:30 +00:00
Kai Evans 1754318df7 Browser: the visibility eye at the right end of each row (#8718) (#8720)
* claim: #8718

* Browser: the eye sits in a column at each row's right end (#8718)

Tree rows draw the visibility eye right-aligned at the row's end, clear of
the fold arrow and the radio and of a floating scroll bar; names stop short
of it. Preferences > Interface > Eye next to the fold arrow
(browser_eye_left) keeps the old position. Rows report their control
positions so placement handles and inline rename follow the layout.

* Docs: the browser's eye column and browser_eye_left (#8718)

* Browser: the eye's tip shows by the eye at the row's end (#8718)

* Browser: a gap keeps the fold arrow and the radio apart; the eye clears a floating scroll bar (#8718)

The eye's old slot leaves a 10 pt gap instead of 4 pt, so the radio's hit
area is 7.5 pt from the fold arrow's and History pattern rows step in from
their sketch as far as browser children do. The eye's inset follows the
scroll style: only a floating bar laid over the rows takes room. Rows
publish indent:<label> where their depth puts them.

* Tests: browser depth checks run on the default layout, by indent handles (#8718)
2026-10-30 13:24:22 +00:00
semmelrinde f273195144 Czech: translate the 29 messages main gained after batch 7, construction lines included (batch 8) (#9427)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 13:23:55 +00:00
semmelrinde 247a77e1b1 French: translate the 25 messages main gained after batch 7, construction lines included (batch 8) (#9428)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 13:23:48 +00:00
semmelrinde ce4212a33a Hungarian: translate the 29 messages main gained after batch 7, construction lines included (batch 8) (#9429)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 13:23:41 +00:00
semmelrinde 522bd95254 Italian: translate the 29 messages main gained after batch 7, construction lines included (batch 8) (#9430)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 13:23:34 +00:00
semmelrinde b0b1e6832e Dutch: translate the 29 messages main gained after batch 7, construction lines included (batch 8) (#9431)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 13:23:27 +00:00
semmelrinde 3cb9aa07b5 Romanian: translate the 29 messages main gained after batch 7, construction lines included (batch 8) (#9433)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 13:23:19 +00:00
semmelrinde 60f6836ab7 Swedish: translate the 29 messages main gained after batch 7, construction lines included (batch 8) (#9434)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 13:23:12 +00:00
github-actions[bot] 43f0438fbb Regenerate generated files and fold pending files [skip ci] 2026-10-30 13:58:26 +00:00
github-actions[bot] ded10b52da Regenerate generated files and fold pending files [skip ci] 2026-10-30 13:49:40 +00:00
Frank 4bc5bdc791 Reverse zoom direction no longer flips 3D mouse zoom (#8993) (#8994)
* claim: #8993

* Reverse zoom direction no longer flips 3D mouse zoom (#8993)

The checkbox sits under Wheel and trackpad and now reverses the wheel
(and the navigation bridge's wheel-style steps) only. The 3D mouse cap
zooms by its own per-axis invert switch alone: device_camera no longer
takes a reverse_zoom argument, nor does the test model in Preferences.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 13:23:12 +00:00
semmelrinde 1baaebd3ef Turkish: translate the 29 messages main gained after batch 7, construction lines included (batch 8) (#9436)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 13:23:05 +00:00
semmelrinde 97e467f2d1 Traditional Chinese: translate the 29 messages main gained after batch 7, construction lines included (batch 8) (#9437)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 13:22:58 +00:00
semmelrinde 665ff84495 Malay: translate the 29 messages main gained after batch 7, construction lines included (batch 8) (#9438)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 13:22:52 +00:00
semmelrinde 07b3b92703 Indonesian: translate the 29 messages main gained after batch 7, construction lines included (batch 8) (#9439)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 13:22:45 +00:00
semmelrinde ddf56e1246 Galician: translate the 29 messages main gained after batch 7, construction lines included (batch 8) (#9440)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 13:22:38 +00:00
semmelrinde 0ddbe87935 Norwegian Bokmål: translate the 29 messages main gained after batch 7, construction lines included (batch 8) (#9441)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 13:22:31 +00:00
semmelrinde 19dc8b6881 Bosnian: translate the 29 messages main gained after batch 7, construction lines included (batch 8) (#9442)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 13:22:25 +00:00
semmelrinde 3e2e229dd1 Croatian: translate the 29 messages main gained after batch 7, construction lines included (batch 8) (#9443)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 13:22:18 +00:00
semmelrinde c8d9a4bfdd Serbian: translate the 29 messages main gained after batch 7, construction lines included (batch 8) (#9444)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 13:22:12 +00:00
Dane Doubell 6c764dce06 Sketch: pick a whole profile or chain for Mirror/Move/Copy/Scale entities (#8390) (#8812)
* claim: #8390

* Sketch modify: pick a whole profile or chain for the shared Entities input (#8390)

- Click inside a closed profile selects its whole boundary (and its holes);
  double-click a curve selects its whole chain, for Mirror, Move/Copy, Scale
  and the patterns. Single curves and points keep working as before.
- The mirror line may itself be among the entities: the command leaves the
  line in place and mirrors the rest, instead of rejecting it.

* Mirror: the shared line case is valid, not an error (#8390)

Update the half-filled-window test: a mirror line that is also among the
entities is now accepted (the command leaves it in place and mirrors the
rest), matching the engine.

---------

Co-authored-by: Dane Doubell <2853036+danedoubell@users.noreply.github.com>
2026-10-30 13:13:53 +00:00
SilentWolf75 655e771455 Update: Cancel stops the download sooner (#8908) (#8946)
* claim: #8908

Co-authored-by: Cursor <cursoragent@cursor.com>

* Update Cancel clears the UI and stops before checksum downloads.

Drop the install job as soon as Cancel is pressed so Downloading ends
immediately, and check the cancel flag around feed refresh and
checksum GETs so a stall before the package body no longer ignores it.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-30 13:13:44 +00:00
bherbruck 15f74b7981 Agent chat composer: one margin, input box fills its area; hint stays on one line (#7268)
* Assistant composer: the input box fills its area, and the hint stays on one line

The composer footer inset the box by its own margin on top of the box's text
padding. The footer now has no margin, so the box spans it on all four sides and
only the text padding remains. The hint is painted on a single row, cut with an
ellipsis when the panel is narrow.

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

* Assistant composer: the hint takes its colour from the theme, not a literal

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 12:47:04 +00:00
github-actions[bot] 9b607f6f1f Regenerate generated files and fold pending files [skip ci] 2026-10-30 12:38:49 +00:00
github-actions[bot] ab67126eec Regenerate generated files and fold pending files [skip ci] 2026-10-30 12:31:20 +00:00
Nathan Dane 3b8cbc4e22 Viewport: surface analysis status items and legend keys (#8547) (#8556) 2026-10-30 12:12:19 +00:00
Frank f59f6698f1 Ribbon drop-downs as a grid of large icons, as an option (#4739) (#5012)
* Ribbon drop-downs as a grid of large icons, as an option (#4739)

Preferences > Interface gets two settings, both off by default:

- Menus: List | Large icons. With Large icons a panel's drop-down shows its
  commands as rows of up to five icons at 32 px (twice the list's), keeping
  the list's group headings and order. The name, shortcut and help are in
  the tooltip, as on a ribbon button. Arrow keys move across and between
  rows; a click starts the command; right-click and drag pin it as before.
  Resting on an icon for two seconds arms a pin in its corner; a quicker
  click anywhere starts the command, so nothing is pinned by accident. A
  pinned command shows its pin.
- "Leave pinned commands out of the drop-down menus": a drop-down (list or
  grid) leaves out the commands the ribbon shows as buttons; a panel too
  narrow for its buttons lists them again, and a menu is never left empty.

No icon artwork or colours change. Sketch > Create keeps its family list.

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

* Sketch > Create in the large-icon grid: every variant under its family (#4739)

With Menus: Large icons, Sketch > Create showed its family sub-menus as
plain text rows (Line, Rectangle, Circle, ...), unlike every other menu. It
now shows every variant as a large icon under its family's heading, then the
other tools under "More". The list keeps its family sub-menus.

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

* Large-icon menus: groups side by side, no footer (#4739)

A menu of two or more headed groups (Sketch > Create's families, Model >
Modify's Edges, Faces, Bodies, Specialise) puts the groups side by side, each
heading over a column of its icons, at most two across. Menus without groups,
or one too wide for the window, keep rows of up to five. The pinning note
under the large-icon menus goes: the tooltips and right-click menus say it.

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

* Large-icon menus: a separator line between groups side by side (#4739)

As between the ribbon's sections: a 1 px line in the theme's border colour.

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

* Large-icon menus: at most three rows, growing to the right (#4739)

A menu fills five columns, more when that would take over three rows (Model >
Create: 7 x 3); in grouped menus a group too big for three rows gets more
columns. Only a window too narrow for that wraps into more rows. A test checks
every panel's menu of every tab.

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

* Large-icon menus: group headings centred over their icons (#4739)

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

* Drop-down group headings: smaller than the drop-down's header, not in capitals (#4739)

Edges, Faces, Bodies (and Sketch > Create's families) were drawn in capitals
at font_small, which reads larger than the drop-down's own header
(font_caption). They now use font_micro, written as named, in the list and the
large-icon grid. Review feedback on #5012.

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

* Large-icon menus: start a command from the grid at its icon, as main's rows do (#4739)

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

* Ribbon icon grid strings: a pending locale file, with ar, he and vi (#4739)

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

* Large-icon menus: commands in no group stand apart, so a left-out group doesn't move them (#4739)

With Convert to Sheet Metal pinned and left out of Model > Modify, its
Specialise group went and Delete, in no group, moved under Bodies.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 11:59:08 +00:00
Rick Morgans bd1525dcfb Expressions: hyperbolic functions sinh, cosh, tanh and their inverses (#9332) (#9334)
* claim: #9332

* Expressions: hyperbolic functions sinh, cosh, tanh and their inverses (#9332)

`sinh`, `cosh`, `tanh` and `asinh` take a unit-less number and give one,
as `exp` does; `acosh` refuses x < 1 and `atanh` refuses |x| >= 1, worded
as `asin` and `ln` refuse what lies outside their domain. They are in the
expression help (`FUNCTIONS`, the parameter autocomplete), the
hostile-expression fuzz tokens, the parameters and equation-curve guides,
and the checking skill's list of functions. Horn flares (Salmon's hypex
`cosh h + T sinh h`), catenaries and the tractrix can now be written as
they are stated instead of expanded into `exp`.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 11:13:59 +00:00
kaltenbrunnermarkus 1e5eb1e3b8 Sketch: find profiles island by island, so dragging in a large imported logo stays smooth (#6896) (#9313)
* claim: #6896

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

* Sketch: find profiles island by island and say when the search is cut short (#6896)

Every live drag frame ran the profile search over the whole sketch: every
spline piece of a large imported logo was sampled, checked for crossings
and traced again, about 100 ms per frame at 6,000 degrees of freedom.

Profile curves are now split into islands whose enlarged piece boxes do
not touch (the margin exceeds every tolerance the search uses), each
island's loops are found on their own and cached by a fingerprint of its
geometry, and the loops are nested into profiles over the whole sketch in
the order a single search finds them, so profiles, ids, holes and
whole-boundary certificates stay the same. The end-near-crossing lookup
covers its whole reach, independent of the grid's cell size.

The 50,000-piece limit now holds per island, and a sketch that still
exceeds it gets a warning instead of silently missing profiles.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 11:13:52 +00:00
ABDULLAH a1483d63c1 Arabic: plural wording in the Discard autosaves title (#9354)
recovery.discard_all_title read "discard {count} of the autosave"; it now names the autosaves as a plural.
2026-10-30 11:10:28 +00:00
semmelrinde 491687d4c1 Sketch: the cursor snaps On a spline, ellipse or conic while drawing, as on a line or circle (#9321) (#9343)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 11:10:25 +00:00
Rick Morgans 66bd64cc5e Engine: one contract check for a call, used by the preflight, the bridge and the app (#9339)
`schema::issues(id, params)` looks up the command's typed contract and
lists what is wrong with the parameters (none without a contract or for
an unknown id). The batch preflight, the MCP bridge's pre-send check and
the app's check of a design-switching command each did that lookup
themselves; they now call it. No behaviour change.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 10:21:06 +00:00
jeromehzl f9146cdd58 Hole: a Hole at sketch points hides its sketch as an Extrude does (#9082) (#9211)
sketch_consumed knew Extrude, Revolve and the sheet metal flanges but not
a Hole drilled at a sketch's points, so that sketch stayed drawn inside the
holes. A Multiple hole now counts as using its sketch; Edit Feature shows
its points while the dialog is open. Single holes keep their sketch shown,
since a hidden sketch offers no points to snap to.
2026-10-30 09:37:53 +00:00
github-actions[bot] 23e7ac767e Regenerate generated files and fold pending files [skip ci] 2026-10-30 09:58:59 +00:00
phanoob 3ac7a196c9 Recover unsaved design?: Discard all, with a confirmation (#9299) (#9300)
* claim: #9299

* Recover unsaved design?: Discard all, with a confirmation (#9299)

A Discard all button at the left of the window's footer, away from Later.
It asks first ("Discard 7 autosaves? This cannot be undone.", Discard all /
Cancel), then discards every listed autosave the way its own Discard button
does (doc.recovery_discard on each id). Cancel goes back to the list
unchanged. The button publishes the handle recovery:discard_all.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 09:26:04 +00:00
Logan Night 202e713cf1 Preferences: search finds settings in rows without a label (#9265) (#9318)
* claim: #9265

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

* Preferences: a setting in a row with no label is found by the words it shows (#9265)

A search hid a row by its label before its controls were drawn. Seven checkbox rows and two segmented rows on the Interface page have no label, so "status bar", "search results" and "reveal on hover" found nothing. A hidden row with no label now keeps the words of its controls, as a dropdown keeps its value, and is drawn when they match. Each such row has a key of its own, so its neighbours are not drawn with it.

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

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
2026-10-30 09:25:24 +00:00
N3MI-DG 63923da2ca Viewport: an opened design is framed at the home view, in the middle of the view (#9195) (#9204)
* claim: #9195

* Viewport: an opened design is framed at the home view, in the middle of the view (#9195)

A fit the viewport cannot frame yet is kept and asked again on the next frame instead
of being consumed and lost, so a design opened from the Home screen or by launching on
a file no longer comes up at the default camera, wherever its own coordinates put it.

Framing then centres the model on the view rather than on the free rectangle between
the floating controls: `Layout` carries `middle`, the view without the panels, computed
before the compass, the navigation bar and the OK/Cancel corner join the obstacles. The
model is still sized to `drawable`, so it stays clear of every control, but only a panel
moves it.

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

* Viewport: keep the centred fit clear of the controls, and frame --sample (#9195)

The fit framed the model into a rectangle the size of `drawable` centred on `middle`, which is
not inside `drawable`, so the model could sit under the compass, the navigation bar or the
render product frame. The model is now sized to `drawable` as before, and only the room left
over in it moves the model towards the middle of the view.

`s27_move_triad` turns the Z ring on its far side: the model now sits further right, and the
Move dialog that docks on the right afterwards covered the ring's old grab point.

A launch with `--sample` builds the design straight into the session and never asked for a
fit; it now comes up at the home view, framed, like an opened design.

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

* Viewport tests: the cursor orbit's pivot keeps the pixel it was picked on (#9195)

With the model now centred on the view, one of the test's perspective drag points lands within
the pick tolerance of the box's top edge, and the pick snaps the pivot onto that edge, a few
pixels from the cursor. The orbit keeps that point still, as it should; the test compared it
with the cursor instead of with where it started.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-10-30 09:14:18 +00:00
jeromehzl 7e22aed739 View ribbon: Visual style and Navigation tool offer their choices instead of an error (#8868) (#9127)
Both commands need a choice, but start_with_variant_memory only special-
cased the parameterless display toggles, so a click on the ribbon (or
command search, a shortcut, ui.start) ran them with {} and showed only
the engine's "style must be ..." / "tool must be ..." error.

They now open a short menu, the way Units does: under the ribbon row
that started it (a second click closes it), or in the middle of the view.
Visual style lists the four styles with the current one checked; the
Navigation tool lists Orbit, Pan and Zoom, and picking the latched one
releases it, as its navigation-bar button does. The four styles come
from one list shared with the navigation bar's menu.
2026-10-30 09:04:39 +00:00
jeromehzl 966b10e731 Sketch: a feature on a failed sketch names that sketch (#9075) (#9276)
A sketch that failed (a dimension set negative by a parameter) left every
feature built on it with "unknown sketch 1 (it must come earlier in the
timeline)", which points at the timeline order. The evaluation now notes
the features that failed earlier in the pass, and a feature whose input
sketch is one of them fails with "sketch `Sketch1` failed: <its error>".
That result is never cached, since the cache key holds neither the
sketch's name nor its error: a second failing value or a rename shows
up at once.
2026-10-30 09:03:46 +00:00
github-actions[bot] 99eda4cd1b Regenerate generated files and fold pending files [skip ci] 2026-10-30 09:24:07 +00:00
jeromehzl 47292bb2bd Preferences: the window covers the ribbon behind it (#9078) (#9243)
A page window (Preferences) filled its frame with
dialog_body.gamma_multiply(0.97), which also scales the alpha, so the
ribbon's labels showed through its title bar, search field and heading.
It now uses the opaque dialog_body, as every other window does; the
navigation bar keeps its own translucent resting fill.
2026-10-30 09:01:33 +00:00
semmelrinde 37dc878f31 Shell: test the interface scale raised in a narrow window, the crash #9271 fixed (#9315)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 08:58:15 +00:00
Igor Bronovskyi 7da02460d8 i18n(uk): add pending translations for strings added in upstream (#8977)
* i18n(uk): add pending translations for missing strings

* i18n(uk): add pending translations for new strings from upstream

* i18n(uk): improve pending translations

* i18n(uk): professional translation of all pending strings

* i18n(uk): finish the pending translations for 29 October

Every value in uk-sync-29oct.json is now Ukrainian except the seven symbols
and formulas that must stay unchanged: the modifier keys, HDR and the three
inertia tensors.

Terminology follows uk.json: spot light is "прожектор", directional light is
"спрямоване", body is "тіло", render is "рендеринг". Camera orientation keeps
тангаж, рискання and крен.

Removes history-origin-bodies.json, which was empty after its content moved
into this file.
2026-10-30 08:39:45 +00:00
P'tite Tête f73f4da665 fix: Find a folded folder's mark in one pass over a long history (#9294)
- worst_status searched the history and the results for each feature of a folded folder, the square of the history's length every frame
- A folder of 16 features or more now finds places and results in tables built once, first of an id winning as feature_index and Model::result do
- Frame on a 4,880-body design: 20.4 ms -> 7.9 ms (M4 Max, release, headless)
- Test a_folders_mark_is_the_worst_of_its_features_own

Co-authored-by: P'tite Tête <asso@ptitetete.com>
2026-10-30 08:16:36 +00:00
github-actions[bot] ef5985f841 Regenerate generated files and fold pending files [skip ci] 2026-10-30 08:22:05 +00:00
Ahmad Khan 71bb4f7c98 Docs: a beginner's page for equation curves, with pictures and examples (#9282)
A new user guide page, Equation curves, under Sketching: the first curve step by step, Explicit and
Parametric explained with tables of rules to type (a line, a parabola, a wave, a circle, a spiral),
radians, parameters with the involute of a base circle, building a part (a closed flower curve
extruded), editing, and common mistakes. The technical details stay in a short section for scripts
and agents.

Seven pictures, rendered by `cargo xtask book` from new scripts in docs/book/scripts/equation-*.json
(gitignored output, attributed in attribution/pending/docs-equation-curves.md). The Sketching page
keeps a short summary and links to the new page.
2026-10-30 07:54:27 +00:00
Rick Morgans 92af4a21c8 Control channel: a per-request param_check level, applied and confirmed by the app (#9184) (#9185)
* claim: #9184

* Control channel: a request's own param_check level, applied and confirmed by the app (#9184)

`engine.execute`, `engine.batch` and `engine.script` take an optional
`param_check: "off"|"warn"|"strict"` in their params. The app runs the
request at that level through the same `run_checked` path and batch
preflight a headless session uses, puts its own level back, and every
reply says the level it ran at as `param_check` beside `ok`. A command
that switches designs keeps the app's level; any other word is refused.
Readings and exports answered on a copy off the window thread keep the
level too.

`control.capabilities`, answered at once on the control-server thread,
lists what the channel does beyond the methods every version has:
`{"param_check": ["off", "warn", "strict"]}`. An older app answers
"unknown method".

The MCP bridge asks it once per connection. When the app lists the
level, every call and batch goes with it and the app checks against its
own commands; otherwise the bridge checks before sending, as #9073 does.
At strict, a reply that doesn't say the app checked the call is an error,
and the next calls are checked by the bridge. Also: `add_param_warnings`
takes the reply by `&mut`, and `with_param_check` sits beside
`Remote::new`.

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

* Control channel: a design-switching command is checked at the request's level

Review fixes for #9184. `component.edit_in_place`, `component.finish_edit`,
`view.next_tab` and `view.previous_tab` ran at the app's own level and
echoed `off`, so a misspelt call went through at strict. They now have
their parameters checked at the request's level before they run (and
still run at the app's own level, as each design keeps its own), and the
reply echoes the requested level. The MCP bridge at strict now requires
the echo to match its level, not just to be there. `param_check` is read
only by `engine.execute`, `engine.batch` and `engine.script`; other
methods ignore it. The off-thread test now shows the design change
computing off the window thread before it is refused.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 07:24:41 +00:00
ABDULLAH 5d6c5bab39 Arabic: sketch and spline terms in the construction line help and hints (#9269) 2026-10-30 07:05:00 +00:00
semmelrinde 8251be9c06 Shell: content kept outside a changed viewport no longer turns inside out and crashes the Render frame overlay (#9271)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 07:01:16 +00:00
Steve Jason 3d744cc8a2 A formula that is a bare number gets the design unit (#9261) 2026-10-30 06:42:04 +00:00
github-actions[bot] 1447bc772a Regenerate generated files and fold pending files [skip ci] 2026-10-30 06:56:11 +00:00
semmelrinde 7b24a1dc1e Sketch corners: a round that would use up an arc, or where the curves meet smoothly, is refused (#8813)
* Sketch corners: a round that would use up a whole arc is refused

A quarter arc of radius 5 running into a line along it, rounded with
radius 5, put the round's tangent point at the arc's other end and left
an arc of no length. round_corner now skips a round whose tangent point
falls on either curve's other end; with none left it says that radius
does not fit, as for two lines (found by a local trim fuzzer).

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

* Sketch corners: a round where the curves meet smoothly is refused

A line running on from an arc's end along its tangent (off only by rounding)
has no corner, but the round's tangent points landed within 1e-7 mm of it and
the round came out as an arc of no length (found by a local trim fuzzer). The
corner test's line met its arc this way too and was checking that output; it
now meets the arc at a corner.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 06:23:17 +00:00
Nathan Dane d95f40ac88 Ribbon: pressed state for commands that stay on (#8546) (#8555)
* claim: #8546

* Ribbon: pressed state for commands that stay on (#8546)
2026-10-30 06:20:31 +00:00
austin62jake 82ac9a6b03 sketch.inspect: each profile loop names a curve once, not once per segment (#9179) (#9186)
* claim: #9179

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

* sketch.inspect: each profile loop names a curve once, not once per segment (#9179)

A profile records one curve id per piece its loop walks, and a conic or
spline is walked as many short pieces, so sketch.inspect listed a single
Bezier curve 50 times in a profile's `curves` and as often in a hole loop.
The reply now names each curve of a loop once, in the order the loop walks
them (solidcraft_sketch::distinct_curves, as delete and profile references
already do). The solved profiles keep their per-piece lists.

The lists are now a profile pick as they stand: a whole_boundaries pick
refuses a curve named twice, and the regression test extrudes the wall
from the reply's lists.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-30 06:14:06 +00:00
Homero Thompson 6ab46b6488 fix(#7058): the selection tools reach the geometry an SVG import leaves in the document (#7368)
An SVG import leaves a sketch with regions and no body. The marquee (box select) took only the bodies, so the imported geometry was unreachable with the selection tools: a marquee over it selected nothing, and a click on a region did not take it either.

The marquee now takes the regions of the shown sketches when the design has no body. A design that already has a body keeps the old exclusive behaviour: the marquee takes the bodies alone, not the regions of the sketches that are shown. A dialog that asks for edges or faces (the Patch dialog) never takes the regions: they are neither.
2026-10-30 06:03:16 +00:00
Matthew Knutson 991082fbaf Typed contracts for the construction axes, points and planes (#2829) (#7750)
* claim: #2829 (construct.* contracts)

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

* Typed contracts for the construction axes, points and planes

The 19 construct.* commands get input contracts and FeatureResult as
their output, with shared types for references, planes, a plane or
face base, an axis line and a construction plane's name, each calling
the reader its handler uses (split out of geo, plane_param, the face
capture and named_axis_line).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 06:03:04 +00:00
github-actions[bot] 4d338ba2eb Regenerate generated files and fold pending files [skip ci] 2026-10-30 06:02:42 +00:00
AsTechEvolves 94b399e98f Preference: keep the view when a sketch opens; Look At faces it on request (#9188) (#9190)
* claim: #9188

* Preference: keep the view when a sketch opens, Look At faces it on request (#9188)

Preferences > Sketch > "Turn to face a sketch when it opens" (on by default,
so nothing changes unless it is turned off). Off: creating or editing a
sketch leaves the view as it is, a sketch started on a face no longer
re-centres on the picked point, and Look At (the palette button, Ctrl+8)
still turns to face the sketch. The control channel sets it as
`ui.prefs {"sketch_open_look_at": false}`.

Changes a default: no

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 05:32:56 +00:00
Homero Thompson 5aa41703f7 Control channel: ui.home refuses a sample index that names no sample (#9087) (#9212)
* claim: #9087

* fix(#9087): ui.home refuses a sample index that names no sample

The control channel passed the sample index straight to open_sample, which drops an index that names no sample and says nothing. The request then replied ok, so an agent could not tell a typo from a sample that had opened.

The index is checked before it reaches open_sample: an index that names no sample is refused with the valid range in the error. A sample that exists still opens, as before.
2026-10-30 05:27:06 +00:00
austin62jake d9aa73b597 Measure: an exact circle's or arc's length agrees with its circumference (#9086) (#9227)
* claim: #9086 (edge lengths)

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

* Measure: an exact circle's or arc's length agrees with its circumference (#9086)

Measure gave a circle's or an arc's length_mm as the length of the
polyline drawn for it, which runs inside the curve, while the same reply
gave its circumference or arc_length from the exact circle. When the edge
is known exactly (the kernel's circle, or a sketch's own circle or arc),
length_mm is now its radius times its angle. A circle fitted to a mesh
body's polyline keeps the polyline's length.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-30 05:27:00 +00:00
github-actions[bot] 6e4e94a8a8 Regenerate generated files and fold pending files [skip ci] 2026-10-30 05:30:32 +00:00
github-actions[bot] ae2a5b2c0d Regenerate generated files and fold pending files [skip ci] 2026-10-30 05:24:44 +00:00
railconstr 81cbcef31c Sketch: add construction-line toolkit (#8239) (#8679)
Co-authored-by: railconstr <335242259+railconstr@users.noreply.github.com>
2026-10-30 05:02:54 +00:00
semmelrinde e3d9090e6c Russian: translate the 580 messages the catalog lacks, Render panels included (batch 7) (#9231)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 04:54:25 +00:00
SilentWolf75 b8e93270b4 Sketch: align view to plane when editing a rotated component sketch (#9029) (#9042)
* claim: #9029

Co-authored-by: Cursor <cursoragent@cursor.com>

* Square sketch axes when looking at a turned plane (#9029)

Look At only yaw-aligned sketches facing world up/down, so a sketch on a rotated component stayed tilted on screen. Roll (or yaw at the poles) so the sketch's x runs right and y is upright.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-30 04:27:24 +00:00
SilentWolf75 ef628d1c86 Canvas: pick Calibrate points on the image (#9108) (#9208)
* claim: #9108

Co-authored-by: Cursor <cursoragent@cursor.com>

* Canvas calibrate: pick Point A and B with viewport clicks (#9108).

The Calibrate card only had typed coordinates, so users could not mark the image. Clicks on the canvas now fill A then B, with on-image markers and a short hint.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Calibrate canvas: ja/zh/ar/he/vi strings for the click hints

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 04:25:52 +00:00
SilentWolf75 d93b1cb282 Sketch Trim: prefer free curves over projected edges (#8979) (#9224)
* claim: #8979

Co-authored-by: Cursor <cursoragent@cursor.com>

* Sketch Trim: prefer free curves over projected edges (#8979).

When Trim/Extend/Break is active, linked face edges yield to nearby drawn curves, and the refuse message names Break Link.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-30 04:25:45 +00:00
Derek Van Allen a66a517c40 Persistent naming: a proposal from a second implementation (#7990)
* Persistent naming: a proposal from a second implementation

A design proposal for the persistent-naming failures still open (#4341,
#5198, #5308, #5857, #5858, #5860, #5861), from building persistent naming
in Form Studio: fall back by lineage, never by geometry alone; a suppressed
maker is not a lost reference; names saved as structured one-level values;
ordinals from the feature, not the kernel's face order; split picks resolve
to all pieces; body identity apart from the display name. Documentation
only.

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

* Persistent naming proposal: body identity follows the #5308 plan

Change 6 now agrees with bluecmd's plan on #5308: an id minted from
next_id and stored per feature slot, better than the derived identity
the first version proposed. It adds that a stored id is only as stable
as the slot assignment, which today follows each lump's least face
name, and gives the lineage signature from the #5308 discussion.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 04:17:54 +00:00
semmelrinde f3ee34d372 English: American spelling throughout the base catalog, British only in English (UK), and "1 body" (#9085) (#9194)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 04:16:23 +00:00
SilentWolf75 c94d1e9894 Inspect: keep analysis legends clear of left History (#4515) (#9210)
* claim: #4515

Co-authored-by: Cursor <cursoragent@cursor.com>

* Inspect: keep analysis legends clear of a left History (#4515).

Draft, Accessibility and Curvature legends were painted at the viewport's left edge under a left History sidebar. Place them in the free workspace content instead.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-30 03:59:33 +00:00
kaltenbrunnermarkus 46a2d6b53c ui-egui offscreen tests pick their GPU by SOLIDCRAFT_GPU (#9052)
offscreen_device() asked wgpu for its default adapter and ignored the
SOLIDCRAFT_GPU policy that headless pictures follow, so with
SOLIDCRAFT_GPU=software the ui-egui offscreen tests still opened the
hardware GPU. Under hardware or software the adapter now comes from
solidcraft_gpu::select (new pick_among, which pick uses too); under cpu
there is none; unset (auto) keeps wgpu's default adapter as before.

Part of #9040.


Claude-Session: https://claude.ai/code/session_013WUFq6QFBqmZjxwaKy2J1M

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 03:55:09 +00:00
austin62jake c9935007e8 Sketch: the Sketch Grid box in the Sketch panel turns the sketch's grid off (#9147) (#9187)
* claim: #9147

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

* Sketch: the Sketch Grid box in the Sketch panel turns the sketch's grid off (#9147)

The box sets the open sketch's hide_grid, but the viewport drew the grid
whenever a sketch was open, so hide_grid only reached the ground grid and
the box did nothing. An open sketch now shows its grid whatever the Grid
switch says, unless its own Sketch Grid option is off; the window guides
that renders and the control channel use follow the same rule.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-30 03:55:05 +00:00
ABDULLAH 7b9d37c787 Arabic: Off wording in the recent snap note matches the other preference notes (#9215) 2026-10-30 03:52:57 +00:00
bherbruck aa1fe28ddf Release 0.27.1 2026-10-29 23:34:31 -04:00
bherbruck 8f1b456275 Release 0.27.1 (#9181)
* Release 0.27.1

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

* Render job test: wait for the cancelled job's reservation to be released

The worker drops a job's reservation just after the job turns terminal, so
checking admitted bytes right after wait_timeout raced (10 of 40 local runs
failed). Poll with a deadline, as dielectric_tests does.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 23:34:23 -04:00
JK3D 8a6a064d37 Headless pictures: draw counts come back with the picture they count (#7521) (#7522)
* claim: #7521

* fix: headless pictures return their draw counts with the picture

`the_renderer_counts_what_it_draws` drew with `Capturer::rgba` and then
read `Capturer::last_stats()`. The capturer is shared with the binary's
other tests, which draw on it from parallel threads, so another picture
could land between the two calls and the test read its counts (e.g. 10224
triangles for the empty design).

`Capturer::rgba_with_stats` returns the picture and its counts, read while
the call still holds the device. `last_stats()` is removed; this test was
its only caller (the performance overlay reads the viewport renderer's own
stats handle).

New test `pictures_drawn_at_once_each_count_their_own` draws empty and box
designs from eight threads on the shared capturer. Against the old pair it
failed 25 of 40 full runs of the `capture` binary; it passes with the new
call.

* fix: a headless picture waits for its read-back's answer

A picture maps its read-back buffer, waits for its submission with
`device.poll(PollType::Wait)`, and then checked once with `try_recv`
whether the map callback had run. wgpu calls a finished mapping's
callback from whichever thread next maintains the device, in a poll or
in any `queue.submit`, after it lets go of the device. When another
thread got there first, the wait returned with the mapping done but its
callback not yet called, and the picture failed with "not supported yet:
reading a picture back without waiting for the browser", the error meant
for a browser.

On the shared capturer, the other thread is another picture's device
check (`probe_lost` submits nothing before it takes the capturer's lock),
so the `capture` tests failed now and then: `bad_picture_sizes_are_errors`
(its last 8 x 8 picture), `pictures_show_a_plain_cut_without_the_hatch`,
`pictures_blend_a_line_that_is_not_opaque`. In the app, the window's
frames are submitted on the device its thumbnails and `ui.render`
pictures are drawn on.

A native picture now waits for the answer, up to the picture timeout; a
browser still answers later, on its own.

New test `pictures_read_back_while_another_thread_uses_the_device`: 2,000
small pictures while another thread polls their device. Without the fix
it failed 19 of 20 runs (1 to 7 pictures each); with it, 150 of 150 runs
pass. `bad_picture_sizes_are_errors` now prints the error it gets.
2026-10-30 03:19:33 +00:00
github-actions[bot] f35f3197c3 Regenerate generated files and fold pending files [skip ci] 2026-10-30 04:44:58 +00:00
github-actions[bot] 78499f44af Regenerate generated files and fold pending files [skip ci] 2026-10-30 04:36:06 +00:00
semmelrinde 5b5201f1a6 Galician: translate the 386 messages the catalog lacks, Render Light and Frame stages included (batch 7) (#9162)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 03:02:11 +00:00
semmelrinde f28ff15ffb Hungarian: translate the 386 messages the catalog lacks, Render Light and Frame stages included (batch 7) (#9154)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 03:01:02 +00:00
semmelrinde 4f8bbec9fb French: translate the 356 messages the catalog lacks, Render Light and Frame stages included (batch 7) (#9153)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 03:00:21 +00:00
semmelrinde 77ebf1326c Italian: translate the 377 messages the catalog lacks, Render Light and Frame stages included (batch 7) (#9155)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 03:00:09 +00:00
github-actions[bot] fdc95337cf Regenerate generated files and fold pending files [skip ci] 2026-10-30 04:11:55 +00:00
github-actions[bot] 0e053e5e61 Regenerate generated files and fold pending files [skip ci] 2026-10-30 04:02:58 +00:00
semmelrinde 6bbb4276c0 Romanian: translate the 386 messages the catalog lacks, Render Light and Frame stages included (batch 7) (#9151)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 03:00:02 +00:00
semmelrinde 6d641c87c4 Malay: translate the 386 messages the catalog lacks, Render Light and Frame stages included (batch 7) (#9160)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 02:59:47 +00:00
semmelrinde 553f5fb9b8 Czech: translate the 386 messages the catalog lacks, Render Light and Frame stages included (batch 7) (#9152)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 02:59:46 +00:00
semmelrinde 88dd699715 Swedish: translate the 386 messages the catalog lacks, Render Light and Frame stages included (batch 7) (#9157)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 02:59:38 +00:00
semmelrinde c092e0551b Dutch: translate the 383 messages the catalog lacks, Render Light and Frame stages included (batch 7) (#9156)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 02:59:14 +00:00
semmelrinde c320dc421c Turkish: translate the 369 messages the catalog lacks, Render Light and Frame stages included (batch 7) (#9158)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 02:58:58 +00:00
semmelrinde 0f489a5777 Croatian: translate the 384 messages the catalog lacks, Render Light and Frame stages included (batch 7) (#9165)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 02:58:49 +00:00
semmelrinde 3df709be63 Norwegian Bokmål: translate the 386 messages the catalog lacks, Render Light and Frame stages included (batch 7) (#9163)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 02:58:45 +00:00
semmelrinde 875ac2ce49 Traditional Chinese: translate the 385 messages the catalog lacks, Render Light and Frame stages included (batch 7) (#9159)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 02:58:31 +00:00
semmelrinde 698275874f Indonesian: translate the 386 messages the catalog lacks, Render Light and Frame stages included (batch 7) (#9161)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 02:58:10 +00:00
semmelrinde 0d4470cd8c Bosnian: translate the 384 messages the catalog lacks, Render Light and Frame stages included (batch 7) (#9164)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 02:57:53 +00:00
semmelrinde 5dfc3481c1 Serbian: translate the 384 messages the catalog lacks, Render Light and Frame stages included (batch 7) (#9166)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 02:57:11 +00:00
bherbruck b596bfc730 Bottom bar: keep active toggles readable when the selected fill is as dark as the accent (#9168) (#9171)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 22:42:27 -04:00
Colin Faulkingham d11d268597 Render: reject excessive pixel work during preflight (#5088) (#5728)
* claim: #5088

* fix: reject excessive render pixel work during preflight (#5088)
2026-10-30 01:35:12 +00:00
github-actions[bot] 4b0ba62c69 Regenerate generated files and fold pending files [skip ci] 2026-10-30 01:13:55 +00:00
taylorhayis 313d2bdb41 Sketch: snap where two recent centre and midpoint guides meet (#8694) (#8744)
* Sketch: snap where two recent centre and midpoint guides meet (#8694)

The alignment guide remembered one reference and did not move the cursor. Keep the previous centre or midpoint as well, and snap a drawing point to where their guides cross.

* Sketch: keep the recent-guide crossing snap off until asked (#8694)

The crossing snap was on whenever alignment guides were. Give it its own Sketch preference, off by default, so the guides stay visual until someone turns the snap on.

* Sketch recent snap: ar/he/vi strings and clippy borrow fixes

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

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 00:40:05 +00:00
Colin Faulkingham 079f6e61e6 Sketch: show no geometry for an empty sketch (#9077) (#9104)
* claim: #9077

* Sketch: show no geometry for empty viewport sketches (#9077)
2026-10-30 00:33:47 +00:00
Rick Morgans 4a781b8cd8 MCP: --param-check checks calls to the running app (#9072) (#9073)
* claim: #9072

* MCP: --param-check checks calls to the running app (#9072)

`mcp --connect` (and `mcp` once it bridges to an app) took
`--param-check` and forwarded every call unchecked, while the app's own
session is always off. `Remote` now checks each `engine.execute` and
`engine.batch` against this server's command contracts before sending:
`strict` refuses a call, or a batch with such a step, without sending
anything, and `warn` adds `param_warnings` to the app's reply (each
step's result in a batch). A command with no contract here goes to the
app unchecked. `Auto` carries the headless session's level over when it
bridges to a late app. `batch_preflight::contracts` is the contract half
of the existing preflight, for a runner in another process.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 00:33:42 +00:00
bherbruck 5b0eb08473 Release 0.27.0 2026-10-29 19:53:24 -04:00
bherbruck 3e62553d9c Release 0.27.0 (#9106)
Fold the pending design format changes (sketch-equation-curves, timeline-joint-folders) into format 44.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 19:52:57 -04:00
semmelrinde 87206f20c8 Theme: the System theme leaves the window to follow the system appearance (#8983) (#9103)
theme::apply always sets egui's theme to light or dark, and egui's own window
sync then told the native window to be light or dark. A macOS window told so
keeps that appearance and no longer reports the system appearance changing,
so with Theme set to System the app stayed light after macOS turned dark. The
app now syncs the window itself: the system's appearance while the theme
follows it, the theme's own otherwise (for the title bar).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 23:51:07 +00:00
Logan Night 5a24d4095a Sketch: an old edge-on projection keeps its line when it is re-resolved (#9025) (#9097)
* claim: #9025

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

* Sketch: an old edge-on projection keeps its line when it is re-resolved (#9025)

A face seen edge-on was saved as two lines on the same two points, one each way, and is one line now. Re-resolving the link dropped both lines and built a new one under a new id, so an offset that names the first line as its source failed with "an offset source vanished or changed shape". Keep the first line and drop only the second.

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

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
2026-10-29 23:50:29 +00:00
semmelrinde d4fb5538e6 Sketch: a side squeezed to a hair in two solves is refused too (#9049)
The check for a constraint that shrinks a curve to a point took under 1e-6
for a point. One solve could squeeze a 49 mm side to about 1.2e-6 mm and
the next take it the rest of the way, so neither solve tripped it (two
rectangles' corners made coincident, found by a local sketch fuzzer). Under
1e-5 now counts: in 4000 random sketches the constraints that still
collapsed a line went from 9 to 0, and every refusal rate stayed the same.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 21:52:36 +00:00
github-actions[bot] f6393ef211 Regenerate generated files and fold pending files [skip ci] 2026-10-29 22:12:44 +00:00
SilentWolf75 355b486595 Parameters: unit-less numbers follow the other side's unit (#9015) (#9027)
* claim: #9015

Co-authored-by: Cursor <cursoragent@cursor.com>

* Evaluate bare length offsets in the design's units (#9015)

Feature expressions like baseLength - 1 were subtracting millimetres because val() parsed with mm defaults and only applied design units to a final unit-less result.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-29 21:52:23 +00:00
Rick Morgans 2356a63aea CLI: run takes --param-check (#6921) (#6924)
* claim: #6921

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

* CLI: run refuses unknown options and takes --param-check (#6921)

`solidcraft-cli run` now checks its options through `known_args`, so a
mistyped option fails instead of being ignored, and takes
`--param-check off|warn|strict` with the MCP server's words and default
`off`. `warn` prints each unknown key with a suggestion on stderr and
runs the step; `strict` stops at that step and writes nothing.

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

* CLI: run's option check takes --param-check; warn on timed runs as steps complete

`run` keeps #6919's (#6918) unknown-option check, with `--param-check`
among the options that take a value. `Session::run_script_timed` now
takes the same per-step callback as `run_script_each`, so `run --timings`
or `--profile` at the warn level reports a step's warnings even when a
later step fails, instead of after the script (and not at all on a
failure).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 21:40:30 +00:00
Steven Waters 57c0121cb3 Cut & paste features between components (#5798) (#5805)
* Add timeline.cut and refactor timeline.paste to allow cutting and pasting features

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

* Updated test for new grounded behaviour

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

* Resolved conflicts with #3390

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

* Fixed failing test

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

* Resolved conflicts with #3512

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-29 21:39:53 +00:00
austin62jake 9b97e06f71 Web: open tabs share settings changes, so one tab no longer undoes another's (#8644) (#8832)
* claim: #8644

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

* Web: open tabs share settings changes, so one tab no longer undoes another's (#8644)

Every open tab of the web app saved its whole copy of the settings to the
same browser storage, every 30 s and on focus loss, so a tab still holding
old settings put them back over a toolbar change made in another tab.

A tab now writes its settings in the frame they change, and the other open
tabs load them from the browser's storage event, so their own saves keep
them. Settings loaded that way aren't written back.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-29 21:36:10 +00:00
Colin Faulkingham 9c5a636987 Parameters: accept mixed-case text units (#8960) (#8985)
* claim: #8960

* fix: accept mixed-case text parameter units (#8960)
2026-10-29 21:11:16 +00:00
SilentWolf75 18b00a4192 UI: keep Preferences above the Distance entry (#9016) (#9022)
* claim: #9016

Co-authored-by: Cursor <cursoragent@cursor.com>

* UI: keep Distance entry under Preferences and Report a bug (#9016)

Canvas value boxes used Foreground order, so they covered system windows. Draw them as Background chrome instead.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-29 21:09:28 +00:00
ABDULLAH 988caa83fc Arabic: settings profiles, ribbon reveal, orbit, viewport and history marker wording (#9019)
* Arabic: Settings profiles and command search ribbon reveal wording

* Arabic: orbit, viewport and history marker terms in today's new strings
2026-10-29 21:03:28 +00:00
semmelrinde 18dc05c83d Sketch Trim and Extend: a crossing at a curve's own end is that end, leaving no curve of no length (#9012)
* Sketch Trim: a cut at a curve's own end leaves no piece of no length

A curve that ends where another one ends crosses it there only within the
intersection's tolerance (1e-6), but bracket dropped cuts at the curve's
ends only within 1e-9 of its parameter range. A line ending on an arc's end,
trimmed from its other end, was cut a hair inside its own end and kept a
piece about 1e-7 mm long (found by a local trim fuzzer: 78 such trims in
4000 sketches, and 8 breaks). bracket now also drops a cut within that
tolerance of the curve's ends, as add_cut merges cuts that close together.

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

* Sketch Extend: a touch at an arc's growing end is that end, not a stop

An arc whose end is where a line ends that touches the arc's circle there
found the touch a hair beyond the end (within the intersection's tolerance),
stopped on it, and added an arc from that point to itself. A hit within that
tolerance of the growing end (1e-6, as for the far end) is now the end.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 20:48:18 +00:00
semmelrinde 5cf18ba8c3 Sketch: a constraint that would shrink a curve to a point is refused (#8988)
A constraint whose only solution squeezes curves to a point (a rectangle's
corner made the midpoint of its opposite side, or two opposite corners made
coincident) collapsed the whole rectangle while the sketch said solved. A
solve that shrinks a line or circle from over 1e-3 to under 1e-6 now counts
as one that fails, as one that throws points far away does: a strict edit is
refused, another keeps the sketch from before its solve, and the free solve
after the held ones in apply_in_order refuses the constraint.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 20:21:51 +00:00
P'tite Tête 9454afe13a fix: Draw only the timeline chips in view (#8976)
- Skip drawing and interaction for history chips outside the strip's visible part, after their slot is recorded
- Keep a dragged or focused chip, and every chip while a popup is open
- Frame on a 4,880-body design: 20.3 ms -> 14.6 ms (M4 Max, release, headless)
- Test a_long_timeline_paints_only_the_chips_in_view

Co-authored-by: P'tite Tête <asso@ptitetete.com>
2026-10-29 19:29:54 +00:00
MATHEUS PINHEIRO RAPOSO 70d39bb4f3 Three tests pass on Windows with an 8.3 TEMP path (#8968)
On a Windows account whose %TEMP% is a short (8.3) path, such as
C:\Users\MATHEU~1\AppData\Local\Temp, three tests failed because they built
their folders from std::env::temp_dir(), and the agent file scope refuses short
names ("not allowed for this agent: short (8.3) names such as `PROGRA~1`"):

- engine: cmd::render::settings_tests::embedded_hdr_capture_rejects_parameters_scope_and_cpu_primaries_without_mutation
- ui-egui: agent_chat::tests::file_actions_wait_for_the_user
- ui-egui: agent_chat::tests::permission_modes_decide_what_asks

They now use vfs::temp_dir(), which spells the folder with long names, as
libraries_tests and motion_tests already do (#1385, #1211).

Checked on Windows 11 x64 with the 8.3 TEMP: the agent_chat and
render::settings tests (113) pass, and clippy -D warnings is clean for both
crates.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 19:13:36 +00:00
github-actions[bot] a4fd998539 Regenerate generated files and fold pending files [skip ci] 2026-10-29 19:40:05 +00:00
github-actions[bot] 1c673e38c7 Regenerate generated files and fold pending files [skip ci] 2026-10-29 19:31:29 +00:00
tr1pp4 b43712cdb0 Command search: show a command's place in the ribbon, also inside section menus (#7287) (#7322)
* claim: #7287

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

* Command search: show a command's place in the ribbon, also inside section menus (#7287)

Hovering a command result in Search commands switches the ribbon to the command's
tab and marks its button with its tooltip; a command that has no button there
opens its section's drop-down on its row instead. Leaving the results (or closing
the box) goes back to the tab the user was on, so pressing S, typing and Enter
works as before.

Starting a command from the box makes its button or section label blink twice,
and a command started from a section drop-down marks that section's label while
it runs, like a pressed button.

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

* Command search: the ribbon reveal is a preference, off by default (#7287)

Preferences › Interface › Show search results in the ribbon (`ribbon_reveal`) turns on the hover
preview, the blink after a start and the running mark on a drop-down's panel label. Off, the
ribbon behaves as on main.

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

* Pending locale: Arabic, Hebrew, Japanese, Vietnamese and Chinese for the ribbon reveal setting

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-29 19:06:44 +00:00
Homero Thompson 184d4720be Sketch: a dimension can measure a model edge (#7912) (#8010)
* claim: #7912

* Sketch: dimension a sketch entity against a model edge

A dimension took sketch entities only, so measuring a sheet-metal body's edge
was refused with "cannot dimension that combination of entities". The
entities now take a model reference too, an {edge: [x,y,z], body?} or a
{vertex: [x,y,z], body?}, which is projected into the sketch first, as the
constraint commands' operands are since #6348, and measured there.

The Dimension tool picks a body's edge or vertex when the sketch has nothing
under the cursor, and reads the pick back into the reference the command
takes.

* style: rustfmt the sketch dimension command

* Cap the entities one call may project

A dimension projects the model references among its entities into the
sketch, one link and one resolve each, and the loop had no cap: a single
request could make the engine resolve thousands of model edges before
the dimension refused the list. The count is now refused before anything
is projected, and the projection loop stops at the same limit the
Project command uses.

* Sketch dimension on a model edge: use the sketch store after the store refactor

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

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 18:54:42 +00:00
Colin Faulkingham f08623be3c Fix V visibility shortcut for browser folders (#8800) (#8925)
* claim: #8800

* Fix folder visibility shortcut in browser (#8800)

* retrigger CI [skip ci]

* retrigger CI
2026-10-29 18:54:34 +00:00
semmelrinde 31218b1451 Sketch drag: an end the cursor can't reach stays where it is (#8944) (#8967)
The cursor correction tries the constraints' free directions a whole step at
a time and took a step as soon as the cursor's distance fell, by any amount.
Along freedom that can't reach the cursor (the far end of a line held only
by its direction), retracting onto the constraints still moves the cursor by
rounding, so such steps were taken: the end walked away a step at a time, and
a line measured by an angle could shrink to nothing, as in #7635. A step now
has to move the cursor itself by more than the solve's tolerance.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 18:54:27 +00:00
tr1pp4 cb34eef6aa Settings profiles: save, switch, export and import input and interface settings (#6237) (#6254)
* claim: #6237

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

* Settings profiles: save, switch, export and import input and interface settings (#6237)

A profile keeps the Interface, Navigation and Sketch preferences (mouse mode,
radial menu and gestures with every studio's lists, theme, scale, font, ...),
the language, the keyboard shortcuts, the toolbar, the S box favourites and
the imported theme it uses under a name. Machine-specific settings stay out
(3D mouse, graphics, units, AI, recent files).

- Preferences > General > Settings profiles: choose the active profile to
  apply it, Save, Rename, Delete, Export... and Import...; a note shows when
  the settings differ from the saved profile; Cancel takes a switch back.
- settings.profile.{list,save,apply,rename,delete,export,import} commands for
  the control channel, palette and scripts; the engine checks the parameters,
  the app does the rest.
- One `solidcraft-profile/1` file; import checks the format and name, drops
  unknown keys and unreadable values, caps sizes, never switches, and never
  panics. Each preference is applied through prefs::set, so a value this
  computer refuses keeps its current one and is reported.

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

* Glossary: settings profile, and "profile" among the words with more than one meaning (#6237)

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

* Settings profiles: new strings in a pending locale file, not the catalogs (#6237)

New UI strings now go to assets/locales/pending/ (CONTRIBUTING.md), so
this pull request no longer edits the shared catalogs.

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

* Preferences snapshot: compare the toolbar, favourites and profile too

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

* Settings profiles: keep a command's several keys (#6237)

Since a command can have several keys (#6288), the keymap saves a list where it has more than
one. A profile's checks only took a single key, so saving or importing a profile dropped such a
command's shortcuts. They now read the keys as the keymap does.

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

* Settings profiles: Arabic, Hebrew and Vietnamese strings (complete catalogs) (#6237)

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

---------

Co-authored-by: Tripper <tripper@localhost>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-29 18:42:36 +00:00
github-actions[bot] b9e9521128 Regenerate generated files and fold pending files [skip ci] 2026-10-29 19:03:47 +00:00
github-actions[bot] e59a2bcf80 Regenerate generated files and fold pending files [skip ci] 2026-10-29 18:56:56 +00:00
Logan Night 6fcdca1bb7 Dialogs: a pre-selected sketch line lying on a body edge goes in as that edge (#8927) (#8942)
* claim: #8927

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

* Dialogs: a pre-selected sketch line lying on a body edge goes in as that edge (#8927)

With a finished sketch shown, a click on a body edge that one of its lines
lies on picks the sketch line. Fillet and Chamfer take edges and no sketch
curves, so the pre-selection found no input to fill: the tool opened with
Edges empty and the selection was dropped, and only a second click with the
tool open (which picks the edge) made it work.

When nothing in the dialog takes a pre-selected sketch curve but an input
takes edges, look for the body edge the curve lies on from end to end
(display polylines, with room for a curved edge's chord sag) and take that
edge instead.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 18:26:56 +00:00
CamWight a985b8ee17 Preferences: Perspective view's field of view, a slider or a typed value (#5364) (#5373)
Preferences > Graphics > Viewport gets a Field of view row under Perspective view: the UI kit's
slider in degrees, 15 to 90, dragged or typed and kept to whole degrees, enabled only while
Perspective view is on. The preference is perspective_fov, in radians (default 0.6, about 34°,
so the default view draws exactly as before), and Restore Defaults puts it back.

One place decides the angle the view is drawn with: SolveApp::view_fov(), the preference with
Perspective view on and 0 with it off or while a sketch is open. A saved value outside 15°-90°
reads as the default. projection and fit_bounds read it (fit_bounds' second hard-coded copy of
the 0.6, tan(0.3), now goes through Camera::half_height), and so does the headless fit.
perspective_view() stays, as view_fov() > 0, so #4291 needs no change.

When the angle changes from one perspective angle to another, the camera's distance scales by
tan(old/2) / tan(new/2), with a running view animation's ends, so the target keeps its size and
the slider changes the depth, not the zoom. prefs::follow_view_fov does it once per frame and
after ui.prefs, Cancel and Restore Defaults; a parallel view keeps the last perspective angle,
and while Render is open the camera is Render's own, so the change waits for the design view.

The Render workspace's Perspective check box turns perspective on at the preference's angle,
not π/4; a saved render setup keeps its own. ui.prefs takes perspective_fov and refuses values
outside the range; ui.camera reports the angle the window draws with, and checks a replayed
camera against it, so its reply still replays. Field of view is in en, ar, de, he, ja, ko, vi and zh;
the ui.prefs settings list in docs/control-protocol.md and the guide's tour describe it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 18:23:49 +00:00
HiroKenEz c857f2fe70 parameters.add: a parameter with no unit takes the design's units (#8865) (#8880)
* claim: #8865

* parameters.add: a parameter with no unit takes the design's units (#8865)

A new parameter given no unit took "mm" for any length or bare number, so
in an inch design `parameters.add {name: v, expression: "40/2"}` made a
20 mm parameter where the Parameters dialog makes 20 in. The unit a new
parameter infers is now a length in the design's units (mm designs are
unchanged); parameters.change on a new name and parameters.import share
the same inference. Angles and unit-less values keep their units.

A definition typed into a value of no fixed kind (a coupling's pitch,
a joint's value) still reads its bare number as millimetres, as that
field does.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 18:18:04 +00:00
SilentWolf75 9416e975e0 View cube: rapid clicks add full turns from the in-flight target (#8912) (#8929)
* claim: #8912

Co-authored-by: Cursor <cursoragent@cursor.com>

* View cube: rapid clicks add full turns from the in-flight target.

Match arrow-key retargeting so a second cube click turns from the glide destination instead of the mid-animation camera (#8912).

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-29 18:17:02 +00:00
kaltenbrunnermarkus 5f11d92458 Opening a design pins its body names, so the first undo evaluates nothing (#8939)
Problem: a design saved before body names were pinned (#6403) is pinned by its first command
that changes it, as part of that command. Undoing that command went back to the unpinned design,
whose fingerprints no evaluated result matched any more, so the first undo after opening
evaluated the whole timeline again: 63-72 s in the SELENE rover (5,484 features) when opened
from the command line or headless (`Session::opened_at`), 0.08 s with this change. `doc.open`
pinned at the end of the command instead, which kept undo fast but left the design with unsaved
changes on opening.

Solution: `Session::opened_at` pins the names as it opens the design, with the names the file's
bodies have (no result changes, so nothing is evaluated), and takes the pinned design as the
saved one: opening is no change.

Claude-Session: https://claude.ai/code/session_013WUFq6QFBqmZjxwaKy2J1M

Co-authored-by: Markus Kaltenbrunner <kaltenbrunnermarkus@users.noreply.github.com>
2026-10-29 18:15:47 +00:00
SilentWolf75 97550dc37e Parameters: one Name-column floor for every table in the dialog. (#8945)
Favorites, User and Model sections stay lined up when only some lists hold a long name (follow-up to #8551).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-29 18:14:35 +00:00
ABDULLAH 4456e0470a Arabic: Preferences instead of Settings, hover and Project option wording (#8949)
home.settings and ai.connected_toast follow the English change from Settings to Preferences (التفضيلات, as ui.preferences). Hover is no longer rendered as scrolling in the browser and navigation bar options, the Project options dialog label is fully in Arabic, and the trackpad zoom key help says to hold the key.
2026-10-29 18:14:26 +00:00
HiroKenEz 561336aab6 Text fields: a right-click menu with Cut, Copy, Paste and Select All (#8875) (#8882)
* claim: #8875

* Text fields: a right-click menu with Cut, Copy, Paste and Select All (#8875)

Every kit text field (dialog and sketch value fields, the sketch dimension
box, canvas value boxes, names, text areas, passwords, read-only text) now
calls one shared ui_kit::text_menu::menu after drawing its editor. Rows are
disabled when nothing is selected, the text is read-only or a password, or
the clipboard holds no text. The edit marks the response changed and gives
the focus back, so owners that commit on lost focus keep the field; a right
press inside the selection keeps it; Esc closes only the menu; expression
suggestions wait while it is open. New message ui.select_all in a pending
locale file; Cut, Copy and Paste reuse ui.cut, ui.copy and ui.paste.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 18:14:09 +00:00
Daniel 82b38f5bae Timeline: delete after the marker counts and deletes the shown features (#8860) (#8866)
* claim: #8860

* Timeline: delete after the marker counts and deletes the shown features (#8860)

With the bottom timeline filtered to one component, "Delete N Features After
History Marker" took the marker as a position among the shown features but
sliced the design's feature list with it. N counted features of other
components, and confirming deleted them too, including features before the
marker.

The entry now gets the feature's design position, the design's marker and
the shown positions, and deletes the shown features after the marker; the
label counts the same set. The history beside the browser shows every
feature and passes no filter, so it behaves as before.

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

* Timeline: look up shown positions by binary search, test the entry before the marker (#8860)

The shown positions are ascending, so a binary search finds each one without
walking the list for every feature after the marker. The test now also checks
that a shown feature before the marker does not offer the entry.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 18:03:31 +00:00
github-actions[bot] 43a22bed46 Regenerate generated files and fold pending files [skip ci] 2026-10-29 18:25:46 +00:00
bherbruck cd12d9d8e7 Scenario: set the history sketches layout through ui.prefs (#8961)
A test-only change: the scenario no longer clicks in the growing Interface page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:23:29 -04:00
github-actions[bot] 15e3245180 Regenerate generated files and fold pending files [skip ci] 2026-10-29 18:19:08 +00:00
semmelrinde 730dc5f6c3 Segmented choices: show the options in the user's language (#8030)
A segmented control measured its options translated but painted them as
the caller passed them, so the many callers that pass English labels
(the Coil's Circular | Square and Inside | Center | Outside, Revolve's
One Side | Two Sides | Symmetric, Shell's direction, ...) showed English
in every language. Only where the row was too narrow and the control
fell back to its dropdown, which translates, did they read right.
Segments now paint, size, describe and tip their options translated;
the handles keep the option as passed.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 17:55:25 +00:00
Colin Faulkingham 3cdc68aaf1 Viewport: opt-in model zoom and Traced 3D mouse orbit (#7435) (#7991)
* claim: #7435

* Viewport: the 3D mouse, the bridge and drag-zoom stop at the model, like the wheel (#7435)

The wheel and pinch hold the model point under the cursor, so the view
comes up to the surface and stops there. Every other zoom path passed no
anchor, so the 3D mouse cap, the navigation bridge and drag-zoom zoomed
straight through the surface into the body.

A device has no pointer, so the cap and the bridge now hold the model
point in the middle of the view; drag-zoom holds the point under the
pointer, like the wheel. With "Zoom to the cursor" off the old
zoom-through-the-model behaviour stays, as it does for the wheel.

The orbit half of the issue is a design decision and is untouched.

* Viewport: drag-zoom holds the point where the drag started (#7435)

The zoom anchor followed the moving pointer, so the zoom slid away from
what was pressed on as the drag went on. It now holds the press point,
the way the wheel holds the point under the cursor.

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

* 3D mouse: turn around the model in the middle of the view (#7435)

The cap and the navigation bridge turned the view around the view's
centre, which after a pan or from far away sits behind the face, so the
part swung away while it turned. They now turn around the model point in
the middle of the view (the middle of the bodies in view over empty
space), and keep it still on screen. Preferences > Navigation > "Turn
around the model in the middle of the view" switches back to the old
behaviour.

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

* 3D mouse: keep the turning point until the cap is back at rest (#7435)

The point the cap turns around was picked again every frame, so it slid
to whatever came into the middle as the view moved. It's now chosen when
the cap leaves rest and kept until the cap comes back, the way a mouse
orbit keeps the point it started on.

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

* 3D mouse: show the point the view turns around (#7435)

While the cap turns the view, a faint ball in the accent colour marks the
model point it turns around, so it's clear what the turn holds still. The
point is now picked when the cap starts turning, not when it only pans or
zooms, so a pan shows no ball.

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

* 3D mouse: orbit is a choice of Traced or Classic (#7435)

The checkbox becomes a two-way choice on the 3D mouse section's form:
Traced turns around the model in the middle of the view when the turn
begins and marks it with a faint ball; Classic turns around the view's
centre as before. Traced stays the default.

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

* Docs: the 3D mouse's Traced and Classic orbit (#7435)

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

* Make model zoom and traced orbit opt-in (#7435)

---------

Co-authored-by: nomadsgalaxy <16734355+nomadsgalaxy@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 17:49:15 +00:00
ReDead e3c08471b6 Sketch: diameter box appears for a second concentric circle (#8899) (#8904)
* claim: #8899

Co-authored-by: ReDead <ReDead@users.noreply.github.com>

* Sketch: keep Diameter box when placing a second concentric circle (#8899)

A centre-point circle's press starts drag-to-draw; releasing on the same
centre (or close to the press) must keep the stage-1 Diameter field instead
of resetting it and risking a second on-click that finishes the shape.

Co-authored-by: ReDead <ReDead@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: ReDead <ReDead@users.noreply.github.com>
2026-10-29 17:48:53 +00:00
Ahmad Khan a7048f41ab Sketch: equation-driven curve, a curve from x(t) and y(t) formulas (#8352) (#8353)
* claim: #8352

* Sketch: equation-driven curve, a fit point spline from x(t) and y(t) formulas (#8352)

sketch.equation_curve draws a curve from formulas in the expression
language and the design's parameters: x(t) and y(t) over a range of t, or
y = f(x) over a range of x. The curve is a cubic fit spline through fixed
points sampled from the formulas, as many as keep it within a tolerance
(0.01 mm by default, at most 500 points); a curve that ends where it starts
closes smoothly. The sketch keeps the formulas (one new design-format
file), places the points again before every solve so they follow the
parameters, and sketch.equation_curve.edit changes them, keeping the joined
end points. Both commands have input and output schemas.

* Sketch: Equation Curve window, its Edit menu item, labels and guide (#8352)

The ribbon's Equation Curve opens a window for the form (parametric or
explicit), the formulas, the range and the tolerance; OK runs
sketch.equation_curve. Right-clicking an equation curve offers Edit
Equation Curve, which reopens the window on its formulas and runs
sketch.equation_curve.edit. New labels and help in a pending locale file
(with ar, de, he, ja, ko, vi and zh), the icon, the glossary term and the
sketching guide.

* Equation curve: one if for closing the sampled points (clippy collapsible_if)
2026-10-29 17:42:59 +00:00
github-actions[bot] 1c0001af8e Regenerate generated files and fold pending files [skip ci] 2026-10-29 17:50:55 +00:00
semmelrinde 8b1ee94296 Sketch: an edit whose solve throws points far away is not taken (#8931)
A held solve applying a constraint, or the edit's own solve, could find a far
solution of the constraints and move points millions of millimetres while
the sketch said solved. Such a result now counts as a failed one: the next
held solve is tried, or the edit is refused.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 17:24:02 +00:00
Ryan 791abbd9b1 Sketch: hold horizontal/vertical and line up with other points at once while drawing (#7828) (#8474)
* claim: #7828

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

* Sketch: hold horizontal/vertical and line up with other points at once while drawing (#7828)

While drawing, the pointer lines up horizontally with the nearest sketch
point level with it and vertically with the nearest one plumb with it, at
the same time, as a dragged point already does; holding H/V with the
previous point still lines up across with another point. Dotted guides run
from the points lined up with (H, V or H+V), and the click adds a
Horizontal or Vertical constraint with each, in the shape's undo step,
unless the sketch already has it.

A click takes the tool out of the app while it runs, so refine_snap
treated every tool as the Line tool then: a rectangle's click could take a
line-extension inference its hover never showed. The tool's points and
kind are kept from the last hover, as the previous point was.

Preferences > Sketch > "Line up with other sketch points while drawing"
(sketch_align_points) turns it off for the old behaviour.

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

* Sketch: lining up with other points is off unless turned on

Review on #8474: sketch_align_points defaults to off, so nothing changes for anyone who
doesn't turn it on. With it off, a click also treats the tool as before (the Line tool's
inferences), instead of keeping the tool's kind from the hover.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 17:11:42 +00:00
Nathan Dane 6255a10e80 Viewport status item: engine list of what is on (#8545) (#8554)
* claim: #8545

* Declare ordered viewport status items (#8545)

* Keep legacy surface analysis coexistence behind a preference (#8545)
2026-10-29 17:11:34 +00:00
Ryan d05a114241 Insert Design: pick one of the other open designs, or browse for a file (#8570) (#8571)
* claim: #8570

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

* Insert Design: pick one of the other open designs, or browse for a file (#8570)

With other designs open in tabs, Insert Design first lists them under the
button: one click inserts a saved one as a linked component; one with
unsaved changes inserts its saved file and the status line says so; one
never saved is greyed out, as there is no file to link. Browse... opens
the file picker, as the button did before, and it still goes straight
there with no other design open.

Preferences > Design > Linked designs > "Insert Design lists the designs
open in other tabs first" (insert_lists_open_designs) turns the list off.

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

* Insert Design list: sit with the other menus' items, so run_item keeps its doc comment

Review on #8571: insert_design_items had landed between run_item and its comment. The
rebased menu-walk list keeps main's Target::HistoryControls beside Target::InsertDesign.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 17:11:25 +00:00
PaddleStroke 6ebfe91661 History: say why an edited feature behind the marker changes nothing (#8100) (#8727)
* claim: #8100

* History: say why an edited feature behind the marker changes nothing (#8100)

Editing a feature that sits after the history marker (double-clicking its
greyed-out item) rolls back to it, previews the change, and on OK puts the
marker back where it was: the feature stays rolled back, so nothing in the
view changes and nothing says why. That is the silent "pattern of a cut
does nothing" in #8100 (the reporter's design has its pattern behind the
marker; on main the pattern itself builds fine once rolled forward).

- timeline.redefine replies `rolled_back` and, when true, a `note`; it also
  carries the feature's `warning` (for a rolled-back feature, the one its
  constructor met building it at its place).
- The window shows that note in the status line after such an edit.
- A rolled-back item's tooltip in both History layouts says it is after
  the history marker and not built.

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

* Pending locale: Arabic, Hebrew, Japanese, Vietnamese and Chinese for the rolled-back notes

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-29 17:11:10 +00:00
Logan Night d0b357d532 Sketch profiles: close the joints near-coincident curves leave open (#8639) (#8922)
* claim: #8639

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

* Sketch profiles: close the joints near-coincident curves leave open (#8639)

Two free-form curves that lie on each other along a stretch (a DXF that
carries an outline twice, with the same straight sides and different
corners) are cut into sampled pieces whose ends the graph joins into one
node within 1e-4 mm, but the pieces keep their own ends, a few millionths
apart. Only analytic edges were closed at those joints, so the sliver
loops between the two curves stayed open by up to 1e-5 mm, more than the
1e-6 a solid needs. The region around them took those loops as holes:
extruding it failed with "profile loop is not closed" and it drew no
fill when picked, so it looked unselectable.

Close a line's joint in each finished loop: line to line at their middle,
a line onto any other segment's end. On the reporter's DXF the band
between the outer two rings now extrudes (110.5 mm2 by 2 mm = 220.9 mm3),
and the zero-area slivers no longer form profiles (221 -> 216).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 17:11:01 +00:00
HiroKenEz cc047a29c1 Sketch: the status line after a dimension is in the interface language (#8863) (#8879)
* claim: #8863

* Sketch: the status line after a dimension is in the interface language (#8863)

The Dimension tool built its "Dimension applied" status sentences (new
parameter, renamed parameter, plain value) and the driven-dimension note
with English format! strings, so they stayed English under a translated UI.
They now come from the catalog (one pending locale file: en, ar, de, he,
ja, ko, pl, vi, zh), with the typed parameter name passed in as is.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 17:07:31 +00:00
Daniel 32c8b23199 History: Delete on a selected feature deletes the selection in both timelines (#8861) (#8867)
* claim: #8861

* History: Delete on a selected feature deletes the selection in both timelines (#8861)

With several features selected, Delete in a selected feature's menu deleted
the whole selection in the bottom timeline and only that feature in the
history beside the browser, which offered the selection as a separate
Delete Selected Features… entry.

Both menus now use one Delete entry: the selected features when the
right-clicked one is among them, labelled "Delete N Selected Features",
else that feature alone. The history beside the browser no longer adds its
own Delete Selected Features… to a selected feature's menu, since the entry
above covers it; folder menus keep it. The single-feature delete helper had
no callers left and is removed.

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

* History: a feature's menu leaves out Delete Selected Features… by a flag (#8861)

A right-click on a feature outside the selection selects that feature
first, so in a feature's menu the clicked feature is always selected. A
flag that says the menu is a feature's says the same without the lookup.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 17:06:25 +00:00
github-actions[bot] 21366ec47e Regenerate generated files and fold pending files [skip ci] 2026-10-29 17:18:00 +00:00
N3MI-DG 699d730151 Button head cap screws, ISO 7380-1, in the standard parts library (#8006) (#8017)
* claim: #8006

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

* Parts library: button head cap screws, ISO 7380-1 (#8006)

Insert Part offered the socket head cap screw but no button head, so a
design using button heads had to stand in a cap screw and accept the
wrong head. A button_head_cap_screw family now sits beside it, taking
the same parameters from the ISO 7380-1 table over M3 to M16.

The head is a new Head::Dome: the shank and the head revolve as one
outline, with the rim at dk, a short cylindrical flank, the arc of the
crown, and a flat top around the socket, so the hex socket stays a plain
cut on a plane. The standard draws the flank and the flat but dimensions
neither; they are 0.15 k and 0.7 s, and the comment on those constants
records what that costs against the standard's crown radius r1 and why
neither other fractions nor r1 taken as given does better. dk, k, s and
t are exact, each checked against three reproductions of the table.

Insert Part, Change Part Size and the bill of materials need no change:
they read the families and their table rows from parts.library.

The wider fastener request is issue 7692; this is only the one family
from it, and leaves the rest of that list open.

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

* Parts: format after the rebase

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

* Pending locale: the Button head cap screw family name

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-29 16:43:36 +00:00
Lars Jansen a4274f9b59 Line tool: press and drag draws one line (#7247) (#7252)
* claim: #7247

* feat(sketch): press and drag in the Line tool draws one line (#7247)
2026-10-29 16:34:30 +00:00
N3MI-DG d6d6c7d9ee Copy and paste bodies and components between designs (#3511) (#3512)
* Components: copy and paste bodies and components between designs and components (#3390)

Squashed for the rebase onto main.

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

* claim: #3511

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

* Paste New: copy from a design other than the one pasted into

`component_clone::paste` read the design it copies from through the session it pastes into.
`build` now takes the source (document, evaluated model, unapplied moves) apart from the
target session; Paste New within one design passes the session as both, as before. Names are
numbered against the target's names.

From another design (`Source::copied`, a `ForeignCopy` snapshot taken at copy time):
- names are kept where the target doesn't have them, else numbered;
- the user parameters the copy uses come along, `w_2` where the target has a `w`, with the
  copied expressions following;
- a copy whose features use what isn't in it (bodies, sketches, faces, parameters outside it)
  is refused with a list of what; `FeatureKind::input_bodies` lists the bodies a feature uses;
- configurations stay with their design, and the target's timeline marker must be at the end;
- a body moved in from outside comes as an imported base named `BodyImport1`.

Body transfers are pointed at the copy's components before they are added: `add_feature`
checks that the components they name exist, which another design's don't.

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

* Engine: paste bodies and components copied in another design

`component.paste_from_design` pastes what `Session::foreign_copy` holds, at the same world
coordinates as in its design, into the active component or `component` (placed by
`component_path`; a component placed more than once gets it in every instance), as one step
to undo:
- bodies as imported bodies, without their history, in one Import feature (named as any
  import is, "Import1"); a body seen in another placement ("Cube (B:2)") pastes as "Cube";
  names the target has are numbered;
- components as independent copies (Paste New from the snapshot): with their history,
  appearances, materials, joints and motion inside them; joints to the outside stay behind.

Tests: bodies (position, naming, target component, undo; a body of another placement) and
components (history, appearances, a part placed twice, editing independently, save and reopen,
undo; user parameters renamed; refused when using a body outside it; bodies moved in).

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

* Clipboard: copy in one design, paste in another

Copying or cutting bodies or components also keeps a snapshot of them (`ForeignCopy`); another
tab pastes it with `component.paste_from_design`: bodies as imported bodies, components as
independent copies (Paste and Paste New alike), folded and selected. Cut pastes as a copy
there, the original staying; Esc on the cut leaves nothing to paste anywhere.

The last copy in any design is what pastes: each copy or cut is numbered, and a tab whose own
copy is older empties its clipboard when it is switched to (`drop_stale`). A failed copy
anywhere leaves nothing to paste in any tab.

Tests: bodies and components pasted in another tab, the last copy winning across tabs, cut
as a copy. `copied_bodies_stay_with_their_design` gave way to the cross-design test.

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

* Clipboard: what is selected wins over a design row picked earlier

Copy copied the whole design as a linked component whenever the design's
browser row was still picked, even after a component, body or feature had
been selected in the view or another tab, and failed in an unsaved design.
The design row now gives way to anything selected, and switching tabs
drops the picked components of the design left.

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

* Put the Paste from Another Design label in the pending locale file

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

* Tests: unground the placement before moving it (a design's first part is grounded)

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

* Drop locale keys main already folded into en.json

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

* Pending locale: Paste from Another Design in the complete catalogs

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-29 16:31:09 +00:00
DuBearte 5bd85dded6 Keyboard Shortcuts: the read-only list says so and leads to the editable page (#7409) (#7449)
* claim: #7409

* Keyboard Shortcuts: the read-only list says so and leads to the editable page (#7409)

Home › Keyboard shortcuts and Help › Keyboard Shortcuts open the
Shortcuts page read-only, in the Preferences window with its sidebar and
Cancel/Apply/OK, so it looked exactly like Settings › Shortcuts with
Import…, Reset All and recording missing. The read-only page now says
"Read-only list: shortcuts can't be changed here." and has an "Edit
shortcuts" button that turns the same page into the editable one.

Strings in assets/locales/pending/shortcuts-read-only-note.json (en, ja,
zh). Harness test opens it both ways, checks the note, clicks Edit
shortcuts, and finds Import…, Reset All and a recordable binding.

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

* Pending locale: Arabic, Hebrew and Vietnamese for the read-only shortcuts note

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-29 16:30:40 +00:00
austin62jake e5ef3bad02 Viewport: with Orbit, Pan or Zoom lit, a press held still keeps the highlight under it (#8864) (#8898)
* claim: #8864

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

* Viewport: with Orbit, Pan or Zoom lit, a press held still keeps the highlight under it (#8864)

A mode lit in the navigation bar, or a held Space, owns drags, not a still
press (#8438), but the hover pick counted any left press under such a mode
as navigation and dropped the highlight, so the line under a Select Other
hold went dark until the list opened. Only a preset's left-button binding
or Ctrl+left orbit navigates from the press on now; a lit mode's drag
still drops the highlight once it starts.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-29 16:25:11 +00:00
github-actions[bot] 4aa2d6248e Regenerate generated files and fold pending files [skip ci] 2026-10-29 16:45:50 +00:00
github-actions[bot] bd4a234461 Regenerate generated files and fold pending files [skip ci] 2026-10-29 16:36:59 +00:00
Frank 162b3cd4ed Sketch Intersect: add Faces/Bodies and Projection Link options (#1110) (#7989)
* Sketch Intersect: add Faces/Bodies and Projection Link options (#1110)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Sketch Project: add Geometry/Bodies and Projection Link options (#1110)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Sketch Project/Intersect: keep exact lines and arcs of whole bodies, no noisy rebuild warning (#1110)

Whole-body silhouettes and sections fit lines and arcs tightly and snap arcs
tangent to their neighbours. A rebuilt projection warns only when it removed
the user's own constraints or curves. Pending locales cover ar, he and vi, and
the preselection tests use the direct-pick workflow.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Select Other: label curves from Intersect as intersected (#1110)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-29 16:09:18 +00:00
PaddleStroke 420dff36c8 Browser: remember collapsed state, optional reveal on hover (#8692) (#8762)
* claim: #8692

* Browser: remember the folded card across runs, optional reveal on hover (#8692)

The browser card's fold (the chevron in its header) is now a preference,
saved with the other layout state, so the next run starts the way the user
left it. Preferences > Interface > Layout adds Shown / Reveal on hover: the
browser then stays out of sight until the pointer comes over its place or
the thin tab at its left edge, fading in like the navigation bar's Hide
until hovered, and stays while the pointer is inside or a menu, drag or text
edit started from it is open. It never folds in that mode.

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

* Browser: move the reveal-on-hover messages to a pending locale file (#8692)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 16:03:05 +00:00
raykholo 3cdfec798f Timeline: joints in feature folders (#8306) (#8316)
* claim: #8306

* Timeline: joints in feature folders (#8306)

timeline.group and timeline.move_to_group take joints as well as features, so
hundreds of joints fold into one folder tile. A closed folder shows one tile
whatever it holds; opening it shows its features and joints in both the bottom
and left History. Suppress, delete and ungroup on a folder reach its joints too
(one undo step), deleting a joint removes it from its folder, and an unknown
joint id or name is an error. Saved with the design; designs without joint
folders read and write as before.
2026-10-29 15:59:18 +00:00
austin62jake c32f6b60eb Expressions: Enter after pi mm keeps the unit, and the list's state follows the text (#8862) (#8871)
* claim: #8862

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

* Expressions: Enter after `pi mm` keeps the unit, not a longer name (#8862)

A unit after a name or constant multiplies it, as after a number (`pi mm`
is pi millimetres), so it is a complete word there: the longer names are
listed unarmed and Enter submits what was typed. A unit at the start, or
after an operator or bracket, stays a prefix as #3664 chose.

Whether Enter completes is now worked out again every frame, unless a key
armed the list. It was kept from when the list first appeared for a word
and caret position, so it went stale when the text before the word was
replaced, or when the box took the focus before the names were gathered.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-29 15:56:31 +00:00
github-actions[bot] 5fe8d5a757 Regenerate generated files and fold pending files [skip ci] 2026-10-29 15:57:43 +00:00
Colin Faulkingham 4e78acc7a2 Sketch: the same constraint twice is one constraint (#5224) (#8850)
* claim: #5224

* Sketch: the same constraint twice is one constraint (#5224)
2026-10-29 15:31:18 +00:00
phanoob 4cd90e6806 Status bar: switch the mouse mapping from a menu (#7023) (#8856)
* claim: #7023

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

* Status bar: switch the mouse mapping from a menu (#7023)

The status bar gets a mouse-mapping button beside the units. It shows the
current mapping, and a click opens a menu of the mappings above the bar.
Picking one sets what Preferences > Navigation sets for it (bindings, wheel
direction, middle double-click, right drag orbits) through
choose_nav_preset. Nothing changes while the status bar is off (the
default).

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-29 15:28:34 +00:00
raykholo 8a888ff0f3 Preferences: mouse-wheel zoom speed (#4176) (#4179)
* claim: #4176

* Preferences: mouse-wheel zoom speed (#4176)

A 0.25x to 4x multiplier for each wheel zoom step, applied in
navigation::handle so every canvas uses it. The 3D mouse keeps its own
speed. Values outside the range are refused when set and reset to 1x when
loaded.

* Control protocol: wheel_zoom_speed in the sorted ui.prefs list (#4176)

* Wheel zoom speed: its label in the complete catalogs (#4176)
2026-10-29 15:26:14 +00:00
ABDULLAH 5530c3c68e Arabic: review of strings added today (Resize Part hint, navigation bar position, geometry budget help) (#8826)
* Arabic: fix the Resize Part hint (tanween style, part list, command name)

* Arabic: navigation bar Edge reads as position, tanween spelling in the geometry budget help
2026-10-29 15:24:59 +00:00
Dahak Builds 35601da3f0 3D sketch: 3D Line, Spline and Point buttons start tools (#8040) (#8041)
* claim: #8040

* wip: 3D line/spline/point tools (#8040)

* Take sketch.move3d off the toolbar help backlog now that it is agent-only (#8040)

---------

Co-authored-by: Dahak Builds <dahakbuilds@users.noreply.github.com>
2026-10-29 15:10:41 +00:00
github-actions[bot] 1d566398d8 Regenerate generated files and fold pending files [skip ci] 2026-10-29 15:28:09 +00:00
austin62jake 3e01081ff9 Control token: a closing window removes only its own token and discovery files (#8794) (#8837)
* claim: #8794

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

* Control token: a closing window removes only its own token and discovery files (#8794)

Under Flatpak each instance has its own loopback, so a second window
binds the same port number as the first and writes the same
<port>.token, <port>.pid and current.port in the shared data folder.
At exit the first window deleted them all, unconditionally, leaving the
second window running with no token file a client could read.

remove_token_file now takes this launch's token and removes the token
and process files only while the file still holds it; the process id
can't tell instances apart, as every Flatpak instance has the same one.
remove_current_port also takes the token and keeps the discovery file
while another launch's token file holds that port, and removes
current.pid with current.port (it was left behind).

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-29 15:01:45 +00:00
austin62jake 420785b514 Sketch: a selected text moves when dragged from anywhere inside its frame (#8835) (#8851)
* claim: #8835

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

* Sketch: a selected text moves when dragged from anywhere inside its frame (#8835)

A drag had to start on one of the text's letters to move it. From the
space between letters, or inside a letter's outline, it found no geometry
and drew a selection box instead.

Off the sketch's own geometry, a drag from inside the selected text's
frame now grabs the text, as one from a letter does. Handles, dimensions
and geometry under the pointer still come first, and outside the frame a
drag from empty space still draws a selection box.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-29 14:58:03 +00:00
raykholo dcae86e456 Trackpad: zoom with two-finger scroll while holding a key you choose (#8322) (#8584)
* Trackpad: zoom with two-finger scroll while holding a key, chosen in Preferences (#8322)

Holding the zoom key turns a vertical two-finger scroll (or a wheel) into zoom at
the pointer, with the wheel zoom's speed, direction and anchor, so a laptop user
zooms without pinching. Preferences > Navigation > "Zoom with two-finger scroll
while holding": Cmd on macOS and Ctrl elsewhere (default), Option/Alt, Ctrl, or
Off. That key with the scroll did nothing before, so no default changes; Off
keeps it that way. Shift keeps its orbit.

* Trackpad zoom modifier: off by default (#8322)

Scroll keeps its usual meaning until a key is chosen in Navigation.

* Trackpad zoom modifier tests: Off is the default; tests that zoom choose Cmd (#8322)

* Trackpad zoom modifier: zoom once alongside the Ctrl+wheel pinch from #8539 (#8322)

A wheel the chosen key claims is no longer also read as a pinch, and a key that is not
chosen keeps main's pinch handling unchanged.
2026-10-29 14:49:19 +00:00
Colin Faulkingham 443e5cd59d Feature editor: allow visibility changes while editing (#8810) (#8827)
* claim: #8810

* fix(engine): preserve feature editors across visibility changes (#8810)
2026-10-29 14:26:24 +00:00
github-actions[bot] b4799575fd Regenerate generated files and fold pending files [skip ci] 2026-10-29 14:58:12 +00:00
Charlie 5a0abb8710 feat(align): add Components mode to Align for occurrence placement (#7224)
Align gets an Object Type row (Bodies | Components). In Components mode it runs the new
occurrence.align command, which moves only the chosen occurrence placements (their
transforms) instead of adding an Align feature to the shared component, so other instances
stay where they are.

- occurrence.align: from/to points, snaps, planar faces and planes in world coordinates,
  flip, capture/pending, grounded and rigid-group refusals as occurrence.move.
- A viewport or browser-body pick takes the placement directly under the active component.
- A placement inside a subassembly placed more than once still moves (as occurrence.move
  does) but the dialog warns, and the reply lists it under shared.
2026-10-29 14:19:05 +00:00
Andrzej Błasik cb05e1e0f7 GPU path tracer: run on Metal on macOS (#7183) (#7185)
* claim: #7183

* GPU path tracer runs on Metal on macOS, not only Vulkan (#3217)
2026-10-29 13:58:49 +00:00
jeromehzl 6fdd58dec6 Toasts: the corner stack keeps its full width just left of a pane at the right edge (#8804) (#8840)
corner_free looked only in a fixed column at the view's right edge and took
the largest free rect there by area. Next to a tall dialog docked at the
right edge, or the Assistant in the Floating layout, the narrow strip beside
the pane won: the toasts were squeezed into it, their text broke inside
words, and with a wider pane they were not shown at all.

It now tries that column first and then columns ending at each obstacle's
left edge, nearest the corner first, and takes the first free rect that is
TOAST_WIDTH wide and tall enough for a toast, as the frame-rate readout
moves beside a right pane (#6755); failing that, a rect tall enough beats a
wider one too short to show any toast. Without a pane the stack stays
exactly where it was.
2026-10-29 13:58:16 +00:00
phanoob 5b0a86a5b2 Graphics: a background colour of your choice for the 3D view (#7688) (#8815)
* claim: #7688

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

* Graphics: a background colour of your choice for the 3D view (#7688)

Preferences › Interface › Theme gets a Background row: the theme's
gradient (the default, unchanged) or one colour for the whole application,
filling the view in either theme. The window keeps its own colours, a
picture of the view (`ui.render`) takes the colour too, and `ui.prefs`
sets it as `viewport_background` ([r, g, b] or null).

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-29 13:58:13 +00:00
cronos 28e26209f3 Assembly Move: drag allowed joint freedoms (#6679) (#6904)
* feat: add joint-aware assembly Move handles and live motion readouts

* test: improve render job failure diagnostics

* fix: preserve assembly Move compatibility and viewport theme colors

* ci: skip default label sync for fork PRs

* fix: keep Windows CI checks portable after main rebase

* test: cover RTL assembly strings and stable refraction job

* i18n: cover assembly Move in Vietnamese

* i18n: cover assembly Move in French

* fix: complete CGA palette for assembly motion handles

* fix: complete Assembly Move palettes and Turkish strings

* fix: retain assembly move cache answers and toolbar help

* style: format merged assembly Move navigation guard

* fix: align Assembly Move preferences and tests with main

* Fix offline update fixture synchronization and clipped prism intersections

Wait for all v5 manifest requests before deploying v6. Intersect hole-free prism sections over their common height, retaining the established B-rep path for newly clipped perforated sections. Preserve the failing property seed and add closed-form and cap-boundary regressions.
2026-10-29 13:57:31 +00:00
github-actions[bot] 986de7ec9a Regenerate generated files and fold pending files [skip ci] 2026-10-29 14:25:59 +00:00
github-actions[bot] 7c7c0a57bd Regenerate generated files and fold pending files [skip ci] 2026-10-29 14:16:56 +00:00
tr1pp4 54b07d3e0b History in the browser: mark features that failed or built with a warning (#8592) (#8593)
* claim: #8592

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

* History: error and warning marks in the browser and on folded folders (#8592)

In the History "In browser" layout, a feature whose build failed gets the
error icon at the end of its row, one that built with a warning the
warning icon, with the message in the tooltip: the marks and text of the
History on the left. A folded History or Features folder carries the
worst mark of what it holds, and so does a folded folder in the History
on the left. Suppressed and rolled-back features are not built and get
none.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 13:51:20 +00:00
P'tite Tête 7dc341ae55 fix: Remove two per-frame quadratic lookups on large designs (#8843)
- active_body: take every body's owner in one pass (model_body_components) instead of searching the timeline body by body
- Timeline strip: find each chip's result in a table built once per frame (result_index) instead of a linear search per chip
- 4,880-body design: UI frame 31-32 ms -> 7 ms of CPU; tests pin the active body and the result lookup

Co-authored-by: P'tite Tête <asso@ptitetete.com>
2026-10-29 13:49:59 +00:00
Ryan 5e0e168ae9 Properties: moment of inertia, principal axes and first moments of a part or assembly (#8095) (#8103)
* claim: #8095

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

* Properties: moment of inertia, principal axes and first moments of a part or assembly (#8095)

component.properties and occurrence.properties report each closed body's
inertia tensor about its centre of mass, and in total the tensor about the
total centre of mass (world axes, g·mm²), its principal moments and axes,
the first moments, and with `about: [x, y, z]` the tensor about any point.
The tensor comes from each body's welded export mesh, tessellated once per
body, scaled to the measured mass and turned by each placement; bodies add
by parallel axes. The maths is a new crates/geom inertia module.

The Properties window shows the first moments, the tensor, the principal
moments and axes, and the view marks the centre of mass with the principal
axes while it is open. Inspect → Measure → Properties now opens that window
(it ran the command and showed nothing).

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

* Properties: Copy matrix, and the whole design's card titled just Properties (#8095)

Copy matrix, under the tensor, puts it on the clipboard as three lines of
tab-separated values at full precision, in the units shown. The card for
the whole design is titled Properties; one opened on a component,
placement or bodies keeps its name.

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

* Properties: measure beside the window, so a large assembly doesn't freeze it (#8095)

Properties ran on the window thread: 18 s on a 144-body design, with the
window answering nothing meanwhile (#5203). In the desktop app the card now
measures on a copy of the session on a worker, the way #4614 made agents'
measures work, says "Measuring…" until the reply is in, and Close drops it.
A quick measure still shows at once; headless, tests and the web measure in
place.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 13:47:44 +00:00
bherbruck be8bd43a18 CI: wasm compile in the merge queue, dependency advisories on dependency changes (#8652)
The wasm compile ran only by hand, so #8227 (a native-only DroppedFile::bytes() call in
crates/ui-egui) passed PR CI and the merge queue and broke the web build on main (#8534).
A merge group that touches the crates below the apps, the web app, the vendored crates, the
manifests, the toolchain or this workflow now runs `cargo xtask wasm`, and `ci` requires it.

Dependency advisories ran only nightly. A pull request or merge group that changes Cargo.lock
or a Cargo.toml now runs `cargo deny --locked check --warn unmaintained advisories`, and `ci`
requires it then. The nightly keeps the full advisories, bans and sources scan.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 13:39:48 +00:00
raykholo 884c47496b Render preview: geometry budget from the device, readable refusals (#4127) (#4154)
* claim: #4127

* Render preview: geometry budget from the device, readable refusals (#4127)

The Render preview refused every scene over a fixed 128 MiB of geometry. On
desktop the budget now follows the device's buffer limit (eight of its
largest buffers, at most 2 GiB; 2 GiB with the usual 256 MiB buffers). The
web keeps 128 MiB.

The product upload runs under its own GPU error scopes: an allocation the
device refuses is reported in the preview instead of reaching the device's
uncaptured-error handler, and the half-uploaded batches are dropped. Buffer
uploads map the buffer themselves so a refused buffer fails the upload
rather than panicking in create_buffer_init.

Refusals name the geometry size and the limit and say how to get under it,
on a theme panel card that stays readable on any preview background.

* Render preview: keep the previous geometry budget behind a preference (#4127)

* Render preview: adapt the device budget to main's GPU upload errors (#4127)
2026-10-29 13:33:39 +00:00
Karl Von Richter b2d727f4b2 Toolbar: choose visible tabs while preserving all defaults (#5717) (#8594)
* claim: #5717

* ui(feat): let users choose visible toolbar tabs

Keep Sheet Metal and Weldments visible by default while adding persisted tab visibility controls and restoring specialty tabs for designs that use them.

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

* ui(fix): respect hidden toolbar tabs in every design

Remove feature-based visibility overrides for Sheet Metal and Weldments. Keep all tabs visible by default and preserve the user choice across feature creation, opening designs, selection and undo.

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

---------

Signed-off-by: Karl Von Richter<karl@embeint.com>
2026-10-29 13:32:21 +00:00
bherbruck 4f5ed7bfa6 CI: open an issue when the nightly fails, close it when it passes (#8651)
The nightly failed every day from 2026-10-16 to 2026-10-28 without anyone noticing. A new
nightly-alert job (scheduled runs only, the only job with issues: write) opens or comments on
one issue, "Nightly CI is failing" (label ci), when a nightly job fails, and closes it when a
nightly passes.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 13:31:29 +00:00
raykholo f03d0a20bd Update now installs the newest release, checked when pressed (#7724) (#7725)
* claim: #7724

* Update now installs the newest release, checked when pressed (#7724)

* Update: close the unmanaged-install test after the rebase (#7724)

* Update: the refresh messages in Arabic, Hebrew and Vietnamese too (#7724)
2026-10-29 13:29:43 +00:00
SilentWolf75 c6c91e6f43 Parameters: show full names in the table and formula popup (#8533) (#8551)
* claim: #8533

Co-authored-by: Cursor <cursoragent@cursor.com>

* Parameters: show full names in the table and formula popup.

Hover tips carry the full identifier when Name cells or completion rows clip, and the completion list widens for long suggestions (#8533).

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-29 13:25:57 +00:00
github-actions[bot] 2f56d77e90 Regenerate generated files and fold pending files [skip ci] 2026-10-29 13:45:31 +00:00
Dylan M. Taylor 68b53a4c77 Release: Arch Linux packages for x86_64 and aarch64 (#8576)
A new arch job repackages each release tarball as
solidcraft-<version>-linux-<arch>.pkg.tar.zst with makepkg, through the Craft apps' shared
action (storytold/craft-common-actions/actions/arch-package, pinned by commit). The package
is io.tangencylabs.solidcraft, named after the tarball's .desktop file, provides solidcraft
and conflicts with solidcraft-bin/-git. Each package also gets a version-less copy, and
docs/downloads.md links them.
2026-10-29 13:17:34 +00:00
phanoob 2235389344 Navigation bar: can sit at the top of the view (#8183) (#8793)
* claim: #8183

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

* Navigation bar: can sit at the top of the view (#8183)

Preferences > Navigation bar gets an Edge choice, Bottom (default) or Top.
On top the bar sits under the ribbon, its menus open downwards and its
tooltips show below it; the OK/Cancel pair and the axis view move down
below it when they would overlap.

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

* Navigation bar: the edge's labels in the complete catalogs (#8183)

ar, he, ja, vi and zh carry every English message; the Edge row's three
labels were missing from them, so `xtask i18n --check` and
`every_complete_catalog_carries_every_english_message` failed.

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-29 13:16:51 +00:00
jeromehzl 9509348320 Viewport: editing a material's colour redraws the bodies that use it (#8764) (#8814)
body_appearance_key hashed only the name of a body's assigned material, so
after material.edit changed that material's colour (or an undo or redo
changed it back) the key stayed the same and the viewport kept the cached
old look. The key now also hashes the material's resolved look; with an
unchanged material the cache is reused as before.
2026-10-29 13:14:15 +00:00
Colin Faulkingham fd8ddc23b7 Pattern on Path: reopen saved patterns in the editor (#8784) (#8805)
* claim: #8784

* fix(ui): restore saved path pattern editor
2026-10-29 12:49:17 +00:00
Joseph 07d692ee0d Keyboard Shortcuts: several keys for one command (#6273) (#6288)
* Keyboard shortcuts: a command can have several keys

Click a key on the Keyboard Shortcuts page to replace it, or the + after it
to add another; every key runs the command and menus show the first. One
key is still saved as a string, several as a list. Taking a key over from
another command leaves that command its other keys. ui.shortcut takes a
slot and reports every key.

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

* Keyboard shortcuts: follow main's search layout; fix prefs test for key lists

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-29 12:32:19 +00:00
github-actions[bot] 00f9243c0d Regenerate generated files and fold pending files [skip ci] 2026-10-29 12:29:52 +00:00
ABDULLAH 1e1be6934f Arabic: use the Mouse mapping term in the Right drag orbits help (#8803)
nav.right_drag_orbits.help now names the setting as prefs.mouse_mapping does (تعيين أزرار الفأرة).
2026-10-29 11:58:59 +00:00
semmelrinde 6a183cce02 Sketch corners: a corner cut is refused where two lines lie in line, as rounding is (#8802)
Chamfer at a corner of two lines lying on each other cut both back and
added a line of no length between their ends; lines in line with each
other have no corner either. chamfer now refuses a corner angle of 0 or
180 degrees with Fillet's message, "the lines are parallel" (found by a
local trim fuzzer).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 11:49:31 +00:00
jeromehzl dd6a91533d Standard parts: Resize Part says what it works on, Parts opens Insert Part (#8735) (#8801)
* Resize Part: say what it works on instead of a parameter error; Parts opens the library (#8735)

Resize Part (parts.resize) started with no standard part selected, as the
ribbon starts it, ran the command with no parameters and showed
"`component` must be an id or a name". It now says to select an inserted
standard part, and that Scale resizes a body. Parts (parts.library) only
listed the library, so its button did nothing: it opens the Insert Part
dialog. The German and Dutch button labels said "scale" ("Teil skalieren",
"Onderdeel schalen"), like the Scale command: they now say change size.

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

* Resize Part: the German label matches the command, and the test checks that nothing ran (#8735)

The German ribbon label reads "Bauteilgröße ändern", the name the command,
its dialog and its messages already use. The hint for a missing standard
part is now one function shared by the ribbon start and the Browser's
Change Part Size. The regression test also checks that the design and its
history stay as they were and no message carries the command's own error,
that a scripted parts.library still only lists the library, and that with a
standard part picked Change Part Size opens on it.

---------

Co-authored-by: semmelrinde <124386289+semmelrinde@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 11:49:25 +00:00
Christian Hammacher 93d0298d25 Browser: Turn Up Axis on a placement row asks that placement whether it can move (#8786) (#8788)
A placement row's menu starts from its component's menu, which takes
Turn Up Axis's enabled state from the component's first placement.
Move/Copy was already set again for the placement right-clicked; Turn Up
Axis and its two turns now are too, so a free second placement can turn
while the first is grounded, and a grounded one can't.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 11:28:19 +00:00
semmelrinde cf86f18c11 Sketch: an end held on a removed curve where another curve ends stays tied (#8779)
A trimmed circle ended on a line exactly where an arc started on that
line, held there by a point-on-curve tie. Trimming the line away dropped
the tie and left the two ends on one spot, untied. remove_curves now ties
each point held on a removed curve at one of its ends to that end before
the curve goes (found by a local trim fuzzer, 15 -> 12 cases in 2000
seeds).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 11:07:28 +00:00
github-actions[bot] 63f053e4fe Regenerate generated files and fold pending files [skip ci] 2026-10-29 11:06:21 +00:00
Colin Faulkingham 81039a0e10 Pinch zoom: route Ctrl+wheel input to the camera (#8480) (#8539)
* claim: #8480

* Pinch to zoom on touchpad: native Ctrl+wheel pinch zooms the view (#8480)

The viewport's pinch path read only `egui::Event::Zoom`; `immediate_wheel` leaves
wheel events carrying the zoom modifier to egui. Native Linux and Windows
touchpads report a pinch as Ctrl+wheel in points, so the gesture reached neither
path and the view did not move. `pinch_factor` now reads those wheels too, in
every wheel unit and scaled as egui scales them, and `ui.scroll` can send the
same gesture (`ctrl: true`) so scripts and scenarios can replay a pinch.

* fix: finish Ctrl+wheel pinch handling and replay validation (#8480)
2026-10-29 10:40:10 +00:00
bherbruck 9df8837d43 Release 0.26.1 2026-10-29 06:33:09 -04:00
bherbruck 90f0353724 Release 0.26.1 (#8647)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 06:33:01 -04:00
Lars Jansen 375dc01602 Navigation: Right drag orbits option; picking the SolidWorks preset turns it off (#6492) (#6497)
* claim: #6492

* feat(navigation): Right drag orbits option, off when picking the SolidWorks preset, which also fits on middle double-click (#6492)
2026-10-29 10:24:37 +00:00
PaddleStroke fe3f913d76 Dialogs: disable Apply until something changed (#8561) (#8728)
* claim: #8561

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

* Dialogs: disable Apply until something changed (#8561)

The dialog footer gets a disabled state for Apply (Footer::nothing_to_apply,
Window::apply_enabled): drawn with the theme's dim ink, not clickable, not
reached by Tab and reported disabled to accessibility. Preferences enables
Apply only while something differs from its snapshot (what Cancel would put
back), so it is disabled when the dialog opens and again after Apply.
ui.prefs replies with apply_enabled.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 10:14:45 +00:00
N3MI-DG 4f3ac07aec Move/Copy: changing Move Type keeps the move made so far, and picks follow the moved body (#8746) (#8747)
* claim: #8746

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

* Move/Copy: changing Move Type keeps the move made so far, and picks follow the moved body (#8746)

Switching the Move Type reset the values, so the move made under the
earlier type was lost. The dialog now keeps it (`chain`) and the new type
moves the bodies on from there; params() composes the two into one
solid.move (one turn about the origin and a translation, as before).
While the new type's picks are missing, the kept move alone is made.

The new type measures from where the bodies now stand (Free Move's pivot,
Rotate's ring, round-edge centres and axes on a moved body). The picker,
the edge/face highlights, the centre marker and Rotate's axis line follow
the moving body, mid-drag too; a pick on it is taken back by the current
type's own move so it doesn't move with the move it sets. While the
triad is dragged the moved bodies' highlights are hidden and rebuilt once
on release.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 09:47:26 +00:00
ABDULLAH 710a3f8f11 Arabic: re-translate outdated strings and review the Render light and frame strings (#8752) 2026-10-29 09:47:18 +00:00
github-actions[bot] 4cee4db19b Regenerate generated files and fold pending files [skip ci] 2026-10-29 09:59:37 +00:00
semmelrinde 036d4234a1 Croatian: translate the 781 messages added since the catalog came in (#7813), the Render workspace included (#8683)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 09:29:12 +00:00
PaddleStroke 7341b8f6be Browser: Show/Hide context menu on origin axis rows (#8382) (#8724)
* claim: #8382

* Browser: Show/Hide context menu on origin axis rows (#8382)

Right-clicking an origin axis row (X, Y, Z) now opens its own menu with
Show/Hide (V), through ui.originPart and browser.visibility like the
origin planes' entry. An axis takes no sketch, so Create Sketch is not
offered.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 09:20:11 +00:00
semmelrinde 0f2ad19bdb Navigation menus: a label too long for its row ends in an ellipsis (#8700) (#8745)
The Display menu's "Perspective while orbiting, ortho on faces" ran past
the menu's edge: a menu row painted its label at full length. It is now
cut before the row's detail and check, and shown whole on hover, as a
long name in a list is.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 09:20:05 +00:00
jeromehzl 1643b74111 Sketch: selected points stay marked after the pointer moves on (#1069) (#8714)
* fix(ui): keep selected sketch points highlighted

Add persistent point selection rings while retaining constraint-state markers, with regressions for pointer selection, dialog inputs, aliases and visibility. Fixes #1069.

* Move the sketch point selection tests into the viewport overlay tests (#1069)

Run them through a frame that clears its texture uploads, as the current egui
requires, next to the other overlay tests.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Victor Santos Oliveira <56417388+Vittuu@users.noreply.github.com>
Co-authored-by: Colin Faulkingham <colinfaulkingham@Colins-Mac-mini.local>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-29 08:46:34 +00:00
github-actions[bot] 815e7de7e7 Regenerate generated files and fold pending files [skip ci] 2026-10-29 09:07:06 +00:00
Nathan Dane 3cc10acafa Render lights: uniform light list, solo and mute, and the Light editor (#6281) (#7265)
* Render lights: uniform light list, solo and mute, and the Light editor

* Render lights: Lighting and Light editors to the approved design

* Render lights: the scale-test frame carries the product light list

* Restore the stable Render light mute target on the shared eye control

* fix: validate the viewport BRDF probe with current shader dependencies

* fix: compile legacy lighting controls only for tests

* fix: scope the legacy lighting preset import to tests

* fix: reuse the merged Add Light label and translations

* test: enable the implemented Add Light radial action

* fix: restrict the legacy CPU vector editor to tests

* test: reveal Output before checking Lights browser focus

* test: use the published Browser card rectangle for Output scrolling

* test: reveal Output children before exercising editor navigation
2026-10-29 08:40:51 +00:00
N3MI-DG 06dfbc4000 Sketch Create menu: family flyouts open beside their row, on hover (#8267) (#8713)
* claim: #8267

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

* Sketch Create menu: family flyouts open beside their row, on hover (#8267)

A family row (Line, Rectangle, Arc, ...) opened its variants as a dropdown
below the row, covering the rows under it. The flyout now opens to the
right of its row, level with it, and on the left only when the right has
no room. Hovering a row opens it, as the context menu's Appearance
flyout does; resting on another row closes it after the shared delay.

The context menu and the Create menu share the flyout timing
(SUBMENU_CLOSE_DELAY, flyout_hover, flyout_leaving) and placement
(flyout_at) in ui_kit::menu.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 08:31:36 +00:00
semmelrinde 2d56f5d599 Construction: a vertex where another body's edge runs past stays its corner (#8650) (#8722)
A vertex pick found its named edges and then picked each again by a point
on it, among the edges of every body. At a corner where another body's
edge runs past (a wall against a side wall), all three became that edge,
and the vertex its end 3 mm away. Take each named edge's ends from the
body that has it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 08:03:31 +00:00
ABDULLAH 2f7a9a1fc0 Arabic: review of strings added since the catalog came in (#8393)
* Arabic: consistent joint type names, rim face and tanween spelling

* Arabic: review of the Render and Edit in Place strings
2026-10-29 07:58:14 +00:00
github-actions[bot] 7ad2bbd391 Regenerate generated files and fold pending files [skip ci] 2026-10-29 08:12:10 +00:00
austin62jake c10d36a0b5 Tests: Open Linked Design waits for the part as settle waits for a load (#8716)
menu_tests::linked_component_opens_its_part_in_a_tab_and_reports_a_missing_source
gave the background open of the linked part 600 frames. Headless frames
are quick, so on a loaded machine (the whole ui-egui suite at once) the
read can take longer, and the test then found the assembly still shown.
It now waits until the part is open, up to 120 s as Harness::settle and
the edit-in-place tests wait for a load.

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-29 07:37:20 +00:00
raykholo aee57875f5 Sketch Fillet: 3D sketch lines are not offered and say they can't be filleted yet (#4160) (#4177)
* claim: #4160

* Sketch Fillet: 3D sketch lines are not offered, and say they can't be filleted (#4160)

The Fillet tool offered 3D sketch lines as picks, and sketch.fillet then
refused them with "`a` must be a line". The tool no longer offers them, and
the command refuses them with "not supported yet: filleting 3D sketch lines".

* Sketch Fillet and Chamfer: refuse all spatial sketch curves (#4160)

* Sketch corner test: give the picked curve its point (#4160)

main added a point to sketch-curve hits after this branch was written.
2026-10-29 07:22:53 +00:00
Nathan Dane d2fcab8432 Render shot editor: camera and frame controls, aspect presets, guides and frame overlay (#6281) (#7232)
* Render shot editor: camera and frame controls, aspect presets, guides and the frame overlay

* Render shot: Shot editor and Scene overview to the approved design

* Distinguish Render stage variants from native window frames

* fix: restore library inspection after clearing shot selection

* test: follow the merged Output Check navigation

* test: inspect Check without requiring a preflight result

* test: cancel Shot removal through its shared dialog action

* fix: remove obsolete Inspector stage form after editor integration

* fix: preserve Appearance Advanced when clearing Wear focus

* test: expect the dedicated Check editor from Scene overview
2026-10-29 07:03:24 +00:00
github-actions[bot] c7a1cee357 Regenerate generated files and fold pending files [skip ci] 2026-10-29 07:43:09 +00:00
semmelrinde 883f8e8e90 Save: the status line says where the design was saved (#8693) (#8705)
Saving gave no sign that it worked, while an export names the file it wrote.
After Save or Save As, the status line now reads "Saved <path>".

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 06:48:35 +00:00
github-actions[bot] a6b272dec8 Regenerate generated files and fold pending files [skip ci] 2026-10-29 07:13:25 +00:00
github-actions[bot] 2a749f4294 Regenerate generated files and fold pending files [skip ci] 2026-10-29 07:06:44 +00:00
semmelrinde 5241c64aa6 Sketch solver: eliminate row by row on slices, so the dense solve vectorises (#8631)
The dense solve updated each matrix element through bounds-checked get/add;
on slow scripted drags it was most of the time. The same operations in the
same order on row slices give bit-identical solutions, about twice as fast.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 06:39:05 +00:00
raykholo c688054db0 Canvas and Decal say where to click after the image is chosen (#4359) (#4396)
* claim: #4359

* Canvas and Decal say where to click after the image is chosen (#4359)

After picking an image the placement tool started silently, so it looked
as if nothing had happened. Show the tool's placement hint as a toast while
it waits for the click (translated), and end it once the image is placed.

* Canvas: include placement hint in viewport obstacles (#4359)
2026-10-29 06:17:35 +00:00
semmelrinde d77b0434dd English (UK): UK spellings for 43 messages that still had US ones (#8696)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 06:16:27 +00:00
N3MI-DG 0818ad0ed4 Preferences: typing in a number field edits it instead of searching (#8662) (#8663)
* claim: #8662

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

* Preferences: typing in a number field edits it instead of searching (#8662)

Type-to-search takes printable keys unless the focused field notes the key
sink. number_field (Autosave every, the Largest file to open limits) never
did, so its digits went into the search.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 05:32:25 +00:00
github-actions[bot] 54f74a613e Regenerate generated files and fold pending files [skip ci] 2026-10-29 06:38:44 +00:00
github-actions[bot] 74791e0220 Regenerate generated files and fold pending files [skip ci] 2026-10-29 06:29:24 +00:00
raykholo afe7dcd4e1 Allow sketch Mirror to use origin axes (#4193) (#4207)
* claim: #4193

* Sketch Mirror: an origin axis can be the mirror line (#4193)

Mirror accepted only a straight curve of the sketch. It now also takes an
origin axis that lies in the sketch plane ({axis: X|Y|Z}), projected into
the sketch as a fixed linked line, so the copies stay symmetric to it and
follow the source when it moves.

* Sketch Mirror: use current sketch store (#4193)
2026-10-29 05:08:00 +00:00
semmelrinde fed7ea0c88 Malay: translate the 264 messages the catalog lacks, Render panels and point clouds included (batch 6) (#8667)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 05:03:26 +00:00
semmelrinde dfa8ae9a60 Hungarian: translate the 264 messages the catalog lacks, Render panels and point clouds included (batch 6) (#8655)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 05:03:00 +00:00
semmelrinde 53b2f73d2e Bosnian: translate the 781 messages added since the catalog came in (#7813), the Render workspace included (#8682)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 05:02:58 +00:00
austin62jake 1dce42e744 Preferences: a drop-down's Search box keeps what is typed in it (#8675)
In Preferences, letters typed in a drop-down list's own Search box (the
Font list, a 3D mouse's button assignments) went to the Preferences search
instead, which then filtered the page, usually to "No matching
preferences", and closed the list. The Preferences search takes typing only
when no text field held the keyboard on the previous frame, and the
drop-down's Search box never said it did. On the full Interface page the
number fields' focus check covered for it; once a search hid them, as when
the Font row is found by searching for "font", nothing did.

The Search box in searchable_dropdown_with now reports that it has the
keyboard, as the Preferences search box does.

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-29 05:02:44 +00:00
semmelrinde aad3d56ec2 Serbian: translate the 781 messages added since the catalog came in (#7813), the Render workspace included (#8684)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 05:02:18 +00:00
semmelrinde 8719cc7b57 Swedish: translate the 264 messages the catalog lacks, Render panels and point clouds included (batch 6) (#8660)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 05:02:06 +00:00
semmelrinde 651addda84 French: translate the 262 messages the catalog lacks, Render panels and point clouds included (batch 6) (#8654)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 05:01:47 +00:00
github-actions[bot] dd16cc9faf Regenerate generated files and fold pending files [skip ci] 2026-10-29 06:02:10 +00:00
github-actions[bot] 6747ea0dcf Regenerate generated files and fold pending files [skip ci] 2026-10-29 05:53:14 +00:00
semmelrinde 4215fb11c2 Italian: translate the 264 messages the catalog lacks, Render panels and point clouds included (batch 6) (#8656)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 05:01:40 +00:00
semmelrinde 504fe1afcb Dutch: translate the 262 messages the catalog lacks, Render panels and point clouds included (batch 6) (#8657)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 05:01:37 +00:00
semmelrinde 7367b81ffe Romanian: translate the 264 messages the catalog lacks, Render panels and point clouds included (batch 6) (#8659)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 05:01:33 +00:00
semmelrinde 183226d2b6 Turkish: translate the 264 messages the catalog lacks, Render panels and point clouds included (batch 6) (#8661)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 04:55:04 +00:00
semmelrinde 514563187b Traditional Chinese: translate the 264 messages the catalog lacks, Render panels and point clouds included (batch 6) (#8664)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 04:54:48 +00:00
semmelrinde 5286f50fd9 Ukrainian: translate the 264 messages the catalog lacks, Render panels and point clouds included (batch 6) (#8665)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 04:54:42 +00:00
semmelrinde 46487216d0 Indonesian: translate the 264 messages the catalog lacks, Render panels and point clouds included (batch 6) (#8668)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 04:54:24 +00:00
semmelrinde 39250092a3 Galician: translate the 264 messages the catalog lacks, Render panels and point clouds included (batch 6) (#8669)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 04:54:18 +00:00
semmelrinde a1fd2be2c8 Norwegian Bokmål: translate the 264 messages the catalog lacks, Render panels and point clouds included (batch 6) (#8670)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 04:54:11 +00:00
Nathan Dane bd8cb4e6d6 Render output: Effects and Check sections, Size presets panel and the Check chip (#6281) (#7233)
* Render output: Effects and Check sections, Size presets panel and the Check tool chip

* Render output: Sampling, Effects and Check editors as child rows under Output

* fix: restrict legacy Output controls to test builds

* fix: cover status controls and reveal Effects test targets

* fix: include shared quality reviews in copied check reports

* fix: remove the obsolete Effects GPU guard after Wear integration
2026-10-29 04:39:34 +00:00
Nathan Dane c1925bb219 Render appearance: click, drag and paint to assign, Pick Up, copy and paste (#6281) (#6431)
* Render appearance: click, drag and paint to assign, Pick Up, copy and paste, and Remove

* Exclude the workspace Shots strip from dialog width checks

* Exclude the collapsed Shots control from dialog width checks

* Retain assembly cache listings and measure actual dialog bounds

* fix: handle toast button rectangles in assignment status

* fix: restrict gallery note measurement helper to tests

* test: measure assignment help at the catalogue footer

* fix: share the command revision snapshot after restacking
2026-10-29 04:33:31 +00:00
mynoc 75035ac4a5 Color picker: add Hex alongside U8 and F (#7445) (#7450)
* claim: #7445

* Color picker: add shared Hex input mode (#7445)

Accept strict RGB/RGBA Hex input while preserving exact opacity,
opaque/PBR restrictions, invalid drafts and existing U8/F behavior.

Keep explicit English fallback entries for the three new Hex messages
in Arabic and Hebrew, which final integration main now requires as
complete catalogs. No Arabic or Hebrew translations are claimed.

The user authorized this feature-only commit after full cargo xtask ci
stopped at the pre-existing geometry nonminimal_bool lint. The same
failure was reproduced without this patch at the published claim and
main. No unrelated lint fix or suppression is included.

* i18n: cover complete catalogs for Hex picker (#7445)

Add the three Hex message keys to Vietnamese in the feature's pending
locale file. Keep the unchanged English source values as explicit
fallbacks; no Vietnamese translations are claimed.

Audit the complete-catalog set (ar, he, ja, vi, zh) rather than only
the first catalog reported by CI. Preserve the existing completeness
test and all picker/Appearance code.

The completeness regression changes from red to green. Full local CI
and wasm checks remain blocked by failures independently reproduced
in clean upstream main; no unrelated fixes or suppressions are added.

* Appearance: use the shared opacity-aware color picker in Render (#7445)

Route the merged Render Appearance editor through the UI-kit picker,
passing exact opacity only for non-PBR looks. Keep a non-editable mixed
label and treat explicitly accepting the first color as a mixed edit.

Remove the external Hex field and duplicate parser, replace its gallery
entry with the shared mixed-aware picker, and update the control contract.

Preserve existing regression assertions and add actual Render UI tests
for alpha edits, precision, invalid drafts, PBR rejection and mixed edits.

Full local CI and wasm were run before this commit and remain blocked
by the separately reproduced unchanged upstream geometry lints and
prefs.bytes wasm error. No unrelated fixes or suppressions are included.

---------

Co-authored-by: mynoc <298906611+mynoc-96@users.noreply.github.com>
2026-10-29 04:33:10 +00:00
github-actions[bot] ae47ed342b Regenerate generated files and fold pending files [skip ci] 2026-10-29 05:24:00 +00:00
Mike111177 03cf8f3e90 Configuration table: a filter in the Add Column menu (#8086) (#8088)
* claim: #8086

* Configuration table: a filter in the Add Column menu (#8086)

The + menu listed every parameter, feature suppression and body
material/appearance with no way to narrow it. A filter field now sits at
the top, takes focus when the menu opens, narrows the entries and the Face
Appearance submenu as typed, picks a lone match on Enter and clears when
the menu closes. A click into the field keeps the menu open (plus_header
closes on outside clicks; entries close it themselves), and the list keeps
its full height after the filter has emptied it once.

New kit element ui_kit::menu_filter (glyph, frameless editor, rule) so the
field does not read as an entry; gallery, i18n guard and docs/ui-kit.md
updated. Regression test in manage_dialogs_tests.
2026-10-29 04:20:42 +00:00
MarkusRe 0baf49b25f Windows: never load a dxcompiler.dll found on PATH; FXC unless DXC ships beside the app (#6756) (#7923)
* claim: #6756

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

* Windows: never load a dxcompiler.dll found on PATH; FXC unless DXC ships beside the app (#6756)

wgpu's Dx12Compiler::Auto loads dxcompiler.dll by name, so Windows takes the
first one on PATH. SolidCraft ships none, and DXC 1.8.2505 from other programs'
folders (Obsidian, NVIDIA SDK Manager) crashed every start with
EXCEPTION_ILLEGAL_INSTRUCTION during the viewport shader compile.

solidcraft_gpu::dx12_compiler() now chooses: WGPU_DX12_COMPILER as wgpu reads
it when it names a compiler; otherwise DXC from the dxcompiler.dll beside the
program, by its full path, else FXC. The window, the headless/CLI instance and
the test helpers start with it, and dx12_compiles_with_fxc follows it.
WGPU_DX12_COMPILER=dxc keeps the old lookup.

Fixes #6756

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 04:14:57 +00:00
github-actions[bot] 9bcda52408 Regenerate generated files and fold pending files [skip ci] 2026-10-29 04:45:23 +00:00
semmelrinde f4e52c4d3e Sketch: points tied only through a removed curve's end stay tied (#8640)
* Sketch: points tied only through a removed curve's end stay tied

Removing a curve removed its orphaned end points with their constraints, so
two points made coincident with such an end lost their tie although they
still met: two rectangles' corners on one arc end came apart when the arc
was trimmed. They are now tied to each other before the point goes.

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

* Sketch: keep the tie test with the other trim tests

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 04:14:22 +00:00
semmelrinde 2da969cf5c Czech: translate the 264 messages the catalog lacks, Render panels and point clouds included (batch 6) (#8653)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 04:14:04 +00:00
NOWN-DEV dd8308e835 Naming: a face swept from one of many sketch curves is named without measuring every curve (#8643)
Naming a face swept from a sketch flattened every curve of the sketch to
a polyline and measured its distance to each, for every face. On a sketch
with thousands of curves (a sheet with many cut-outs) that grew with
curves times faces. The curves are now flattened once per feature, each
with its box, and a curve whose box is farther than the best distance so
far is not measured.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 04:12:56 +00:00
Colin Faulkingham 6f9788ccba Browser: move a component under another by drag and drop (#5164) (#8532)
* claim: #5164

* Browser: drag a placement row onto another to move it into that component (#5164)

---------

Co-authored-by: Colin Faulkingham <colinfaulkingham@Colins-Mac-mini.local>
2026-10-29 04:01:21 +00:00
github-actions[bot] 46a6686380 Regenerate generated files and fold pending files [skip ci] 2026-10-29 04:15:14 +00:00
CaRLymx ec76068a0b Spanish (es-ES): catch up with en.json (#6899)
* Spanish (es-ES): catch up with en.json

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

* Navigation preferences: name the Shift key through the catalog

Spanish now translates the key name, so the page painted it in English.

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

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 03:42:24 +00:00
Colin Faulkingham 99664a4d35 fix: keep expression move distances on world axes (#7234) (#8508) 2026-10-29 03:39:39 +00:00
froglemonade2 e55eff7c5d Orbit about the cursor: on an edge or sketch line, turn about the point under the cursor (#7356) (#7358)
* claim: #7356

* Orbit about the cursor: on an edge or sketch line, turn about the point under the cursor (#7356)

With Orbit around: point under the cursor, a drag that starts on a body
edge turned about the edge's middle, which on a long edge is far from
the cursor, and one that starts on a sketch curve turned about the
centre of the bodies in view. Edge and sketch curve hits now carry the
point under the cursor, which the pick already measured, and
pivot_under uses it. An edge hit's `mid` is unchanged, so selections
still record the edge's middle.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 03:36:05 +00:00
austin62jake d8d22cd52a 3MF export: a component's bodies as one object with parts (#8139) (#8187)
* claim: #8139

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

* 3MF export: a component's bodies as one object with parts (#8139)

file.export and file.save_mesh wrote every body as its own 3MF object and
build item, so a slicer loaded a multi-body part as separate objects and
could arrange them apart on the bed. With objects: "component", each placed
component that holds two or more bodies is written as one object whose
<components> are its bodies' meshes, built once, so PrusaSlicer, OrcaSlicer,
Bambu Studio and Cura load it as one object with a part per body, each of
which can take its own filament. The design's own bodies, and a component
with a single body, stay objects of their own. The default (objects: "body")
writes the same file as before.

- io: model_xml_parts and write_3mf_parts write the parts; export_with_parts
  maps body names to them. model_xml_with and export_with_quality give the
  same output as before.
- engine: mesh_parts reads `objects` (component is refused for formats
  other than 3MF); component_parts groups the placed bodies by placement,
  each part named after its occurrence (Bracket:1, Bracket:2).
- file.save_mesh passes the parts on when it also sends the file to a slicer;
  file.send_to_slicer's own folder export is unchanged.

Fixes #8139.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-29 03:35:38 +00:00
Nathan Dane 40ff251e5f Render wear: a Wear row and editor for the aging layers (#6281) (#7459)
* Render wear: a Wear row and editor for the aging layers

* test: read Wear header text from accessibility label values

* test: restore body picks for Wear header recovery assertions
2026-10-29 03:29:10 +00:00
github-actions[bot] 081c4bca73 Regenerate generated files and fold pending files [skip ci] 2026-10-29 03:40:32 +00:00
Nathan Dane de5cee9bf1 Viewport status item: UI-kit element and row (#8544) (#8553)
* claim: #8544

* Add reusable viewport status items and fixture (#8544)
2026-10-29 03:02:43 +00:00
Nathan Dane 09bf603588 Render environment editor: rotation, intensity, backdrop colour and ground (#6281) (#7231)
* Render environment editor: rotation, intensity, backdrop colour and ground through render.environment.set

* Render environment editor: rotation ring in the view with one undo step per drag

* Render environment: Source, orientation, backdrop, ground and atmosphere sections

* Keep segmented controls inside their allocated rows

* fix: compile legacy environment controls only for tests
2026-10-29 02:49:16 +00:00
semmelrinde f071f00155 Sketch Trim: an end at a place several curves cut is tied to the one with an end there (#8612) (#8628)
Where several curves cut at one place, trim pinned the new end onto the
first of them. At the touch point of two circles that was a rectangle side
passing through, and once a later trim removed the side the arc ends were
loose: a trimmed dog bone kept two untied ends.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 02:45:35 +00:00
raykholo 8fb452b05e Report a bug: always offer a way to submit (#5463) (#5615)
* claim: #5463

* Report a bug: always offer a way to submit (#5463)

* Keep report strings pending and update the focus regression (#5463)
2026-10-29 02:43:49 +00:00
semmelrinde e4cfbbd0c3 Sketch Coincident: refuse a line's two ends, as MidPoint refuses its own end (#8611)
* Sketch Coincident: refuse a line's two ends

Making a line's two ends coincident shrinks it to a point; a Parallel or
Perpendicular on it afterwards has no direction to hold and a later solve
could throw points far away while reporting solved, as with a line's own end
as its midpoint (#8562).

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

* Sketch Coincident: keep the own-ends test with the other Coincident tests

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 02:21:05 +00:00
Colin Faulkingham 746e1c633d Dialogs: wrap status and refusal messages at full width (#8455) (#8460)
* claim: #8455

* fix(ui): wrap dialog status and refusal messages (#8455)
2026-10-29 02:16:56 +00:00
The Nomad 674ef84b53 Add History playback to the Inspect ribbon (#3693) (#7853)
* claim: #3693

* Fix timeline playback to show each rebuilt feature

* Move History playback into the Inspect ribbon

* Refuse Play History up front when playback couldn't run

Starting Play and the per-frame check that stops it were looking at
different app state, so Play could be accepted during an apply, a delete
check or queued history work and then stop silently on the next frame.
Both now use one playback_blocked check.

The UI also reads the engine's PlaybackAction as the typed enum instead of
matching strings, so a bad reply is an error rather than a quiet toggle.

* Put Play History in the History controls

History now shows five controls in transport order: start, previous,
play, next, end. Play runs timeline.play, so it turns into Stop while
playback runs, same as the ribbon button and the shortcut.

It's on by default (#3688, #3740, #4216). Anyone who wants the old four
controls back can right-click them and pick Hide Play Button, or clear
history_play_button under Preferences > Interface. In a named vertical
History too narrow for five buttons beside its grip, Play is left to the
ribbon rather than overlapping the grip.

* Draw the History Play button as a plain play/pause glyph

The Play button in the History controls now uses the same drawn glyphs as
its neighbours: a filled triangle, and two bars while History plays. The
triangle is bigger than the step arrows so it doesn't read as Forward.
The ribbon command keeps its timeline_play and timeline_stop icons.

* Keep the playback changes clear of PRs queued ahead

Several PRs in the merge queue touch the same lines, so this moves ours
aside without changing behaviour:

- history_play_button and its reset-test row sit with the other History
  preferences, not beside view_confirm_corner (#7248 adds status_bar there).
- History keeps main's history_transport_width() for four controls (#6430
  and #7459 call it); the Play button's count uses history_controls_width.
- A new or opened document stops playback after dim_view::cancel(), clear
  of the line #7819 adds after reset_chat.
- The Play/Stop label swap moves from interactive_command_info, which #7906
  rewrites, into the ribbon's command_info.

* Stop playback after the command completes, clear of #6433

#6433 rewrites the session.execute line in execute_command, and our playback check sat right under it, so the queue couldn't merge the two. Checking the revision after command_completed does the same job a couple of lines later.

---------

Co-authored-by: cfaulkingham <colin.faulkingham@gmail.com>
2026-10-29 02:14:53 +00:00
Frank be757154d6 fix(sketch): join nearby profile endpoints (#7269)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-29 01:54:56 +00:00
PaddleStroke 3b983dc550 Viewport: vertex markers where edges are drawn (#7834) (#8119)
* claim: #7834

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

* Viewport: vertex markers where edges are drawn (#7834)

Each body's vertices (the ends of its pickable edges, once each) show as
small screen-space squares in the edge colour in the styles that draw
edges: shaded with edges, wireframe, and shaded with hidden edges, where
hidden vertices show faintly like hidden edges. Plain shaded draws none.
A marker is a lifted line from the vertex to itself in the body's block,
so it is instanced with the edges, cached with them, and depth-tested
like a highlight band. The existing vertex hover ring stays on top.

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

* Viewport tests: count edge segments apart from vertex markers (#7834)

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

* Vertex markers: an option, off by default (#7834)

Adds the Vertex markers preference (Preferences > Graphics, off by default) and draws the dots only when it is on.

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-29 01:53:01 +00:00
github-actions[bot] b5217b1e86 Regenerate generated files and fold pending files [skip ci] 2026-10-29 03:07:55 +00:00
github-actions[bot] 5dccc3d978 Regenerate generated files and fold pending files [skip ci] 2026-10-29 03:00:55 +00:00
semmelrinde 800b6b4486 Saved model: read text records up to 16 MiB, so a long warning keeps fast opening (#8541) (#8607)
Open refused a saved model whose text records (a feature's warning, a body or
sketch name) held more than 65,536 bytes, although Save writes them, so such a
design was evaluated again on every open.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 01:52:21 +00:00
semmelrinde 11f208f665 Viewport: cut dashed lines to the view, so a huge distance can't fill the memory (#8540)
* Viewport: cut dashed lines to the view, so a huge distance can't fill the memory

epaint makes one shape per dash and steps along a line in f32. The
viewport's dashed lines (the Extrude taper handle, Measure's legs, the
fillet radius guide, the inference and snap guides, a sketch text's
frame) were dashed over their whole projected length. With an Extrude
distance of 1e9 mm the taper line was billions of points long: the
frame never finished and filled the memory within a second.

`viewport::dashed_line` dashes a line that lies in view exactly as
before, and cuts one that reaches far off screen to the view (grown by
two dash periods) first, keeping its dashes in phase with its start.

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

* Viewport: compute a cut dashed line's ends in f64

A line a trillion points long lost its cut ends in f32: they collapsed to one point, and the part in view drew nothing. The new test caught it.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 01:27:34 +00:00
phanoob ba52632985 Tabs: Ctrl+Tab shows the next design tab, Ctrl+Shift+Tab the previous one (#8565) (#8597)
Two new commands, view.next_tab and view.previous_tab, with Ctrl+Tab and
Ctrl+Shift+Tab as their default keys: they appear on the Keyboard Shortcuts
page and can be rebound or cleared like any other. The window steps along the
tab strip and goes round at either end; one design alone stays as it is, and
a "save changes?" prompt keeps its tab shown. The engine only echoes the
step (no design edit, no undo step); engine.batch refuses them like the
other steps that go to another design.

Co-authored-by: Phanoob <phanoob@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 01:25:12 +00:00
bherbruck 4571701179 Embed a Windows version resource in solidcraft.exe (#8378)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 01:21:02 +00:00
raykholo 4c6382c0a3 PR checks: a read-only token gets a notice from the label job, not a failure (#7850) (#7851) 2026-10-29 01:00:15 +00:00
raykholo 0e01dc9bad Pattern counts: say when a parameter has a length unit, and refuse fractional counts (#5066) (#5337)
* claim: #5066

* Pattern counts: name a length-unit parameter, refuse fractional counts (#5066)

A count parameter created with a length or angle unit now gets an error
that names it and says to set its Unit to none. A count that evaluates to
a fraction is refused instead of being rounded silently.
2026-10-29 01:00:10 +00:00
bherbruck c0d84ad460 Dependencies: security updates (2026-10-28) (#8588)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 00:30:53 +00:00
yhamil04 af9772fb3c Browser: mesh bodies get their own icon and a body-kind tooltip (#6907) (#7059)
A mesh body's row (an imported STL, 3MF, OBJ, ...) now shows a new
mesh_body icon, a triangulated cube in the mesh accent, instead of the
solid body's grey cube. Hovering a body row says Mesh body, Surface body
or Solid body. Body::is_mesh() decides; nothing new is saved.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 00:26:01 +00:00
w0prup 3449f0def2 Wayland: trackpad pinch zooms the view (#7744) (#8023)
* claim: #7744

* Vendor winit 0.30.13 with Wayland touchpad pinch and rotation gestures

The 0.30 Wayland backend never binds zwp_pointer_gestures_v1, and
WindowEvent::PinchGesture / WindowEvent::RotationGesture only come from the
macOS and iOS backends, so a trackpad pinch produces no input at all on
Linux and Wayland (two-finger scroll still works, since X11/XWayland
reports it as a wheel). egui-winit already maps the events to
egui::Event::Zoom and navigation::handle already zooms the camera from
that, so the Wayland backend was the only missing piece.

Upstream the gesture support is part of winit 0.31 (rust-windowing/winit#4338),
which eframe 0.36 cannot use while it still requires winit 0.30, and the
0.30 backport rust-windowing/winit#4778 was closed unmerged. So ship the
same fix locally, upstream-style-applied and stripped of example and test
targets, with its unit tests for the gesture state machine kept under
cargo test -p winit --lib -- pointer_gesture:

- src/platform_impl/linux/wayland/seat/pointer/pointer_gesture.rs: bind
  the global (versions 1..=3), one pinch gesture object per pointer,
  destroyed before the pointer is released; begin/update/end become the
  two events for the window the gesture began over. Two fingers only;
  the protocol's scale is relative to the gesture's beginning, so the
  event carries the change since the previous update; rotation is
  negated to winit's counterclockwise degrees; a non-finite scale is
  dropped, and so are updates of a gesture whose begin we never saw.
- seat state wiring: bind the global, track and drop the gesture object.
- wayland-protocols' unstable feature, platform notes on both events,
  changelog, patch-section entry in Cargo.toml, vendor/README, and
  Linux/Wayland notes in the troubleshooting guide and the UI tour.

Retire this vendoring once an eframe release consumes winit 0.31 or
later; the cleaned branch is kept as
https://github.com/purp0w/winit/tree/wayland-pinch-gestures.

* ci: retrigger after flaky occurrence-cache test

---------

Co-authored-by: Francisco das Chagas Danta Filho <dev@dantas-filho.me>
2026-10-29 00:09:02 +00:00
github-actions[bot] 0889ef9dfc Regenerate generated files and fold pending files [skip ci] 2026-10-29 02:11:11 +00:00
semmelrinde 1937dea04b Sketch constraints: refuse one curve picked twice for a two-curve constraint (#8577)
Parallel, collinear, equal or concentric of a curve with itself was added
as a constraint that always holds; perpendicular or tangent to itself was
refused as geometry that can't move. These six now refuse one curve
picked twice, as Coincident already does for a point, and leave the
sketch as it was. Constraints already in a design are untouched.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 23:52:44 +00:00
Nathan Dane a545f32113 Render stage panels: Surfaces and references, Studios and environments, and the review dialogs (#6281) (#7999)
* Render stage panels: Surfaces and references, Studios and environments, and the review dialogs

* fix: complete stage panel translations and gallery tile fields

* fix: exercise stage catalog sizing in the floating layout

* fix: retain the Appearance library selection query
2026-10-28 23:49:39 +00:00
github-actions[bot] 937d5f0176 Regenerate generated files and fold pending files [skip ci] 2026-10-29 01:43:00 +00:00
bherbruck 409ab1ed45 Release 0.26.0 (zip 9.1.3) 2026-10-28 19:41:38 -04:00
dependabot[bot] 00df48dfe4 Bump zip from 9.0.4 to 9.0.5 (#8572)
Bumps [zip](https://github.com/zip-rs/zip2) from 9.0.4 to 9.0.5.
- [Release notes](https://github.com/zip-rs/zip2/releases)
- [Changelog](https://github.com/zip-rs/zip2/blob/master/CHANGELOG.md)
- [Commits](https://github.com/zip-rs/zip2/compare/v9.0.4...v9.0.5)

---
updated-dependencies:
- dependency-name: zip
  dependency-version: 9.0.5
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-28 19:41:29 -04:00
bherbruck 5e03fe2f4b Release 0.26.0 2026-10-28 19:32:17 -04:00
bherbruck 0656819123 Release 0.26.0: fold point-clouds into format 43 (#8579)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 19:32:15 -04:00
github-actions[bot] 926d753a40 Regenerate generated files and fold pending files [skip ci] 2026-10-28 23:29:43 +00:00
semmelrinde 05a7a4fef9 Traditional Chinese: translate the 56 messages main gained since batch 1 (batch 3) (#8365)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 23:28:02 +00:00
github-actions[bot] c03dccabb6 Regenerate generated files and fold pending files [skip ci] 2026-10-28 23:20:35 +00:00
bherbruck 776653dd7c Release 0.26.0 (#8574)
Fold the pending design format change (linked-body-properties) into format 42.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 19:17:37 -04:00
N3MI-DG 33c27106ff Pick by the body's own size, not the camera's distance (#7919) (#7920)
* claim: #7919

* Pick by the body's own size, not the camera's distance (#7919)

The occlusion test that decides whether an edge or vertex is hidden by the
face in front allowed a slack of `scene_radius(app) * 0.02`, and
`scene_radius` ends with `.max(app.cam.half_height() * 2.0)`: in an
orthographic view that is `0.016 x camera distance`. Zooming out grew the
slack without bound until a whole body fitted inside it and every edge behind
the front face counted as visible, and edges and vertices are offered before
faces, so they won the pick. Cutting the slack from the scene also gave a
small part in a large assembly a tolerance many times its own size.

`pick_slack` now takes a size and returns the mesh's own chord tolerance with
room to spare, `DisplayQuality::Coarse.chord(size) * 4.0`: a facet sits at
most a chord tolerance inside its surface, and that tolerance is already a
fraction of the body's size, so the slack no longer moves when the camera
does. Each body is tested with its own bounding box, which the loop already
holds. `view.pick` does the same, so an agent's pick still matches a click on
the same pixel. Sketch profiles and origin planes belong to no body and keep
a scene-level value, `scene_pick_slack`.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-10-28 23:15:09 +00:00
semmelrinde 17807ab6dc Sketch MidPoint: refuse a line's own end as its midpoint (#8562)
A line's own end held at its middle shrinks the line to a point. The
solve accepted that, and a later angle, tangent or parallel on the line
had no direction to hold: its points went millions of millimetres away
while the sketch still reported solved. MidPoint now refuses the pick,
saying why, and leaves the sketch as it was. Constraints already in a
design are untouched (only the command refuses).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 23:11:05 +00:00
momysnow 1445b5a4f0 Extend: pick two curves to extend both to where they meet (#4750) (#8392)
* claim: #4750

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

* Extend: pick two curves to extend both to where they meet (#4750)

`sketch.extend` gains a second form: `curves: [a, b]` names two lines or
arcs, with an optional `at: [[x,y],[x,y]]` naming the end of each to grow
(default: the end nearest the meeting point). Both grow along their own
line or circle to where they cross and the two ends are joined with one
Coincident, in one undo step. `moved` is the joined end's id.

- Where two crossings are possible (line and arc, two arcs) the one with
  the least total growth is used; a tie is refused.
- A curve that already reaches the point does not grow. An end held by
  other geometry or a constraint stays and gets a new line or arc.
- Parallel or collinear lines, curves that never meet, a point behind the
  chosen end, the same curve twice, splines, ellipses, infinite lines,
  offset geometry and malformed `curves`/`at` are refused with no change.
- The single-curve form is unchanged; its code is shared through
  `grow_line` / `grow_arc`.

Canvas: in the Extend tool, Shift-click two curves. Shift goes through
the tool modifier registry as the new `extend_pair` action, and Extend
turns off Shift's midpoint snap. Escape drops a pending first pick. The
sketching guide describes the gesture.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-28 23:10:24 +00:00
github-actions[bot] 066de1b005 Regenerate generated files and fold pending files [skip ci] 2026-10-29 01:07:21 +00:00
github-actions[bot] d6876d6e67 Regenerate generated files and fold pending files [skip ci] 2026-10-29 00:58:24 +00:00
raykholo a1e7337d3c Toasts: one stack in the view's bottom-right corner, one width, buttons as in dialogs (#7893) (#7959)
* claim: #7893

* Toasts: one stack in the view's bottom-right corner, one width, buttons as in dialogs (#7893)

* Control protocol: toast_place in the sorted ui.prefs list (#7893)

* Toasts: a sketch test clears the status notice before clicking the view's corner (#7893)

The test's line end lands in the bottom-right corner, where notices now stack
and take clicks as they did at the bottom centre; the test is about the circle.

* Toasts: the pattern scenario's circle sits clear of the view's corner (#7893)

After the refit, a circle at y 0 landed in the bottom-right corner, where the
tool's prompt now stacks and takes clicks. The circle moves 40 mm up; the
pattern it checks is the same.
2026-10-28 22:35:24 +00:00
semmelrinde 951b5eb1d3 Indonesian and Traditional Chinese: the Appearances panel's 27 Render messages (#8336)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 22:27:28 +00:00
Colin Faulkingham 5221103369 Components: a component named like the design is reachable by name (#8358) (#8423)
* claim: #8358

* Components: a component named like the design is reachable by name (#8358)

Document::find_component resolved the design's own name to the root component (id 0) before
searching the component list, so a component that shares the design's name was unreachable by
name: appearance.assign reported `no component `CurtainRailPlug`` (the same lookup serves every
command that takes a component by name).

An exact component name (or component id) match now wins over the design-name fallback; "0" and
"root" still always mean the design.

Regression test: a_component_named_like_the_design_takes_its_appearance_by_name (fails on main
with the reported error, passes with the fix).
2026-10-28 22:07:58 +00:00
JK3D 9b7a6ea04d History: feature folders get their own symbol (#8521) (#8522)
* claim: #8521

* feat: give History feature folders their own symbol

A folder of features in the History on the left drew the same folder icon
as the Browser's folders. It now draws feature_group, a pile of history
chips with the front one in blue, and so do Add to New Folder and Remove
from Folder in the History's menu. Other uses of folder keep it.
2026-10-28 22:04:12 +00:00
Lars Jansen fe265d74b1 Sketch: draw conflicting curves and points in the conflict colour (#7244) (#7249)
* claim: #7244

* feat(sketch): draw curves, points and constraints that conflict in their own colour (#7244)
2026-10-28 22:03:54 +00:00
NOWN-DEV eb29c4b2d8 Sheet metal feature commands: typed parameter contracts (#6581) (#6582)
* claim: sheet.* typed parameter schemas

* Sheet metal feature commands: typed parameter contracts

sheet.flange, sheet.perforate, sheet.hem, sheet.fold, sheet.unfold, sheet.refold and
sheet.convert read their parameters by hand, so a misspelt key or a value of the wrong
shape was ignored or failed late, and describe_command had only prose for them.

Each now has a contract in schema/sheet.rs, checked in front of the unchanged handler,
and returns FeatureResult. The values no shared type fits (sheet-edge points, an edge
flange's edges, a fold's line and fixed point, the flange type, the positions, the
relief shape and the perforation pattern) get types that call the handlers' own
readers, split out of cmd/sheet.rs, and give their messages. The rule's values
(thickness, K-factor, bend radius, reliefs) stay out of the contracts: the handlers
refuse them, and strict refuses them as unknown keys.

A test passed a nested `base` object whose `sketch` the flange never reads; it now
names the sketch at the top level.

Part of #2829 (#6581).

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

* Sheet metal contracts: a further bend's angle stays as lenient as before

The flange handler reads a `then` bend's `angle` that is no expression
(`true`, a list) as left out and bends it 90 degrees. The rebased
contract had moved that check into the shared reader, so the handler
refused such a call even with the check off.

The reader is back to the handler's behaviour, and `ThenSteps` is the
stricter one, as the schema guide asks: warn reports such an angle and
strict refuses it. Tests cover the reader's leniency, the type's
refusal and the three check levels on one flange.

Part of #2829 (#6581).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 22:01:35 +00:00
github-actions[bot] f1ab19422b Regenerate generated files and fold pending files [skip ci] 2026-10-29 00:30:57 +00:00
github-actions[bot] 0a187f5cb1 Regenerate generated files and fold pending files [skip ci] 2026-10-29 00:21:59 +00:00
tr1pp4 7adbe00377 History in the browser: a History layout that lists each component's history in the browser (#7709) (#7710)
* Browser: the history in the browser, as a History layout (compact mode)

Settings > Interface > History gains "In browser" (view.history_layout dock: browser):
neither timeline is shown, and each component's History folder lists its sketches and
features in history order with the marker. Dragging the marker rolls the history back
through timeline.roll_to; Find in Timeline reveals the feature in the browser.

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

* Browser history: sketches listed under their feature as in the vertical History

With Sketches: Under their feature, the history in the browser (compact mode) lists each
sketch under the first feature built from it, through the shared history_rows (#6494)
without reference rows: same rule, same fold key, so switching the History layout keeps
what is open. A drop above a row rolls back its listed sketches with it. Selecting a feature
lights the rows of its sketches, and selecting a sketch lights the features built from it.

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

* Browser: with the history in the browser, the Sketches folder starts closed

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

* Browser history: sketch rows have the eye of their Sketches folder row

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

* History in the browser: Arabic, Hebrew and Vietnamese for the layout's name

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 21:49:21 +00:00
semmelrinde 0243afdc0b Dialogs: scroll a long refusal into view with its Create Issue (#8454) (#8506)
* Dialogs: scroll a long refusal into view with its Create Issue (#8454)

A dialog's refusal comes last in its fields. When the fields fill the
dialog, the refusal sits under the fold: an Extrude Cut that the kernel
refused showed only its first line, "boolean Cut: the operation failed: no",
and no Create Issue. The dialog now scrolls a new refusal into view once:
a refusal on OK at once, a preview's once no field is being typed in, so a
field never moves away while it is typed in.

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

* Dialogs: decide the reveal in one function and test it

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 21:48:09 +00:00
raykholo 8e6a107f6e Lists many PRs add to: no written lengths, one file per theme (#7747) (#7898)
* Lists many PRs add to: no written lengths, one file per theme (#7747)

* xtask lists: the fixed-table allowlist in order, each with its own reason

* Lists: the wide-character blocks and the drawing scale presets as slices too (#7747)

* Lists: the drawing units test reads UNITS as a slice (#7747)
2026-10-28 21:33:34 +00:00
semmelrinde 4ad18407dc Paste: leave copied body transfers out, so the design still opens (#8478)
A copied body transfer was pasted like any feature and given the target
component as its owner. A transfer belongs to the component it moves bodies
to, so the saved design was refused on open ("body transfer owner must match
its destination"), and a pasted transfer had nothing to move anyway: the
copies go into the component they are pasted into. Paste now leaves body
transfers out and says so in its notices; a clipboard of only transfers is
refused.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 21:29:05 +00:00
semmelrinde 5695785e3e Dutch: translate the 117 messages the catalog still lacks (batch 4) (#8486)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 21:26:12 +00:00
semmelrinde c2e1dbe6e4 Italian: translate the 125 messages the catalog still lacks (batch 4) (#8491)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 21:26:00 +00:00
Logan Night bda5f69c5c Preferences: reverse trackpad orbit direction (#8410) (#8484)
* claim: #8410

* Preferences: reverse trackpad orbit direction (#8410)

Add "Reverse trackpad orbit direction" under the pan settings in
Preferences > Navigation. orbit_reverse defaults off, so older settings
files load unchanged, and turns the Shift + two-finger orbit the other
way on both axes, as natural scrolling flips both. Pan, zoom and mouse
orbiting are untouched. Labels in en, ar, de, he, ja, ko, vi and zh as
a pending locale file; orbit_reverse in the control protocol's sorted
settings list.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 21:25:24 +00:00
semmelrinde 78d05e35bb Malay: translate the 125 messages the catalog still lacks (batch 4) (#8492)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 21:25:18 +00:00
semmelrinde 70fdc27703 Czech: translate the 121 messages the catalog still lacks (batch 4) (#8485)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 21:24:15 +00:00
semmelrinde b650d7b49b French: translate the 121 messages the catalog still lacks (batch 4) (#8487)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 21:24:12 +00:00
semmelrinde 883aff8816 Traditional Chinese: translate the 125 messages the catalog still lacks (batch 4) (#8498)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 21:23:58 +00:00
semmelrinde 43cf7774aa Galician: translate the 125 messages the catalog still lacks (batch 4) (#8488)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 21:23:54 +00:00
raykholo 29749c73f3 Report missing translations for complete catalogs before review (#8542) (#8543)
* claim: #8542

* CI: report missing translations for complete catalogs before review (#8542)

`cargo xtask i18n --check` already reads the pending locale files and fails
when a complete catalog lacks an English message, but no workflow ran it, so
the gap surfaced only in the full test run after approved-for-ci. It now runs
in Format and repository checks, and CONTRIBUTING names the complete catalogs.
2026-10-28 19:51:47 -04:00
github-actions[bot] 05081e8428 Regenerate generated files and fold pending files [skip ci] 2026-10-28 23:50:48 +00:00
semmelrinde 9b8be3b8a5 Romanian: translate the 121 messages the catalog still lacks (batch 4) (#8494)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 21:23:47 +00:00
semmelrinde d755b70e25 Swedish: translate the 121 messages the catalog still lacks (batch 4) (#8497)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 21:23:33 +00:00
semmelrinde 0c4673a89d Russian: translate the 125 messages the catalog still lacks (batch 4) (#8495)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 21:23:29 +00:00
semmelrinde 1b000c92f3 Hungarian: translate the 121 messages the catalog still lacks (batch 4) (#8489)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 21:23:28 +00:00
semmelrinde eb8cfdf8cc Indonesian: translate the 125 messages the catalog still lacks (batch 4) (#8490)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 21:23:25 +00:00
semmelrinde 2da2c85f27 Norwegian Bokmål: translate the 121 messages the catalog still lacks (batch 4) (#8493)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 21:23:02 +00:00
semmelrinde 359a7253e9 Ukrainian: translate the 121 messages the catalog still lacks (batch 4) (#8500)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 21:22:18 +00:00
semmelrinde 64bbfe9ed3 Turkish: translate the 121 messages the catalog still lacks (batch 4) (#8499)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 21:22:10 +00:00
YAEemf e6dc105134 Point clouds: insert PLY, XYZ and PTS scans as a modelling reference (#5151) (#5381)
* Point clouds: insert PLY, XYZ and PTS scans as a modelling reference (#5151)

A point cloud is kept in the design as a reference, drawn as depth-tested
dots and never a body.

- io: a PLY reader (ASCII, binary little/big endian; x, y, z of any numeric
  type, optional colours, other elements skipped; counts checked against the
  file) and XYZ/PTS text, thinned evenly past max_points. PLY meshes with
  faces can also come in through file.insert_mesh.
- doc: Document.point_clouds (shared f32 points relative to an f64 origin,
  colours, units, rigid placement, size and colour), stored as a binary
  container part; format change in pending/point-clouds.rs.
- engine: pointcloud.insert / edit / delete / list; doc.open opens a cloud as
  a new design; selection.delete takes point_clouds; view bounds include them.
- gpu: an instanced point-sprite pipeline (16 bytes a point).
- ui: Browser Point Clouds folder with eyes, menu and an Edit Point Cloud
  card; drop, Open, Insert and file dialogs take .ply/.xyz/.pts; a CPU
  viewport fallback; messages in en, en-GB, ja, zh, de and ko.

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

* Point clouds: colour by the file, one colour or height, from the UI (#5151)

The Edit Point Cloud card gets Color By (File Colors, Single Color,
Height). Height is a blue-to-red ramp over design Z, between the cloud's
own two heights or, by default, a range worked out from the points (all
but the lowest and highest 2 %, from an even sample). pointcloud.insert
and pointcloud.edit take color_by and height_range; pointcloud.list
reports them. The colour of each point is worked out in one place
(PointCloud::shade) for the GPU and CPU viewports.

Also: the card's dropdowns compare the picked index (a dropdown's
response doesn't report the pick, so File Units did not change either).

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

* Point clouds: integrate format registration and viewport caches (#5151)

* Point clouds: satisfy current UI audits and document comparisons (#5151)

Route model RGB data through the theme conversion boundary, provide a compact ribbon label, and include point clouds in the new document comparison. Gate the Unix-only CLI-link helper and test so Windows all-target checks remain buildable after the rebase.

* Point clouds: isolate translations for current main (#5151)

Move the existing 74 messages unchanged into one pending locale file and attribute it through a pending row, following the current repository workflow. Repository checks, 50 tooling tests, final workspace Clippy, wasm and generated-catalog equality checks passed after the rebase.

* Point clouds: fit the rebased code to main's GpuTarget and Frame

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

* Compare point clouds when reusing placement-independent results

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

* Point clouds: complete command help and required translations (#5151)

* Point clouds: size the save cap from the raised limit after the rebase

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-28 21:17:02 +00:00
CamWight 866029aaa2 Engine: one sketch store for the sketch commands (W38.0, #5425) (#6336)
* Engine: one sketch store for the sketch commands (W38.0, #5425, no behaviour change)

The sketch commands found and kept the sketch they work on in about forty places, each its own
way. They now go through one module, cmd::sketch_store, so a drawing session (W38a/b) can put
sheet and view sketches behind the same calls without the commands changing again.

- target: the command's `sketch` param or the sketch being edited (target_sketch, moved).
- active: the sketch being edited, if any. The thirteen commands that need one keep their own
  refusal text; unifying the three texts is a separate, visible change.
- read and write: the only calls from the sketch commands to Document::sketch and sketch_mut.
- update: change a sketch on a copy of the document and keep both, the edit the text and link
  commands made by hand (sketch.text, sketch.edit_text, the projection, intersection and
  include links, sketch.fit_curves_to_section, sketch.break_link). As before, those paths
  neither solve the sketch nor run its checks.
- checked: the checks an edited sketch passes (the periodic splines), shared by edit_solved and
  sketch.offset.break.

edit and edit_solved stay in sketch.rs and load and keep their sketch through read and write.
Imported text, snap points and the face projections keep their own shape (a text that can't be
linked is skipped, snap points copy the document only when one is needed) through read and
write. The reads and writes that are about the design's own sketches stay as they are, each on
a line marked `// design-only:` (Redefine Sketch Plane, a joint's curve, a motion trace's wire,
and the session's live drag, parameter edits and `created` diff).

A source scan, each_sketch_command_goes_through_the_store, fails when a command reads or writes
a sketch, or asks for the active one, anywhere but the store or a marked design-only line in the
files that work on the design's sketches. Every existing sketch test is unchanged.

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

* Engine: the sketch store's scan also catches the active sketch taken as a value (#5425, review of #6336)

The scan only caught `active_sketch.ok_or`, so main's `let Some(id) = s.active_sketch else …`
could have come back unseen (0xlillith's review). It now also flags the value-read forms:
`) = s.active_sketch` (let-else and if let), `=> s.active_sketch` (a match arm),
`s.active_sketch.and_then(` and `s.active_sketch.map(`. State checks (`is_some`, `is_none`,
`== Some(`), assignments and snapshot copies stay allowed.

The wider scan found five commands that hand-rolled the store's `target` rule (a `sketch` param or
the sketch being edited). They now call `sketch_store::target`: sketch.inspect, sketch.glyphs,
sketch.export_dxf, inspect.curvature_comb and inspect.minimum_radius (their `curves` form).
Their refusals become target's:
- no `sketch` and none being edited: "no sketch is being edited" (was "no such sketch (and none
  is active)", or "no such sketch" for sketch.glyphs);
- an unknown name or id: "no sketch `X`" (an unknown id used to reach "that sketch is not
  evaluated");
- a feature that is not a sketch: "`X` is not a sketch";
- a `sketch` that is neither an id nor a name (true, a list, an object): refused,
  where it used to fall back to the sketch being edited. A null `sketch` still means none given
  (the scenario harness sends one): target now reads it that way for every sketch command
  (target_reads_a_null_sketch_as_none_given).
No test, doc or schema text named the old wording; the params docs ("sketch?: id|name (default
active)") still hold.

The two remaining value reads, the default sketch for a feature (features.rs) and the sketch a
measurement prefers (measure_sel.rs), go through `sketch_store::active`, with no change.

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

* Engine: a number in `sketch` is an exact sketch id, never a name (#5425, review of #6336)

`sketch_store::target` turned a JSON number into text and looked it up with
`Document::find_feature`, which falls back to a name when no feature has that id. So
`{"sketch": 999999}` could pick a sketch named "999999" whose id is something else, and export
or inspect it. sketch.export_dxf, sketch.glyphs and the two curvature inspects read an exact id
before they moved onto the store; the other sketch commands (the old `target_sketch`) and
sketch.inspect read a number as text.

One rule now, for every command that takes `sketch`: a number is an exact id (`as_u64`, then
the feature with that id); text is an id written out or a name, as before. A number that isn't
a whole number of 0 or more (negative, fractional) is refused with "`sketch` must be an id or a
name"; an id no feature has gives "no sketch `N`", as before. Nothing in the tests, docs,
scenarios or examples names a sketch with digits, so nothing relied on the old fallback.

Test (sketch_store): a sketch named "999999" with another id. sketch.export_dxf, sketch.glyphs
(with another sketch being edited, for its enabled check), inspect.curvature_comb and
inspect.minimum_radius (with a real curve id) refuse `{"sketch": 999999}`, find the sketch as
`"999999"` and by its own id, and refuse -1 and 1.5.

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

* Engine: MidPoint finds its sketch through the store too (#5425, after #5011)

#5011 added a caller of the old `target_sketch` (MidPoint's `line_stays`, which swaps a point
picked after its line) after this branch's base. It now reads the sketch through
`sketch_store::target` and `sketch_store::read`, as the other sketch commands do, so a number
there is an exact id too.

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

* Engine: standard parts fix their sketch's entities through the store (#5425, after #6376)

#6376 (a standard part's sketches are fully defined) reads the sketch being drawn straight from
the document's features, after this branch's base. `fix_all` now takes it from
`sketch_store::active` and `sketch_store::read`, as the store's scan requires, with the same
result.

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

* Engine: Toggle Diameter and Delete on a profile find their sketch through the store (#5425)

Two sketch commands landed on main after the store: sketch.toggle_diameter (#7729) still
called the removed sketch::target_sketch, and Delete on a picked profile (#7107) read the
sketch straight from the document. Both now go through sketch_store (target and read), and
the store's source scan passes again.

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

* Sketch dimensioned: use the sketch store's target after the rebase

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

* Sketch dimensioned: read the sketch through the store

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-28 20:56:05 +00:00
Nathan Dane 85a60c1af6 Render output: Output editor, size presets, Check list, Copy, progress pill and result viewer (#6281) (#6433)
* Render output: size presets, Check list, Copy, progress pill, R and Esc, and the result viewer

* Complete Output GPU help translations and format choices

* Remove Output size labels already present in catalogs

* fix: compile the legacy finishing controls only for tests
2026-10-28 20:55:55 +00:00
semmelrinde 683535a18f Evaluation: a pattern of another component's body follows its own component when that moves (#8467)
A pattern of bodies copies them about an axis in its component's frame,
so its copies of a root body depend on where that component is placed.
placement_digest never counted a pattern's bodies, so moving the
component kept the cached copies. Read each body through placed_body,
as Combine does.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 20:49:55 +00:00
Logan Night d67c8e6231 Sketch text: Arabic and Hebrew are ordered right to left and joined (#8237) (#8324)
* claim: #8237

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

* Sketch text: right-to-left lines are ordered and shaped (#8237)

A line with Arabic or Hebrew letters is split into display-order runs by the
Unicode bidirectional algorithm (unicode-bidi) and each run is shaped with the
font's own rules (harfrust), so letters join and marks sit where the font puts
them. Lines without right-to-left characters keep the glyph-by-glyph layout.

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

* Sketch text: a line break inside a right-to-left line keeps the text after it (#8237)

The bidirectional algorithm ends a paragraph at a line break; every
paragraph is shaped now, not only the first, so measuring such a line
counts all of it.

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 20:31:02 +00:00
semmelrinde 2841210fb2 Sketch Dimension: choose horizontal or vertical from the two points' box (#7295) (#7355)
* claim: #7295

* Sketch Dimension: choose horizontal or vertical from the two points' box (#7295)

Two points or a slanted line got the aligned distance whenever the text
was between the perpendiculars through them, so straight above the middle
of nearly level points was aligned and horizontal needed a thin wedge.
Now the text position relative to the box the two span picks the type:
inside it or in its outer corners aligned, above or below horizontal,
beside it vertical. This is the rule midpoint pairs already used, so both
share auto_orientation().

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

* Sketch Dimension: aligned across the line, horizontal or vertical by the pull past an end

Review on #7355: the aligned length must stay the default, and the box
rule shrank its zone to a thin strip on a shallow line. Now `auto` is
the distance between the two points everywhere across from them
(between the perpendiculars through them, and a tenth of their distance
past), and only a clear pull past an end changes it: up or down from
that end gives the horizontal distance, sideways the vertical one. The
two meet on the diagonals through the end, so both zones stay wide on a
shallow or steep line. Midpoint pairs keep main's box rule.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 20:27:44 +00:00
github-actions[bot] 170f2555ce Regenerate generated files and fold pending files [skip ci] 2026-10-28 22:30:25 +00:00
raykholo 6b523d6839 Theme drops: build for the web, and import there too (#8226) (#8534)
DroppedFile::bytes exists only on native; the web build stopped at
take_theme_drops. Native keeps reading the file there. On the web, the
shell already reads every drop asynchronously before the app sees it: a
drop over Preferences' Import tile now arrives as a theme and goes to
prefs::import_theme_bytes, instead of being opened as a design.
2026-10-28 18:25:26 -04:00
github-actions[bot] 06c9479a3f Regenerate generated files and fold pending files [skip ci] 2026-10-28 22:22:05 +00:00
Frank d7ef5280c5 Ctrl+W closes the design shown, not the browser tab (#5366) (#5466)
Ctrl+W (Cmd+W) closes the active design, asking first when it is
unsaved; closing the last one shows the start page. In the web app a
capture-phase keydown listener keeps the browser from closing the tab
while a design is shown; on the start page the browser closes the tab.
Ctrl+W joins the keys the app reserves, and the tab menu's Close shows it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 20:15:33 +00:00
froglemonade2 f595f38e1c Define a named parameter inline from any value input (#4071) (#7341)
* claim: #4071

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

* Define a named parameter inline from any value input (#4071)

Any value input, typed or sent as a command's expression parameter, takes
`name = expression` (or `#name = expression`): the design gets the user
parameter `name` with that expression, and the input stores `name`, in the
command's one undo step.

- doc: the lexer skips a definition's `name =`, so until the design stores
  it the text evaluates as its expression and commands read and check it
  like any value. `Document::define_inline` then turns every stored
  definition (feature inputs and placements, dimension parameters, sketch
  pattern and text values, joint and coupling values, sheet metal and
  plastic rules, canvas planes) into a user parameter, the unit following
  how the input reads a bare number. A taken, reserved or empty name, two
  different values for one name, and a definition in a parameter's own
  expression or a configuration cell are refused; all or nothing.
- engine: `Session::execute` runs it after any command whose parameters
  hold definition-shaped text; the reply has `defined` (the first name,
  as `sketch.dimension` already gives) and `definitions`. A refusal fails
  the command, which leaves the design as it was.
- `parameters.change` with `new = expression` renames the parameter it
  changes (a dimension's or feature input's own, references following)
  and sets it; when the value is just `new`, it sets `new`. `changes`
  refuses definitions. `expr.evaluate` reports `defines`.
- UI: lengths keep the name when the design unit is applied; sketch text
  sends a definition as typed and previews only its value; the box corner
  and offset Distance 2 use the value; fields whose command keeps no
  expression (sketch scale, move, chamfer) refuse a definition with the
  way to name it. The dimension editor says which parameter it named.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 20:14:36 +00:00
Colin Faulkingham a3ee8957f4 Sketch: conic curves take their own id prefix, not the constraints' k (#6173) (#8407)
* claim: #6173

* Sketch: conic curves take the id prefix n, not the constraints' k (#6173)
2026-10-28 20:12:48 +00:00
semmelrinde be2168f70d Snap Fit: the Base Height hint in the user's language (#8451)
The hint "at the face" had no catalog message, so it stayed English in
every language. Add it in English and the 26 other catalogs, and check
that every plastic dialog hint has a message.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 20:12:42 +00:00
Daniel 93066ab60c Select Other: a still hold opens the list while Orbit, Pan or Zoom is lit (#8437) (#8438)
* Select Other: a still hold opens the list while Orbit, Pan or Zoom is lit

With a mode lit in the navigation bar (or Space held), holding the left button still on overlapping geometry opened nothing, and the release then selected the top item as a click. The viewport treats such a mode as owning left drags: a plain click still selects, and every other check on the mode sits on a drag. The hold was the one place a press that had not moved counted as navigation.

The hold is now cancelled only by a navigation that has started (the drag past the click distance), so it behaves as it does with no mode lit. The hint and the user guide already describe the hold without that condition. A preset's modifier binding (Maya Alt, Alias Shift+Alt, HFSS Alt) and Ctrl+left orbit still cancel it at the press, as before: holding such a chord is a gesture to navigate.

Tests: holding still with each mode lit opens the list, picks from it and a click still selects; a drag under each mode still moves the view and opens nothing; Maya's Alt chord held still does not open the list (it fails without the preset check).

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

* Select Other: the press that opened the list does not move the view

egui calls a press a drag once it has lasted 0.8 s. With Orbit, Pan or Zoom lit, the press that opened Select Other at 0.5 s then became an orbit, pan or zoom, and moving the pointer to a row turned the view under the open list. While the list is open, a primary press no longer takes the lit mode.

Test: a press held past 0.8 s that then moves keeps the list open and the view still, for each mode.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 20:07:33 +00:00
Colin Faulkingham 90ef106e5f Preferences: the File view entry and the search field carry the window's name (#8318) (#8432)
* claim: #8318

* Preferences: the File view entry and the search field carry the window's name (#8318)

The File view's entry that opens Preferences said "Settings", and the window's
search field said "Search settings" / "No matching settings", while the window
title, the File menu and the help texts around the app say "Preferences".  The
strings that name the window or point to it now use "Preferences"; ids unchanged.

Translations of these ids followed the English source and keep their wording
until the language teams catch up.

Tests: home::tests::the_entry_that_opens_preferences_carries_the_windows_name
and prefs_search::tests::the_search_field_names_the_window_it_searches fail on
main ("Settings" vs "Preferences") and pass here; the prefs_search:: and home::
suites pass, and the full solidcraft-ui-egui lib suite passed in `cargo xtask ci`.
2026-10-28 20:06:24 +00:00
froglemonade2 02ac56d12f Feature windows choose Join, New Body or Cut from what the material touches (#6901) (#6903)
* claim: #6901

* Feature windows choose Join, New Body or Cut from what the material touches (#6901)

Extrude, Revolve, Loft, Sweep, Coil and the Box, Cylinder, Sphere and Torus choose
their operation by running the Join on a copy of the design off the window thread
and counting the pieces: Join when the material makes one body with what it
touches, New Body when it would be a separate piece, Cut when it lies inside a
body (Revolve and the four primitives; Extrude keeps its own Cut). The choice
changes only when a check is in, a pick in the Operation row (the shown option
included) stops it for good, and OK waits for a running check. Hidden bodies take
no part: with some hidden, the shown ones become the feature's targets.

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

* Operation row: a pick in Extrude stops its own choice at once; ja/zh strings, gallery entry

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

* Operation check: contact test first, mesh volumes for Cut, OK takes the evaluated Join

Material touching no body is a New Body without running the Join (the contact
test costs 0-240 ms where the Join costs seconds on a large body). Inside is told
from display-mesh volumes instead of exact ones (8 s on a 400-hole plate). The
check evaluates the Join through Session::ahead, and OK of the same commands
takes it (Session::prime) rather than computing it again.

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

* Preference: Choose the operation automatically (on by default)

Preferences > Design gets "Choose the operation automatically" (auto_operation,
on by default). Off, feature windows don't choose Join, New Body or Cut from
what their material touches: each keeps the operation it opens with (Extrude
its own Join or Cut), no check runs and OK doesn't wait for one, as before

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

* Arabic, Hebrew, Vietnamese and Turkish strings for the operation choice and its preference

Arabic, Hebrew and Vietnamese are complete catalogs now, and Turkish has a full
catalog, so this change's three strings get them too.

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

* Operation check: a closed window's check stops; the icon audit doesn't choose

A window's operation check kept running after the window closed, a Join on
the design for nothing, and the test harness waited for it after every step:
the icon audit, which opens every window, took ~30 min instead of ~1. The
check now runs under a Progress, and closing the window (or one that doesn't
choose) abandons it: its evaluation stops at the next feature and nothing
waits for it. The icon audit turns auto_operation off, as it only looks at
icons (75 s again).

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

* Box placement test: the window keeps its New Body

The test #6248 added places a box that overlaps Base and expects a second body;
with #6901 the window would join or cut it. The test is about where the box
goes, so it turns auto_operation off.

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

* Join pieces: read the target lists leniently after the rebase

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-28 19:57:27 +00:00
Lars Jansen 261d00fbb2 History and browser: a sketch's icon in the colour of its state (#7245) (#7250)
* claim: #7245

* feat(ui): a sketch's icon in History and the browser takes the colour of its solve state (#7245)
2026-10-28 19:40:45 +00:00
raykholo e2935aff95 Stand-up offer: a note only when the part seems to lie on its back (#7717) (#7718)
* claim: #7717

* Stand-up offer: a note only when the part seems to lie on its back (#7717)

* Stand-up note: an unclear shape still gets the note

* Stand-up note: a Don't ask again button, as the dialog has

* Control protocol: the stand-up settings in the sorted ui.prefs list (#7717)

* Stand-up note: the control import test asks as before (#7717)

The test checks the caller's `up`, so it sets the setting to Ask and every
file from another program still gets the offer.
2026-10-28 19:40:25 +00:00
sascha2101 a95f30f186 Sketch: Center Point Arc follows the cursor clockwise or counter-clockwise (#6645) (#6664)
* claim: #6645

* Sketch: Center Point Arc follows the cursor clockwise or counter-clockwise (#6645)

The centre-point arc always ran counter-clockwise from its start; a
clockwise arc needed a typed negative sweep. The tool now tracks the
signed turn the cursor makes round the centre after the start is placed,
and its sign sets the direction of the preview, the live Sweep box and
the created arc.

sketch.arc.center_point takes `clockwise` with a clicked end: the arc is
stored counter-clockwise from the end and reversed, as a negative sweep
is, so the start keeps its radius and `<id>.start`.

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

* Sketch: keep the old counter-clockwise centre-point arc behind a preference (#6645)

Preferences > Sketch gets "Center point arc follows the cursor's
direction" (center_arc_follows_cursor, on). Off, the arc runs
counter-clockwise from the start whichever way the cursor went, as it
always did. Its strings are a pending locale file (en, de).

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

* Sketch: Japanese and Chinese strings for the centre-point arc preference (#6645)

ja and zh are complete catalogs, so the new preference's label and tip
need them too.

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

* Sketch: Arabic, Hebrew and Vietnamese strings for the centre-point arc preference (#6645)

Arabic, Hebrew and Vietnamese became complete catalogs on main, so the new
preference needs them too.

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

---------

Co-authored-by: sascha2101 <221609842+sascha2101@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 19:32:13 +00:00
semmelrinde 429c2b44b5 Russian: translate the Render workspace's 733 messages (batch 2) (#8251)
* Russian: translate the Render workspace's 706 messages (batch 2)

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

* Russian: the 27 Render messages main gained since (the Appearances panel)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 19:06:50 +00:00
github-actions[bot] 28b8da3126 Regenerate generated files and fold pending files [skip ci] 2026-10-28 21:55:57 +00:00
github-actions[bot] 9ddbcac775 Regenerate generated files and fold pending files [skip ci] 2026-10-28 21:46:59 +00:00
semmelrinde 9628e60ca0 Sketch: Reverse Spline Direction (#8224) (#8414)
A new command, sketch.spline.reverse, runs a spline the other way: its
point list is reversed in place, so its last point becomes its first and
the curve keeps its path (chord-length fits, uniform clamped knots and
closed fits read the same both ways). Points and constraints stay. A
projected spline and one an offset uses or makes are refused. The
sketch's right-click menu offers it for one selected spline.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 18:56:34 +00:00
Logan Night bcdee6e0bb Sketch on a face: a round's edges that end a hair apart still close (#8284) (#8386)
* claim: #8284

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

* Sketch on a face: a round's edges that end a hair apart still close (#8284)

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

* Sketch on a face: test both ends of a rounded loft end to end (#8284)

A square lofted to a circle with its side edges rounded, as in the
report: a sketch on either end face takes the whole face. Without the
fix the circular end fails with "face projection has a branched
boundary". Also drops a redundant term from the join tolerance.

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 18:56:20 +00:00
Colin Faulkingham c2e69ed724 Timeline: the feature menu's suppress item follows the reader's language (#8301) (#8383)
* claim: #8301

* Timeline: the feature menu's suppress item follows the reader's language (#8301)

Suppress Features / Unsuppress Features were raw literals. Menu::label()
translates a label only when a catalog entry carries it, and none did, so the
item stayed English in every language, the Japanese UI included.

The two labels are now the messages timeline.suppress_features and
timeline.unsuppress_features in a new assets/locales/pending file: the English
wording is unchanged, and the complete catalogs (ar, he, ja, vi, zh) plus de and
ko carry the translations.

cargo test --profile ci -p solidcraft-ui-egui --lib -- timeline::tests::the_feature_menu_suppress_item_follows_the_language
fails on the claim commit (the Japanese menu paints the English literal) and
passes with the change.
2026-10-28 18:55:37 +00:00
semmelrinde 94d23338fe Render: the Appearances panel and the shots strip in 15 languages (#8417)
The Appearances panel's 27 messages for Dutch, Hungarian, Galician,
Malay and Norwegian Bokmål, which their merged Render batches missed,
and the shots strip's 7 newer messages for those and ten more.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 18:54:59 +00:00
Jasper van Loenen 728100c26d UI: Fix settings autoscroll bug (#8388)
* Fix issue with settings window automatically scrolling to selected item, preventing scrolling by user

* Fix issue with settings window automatically scrolling to selected item, preventing scrolling by user
2026-10-28 18:54:23 +00:00
Daniel b470aebe80 Toolbar: Sketch Create drop-down rows share the menu's width (#8409)
In the Sketch > Create drop-down a plain row such as Point was drawn
200 pt wide, the minimum width its labels fit in, while a family row such
as Slot filled the menu. The menu was as wide as the one-line pin hint in
its footer, so the plain rows' hover background and pin stopped short of
the right edge.

The Create menu now sets its width from its rows before drawing them, as
the other panel drop-downs do, and passes that width to the plain rows
instead of recomputing it over a subset. Family rows fill the same width
and the footer hint wraps inside it. The overflow menu's plain rows take
the width of all its commands in the same way. A family's submenu still
sizes its variants to their own labels.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 18:53:32 +00:00
MarkusRe 05f62b09e0 Browser menus: V runs the open menu's Show/Hide on the row it was opened for (#8377)
Most browser menus show V beside Show/Hide, but the V key always ran
toggle_visibility, which acts on the selection. A right-click on a body or
sketch row also selects it, so the two agreed there; a right-click on the
Bodies, Sketches or Origin folder, a group, the component or an origin plane
does not, so V did nothing, or flipped a body selected earlier, and left the
menu open.

With a menu open, V now runs that menu's row whose shortcut is V, through
run_item as a click does, and closes the menu (context_menu::press_shortcut).
A disabled row takes the key and does nothing. With no menu open, or a menu
without such a row, V still acts on the selection (#4128, #4409: V stays
selection-based).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 18:23:26 +00:00
bherbruck 5d1c33fc82 Docs: the macOS app is notarized, and the update check names tangency-labs/solidcraft (#8402)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 18:15:03 +00:00
bherbruck d26de76097 Updates: leave the update to the launcher that manages this copy (#8403)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 18:14:57 +00:00
austin62jake 65784cae2f Save As offers the open file's own name, not the name stored in the design (#8329) (#8354)
* claim: #8329

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

* Save As offers the open file's own name (#8329)

Save As, Save As JSON and Save a Copy proposed the design's name, the one
stored inside the file. Copies of a file under other names keep the
stored name, so "Sample Plate-bug.solidcraft" was offered
"Sample Plate.sc", the same as its sibling, and one replaced the other.

Session::save_as_name() offers the stem of the open file unless the
design was renamed since it was opened or last saved, and the design's
name otherwise (a new design, or one renamed in the Browser).

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-28 18:11:06 +00:00
semmelrinde 003d388b78 Sketch: an arc slot is drawn without a redundant Equal on its caps (#8355) (#8395)
Each cap is tangent to both concentric sides, so its radius is already
half the gap between them and the two caps are equal. The Equal the arc
slot added between them was always redundant; it is no longer added.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 18:09:47 +00:00
Jasper van Loenen 562d066c0e Fix endless redraw of settings window if setting has empty row, caused by storing setting in row of previous setting (#8399) 2026-10-28 18:07:52 +00:00
github-actions[bot] eaf6ef741c Regenerate generated files and fold pending files [skip ci] 2026-10-28 21:14:05 +00:00
github-actions[bot] 3026cca31a Regenerate generated files and fold pending files [skip ci] 2026-10-28 21:06:56 +00:00
semmelrinde f0095a1ae6 Ribbon: a two-line button label stays inside the button's highlight (#8391) (#8405)
A button is 60 points tall and its label starts 32 points down, so two
lines of the label font (31 points) ran 3 points past it. Two-line
labels now take the font size plus 2 points per line and start higher
when they would still run past the button, leaving 2 points below.
One-line labels are unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 18:07:43 +00:00
semmelrinde aca93d1557 Swedish: translate the 203 messages main gained since batch 1 (batch 3) (#8387)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 18:07:40 +00:00
N3MI-DG 6fffd745fd Insert Part: the Length list opens on the default length, with no "Default" item (#8020) (#8022)
* claim: #8020

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

* Insert Part: the Length list opens on the default length (#8020)

The list carried an item called "Default" above the standard lengths. It
never said which length it stood for, and one of the lengths below it
always was that length, so the list offered the same length twice under
two names. Change Part Size has always listed concrete lengths.

The item is gone and the list opens on the length the part would be given
anyway. The default cannot be worked out in the dialog, because it is not
one rule: a cap screw or bolt takes the first standard length at or above
2.5 d, a set screw the first at or above d, a dowel pin the first at or
above 4 d. parts.library now reports default_length beside each size's
table values, taken from the shape the engine would build, and the dialog
selects it; the test checks that report against what an insert without a
length is actually built to, for every size of every family.

Leaving the length alone behaves as "Default" did: a new size resolves
the list again, so M6 to M12 moves the selection from 20 to 30. A length
chosen by hand stays until the size changes.

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

* Insert Part: the labels test skips default_length too

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-28 17:58:03 +00:00
Tyler Gregorcyk 4a7a8f31f8 Sphere, Torus and Cone dialogs: place them by their centre or base (#5638) (#8328)
* claim: #5638

* Sphere, Torus and Cone dialogs: place them by their centre or base (#5638)

The Box and Cylinder dialogs already had typed X, Y and Z placement
fields; the Sphere, Torus and Cone dialogs now do too. Sphere and Torus
show Placement: Center and take a picked point like the Box; Cone shows
Placement: Base Center like the Cylinder. The fields take expressions,
and Edit Feature shows and keeps the stored centre or base.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 17:39:31 +00:00
Daniel a1a801fb31 Feature dialog: draw the busy spinner in the footer row (#8362)
With the dialog buttons at the bottom, the spinner shown while the preview
computes or OK applies was painted on its own after the footer: at the
card's left edge and one control height up from its bottom. The footer
insets its row by 12 pt at the sides and 10 pt above and below and centres
the buttons, so the spinner sat outside that inset and about 10 pt below
the buttons' centre line.

Panel::feature now draws the spinner through the footer's left slot, so it
shares the row's inset and vertical centre, in the space the footer already
leaves for it before the status text. The rect is published as
dialog:footer:spinner, and a panel test checks it lies in the footer row,
at its left inset and on the OK button's centre line. The spinner in the
title row (buttons at the top) is unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 17:32:56 +00:00
semmelrinde 2a6e7105cc Italian: translate the 159 messages main gained since batch 1 (batch 3) (#8361)
* Italian: translate the 132 messages main gained since batch 1 (batch 3)

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

* Italian: the Appearances panel's 27 Render messages

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 17:32:56 +00:00
semmelrinde 035e065b27 Russian: translate the 247 messages main gained since batch 1 (batch 3) (#8363)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 17:32:21 +00:00
semmelrinde 67bda4dfd1 Malay: translate the 199 messages main gained since batch 1 (batch 3) (#8368)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 17:32:17 +00:00
semmelrinde aba05eb951 Dutch: translate the 199 messages main gained since batch 1 (batch 3) (#8366)
* Dutch: translate the 199 messages main gained since batch 1 (batch 3)

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

* Dutch: Spoed for a thread's pitch, as the catalog's Pitch (review)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 17:31:27 +00:00
semmelrinde b8bae7222a Japanese: translate the 29 Render entries that still held English (#8357)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 17:31:27 +00:00
semmelrinde 32eea80c98 Czech: translate the 203 messages main gained since batch 1 (batch 3) (#8376)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 17:31:21 +00:00
semmelrinde fa19a49db9 Indonesian: translate the 247 messages main gained since batch 1 (batch 3) (#8364)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 17:31:20 +00:00
github-actions[bot] 6ef3d71e02 Regenerate generated files and fold pending files [skip ci] 2026-10-28 20:37:10 +00:00
semmelrinde bd6e8ef567 French: translate the 149 messages main gained since batch 1, Render included (batch 3) (#8379)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 17:30:29 +00:00
semmelrinde 5df84bbaf5 Turkish: translate the 340 messages main gained since batch 1, Render included (batch 3) (#8380)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 17:30:08 +00:00
github-actions[bot] eaea3e9087 Regenerate generated files and fold pending files [skip ci] 2026-10-28 20:04:18 +00:00
github-actions[bot] c03a43c88c Regenerate generated files and fold pending files [skip ci] 2026-10-28 19:54:52 +00:00
semmelrinde b32448f713 Ukrainian: translate the 340 messages main gained since batch 1, Render included (batch 3) (#8381)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 17:29:47 +00:00
semmelrinde f3a23d1c75 Galician: translate the 199 messages main gained since batch 1 (batch 3) (#8367)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 17:29:34 +00:00
semmelrinde 2dc406ae86 Norwegian Bokmål: translate the 198 messages main gained since batch 1 (batch 3) (#8375)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 17:24:56 +00:00
Nathan Dane 293ed5fab7 Render inspector: Appearance editor with the look chain, scope, mixed values and Advanced (#6281) (#6432)
* Render inspector: selection-following appearance panel with the look chain, Applies to and edit controls

* Render appearance: Glass, Clear coat, Maps, Saved optics and Reflections sections

* Update Render navigation tests for dedicated editors

* Exercise Focus keyboard protection through the Library search field
2026-10-28 17:23:16 +00:00
semmelrinde d2456921ca French: translate the 295 messages the catalog was missing (#8012)
* French: translate the 205 messages the catalog was missing

The French catalog lacked 205 of the English messages, so parts of
Preferences, the hole presets, the slicer messages, the Render
workspace and the Assistant showed in English. They are translated in
one pending file, with the catalog's terms (Esquisse, Fonction,
Bossage, Appui, Clip, Prise de vue) and its non-breaking space before
: ; ? and !.

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

* Translate the 90 messages main added since (the navigation bar's)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 17:22:21 +00:00
semmelrinde 93d1e09bf7 Hungarian: translate the 203 messages main gained since batch 1 (batch 3) (#8373)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 17:09:02 +00:00
semmelrinde dddae33285 Romanian: translate the 203 messages main gained since batch 1 (batch 3) (#8374)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 17:08:54 +00:00
semmelrinde 1eec6d90b7 Sketch text: Bold in a variable font sets its weight axis (#8211) (#8331)
A variable font (one file for every weight, as most web fonts ship) has no face marked bold,
so Bold failed with "has no bold face". Without a bold face, a face whose wght axis reaches past
semibold is now set to weight 700. ttf-parser gets its variable-fonts feature for that.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 17:08:27 +00:00
semmelrinde 405217c3e5 Czech: translate the Render workspace's 584 messages (batch 2) (#8323)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 16:58:48 +00:00
austin62jake 86342b8c5c DXF import: an elliptical arc comes in as exact conic arcs, not a fitted spline (#8155) (#8241)
* claim: #8155

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

* DXF import: an elliptical arc comes in as exact conic arcs (#8155)

A partial ELLIPSE (how Fusion, and SolidCraft's own DXF export, write an
elliptical arc) was read as a degree-3 spline fitted through 16 samples,
which strays from the ellipse between them: 0.84 mm on a 300-degree arc
of a 200 x 40 mm ellipse. It is now read as exact rational-quadratic
conic pieces of at most a quarter turn each, which the sketch keeps as
conics. A conic arc written by SolidCraft's DXF export now reads back as
the same conic.

The arc's sweep is taken with rem_euclid instead of a loop that added a
turn at a time, which ran for billions of turns on a start angle of 1e11.

sketch.drawing_bounds and the width/height sizing of sketch.insert_dxf
measure a conic by points along it, not by its apex, which can lie
outside the curve.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-28 16:56:17 +00:00
Colin Faulkingham 95ba99d710 Move/Copy: a triad drag ends where the button is released (#8265) (#8309)
* claim: #8265

* Move/Copy: a triad drag ends where the button was released (#8265)

A frame carries all the input queued since the last one, so the release
that ends a drag and further pointer movement — the hand keeps moving as
the button goes up, or input is queued during a busy frame — arrive
together, and the frame's latest pointer position is already past the
release. The drag's final change was read from it, so the value, the
preview and the command both jumped to wherever the mouse had got to;
pressing Enter right after the release closed the window first, which is
why that worked around it. Rotate and occurrence moves share the triad.

The final change now comes from the release event's own position, which
the raw input carries (egui's processed pointer events drop it). The
regression test sends a release at 20 mm and a move to 30 mm in one frame:
30 mm before, 20 mm after.

Fixes #8265.
2026-10-28 16:53:41 +00:00
raykholo dd2e44bb12 Raise the largest file SolidCraft opens with a setting or launch flag (#4086) (#7565)
* claim: #4086

* Raise the largest file SolidCraft opens with a setting or launch flag (#4086)

* Translate the file size limit strings

* Size limits: design files are .sc now, .solidcraft still read
2026-10-28 16:52:29 +00:00
github-actions[bot] 506f79c092 Regenerate generated files and fold pending files [skip ci] 2026-10-28 19:23:10 +00:00
semmelrinde 2b7159f363 Romanian: translate the Render workspace's 733 messages (batch 2) (#8343)
* Romanian: translate the Render workspace's 733 messages (batch 2)

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

* Romanian: the Render tree's switch hint is a verb

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 16:41:23 +00:00
semmelrinde 895264c4cd Swedish: translate the Render workspace's 733 messages (batch 2) (#8344)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 16:41:17 +00:00
github-actions[bot] 48ba6ff3c6 Regenerate generated files and fold pending files [skip ci] 2026-10-28 18:54:51 +00:00
Homero Thompson f103468dba Params: a list parameter refuses a wrong type instead of losing entries (#6651) (#6683)
* claim: #6651

* test(#6651): a list parameter of the wrong type is refused

A list parameter such as a cut's `targets` or an export's `bodies` loses entries without an error: a value of the wrong type reads as an empty list, which most commands take to mean "every body". The regression test drives the engine the way a user does: two bodies, a box cut that crosses both and is meant for one of them, and a `targets` of the wrong type. The command must refuse it and leave the design as it was.

* fix(#6651): a list parameter refuses a wrong type instead of losing entries

A list parameter of the wrong type was read as an empty list, and an empty
list means "every body" to most commands: a cut meant for one body cut every
body it touched and the command reported success.

A wrong type is now an error that names the parameter, the offending entry
index and the value it got. A list longer than 10000 entries is an error
instead of being cut short. `null` stays absent, as the schema contract says,
and a plain string is still a list of one. The 80 call sites now propagate the
error instead of losing entries; the commands that take feature keys, which
may be named or numbered, read them with their own reader.
2026-10-28 16:25:53 +00:00
Lars Jansen dca332201f Status bar: the sketch's state, what is being edited and the design's units (#7243) (#7248)
* claim: #7243

* feat(ui): status bar with the sketch's state, what is being edited and the design's units (#7243)
2026-10-28 16:25:32 +00:00
github-actions[bot] fe3fb381b1 Regenerate generated files and fold pending files [skip ci] 2026-10-28 18:21:33 +00:00
github-actions[bot] 0778d24801 Regenerate generated files and fold pending files [skip ci] 2026-10-28 18:14:57 +00:00
PaddleStroke 79f53a0999 Sketch dimensions in their own colour in every theme (#7861) (#8124)
* claim: #7861

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

* Sketch dimensions in a colour of their own in every theme (#7861)

Dimension lines, arrows and values were drawn in the same near-black or
near-white as fully constrained sketch curves, so they were hard to tell
apart in a busy sketch. Every theme now sets its own dimension colours:
plum on Light, orchid on Dark, and a hue from each built-in theme's own
palette. Colour vision tests measure them against the curves, the
construction lines and the conflict red, and a new test holds every
shipped theme to readable, distinct dimension colours.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 16:23:47 +00:00
froglemonade2 dba3a226f6 Closing from the background brings the window forward to ask about unsaved changes (#7783) (#7784)
* Closing from the background: the window comes forward to ask about unsaved changes

Right-click › Close on the taskbar while another app had the focus showed the
"save changes?" prompt in a window that stayed behind, so the close seemed to do
nothing. When the close stops at the prompt, the window now asks for the focus
(unminimizing it first) and for the user's attention, which is what native
Wayland offers (xdg-activation) and marks the taskbar entry where the system
refuses the focus. A window already in focus is left alone.

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

* X11: ask the window manager to raise the window as a user action

winit's Focus asks as an application, which KWin's focus-stealing prevention
refuses for a window last used before the switch away: it raised nothing and,
since the attention request was then skipped, did not even mark the taskbar.
On X11 send _NET_ACTIVE_WINDOW with source 2 (a direct user action) through
x11rb, which restores a minimized window too; native Wayland keeps the
attention request. Moves the logic into come_forward.rs.

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

* Come forward once the taskbar menu is gone, without flicker

KWin raised the window, then the taskbar's closing context menu handed the
focus back to the previous app 2 to 11 ms later, so the window only came
forward some of the time; asking again made it flicker. Ask 120 ms after the
close instead, and again only when the focus is taken back within 50 ms of a
request (a slow menu) or never came. A later switch away is the user's own and
the window stays behind.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 16:09:25 +00:00
cronos b72165fd30 Sketch: show temporary centre and midpoint alignment guides (#7903)
* Sketch: show center and midpoint alignment guides

Add transient per-viewport horizontal and vertical guides with a sketch preference. Related tests, native UI, WASM and supplementary Linux checks pass. Full CI remains blocked by unchanged Windows engine dead-code Clippy errors.

* Sketch: complete alignment-guide translations

* Tests: compile chosen-program mutex only on Unix
2026-10-28 16:07:05 +00:00
Frank 8e26fe9388 Measure: number the two picks in the view (#6020) (#6021)
* claim: #6020

* Measure: number the two picks in the view (#6020)

"1" and "2" beside the measured line's ends, as the panel lists the
picks, each just past its end so it stays off the line and its length.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-28 16:05:22 +00:00
semmelrinde c8c9534022 Create Sketch: the grid previews the origin plane under the pointer (#6975) (#7404)
* claim: #6975

* Create Sketch: the grid previews the plane under the pointer (#6975)

While Create Sketch waits for its plane, the grid now lies on the origin or
construction plane under the pointer, so the sketch's plane shows before
the click. A plane's browser row under the pointer lights that plane in the
view, and previews it the same way.

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

* Create Sketch: the planes at the top of the History preview too (#6975)

main now draws the Origin rows at the top of the vertical History as well
(history_rows). Hovering a plane there lights it like its row in the browser:
the plane is handed over the same way the hovered bodies are.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 15:58:33 +00:00
Christian Hammacher aae2c5e417 Assemblies: edit a linked component in place, with the assembly around it (#7672) (#7819)
* Links: update linked designs that changed while the assembly's tab was away

A design coming back to its tab compares the modification time and size of its linked
design files with what they were when it went to the background, and runs Update Links
on the worker thread for those that changed: saved in another tab, another window or by
a checkout. Preference update_changed_links (Design, Linked designs) turns it off.

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

* Docs: the assembly guide says linked designs update when their tab returns

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

* Links: Vietnamese strings, now that vi is a complete catalog

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

* Links: remember what each design last read, so an update that fails is tried again

A design's linked files are now recorded as the design last read them: when Update Links reads a
link without an error, or when the link is first seen. Coming back to the tab reads again what
differs, one link per run, so a file that cannot be read holds up no other and is tried again
the next time, and a part changed while the assembly was shown is taken up on its next return.
Closing several tabs together reads nothing on the way, and the web build, which keeps no file
times, looks at nothing.

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

* Links: no read during a save-changes prompt, records for adopted designs and missing files

A tab shown to answer "save changes?" reads no link before its save, and Cancel there or on a
batch lets the tab left in front read what it skipped. A design opened in place of the blank one
records its links at once, a link whose file is missing is recorded as missing so it is read when
the file comes back, and a read queued for a design that is no longer shown by the time it starts
is dropped. More changed links than can wait at once are read on the next return.

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

* Assemblies: edit a linked component in place, with the assembly around it

Activating a linked component opens the design it comes from in a tab of its own, with the
rest of the assembly drawn faded around it where the component sits, so the part's own history,
browser and commands are at hand. Finish Editing in the browser saves the part, goes back to the
assembly and closes the part's tab when the edit opened it; coming back to the assembly updates
the changed link (or Finish Editing runs Update Links when that is turned off). Update Links in
the part also reads the assembly's links again, off the window thread, and redraws the assembly
around the part; a result for an assembly changed meanwhile is dropped with a message.

The part is an ordinary tab, so saving, autosave, recovery, closing and quitting treat it as any
other design. The model scene's ghost triangles are now drawn (only the preview's were).
Preference edit_linked_in_place turns it off. Builds on the update of changed links when an
assembly's tab returns.

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

* Edit in place: a double-click opens it, Activate only activates

A double-click on a linked component edits it in place, in the browser as on a face in the
view (around the placement clicked), and the component's menu gets Edit in Place. Activate and
the browser's radio button make it the active component as they always did, so a linked
component can still be activated without opening its design.

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

* Double-click on a component's face: edit a linked one in place, activate any other

A double-click on a face in the view activates the component the body belongs to, at that
placement, as a double-click on its row does; a linked one is edited in place. Bodies of the
design itself or of the active component are left alone, and so is a double-click with a
modifier. One preference, double_click_components, covers both gestures.

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

* Edit in place: fixes from review

An edit whose part never opens is forgotten instead of starting on a later open of the same
file, and the web build starts the edit after its synchronous open. Finish Editing goes on only
for a save into the linked file and in the part's own tab, waits for a "changed on disk"
answer, and says so when the part was saved elsewhere. Showing the assembly no longer drops a
refresh, which now records the links it read so coming back reads nothing twice. The faded
assembly is worked out once per picture, a bad mesh index skips its whole triangle, and the
last two status texts come from the catalogs.

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

* Edit in place: Finish Editing on the MODEL tab and the mouse's back button

Finish Editing is a command now, component.finish_edit, with its schemas: the app window
answers it, anywhere else nothing changes, as with view.fit. While a linked design is edited in
place the MODEL tab ends with a FINISH EDITING panel, pinned at the right as FINISH SKETCH is,
with a new left-arrow icon; the browser keeps only the line naming the assembly. The mouse's
back button, or a keyboard's Back key, finishes too unless a dialog, tool or sketch is open.
The assembly guide describes editing in place.

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

* Edit in place: fixes from the second review

Saving the assembly waits while it reads its links for the part
Finish Editing waits for dialogs, tools, sketches and prompts open in the part, also when run as a command
The assembly reads the edited part explicitly when it does not check its links on its own
The FINISH EDITING panel cannot be customized
A tab opened by an edit stays that edit's to close when the part is entered again
The mouse's back button finishes editing; the keyboard's only while no text field has focus
Refreshing the assembly keeps its selection and active component

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

* Edit in place: fixes from the third review

component.finish_edit run as a command opens nothing and answers with an error when it cannot finish; engine.batch and engine.script refuse it
A double-click or Edit in Place while a dialog, tool or sketch is open in the assembly keeps it open
What changes in the assembly while it reads its links without changing its document stays: picks, activation, components dragged but not captured
Showing the assembly while it reads its links starts no second read
A new or opened design in the part's tab ends the edit
Finish Editing keeps the part's tab open while its agent chat runs

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

* Edit in place: Update Links run as a command in the part refreshes the assembly too

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

* Edit in place: the ribbon button reads Finish Edit, as Finish Sketch does

A bare "Finish" took the catalogs' surface-finish translation (German "Oberfläche").

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

* Edit in place: fixes from the fourth review

Finish Edit waits while the part's agent is in the middle of its turn, instead of keeping the tab of any chat open
A part saved while its assembly refreshes is read again when the edit finishes: the refresh records the files as it found them
A double-click on a linked component while something is open in the assembly says so instead of activating it
Commands are recognised in any spelling the engine takes, so engine.batch and engine.script cannot slip Finish Edit through
Update Links run as a command refreshes the assembly from the control channel only, not for the app's own reads
Moves of components the refresh removed are not carried over, and a refusal is one message
The command description and the guide say that the command does not save the part

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

* Edit in place: the command is Finish Edit, as Finish Sketch is

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

* Edit in place: a command to start it, and fixes from the full review

Added component.edit_in_place {component, path?}: the double-click and Edit in Place in the menu run it, and as a command it asks nothing, so a network location not allowed yet or a design open elsewhere makes it fail instead
Finish Edit asked by the part's own agent finishes once that agent's turn ends; the button still waits for the turn
The parameters and design properties panels, with text typed and not applied yet, hold up entering and finishing an edit
A refresh is not taken up under an undo, a queued command or a feature still being computed in the assembly
Refusals say what holds them up (a refresh, a file being read or written) instead of nothing
The browser strip names the assembly that is refreshing, not any assembly
The schema description says the command does not save the part
Korean strings for every new message

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

* Edit in place: fixes from the second full review

The MCP batch tool refuses component.edit_in_place and component.finish_edit as steps instead of sending them one by one
Edit in Place asked while the assembly's agent is in its turn opens the part once the turn ends; a double-click then is refused rather than stopping the agent
Both commands answer queued: true when they wait for the agent's turn, and stopping the agent forgets what it queued
An undo, a queued command or a delete check running in the design holds up entering and finishing an edit
A refresh redraws every edit of the assembly, the parts in other tabs too
In an assembly never saved, a double-click activates the linked component as before
The control and MCP docs list the new batch and script refusals, and the commands list every way they fail

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

* Edit in place: fixes from the third full review

The MCP batch tool refuses the two commands however a step names them, an execute step too
A queued Edit in Place is checked fully when asked for, a second one is refused, and both queued requests wait for what runs in the design instead of being dropped
Disconnecting the agent forgets what it queued, as stopping it does

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

* Edit in place: help text for the Finish Edit button

main now requires help for every toolbar command (#7134), and Finish Edit
is one. Written in every language the branch's catalog carries.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 15:57:44 +00:00
Ajmal Musthafa 2cc6615c2d Enhance two-tangent circle tool: add default radius handling and improve interaction hints (#8116) 2026-10-28 15:57:06 +00:00
Homero Thompson 9c5b3beb73 geom: cover inside_loop and segments_meet in the fill predicates (#6162)
Add unit tests for fill.rs's two ray-casting predicates, which
fill_general.rs relies on to decide which parts of a surface a loop
covers: a point inside/outside a square and an L-shaped (non-convex)
loop, documenting how inside_loop resolves points on an edge or
vertex; and segments that cross, stay apart, touch at an endpoint,
or are collinear with and without overlap for segments_meet.

Closes #38

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-28 15:49:30 +00:00
CamWight 14acdc2a6d Render: one sRGB transfer function (#7161) (#7162)
* Render: one sRGB transfer function, geom::srgb, for the hand-written copies (#7161)

Twelve places wrote the sRGB encode or decode formula out by hand (doc's glass
recipe, engine's render library, glTF import, the PNG film, photographic and
presentation output, the ray tracer's image, the stage, the surface decoder,
texture normalisation and the theme's contrast check). They now call
solidcraft_geom::srgb: encode/decode on 0..1 values, encode8/decode8 for 8-bit
codes. render::surface::srgb8_to_linear stays as a re-export, so its callers
are unchanged.

Same results: the module's tests compare it bit for bit with the formula as the
callers wrote it, over all 256 codes and a dense sweep of linear values plus
the knees, out-of-range values, infinities and NaN, for each way the callers
clamped (after encoding, before it, or not at all), and decoded values either
side of the decode knee, where no 8-bit code falls.

One change: glTF import encoded in f32. Over every f32 in 0..1, 218 of
1,065,353,217 values sit on a rounding tie that f32 got wrong and now give the
neighbouring code (f64 gives the nearer one).

Left as they are: render/job/image.rs (replaced by exact tables in its own
change), the tests and the starter-pack validator that keep their own copy as
an independent reference, and the WGSL shaders (they can't call Rust).

Fixes #7161

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

* Tests: the sRGB codes of five callers that had no test of their output (#7161)

The glass swatch, a library surface applied as a plain appearance, glTF's
fallback for a colour channel that isn't a number, a stage panel and an empty
pixel in presentation output each get an assertion with their sRGB codes worked
out by hand. A planted wrong conversion at each is now caught by that test.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 15:48:56 +00:00
semmelrinde bcb67cc2ec Mirror and Pattern: a body feature reopens with Bodies and its Operation (#8207) (#8298)
Editing a body Mirror from the history showed Object Type Features (the dialog starts from
the timeline selection, the Mirror itself, which turns pattern objects into features) and
dropped its saved Join. The editor now sets the objects back to Bodies for a body Mirror or
Pattern and restores the Mirror's combine.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 15:38:41 +00:00
Jasper van Loenen a8e5bfca73 Fix BOM table being too narrow (#8113) (#8144)
* claim: 8113

* Bill of Materials: size columns to their contents, Description fills the rest (#8113)
2026-10-28 15:28:55 +00:00
raykholo b68d42e5ec Theme: paste a theme into the Import tile (#8226) (#8227)
* claim: #8226

* Theme: paste a theme into the Import tile (#8226)

Ctrl/Cmd+V on Preferences > Interface imports a copied theme file, the tile's
menu offers Paste theme, and a theme file dropped on the tile imports instead
of opening as a design. Pasted text that is not a theme keeps its usual
meaning. The tile reads "file or paste".

* Theme paste: the new strings in Arabic, Hebrew and Vietnamese too (#8226)
2026-10-28 15:28:48 +00:00
github-actions[bot] 1ca2c5299c Regenerate generated files and fold pending files [skip ci] 2026-10-28 17:46:27 +00:00
github-actions[bot] 7d652d0e1f Regenerate generated files and fold pending files [skip ci] 2026-10-28 17:39:36 +00:00
semmelrinde 5f98396460 Dutch: translate the Render workspace's 733 messages (batch 2) (#8283)
* Dutch: translate the Render workspace's 706 messages (batch 2)

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

* Dutch: the 27 Render messages main gained since (the Appearances panel)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 15:28:41 +00:00
Badger B bffa846a55 Add German translations, batch 3 (#7237) (#8285)
* Add German translations, batch 3 (#7237)

* Use "Ansatz" for boss in German (#7237)

* Fix article for "Ansatz" (#7237)
2026-10-28 15:28:35 +00:00
semmelrinde 62da9deb29 Hungarian: translate the Render workspace's 733 messages (batch 2) (#8286)
* Hungarian: translate the Render workspace's 706 messages (batch 2)

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

* Hungarian: the 27 Render messages main gained since (the Appearances panel)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 15:28:28 +00:00
semmelrinde d17bc410a9 Galician: translate the Render workspace's 733 messages (batch 2) (#8292)
* Galician: translate the Render workspace's 706 messages (batch 2)

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

* Galician: the 27 Render messages main gained since (the Appearances panel)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 15:28:21 +00:00
semmelrinde fe210ece6d Malay: translate the Render workspace's 733 messages (batch 2) (#8299)
* Malay: translate the Render workspace's 706 messages (batch 2)

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

* Malay: the 27 Render messages main gained since (the Appearances panel)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 15:28:06 +00:00
semmelrinde e8d214716d Norwegian Bokmål: translate the Render workspace's 733 messages (batch 2) (#8300)
* Norwegian Bokmål: translate the Render workspace's 706 messages (batch 2)

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

* Norwegian Bokmål: the 27 Render messages main gained since (the Appearances panel)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 15:27:59 +00:00
P'tite Tête a4d1b5b6a9 Viewport: fewer ambient occlusion samples while the view moves (#7165) (#7214)
* feat: Fewer ambient occlusion samples while the view moves (#7165)

- Draw AO with 8 samples while the view moves, 32 once it has held still for 0.25 s
- Share the settle tracking with adaptive supersampling and repaint when it settles
- ui.screenshot waits for the full-quality frame (at most half a second)
- Test the sample choice, the uniform slot, and ghosting at both counts

* fix: Set the AO sample count in the scale test frame

- scale_tests.rs, new on main, builds a gpu::Frame without ao_samples
- Use AO_SAMPLES, as the other still-frame builders do

---------

Co-authored-by: P'tite Tête <asso@ptitetete.com>
2026-10-28 15:05:34 +00:00
semmelrinde 8589cdfb84 Ukrainian: translate the catalog entries that held English, and the missing ones (#8073)
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 15:05:02 +00:00
dudedagger42 4a34a258e1 Logo: an option to draw it in the theme's accent (#8133)
* Logo: an option to draw it in the theme's accent

Preferences > Interface > Logo: Theme's colours (the default, as before) or
Accent, three shades of the theme's accent. ui.prefs logo_accent.

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

* Logo: US spelling for the option's label, as upstream's English catalog uses

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

* Translations for the new options in the five complete catalogs (ar, he, ja, vi, zh)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 15:04:53 +00:00
Herperlerpicus 3a951bc8c3 Sketch: dimension new shapes as drawn (squashed onto current main) (#5987)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 14:57:54 +00:00
HiroKenEz 4fa051c9fc View angles: an optional readout under the orientation gizmo and a command to set them (#6032) (#6249)
* claim: #6032

* View angles: an optional readout under the orientation gizmo and a command to set them (#6032)

View > View angles (also Preferences > Graphics, or view.angle_readout) shows the
view's pitch, yaw and roll in degrees and Ortho/Persp under the axis view or cube,
updated live; off by default. Clicking the readout opens a small card to type
exact angles, which runs view.apply.

The angles are the camera's own (Camera::view_angles / with_view_angles), about
the up axis: yaw is the heading of the line of sight (Front 0, Left 90, Right -90,
Back 180), pitch the eye's elevation (Top 90, Bottom -90), and a positive roll
turns the model clockwise. view.apply documents this convention and now refuses a
pitch past +/-pi/2.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 14:49:47 +00:00
Homero Thompson 31d6873f7d OBJ export: the body colours travel with the file (#4451) (#6693)
* claim: #4451

* fix(#4451): the body colours travel with the OBJ file

An OBJ export left the colours of the design behind: the mesh file named no
material, so every body arrived in the reader's default grey. The export now
writes a material library (.mtl) next to the mesh file, with a material per
body, and the mesh file names that library with mtllib and picks the material
of each body with usemtl. Every path is checked before anything is written,
the library goes first and the mesh file last, so a mesh file never names a
library that is not there. Only the colour of the body travels: the colours of
single faces and the transparency of a body stay out, as a face colour needs
material groups inside one object. The export that writes one file stays
material-free, so it never names a library it does not write.
2026-10-28 14:44:32 +00:00
github-actions[bot] 545232be37 Regenerate generated files and fold pending files [skip ci] 2026-10-28 17:08:18 +00:00
Nathan Dane c1dee61482 Render shots: strip with thumbnails, Update and Revert for camera changes (#6430) 2026-10-28 12:59:40 -04:00
github-actions[bot] ad182057b9 Regenerate generated files and fold pending files [skip ci] 2026-10-28 16:53:13 +00:00
github-actions[bot] bfe7792592 Regenerate generated files and fold pending files [skip ci] 2026-10-28 16:44:05 +00:00
semmelrinde be2deb8c6f Create Sketch: a face of a combined Mirror's copy keeps its own attachment (#5779) (#8278)
A mirror's or pattern's copy of a face carries its seed's construction certificate, and on a
combined body that certificate finds the seed's face. Capturing an attachment never checked
that the certificate finds the picked face, so a sketch on the mirrored half sat on the
original. The capture now falls back to the face's exact history name when the certificate
names another face, as docs/face-attachments.md describes.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 14:44:32 +00:00
PaddleStroke cecb1d8b66 Naming: a long history of one body no longer overflows the stack on reorder (#7587) (#8159)
* claim: #7587

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

* Naming: name a long history of one body from the oldest up (#7587)

A body's face names are worked out from the names of the body it was made
from, recursively. After a few hundred features in a row whose names
nothing asked for, reordering or moving an earlier feature re-evaluated
them all and the first name lookup (a fillet at the end) recursed once per
feature, overflowing the 1 MB window-thread stack on Windows. Name every
16th earlier version oldest first so no lookup recurses through more than
16 of them; the names are the same.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 14:43:42 +00:00
semmelrinde 427bd730f0 Title bar: a longer version moves Report a bug left instead of covering it (#5621) (#8280)
The version right of Report a bug had a fixed 60 points, enough for v0.25.1 but not for a
release candidate such as v0.21.0-rc.1, whose text was painted over the button. The bar now
measures the version and reserves its width plus a gap, never less than before. The extra
width comes only from the document tabs' spare room: where they would keep less than one tab,
the version is hidden, as on a narrow bar.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 14:33:25 +00:00
PaddleStroke 10b8faeba3 Windows: an export over a file another program holds briefly waits for it, up to 2 s (#8104) (#8125)
* claim: #8104

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

* Windows: an export over a file another program holds briefly waits for it, up to 2 s (#8104)

write_atomic replaces the target with one rename. Windows refuses that rename
(os error 5 or 32) while another program, such as an antivirus or indexer
scanning a file written a moment earlier, has the file open without delete
sharing. The retry gave up after 127 ms of waiting, which a scan under load
outlasts, so render.export and library.export over an existing file failed
with "Access is denied".

The retry now keeps doubling its wait up to 250 ms, about 2 s in all. If the
file is still held after that, the error says the file is in use by another
program, followed by the system's own message. The old file stays whole
throughout, as before.

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

* Saving over a read-only file fails at once, and says why Windows refused

Access denied (os error 5) is also what Windows gives for a read-only or
ACL-denied target, which waiting does not fix. A read-only target is now
refused without the 2 s wait, with "the file is read-only". Any other refusal
that outlasts the wait says another program may have the file open or it is
read-only, followed by the system's own message.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 14:32:20 +00:00
Dane Doubell 649ab0ef91 Sketch dimension: a slot's two caps measure the centre-to-centre distance (#7464) (#7488)
* Sketch dimension: a slot's two caps measure the overall length (#7464)

* Sketch dimension: slot caps need tangent sides, not just shared lines (#7464)

* Sketch dimension: slot caps measure the centre-to-centre distance (#7464)

---------

Co-authored-by: Dane Doubell <2853036+danedoubell@users.noreply.github.com>
2026-10-28 14:23:48 +00:00
PaddleStroke 8be7bb0201 Insert design: file names get the dialog's width (#8097) (#8122)
* claim: #8097

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

* Insert design: file names get the width the size and actions leave (#8097)

The web app's "Files in this browser" window listed stored files in an egui
Grid sized from cell content: the wide action buttons took most of the fixed
window width, the name and size columns were cut, and the rest was empty.

Add ui_kit::name_list on the kit's table grid: the name column takes the
width the other columns leave, the other columns are as wide as their widest
cell (measured as drawn, a changed measurement discards the frame), and a
name is cut with an ellipsis and its full text on hover only when it does
not fit. The window is a little wider (still capped at the browser width).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 14:23:31 +00:00
Lars Jansen 67d5051792 Ribbon: keep the SKETCH tab shown outside a sketch (#7904) (#7906)
* claim: #7904

* feat(ui): keep the Sketch tab shown outside a sketch; its drawing tools start one (#7904)
2026-10-28 14:14:08 +00:00
Homero Thompson 7e8f87dc57 Extrude: a face shared by two bodies is resolved in the active occurrence (#419) (#6469)
* claim: #419

* fix(#419): a face shared by two bodies is resolved in the active occurrence
2026-10-28 14:13:45 +00:00
Colin Faulkingham 84016f036e Redefine: the reply reports the features it rebuilt (#5694) (#8244)
* claim: #5694

* Redefine: the reply reports the features it rebuilt (#5694)

timeline.redefine builds the edited feature and the features after it in its trial session;
the session's own evaluation then only takes those results from what the trial kept, so the
reply reported that second evaluation and read as "nothing was rebuilt" although the geometry
changed. Report the trial's rebuild, plus anything the session still had to compute itself, as
timeline.edit reports the same change; `restored` keeps counting only genuine reuse of earlier
results.

Tests: redefining_reports_the_features_it_rebuilt fails before the change with
{"feature": 2, "recomputed": 0, "restored": 2} and passes after it;
redefining_computes_the_later_features_once still guards the single computation (the reply
doubles if the kept results are lost).
2026-10-28 14:02:44 +00:00
raykholo 5f90d06452 Preferences: reverse vertical pan direction (#5240) (#5454)
* claim: #5240

* Preferences: reverse vertical pan direction (#5240)

Add "Reverse vertical pan direction" beside the horizontal setting in
Preferences > Navigation. pan_reverse_y defaults off, so older settings
files load unchanged, and flips the vertical part of two-finger pan on
every canvas through navigation::handle, independent of the horizontal
setting. Labels in en, ja, zh, de, and ko.

* Reverse vertical pan: strings as a pending locale file, with Arabic, Hebrew and Vietnamese

* Control protocol: pan_reverse_y in the sorted ui.prefs list (#5240)
2026-10-28 13:59:38 +00:00
Colin Faulkingham 27772ddba1 Keep command dialogs above Browser and History (#8174) (#8203)
* claim: #8174

* fix(ui): keep command dialogs above workspace panels (#8174)
2026-10-28 13:59:28 +00:00
semmelrinde e1b49238ba Italian: translate the Render workspace's 733 messages (batch 2) (#8250)
* Italian: translate the Render workspace's 706 messages (batch 2)

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

* Italian: the 27 Render messages main gained since (the Appearances panel)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 13:58:52 +00:00
github-actions[bot] ba35509ca0 Regenerate generated files and fold pending files [skip ci] 2026-10-28 16:12:44 +00:00
github-actions[bot] 32f11961e9 Regenerate generated files and fold pending files [skip ci] 2026-10-28 16:06:14 +00:00
semmelrinde 4dda001210 Traditional Chinese: translate the Render workspace's 706 messages (batch 2) (#8272)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 13:58:23 +00:00
semmelrinde f49d5c2f29 Indonesian: translate the Render workspace's 706 messages (batch 2) (#8271)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 13:58:13 +00:00
github-actions[bot] afde0f8073 Regenerate generated files and fold pending files [skip ci] 2026-10-28 15:32:53 +00:00
Nathan Dane 2897d3b22e Render library: Appearances card with a sphere grid, scopes, favourites and recents (#6429) 2026-10-28 13:41:24 +00:00
bherbruck 9bcb2ffe68 Drawings: first landing from the drawings branch (#8249)
* Drawing sheets: sizes, the ISO 5457 border and its marks (#3875) (#5108)

* Drawing sheets: sizes and the ISO 5457 border (#3875)

A sheet is a paper size and an orientation: ISO A0 to A5, Letter, Legal,
ANSI A to F, architectural A to E1, or a custom size (finite, above 0 and
at most 5,000 mm a side), landscape by default and either way for every
size.

A border profile is the rule for its border, and layout() works it out
for a sheet: the frame inside the margins, and the reference fields with
their labels. The ISO profile has a 20 mm filing margin on the left and
10 mm elsewhere, a 0.7 mm frame, fields of 50 mm out from the centre
lines with the corner fields taking what's left, rows lettered from the
top without I and O (AA, AB and on past Z), columns numbered from the
left, and an A4 sheet labelled on its top and right only. A profile can
divide the frame equally instead, or have no grid.

The records sit in geom, next to the section records, so saving,
rendering and export can all use them. Nothing is saved or drawn yet:
there is no command and no format change. Margins that leave no drawing
space, values that aren't finite, fields outside 1 to 1000 mm, more than
200 equal fields and unknown JSON fields are refused.

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

* Drawing sheets: ISO 5457's centring and trimming marks (#3875)

A drawing border profile now also holds the grid reference border and the
centring and trimming marks, either of which can be left out. The ISO
profile follows ISO 5457:1999 (read in its identical adoption,
IS 10711:2001):
- the grid reference border 5 mm outside the drawing border on every
  side, with the field labels between the two;
- four centring marks on the sheet's centre lines, from the grid
  reference border to 10 mm inside the drawing border;
- at each corner a trimming mark of two overlapping 10 x 5 mm
  rectangles, in the margin outside the grid reference border.

The labelled sides are now a rule worked out for each sheet: ISO 5457's
(the top and right of A4, all four sides of the others) or fixed sides.
So one profile labels every size correctly. validate() checks what
doesn't depend on the sheet, for when a profile is read; layout() calls
it, then checks the rest against the sheet.

Refused:
- a grid reference border that isn't finite, is negative or is wider
  than a margin;
- a centring mark that isn't above 0, would reach past the sheet's
  centre lines, or is on a drawing border those lines miss;
- a trimming mark that isn't above 0, is wider than it is long, is
  wider than the margin left outside the grid reference border, or is
  longer than half the sheet's shorter side.

The grid rule's JSON is now externally tagged ("none",
{"from_centre": {"field_mm": 50}}), as the sheet size is, so an unknown
field is refused in every form; the internally tagged "none" ignored
one. Nothing is saved yet, so the shape change costs nothing.

The names follow the glossary, and ISO 5457's "drawing sheets": the
module is drawing_sheet (was sheet, which means sheet metal elsewhere
here), DrawingBorderProfile and SheetLayout (were BorderProfile and
BorderLayout), sheet_border and drawing_border (were paper and frame),
and Sheet::size_mm (was paper_mm). The first part of S1 is still a draft,
so the renames are made here rather than in a commit of their own.

The extra centring marks ISO 5457 asks for on sheets larger than A0
aren't here yet. Still nothing is saved or drawn.

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

* Drawing sheets: a lighter built-in border, the filing margin a choice (#3875)

Cameron's decisions after seeing the sheet in the app, 2026-10-23:
- No trimming marks in the built-in profile: the filled corner blocks
  take a lot of ink. trimming_mark_mm stays, so a profile can still ask
  for them.
- Centring marks stop at the drawing border: from the grid reference
  border to the drawing border, never into the drawing area (on A4 they
  crossed the tables). centring_mark_mm keeps its meaning, how far a
  mark reaches inside the drawing border, and now takes 0, the built-in
  value, as grid_border_mm already takes 0 for "on the drawing border".
  A mark of no length (a reach of 0 with no grid reference border) is
  refused.
- Margins of 10 mm all round. ISO 5457's 20 mm filing margin on the
  left, for drawings that get bound, is a named choice: filing_margin,
  off when not given. On, it stands in for margins.left_mm, which is
  kept, and still checked, for when it's off again. The 20 mm is
  FILING_MARGIN_MM, and validate() and layout() both take the margins
  from one helper, laid_out_margins().

A3 landscape is now 400 x 277 at (10, 10), with eight 50 mm columns.
With the filing margin it is 390 x 277 at (20, 10) again, the same as a
20 mm left margin set by hand. A profile with all three choices on
(ISO 5457's full border) lays out exactly as the built-in profile did
before, and the tests keep the old hand values for it.

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

---------

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

* Drawing W23a workshop plate oracle fixtures (#4950) (#4962)

* test(oracle): reviewer-aligned parameterized workshop plate fixtures (#4950)

Independent analytic W23 expected values and seven verified models,
including four R10 corners and the 2 mm tangent top chamfer.
Document accepted Drawing states and commit regenerated docs/oracle.md
from successful workflow run #29 (90/90 pass).

* fix(oracle): correct W23 chamfer centroid and clarify drawing session ownership

* Paper scene: plain records of what is drawn on a sheet (#4890 A1) (#5513)

geom::paper holds what a sheet shows as plain data: groups of strokes and fills, each group tagged
with the object that drew it (an opaque id) and its kind, with one validation pass over a bounded
record count. Seg2's checks and the coordinate rule move into profile.rs so section and paper share
them, and drawing_sheet takes its side and line limits from paper, so each limit is written once.

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

* Drawing sheets: centring marks across the whole margin (#3875) (#5554)

The built-in sheet's centring marks now run from the trimmed sheet's edge to the drawing border.
From the grid reference border, as before, a mark was the same length as a grid field mark, and on
A3 it sat on one (the sheet's centre falls on a field edge), so it no longer read as a mark once the
grid reference border's outline is left out (Cameron, after the prototype on #3875).

- DrawingBorderProfile.centring_mark_start: sheet_edge (the built-in's, and when not given) or
  grid_border (ISO 5457's figure 5); ISO's full border in the tests asks for grid_border.
- A mark still needs a length: one that stops on the drawing border needs room outside it on the
  side it starts from, every margin for sheet_edge, the grid reference border for grid_border.

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

* Drawing sheets: no centring or trimming mark longer than the largest sheet's side (#3875) (#6410)

A border profile capped every length at 5000 mm (the largest sheet's side) but two: a
centring mark's reach inside the drawing border and a trimming mark's length. A file could
give either a huge whole number, such as 18446744073709551615. The read's number check passes
whole numbers, so the profile took it as 1.8e19 mm, and saving wrote it as a decimal the next
read refused: a drawing that saved but didn't open again. K2's fuzz test of drawing files
(#3890) found it.

Both now take the cap the margins have, `MAX_SIDE_MM`, in `validate`, so the file is refused
when it's read. Layout's own checks against the sheet are unchanged, so a 5000 mm mark still
validates and is refused only on a sheet too small for it.

Tests: the hostile-profile test refuses 5000.000001 mm and 1.8e19 mm for each mark, and takes
5000 mm on its own; 4 planted faults (each cap removed or made one short) are all caught.

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

* Bill of materials types: phantom, reference, purchased and inseparable (#3874) (#5107)

* Bill of materials types: phantom, reference, purchased and inseparable (#3874)

A component's bill of materials type is a part property (`bom_type` in its metadata, set with
component.metadata.set and read by component.metadata, as part_number is), so older releases
keep it and no format change is needed. It isn't a design property: the Design Properties
dialog doesn't list it and document.metadata.set refuses it. A word this build doesn't know
reads as normal and is kept.

assembly.bom counts by type: a phantom has no row and what it holds counts where it is placed,
in the same rows as the same parts there; a reference counts nowhere, and nothing inside it
does; purchased and inseparable components are one row, and in parts only an inseparable
component's purchased parts are listed too. Rows of one part number but different types are
different rows. Rows and children carry bom_type. With no types set the rows are as before.

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

* Glossary: bill of materials and bill of materials type (#3874)

The bill of materials types are a new idea, so the glossary gets their
term, with the five types, and "normal" gets its fourth meaning in the
table of words with more than one meaning.

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

* Bill of materials types: a plain line for each (#3874)

The guide's table and the glossary entry say what each type is for in
plain words: something you make, something just to group, something to
use as a reference, something you buy, something made as one piece.
The names stay the five standard ones.

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

* Bill of materials: kits fold together only when their contents' types match (#3874)

With group_children, two kits with one part number share a row when what they hold reads the
same. That reading (kit_key) compared each child by part number and count, but not by its bill
of materials type, though rows themselves are told apart by type. So a kit holding a made bolt
and a kit holding a bought one, both KIT, folded into one KIT x2 row listing the first kit's
bolt twice: the other type was lost, and which one depended on creation order. 0xlillith found
it in review of #5107.

kit_key now includes each child's type. Equal part number, type and contents still fold.

Test: two KIT kits each holding a BOLT fold into one row of two; once one bolt is purchased they
are two rows, each listing its own bolt. Planted fault (the type left out of the key): caught.

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

---------

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

* Drawing files: a drawing saved as a container or as plain JSON, never read as a design (#3890) (#7098)

* File format families: drawings share the design format's versioning (#3890)

The format code works for a family of files: its own tag, its own numbers and
its own folder of change files, with one reading of a file's format, one
refusal of a file a newer build saved and one upgrade for every family. Designs
(solidcraft/N) are one family and keep everything they had: FORMAT and FORMATS
are as they were, and pending_names, format_string, version_of and read mean
what they did. Drawings (solidcraft-drawing/N) are the other, with format 1 as
their first record and each later change one file in
crates/doc/src/drawing/format/pending/, as design changes are. Nothing saved
changes.

A design tag never reads as a drawing's or the other way round, and a file that
names no format is a design from before format strings, never a drawing. Every
refusal names its family; a design's read word for word as before. A design
listing a pending change this build doesn't have is now refused as a newer
build's before its content is checked, as a newer number already was, so a
newer build's fields never read as damage. The list reader is
take_names(v, key, noun), the shape #4811 uses for its own list.

For the maintainer, the release step changes: scripts/fold-format-pending.sh
now folds every family that crates/doc/build.rs lists (FAMILIES), each into
its own next number: designs into crates/doc/src/format/v<N>/ as before,
drawings into crates/doc/src/drawing/format/v<N>/ from 2. With nothing pending
in a family it says so and moves nothing. Its test folds a drawing change into
v2 beside the design changes. docs/releasing.md, CONTRIBUTING.md and AGENTS.md say so.

build.rs lists each family's changes from its own folder, each folder's
versions contiguous from its own base.

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

* Drawing record: format 1 of the drawing family (#3890)

A drawing is now a record of its own (crates/doc/src/drawing.rs): its name,
the designs it draws (sources: an id and the design file's path, relative to
the drawing's folder as a linked design's is), its properties under the design
metadata's rules, its units (each design's own, or one length unit of the
expression unit table), its content language (a BCP 47 tag) and its sheets.
A sheet holds its paper, a copy of its drawing border's profile, the
resolution its pictures are rendered at (300 dpi unless it says, 25 to 2400),
the component it documents and whether it is left out of the sheet count or
of printing. This is format 1 of the drawing family; each later kind of
record adds one file in crates/doc/src/drawing/format/pending/.

The rules: one id counter for every object, raised above the largest id when
a file is read and never rewound; every reference is an id that must resolve,
or the file is damaged and refused, never patched; 1 to 1,000 sheets, at most
64 sources, names of 1 to 256 bytes, paths of 1 to 4,096 bytes without NUL.
The same drawing always saves as the same bytes.

Drawing::from_json reads at most 16 MiB of text, then runs the family's format
check, the number check, the upgrade, the typed record (unknown fields refused
everywhere) and Drawing::validate. The number check is now one function every
family calls, Family::check_numbers, told which field holds other tools' data;
a design's refusals read as before.

Nothing saves or opens a drawing yet: the container is the next step.

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

* Drawing ids: random and collision-free from format 1 (#3890)

A drawing's objects now take random ids, the form jyviko chose on #3890 from
#1017: no counter, so objects added on two branches never share an id and
never edit one line. The allocator is one module for drawings now and designs
later (#626 Phase 3), crates/doc/src/ids.rs: an IdSource gives nonzero u64s by
splitmix64 from a seed, each checked against the ids in use and drawn again
when it is one, or when it is 0. A run never draws a number twice, so at most
one draw per id in use comes before a free one, and a seeded run over a file
the same seed made still gets a new id. IdSource::seeded gives the same ids
for tests and replays; IdSource::from_entropy seeds a session from the
standard library's hash keys, the time and a count, with no new dependency.

In a file an id is 13 characters of Crockford base32, lowercase
(0000000000001 to fzzzzzzzzzzzz): ids::encode writes it and ids::decode reads
only that spelling, case-sensitive, so an id has one form to search for.
Anything else, 0 included, makes the drawing damaged. The drawing loses
next_id (a file that holds one is refused), and Drawing::new and allocate_id
take the IdSource. Messages name ids as the file writes them. The component a
sheet documents stays its design's own number until designs move over.

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

* Drawing record: a drawing too large to reopen isn't saved (#3890)

from_json reads at most MAX_DRAWING_BYTES (16 MiB), but to_json had no limit. Other tools'
properties are kept as they came, and indenting them can grow a drawing read in at about 4 MB
past 16 MiB (two million zeros in one array), so the next read refused what the save had just
written. 0xlillith found it in review of #3891.

to_json now returns a Result and refuses JSON longer than a read takes ("drawing too large to
save (at most 16 MiB)"), so every drawing saved opens again. Nothing writes drawing files yet, so
the error reaches the future file writer before it publishes anything.

Test: that case is refused, and a smaller compact drawing saves and reopens unchanged.

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

* Drawing files: a drawing saved as a container or as plain JSON, never read as a design (#3890)

K3 of the drawing storage plan (W11.2): the file a drawing is saved in. Nothing writes one from a
command yet; opening and saving come with K5.

- A drawing container is a design container holding `drawing.json` (Deflate, as `document.json`)
  in place of `document.json`, and `container.json` says so:
  {"content": "drawing", "format": "solidcraft-container/1"}. A design's `container.json` is as it
  was, with no `content`. The plain JSON form is the drawing's own text. Both give the same bytes
  every save, so a drawing can live in version control as text.
- `io::file_kind` tells a design from a drawing by `container.json` or by the JSON's format. The
  design reader refuses a drawing ("this file is a SolidCraft drawing, not a design: open it as a
  drawing") and the drawing reader a design; plain JSON only asks when it isn't a design, so
  reading a design costs nothing more. A container's content this build doesn't know, or a drawing
  container that also holds a design's `document.json`, is refused.
- `read_drawing` (bytes, either form), `read_drawing_file` (one seekable handle, the design
  reader's checks) and `write_drawing_container`; `DRAWING_EXT` (`scdwg`) and
  `is_drawing_path` beside the design's.
- The container writer's set-up and finish, the file opening and the container sniffing move into
  one function each, which designs and drawings both use. The container's own checks (budgets,
  entries, the ZIP directory) are the design container's, word for word.

Tests: a drawing round-trips through both forms byte for byte, and the container holds exactly
`container.json` and `drawing.json`; `file_kind` on both forms of both kinds; each reader refuses
the other's file; unknown content, a stray `document.json`, a missing drawing, an unknown
container version and a damaged drawing refused; both forms from disk, and a directory refused;
mutation fuzz of both forms (an error or a drawing, never a panic).

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

---------

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

* Paper scene: SVG of a sheet, its lines, arcs, cubics and fills (#4890 A3a) (#7032)

The first export of a paper scene, as SVG first (jyviko and 0xlillith on #4890), from the research
in the plan's §16. A3b adds ellipses, hyperbolas and Béziers above degree 3, which this refuses as
"not supported yet".

- `geom::paper::Prepared`: writers read a scene through `Scene::prepare`, which checks it first, so
  none reads one unchecked. Clipping goes there too, once (A8).
- `geom::paper::arc_pieces`: an arc's ends in pieces of at most a quarter turn, the last its own end.
  A half turn drawn from its ends is ill-conditioned: rounded to the nanometre (six decimals of a millimetre) its
  centre moves up to 1e-2 mm, where a quarter turn's stays under 1e-6 mm.
- `io::paper::paper_svg`: one user unit a millimetre, the size in mm on the root; y flipped by
  writing the height less y, so the file's numbers are page coordinates and an anticlockwise arc
  has sweep flag 0; lines `L`, arcs `A` (never large-arc), cubics `C`, parabolas `Q`; fills
  black and even-odd; one path per item; groups as `data-object` and `data-kind` (object ids can't
  be XML ids). No background, so cutters see only the marks.
- Numbers are whole nanometres (six decimals of a millimetre): no exponent, no "-0", the same bytes every time. They are the
  writer's own state too, so a joint is written once and a closed path ends exactly on its first
  point. Numbers are written straight onto the document, with no text made per number. A width
  under half a nanometre is written as one, and a piece too short for the grid is merged into the
  next, both counted in the report; a path's last piece has no next, so it is kept however short
  (unless it has no length). Output over 64 MiB is refused.

Tests: the A3 drawing border's whole document; quarter, 270°, clockwise and whole-turn arcs, and a
circle of two halves giving the same four quarters; the quarter-turn boundary both sides; cubics and
parabolas, and the conics refused; the number format; a closed path whose start and end round
apart; a sliver merged; an open path's short last line and arc kept, and one of no length dropped; read back through our own SVG reader (lines exactly, the arc as an arc);
the scene's JSON mutated into the writer (never a panic).

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

* Drawing W17a: view definitions as data (#4816) (#4817)

* claim: #4816

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

* Drawing views as data: named views, scales and the map onto paper (#4816, W17a P1)

What a drawing view is, before anything draws it (geom::drawing_view):
- NamedView: the six principal views and the isometric views from the four upper corners, each
  an exact basis (a Plane whose normal points back at the viewer), never taken from a camera,
  whose pitch clamp would tilt a top view, with no -0 to write out; `iso` reads as the
  front-right one.
- basis_looking: any other view from its direction, an up and an origin (paper x = direction x up,
  paper y = x x direction), refusing a zero direction, an up along it, or an origin that isn't
  finite or within 1e12 mm. W07's
  {direction, up, origin} view reads through it.
- Scale: paper : model as two positive numbers, read and written as "1:2", from 1:100000 to
  1000:1; Scale::fit picks the largest ISO 5455 preset (50:1 to 1:10000) at which a view fits a
  space (W07's "fits the page" default), compared as size x paper <= room x model so an exact
  fit at a reduction isn't lost to rounding.
- ViewFrame: a view's basis, scale and place on paper, the point of its plane at `centre`
  landing at `position_mm`. Its paper map moves the view's curves and their accuracy onto
  paper (paper error = scale x model error); `boundary` maps a box of its plane onto paper from
  its four corners.

In geom, where Seg2 lives:
- Similarity2 and Seg2::similar (new): scale, turn and shift a segment exactly, an arc staying an
  arc. Similarity2::new refuses a scale that isn't above 0 and works the turn out once, not per
  point;
- Seg2::map_controls: a segment's control points mapped by any function. It was doc's
  sheet::map_controls; its four callers in doc use the method now, unchanged.

CurveAccuracy::scaled gives a curve's accuracy at another scale.

Tests: each named view's basis and viewer side, and the same bases from a direction and up; a 50 mm cube in every principal view at 1:1 and
1:2, and in isometric (edges 50 x sqrt(2/3), outline 50 x sqrt(2)); the view's centre on its
position (W07's 100 x 20 front view on A3 at 2:1); scales read, written and refused; the fitted
scale, an exact fit at 1:5 and ISO's smallest; no -0 in any named view's axes; an origin refused by
its own message; hostile bases, centres and positions; every segment kind moved by a similarity,
and the similarities refused; scaled accuracy.

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

* Drawing views: the bottom view pinned, square near parallel, bounded scales and boxes (#4816)

From DevonHope's review of #4817:
- The tests pin all six principal views exactly. The direction-and-up test uses literal ups,
  so a bottom view turned half round now fails it.
- basis_looking squares x to the direction before normalising. Near the 1e-9 threshold the
  normal was up to 8.8e-9 rad off; now it is about 1e-16.
- Scale::fit fits nothing for a size or room that is negative or not finite. A size of 0 still
  sets no limit that way.
- Each side of a scale is 1e-6 to 1e6, so a scale writes at most 49 characters.
- ViewFrame::boundary returns a Result. It refuses a box that is inside out or not finite on
  paper, instead of dropping a corner.
- paper_map, boundary and seg_to_paper say which coordinates they take.

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

---------

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

* Drawing W18 S2: title blocks as data (#3875) (#3877)

* Title blocks: what a cell shows and where its text goes (#3875)

geom::cell holds a title-block cell's content: parts of text and fields
(component, drawing, sheet, revision or prompt, by key, with an optional
unit and decimals kept as written for the resolver to check), at most 64
parts and 4096 bytes of text, with no control character but a line break.
Beside it are a resolved value (live, overridden and drifted, or missing,
with "-" for each part that couldn't be read and why), an override with
the value it replaced, and the projection a drawing's views use (first or
third angle), which the title block's symbol will read.

A fit warns and never cuts: a count of characters, not bytes, with its own
count for text in Chinese, Japanese, Korean or Yi characters.

The cell text rule places a label and a value: the label's baseline 1 mm
plus its cap height below the cell's top, the value box under it inset by
1 mm, one line centred in the box, and wrapped or several lines down from
its top at the font's pitch, with a warning when the last falls below the
box. Wrapping by width, width warnings and glyphs a font lacks need the
text measured, so they wait for the paper scene's text.

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

* Title blocks: definitions and their place on a sheet (#3875)

geom::title_block holds a title block's definition: its size (1 to 1000
mm a side), its outline and cell line widths, and up to 64 cells. Each cell
has a snake-case name of its own, a rectangle measured from the block's
lower-right corner, an optional one-line label with its cap height, and a
value: text from cell content, with its cap height, alignment and fit, or
the projection symbol, which reads the drawing's projection and is never
fixed in the block.

validate() checks a definition on its own, as when one is read: cells
inside the block and not overlapping (touching is fine), label and value
cap heights that fit, and the content's own limits. layout() puts the
block on the drawing border's lower-right corner of a sheet, refusing one
that doesn't fit, and gives each cell's rectangle, its label's place and
its value box by the cell text rule. On A3 landscape a 180 x 54 mm block
runs from (230, 10) to (410, 64); it fits A5 landscape and every larger
sheet, but not A5 upright.

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

* Cell text: a value box lies within the paper's coordinates (#3875)

place_lines took any finite, ordered value box, but centring one far out (min x 1e308, max x
1.1e308) overflows: the anchor came back infinite, with no warning. 0xlillith found it in review
of #3877.

The paper scene already bounds every coordinate it holds by MAX_COORDINATE_MM (twenty times
the longest sheet side). That limit is now public, and place_lines keeps its box inside it, so
its anchor is always finite: one rule for what a paper coordinate may be.

Tests: the hostile-box test refuses both overflow cases from the review and a box just past the
limit on each side, and places lines in a box the size of the whole paper limit. The fuzz test's
numbers now include values past the limit (1.1e5, ±1e308).

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

* Title blocks: padding from the lines' inner edges, one label rule, nothing unread (#3875)

A cell's text is padded from the inner edge of its lines: each side of a cell moves in by half
the line drawn on it (the outline's on the block's edges, a cell line's inside), so a wide
outline never covers the text. Before, the padding ran from the line's centre.

The label's one-line rule lives with the cell text rules (`cell::check_label`), where parts
lists will find it. The cell types nothing read yet (`Override`, `Resolved`, `State`,
`Problem`, `Reason` and `MISSING`) wait for the resolver that will read them.

The tests check the exact thresholds inside the lines (a label that leaves no room, a value
that just fits), pick each of the lines, alignment and wrapping on their own so every
combination comes up, and lay fuzzed blocks out on every standard sheet, either way, with and
without the filing margin.

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

* Title blocks: the drawing border's line, descenders, room where the block sits (#3875)

From DevonHope's review of #3877:
- Each cell's lines are clamped to the inner edge of the drawing border's line. That line runs
  along the block's right and bottom edges, and along any other edge it reaches, as when the
  block fills the border. Aabb2::intersection, the 2D twin of Aabb3's, does the clamping.
- layout checks each cell's room again where the block sits (place_cells, which validate runs
  at the origin), so rounding at the real corner can't turn a cell inside out.
- The cell text rule counts descenders, at 3/10 of the cap height (ISO 3098's lettering B).
  They may reach into the padding under a value box, never past it to a line. place_lines
  warns Height when they would, and validate asks it whether one line fits.
- The module doc no longer links to a private function.

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

---------

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

* Drawing W06a: exact orthographic hidden lines for the analytic subset (#2455) (#2457)

* Drawing W06a: exact orthographic hidden lines for the analytic subset (#2455)

crates/geom/src/hlr (L0): prepare once per body, then hidden_lines for a
scene of placed bodies. Edges and face silhouettes are projected from their
exact curves, split where projections cross, and each piece is classified by
rays toward the viewer against the trimmed faces: visible, hidden or
explicitly uncertain, never guessed. Planes, cylinders, cones, spheres and
tori are supported whether stored as analytic records, revolutions or spline
patches; freeform faces are reported and make what they could hide
uncertain. Screen geometry is Seg2 with the W01a accuracy vocabulary: exact
lines and conic arcs where the projection is exact, bounded chords
otherwise. docs/drawing-hlr.md holds the contract and its limits.

* geom/hlr: handle the brep records main gained in #3093

Main's exact model storage (#3093) added Curve::SurfaceIntersection,
Surface::PlaneThrough and Surface::PlacedRevolution, and the HLR matches on
the records no longer compiled. A plane through three points is a plane: it
is trimmed and intersected as one, and a box stored that way draws the same
bits as one with axis-stored planes. The procedural intersection curve and
the placed revolution (whose affine placement may scale it unevenly) are
refused by prepare as not supported yet, until the kernel adapter handles
them. The records' positions move with the body's centre like the others.

* geom/hlr: pad plane faces' boxes, so a plane record off by rounding still hides

Plane faces took the box of their boundary chords unpadded, flat along the
normal. When the plane record and its edges disagree by rounding (a unit cube's
top plane one ulp above its edges, a 100 mm cube's 1e-11), the ray toward the
viewer reached the plane just outside that box, the slab test culled the hit,
and the back edges came out Visible with no doubt. Every face's box is now
padded by 1e-6 of its size, as curved faces' boxes already were. Reported by
DevonHope on #2457.

* geom/hlr: limits from review: trim samples, scene extent, crossing pairs, surface groups

From DevonHope's review of #2457:
- Options::trim_samples must be 1 to MAX_TRIM_SAMPLES (4096).
- Placed bodies must stay within MAX_COORDINATE (1e12 mm, as section and brep
  bound stored numbers). Placements 1e200 apart made the scene's size infinite
  and every tolerance taken from it with it, so everything came out Visible.
- The crossing search counts its candidate pairs before it starts and meets
  each pair only in the first grid cell the two segments share, instead of
  keeping a set of the pairs met, which could outgrow wasm's 4 GB before the
  500 million pair budget tripped. The same pairs in the same order.
- prepare groups faces on one quadric or torus by comparing each face with
  the faces of its kind whose size agrees to the tolerance (sorted), not with
  every face; at most 50 million comparisons. prepare_cancellable polls a
  cancel callback between edges, faces and comparisons.

* geom/hlr: boundary chords on a grid, and no fine pass over coincident curved chords

The cost items of DevonHope's review of #2457:
- Every ray hit asked how far it lies from its face's boundary by scanning all
  of the face's chords (4096 per circle; a plate's face with 256 holes has
  about a million). The chords are now bucketed on a uniform grid over the
  boundary's box, cells at least as long as the longest chord, and the test
  looks only at the cells around the point. It finds exactly the chords the
  scan finds.
- Chords of two curved curves that lie on each other end to end (aligned holes
  seen along their axes) were cut into 16 by 16 pieces and intersected, to
  find crossings that are only noise within the curves' tolerance. They are
  now skipped; visibility changes where a coincident run starts or ends, and
  chords that only partly overlap still find those.
- Fixtures: a plate with holes, coaxial cylinders seen along their axis, and
  300 placements.

* geom/hlr: pad boxes by the body's coordinates too; coincident chords only to rounding

Review of the previous commits found two holes:
- The padding grew with the face's size, but rounding grows with where the body
  was modelled: a 0.001 mm cube 1e7 mm out, its top plane one step (1.9e-9 mm)
  above its edges, still had its hit culled. The pad now adds 1e-12 of the
  body's offset.
- Two chords were taken for coincident when their ends lay within each other's
  sag, which in a coarse scene can hide real crossings: an arc can bulge across
  a line 1 mm off its chord twice. Only chords that coincide to rounding are
  skipped now; identical projections, the costly case, still are.

* geom/hlr: coincident curves meeting at shared sample points do not cross

Timing the review's scenes showed that skipping coincident chords was not
enough: chords of two coincident curves also touch end to end at every sample
point they share, and each touch was a crossing to refine and split at. 64
coaxial cylinders seen along their axis gave 8.3 million crossings (19 s), 64
cylinders one behind another along the line of sight 4.2 million (214 s;
before these commits the crossing budget refused it). A touch at a shared
sample point is now dropped when the two curves also coincide, to rounding, on
both sides of it. Where a coincident run starts or ends one side differs, and
the touch is kept.

* geom/hlr: the own-face margin allows for rounding where a body was modelled

The far 0.001 mm cube of the previous commit showed a second effect of records
rounded at large coordinates: its top plane, one ulp (1.9e-9 mm) above its
edges, hid the top edges themselves, because a ray leaving an edge met its own
face 3.2e-9 mm along, past the own-face margin of 1e-6 of the face (1.4e-9).
The margin now also covers 1e-14 of the body's offset (about 45 ulps of its
coordinates), which only matters for parts far smaller than their distance
from the origin.

* geom/hlr: no parity test for a hit on the boundary, where it is uncertain anyway

* docs/drawing-hlr: the fixtures of the review's second round

---------

Co-authored-by: MateuszSychXpl <151755914+MateuszSychXpl@users.noreply.github.com>

---------

Co-authored-by: CamWight <91156642+CamWight@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Benny1496 <163034806+Benny1496@users.noreply.github.com>
Co-authored-by: Mateusz Sych <151755914+SychPL@users.noreply.github.com>
Co-authored-by: MateuszSychXpl <151755914+MateuszSychXpl@users.noreply.github.com>
2026-10-28 13:31:16 +00:00
Ahmad Khan 6af571eb40 Flat Pattern: show the whole flat size (#7495) (#7496)
* claim: #7495

* Flat Pattern: show the whole flat size (#7495)

Each side of the flat size and the thickness get their own row (Flat size X, Flat size Y,
Thickness), so the panel's width no longer cuts the value off after its first side.

* Flat Pattern: the flat size labels in Arabic, Hebrew and Vietnamese (#7495)
2026-10-28 13:25:09 +00:00
ermanyop-stack a5f472c23e Prevent angular drags from collapsing construction geometry (#8148) (#8160)
* Reject newly collapsed angular reference lines (#8148)

Reject angular solutions that collapse a reference relative to its input length. Cover constrained drags and already-short references. Engine replay and full CI validation remain in progress.

* Cover live and non-live angular drags (#8148)

Adds a replay of #8148 for live and non-live drags. The integration test compiles locally, but the Windows GNU test executable exits with STATUS_ACCESS_VIOLATION before running tests, including with --list/--help. Kept in the draft PR pending validation on a working native runner.
2026-10-28 12:44:16 +00:00
semmelrinde 34efb90489 Romanian: translate 946 messages the catalog was missing (batch 1) (#8193)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:44:10 +00:00
Tarik Sijerčić f060702676 i18n: add Bosnian, Croatian and Serbian interface languages (#7813)
* i18n: add Bosnian (bs), Croatian (hr) and Serbian (sr) catalogs

Full translations of all English messages, the pending ones included,
kept in step with one shared terminology:

- Bosnian (Bosanski): ijekavian, Latin script.
- Croatian (Hrvatski): ijekavian, Latin script, Croatian standard terms
  (točka, kut, brid, ploha, značajka, spremi, izbornik, sučelje).
- Serbian (Српски): ekavian, Cyrillic script; brand and file names,
  acronyms, units, key names and placeholders stay in Latin.

The Browser panel is "Stablo modela" so it is never confused with a web
browser, and the History panel is the timeline ("Vremenska traka"), not
the undo history, per the glossary.

Registers the three catalogs, adds them to the new-language test, a
pending attribution file, and a terminology note in docs/localization.md.

* i18n(bs, hr, sr): translate the 195 messages added on main since the catalogs were made

Covers the navigation bar customisation, the render stage, the new
command help texts and the Assistant start-up preferences, so the three
catalogs again carry every English message.
2026-10-28 12:38:27 +00:00
Homero Thompson d6b1d03695 Browser: an exact body name wins over a case-only sketch match (#6967) (#7004)
* claim: #6967

* Browser: an exact body name wins over a case-only sketch match (#6967)
2026-10-28 12:22:27 +00:00
Mateusz Sych e181015fd1 MCP save: a folder the agent opened or saved a design in stays in scope (#7181) (#7216)
* claim: #7181

* MCP save: a folder the agent opened or saved a design in stays in scope (#7181)

The agent's default folders held the open design's folder only while that
design stayed open there, so after a new design, an opened STEP file or a
save-as elsewhere a save beside the earlier design was refused, while the
same save through the control port worked.

The MCP server now keeps, for the rest of its run, the folder of every
design file a call opened or saved (open, save, and doc.open, file.save,
file.save_as through execute or batch, path given in full) and, headless,
of every design open in its session. Such a call only succeeded because the
folder was already in scope, so naming a path adds nothing. The folders get
the open design folder's terms (inside home or temp, not hidden, hidden
entries unreachable), at most 16. Bridged, they go to the app as
design_folders, which it judges the same way.

The save tool description and docs/mcp.md say how to add a folder with
--allow-path.

* MCP scope: a design's folder is the folder its file resolves to (#7181)

A link in another folder to a design inside the agent's folders passes the
scope check, which follows it, but its own folder was what got remembered
(and, for the open design, what counted while it was open). Take the folder
of the file as the check resolves it.

* Control test: a hidden file the export format accepts (#7181)

---------

Co-authored-by: MateuszSychXpl <151755914+MateuszSychXpl@users.noreply.github.com>
2026-10-28 12:22:20 +00:00
Homero Thompson 387e86729c Sketch: the rounded rectangle completes from a vertex like the plain one (#7878) (#7943)
* claim: #7878

* Sketch: the rounded rectangle completes from a vertex start point (#7878)
2026-10-28 12:20:46 +00:00
Dahak Builds 64ff25f380 Fix sketch drag breaking midpoint-constrained line solver (#7635) (#7810)
* claim: #7635

* Keep the drag cursor on the manifold of an ill-conditioned sketch (#7635)

A construction line collapsed to zero length makes its angle row's entries
on that line ~1e7 while the same row's entries on the measured line are ~1.
Dragging an endpoint of the midpoint-pinned line then failed two ways: the
null-space rank analysis, which scaled rows only, hid the small (physically
meaningful) entries below its pivot tolerance and let the measured line
rotate freely; and the cursor's refinement, which demanded a TOL-tight
correction the ill-conditioned system cannot deliver, gave up entirely and
let the drag leave an unsolvable step placed - breaking the perpendicular
and angle constraints.

Equilibrate the null-space Jacobian on both sides (columns by their largest
entry, then rows) so one row's entries share a scale, and let the cursor
keep working from a pose the solver itself calls solved (residual below
ACCEPT) when refinement stalls above TOL, accepting corrected trials at the
same tolerance the final validation uses.

---------

Co-authored-by: Dahak Builds <dahakbuilds@users.noreply.github.com>
2026-10-28 12:20:40 +00:00
Jomguy2 1dba7f7f04 Agent chat: OpenCode Go preset with per-model endpoints (#6111)
* agent chat: OpenCode Go preset with per-model endpoints

One preset (https://opencode.ai/zen/go/v1) covers every Go model: each
model is routed to its endpoint family (chat completions, responses,
messages, Gemini), always with the x-opencode-session header Go
requires. Unknown models fall through protocols when Go names the
mismatch; the conversation and tools translate per protocol.

* agent chat: keep Go reasoning metadata, route all listed models (#5656)

* fix agent chat Go endpoints to documented inference routes

* style: collapse long assert in Go routing test (cargo fmt)

* refactor: share Go tool/args helpers, table-driven protocol routing (no behavior change)

* refactor: share GoCallAccum across Go reply parsers (no behavior change)

* refactor: share part/call extractors across Go history converters (no behavior change)

* refactor: share post_to envelope between post and go_post (no behavior change)

* refactor: pass system note into go_responses_input, drop history clone (no behavior change)

* refactor: lazy Gemini name map, shared go_tools, named tool-call cap (no behavior change)

* refactor: share shaped-tool wrapper across Go tool converters (no behavior change)

* fix: set request_timeout_s in Go test setups for rebased main (no behavior change)

* fix: expect ToolsAvailable first in tool-loop tests (matches new event order)

* simplify: reuse tool_arguments, drop restater comments (no behavior change)

---------

Co-authored-by: Julius <julius@localhost>
Co-authored-by: Jom_g <jom_g@users.noreply.github.com>
Co-authored-by: HoloPort <holoport@localhost>
2026-10-28 12:20:19 +00:00
N3MI-DG 7d9b28e20d Context menu: only the row under the pointer stays highlighted while a flyout closes (#8065) (#8066)
* claim: #8065

* Context menu: only the row under the pointer stays highlighted while a flyout closes (#8065)

A row that owns a flyout kept its highlight for the whole of the flyout's
closing delay, so the row the pointer had settled on lit up alongside it.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-10-28 12:19:51 +00:00
github-actions[bot] 48bcc83e2e Regenerate generated files and fold pending files [skip ci] 2026-10-28 14:56:01 +00:00
austin62jake 1e3cb3e36e Motion interference: a check cut short is not clear, and budget_s gives it time (#7855) (#7934)
* claim: #7855

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

* Motion interference: a check cut short by its work budget is not clear (#7855)

motion.interference stops when its work budget runs out (truncated) but
answered clear: collisions.is_empty(), so a check that reached only the first
steps read as a pass: a gear pair checked for 14 of 61 samples was "clear".
clear is now true only when every sample was checked and none collide.

The budget is an argument of interference_within, so a test can cut a check
short: B slides up to touching A, a full check is clear, and the least work
that checks a first step stops after 1 of 16 samples with clear: false (it
was clear: true before). The guide and the mechanisms skill say the same.

The issue's second request, a budget_s on motion.interference, is not here.

Refs #7855.

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

* Motion interference: budget_s gives a check the time it needs (#7855)

The work budget is fixed, so a gear pair drawn in detail was cut short
after a few samples, or refused before its first one. motion.interference
now takes budget_s (seconds, above 0, at most 86400, as inspect.interference
allows): the check then runs until that time instead of the work budget,
looked at before each sample, and echoes budget_s in its answer. Without
budget_s nothing changes. The refusal before a first step now names the
budget that ran out and suggests budget_s.

Tests: given a time, the smallest work budget (which checks no step) no
longer stops the check, through motion.interference too; budget_s 0, -1,
1e9 and "soon" are refused.

Fixes #7855.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-28 12:19:10 +00:00
Inguss 4d6664e4ca fix(ui): expose Redefine Sketch Plane in History menus (#8024) 2026-10-28 12:18:53 +00:00
raykholo d56dc310b0 Browser: Collapse All keeps the clicked row open (#8032) (#8033)
Collapse All from a component or placement row folds everything under that
row and leaves the row itself open, matching the whole-tree Collapse All,
which already keeps the design row open.
2026-10-28 12:18:28 +00:00
semmelrinde 4ab612ea67 Standard Parts and Contact Set: label the rows in words, not with raw keys (#8005)
* Standard Parts and Contact Set: label the rows in words, not with raw keys

The Insert Part dialog listed a size's dimensions under the library's
keys (d, head height, socket depth), and the Contact Set dialog's
checkbox read "enabled". The dimensions now take messages (Nominal
diameter, Head height, Socket depth, ...) and the checkbox reads
Enabled, translated like the rest of the dialogs.

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

* Contact Set: translate the Enabled checkbox's label

ui_kit::checkbox paints the text it is given; the callers translate it,
as Configuration Rules' Enabled does.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:17:07 +00:00
semmelrinde 73f06753b6 Swedish: translate 946 messages the catalog was missing (batch 1) (#8181)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:16:54 +00:00
semmelrinde 292177e085 Preferences: the tab colour strip names the tabs as the ribbon does (#8027)
The Interface page's strip of tab accent colours looked up its own
word-cased names ("Sheet Metal", "Surface"), which the catalogs
mostly don't carry or carry with another meaning: in German it read
"Sheet Metal" and "Flächenkörper" where the ribbon says Blech and
Fläche. It now names each tab the way the ribbon does, through the
tab's heading message, and the Form tab gets one.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:16:48 +00:00
Colin Faulkingham d7111c7326 Ribbon: keep workspace captions inside the switcher (#7931) (#7956)
* claim: #7931

* Fix translated workspace captions overflowing the switcher (#7931)
2026-10-28 12:16:19 +00:00
github-actions[bot] 745184f2fc Regenerate generated files and fold pending files [skip ci] 2026-10-28 14:26:54 +00:00
semmelrinde 942f11169c Preferences: the snap-suppress choice and Direct interaction in the user's language (#8067)
* Preferences: the snap-suppress choice and Direct interaction in the user's language

The Hold to stop snapping choice joined its keys with an English "or"
("Cmd or Option", the default) in every language, and Navigation's
Direct interaction setting and its note had no message. The choice now
reads through a message with the keys as placeholders, and the setting
gets its two messages.

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

* Move the new test away from where another open PR adds one

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:15:48 +00:00
semmelrinde 029cf29b49 Dialogs: 54 labels that read English in every language get messages (#8049)
* Dialogs: 54 labels that read English in every language get messages

The construction dialogs' selection fields (First edge, Second point,
Through point, ...), the joint types (Rigid, Revolute, Slider, ...), the
Sheet Metal and Plastic rules' rows (K Factor, Flange Gap, Min Draft,
...) and a few options and prompts are passed to the label translator as
English source text that no catalog carried, so they showed English in
every language. They are added in one pending file, with the eleven
usual translations, and a test makes sure every dialog's selection
fields keep a message.

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

* Dialog labels: German Flange Gap is Flanschspalt, as the catalog's Flansch

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:15:35 +00:00
semmelrinde 7ad059aec9 Italian: translate 719 messages the catalog was missing (batch 1, all but the Render workspace) (#8062)
The Italian catalog lacked 1350 of the English messages. This batch
translates the 719 outside the Render workspace: Preferences, the
Assistant, the help texts, command labels, materials, navigation and
the rest. It uses the catalog's own terms (Schizzo, Lavorazione, Corpo,
Giunto, Cronologia, Struttura, Risalto, Appoggio, Aggancio a scatto).
The Render workspace's 600 messages are left for a second batch while
that workspace is still being built.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:15:24 +00:00
github-actions[bot] a2a00aa9ee Regenerate generated files and fold pending files [skip ci] 2026-10-28 13:58:14 +00:00
github-actions[bot] 59551c59ab Regenerate generated files and fold pending files [skip ci] 2026-10-28 13:49:47 +00:00
semmelrinde bf1b09698c Insert Part: the part families and the Name hint in the user's language (#8018)
* Insert Part: name the part families in the user's language

The Type list of Insert Part and the Change Part Size dialog showed the
library's English family names (Socket head cap screw, Hex nut, ...) in
every language. Each family now has a message under its library id,
and a family the catalogs don't know keeps the library's name.

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

* Insert Part: the Name field's automatic hint in the user's language

The hint was the English word "automatic"; it now takes the catalogs'
parts.automatic_name, which every language already translates.

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

* Move the family-name helper and its test away from where #8005 adds its own

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:15:15 +00:00
semmelrinde fc2142b407 Turkish: translate the 200 messages the catalog was missing (#8070)
* Turkish: translate the 113 messages the catalog was missing

The Turkish catalog lacked 113 of the English messages: the newer
Preferences settings, the hole presets, the slicer messages, the
Assistant's start-up options and tool summaries, and the Render
workspace's stage, shots and inspector labels. They are translated in
one pending file, with the catalog's own terms.

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

* Translate the 87 messages main added since (the navigation bar's)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:15:03 +00:00
semmelrinde d3b2c715d3 Dialogs: translate the texts a few dialogs draw themselves (#8056)
* Dialogs: translate the texts a few dialogs draw themselves

The joints' Play/Stop buttons, the Motion Study's Loop and the BOM's
sub-parts checkboxes, the Stitch tolerance hint, the Flange type and the
Emboss wrap note were drawn as English literals; the joint lists named
each joint's type by its Rust name (PinSlot, PointOnPlane). They now take
messages, and the joint lists use the Type box's label, translated.

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

* Move the new test away from where another open PR adds one

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:14:51 +00:00
semmelrinde 09308fceb1 Dialogs: 96 hints and refusals that read English in every language get messages (#8083)
The Surface, Sheet Metal, Plastic, Frame, Move, Align, Assembly, Cam,
Coupling, Sweep, Part Size and sketch editing dialogs refuse their
inputs with English sentences ("select bodies first", "pick faces of
one body"). The dialog translates a refusal through the catalogs as it
shows it, but no catalog carried these 96, so they showed English in
every language. They are added in one pending file with the eleven
usual translations.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:14:41 +00:00
semmelrinde 8f87509f7d Russian: translate 841 messages the catalog was missing (batch 1) (#8077)
Everything outside the Render workspace, which is still being built:
Preferences and the new navigation bar, the Assistant, the help texts,
command labels, materials, hole presets, the slicer messages and the
remaining dialog labels, with the catalog's own terms.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:14:34 +00:00
semmelrinde c962e5e8cd Traditional Chinese: translate 897 messages the catalog was missing (batch 1) (#8078)
Everything outside the Render workspace, which is still being built:
Preferences and the new navigation bar, the Assistant, the help texts,
command labels, materials, hole presets, the slicer messages and the
remaining dialog labels, with the catalog's own terms.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:14:33 +00:00
semmelrinde 7df858594d Indonesian: translate 898 messages the catalog was missing (batch 1) (#8098)
Everything outside the Render workspace, which is still being built:
Preferences and the new navigation bar, the Assistant, the help texts,
command labels, materials, hole presets, the slicer messages and the
remaining dialog labels, with the catalog's own terms.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:14:22 +00:00
semmelrinde fed9605357 Sketch: the status in the view and the browser's editing mark in the user's language (#8084)
* Sketch: the status in the view and the browser's editing mark in the user's language

While a sketch is edited, the view's corner said "Sketch1 — 2 degrees of
freedom" (or "fully constrained", "constraints conflict") and the
browser marked the sketch "(editing)" or "(attachment lost)", in
English whatever the language; one degree of freedom read "1 degrees of
freedom". They now take messages: the catalogs' existing degrees-of-
freedom ones and four new ones.

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

* Sketch status and editing mark: the sketch's default name as shown elsewhere

The browser shows a default name in the user's language ("Skizze 1")
except on the edited sketch's row, whose mark made it a different text;
the view's status kept the stored name too. Both now use the name as the
browser shows it.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:13:38 +00:00
semmelrinde bf1886e90a Motion dialogs: translate the notes and refusals they draw themselves (#8096)
Motion Study, Motion Trace, the motion plot, Explode and the contact
check drew their notes and refusals as they got them ("add a key for a
joint first", "select a point on a component", "none"), so they read
English in every language. They now go through the label translator, and
the four that no catalog carried get messages.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:13:38 +00:00
phanoob dfa9afd39f Sketch: leaving a sketch opened inside a component gives the active component back (#8127) (#8157)
Double-clicking a sketch row inside a component (an inserted fastener's sketch)
activates that component so the sketch is edited in its placement. Finish and
Cancel only closed the sketch, so the component stayed active and every other
body stayed faded.

Edit Sketch now remembers the component and placement active before it
activated the sketch's own (also when sketch.edit activates the component of a
face-attached sketch), and leaving the sketch makes that one active again, as
Finish already puts the history marker back. A component activated while the
sketch is open stays active.

Co-authored-by: Phanoob <phanoob@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:13:15 +00:00
semmelrinde 944c658c90 Czech: translate 867 messages the catalog was missing (batch 1) (#8188)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:12:06 +00:00
semmelrinde 8f8df03358 Navigation: Maya and HFSS mouse presets (#6474, #2742) (#8184)
Maya moves the camera with Alt and any button (left orbits, middle pans,
right zooms), middle drag also pans. HFSS pans with Alt+left drag and zooms
with Shift+Alt+left; its Ctrl+left orbit is the drag from empty space every
preset already has, so a Ctrl+click on geometry still adds to the selection.
The help texts are in the catalog for every locale that has the other
presets' help.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:12:05 +00:00
github-actions[bot] 92fc868aa9 Regenerate generated files and fold pending files [skip ci] 2026-10-28 13:22:20 +00:00
github-actions[bot] 33281cf3bc Regenerate generated files and fold pending files [skip ci] 2026-10-28 13:13:50 +00:00
semmelrinde d64816437a Dutch: translate 942 messages the catalog was missing (batch 1) (#8172)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:12:02 +00:00
semmelrinde 8210e94b6d Malay: translate 888 messages the catalog was missing (batch 1) (#8216)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:11:08 +00:00
semmelrinde f6d969f98a Hungarian: translate 946 messages the catalog was missing (batch 1) (#8199)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:11:06 +00:00
dudedagger42 59faca3850 Canvas and Decal: send the picked face, so a large design takes the image (#5592) (#5593)
* claim: #5592

* Canvas and Decal: send the picked face, so a large design takes the image (#5592)

A click with Canvas or Decal sent only the point, so the engine searched every face of every
body for it and gave up on a large design ("face capture work limit exceeded"). Send the
picked body, face index and path as Create Sketch does; canvas.decal takes that face pick too.

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

* Canvas and Decal test: drop the check that a point alone gives up

On upstream main a sketch on a face by its point alone no longer gives up on this design, so
the test keeps only what the picked face has to do: both images land on it.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:11:01 +00:00
semmelrinde 29a2f33664 Galician: translate 888 messages the catalog was missing (batch 1) (#8214)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:10:58 +00:00
sascha2101 9d4d7932ee Sketch: offer an arc length dimension for a selected arc (#6039) (#6264)
* claim: #6039

* Sketch: offer an arc length dimension for a selected arc (#6039)

The arc length dimension already existed in the engine (sketch.dimension with
type arc_length), but nothing in the UI led to it: the Dimension tool and the
context menu always gave an arc its radius.

With exactly one arc selected, the sketch context menu now offers Arc Length
Dimension next to Sketch Dimension, which adds the dimension through the
existing command.

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

* test: move the arc length menu test up to avoid a merge conflict

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

---------

Co-authored-by: sascha2101 <221609842+sascha2101@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:10:45 +00:00
github-actions[bot] 54a207cb8b Regenerate generated files and fold pending files [skip ci] 2026-10-28 12:44:09 +00:00
github-actions[bot] 5bf5de49a5 Regenerate generated files and fold pending files [skip ci] 2026-10-28 12:35:35 +00:00
semmelrinde cee995e776 Norwegian Bokmål: translate 693 messages the catalog was missing (batch 1) (#8218)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:10:34 +00:00
Nathan Dane b86c75544e Appearance: read the look chain and replace a look everywhere in one undo step (#6428) 2026-10-28 12:10:31 +00:00
yhamil04 249318cffb Right-click opens the menu despite tiny pointer movement (#5517) (#7939)
* claim: #5517

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

* Right-click opens the menu despite tiny pointer movement (#5517)

A Magic Mouse moves the pointer a fraction of a point as it is clicked.
The right button orbited on any movement, and the menu needed exactly
zero movement in the release frame, so most right-clicks only nudged
the view. The right button now navigates once the press can no longer
be a click (egui's click distance and time), catching up with the
travel since the press, and a right-click is egui's click.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:08:27 +00:00
Colin Faulkingham 6f8ee10a86 Appearance: apply browser actions to all selected bodies (#7935) (#7941)
* claim: #7935

* fix: apply browser appearance changes to selected bodies (#7935)
2026-10-28 12:08:21 +00:00
N3MI-DG 30cb1238f7 Browser: reveal a placed body's row when it is picked or found (#7947) (#7948)
* claim: #7947

* Browser: reveal a placed body's row when it is picked or found (#7947)

The off-screen skip in `entry_row` keeps the reveal target's row alive so it
still has a rect to scroll to, but it matched `app.tree.reveal` (a world body
name) against `Entry::key` (the placement-local name). For a placed body those
never match, so the row returned early and the `scroll_to_rect` below it, which
does use `row_key`, was never reached. `app.tree.reveal` is then cleared at the
end of the frame, so there was no second chance.

Match the skip on `row_key` minus its `b:` prefix, as the `scroll_to_rect`
already does.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-10-28 12:08:01 +00:00
raykholo db999b2a98 Control protocol: the ui.prefs settings as a sorted list, one per line (#7747) (#7988) 2026-10-28 12:07:43 +00:00
semmelrinde e59decda95 Primitive dialogs: label the size fields in words, not with the parameter names (#7995)
The Box, Cylinder, Cone, Sphere, Torus and Coil dialogs labelled their
size rows with the command's parameter names (length, top_diameter,
section_size), in every language. Each row now takes a message: Length,
Top diameter, Major radius, Section size, translated as the rest of the
dialog is.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:07:37 +00:00
github-actions[bot] aca80f2054 Regenerate generated files and fold pending files [skip ci] 2026-10-28 10:25:10 +00:00
Nathan Dane 0b2efd60b9 Render browser: scene tree with Environment, Lights, Shots and Objects rows, resolved appearances and a Browser selection (#6427) 2026-10-28 09:58:57 +00:00
amolinari-autotex 54f613e4dd Preserve linked component materials and appearances (#7609) (#7616)
* Fix linked component materials and appearances (#7609)

* Linked components: the cached link read carries the source properties

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

* Update Links: a link without recorded inherited properties is read again

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

* Preserve authored linked-face overrides and apply cached source properties

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 09:33:49 +00:00
github-actions[bot] ae07e36a7d Regenerate generated files and fold pending files [skip ci] 2026-10-28 07:57:59 +00:00
Nathan Dane 18fe82ab26 Render workspace: focus view, compact docks, a Browser chip at narrow widths and a localisation pass (#7376) 2026-10-28 07:29:53 +00:00
github-actions[bot] 372badfc1b Regenerate generated files and fold pending files [skip ci] 2026-10-28 06:43:04 +00:00
Nathan Dane 1781b4d323 Render workspace: move the Inspector or Browser dock to either side with view.render_dock and a header menu (#7230) 2026-10-28 06:15:13 +00:00
github-actions[bot] cf5dc813e2 Regenerate generated files and fold pending files [skip ci] 2026-10-28 04:43:28 +00:00
Nathan Dane b0c8c466b0 Navigation bar: icons for visual styles, selection modes and display (#6585) (#6898)
* Navigation bar: slot model and one-row renderer (#6585)

* Navigation bar: customisation section in Preferences (#6585)

* Navigation bar: icons for visual styles, selection modes and display (#6585)

* Navigation bar: redraw the Bodies, Parts and Components selection icons (#6585)

* Navigation bar: refine the Ground shadow, Ambient occlusion, Parts and Components icons (#6585)

* Navigation bar: solid cube and a longer arc for the Ground shadow icon (#6585)

* Navigation bar: the owner accepts the new icons as final (#6585)
2026-10-28 04:16:44 +00:00
github-actions[bot] 85aaed2ccb Regenerate generated files and fold pending files [skip ci] 2026-10-28 03:48:50 +00:00
Nathan Dane 110efa3598 Render: allow picking in the product view with a Body, Face or Component filter and outline the selection in the product pass only (#6426) 2026-10-28 03:20:43 +00:00
P'tite Tête 9fb41f8378 feat: Start a new selection's preview without waiting for the obsolete one (#7167) (#7261)
- Leave the running preview behind when the selection moves to another setup, asked to stop
- Keep at most two obsolete previews running; streaming, tools and other inputs wait as before
- Keep no result of a cancelled watched evaluation in the shared evaluation cache
- Test the detaching, the cancel reaching the spawned job, and the cache guard

Co-authored-by: P'tite Tête <asso@ptitetete.com>
2026-10-28 01:32:17 +00:00
bherbruck 37a586dd61 Release 0.25.1 2026-10-27 20:58:30 -04:00
bherbruck 79214b6b49 Release 0.25.1 (#7983)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 20:58:27 -04:00
github-actions[bot] 49c0faa7da Regenerate generated files and fold pending files [skip ci] 2026-10-28 00:50:43 +00:00
bherbruck 2cdfe050db Extrude to Object: a Join leaves the body it runs to alone when that body is in another component (#7975)
#6471 joined the body a To Object extent runs to into the tool, whatever its component.
A connector's boss extruded up to an enclosure's face (each in its own component) merged the
enclosure into the connector, and features referencing the swallowed body failed after it.
The body it runs to now joins only when it is in the feature's own component, or when the
feature names it as a target.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 20:48:28 -04:00
CamWight 303fddb20e Browser: Bodies folders only where they help: Always, Several bodies only (the default) or Never (#4217) (#4219)
* claim: #4217

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

* Browser: Bodies folders only where they help: Always, Several bodies only (the default) or Never (#4217)

A new preference on the Interface page, in the Layout section under Browser, sets when a
component's bodies sit in a Bodies folder of their own. Several bodies only lists a component's
lone body, when it has no groups, straight under its placement. Never lists every component's
bodies and groups there. Always keeps the folder as before. The design's own Bodies folder and a
folder hidden with its eye always stay.

One rule, browser::bodies_folded, decides it for the rows and for the menu. A folded folder
draws no row and stays open; its items move up one level, and their own eyes, hover and drops do
its jobs. While a folder is folded, its placement's menu takes the folder's Create Components from
Bodies and Group Selected; New Group and Show All were already there.

ui.prefs takes browser_bodies_folders (always, several, never) and refuses other values; saved
preferences read an unknown value as the default. The s37 scenario sets Always so that it still
checks the Bodies folder's chevron.

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

* Browser: Bodies folders in Arabic, Hebrew and Vietnamese too (#4217)

Arabic, Hebrew, Japanese, Vietnamese and Chinese are complete catalogues, so
every_complete_catalog_carries_every_english_message fails a new message any of them lacks. The
preference's four messages get their Arabic, Hebrew and Vietnamese text, in the words those
catalogues already use for a body and a folder.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 00:24:17 +00:00
github-actions[bot] 03606ec97b Regenerate generated files and fold pending files [skip ci] 2026-10-27 23:59:03 +00:00
taylorhayis c4fb42f665 Preferences: search settings (#2218) (#6002)
* claim: #2218

* Preferences: search settings across every page (#2218)

A field at the top of Preferences filters the live controls with the same scorer as command search, including a dropdown's current value and the English source of a translated label.

* Preferences search: keep the plain page list for its tests (#2218)

The production dialog always draws the search strip, so the no-header entry is test-only and no longer warns as unused.

* Preferences search: show only the settings that match (#2218)

A page name or a loose string of letters was keeping the rest of that page on screen, including notes and the mouse legend.

* Preferences search: drop stray chrome and clear from the field (#2218)

The colour swatches and folded Advanced heading were painted on every query, and the field had no way to empty itself.

* Preferences search: fold shortcut matches and drop the blank gaps (#2218)

* Preferences search: stop unmatched pages painting over the hit (#2218)

* Preferences search: a page tab clears the search and shows that page (#2218)

* Preferences search: keep OK and the graphics checkboxes in view (#2218)

A dropdown that skipped its real height on a dialog's first frame left OK outside the window, and the search strip used up the rows the graphics page needs. The shortcut fold is in the UI kit gallery.

* Preferences search: type a query and open the match from the keyboard (#2218)

A printable key or Cmd/Ctrl+F focuses Search settings, arrows move through the matches, and Enter opens that row. Escape clears the query before it can close the dialog, and clearing returns to the page that was up when the search started.

* Preferences search: translate the search strings for the complete catalogs (#2218)

Arabic, Hebrew and Vietnamese are delivered complete, so Search settings and the empty result need a translation there. German and Korean get the same two strings.
2026-10-27 23:27:33 +00:00
yhamil04 00e31c24fd Sketch: Delete on a picked profile deletes the curves around it (#7107) (#7120)
* claim: #7107

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

* Sketch: Delete on a picked profile deletes the curves around it (#7107)

While a sketch is being edited, a profile picked by clicking inside a
shape stands for the curves of its outer loop, so Delete removes the
shape instead of failing with "a profile goes with its sketch". Holes
inside it are shapes of their own and stay. Outside the sketch a
profile still goes only with its sketch.

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

* Delete a picked letter's whole text, not just its outline (#7107)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 23:04:41 +00:00
bherbruck 03b8f4138b New designs save as .sc; .solidcraft files still open (#7325)
* claim: new designs save as .sc

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

* New designs save as .sc; .solidcraft files still open and keep their name on Save

DESIGN_EXT is now "sc"; DESIGN_EXTS lists sc, solidcraft and solvecraft and
every open path (dialogs, drag-drop, recent files, CLI, MCP, links, the web
picker) accepts all three. Save of an opened .solidcraft file writes that
file; Save As, autosave, recovery and crash reports use .sc. Windows, macOS
and Linux register .sc alongside the older extensions.

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

* Update UI tests for the .sc default extension

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 22:41:40 +00:00
github-actions[bot] 613b468ef5 Regenerate generated files and fold pending files [skip ci] 2026-10-27 22:58:37 +00:00
yhamil04 c0a0da225a Ribbon tooltips: help for the Solid > Modify tools, and new toolbar commands must have it (#2758) (#7134)
* Ribbon tooltips: help for the Solid > Modify tools; new toolbar commands need it (#2758)

Writes the two-sentence help for the 16 Modify tools that had none (en, fr,
ja, zh), and adds a test that fails when a toolbar command has no help; the
190 older ones are on a backlog that only shrinks. AGENTS.md, CONTRIBUTING.md
and the Adding a command guide say so.

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

* Ribbon tooltips: the help test reads the ribbon as the toolbar lays it out (#2758)

Review: commands placed by a workspace section without .at(...) were not
checked; 79 of them join the backlog. Physical Material's help no longer
promises the centre of mass.

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

* Ribbon tooltips: French help uses the glossary terms (#2758)

Matériau, Apparence and Scinder, as rlartot asked in review; Delete Face
says the hole stays open.

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

* Ribbon tooltips: the help rule fits the commands skill's size limit (#2758)

AGENTS.md became a router to skills on main, so the rule lives in
skills/solidcraft-commands/SKILL.md, kept under its 3600-character limit.

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

* Ribbon tooltips: Arabic, Hebrew and Vietnamese help; the backlog follows main (#2758)

Arabic, Hebrew and Vietnamese are complete catalogs now, so the 16 texts
come in those too. Twelve toolbar commands that landed before the rule
join the backlog; the Render commands, which left the ribbon, leave it.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 22:30:15 +00:00
bherbruck a79f40a711 Release 0.25.0 2026-10-27 18:05:39 -04:00
bherbruck 03d0342469 Release 0.25.0: version bump, design format 41 (#7916)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:05:36 -04:00
github-actions[bot] 13d62705f3 Regenerate generated files and fold pending files [skip ci] 2026-10-27 21:51:37 +00:00
Frank 7fad8dbec0 Fix feature creation at timeline end (#7620)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-27 21:17:39 +00:00
Lars Jansen 83ac98ac76 Origin plane names: Top / Front / Right (#6491) (#6496)
* claim: #6491

* feat(ui): origin plane names Top / Front / Right (#6491)
2026-10-27 21:06:14 +00:00
sascha2101 42ec27cfe4 Browser with History under it: drag the split between the two cards (#7371) (#7803)
* claim: #7371

* Browser with History under it: drag the split between the two cards (#7371)

Under the browser, History took exactly half the column. The seam
between the two expanded cards is now a grip with the vertical resize
cursor; dragging it moves the split, as the column's right edge moves
its width. The split is kept as the browser's share of the column in
a new preference, browser_share, whose default is the equal split, so
nothing changes until someone drags.

Either card keeps at least its header and two rows. Folding either card
works as before and gives the other the rest; while one is folded or
folding there is no grip. A non-finite share falls back to one half.

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

* Prefs: keep the browser share with the History-under-browser settings

Moves the entry so it no longer sits next to the render and dialog
preferences that main added after browser_width.

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

---------

Co-authored-by: sascha2101 <221609842+sascha2101@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 20:59:38 +00:00
semmelrinde ca75f86947 Sketch: deleting a dimension in a rolled-back history no longer rebuilds the features after the marker (#7253) (#7425)
* claim: #7253

* Sketch: deleting a dimension in a rolled-back history no longer rebuilds the features after the marker (#7253)

The check before a delete evaluated the whole history twice when the
timeline was rolled back. Removing a sketch constraint changes the later
features' cache keys, so on a long history Delete on a dimension took
minutes. When the design differs only in sketch constraints (and model
parameters nothing uses any more) and each changed sketch solves to the
same geometry, profiles and ends, the features after the marker see the
same inputs: compare the live models only. Anything else still gets the
full check, so the report is unchanged.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 20:53:34 +00:00
bherbruck 7a6c884418 Change the app id to io.tangencylabs.solidcraft, with aliases for existing installs (#7681)
The app id moves from ai.storyteller.solidcraft to io.tangencylabs.solidcraft: macOS bundle id
(the CLI signs as io.tangencylabs.solidcraft.cli), Linux desktop/metainfo/MIME file names, icon
name, Wayland app_id / X11 class, Flatpak, Snap common-id, nfpm/FreeBSD/Nix/AppImage staging,
the web app's icon paths and the docs.

Existing installs keep working: the Flatpak repository marks both old ids
(ai.storyteller.solidcraft and ai.storyteller.solvecraft) end-of-life with a rebase straight to
the new id, re-marking the solvecraft ref that pointed at the 0.8 id; the manifest grants
read-only access to both old ~/.var/app folders and the first launch copies settings and
autosaves from the newest one; AppStream <replaces>/<provides> and X-Flatpak-RenamedFrom list
both; the AppImage removes menu entries it wrote under either old id. The data and settings
folders (solidcraft / SolidCraft) are unchanged. The MSI UpgradeCode and ProgIDs are unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 17:16:40 -04:00
github-actions[bot] a6ef3ee4d2 Regenerate generated files and fold pending files [skip ci] 2026-10-27 20:57:39 +00:00
github-actions[bot] 153af8da22 Regenerate generated files and fold pending files [skip ci] 2026-10-27 20:49:28 +00:00
semmelrinde 392f4ed396 Report a bug: Ctrl/Cmd+Enter continues on GitHub (#7203) (#7204)
* claim: #7203

* Report a bug: Ctrl/Cmd+Enter continues on GitHub (#7203)

The report's fields are multi-line, so Enter adds a line break and the
only way on was the mouse. Ctrl+Enter (Cmd+Enter on a Mac) in any field
now does what Continue on GitHub does: it is read before the fields see
it, so it adds no line break, and an empty description is still asked
for. A note under the description says so, with the keys of this
computer.

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

* Report a bug: keep the shortcut hint apart from the other report changes

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

* Report a bug: holding Ctrl/Cmd+Enter continues on GitHub once

egui's consume_key also matches the key's repeats, so every repeat frame
opened another GitHub tab. Only the press counts now; repeats are dropped.
The test sends the modifiers each platform really sends (mac_cmd or ctrl
together with command) and holds the key.

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

* Locales: Arabic and Hebrew for the new strings

ar and he are complete catalogs on main now (#3458): every new message
needs them. The pending-messages test compares right-to-left messages in
display order once #7532 lands (#7527).

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

* Locales: Vietnamese for the new strings

Vietnamese became a complete catalog on main (#5823), so every new English
message needs it too.

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

* Report a bug: the Save screenshot test scrolls its button into view

The shortcut hint adds a line above the screenshot, which moves its
buttons below the fold of the review's scroll area at the test's size.
The test scrolls them into view first, as the other report tests do.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 20:28:04 +00:00
Patrick Steinert 0e4c5ae577 Save as Mesh: Add all bodies and Add visible bodies buttons (#7297)
* Save as Mesh: Add all bodies and Add visible bodies buttons

Two buttons under the Bodies input fill it with every body of the design,
or with only the bodies the viewport shows.

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

* Save as Mesh: translate the two body buttons for every complete catalog

ar, he, ja, vi and zh are complete catalogs, so every English message needs
an entry there; ko as the ui-text skill asks.

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-27 20:08:27 +00:00
raykholo d2f0627421 Follow a theme file and reload it when it changes (#7573) (#7574)
* claim: #7573

* Follow a theme file and reload it when it changes (#7573)

* Translate the follow-a-theme-file strings
2026-10-27 20:08:12 +00:00
semmelrinde e0e2c566e7 Viewport: clear new resolve targets so AMD Direct3D 12 shows the scene (#6828) (#7399)
* claim: #6828

* Viewport: clear new resolve targets so AMD Direct3D 12 shows the scene (#6828)

With anti-aliasing above 1x the scene is drawn multisampled and resolved
into textures that nothing but the resolve writes. wgpu counts the resolve
as their first write and never clears them. Direct3D 12 requires a render
target placed in reused memory to be cleared, discarded or copied over
first, and some AMD Radeon drivers then show the uncleared texture as
transparent, a flat colour or speckles: no grid, origin planes or bodies
(#6828, #7157, #6613, #7052). Each new resolve texture (the frame's colour
and the see-through sums) is now cleared once when it is made.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 20:01:40 +00:00
Dahak Builds ebdc0bc07e Assembly: deeply nested body selection no longer stalls the window (#7502) (#7694)
* claim: #7502

* Assembly: name placed bodies from a per-design index, reveal the Browser from the body's path, evict cache entries singly (#7502)

* Release the placement index before in-place document edits so pinning body names stays in place

---------

Co-authored-by: Dahak Builds <dahakbuilds@users.noreply.github.com>
2026-10-27 19:44:52 +00:00
Sebastian Höpfl d1398e2c52 Evaluation: keep a sketch's frame bodies across evaluations that move nothing (#7814)
A sketch on a face of a placed component is evaluated in that component's frame, so the
evaluator moves (and meshes) the bodies placed differently into it. FrameBodies keeps those
moved bodies, but every evaluate started a new generation and dropped what neither it nor the
evaluation before it used. Evaluations that reuse every result never touch the cache, so two
of them in a row emptied it: Delete refreshes the model three times (selection.delete's nested
refresh, its own, then execute's), and the confirmation's dry run more, and an undo answered
from the cache is another. The next edit of the sketch then moved and meshed every placed body
again: in a design of ~3,500 imported bodies each sketch edit after a Delete took 46 s, 34-43 s
of it tessellation.

The generation now starts on the evaluation's first use of the cache, so an evaluation that
moves nothing leaves it alone. It still holds at most two generations of moved bodies.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 19:43:12 +00:00
Badger B 5bdb11bd56 Add German translations, batch 2 (#7237) (#7826)
* Regenerate generated files and fold pending files [skip ci]

* Add German translations, batch 2 (#7237)

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-10-27 19:34:36 +00:00
Irakli Kourtis 69e45b88e7 docs: a disclaimer of warranty, liability, indemnity and harmful use (#3882)
Claude-Session: https://claude.ai/code/session_013Bhtfdu3WDoF5dQjzzdcbq

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 19:32:16 +00:00
Victor Santos Oliveira 5869f4c247 Windows: gate Unix-only slicer readiness states (#7696) (#7697)
* claim: #7696 slicer readiness platform guards

* fix(engine): gate platform-specific slicer readiness code
2026-10-27 19:28:34 +00:00
tr1pp4 98abb249c4 Cylinder: place by distances from reference edges, like Hole (#6380) (#6381)
* claim: #6380

* Cylinder: place by distances from reference edges, like Hole (#6380)

A Cylinder standing on a flat face can take up to two straight edges of
that face as references, each with the distance of its base centre from
the edge's line, measured in the face's plane, as a Single hole does
(#37). `solid.cylinder` takes them as `references: [{edge, distance}]`
with a numeric `base`; the edges are found again by their persistent
names, else their points, so the cylinder follows them when the part
changes, and each distance is a parameter (an input of Inspect Feature).
A cylinder without references stays at its base, as before.

In the Cylinder window, started on a picked face, Reference 1 and 2 take
the edges, each with its Distance measured from the pick, so it works
with the mouse alone; the canvas shows each distance with a box to type
it in, and Edit Feature picks the edges again.

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

* Cylinder: Edit Feature shows each reference edge input once (#6380)

A face selected when Edit Feature opened the window had already added
the reference inputs; the edit added them a second time.

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

* Cylinder references: the measured-once cost test's cylinder has none (#6380)

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

* Cylinder: pick the face it stands on in its window, then the edges (#6380)

The Cylinder window took its place only from a face selected before it
opened, so started first it stood at the origin and offered no reference
edges. It now has a Face input before Reference 1 and 2, as the Hole
window does: start Cylinder, click the face, click the edges, OK.

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

* Glossary: reference edge, and "Reference" among the words with more than one meaning (#6380)

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

* Cylinder edge references: the Cone dialog (#6250) gets the references field too

The Cone primitive landed on main after this branch began; its dialog initialisers lacked
the `refs` field the Primitive kind gained here. The dense form check for Cylinder fills only
its fields: its face and reference edges place it, which the reference tests cover.

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

* Cylinder: declare references in its contract; reference-placed cylinders are not placement-independent

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-27 19:28:22 +00:00
Daniel Dimitrov 7b03439dda Navigation: Free orbit can turn around the vertical axis (#6964) (#6965)
* Navigation: Free orbit can turn around the vertical axis (#6964)

A new option under Free orbit, Turn around the vertical axis, makes mouse
drags a turntable without the pole limit: sideways turns about the world's
up axis (reversed upside down so the model follows the cursor), up/down
tilts over the top. Off by default; free orbit without it is unchanged.

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

* i18n: Japanese and Chinese for the vertical-axis orbit option (#6964)

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

* i18n: Arabic, Hebrew, Vietnamese, German and Korean for the vertical-axis orbit option (#6964)

Main now treats ar, he and vi as complete catalogs, so the merge queue's
every_complete_catalog_carries_every_english_message failed on the two new ids.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: danieldimitrov86 <danieldimitrovdev@gmail.com>
2026-10-27 19:28:14 +00:00
Dahak Builds 74d1db902e Turkish locale: complete tr.json catalog (#6240) (#7668)
* claim: #6240

* i18n(tr): complete the Turkish catalog with 1280 missing messages

---------

Co-authored-by: Dahak Builds <dahakbuilds@users.noreply.github.com>
2026-10-27 19:22:49 +00:00
github-actions[bot] 882737ff94 Regenerate generated files and fold pending files [skip ci] 2026-10-27 19:52:35 +00:00
Nathan Dane 295d04932f Render workspace: stage bar, aligned floating docks and the Shots strip host (#6281) (#6296)
* claim: #6281

* Render workspace: stage bar, aligned floating docks and the Shots strip host
2026-10-27 19:21:09 +00:00
semmelrinde 0d18757ed7 File dialogs open in the folder used last (#7756) (#7807)
* claim: #7756

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

* File dialogs open in the folder used last (#7756)

The desktop dialogs never said where to open, and the Linux ones then
start in the home folder every time. Open, Insert, attach, Save As and
Export now open in the folder of the file last chosen in a dialog, or of
the open design when another design becomes the open one, whichever came
last. Also fixes #7735.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 19:19:10 +00:00
Hainlein4711 9030d2ff19 Ribbon: wrap Japanese button labels across two lines (#7586) (#7669)
* claim: #7586

* UI: wrap Japanese ribbon button labels across two lines at CJK script boundaries (Fixes #7586)
2026-10-27 19:06:07 +00:00
raykholo 38820658fa Body appearance replaces imported STEP face colours (#5769) (#5775)
* claim: #5769

* Let body appearances replace imported STEP face colours (#5769)

A body appearance assigned in the design now replaces the face colours the
body was imported with; face appearances set in the design still win over it.
2026-10-27 19:03:10 +00:00
Erlis Vidal c8c5c882de Sketch: name = value in a drawing tool's value box defines the parameter (#7567) (#7660)
* claim: #7567

* Sketch: a drawing tool's value box takes `name = value` (#7567)

While drawing, a value box (circle diameter, line length, rectangle width and
height, slot, arc, polygon) now accepts `name = expression` and
`#name = expression`, as the Dimension tool already does: the shape is drawn at
the expression's size and its dimension defines and uses the user parameter
`name`, in one undo step.

- sketch.draw.dimensioned checks a definition's right-hand side, read as the
  new parameter will hold it (a bare number in the design's unit), and passes
  the text on to sketch.dimension; the reply lists `defined`.
- The value boxes size the preview and validate by the right-hand side, refuse
  a definition in a box that is not a dimension (polygon sides, rounded
  rectangle corner radius, conic rho), and the status line names the
  parameters defined.
- Guides: parameters and sketching.

Fixes #7567

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 19:02:09 +00:00
semmelrinde 5dc95ef7f6 Viewport: a frame the GPU has no memory for is drawn smaller, not a crash (#7296) (#7398)
* claim: #7296

* Viewport: a frame the GPU has no memory for is drawn smaller, not a crash (#7296)

The viewport's offscreen frame, its see-through targets and the cached
still picture were created with no error scope, so a texture the card had
no video memory for went to wgpu's default handler and closed the app
("wgpu error: Out of Memory" on a GT 740). They are now created under error
scopes; a refused frame lowers the renderer's frame budget and is asked for
again smaller (less supersampling, or the view's own frame drawn smaller and
filtered up), refused OIT targets fall back to sorted see-through surfaces,
and the status line says once that the view is drawn at a lower quality.

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

* Locales: Arabic and Hebrew for the new strings

ar and he are complete catalogs on main now (#3458): every new message
needs them. The pending-messages test compares right-to-left messages in
display order once #7532 lands (#7527).

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

* GPU memory test: targets take the OIT flag main added (#7296)

The test's two GpuTarget initializers predate the oit field and no longer
compiled once the branch met main.

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

* Locales: Vietnamese for the new strings

Vietnamese became a complete catalog on main (#5823), so every new English
message needs it too.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:58:31 +00:00
sascha2101 e2d409e6ed Save: a design changed while it is written stays unsaved, and scripts wait for it (#6003) (#7619)
* claim: #6003

* Save: a design changed while it is written stays unsaved, and scripts wait for it (#6003)

Two of the gaps @raykholo found in #6003 were still open on main:

- `engine.script` ran while the window's Save was being written: it calls
  the session directly, past `SolveApp::run`'s file-operation guard, and
  `drain_control` did not hold design-changing requests for a save. They
  now wait for the save like they wait for a file being loaded or picked.
- `adopt_save` marked whatever the session held when the save finished as
  saved. It now takes up the state the save wrote (`done.saved`, and its
  visibility), so a change made meanwhile stays unsaved instead of being
  lost on close without a prompt.

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

---------

Co-authored-by: sascha2101 <221609842+sascha2101@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:52:36 +00:00
selune 3179cd2ebd Sketch text: cover line pitch and format-12 glyph zero (#6570) (#6574)
* claim: #6570

* test(text): cover nonpositive pitch and format-12 glyph zero (#6570)

Original synthetic font regressions. Focused native font tests, strict scoped Clippy and wasm pass. Full repository gate status is recorded in PR #6574; no CI infrastructure changes.
2026-10-27 18:51:44 +00:00
phanoob d139e415e4 Sketch: switch a circle or arc dimension between radius and diameter (#7499) (#7729)
* Sketch: switch a circle or arc dimension between radius and diameter (#7499)

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

* Sketch: Toggle Radius / Diameter for a circle's or an arc's dimension (#7499)

New command sketch.toggle_diameter, with input and output schemas, and a
context-menu item on a single selected radius or diameter dimension. The
circle keeps its size: the dimension's parameter is halved or doubled (a
plain value is rewritten, an expression is wrapped). Refused, with nothing
changed, when anything else uses that parameter.

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-27 18:49:30 +00:00
semmelrinde d14dbe5e39 Move: a copying Move can be edited from the History (#7741) (#7761)
* claim: #7741

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

* Move: a copying Move can be edited from the History (#7741)

Editing a feature runs its command on a scratch session with the marker
right after it. For the last feature that marker sits at the end of the
timeline, and place_feature pushed the new feature after it, rolled back,
so a copying Move found no copies and refused the edit. A marker right
after the last feature now moves on past a feature added there.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:47:01 +00:00
Igor Bronovskyi 143145751c i18n(uk): add pending translations for missing strings (#7556) 2026-10-27 18:46:33 +00:00
semmelrinde 98658142c0 Delete: deleting one instance of a component takes its joints with it (#7749) (#7782)
* claim: #7749

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

* Delete: deleting one instance of a component takes its joints with it (#7749)

Deleting an occurrence while its component had other instances dropped
only the occurrence: the joints on it, their motion links, joint origins,
contact set places and exploded moves stayed, so a joint stayed in the
Browser naming an occurrence that was gone. The clean-up that
remove_component_tree did is now Document::remove_occurrences, used by
both.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:44:44 +00:00
github-actions[bot] ee6ec09b0a Regenerate generated files and fold pending files [skip ci] 2026-10-27 19:07:17 +00:00
github-actions[bot] 0accea759d Regenerate generated files and fold pending files [skip ci] 2026-10-27 18:59:13 +00:00
HiroKenEz 8962ae10e7 Box dialog: place the box by its corner or centre (#5638) (#6248)
* claim: #5638

* Box dialog: place the box by its corner or centre (#5638)

The Box dialog gets a Placement choice (Corner or Center) with X, Y and Z
fields that take expressions and units, and a Point input: a vertex, a
point on an edge or face, or the origin picked in the view fills them.
The Cylinder dialog gets X, Y and Z for the centre of its base. Left
alone, a new dialog sends no placement, so the box stays at the origin;
Edit Feature shows the stored point and OK without a change keeps it.

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

* Box dialog test: pass the active feature to Input::accepts

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

* Box dialog: Base Center in the complete Arabic, Hebrew and Vietnamese catalogs (#5638)

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-27 18:29:05 +00:00
semmelrinde 28b27aac8f Saved model: kept only when every body decodes to itself (#6760) (#6761)
The native codec gave one body back with other topology: a pattern copy
cut by an automatic extrude, 10 faces, 18 edges and 10 vertices, came
back with 12 vertices, so the design opened with another body than it
was saved with. Encoded::of now decodes each body it encodes and keeps
the model only when every body comes back with the same face, edge,
vertex and shell counts; otherwise the design is saved without its
model, as when the encoder refuses a body, and opening it evaluates it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:28:41 +00:00
Lars Jansen 5a477b7774 Dialogs: open in the left column in place of the browser and History (#6707) (#6712)
* claim: #6707

* feat(dialogs): open in the left column in place of the browser and History (#6707)

* fix(i18n): Vietnamese messages for the dialog placement option (#6707)
2026-10-27 17:59:00 +00:00
raykholo 236b5c9ebf Add the built-in 1-bit and 1-bit Dark themes (#7550) (#7557) 2026-10-27 17:46:31 +00:00
github-actions[bot] 95ff76cb97 Regenerate generated files and fold pending files [skip ci] 2026-10-27 18:22:08 +00:00
takuto-NA d245541075 Reuse placement-independent model results (#6525) (#6527)
* claim: #6525

* perf(eval): reuse local geometry across placement changes

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

* fix(engine): preserve recovery journal through shared refresh

The shared evaluator retains the document Arc, so refresh and body-name
pinning can copy it after command_done recorded its address. The next read
then mistakes that copy for an outside edit and discards the replay.

Record completion after refresh, name pinning or failed-command restoration.
Add a minimal journaled box/inspect regression in the existing crash suite.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:51:09 +00:00
Mateusz Sych 9a7b657fdc Assistant: connect the last-used agent on startup (#7009) (#7124)
* claim: #7009

* Assistant: connect the last-used agent and open the panel at start (#7009)

Two Settings > AI options, off by default: when SolidCraft starts, connect
the agent started last (once the installed agents are known; a missing one
is not swapped for another) and open the Assistant panel. Not for runs
started with --control.

A connection that never gets ready (no agent, start error, the agent ends
while starting) is a new Failed state: the header reads Connection failed
and Connect reads Retry. The control channel reports it as "failed" and
takes connect_at_start / open_at_start.

* Assistant: keep the start-up agent when a design replaces the blank one (#7009)

Opening a design over the blank design SolidCraft starts with reset its
chat and stopped the agent connected at start. A chat not asked anything
yet now starts over in place and keeps its agent. Turning AI features
off also drops a start-up connection still waiting for discovery.

* i18n: Arabic, Hebrew and Vietnamese for the start-up options (#7009)

ar, he and vi are complete catalogs on main now, so every new English
message needs them.

---------

Co-authored-by: MateuszSychXpl <151755914+MateuszSychXpl@users.noreply.github.com>
2026-10-27 16:38:26 +00:00
tr1pp4 d0c594d311 History: a face picked in the view lights the feature that made it (#6910)
Hovering or picking a face in the view lights the feature that made that face (its
creator, as Edit Feature on a face finds it: the hole, not the plate it was cut into)
in the bottom timeline, the History on the left and the Browser's Features folder;
edges and vertices light the feature that made their body, as before. A new face or
edge pick scrolls the history shown to that feature once, through the Find in
Timeline reveal.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:25:28 +00:00
Parman Mohammadalizadeh 4515af3eb9 Update Links reads again only the linked designs that changed (#5606) (#5674)
* Update Links reads again only the linked designs that changed (#5606)

Update Links read, evaluated and STEP round-tripped every linked design on every run. Remember a digest of each linked file, of the STEP it gave and of its face names, and skip a link whose file, bodies and face names are all unchanged. Read a linked design through its saved model when the file has one, as opening it does.

* Drop an import the rebase left unused

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

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:01:57 +00:00
github-actions[bot] c5fbf6650c Regenerate generated files and fold pending files [skip ci] 2026-10-27 17:09:41 +00:00
github-actions[bot] 1aaa2cdc2a Regenerate generated files and fold pending files [skip ci] 2026-10-27 17:03:03 +00:00
semmelrinde 98b57a3588 Report a bug: Retake rebuilds the details instead of refusing, and keeps the screenshot (#7350) (#7352)
* claim: #7350

* Report a bug: Retake rebuilds the details instead of refusing, and keeps the screenshot (#7350)

Once anything changed with a report open, every Retake refused with "Settings or model
changed. Close this draft…", and each refused, waiting, timed-out or stale capture deleted the
screenshot the draft had. Retake now rebuilds the technical details for the current view
(keeping what was typed, edited details and a crash draft's body) and re-baselines before it
captures; the screenshot is replaced only when a new frame is accepted.

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

* Locales: Arabic and Hebrew for the new strings

ar and he are complete catalogs on main now (#3458): every new message
needs them. The pending-messages test compares right-to-left messages in
display order once #7532 lands (#7527).

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

* Locales: Vietnamese for the new strings

Vietnamese became a complete catalog on main (#5823), so every new English
message needs it too.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 15:17:11 +00:00
bherbruck 35913ced46 Fix flaky dielectric_preflight_and_start test: render a view that does not hit a box edge (#7722)
The default view sends the single pixel's ray through a box edge, which the
raytracer rejects as an ambiguous interface normal. The job then ended Failed
whenever the render thread got ahead of render.cancel (about 7% of runs under
load), and the test only accepted Cancelled or Completed. Render off-axis so
the outcome no longer depends on that race, and print the phase and error if
it ever fails again.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 15:16:48 +00:00
semmelrinde 02981d6bcf Lost GPU: a dropped frame no longer crashes the window's close (#7148) (#7400)
* claim: #7148

* Lost GPU: a dropped frame no longer crashes the window's close (#7148)

On a lost device wgpu's present and discard return early and leave the
acquired frame's image in the surface, holding a swapchain acquire
semaphore. Dropping the surface later (the window closing for the
restart after the loss) then panicked in wgpu-hal. The painter now holds
the frame as AcquiredFrame, which destroys a second handle to the
frame's texture after the frame is presented or dropped; wgpu-core's
destroy of a surface texture takes the image back without checking the
device, and does nothing once a present or discard took it.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 14:48:04 +00:00
amolinari-autotex 96b3ebba54 Physical Material: initialize from selected bodies (#7240) (#7241)
* Fix physical material dialog selection initialization

* Fix material selector translations for complete catalogs
2026-10-27 14:40:47 +00:00
semmelrinde 5bd8387cf7 Repeat Last Command in the command palette, with a key you can change (#7077) (#7353)
* claim: #7077

* Repeat Last Command in command search, with a key you can change (#7077)

Shift+R already started the last tool or dialog again (#3045), but it was a
key the app kept for itself: command search could not find it and Keyboard
Shortcuts could not change it. It is now the command edit.repeat, with
Shift+R as its default key. A call through the engine is refused, because
only the app has a tool to start.

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

* Locales: Arabic and Hebrew for the new strings

ar and he are complete catalogs on main now (#3458): every new message
needs them. The pending-messages test compares right-to-left messages in
display order once #7532 lands (#7527).

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

* Locales: Vietnamese for the new strings

Vietnamese became a complete catalog on main (#5823), so every new English
message needs it too.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 14:40:36 +00:00
semmelrinde ec877315c3 Sketch: add points to an existing spline (#6533) (#7384)
* claim: #6533

* Sketch: add points to an existing spline (#6533)

A spline could not take more points after it was drawn. The new command
sketch.spline.insert_point inserts a fit point on the curve where it
passes nearest a spot, between its neighbours, or adds a new first or
last point. Existing points keep their indices, so constraints stay. A
right-click on a fit point spline offers Add Spline Point at the clicked
spot. Control point splines are refused for now: with uniform knots one
more control point would reshape the whole curve.

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

* Locales: Arabic and Hebrew for Add Spline Point

ar and he are complete catalogs on main now (#3458). The pending-messages
test compares right-to-left messages in display order once #7532 lands
(#7527).

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

* Locales: Vietnamese for the new strings

Vietnamese became a complete catalog on main (#5823), so every new English
message needs it too.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 14:24:13 +00:00
github-actions[bot] 98d021c523 Regenerate generated files and fold pending files [skip ci] 2026-10-27 16:34:22 +00:00
tr1pp4 04e4e72ea1 History: delete a folder with its features, and suppress a whole folder (#7219) (#7220)
* claim: #7219

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

* History: delete a folder with its features, and suppress a whole folder (#7219)

`timeline.delete` and `timeline.suppress` take `groups: [timeline folder ids]`:
every feature in those folders and their nested folders, in one undo step.
A delete takes the folders themselves too; `timeline.ungroup` still keeps the
features. The History on the left offers Suppress/Unsuppress Selected Folders
and Delete Folders and Features… (asking first, like Delete Selected
Features…, when something outside needs them); a folder whose features are
all suppressed is drawn faint.

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

* History folder actions: Arabic, Hebrew and Vietnamese strings (complete catalogs)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 13:28:16 +00:00
Mateusz Sych 2151bc0497 Drive Joints: say why a rigid joint can't be driven and offer Edit Joint (#6981) (#7051)
* claim: #6981

* Drive Joints: say why a rigid joint can't be driven and offer Edit Joint (#6981)

Drive Joints opened on joint 0 when no joint moved, and its preview ran
joint.drive on that rigid joint, so the dialog showed a red "a rigid joint
does not move" with a Create Issue button.

The dialog now says a rigid joint holds its parts still and how to let them
move (Revolute to rotate, Slider to slide, Cylindrical for both), with an
Edit Joint button that turns it into Edit Joint on that joint. Its commands
refuse a rigid joint, so nothing is previewed and OK stays off. A rigid
joint picked in the browser opens Drive Joints on it to say the same; the
joint box lists only joints that move. joint.drive's own refusal names the
types that move and joint.edit.

* Drive Joints: the rigid joint message in Arabic, Hebrew and Vietnamese (#6981)

Main now delivers ar, he and vi complete, so every new message needs them.

---------

Co-authored-by: MateuszSychXpl <151755914+MateuszSychXpl@users.noreply.github.com>
2026-10-27 13:20:35 +00:00
DuBearte 1a9c4cd99d Sketch: a circumscribed polygon stays regular when dragged (#7395) (#7438)
* claim: #7395

* Sketch: a circumscribed polygon stays regular when dragged (#7395)

Circumscribed Polygon held its sides equal and tangent to the support
circle only. Equal sides round one circle leave every other corner free
(a rhombus round a circle), so a hexagon had 9 degrees of freedom instead
of 4, and dragging its centre or a corner pulled it out of shape (radius
10 became 8.02, angles 174/66 degrees).

Its corners now also lie on a construction circle concentric with the
support circle, which makes it regular as an inscribed polygon is, and
one side is tangent to the support circle, which sets its size: dof 4
(centre, size, turn). One tangent rather than all of them, so no
constraint is redundant.

Test in sketch_ux_tests: 4, 5 and 6 sides, dragged by the centre and by
a corner, stay regular with their sides on the support circle.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:47:35 +00:00
Colin Faulkingham b1cf66b628 Fix stored world-axis inputs for revolve (#7179) (#7406) 2026-10-27 12:45:40 +00:00
NOWN-DEV 285fa9433b Sketch: a distance along a line keeps its side (#7506) (#7510)
A point a distance from another point on the same line has two places on it, the same distance
either way. A solve after a value edit or a link move could land on the other one and report the
sketch solved, with no warning: the point jumped to the other side of its anchor, twice the
distance away. Such a distance is now an orientation the solves keep, like an unsigned offset's
side: which way along the line the one point lies from the other. One point must be on the line
by a constraint (an end, a point held on it, or coincident with either); the other may lie on it
by what else holds it (the mirror image of a point on it across a line square to it), read where
it is in the reference state.

Test: a point 3 mm along a linked rail from a point held on it, or mirrored onto it: the image
back past that point counts as a flip, and the rail walked far keeps the side.

Fixes #7506.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:43:05 +00:00
sascha2101 f5bfd6d1f2 Sheet metal: a base or contour flange hides its sketch (#6804) (#7151)
* claim: #6804

* Sheet metal: a base or contour flange hides its sketch (#6804)

Only an Extrude or a Revolve counted as using its sketch, so the sketch of a
base flange stayed shown. Its line on the plate's bottom edge lies exactly on
that edge and took the Flange dialog's click: a sketch line makes a contour
flange, a separate flat wall with no bend whose Flip moves it into the plate.

A base or contour flange now uses its sketch as an Extrude does: hidden unless
shown on its own, and shown while its feature is edited.

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

* Sheet metal: test that a click on the bottom edge makes an edge flange (#6804)

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

---------

Co-authored-by: sascha2101 <221609842+sascha2101@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:43:00 +00:00
bherbruck 48c8965853 CI: run the GPU path tests only for render changes, plus nightly (#7742)
The merge queue ran the four-shard GPU path tracer tests (about 56 runner-minutes)
on every group. Now they run when the diff touches render code (crates/render,
crates/gpu/src/path, the render workspace UI, assets/render), or a pull request
carries the new render-tests label (a pull request or a group holding one). The
nightly and manual runs still always run them; a push to main does not.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 11:58:30 -04:00
github-actions[bot] e7e8a1ef26 Regenerate generated files and fold pending files [skip ci] 2026-10-27 15:37:21 +00:00
Matthew Knutson ae530bff44 Typed contracts for the cylinder, sphere, torus, cone, pipe, bounding solid and scale (#2829) (#7145)
* Typed contracts for the cylinder, sphere, torus, pipe, bounding solid and scale

Contracts for solid.cylinder, solid.sphere, solid.torus, solid.pipe, solid.bounding_solid and
solid.scale, with `FeatureResult` output, and the shared types `AxisDir` (calling
`cmd::axis_dir_form`, split from `axis_dir_param`), `NumberExpr` and `ScaleFactors`. The
descriptions say what the evaluation does: the limits it enforces, the real default scope of
`targets`, and where `participants` overrides a key or has no effect. Scale makes no body, so it
declares `name` and `participants` itself. Box's descriptions get the same corrections. The
reply test runs every form in Warn and checks for no `param_warnings`; the guide lists the new
types and adds lessons from the conversion.

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

* Typed contract for the cone

`solid.cone`, added on main beside the primitives this PR converts, gets its contract and
`FeatureResult` output: the cylinder's keys plus `top_radius` / `top_diameter`, with the
evaluation's limits in the descriptions (a radius above 0, a top radius of 0 or more, a height
that is not 0 and may be negative, sizes within 1e7 mm). A `null` top radius stays the handler's
to refuse, since the handler takes it beside a `top_diameter`.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:42:16 +00:00
yhamil04 3574956d29 Graphics: the window no longer waits for Direct3D 12 shader compiles (#4860) (#7288)
* claim: #4860

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

* Graphics: the window no longer waits for Direct3D 12 shader compiles (#4860)

On Direct3D 12, wgpu compiles every pipeline's shaders with FXC when the
pipeline is made, and nothing is kept between launches, so the window
waited seconds for the viewport.

- The GPU tessellation shader, most of that time, compiles on a thread of
  its own. Bodies are built on the CPU until it is ready, then drawn from
  their plans; where it never compiles they stay on the CPU, as before.
- The viewport's pipelines build in parallel, one job per fragment shader
  (solidcraft_gpu::concurrent). A job that errs, panics or cannot start
  runs again on the calling thread, so errors reach the caller's scopes.
  Threads only on Vulkan, Metal and D3D12 with FXC.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:41:55 +00:00
semmelrinde 27728b10e2 Trackpad: inertia stops when Shift changes mid-orbit (#7327) (#7361)
* claim: #7327

* Trackpad: inertia stops when Shift changes mid-orbit (#7327)

macOS sends the inertia after the fingers lift as its own scroll bracket,
with the modifiers held at the time. Each move was routed by its own
modifiers, so releasing Shift while an orbit coasted turned the rest of
the inertia into a pan (and pressing Shift turned a coasting pan into an
orbit). A bracket that starts as the fingers' one ends now keeps the
fingers' mode and ends when the modifiers change. Switching between pan
and orbit while the fingers are down works as before.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:41:54 +00:00
JK3D 1c109be836 Headless pictures: no highlight left over from the picture before (#7225) (#7326)
* claim: #7225

* fix: headless pictures no longer keep the previous picture's highlight

Capturer::rgba_highlighted left the highlight slot empty when a picture had
no highlight. The renderer keeps its last highlight scene while the slot is
empty, so a selected edge's band drawn for one picture showed in the next
one on the same capturer. The depth tests share their capturers across test
threads, so they failed at random: an edge "192 of 612 px drawn", a washer
showing through its plate, a cut showing what lies behind it. A picture now
always gets a highlight scene of its own, empty without one.

Clearing the leak showed that a_selected_edge_on_a_grazing_face_draws_whole
passed at 88 degrees in a design of radius 30 only by counting the leaked
band: its reference line lies 143 mm behind the sheet's middle, past that
design's far plane, and drew nothing. The small design now holds the edge.

New test: a_picture_without_a_highlight_shows_none_of_the_one_before.
Full `--test depth` runs on Apple silicon: 10 of 10 failed before, 0 of 20
after.
2026-10-27 12:41:48 +00:00
JK3D 388266ca37 Rename: an imported body with numbered siblings is renamed without evaluating (#7274) (#7277)
* claim: #7274

* Renaming an imported body with numbered siblings evaluates nothing again

Since #2467, Model::rename_body evaluates the design whenever another body's
name is worked out from the renamed one's (`X (2)`). Every STEP import whose
solids share a label has such names (`Part`, `Part (2)`, ...), so renaming
one of its bodies re-read the import and meshed every body again.

body.rename keeps the names of a body's siblings and of later bodies named
after it, so for a body no later feature changes (the same mesh from the
feature that made it to the end), renaming it in the model gives the names
evaluating the renamed design gives. That case is followed again, as before
#2467; bodies later features change still take #2467's checks.
2026-10-27 12:41:39 +00:00
semmelrinde 2346c1ae81 Web app: Report a bug saves its screenshot as a download (#7192) (#7197)
* claim: #7192

* Web app: Report a bug saves its screenshot as a download (#7192)

Save screenshot… wrote the PNG with image's save, that is std::fs, which
the browser doesn't have. The screenshot is now encoded in memory and
written through vfs, as every other save is, so the web app hands it out
as a download. Natively the same file is written and ui.screenshot
replies as before. A failed save says why in plain text instead of a
Rust debug value.

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

* Web app: a screenshot without a path no longer panics (#7192)

`ui.screenshot` without a `path` saved to `std::env::temp_dir()`, which panics on
wasm32-unknown-unknown (std has no file system there), and the web app's control
requests come through `window.solidcraft.request`. `vfs::temp_dir()` now returns
`vfs::BROWSER_TEMP`, a folder of the in-memory files, in the browser, and
`save_screenshot` and `vfs::design_folder_refusal` (reached when a request names
`allow_paths` while a design is open) use it. Natively nothing changes.

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

* Web app: keymap and theme exports download (#7192)

Both exports wrote with `std::fs::write`, which always fails in the browser. They
now write through `vfs::write` like the other saves, so the web app hands the file
out as a download. Natively the file is the same; an agent's folder scope now
applies to them as to every other write.

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

* Tests: say what the folder-scope checks of the saves show (#7192)

The screenshot test wraps the write in a folder scope by hand: that proves the write goes through vfs, not that ui.screenshot's write runs inside the request's scope. ui.screenshot checks the agent's folders when the request arrives; the PNG is written in a later frame, outside that scope, as on main.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:41:21 +00:00
raykholo 8372c476ab Add the built-in CGA theme (#7593) (#7594) 2026-10-27 12:41:08 +00:00
Liu XingJian 4ad285570c Control channel: retire a subscription when the events thread ends (#7226) (#7336)
The seat of a subscribed connection on the DesignWatch lived in the
connection's serve thread. It left only when that thread saw the end of
the stream, so one unbounded blocking read stood between a closed client
and the count. Under load (GPU backend probing and crash-dump children
on Windows) that read can sit for seconds; the test's 10 s deadline then
expired and `!WATCH.subscribed()` failed (#7226).

Move the seat onto the events thread. The thread ends on `stop`, or on
the first write a closed client refuses, so the next publish retires the
seat. The test now publishes while it waits, and its wait no longer
depends on the serve thread.
2026-10-27 12:40:41 +00:00
Kai Evans 4766aea8d7 Themes: Okibi's and Akari's start page rail is the void, not the ember (#7061)
Neither theme set the File view's rail colours, so SolidCraft derived the rail from the accent and
the start page's whole sidebar was the primary orange, against the design system's one-or-two
ember budget (#4868 derives an unset rail from the accent). The rail is now n0 (void, beside the
app bar), its hovered and open row n3 (raised), and its text n11.
2026-10-27 12:40:40 +00:00
semmelrinde ad749d3399 Joints: the model is evaluated for the placements a solve leaves (#7084) (#7085)
Session::refresh evaluated the model and then solved the joints, which
writes the solved placements into the design. A feature that reads where
a part is placed kept the result made for the placement before the
solve: a mirror of a jointed part's body, made from the root, ended up
4 mm below the part after a move the slider took back.

When the solve moves parts, the model is now evaluated again for the
placements the design holds, and the joints solved for that model, at
most three rounds. Features that don't read placements come from the
cache.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:40:29 +00:00
NOWN-DEV 0f8593b180 Sketch: a tangent at an end the line and the round share keeps its side (#7428) (#7447)
A tangency at a point the two curves share (or a line's end held on the round) is solved as a
block of its own, and the orientations a solve keeps left it out. When a point placed by
parameters moved far, the line could land on the other tangent from its far end (its mirror
image) with every constraint met and no warning. It now keeps the round's side of the line, as a
tangency away from the ends already does: kept where the move can be walked there, reported as
a flip where it can't.

Fixes #7428.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:40:26 +00:00
Dahak Builds f8d2d90a16 Sketch: dimension placement and editing (#44) (#7056)
* claim: #44

* Sketch: step dimension texts clear of each other and keep placed offsets (#44)

* chore: re-trigger checks (Duplicate PR check was canceled by runner)

---------

Co-authored-by: Dahak Builds <dahakbuilds@users.noreply.github.com>
2026-10-27 12:40:19 +00:00
github-actions[bot] 69a46fb6a3 Regenerate generated files and fold pending files [skip ci] 2026-10-27 15:08:50 +00:00
github-actions[bot] b2f69ab314 Regenerate generated files and fold pending files [skip ci] 2026-10-27 15:00:37 +00:00
sascha2101 7e8ccad341 Browser: a body's name hides the body, not a sketch of the same name (#7206) (#7604)
* claim: #7206

* Browser: a body's name hides the body, not a sketch of the same name (#7206)

`browser.visibility` looked a key up as a feature first, so when a sketch
had exactly the name of a body (a sketch named after the body it makes),
the body's eye in the browser hid and showed the sketch instead, and the
body could not be hidden at all. The browser names a sketch by its id and
a body only by its name, so a name that is exactly both now names the
body. The sketch still hides and shows by its id; matches that differ
only in case are left as they are (#6967, #7004).

Fixes #7569 too.

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

---------

Co-authored-by: sascha2101 <221609842+sascha2101@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:40:02 +00:00
P'tite Tête d28874bec1 French: complete the UI catalog (#7131) (#7133)
* claim: #7131

* feat: Complete the French UI catalog (#7131)

- Add the 1,153 messages missing from fr.json as a pending fr block
- Keep the terminology already used in fr.json and French typography

---------

Co-authored-by: P'tite Tête <asso@ptitetete.com>
2026-10-27 12:39:53 +00:00
semmelrinde 95515ed2bf Report a bug: take the screenshot once the preview is done instead of giving up (#7171) (#7173)
* claim: #7171

* Report a bug: take the screenshot once the preview is done instead of giving up (#7171)

A report opened while a preview was being computed took no screenshot
and asked to be reopened, which loses what was typed. It now waits for
the preview with the report open and takes the screenshot as soon as it
is done; after 10 s it shows the preview unfinished and says so in the
report (a hanging preview is often what is being reported).

The preview the report waited for belongs to its context unless the
design, the dialog or the selection changed meanwhile, so the retake
check compares those alone. What is typed while the capture hides the
report waits for it, and its focused field gets the focus back.

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

* Report a bug: keep the new helpers apart from the other report changes

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

* Report a bug: keep what is typed as the waited-for screenshot is taken

The screenshot a report waits for starts in the frame the preview
finishes, before the form is drawn, so what was typed in that frame went
nowhere. It is now held with what comes while the form is hidden. The
report's context also leaves the dialog's own state out: a preview error
the dialog shows meanwhile (a sketch fillet's) no longer counts as a
change that refuses the screenshot, since it is often what is reported.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:39:32 +00:00
Victor Santos Oliveira 46552bdc3a Profiles: explain public selection forms in parameter errors (#7180) (#7491)
* claim: #7180 public profile selection diagnostics

* fix(engine): explain public profile selection forms
2026-10-27 12:39:31 +00:00
semmelrinde ae3d4faf53 macOS menu: disable design commands behind an open Report a bug draft (#7188) (#7193)
* claim: #7188

* macOS menu: disable design commands behind an open Report a bug draft (#7188)

The native menu bar kept running its actions while a Report a bug draft was
open, although the app's own toolbar and shortcuts wait behind it: Edit > Undo
or Cmd+Z removed the last feature, so the report described a design that no
longer existed. FileAction::enabled now refuses every action but Quit while a
draft is open. Settings, Keyboard Shortcuts and Agent Chat are refused too:
their windows open over the report but take no input until it closes. The
menu's Undo and Redo still go to a focused report field
(FileAction::menu_enabled), which no longer leans on Settings being enabled.

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

* Menus: Quit and Help's windows wait behind an open Report a bug draft

Quit switched to the first design with unsaved changes under the draft and
asked about it in a prompt that can't be answered while the report blocks
the app; with nothing unsaved it quit and lost the typed report. Like any
modal window, the draft now turns Quit off. Help's items that open a
window (About, Keyboard Shortcuts, Messages, Crash Reports, Connect AI
assistants) wait too, in the native menu and in the app's Help menu;
Documentation and Report an Issue… stay.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:39:27 +00:00
semmelrinde 05eb283242 Report a bug: focus the empty description once, not every frame (#7190) (#7195)
* claim: #7190

* Report a bug: focus the empty description once, not every frame (#7190)

After Continue on GitHub found What happened? empty, the form moved the
focus back to it on every frame, so typing in Summary went into the
description. Continue now re-arms the one-time focus (and scroll into
view) instead; the other fields stay free to type in.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:39:17 +00:00
yhamil04 6b7f1934b1 Startup tests: start the backends the app would, no OpenGL driver on Windows (#1156) (#7217)
* claim: #1156

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

* Startup tests: start the backends the app would, no OpenGL driver on Windows (#1156)

The software-adapter test enumerated every wgpu backend and the probe test
asked OpenGL on its own even where the app stops at D3D12, so both loaded the
GPU vendor's OpenGL driver (atio6axx.dll on AMD) into the test process, where
it once aborted the process at exit with c0000409. The adapter test now starts
what the software renderer starts (D3D12 alone on Windows) and the probe test
probes in the app's order, stopping at the first backend with a GPU.

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

* Startup tests: read the software renderer's backends from wgpu_options

Review: the adapter test now takes the backends from the app's own
wgpu_options(true, None) instead of a copy of its choice, and the probe test
no longer re-checks the order choose_backends' own test covers.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:39:01 +00:00
Badger B f9c8abb329 Add German translations, batch 1 (#7237) (#7260)
* Add German translations, batch 1 (#7237)

* Update translation for 'Import material into design'
2026-10-27 12:38:47 +00:00
CamWight 53cba0cf51 AGENTS.md: drop the leftover merge-conflict marker (#7279) (#7282)
The last line was a `>>>>>>>` marker left from resolving a conflict in #6444.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:38:38 +00:00
CamWight d95646378b cargo xtask skills: read SKILL.md front matter on a CRLF checkout (#7281) (#7284)
* claim: #7281

* cargo xtask skills: check the files as Git stores them, on a CRLF checkout too (#7281)

A Windows checkout with core.autocrlf gives AGENTS.md and the SKILL.md files CRLF line endings,
so `front_matter` found no `---\n` and every skill was reported, and each line's `\r` counted
towards the size caps. The check now reads the files with CRLF taken as LF, as #4960 did for
icon_audit. The new test checks a CRLF skill filled to the cap.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:38:34 +00:00
phanoob 75c8700724 Sketch: a circle centred on an existing point shows its Coincident glyph (#7299) (#7331)
* claim: #7299

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

* Sketch: a circle centred on an existing point shows its Coincident glyph (#7299)

The centre circle tool sent a centre snapped to an existing point as that
point's id, so the circle shared it: attached, but with no constraint and so
no glyph to see, pick or delete. A plain point snap now goes as its place,
where `connect` ties the new centre to the point with a Coincident the sketch
draws. Midpoint, on-curve, crossing and body-vertex snaps still go as refs,
so the centre stays held there (#3099 keeps passing).

Fixes #7299

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-27 12:38:23 +00:00
phanoob fac811565b Sketch: an arc centred on an existing point shows its Coincident glyph (Refs #7299) (#7346)
* Claim: centre arc on an existing point shows its Coincident glyph (Refs #7299)

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

* Sketch: an arc centred on an existing point shows its Coincident glyph (Refs #7299)

The Centre arc tool sent a centre snapped to an existing point as that point's id, so
the arc shared it with no constraint and the sketch drew no glyph. A plain point snap
now goes as its place, where draw's connect ties the new centre with a Coincident;
midpoint, on-curve, crossing and body-vertex snaps still go as refs.

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-27 12:38:15 +00:00
semmelrinde fb7ca86dc7 Highlight the selected sketch in the viewport (#7156) (#7347)
* claim: #7156

* Highlight the selected sketch in the viewport (#7156)

Picking a sketch in the Browser or the timeline selected it, but the
viewport lit nothing: the highlight only drew the bodies and faces a
feature made. It now draws all of a picked sketch's curves in the
selection colour, over the model and also while the sketch is hidden,
so a sketch is easy to find without hiding and showing it.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:38:11 +00:00
semmelrinde 9b84789da0 Report a bug: include the Messages log and other failing features (#7348) (#7349)
* claim: #7348

* Report a bug: include the Messages log and other failing features (#7348)

About half of the reports say "No command error was recorded" and often
can't be reproduced. The error was often in the Messages log, or on a
feature that was neither selected nor the latest. The technical details
now list every failing feature (id, name, kind, error; at most 10) and the
last 8 messages with level and age. Each entry is one quoted line of at
most 300 characters, with the home folder shown as ~.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:38:07 +00:00
semmelrinde 9d01dcb358 Open from computer: let iOS and iPadOS pick .solidcraft files (#6977) (#7351)
* claim: #6977

* Open from computer: let iOS and iPadOS pick .solidcraft files (#6977)

The iOS file picker matches an input's accept list by system file type,
not by extension. No system type has the .solidcraft extension, so the
designs were greyed out. On an iPhone or iPad the file inputs now offer
every file; other devices keep the extension filter.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:38:02 +00:00
semmelrinde 0b0886b87c A surface left without a configuration is configured again, not a crash (#7007) (#7401)
* claim: #7007

* A surface left without a configuration is configured again, not a crash (#7007)

wgpu reports device-loss errors to no error scope. On D3D12 a driver reset
found by Surface::configure drops the old configuration and reports nothing,
so the surface was taken as configured and the next acquire panicked with
"Surface is not configured for presentation". A configure that meets a lost
device or an unsupported surface is now a refused configure, the acquire runs
under an error scope, and its Validation status reconfigures the surface.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:37:31 +00:00
Jackson Oursland 6081226fa6 Test: the render-guides picture test keeps its evidence when it fails (#7440)
model_render_follows_the_grid_and_origin_switches_unless_asked compares nine
renders byte for byte and has failed once in a full run without coming back
alone. A mismatch now names the step, counts the differing pixels, lists the
renderer that drew each picture and keeps both PNGs.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 12:37:16 +00:00
Kai Evans 329ef29f24 Value fields on the canvas grow with an expression typed into them (#7470)
The value box beside a command's geometry and the placement fields kept a number's width, so a
formula or parameter names scrolled out of sight while typing. They keep that width for a number
and grow with the text, up to three times it.
2026-10-27 12:37:08 +00:00
semmelrinde f82e517bcb Toolbar: a panel with no buttons keeps one that opens its menu (#7166) (#7503)
* claim: #7166

* Toolbar: a panel with no buttons keeps one that opens its menu (#7166)

Removing New Sketch from the Model tab (Remove from Toolbar, or a drag off
the toolbar) left the Sketch panel empty above its label, so Sketch seemed
to be missing from the toolbar. A panel whose buttons are all gone is now
drawn like a collapsed panel: its first command's icon over a caret, which
opens the panel's drop-down, in the same room.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:36:57 +00:00
Colin Faulkingham e8302b5757 Sketch: delete dimensions from the right-click menu (#7155) (#7170)
* claim: #7155

* fix(ui): delete sketch dimensions from the context menu
2026-10-27 12:36:55 +00:00
Lars Jansen 98762fd20b i18n test: compare pending right-to-left messages in display order (#7527) (#7532)
* claim: #7527

* fix(i18n): compare pending right-to-left messages in display order (#7527)
2026-10-27 12:36:46 +00:00
semmelrinde e0809b427c Web app: Import… reads shortcuts and theme files through vfs (#7643) (#7644)
* claim: #7643

* Web app: Import… reads shortcuts and theme files through vfs (#7643)

Import… on the Keyboard Shortcuts page and the theme strip's Import… tile
read the picked file with std::fs::read_to_string. In the browser a picked
file is kept in vfs's in-memory files under /upload/, and std::fs on
wasm32 is unsupported, so every import failed. Both now read through
vfs::read_to_string, capped (a theme file at theme_files::MAX_BYTES, a
shortcuts file at the new toolbar_custom::MAX_IMPORT_BYTES), like the
other imports. Natively it is the same file on the disk.

The new tests import a file outside a vfs folder scope (refused, nothing
imported) and inside it (imported). With std::fs both fail: the file
outside the scope is imported.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:36:25 +00:00
Kai Evans 17339cf65c Redefine: the features after the edited one are computed once (#7076)
timeline.redefine evaluated every later feature in its trial session, then again in the session:
the evaluation cache keeps only the last few states of a design with large bodies, so the trial's
results were gone by then. The trial keeps them in the session's kept results (or its own), and
the session's evaluation takes them from there. EvalCache::with_budget gives tests a small cache.
2026-10-27 12:35:32 +00:00
bherbruck b5728f284b Release 0.24.1 2026-10-27 08:34:27 -04:00
bherbruck dc32cff620 Release 0.24.1: version bump, design format 40 (#7653)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 08:34:21 -04:00
Colin Faulkingham 22ede1bde3 Mouse gestures: keep the tool search open while editing (#7121) (#7132)
* claim: #7121

* Fix mouse gesture tool-search focus
2026-10-27 12:33:33 +00:00
sascha2101 7895c14e3f Preferences: the Colour vision preview shows the chosen mode again (#7152) (#7169)
* claim: #7152

* Preferences: the Colour vision preview shows the chosen mode again (#7152)

Since the preferences redesign the Colour vision section previewed a mode
with four status chips (Selected, Warning, Error, Done). No colour vision
mode changes those colours, so the chips looked the same in every mode.

The section shows the sample of what the modes recolour again (sketch
lines, axes, bends, Draft Analysis, a failed feature), drawn in the mode
being chosen (Tokens::get_in). The status chips stay as they are.

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

---------

Co-authored-by: sascha2101 <221609842+sascha2101@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:29:55 +00:00
semmelrinde d9ccba0134 Report a bug: scale a large window's screenshot down instead of refusing it (#7091) (#7092)
* claim: #7091

* Report a bug: scale a large window's screenshot down instead of refusing it (#7091)

A frame over 16 million pixels (a 6K display, a window across two 4K
screens) was refused with "Screenshot dimensions are unsupported", so the
report went out without its picture. Such a frame is now averaged down by
the smallest whole factor that fits.

The frame is also fitted to the longest side a texture can have: a frame
under the pixel limit but wider than the GPU's limit (three ultrawide
screens, 10320 x 1440, on a device limited to 8192) made the preview's
upload fail wgpu validation, and the window's device panics on that.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:29:09 +00:00
dudedagger42 531fc04db8 Inspect: center of mass measures the world state, as Measure does (#5246) (#5268)
* Inspect: center of mass measures the world state, as Measure does (#5246)

inspect.center_of_mass read the model state, so in an assembly a moved
occurrence was measured where its bodies were made, a second placement
was left out, and a placed copy's world name was "no such bodies". It
now reads the world state, as inspect.measure does, and agrees with
component.properties' mass centre at uniform density.

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

* Center of mass test: unground the placed part before moving it

Upstream now grounds a design's first part when it is made.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:04:53 +00:00
Nathan Dane 4907429439 Render workspace: original icon drafts for owner review (#6281) (#6283)
* claim: #6281

* assets: add Render appearance, library and paint icon drafts for owner review

* assets: add Render lighting, environment and light-type icon drafts for owner review

* assets: add Render camera, shot, frame and strip icon drafts for owner review

* assets: add Render output, result and compare icon drafts for owner review

* assets: list the Render icon drafts in the inventory and ATTRIBUTION

* assets: remake the Render light icons as one family with a shared dotted stem

* assets: remake the Render look icons around the lit look sphere

* assets: redraw the Render look icons so each action reads at a glance

* assets: record the owner's acceptance of the Render light icons

* assets: simplify the Render look icons and make Remove Override an alias of From Material

* assets: record the owner's acceptance of Render look icons

* assets: mark the removed look on From Material with a cross instead of an arrow

* assets: record the owner's acceptance of further Render icons

* assets: redraw the Render library, presets, environment, ground and backdrop icons

* assets: restore the first Render library and backdrop drawings and equalise the cube in the studio icons

* assets: record the owner's acceptance of the Render studio icons

* assets: redraw the Render camera and shot icons as one family

* assets: redraw the Render All Shots and Lens icons so they read at a glance

* assets: put the play badge back on the Render All Shots icon

* assets: redraw the Render output and result icons around one picture base

* assets: give Render Image its own play badge and enlarge the Compare handle

* assets: record the owner's acceptance of the Render output icons

* assets: describe the final Render icon drafts in RENDER_DRAFTS.md and ATTRIBUTION.md
2026-10-27 12:02:54 +00:00
Nathan Dane 192ef78ff3 ui_kit: lit sphere thumbnail and look-chain widget (#6281) (#6284)
* claim: #6281

* ui_kit: lit sphere thumbnail, cached and bounded

* ui_kit: look-chain widget (four steps, one active, empty steps dashed)

* ui_kit: add both to the gallery and the UI kit notes
2026-10-27 11:52:55 +00:00
Nathan Dane 44a0be8d94 Render workspace UX: roadmap and claim (#6281) (#6282)
* claim: #6281

* docs: record the Render workspace interaction design and implementation roadmap

* docs: plan the Render panels so each has one purpose

* docs: replace the tabs-and-ribbon plan with the stage-bar design

* docs: drop the note about the superseded draft

* docs: plan every Render panel and where each old control goes

* docs: defer the shared lighting rig, UI steps go first
2026-10-27 11:46:54 +00:00
vandang890615 9cba66c14d i18n: catch up Vietnamese (vi) catalog with latest main (3,317 keys) (#5823) 2026-10-27 08:45:02 +00:00
dudedagger42 4afd93331f Patterns: a body of a moved component is copied where it is shown (#4763) (#4969)
* Patterns: a body of a moved component is copied where it is shown (#4763)

A pattern of bodies made outside the bodies' component (a screw's body patterned from the root)
moved each body's stored geometry, which is in its component's frame, by transforms in the
pattern's frame. The copies landed where the body sits before its component is placed: near the
origin, for every pattern kind. As Mirror does, each copy is now moved as the pattern moves the
body in the world and kept beside its source, in its component.

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

* Pattern test: unground the placed component before moving it

Upstream now grounds a design's first part when it is made.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 08:27:38 +00:00
github-actions[bot] 698621dfdf Regenerate generated files and fold pending files [skip ci] 2026-10-27 08:19:30 +00:00
Lars Jansen 5fc23436ca History: show the origin and bodies at the top (#6490) (#6495)
* claim: #6490

* feat(history): show the origin and bodies at the top (#6490)
2026-10-27 07:46:25 +00:00
Jackson Oursland 76879f8b39 Viewport: write hover edges as one record per line, colours interned once (#7416)
Hovering many bodies re-emits every edge in the highlight's colours. Each edge
interned its three colours into the palette again, each segment was written in
eight small appends, and segments without their faces' normals encoded the
same zero normals again. Lines are now one record, the edge colours are
interned once per highlight, the buffers are reserved per edge, and the shared
width word is worked out once.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 07:46:19 +00:00
Jackson Oursland 514b0c84f9 Viewport: hold the blocks the sorted glass stream was taken from (#6970)
The no-OIT glass cache compared the blocks' vertex and run buffers by address
without holding them, so a freed buffer's address could come back for other
contents. It now holds them and compares with Arc::ptr_eq.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 06:35:15 +00:00
Jackson Oursland ddca1054ab Viewport: leave bodies out of the view out of their blocks' draws (#7436)
Instanced placements were culled per view, but a block drew every body in it
whatever the camera saw. Blocks now keep each body's vertex span and box; when
the view changes, each class's runs are drawn without the bodies whose boxes
are out of the view. A block whose bodies are all in view draws its runs as
they are.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 06:35:11 +00:00
Sloptimum 783ee91404 docs: a new label needs Arabic and Hebrew too (#3457) (#3458)
Arabic and Hebrew are complete catalogs since #3768, so
every_complete_catalog_carries_every_english_message fails a pull request
that adds an English message without them. The localization guide's list of
languages a new label needs now says so.

Co-authored-by: rootarchitect <7781866+rootarchitect@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 06:00:02 +00:00
taylorhayis 38dedcca3b Move/Copy can select from the browser (#4771) (#4774)
* claim: #4771

* Move/Copy takes a browser body the same way Combine does (#4771)

A body row feeds the dialog's active input through the same pick as a
click in the view, and does not replace the selection (#4452). A group,
the Bodies folder, or a component is that pick for every body in the
set. M and the Move/Copy button also start from a selected body or from
a component picked in the tree.
2026-10-27 05:23:56 +00:00
github-actions[bot] 99cca6a11a Regenerate generated files and fold pending files [skip ci] 2026-10-27 05:40:43 +00:00
ABDULLAH af07f4e0e5 Arabic interface language with right-to-left text (#3762) (#3768)
* claim: #3457

* Hebrew interface language with right-to-left text (#3457)

Adds a complete Hebrew catalog (assets/locales/he.json, original
translations) and registers `he`.

egui 0.36 shapes each font run with harfrust, so a run of Hebrew letters
is drawn right to left, but it places the runs of a line left to right in
stored order. The new `bidi` module runs the Unicode Bidirectional
Algorithm (unicode-bidi, MIT OR Apache-2.0) over each message of a
right-to-left catalog once, giving egui the runs in display order with
brackets mirrored and placeholders kept whole, while leaving each Hebrew
run's letters in reading order for the shaper. A test lays messages out
with a real Hebrew font and checks where the glyphs land.

Desktop builds append a system Hebrew font (Arial Hebrew / SF Hebrew,
Segoe UI / Arial, Noto Sans Hebrew / DejaVu Sans). Layout mirroring,
wrapped multi-line RTL paragraphs and a web Hebrew font are follow-ups.

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

* Hebrew: translate the new Describe Command label; list he in the control protocol

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

* Hebrew: catch up with main's catalog and the new ribbon (#3457)

- Translate the 258 messages main added since the branch was cut, drop
  the 38 it removed (10 renamed ones keep their translation) and
  retranslate the two whose English changed (toolbar → application bar).
- The timeline title is ציר הזמן, matching the glossary.
- Ribbon buttons split their labels into two lines from the start of
  the string; Hebrew labels come in display order, so the first line
  showed the end of the phrase ("חדשה / סקיצה"). For right-to-left
  labels the first line now takes the words at the end of the string,
  and a shortened line loses characters, and shows its ellipsis, on the
  left. The word-boundary sweep checks Hebrew from the lines the galley
  is built from, since epaint's right-to-left rows carry zero-width
  glyphs.

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

* Hebrew: after rebasing onto main, load the Hebrew face with the cached system fonts

main now searches the system fallback faces once (SystemFonts, on a thread);
the Hebrew face joins that search with its coverage check. Drop the three
camera-animation messages main removed (#4296) from the Hebrew catalog.

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

* Hebrew: translate the messages main added after the catch-up (#3457)

Translates the messages main added since the last catch-up: the history
bar's buttons and name toggles, the 3D mouse driver and axis mapping, the
thread size columns, the File view (home page), the redesigned Preferences, the new issue report
dialog, free orbit and the assistant's setup card. Drops the one message
main removed. The catalog has every English message again, so the
complete-catalog checks pass for he.

* Arabic interface language (right-to-left, on the Hebrew bidi path)

Adds an original Arabic translation of the whole English catalog
(assets/locales/ar.json, every message) and registers it. `ar` joins the
complete catalogs, so a message added to en.json later fails the build
until it is translated. It goes through the right-to-left path of #3458
unchanged: crate::bidi puts each message in display order and leaves
every Arabic word's letters in reading order for harfrust, which joins
them with the font's own tables. Ribbon labels use the right-to-left line
split from the same stack.

fonts.rs adds a system Arabic fallback after the Latin interface font,
which keeps spaces, digits and Latin text so each Arabic word stays one
shaped run: Segoe UI/Tahoma/Arial on Windows, SF Arabic/Geeza Pro/
Damascus/Tahoma on macOS, Noto Sans or Naskh Arabic/KACST One/DejaVu Sans
on Linux, or NotoSansArabic-Regular.ttf in CRAFT_FONTS_DIR. It is the first
fallback for an Arabic UI (Arial Unicode, a CJK candidate, has Arabic
letters without joining data) and the last otherwise; like Thai and
Hebrew it is never promoted for a CJK interface.

Tests: the catalog comes back in display order with no directional
controls, and Arabic laid out with a real font lands on screen word by word
in reading order.

* Hebrew: review the catch-up translations; restore the camera-animation messages (#3457)

- Review of the 533 messages translated in the previous commit: 21
  corrected. Keys in the motion dialog are keyframes (נקודות מפתח, as
  in Add Key), the Design workspace is תכן as in its label, a dropped
  "(Direct Driver)" is restored, terms aligned with the rest of the
  catalog (פורמט, סוכן קוד, רצועת הכלים), and a few calques reworded.
- Translate ui.animate_view_changes, ui.animation_speed and
  ui.look_at_anim_desc, which main has again, so he stays complete.

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

* Arabic and Hebrew: translate the messages main added since (#3457, #3762)

Both catalogs again carry every English message. The 760 messages main added
since the last rebase (render workspace, material editor, standard views,
sheet metal Path Flange, AI app connections, document-on-disk notices and
more) come from A-AL-ANAZI's translations on #3768; the five Perforate dialog
messages are new here. The earlier Hebrew review is kept on top of them, and
33 of the new Hebrew messages are corrected: "completed" was "perfect"
(מושלם → גמור), Regrade read as ranking (now תיקון צבע מחדש), shaders,
guarantee, the Mirror reflection mode, simulations and displacement.

Co-authored-by: Abdullah <alksa2025@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Arabic and Hebrew: attribution rows as a pending file

main now takes new ATTRIBUTION.md rows as one file per change
(attribution/pending/), so the he.json and ar.json rows move there.

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

* Arabic and Hebrew: translate the 23 messages main added since (#3457, #3762)

Both catalogs again carry every English message; the two theme-page messages
main removed are dropped. Twelve of the new messages (link updates while
loading, the agent chat's model choice, plugin search, render quality) come
from A-AL-ANAZI's translations on #3768; the sketch plane descriptions, the
rigid group and empty-boolean messages, free sketch points and the web update
note are new here.

Co-authored-by: Abdullah <alksa2025@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Arabic and Hebrew: translate the 12 messages main added since (#3457, #3762)

Eleven come from A-AL-ANAZI's #3768; "Turn Up Axis" in Hebrew follows the
catalog's existing up-axis wording, and the agent chat's no-tool-call message
is new in both.

Co-authored-by: Abdullah <alksa2025@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Arabic and Hebrew: translate the 22 messages main added since (#3457, #3762)

* Arabic and Hebrew: a label found by its English text comes back in display order too (#3457, #3762)

* Arabic and Hebrew: translate the 32 messages main added since (#3457, #3762)

* Arabic and Hebrew: translate the 34 messages main added since (#3457, #3762)

* Arabic and Hebrew: translate the 30 messages main added since (#3457, #3762)

* Arabic and Hebrew: translate the 7 messages main added since (#3457, #3762)

---------

Co-authored-by: rootarchitect <7781866+rootarchitect@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Abdullah alanazi <x1700@MacBook-Air-Abdullah-2.local>
2026-10-27 05:05:53 +00:00
github-actions[bot] 0c16cfefbb Regenerate generated files and fold pending files [skip ci] 2026-10-27 04:40:23 +00:00
github-actions[bot] b2189e3f4d Regenerate generated files and fold pending files [skip ci] 2026-10-27 04:31:59 +00:00
Victor Santos Oliveira 6e12f5954f Navigation: snap free orbit direction and roll with Shift (#6943) (#6949)
* claim: #6943 free orbit Shift snapping

* fix(ui): snap free orbit direction and roll with Shift

* test(ui): align free orbit coverage and complete snap help translations
2026-10-27 04:11:16 +00:00
Jackson Oursland 3ad5a360f2 Engine: keep the model and placed world across appearance-only edits (#7396)
Evaluation and placement never read appearances, yet an appearance edit
re-evaluated the model and placed every occurrence again. A whole-document
proof that only appearances changed now takes the existing render-only refresh,
which keeps the model and retargets the world-placement cache.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 03:40:29 +00:00
Nathalie Schönwetter 6848c78424 Sketch Transform: one box with handles to scale, move and turn what is picked (#6004)
* Sketch Transform: scale, turn and move sketch entities in one command and one undo

sketch.transform scales about a centre, turns about it, then moves; the window and its
handles for existing sketch geometry build on it.

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

* Sketch Transform window: one box with handles to scale, move and turn what is picked

Select curves and points in a sketch and press G (or Transform in the Modify panel or the
context menu): the picks get a box with a handle at each corner to scale (the opposite corner
stays), the box to move and a knob above it to turn (Shift snaps to 15 degrees). Position,
Width, Height, Scale and Angle in the window are the same values, to type exactly; OK applies
it as one step.

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

* Move the new message to a pending locale file

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

* Sketch Transform: follow the sketch field of Sel::SketchCurve

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

---------

Co-authored-by: Nathalie Schönwetter <nathalie@cfw-ag.ch>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-27 03:34:07 +00:00
DuBearte b47f82d406 Reopen the designs open at the last quit (#5542) (#6006)
* claim: #5542

* Reopen the designs that were open at the last quit (#5542)

A launch with no file to open reopens the saved designs that were open
when SolidCraft was last closed, in tab order. Designs never saved and
files that are gone are skipped. Preferences > General > Reopen designs
turns it off; a launch driven over --control or with --sample starts fresh.

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

* Move the new messages to a pending locale file

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-27 03:34:04 +00:00
Jackson Oursland 614e006eac Engine: keep every placed body's frame with the world state (#7411)
Each command that names a body (appearance, material, move, ...) mapped it to
its component's own name by working out every placed body's world name and
frame, so a command on a large assembly cost a walk of the whole design. The
list is now kept with the world-placement cache while it is current.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 03:33:53 +00:00
Jackson Oursland 4e07b6c4b7 Assembly: name placed bodies without rescanning the design (#7380)
Naming a placed body scanned every occurrence and body of the design, so the
world state and every caller naming all placed bodies grew with bodies times
design size. WorldNames indexes the design once and names each body in time
proportional to its occurrence path; one naming routine serves both it and the
single-body scan. Names are unchanged (a test compares them with the scanning
implementation).

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 03:30:33 +00:00
Jackson Oursland efa2cae0b4 Viewport: draw faded and see-through bodies from their plans with OIT (#7423)
* Viewport: palette-indexed scene and incremental appearance updates

- Move triangle, line and instance colours and opacity into a per-scene
  palette (an R32Uint texture, WebGL2-safe); appearance edits update
  palette entries instead of vertex bytes.
- Build CPU body triangles as one canonical vertex stream per block with
  opaque/glass/faded index lists; an opacity class change rewrites only
  the index list.
- Cache resolved body appearances by a per-body key, so an edit resolves
  only the bodies whose look changed.
- Split the model scene key from the session revision: selection and
  view-only commands no longer rebuild or upload the model scene.
- Replace the O(n^2) section-index lookup and per-placement instance scan
  with hash lookups.

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

* Viewport: skip unchanged palette work when rebuilding

- Reuse each body's appearance key while the document is unchanged.
- Leave a palette slot alone when its value did not change, and allocate
  every body's slots once before the build loop.

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

* Viewport: measure scene rebuild cost by edit kind

An ignored scale test times the scene build after selection, appearance,
theme and one-body geometry edits at 200, 1,000 and 5,000 bodies.

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

* Viewport: read faded grid alphas through the scene palette

The grid-fade tests now resolve line palette indices instead of reading the
line record's old colour byte.

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

* Viewport: measure rebuild cost on few large meshes

Adds the 20/100-torus case where a block rebuild used to re-emit a large
part of the model.

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

* Viewport: measure rebuild cost on an assembly

Unique detailed parts plus one component placed many times, timed for
selection, part and component colour, opacity, theme, hide and move.

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

* Viewport: draw block classes as vertex runs, not index lists

A body's vertices are three per triangle in mesh order, so each draw class
is a few runs of the block's vertex buffer: all-opaque blocks need no index
buffer, and an opacity class change uploads nothing. A fragmented class
(more than 64 runs) falls back to one index buffer.

Each triangle is written as one record into a block reserved up front,
which takes the cold scene build of a 7.1M-triangle assembly from 97 ms
to 55 ms (main: 83 ms).

Also fix reindexing a block whose bodies are drawn elsewhere: it walked
the mesh's triangles from that body's start, past its own vertices.

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

* Viewport: draw the highlight's own block, and light faded bodies without copies

The highlight scene keeps its feedback (a hovered face, a body copied on
the CPU) in a block of its own; since blocks became vertex runs, only the
model's blocks were drawn, so that feedback was uploaded and never shown.
Draw the highlight's blocks with its triangles.

Whole faded and see-through bodies (another component active, or an
appearance below full opacity) were lit by copying every triangle on the
CPU at each hover change: 85 ms for a design row of 812 bodies. They are
now drawn again from the model's own block vertices, in the highlight
palette with their slots set to the highlight colour, or as placements of
the model's shared triangles. The picture is the same as the copy's.

Adds a GPU frame benchmark for document-tree hover (ignored test).

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

* Viewport: count scene uploads, and keep the sorted glass stream across colour edits

Renderer::uploads reports the bytes the model scene's last change uploaded
(geometry, placements, palette rows, planned colours, re-evaluated vertices).
Without order-independent transparency the global glass stream is kept while
the blocks' see-through triangles are the same, so a colour edit on a glass
body uploads palette rows only. Tests assert both on a GPU.

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

* Viewport: keep palette slots and looks without rehashing every body

A rebuild found every body's palette slots by name and wrote them back,
allocating and hashing a new key per body, and copied every shown name into a
set to check the arena still belongs to the design. Slots are written back only
when a body's faces grew, and the looks cache is pruned only when it holds more
bodies than are shown. Adds the STEP-file benchmark (SOLIDCRAFT_SCALE_STEP) and
the colour command's own time to the assembly benchmark.

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

* Viewport: round-pick construction lines through the scene palette

The axis and centre feedback for round picks (#4610) drew with a raw colour;
on the palette scene it is interned once like the other construction geometry.

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

* Viewport: draw faded and see-through bodies from their plans with OIT

A body faded outside the active component, or see-through, left the GPU
tessellation path and was built triangle by triangle on the CPU, so working in
a component of a large assembly rebuilt every other body. Where the renderer
blends see-through surfaces order-independently (GpuTarget::oit, set by the
renderer), a body whose faces share one class is drawn from its plan: chunks
are split by class, opaque passes, AO and the section draw only opaque chunks,
and the OIT pass draws the rest. The sorted fallback keeps them on the CPU.
The model and its highlights decide through one rule (model_plan_class).

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

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
2026-10-27 03:27:32 +00:00
github-actions[bot] 78d80e0144 Regenerate generated files and fold pending files [skip ci] 2026-10-27 03:52:42 +00:00
CarelT 13f650b695 Engine: one module for the design-link path helpers (no behaviour change) (#5420)
* Move the design-link path helpers into one module

`component_link.rs`'s path rules for links to other design files (base_dir,
absolute, relative, stored, resolve, rebased) move, unchanged, into
`cmd/design_link.rs` with their test, so other kinds of link (#3891's drawing
files, linked parameters #5354, #5113's extract) share them instead of copying
or re-exporting them. No change in behaviour.

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

* design_link: keep base_dir and relative private

Only design_link.rs uses them; the module's surface is absolute, stored, resolve and rebased (review on #5420).

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

* component_link: import Document after the rebase

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

---------

Co-authored-by: Carel Theron <careltheron@Carels-MacBook-Pro.local>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-27 03:10:16 +00:00
Victor Santos Oliveira a7c1909766 CLI: evaluate scripts on the input design (#6861) (#6872)
* claim: #6861 eval scripts on input designs

* fix(cli): evaluate scripts on the input design (#6861)

* fix(cli): validate eval options before opening inputs

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 02:55:01 +00:00
Colin Faulkingham 702f35ae6e Loft: keep hidden bodies out of automatic Join targets (#1988) (#6894)
* claim: #1988

* fix: preserve hidden bodies in automatic loft joins (#1988)
2026-10-27 02:54:56 +00:00
Lars Jansen 2d629069c6 Dialogs: OK and Cancel at the top (#6708) (#6713)
* claim: #6708

* feat(dialogs): OK and Cancel at the top (#6708)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 02:42:23 +00:00
Homero Thompson 4b327bab1f Canvas: the value boxes do not cover the command window (#6608) (#6661)
* claim: #6608

* Canvas: keep value boxes clear of the command window (#6608)

* Canvas: describe how value boxes clear the command window (#6608)

* Canvas: keep the value boxes clear of the gizmo handles (#6608)

* ui-egui: format the value box regression test (#6608)

* gpu: the startup MSAA check skips when there is no GPU adapter

The check needs a real adapter to draw the multisampled view and to
resolve it, so on a machine with no software Vulkan adapter it is
skipped, with the reason printed, instead of failing the build.
SOLIDCRAFT_REQUIRE_GPU=1 still makes a missing adapter a failure, as in
the CI job that has a software adapter.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 02:29:53 +00:00
github-actions[bot] 237341423c Regenerate generated files and fold pending files [skip ci] 2026-10-27 02:49:11 +00:00
dudedagger42 b99761a3f7 Extrude: a cut made while bodies are hidden leaves them alone (#3961) (#5148)
A cut (or intersect) that names no bodies went through every body it met,
hidden ones too: a body hidden to keep it out of the cut was cut anyway. When
some of the active component's bodies are hidden, the cut now stores the shown
ones as its targets, so the hidden bodies stay as they are, and stay so once
shown again (the model never depends on what is shown). With nothing hidden it
goes through every body it meets, as before.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 02:23:48 +00:00
DuBearte 3726959d82 Control channel: check the token before parsing the whole request (#6643)
Until a connection had shown the token, its first line (up to 16 MiB)
was parsed whole into a JSON value. A line of [0,0,...] that size is
hundreds of MB once parsed, so 16 connections without a token could
make the app run out of memory and lose unsaved work. The token is now
read from the request object without building the rest of it: other
fields are only skipped, and anything but an object with a string
token is refused as before.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 02:04:28 +00:00
HotThrower71 af1d15b5ea Canvas to Sketch: trace a canvas image into a sketch on it (#3931) (#4263)
* Canvas to Sketch: trace a canvas image into a sketch on it (#3931)

New command canvas.to_sketch, offered as Canvas to Sketch in the canvas
context menu. It traces the canvas image (automatic Otsu threshold, light
drawings on dark backgrounds inverted) with interpolated marching squares
and Douglas-Peucker, and adds lines and cubic Bezier splines through Insert
SVG's add_geometry to a new sketch on the canvas plane, with the canvas'
width, centre, rotation and flip. Holes stay holes; one undo step.

The image's declared width x height is checked before decoding (at most
40 megapixels) and the decoder runs under image::Limits, since canvases
are capped by file size, not pixel count.

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

* Move the new message to a pending locale file

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-27 01:39:18 +00:00
github-actions[bot] 84a3a0c478 Regenerate generated files and fold pending files [skip ci] 2026-10-27 01:57:22 +00:00
github-actions[bot] 1551c0acef Regenerate generated files and fold pending files [skip ci] 2026-10-27 01:51:03 +00:00
Lars Jansen f1cc49bcdb History: list a sketch under the feature built from it (#6489) (#6494)
* claim: #6489

* feat(history): list a sketch under the feature built from it (#6489)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 00:59:57 +00:00
Homero Thompson 622a392a83 Extrude to Object: a Join extrude also joins the target body (#6424) (#6471)
* claim: #6424

* fix: a join extrude also joins the body it targets (#6424)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 00:53:25 +00:00
phanoob 5622963b91 Insert SVG / DXF: place, size and turn the drawing before it is added (#2605, #3443) (#3542)
* claim: #2605

* Insert SVG / DXF: place and scale the drawing before it is added (#2605, #3443)

After the file is picked, an Insert SVG (or DXF) window sets the
drawing's X, Y offset and scale, with a live preview; OK adds it in one
step and Cancel adds nothing. The insert commands already took `at`
and `scale`; the window is what was missing.

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

* Insert scenario: the picked drawing goes through the placement window (#2605)

`sketch_toolbar_imports_use_file_picker` expected the SVG added as soon as
it was picked; it now checks the placement window first and adds it with
OK.

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

* Insert SVG / DXF: a size in millimetres, an angle, and placement by the drawing's centre

sketch.insert_svg and sketch.insert_dxf take width or height (mm, aspect kept, winning over
scale), angle (degrees) and align (origin or center). The new sketch.drawing_bounds reads a
file's own size without inserting it.

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

* Insert SVG / DXF window: Width, Height and Angle, placed by the drawing's middle

Width and Height in millimetres follow the scale (aspect kept); Angle turns the drawing
about its middle, where X and Y now put it. Uses sketch.drawing_bounds for its own size.

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

* Insert SVG / DXF: handles in the viewport to scale, move and turn the drawing

The preview gets its box with a handle at each corner (scales about the opposite corner,
aspect kept), the box itself (moves) and a knob above it (turns, Shift snaps to 15 degrees).
They edit the same X, Y, Scale and Angle the window shows.

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

* Insert handles: the move that comes with the release counts

A quick drag could stop short of the pointer's last position because the final move arrives
in the same frame as the release.

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

* Insert SVG / DXF: the Drawing Size command's label in the catalogs

`sketch.drawing_bounds` came without a catalog entry, which the i18n
test requires (and the complete ja and zh catalogs with it).

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

* Move the new messages to a pending locale file

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>
Co-authored-by: Nathalie Schönwetter <nathalie@cfw-ag.ch>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-27 00:50:35 +00:00
github-actions[bot] 3bea744823 Regenerate generated files and fold pending files [skip ci] 2026-10-27 01:25:12 +00:00
N3MI-DG e8eb7ee11a Components: copy and paste bodies and components between designs and components (#3390) (#3390)
Squashed for the rebase onto main.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 00:33:57 +00:00
yhamil04 ebb6cc53ab Crash reports: capture native crashes on macOS (#2113) (#6593)
* claim: #2113

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

* Crash reports: capture native crashes on macOS (#2113)

A signal handler for SIGSEGV, SIGBUS, SIGILL, SIGFPE, SIGTRAP and SIGABRT
writes the same <journal>.native.json as the Windows exception filter: the
signal, the faulting module and offset, the thread and a frame-pointer walk.
The next start reports it instead of "an unclean exit". The signal then goes
on to the previous handler or the default action, so macOS still writes its
own crash report.

native_crash_macos.rs joins the crash-safety skill's list of files that may
use unsafe (no safe API can catch these signals).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 00:14:00 +00:00
github-actions[bot] 924724dc89 Regenerate generated files and fold pending files [skip ci] 2026-10-27 00:34:00 +00:00
HiroKenEz 56bcd872cb Cone primitive: solid.cone with base and top radius (#5641) (#6250)
* claim: #5641

* Cone primitive: solid.cone with base and top radius (#5641)

A cone or truncated cone placed like the cylinder: base centre, axis,
base radius (or diameter), top radius (0, the default, is a full cone)
and height, with the new body / join / cut / intersect choice. It is
built from what the kernel already exposes, a revolve of its half
section about +Z through the origin, moved into place. Toolbar entry
after Torus, a Cone dialog that also places it on a picked face, Edit
Feature, a timeline icon, pending UI strings and attribution, and one
pending design-format change.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 23:57:43 +00:00
yhamil04 6c0fca30a7 Press Pull on a fillet's round face edits the fillet (#1932) (#6815)
* claim: #1932

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

* Press Pull on a fillet's round face edits the fillet (#1932)

Q on a fillet's round face opened Offset Face, which refuses a face
between two others it is tangent to ("a corner whose faces don't meet
the same way after the move"), so OK stayed disabled. The engine's
solid.press_pull already changes the fillet's size on such a face; the
window now does the same and reopens the fillet (or chamfer) to edit.
Other curved faces still open Offset Face, planar faces Extrude.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 23:34:09 +00:00
yhamil04 ef1d02f274 Claim bot: let an unassigned claimant /unclaim (#6718) (#6719)
* claim: #6718

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

* Claim bot: let an unassigned claimant /unclaim (#6718)

GitHub doesn't assign people who aren't collaborators, so many claims
have the `in progress` label and the bot's "Claimed by @user" reply but
no assignee, and `/unclaim` refused the claimant. When nobody is
assigned, `unclaim()` now also accepts the user named in the bot's
latest "Claimed by" reply on the timeline, unless the label was removed
after it. Maintainers and the owner rule are unchanged.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 23:28:46 +00:00
github-actions[bot] 367aa249bc Regenerate generated files and fold pending files [skip ci] 2026-10-27 00:07:22 +00:00
The Nomad afd6a5c584 Send to Slicer: send the selected bodies to a slicer (#2595) (#3600)
* Add Open in Slicer for the selected bodies (#2595)

One command saves the selected bodies as a 3MF in millimetres (an STL if the 3MF can not be written) and opens it in the last slicer used. It is on the File menu, the Mesh tab and the body right-click menu. The first time, or when that slicer is gone, it asks which one with the same list Save as mesh uses. The Save as mesh dialog is unchanged.

* Send slicer files to a private per-user folder (#2595)

Files sent to a slicer used to go to a shared folder in the temp directory. They now go to the user cache folder (mode 0700 on Unix), under a name that stays the same for the same design and bodies, so sending again replaces the file and the slicer can reload it. The file is written to a temporary name and renamed into place. If the name can not be replaced, as on Windows while the slicer holds the file open, the copy gets a number and the status line says so. Files older than seven days are deleted at startup. The first send tells the user that sending again replaces the open file, and so does the tooltip of Open in Slicer. Send to slicer in Save as mesh uses the same path.

* Open files in a running slicer (#2595)

PrusaSlicer, SuperSlicer, Bambu Studio and Cura are started with --single-instance, so a slicer that is already open loads the file instead of opening a second window. OrcaSlicer has no such flag and is left alone, and macOS bundles are single-instance already. PrusaSlicer 3.0 ships a launcher entry that already carries --single-instance-on-url; it keeps that one and never gets a second flag. It is a preference, on by default, and scripts can pass single_instance. Preferences also gets a button to forget the slicer Open in Slicer remembers.

* List slicers the desktop entries say open 3MF or STL (#2595)

On Linux the scan of desktop entries now also lists a program that claims 3MF or STL in MimeType, even when it is not in our table of known slicers. The table still supplies names, ids, icons and flags for the ones it knows. Entries marked NoDisplay or Hidden are skipped, and so are terminal programs and SolidCraft itself. The existing size and count limits stay. The Windows scan now looks inside a Prusa3D folder, where PrusaSlicer installs itself.

* Only start slicers the user can see or chose (#2595)

file.save_mesh took a slicer parameter that could be any executable path, so a script or an MCP client could run any program. The slicer now has to be one of the detected slicers (by id or name) or a program the user already added under Custom, which the app hands to the engine from its settings. The same rule holds for file.open_in_slicer. The agent chat already asked before any file command; its question now also names the slicer.

* Review fixes for Open in Slicer (#2595)

The file name hash keeps all 64 bits instead of 32. When Open in Slicer has to fall back to an STL, the status line now says so. The engine test that sets the chosen programs clears them again. The unused "could not make the folder" message is gone from the catalogs, and the docs say plainly that finding slicers by file type is Linux only.

* Keep unsaved designs apart and hide Open in Slicer in the browser (#2595)

Two unsaved designs with the same name and body names got the same file in the slicer folder. Every document now has a uid that is new for each New, Open and Recover and stays the same for the life of the document, and the file name uses it (with the process id) until the design has a path. At the first Save As the name changes to the path-based one, so the slicer gets one new file; the old one goes with the seven-day sweep.

The browser can not start a program, so Open in Slicer is now disabled there: the engine command reports why, the File menu item and the body and component right-click items are greyed, and starting it anyway says so on the status line instead of opening a dialog. The Save as mesh dialog keeps its browser guard for the open flow too.

* Put the Session doc comment back on Session (#2595)

The new NEXT_UID static had landed between the doc comment and the struct it describes.

* List every install of a slicer, and test the first Save As (#2595)

Detection kept one entry per known slicer, so a system PrusaSlicer 2.9 and a Flatpak 3.0 beta showed up as one. Every distinct install is listed now. The same program found twice, through a desktop entry and through PATH, is still one. When a slicer has several installs, each gets an id made from where it was found (prusa#comprusa3dprusaslicer) that stays the same on every run, and a name worked out from its command and location without running anything: PrusaSlicer (system), PrusaSlicer (Flatpak, beta), PrusaSlicer (AppImage). A lone install keeps its plain id and name. The plain id or name still picks the first install, so a remembered "prusa" keeps working. Single-instance flags and the other per-slicer behaviour go by the id without the install part.

Also a test that the first Save As changes the file name once and later sends keep the name that comes from the saved path.

* Start PrusaSlicer 3.0 first and send the file once it is ready (#2595)

PrusaSlicer 3.0 starts through slic3r-app-launcher, which ignores a file given at a cold start whatever the flag, and loads it over D-Bus when an instance is already running. A small table in slicers.rs names such slicers by their command; today it holds that launcher, so PrusaSlicer 2.9 and OrcaSlicer are untouched. Ready means a name starting with com.prusa3d.prusaslicer.BroadcastListener. is on the session bus, read from busctl --user list with no new dependency. If busctl is missing or fails, nobody waits and the file is sent as before.

When the slicer is not ready, Open in Slicer starts it without the file and looks every 200 ms for up to 10 seconds. The wait runs on another thread, so the window stays live, and the status line says Starting PrusaSlicer. Once the name appears the file is sent with the same arguments as before. If the limit passes, the status line says the slicer did not respond, that the file is already saved, and to send again. A second click during the wait only repeats the Starting line, so there is one launch and one send. Scripts calling file.open_in_slicer wait inside the command by default, or pass wait: return or no.

* Always show the slicer picker for Open in Slicer (#2595)

File > Open in Slicer, the Mesh tab entry and the right-click entry now always open the picker, with the slicer used last time pre-selected, so the usual case is one click or Enter. Scripts and file.open_in_slicer with a slicer still send without a dialog. The preference button that forgot the remembered slicer is gone: it could no longer make anything ask again, and it did not clear the pre-selection either. The guide says how it works now.

* Leave Bambu Studio out of the single-instance list (#2595)

Open files in a running slicer now adds --single-instance for PrusaSlicer, SuperSlicer and Cura only. Bambu Studio is started as it is, so it may open a second window. A test pins that, for the detected slicer and for one the user picked as a custom program. The guide and the preference tooltip no longer name Bambu Studio as getting the flag.

* Sort the English catalog and leave features.md to the generator (#2595)

cargo xtask lists wanted one message moved in en.json after the rebase. docs/features.md is written by the Generated files workflow on main, so the row I had added by hand is gone from this branch; the workflow adds Open in Slicer when it lands.

* Drop the unused folder message from the Vietnamese catalog (#2595)

Vietnamese arrived on main after I removed ui.save_mesh_temp_failed from the other catalogs, so the check that every translated id exists in English failed on it. Nothing uses the message any more.

* Say so when a started slicer can not be found again (#2595)

After Open in Slicer had started a slicer on its own, a failure to look it up again to send the file just returned, so the status line stayed on Starting and then nothing. It now says the slicer was started, that it can not be found to send the file, and where the file is saved. A test covers it.

* Say why a failed busctl ends the wait (#2595)

If busctl fails partway through a wait, the wait stops and the file is sent, the same as when busctl can not be run at all. That is on purpose: a file sent too early is dropped quietly and costs one more send. A comment in wait_for_ready says so.

* Keep the readiness wait inside its limit (#2595)

wait_until asked the probe before checking the deadline, including right after a sleep, so a yes could be accepted after the limit, and busctl ran with no timeout, so one look could outlast the whole wait. The deadline is checked first now, the probe is told how much time is left, and busctl is given at most two seconds (less near the end of a wait) and is killed after that. A hung busctl counts as not telling, which sends the file as before. This covers the first look on the UI thread too. Tests cover a slow probe, the shrinking time left, and a fake busctl that hangs, answers and fails.

* Send the file that was already written once the slicer is ready (#2595)

When the wait for a started slicer ended, the UI ran file.open_in_slicer a second time. That exported the design again on the UI thread, from whatever the session looked like by then, and could write a numbered copy while the timeout message still named the first file. The wait now keeps the slicer it found and the answer of the first call, and opens the file that was written. A test changes the design during the wait and shows the same file is sent, is not written again, no command runs, and no second file appears.

* Send with the single-instance flag after starting a slicer (#2595)

After Open in Slicer has started a launcher that needs a start first, the file has to reach the instance that is now running, so the send that follows a start carries --single-instance whatever the preference says. A normal send, to a slicer that was already up, still follows the preference. A comment in both places says why. The test uses a plain launcher command with the preference off: the normal send has no flag and the send after a start has it.

* Find the remembered slicer again after an uninstall (#2595)

With two installs the remembered key is prusa#prusaslicer. Remove the other install and the lone one is plain prusa, which matched neither the exact key nor the plain-key fallback, so the picker fell back to the first slicer in the list, maybe a different one. The key is now also matched by the part before the #, so the same slicer is pre-selected. A test covers the lone install, a plain key with two installs, an exact key, and that another slicer is never picked.

* Say a second click did not send anything while a slicer starts (#2595)

While a started slicer is being waited for, another click showed Starting <the first slicer> even when it had picked another slicer or other bodies. It now says it is still waiting for that slicer and that nothing else was sent. The test checks the wording and that it names the slicer being waited for.

* Label macOS installs by their application (#2595)

An open -a command resolved its program to /usr/bin/open, so every application on a Mac was labelled system. Labels for open commands now come from the .app path: /Applications is system and a folder under home is shown as ~/Applications. A test with two PrusaSlicer.app bundles and a made-up home checks both labels and that the ids differ.

* Pick the same place for a program found twice every time (#2595)

When two desktop files, or a desktop file and PATH, pointed at one program, the first one read was kept and its file name went into the id, so the ids could change with the order the folders were read in even though nothing was installed or removed. The place that sorts first now speaks for the program. A test with two aliases and a second install lists them with the folders in both orders and gets the same ids and commands.

* Treat flatpak run spelled two ways as one install (#2595)

flatpak run ... and /path/to/flatpak run ... with the same arguments had different install keys, so one invocation could be listed twice. The launcher is now compared by its file name and the arguments are kept, so both give the same key, while a different branch is still its own install. The test uses a real launcher file so the full-path entry is not skipped as stale.

* Say plainly that a late yes is accepted on purpose (#2595)

The comment on wait_until said a slow probe could not accept a yes that came too late. That was not what the code does: no probe is started after the limit, but one already running may finish and its yes is taken, because the file should still go. The comment now says so, with the bound: the limit plus that last probe, which in production is given the time left and so ends by the limit. A test has a probe that runs past the limit and says yes, and checks it is accepted and the wait stays within limit plus the probe time, and that a probe that says no is not followed by another.

* Give reading busctl the same deadline as running it (#2595)

After busctl exited, the output was read with a join that could wait forever if a process it left behind kept the pipe open. The reader now hands the text back over a channel and the wait uses the time that is left of the same limit, and gives up with nothing otherwise. The polling sleep no longer overshoots the time left either. A test uses a fake busctl that backgrounds a sleep with its output inherited and exits at once: the call returns within its limit.

* Tell two different flatpak launchers apart (#2595)

Comparing only the file name of the launcher made /opt/tool-a/flatpak run ... and /usr/bin/flatpak run ... with the same arguments one install even when they are different programs. The launcher is now found the way any program is, through PATH and with links followed, and its name stands in only where it can not be found. The test has flatpak on PATH and the same file by its path as one install, a different executable of the same name as a second, a different branch as a third, and the fallback when there is no PATH.

* Keep waiting when busctl is slow, send only when it is missing or fails (#2595)

Since the busctl timeout, a slow busctl came back as "can not tell", and that was read as send now. One slow look, likely just when a cold PrusaSlicer 3.0 is starting under load, ended the wait and sent the file early, which gets dropped. The look now says why it has nothing: slow means not up yet, so the wait goes on to its limit and then reports that the slicer did not respond; a missing or failing busctl still means it can not be told and the file is sent. The comments name only those two cases. Tests use fake busctl programs: one that hangs keeps the wait going to the limit and times out, a missing one sends at once, one that says not up and then fails sends, and one that names the slicer is ready.

* Do not open an empty path when the answer had no file (#2595)

If the answer kept for a started slicer had no path, the send ran open with an empty string. The tick now says there is no file to send and starts nothing. The test gives a wait with no path, checks the error line, and that the fake slicer was never run.

* Drop the unused folder message from the Danish catalog (#2595)

Danish arrived on main with ui.save_mesh_temp_failed, which this branch removed from English because nothing uses it any more, so the check that every translated id exists in English failed. The entry is gone from the Danish catalog too.

* Always start the 3.0 launcher with the single-instance flag (#2595)

A hand run showed that starting the PrusaSlicer 3.0 launcher with no file while a 3.0 is already running forwards to it and exits when the flag is there, but opens a second full window when it is not. A slow first look at the bus can start the launcher twice, and with the preference off the flag was stripped. A slicer that has to be started first now keeps the flag on that start whatever the preference says, the same reasoning as the send after a start; when nothing runs the flag does no harm. A normal send with the preference off still has none. The test covers the launcher entry, a bare launcher command, a normal send and a slicer that is not on the table.

* Swap --single-instance-on-url where it does not hand the file over (#2595)

A hand run on the PrusaSlicer 2.9.6 Flatpak showed that its entry's --single-instance-on-url does not forward a file to the running instance: every send opened a new one, three after a cold start and two sends. With plain --single-instance the second process finds the first, forwards, and exits. So when a command carries --single-instance-on-url and the slicer is not on the wait table, that is, not the 3.0 launcher, it is replaced by --single-instance, with the file markers handled as before, when the preference is on, and stripped when it is off. On the 3.0 launcher the flag does forward and stays. The comment on the rule says why. A test uses the real 2.9.6 and 3.0 Flatpak command lines.

* Cut long slicer names to fit the picker (#2595)

With installs listed on their own, a name like PrusaSlicer (Flatpak, master) is wider than its row. The list painted it with no limit, so it ran out of the popup, and the closed selector wrapped it onto a second line. The list now lays the name out on one line that ends in an ellipsis when it does not fit, the selector truncates, and the full name is in the tooltip when it was cut (for a program the user added, ahead of the delete hint). A test checks that a long name is cut to one line within the width and a short one is left alone.

* Keep the 3.0 launcher's flag on a send whatever the preference says (#2595)

A hand run with the single-instance preference off showed the 3.0 launcher needs its flag on a send too, not only on a start: a cold start with a file and no flag loaded nothing, and a send to a running 3.0 left a launcher process that never forwarded. A slicer on the wait table now gets the flag on every launch, as it already did when started alone. The doc comment says why. The preference text, in English, Japanese and Chinese, and the guide now say that PrusaSlicer 3.0 is the exception to turning this off. The tests that took the flag away with the preference off now expect it, for the launcher entry, a bare launcher command and the 3.0 Flatpak line; the test of the send in the UI checks the first send carries it.

* Keep the install-flag swap to PrusaSlicer, found by what it is (#2595)

The swap of --single-instance-on-url for --single-instance ran for any program whose command had the flag, so another program with that flag would have been rewritten. It is now a PrusaSlicer rule: it applies to a slicer recognised as PrusaSlicer by its identity, its id or, for a program the user picked, the name it goes by, never by a version number. The single-instance table uses the same identity check. A test keeps the flag of a program that is not PrusaSlicer, strips it when the preference is off, and swaps it for a picked program named PrusaSlicer.

* Do not promise a hard bound in the wait comment (#2595)

The comment on wait_until said the production probe ends by the limit. Its clock starts after the process is spawned, and killing and reaping the process and scheduling are not bounded by it, so it is a polling and read budget with process overhead on top. The comment says that. Comment only.

* Tell PrusaSlicer 2 from 3 by the installed version (#2595)

PrusaSlicer 2 and 3 need different handling, and the rules had to guess from the command line. Each install of PrusaSlicer now carries its major version, read when it is detected from the release at the top of its appstream metadata and never by running the program: a Flatpak's own files (the branch the command names, else the current one), or the metainfo file beside a desktop entry. PrusaSlicer older than 3 is sent the file with --single-instance in place of --single-instance-on-url, which does not hand a file over there, and is not waited for. PrusaSlicer 3 and newer keeps the launcher's flag, is started on its own, waited for, and sent the file. When the version can not be read, the command decides as before: the slic3r-app-launcher launcher counts as 3, and any other entry is left as it is. A program that is not PrusaSlicer is untouched whatever its flags. Tests use metadata fixtures, no Flatpak: 2.9.6, 3.0.0-alpha10, a branch that is not installed, a system package, an unreadable version with and without the launcher, and a non-PrusaSlicer program with the same flag.

* Make the busctl wait tests steady under load (#2595)

One of them failed once in a full run at its first assertion and passed alone. A script that has only just been written can fail to start (text file busy) while another test forks, and that reads as a busctl that is missing. The fake programs are now started once, patiently, with a flag they answer at once, before a test relies on them; a hanging one uses exec so it is killed cleanly. The time bounds that could trip on a busy machine are several seconds now, and the checks are on outcomes: not ready and keeps waiting, send now, and the number of looks. Thirty runs alone, thirty of the whole slicers filter and thirty of the busctl tests under eight busy loops all pass.

* Say what the busctl reader thread leaves behind (#2595)

The thread that reads busctl output is left to end when the pipe closes. A busctl that answered but left a process holding its stdout reads as slow, and every poll of such a bus can leave one blocked reader behind until that process lets go. A comment next to the thread says so. Comment only.

* Make Save as mesh a plain export (#2595)

Save as mesh asked for the bodies and the format, and could also send the file to a slicer with a checkbox and a slicer list. Sending now has its own command, so the checkbox, the list and the Send label are gone from this dialog: bodies, format, a file dialog, Save. The two settings that only the checkbox used, save_mesh_send and save_mesh_slicer, are no longer kept; a settings file that still has them loads, and they are ignored. The strings only they used are gone from the catalogs. The slicer list stays in the dialog of Open in Slicer. file.save_mesh keeps its slicer parameter for scripts. The guide describes the two as separate.

* Rename Open in Slicer to Send to Slicer (#2595)

The command sends a file to a slicer rather than opening one, and a later Send to App can share the name. File menu, Mesh tab, right-click menus, the dialog button, status lines, the guide and the English, Japanese and Chinese strings now say Send to Slicer, and the command id file.open_in_slicer is file.send_to_slicer. It is new in this change, so there is no alias. The remembered slicer is stored as send_slicer, and the question the agent chat asks says send to. Module and type names stay as they are.

* Put the send targets in one table and one send pipeline (#2595)

The known slicers, the list of slicers with a single-instance flag and the rule for slicers that must be started and waited for were three separate things. They are one table of targets now: id, label, kind, names and program files, accepted formats and the default, how the file is handed over, the single-instance flag, the version from which PrusaSlicer's on-url flag hands a file over, and the wait-for-ready rule. Programs that are not in the table are slicers with the same defaults. The kind is slicer, the only one so far; its comment says it is where a later Send to App would plug in. The send itself is one function: write the bodies, find the target, start it and wait if its rule says so, hand the file over, report. Detection and resolution take a kind. Nothing changes for what already worked.

file.save_mesh keeps its slicer parameter, now through the same function, with its export arguments and result unchanged: a cold PrusaSlicer 3 is started and waited for before the file goes, and it accepts the program path of a detected slicer as file.slicers lists it (file.slicers gives it as program). A path is accepted only when it is the program of exactly one detected target, and that target's own command and arguments run; a launcher several entries share, such as /usr/bin/flatpak, is refused as ambiguous, and any path nobody listed or chose is refused. The parameter and the code say it may be deprecated later in favour of file.send_to_slicer or a Send to App workflow, which is a possibility and not a plan.

Tests go through file.save_mesh itself for the two reported failures, a listed AppImage path and a cold PrusaSlicer 3 that is waited for, with the slicers and the busctl made up; and they check that the result and the file are what an export without a slicer gives, and that a path that is not listed, or is shared by two entries, writes nothing. The pipeline has its own tests: a cold launcher waited for, one already up, wait return, and one that never comes up. A test of save_mesh that shared a file name with another is given its own.

* Keep an AppImage's id through an upgrade (#2595)

When a slicer has several installs, each gets an id made from where it was found. For an AppImage that was its file name, which carries the version, so upgrading it changed the id and the remembered choice missed. The id is now made of the slicer's id, the fact that it is an AppImage and a short hash of its folder, so a new version that replaces the old one in the same folder keeps the id, and the same AppImage in another folder is its own install. Two versions kept side by side in one folder are two installs: while both are there, their file names are added to their ids to tell them apart.

A remembered id is found again only when it names exactly one install. An id that was one AppImage and is now two versions, or a plain id that is now several installs, is treated as not remembered, so the picker starts from the first entry as on a first run and does not quietly pick another install; a single install left of the same slicer is still found, as before. Installs that are not AppImages are told apart as before. An id from before this change no longer matches. Tests cover an upgrade, two versions side by side, a second folder, and the picker's side of each case.

* Add Open with EasyPrint to Send to Slicer

EasyPrint is Printables' slicer in the browser. It's a plain text row in
the Send to Slicer dialog, backed by a new file.open_in_easyprint
command. The command exports the bodies as a 3MF (or STL), hands them to
the host to upload to slicer.printables.com/import, and the app opens the
link that comes back. The desktop app uploads with ureq and the browser
build with fetch, so this is the one send target that works on the web.

The model leaves the machine, so the command refuses without
consent: true, the dialog asks once and remembers, and an AI agent's
request asks first. A model over 100 MB is refused before anything is
sent, and a failed upload shows as an error with the reason.

The chat's privacy note said SolidCraft sends nothing anywhere, which
isn't true any more, so it now says that about the chat. New strings are
in every catalog.

* Place Send to Slicer on the Insert Files panel
2026-10-26 19:59:35 -04:00
bherbruck 5965439a15 Release 0.24.0 2026-10-26 19:23:21 -04:00
bherbruck 0b2d3ecd44 Release 0.24.0: version bump, design format 39 (#7394)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 19:23:19 -04:00
Frank 65366ba131 Move: after picking a body, go on to From, Point or Axis (#6170) (#6171)
* claim: #6170

* Move: after picking a body, go on to From, Point or Axis (#6170)

In Point to Point, Point to Position and Rotate, once a body is picked
and the next input is empty, the dialog goes on to it, so the next click
picks the point or axis instead of toggling another body. Free Move
stays on Bodies: its Pivot is optional.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 23:17:37 +00:00
github-actions[bot] 96e43bb4ef Regenerate generated files and fold pending files [skip ci] 2026-10-26 23:04:43 +00:00
DuBearte f077f48318 Sketch: a changed length dimension keeps its start point, so a side grows from its first corner (#6070) (#6139)
* claim: #6070

* Sketch: a changed length dimension keeps its start point, so a side grows from its first corner (#6070)

Changing a dimension's value re-solved its sketch as the smallest move from
the stored positions, which splits the change between both ends: a
rectangle side set from 40 to 60 mm grew 10 mm each way. parameters.change
now first solves a sketch whose length dimension (distance, horizontal or
vertical distance, line length) the parameter drives with that
dimension's first point held, and stores the result, so evaluation has
nothing left to move. Where holding it can't solve (a symmetry moves both
ends), evaluation solves as before.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 23:03:31 +00:00
github-actions[bot] 955f5aea43 Regenerate generated files and fold pending files [skip ci] 2026-10-26 22:56:34 +00:00
Dahak Builds bd44f6f496 Reference features by id everywhere: Pattern/Mirror seeds and construction planes (#1134) (#6858)
* claim: #1134

* doc: key pattern/mirror seeds and construction planes by feature id (#1134)

* engine: resolve seed names the way evaluation did; keep timeline.edit name input (#1134)

* fmt/clippy cleanups (#1134)

* ui: resolve construction-plane names by feature id in plane_description (#1134)

* fmt: sketch_attachment_ui after rebase adaptation

* engine: timeline.add resolves legacy name-keyed seeds and plane names (#1134)

* chore: re-trigger CI (GPU runner timeout flake)

* doc: replayed_sources walks id-keyed pattern seeds (rebase adaptation)

---------

Co-authored-by: Dahak Builds <dahakbuilds@users.noreply.github.com>
2026-10-26 22:56:06 +00:00
bherbruck 739e442824 Links: ask before following a design's network paths (#7372)
A path a design names (a linked design, a sketch text's font file) now
goes through one rule before any file call: a network location is
reached only once its server is allowed, a device path never. Update
Links and Open Linked Design ask first (Allow once / Always allow this
server / Cancel); Preferences > Design lists the allowed servers and
can allow every network location. component.links reports such a
link's found as unknown with a reason, without looking for it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 18:52:43 -04:00
DuBearte 3918f23c3d Browser: a feature's eye shows or hides the bodies it made (#5099) (#6109)
* claim: #5099

* Browser: a feature's eye shows or hides the bodies it made (#5099)

A row in the Browser's Features folder has an eye when the feature made
bodies that are in the design now (a patch, a box, a pattern's copies;
not a fillet, which changes bodies). It runs browser.visibility with the
new features: [id or name] (and path: for one placement of a component
placed more than once), which hides or shows those bodies.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 22:49:23 +00:00
MAKAO 30f951ecf9 Navigation bridge: a device's own app moves the view (#6094)
A homemade 3D input device's driver app (MAKMOUSE) listens on a loopback
port; with Preferences > Navigation > "Navigate with a device app on this
computer" on (off by default), the desktop app connects, says hello and
reads JSON lines: axis motion (orbit, pan or zoom by mode), zoom steps and
standard views. The motion goes through navigation::device like a 3D
mouse's, only in the focused window; a HID device the app names is left
to it. Documented in docs/3d-mouse.md.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 22:14:52 +00:00
github-actions[bot] 02458892d9 Regenerate generated files and fold pending files [skip ci] 2026-10-26 22:30:02 +00:00
amolinari-autotex 1d02a5f2d4 Personal Hole parameter presets (#4480)
* feat: add personal Hole parameter presets

* feat(hole): prompt for preset names and recall changed selections

Ask for a name only on Save new or Rename in the kit confirmation. Keep Enter and Escape inside the prompt, preserve cancelled drafts, recall only changed selections, and retain independent recessed-hole bottom choices.

* feat(hole): preserve thread direction and partial depth in presets

Carry Left/Partial, explicit source units and Custom draft guards through personal preset capture and recall. Preserve legacy Right/Full/flat defaults and reject unknown options before changing the form. This production bridge depends on the handedness and graphical-depth changes inherited below it.

* docs(hole): describe supported preset thread options

* feat(ui): save modified hole presets in place

* fix(presets): retain reference placement and current locale contracts

* test(ui): borrow restored preset library during serialization

* test(presets): preserve modal focus with viewport focus restoration

* Use UI kit controls for hole preset selection
2026-10-26 22:00:56 +00:00
bherbruck c391ef17cb Themes: Clay (light) and Clay Dark, warm paper and charcoal (#7342)
* Themes: Clay (light) and Clay Dark, warm paper and charcoal with a terracotta accent

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

* Clay: on-accent text a touch lighter for 4.5:1 on the accent

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 21:31:40 +00:00
Nathan Dane e413327821 History: follow the Browser's translucency when docked under it (#6922) 2026-10-26 21:26:28 +00:00
HiroKenEz a1eb63c169 Extrude: the picked region is the one extruded again (#6724) (#6766)
* claim: #6724

* Extrude: the picked region is the one extruded again (#6724)

Since #5530 (#5302) every index or point pick grew to the whole drawn
profile that reference geometry cuts, so a lens or crescent clicked in
Extrude, where the face's projected outline cuts a circle, extruded the
whole circle while the dialog highlighted and counted one region.

Dialogs now send exact_regions: true with their profile picks, and the
command keeps each pick to the region it names. Picks without the flag
(scripts, agents, the CLI) still take the whole drawn profile (#5302).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 21:02:43 +00:00
github-actions[bot] 610f2e479c Regenerate generated files and fold pending files [skip ci] 2026-10-26 21:16:46 +00:00
HiroKenEz f49cd33db1 Select Other: list what is under the pointer front to back, one body at a time (#5345) (#5509)
* claim: #5345

* Select Other: list what is under the pointer front to back, one body at a time (#5345)

The list was sorted by kind first, so on stacked bodies it showed every
body's face, then every body, mixed across depth. Now each body's rows
stay together (vertices, edges, face, then the body), bodies ordered by
their nearest hit along the pick ray; an edge counts at its point under
the pointer. Sketch overlays stay first, profiles, planes and axes last.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 20:55:45 +00:00
Colin Faulkingham 4c7087ec16 Disable Perspective while editing a sketch (#2144) (#4291)
* claim: #2144

* Disable perspective toggle while editing a sketch (#2144)
2026-10-26 20:48:21 +00:00
AhmetV f87b657c82 Sketch: keep the dimension box focused after a palette linetype click (#2927) (#3197)
Co-authored-by: AhmetV <344941595+ahmetv10@users.noreply.github.com>
2026-10-26 20:32:10 +00:00
CamWight 64ba396c05 Part properties: one table of keys (#4784) (#4786)
* claim: #4784

* Part properties: one table of keys, each with its scope, check and label (#4784)

SolidCraft's part-property keys (project, part number, revision, author,
organization, description, licence) were declared once but listed again
by hand in each command, error, dialog and window. They are now one
table, metadata::KEYS: each row has the key, where it may be set
(Scope: the design, a component or both), how its value is checked and
the message id of its label. A new key is one row.

Metadata::set is told whose metadata it changes (Owner::Design or
Owner::Component), and a key is taken where its scope covers that
owner. is_well_known, both set commands, document.metadata,
component.metadata, the refusals, the Design Properties dialog and the
Bill of Materials window read the table; the hand-written lists are
gone (WELL_KNOWN, the label match whose fallback named any unknown key
"License", the window's three labels). metadata::BOM_KEYS names, in
column order, the keys a bill of materials lists for each part:
component.metadata returns them, component.metadata.set takes each as a
parameter of its own and the window edits them; a test keeps each one a
row of the table that a component takes. Free properties use the keys'
text rule (metadata::check_text) instead of a copy of it. The params
docs point to document.metadata's properties instead of repeating the
keys.

Every key accepted before still is, on the design and on a component.
Two things change:
- a refused key or a full metadata on a component says "part property"
  ("`bogus` is not a part property: …", "part properties are limited
  to …"), not "design property";
- changing the design's own properties no longer evaluates the model.
  The fields that change no geometry are named once,
  Document::with_part_properties_of: the design's metadata, and each
  component's metadata and free properties. It compares them first and
  copies the design only when one differs, as before.

The shortcut is for a session's own edits only. A command that gives
the session another design (doc.open, file.new, doc.recover: through
Session::replace_design, which now counts them) is refreshed in full,
however little the new design differs from the one before, so its
joints are solved and its occurrence state pruned. Before, opening a
design that differed from the open one only in render settings or
component properties skipped that; with the design's properties in the
list it would have for two revisions of one part too.

Tests: the table (one row per key, the dialog's order); the bill of
materials' keys (rows a component takes, in column order); each scope
on each owner, and refusals that name the owner (the empty key, an
unknown key, a 256-byte key shown cut); the text check at 4096 bytes,
line breaks, tabs and control characters; hostile keys and values
refused without a change; only-part-property changes in each scope,
and changes beside them (units, a component's name, an added, removed
or swapped component); document.metadata.set, remove, undo and redo
evaluate nothing; opening a revision that differs only in its author
solves its joint; the component wording, the shortcuts and the limits;
the dialog's and the window's labels from the table.

What the tests can't reach: every row is Scope::Both, so a known key
refused for its owner (the wrong-owner branch of check) can't happen
yet. It becomes live with the first single-scope row, bom_type, a
component-only key.

Left for later (not this topic):
- Metadata::others() ignores scope, so a component-only key stored on
  the design would be neither a dialog field nor listed under other
  tools' data. It matters from the first single-scope key (bom_type).
- assembly.bom's rows, its CSV columns and the window's grid headers
  still name the three keys by hand; the bill of materials module's
  rewrite will read BOM_KEYS.
- The bill of materials types (P1, #5107, into drawings) still use
  WELL_KNOWN and the two-argument set: once this is on main, bom_type
  is rebuilt on it as one KEYS row with Scope::Component, set through
  Owner::Component.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 20:03:22 +00:00
Logan Night 1720c56840 Drag arrows: pattern spacing and sketch offset go negative past their start (#5497) (#5508)
* claim: #5497

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

* Sketch pattern: a spacing arrow dragged past zero gives a negative spacing (#5497)

The drag arrow floored an unsigned value at a small positive number, and the
rectangular sketch pattern's two spacing arrows were unsigned, although the
command takes a negative spacing and typing one works. Both arrows are signed
now, like the Extrude, Move and offset ones.

The test drags each arrow back past its start: without the change the first
one stops at 0.001.

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

* Drag arrows: the solid pattern spacing and one-sided sketch offset go negative too (#5497)

The solid rectangular pattern's Spacing arrow and the sketch Offset distance
arrow were unsigned, so a drag back past the start stopped at a small positive
value, although both commands take a negative value and typing one works. Both
are signed now. A symmetric offset uses only the distance's size, so its arrow
still stops at the curve.

Tests: the solid pattern arrow dragged 10 mm back gives Spacing -10 and the
copies on the negative side (without the change: 0.001); the offset arrow
dragged across its circle gives -1 mm and the same circle as typing -1 mm
(without the change: 0.001), and a symmetric offset stays positive.

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 20:03:03 +00:00
github-actions[bot] b28decfaf6 Regenerate generated files and fold pending files [skip ci] 2026-10-26 20:30:44 +00:00
bherbruck 9e4dc48165 Release: sign Windows binaries and MSI through SignPath Foundation (#7067)
* Release: sign the Windows binaries and MSI through SignPath Foundation

When the SIGNPATH_API_TOKEN secret exists, each Windows job sends the
three .exe files to SignPath before packaging, then the MSI built from
them, using the GitHub artifact flow from SignPath's docs. The policy
is test-signing unless the signpath_signing_policy dispatch input or
the SIGNPATH_SIGNING_POLICY repository variable says otherwise. Without
the secret the jobs build unsigned exactly as before.

package.ps1 gains -Phase stage|package so signing can sit between the
build and the MSI; the artifact configuration to paste into SignPath is
packaging/windows/signpath-artifact-configuration.xml.

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

* SignPath: drop the description attributes the Foundation plan doesn't allow

SignPath Foundation projects get an automatic signature description (its watermark);
the artifact configuration may not override it.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 19:47:06 +00:00
bherbruck 3de2f96b7f Sketch dimensions: double-click a shown dimension in the model view to edit it (#7079) (#7080)
* Double-click a shown sketch dimension in the model view to edit its value (#7079)

A finished sketch's dimensions shown in the model view (Show Dimension) now take a
double-click: the same inline value box as sketch editing opens at the text, Enter
changes the dimension's own expression through parameters.change (one undo step), Esc
cancels. The sketch is not edited and the view does not move. Driven dimensions stay
read-only with a status hint.

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

* Control channel: ui.selection lists the shown dimensions of finished sketches

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

* Test: a shown dimension that breaks a later fillet reports it, and Undo restores both

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 19:47:04 +00:00
Jesper We 436ee95095 Preference: return to the previous view when a sketch ends (#7038) (#7043)
* claim: #7038

* Preference: where the view goes when a sketch ends (#7038)
2026-10-26 19:31:55 +00:00
github-actions[bot] 1ee2532678 Regenerate generated files and fold pending files [skip ci] 2026-10-26 20:00:04 +00:00
github-actions[bot] 3152e02fe5 Regenerate generated files and fold pending files [skip ci] 2026-10-26 19:51:41 +00:00
Nathan Dane fbcd4852c1 Navigation bar: the view commands (#6585) (#6586)
* claim: #6585

* Navigation bar: the view commands (#6585)
2026-10-26 17:46:15 +00:00
HiroKenEz 97cce94a1c Agent chat: tool calls read as summaries, raw JSON behind Details (#1457) (#6245)
* claim: #1457

* Agent chat: tool calls read as summaries, raw JSON behind Details (#1457)

Each tool-call card now says what the call did, built from the command's
translated label, its main parameters (lengths in the design's units) and the
bodies the result names: "Extrude Sketch1 by 10 mm", "Box 40 x 30 x 20 mm,
then Fillet 4 edges (r 4 mm)", "Measured Block: ...", "Saved block.solidcraft".
Calls it can't put in words keep the agent's title. The raw arguments (now
kept from the in-app client and ACP's rawInput) and the result sit under a
folded "Details".

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

* Agent chat cards: show file names, never paths; temp files not named

ui.screenshot reads "Screenshot"; save/export/open and other tools' titles
show only the file name. The full text stays under Details and in the tooltip.

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-26 17:22:17 +00:00
Victor Santos Oliveira 5b41d61f1e Tests: exercise home-path tooltips on Windows (#6890) (#6891)
* claim: #6890 portable home-path tooltip test

* test(ui): exercise native home paths on Windows (#6890)
2026-10-26 17:04:43 +00:00
Dahak Builds 2863b80574 Sketch: 3D arc command (#5681) (#5682)
* claim: #5681

* sketch: exact circular arcs on drawn 3D wires (#5681)

Add WireArc (centre, unit normal, radius, start/end) as an optional analytic
form on drawn 3D sketch wires, with arc_from_3_points, arc_sweep and
sagitta-bounded arc_polyline; add_drawn_arc_in stores the arc, its three
defining fit points and its samples; move_drawn_point re-derives the arc
atomically and refuses collinear points; validate_wires checks saved arcs.

* engine,ui: sketch.arc3d exact 3D arc command (#5681)

sketch.arc3d draws an exact circular arc through three world points in a 3D
sketch, stored analytically beside its fit points and samples; sketch.move3d
rebuilds it, sketch.inspect reports centre/radius/sweep; placement mapping
(map_wire, now shared with the engine's world state) transforms the arc.
One pending design-format file records the new field. The viewport's 3-Point
Arc tool draws a 3D arc when clicked off the sketch plane, with a live arc
preview; the SKETCH panel offers sketch.arc3d.

* ui,engine: 3D Arc button starts an interactive tool (#5681)

sketch.arc3d now maps to a Draw(3) tool handled by sketch3d: three world clicks (start, end, through - same order as the 2D arc tool) run the command, with the live arc preview and snapping. The command is only enabled while a 3D sketch is open, so the toolbar never offers a tool that could only fail. Locale labels for the command.

* ui: cargo fmt

---------

Co-authored-by: RayGalt <RayGalt@pm.me>
2026-10-26 16:29:39 +00:00
yhamil04 03a701d678 Viewport tests: the Metal hover pixel test keeps its pictures when it fails (Refs #1383) (#6805)
* claim: #1383

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

* Viewport tests: the hover pixel test keeps its pictures when it fails (#1383)

The Metal hover pixel test failed once in a loaded full-suite run and has
not been reproduced since. On failure it now saves the plain and hovered
GPU and CPU frames and a mask of the pixels that do not light, and names
the folder in its message. The assertion is unchanged.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 16:01:39 +00:00
github-actions[bot] a2ccbd0684 Regenerate generated files and fold pending files [skip ci] 2026-10-26 19:21:19 +00:00
Daniel 2e566e2c58 Placement handles for Rest, Boss and Snap Fit (#4384) (#5362)
* claim: #4384

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

* Placement handles: shared module, with Rest as the first tool

The Rest handles spike becomes a shared module, place_gizmo, so other features placed on a face can
use the same marker and knob. The mechanics (crosshair with its three states, the grab offset, the
footprint while dragging, the turn knob) live in place_gizmo.rs. The handles own the pointer through
canvas::owns_pointer, as the move triad does, so navigation and Select Other's press-and-hold leave
them alone. Each tool is one file under place_gizmo/ that gives a Spec: which dialogs it serves,
which commands' Position input also takes sketch points, where the place is, how the pointer
re-places it, its footprint and its turn, if any. The module lists the tools one per line, so tools
added on separate branches each add one `mod` line and one list line. Rest is the first.

The handles are always on; the preference the Rest spike had is gone. The POINTS accept flag on a
Position input is granted from the tools' Specs instead of a Rest-only arm in the plastic dialog's
start. Handles are published as `rest:position` and `rest:rotate` as before.

Seen straight down, the dialog's height arrow collapses onto the place and its value box sits just
right of it, where it took the press meant for a knob pointing that way. The knob now slides out
along its ray until it is clear of the box.

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

* Placement handles: Boss

The marker moves a boss across the model, also from Edit Feature, and its Position input takes
sketch points while the handles are on, so a screw boss can be dragged onto the centre of a
circle drawn for it. A boss is round, so it gets no knob; the footprint while dragging is its
outer circle. Published as `boss:position`.

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

* Placement handles: Snap Fit

The marker moves a snap fit across the model and its Position input takes sketch points while
the handles are on, so the arm can be put where a sketch says (#3423). The knob points the
catch: it turns between the four directions the Catch Direction combo offers for the face, so
the knob and the combo always agree; the command keeps only those four, so there is no free
turn. The footprint while dragging is the arm's foot, its thickness behind the place and its
width across. Published as `snap_fit:position` and `snap_fit:rotate`.

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

* Placement handles: the look in one file

Everything that draws the handles (the crosshair and its three eased states, the knob's ball, the
cased lines, the angle label, the colours) moves from place_gizmo.rs into place_gizmo/look.rs, so a
restyle touches one file and the mechanics (hit radius, drags, knob geometry) stay where they are.
No change in behaviour.

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

* Placement handles: behind a Design preference, off by default

The handles sit behind Preferences > Design > Placement > Placement handles (`placement_handles`,
also through `ui.prefs`), off by default as the hole placement work in #2912 is: a Rest, Boss or
Snap Fit is then placed by its click alone, its Position input takes faces only, and no handle is
drawn or published. The tests switch the preference on; one checks the default.

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

* Placement handles: the tests hide the origin through set_origin_shown, as main does since #1849

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

* Placement handles: the move marker as designed in the prototype, drawn with theme tokens

The marker is a dot with four arms to the screen cardinals: short and dotted at rest, longer with open arrowheads when pointed at, solid while dragging, each state growing out of the previous one (360 ms). Sizes are the prototype's. Its core is the icon highlight and its casing the marker halo, the tokens main's hole handle uses, so the literal allowance goes. The tip shows while pointing, not during a drag.

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

* Placement handles: the dialog's drag arrow stands above the move marker

With the handles on, the Height arrow started at the marker's centre and ran through its upper arm, head on arm tip. The arrow now keeps the marker's full reach and a gap free at the base, its shortest length grows by as much, and the base dot is left to the marker. Other dialogs are unchanged (clearance zero).

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

* Placement handles: a turn ring on the face replaces the knob

The ring lies on the face about the move marker's centre, drawn in perspective, a fixed 65 px on screen like the move triad's arrows (it is a control of something on the screen, not a measure of the model). At rest it is a short dotted arc with an open chevron grip just outside; pointed at, the dotted circle opens from the grip both ways round and the grip slides in and fills; while turning, a solid arc around the grip. Dragging the grip turns the feature as the knob did, through the same handle name, so the harness tests stand.

The floating value box yields: with a handle's centre under it, it moves to the other side of the arrow's tip or above it, since seen straight down the box sat exactly where the grip is. A handle the arrow's stem crosses keeps the pointer.

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

* Placement handles: a compass while turning, and the turn pulled onto the face's compass points

Four ticks just outside the ring at the face's in-face world directions (the ones the Snap Fit combo names, +X, −Y, …) appear while turning and stay put, so you can see how far you have gone. A Rest's turn is pulled onto one of them within 8°, otherwise it steps by 5° as before; Alt or Ctrl frees it. The readout beside the grip names the point it sits on ("90° · +X") and keeps clear of the ring.

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

* Placement handles: the footprint in the preview's blue, the idle handle steps back

The footprint drawn while the marker drags is a filled shape in the preview's own blue with a thin darker edge, not a white outline, so white stays reserved for what can be grabbed. While one handle is dragged the other fades to a quarter and comes back when the drag ends.

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

* Placement handles: the ring in the preference text, the docs and the tests' wording

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

* Placement handles: the look as tuned in the app

Marker: drawn to the redrawn design (dot r 3.9 pt, two dashes per arm, a filled head 8.7 × 7.1 pt whose base is where the outer dash began); pointed at, the outer dash becomes the head (its far end narrows to a point, its near end widens); dragging runs the inner dash on into the head as a shaft. Every casing is 0.4 pt outside the edge, for lines, heads and the dot alike.

Ring: the grip and the compass lie in the ring's plane like the circle, so the grip is never skewed against it; the grip is a thick chevron with a hole that flattens into the base as the grip slides onto the ring, and fills; the dotted circle stays while turning, with the solid arc laid over it; opening eases in and takes 0.3 s, leaving is a 0.15 s fade over the resting ring, not the sweep backwards; a drag keeps the angular gap of the press, so the grip does not jump onto the pointer; the compass ticks and the readout's point name are in their axis colours, and the grip takes the axis colour (eased) while aligned with one, with that point's tick left out; free turning keeps a hair of pull (0.5°) so an axis can still be hit; the readout wraps to a half turn.

Value box: with a ring it keeps out of the circle the ring, grip, compass and readout sweep, one place per view, nothing moves when a turn starts; also when the value does not evaluate and there is no arrow.

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

* Placement handles: the readout shows a half turn as 180°, not −180°

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

* Placement handles: the motions in one place

The animations the handles grew (a factor eased in and out each way on its own time and easing, a handle stepping back while the other works, a colour that comes and goes from the last one it was on, the gap a press keeps for its drag, an offset that slides instead of jumping) move out of place_gizmo into motion.rs, so the next manipulator reuses them. The look's timings become Transitions in look.rs. No change in behaviour: the contact sheet before and after is identical, the harness tests stand.

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

* Placement handles: the axis colours as main names them since #4430

The compass and the aligned grip take the one set of X, Y and Z colours (axis_x, axis_y, axis_z); the Move gizmo's own array is gone.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-26 15:35:10 +00:00
amolinari-autotex 47285ccb88 Thread: right- and left-handed direction (#4414) (#4415)
* claim: #4414

* feat: persist right and left thread handedness (#4414)

* fix(thread): verify handedness with referenced holes and complete locales

* test: verify handedness with merged tapped thread catalogues

* test: retain tapped thread depth when changing handedness (#4414)

* test: restrict Unix symlink coverage to Unix targets

* test: inspect hole thread depth inside edit command parameters (#4414)

* test(doc): initialize cylinder radius in handedness fixture
2026-10-26 13:57:08 +00:00
github-actions[bot] da16362626 Regenerate generated files and fold pending files [skip ci] 2026-10-26 18:45:42 +00:00
amolinari-autotex 13cd45d1f1 Apply complete feature transactions off the window thread (#3133) (#3135)
* claim: #3133

* Apply complete feature transactions off the window thread

Evaluate dependent history and prepare reference and GPU display caches before accepting a guarded result. Preserve the evaluated original model for safe editor cancellation and journal only accepted commands.

* fix(ui): preserve original marker across sketch-to-feature edits

* test(ui): verify edit-sketch cleanup and guard Unix link fixture

* test(ui): fix texture disposal and unreachable assertion in feature tests

* fix(build): compile CLI link inspection only on Unix
2026-10-26 13:27:04 +00:00
Nathan Dane c5e63b7871 macOS DMG: blueprint window with larger icons and a drag arrow (#5831) 2026-10-26 12:50:24 +00:00
Steven Waters 2755c9ccfb Pin auto-generated body names (#6402) (#6403)
* Pin auto-generated body names

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

* Fixed body counter

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

* Fixed formatting

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

* Altered test behaviour to reflect new pinned body names

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 12:33:58 +00:00
SS-EN 4ab6d92952 Agent chip: the batch step and its command, centred above the navigation bar (#5748)
While an agent's batch computes off the window thread (#1989), the chip reads
N/M · <command>. It is centred on the navigation bar, just above it, and moves up
past any toast or panel it would cover.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:28:30 +00:00
froglemonade2 e063a7778e Axes and points from circular edges and round faces (#3128) (#4610)
* Axes and points from circular edges and round faces

In 3D, a round edge or face stands for its circle wherever a command
takes an axis or a point.

Revolve, Surface Revolve, both Circular Pattern dialogs, Plane at Angle
and Move -> Rotate take a circular edge (through its centre, along its
normal) or a cylinder, cone or torus face (its axis). The features keep
the pick as a model reference, so they follow the model when a parameter
moves it. Straight edges work as before; flat faces are refused.

Every 3D point input takes a circle's centre: construction points (which
follow the circle), To Object, patch points, hole placement, and Move's
pivot and points. Hovering shows the axis as a dashed construction line
or the centre as a star, and the dialog's chip names what the pick
stands for ("centre of edge 10", "axis of face 3").

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

* Format the round-pick hover check

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

* Selection chips: name a round pick as a circle centre or axis

The chip has room for little more than the body and a word or two:
"Shaft · circle centre", "Shaft · circle axis", "Shaft · face 2 axis",
and "Shaft · point" for a point of the model.

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

* Move: a circle's centre in a placed component is where the component shows it

Move's pivot and points read a picked circle from the model in its
component's own frame, while the pick is in the world. They now read it
from the world state (and, editing a Move, from the state before it,
placed the same way), as the viewport and the other round picks do.

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

* Plane at Angle on a planar face about a model edge: test that it follows both

Upstream #1956 made a planar face a base for Plane at Angle; this branch makes a model edge an
axis. The rebase keeps both in one PlaneRef::AtAngle.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:27:32 +00:00
Dahak Builds 8630a3f0e5 External STEP corpus: nightly import checks (#1397) (#6407)
* claim: #1397

* External STEP corpus: nightly import checks on real-world files (#1397)

tests/external-corpus.toml pins 20 real-world NIST PMI STEP files (AP203
geometry-only and AP242 editions from several vendor exporters, public
domain) by archive and per-file SHA-256 with the licence recorded.

cargo xtask external-corpus downloads each source once into the gitignored
corpus/external/, verifies every hash, imports each file headless
(file.insert_step + inspect.measure) and checks body count, total volume
(0.1 %) and area (0.5 %) within tolerances and time and memory budgets.
A file whose upstream hash changed is reported as 'source changed', never
as our failure. --bless "why" re-records expectations in the manifest with
a dated reason. The External corpus workflow runs it nightly.

assets: skip corpus/ when scanning for attribution (downloaded case data
is never committed).

---------

Co-authored-by: Dahak Builds <dahakbuilds@users.noreply.github.com>
2026-10-26 12:26:36 +00:00
dependabot[bot] 0e0c23b080 Bump pillow from 12.3.0 to 13.0.0 in /assets/render/starter/tools (#6409)
Bumps [pillow](https://github.com/python-pillow/Pillow) from 12.3.0 to 13.0.0.
- [Release notes](https://github.com/python-pillow/Pillow/releases)
- [Changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst)
- [Commits](https://github.com/python-pillow/Pillow/compare/12.3.0...13.0.0)

---
updated-dependencies:
- dependency-name: pillow
  dependency-version: 13.0.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-26 12:26:15 +00:00
semmelrinde e29075376c Curvature Map: a scale that lightens steadily from flat to tight (#975) (#6136)
The blue, green, red scale's lightness rose and then fell (L* 44 up to
72 and back to 50), so a flat face and a tight radius looked alike with
hue lost, and its upper part merged for a red-green loss. The stops are
now dark blue, a middle grey-gold and light yellow in one straight
blend, as measured in the issue: L* rises the whole way and every
quarter step stays clear for normal, protan, deutan and tritan vision.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:25:11 +00:00
Karl Von Richter 44b0056d48 Frames: a Weldments ribbon tab (#4003) (#5262)
* Frames: a Weldments ribbon tab (#4003)

- WELDMENTS joins the ribbon after SHEET METAL and is built the same way:
  Sketch (New Sketch), Create (Frame Member) and Configure (Parameters,
  Configurations). Frame Member's home moves there from MODEL > Create.
- Always shown, as a frame starts from a sketch of its lines. It selects
  itself and takes the contextual style when the selected body (or the last
  body of the active component) is a frame member; sheet metal comes first.
- It uses the default accent until the palette has a colour for it.
- The tab label is in every catalog; docs/ribbon-layout.md, docs/frames.md
  and the tab list in docs/control-protocol.md follow.

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

* Frames: Weldments keeps the ordinary tab look (#4003)

Weldments still selects itself when a frame member is the active body, but
no longer takes the solid contextual fill or the 2 px accent line under the
tab row: selected, it looks like a selected Model tab. Only Sketch and Sheet
Metal (on a sheet body) are contextual.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:24:38 +00:00
ivisaf 3bcf77ee32 Expressions: a number and its unit are one value (10 mm / 2 mm is 5) (#6331)
* Expressions: a number and its unit are one value (10 mm / 2 mm is 5)

A unit written after a number was multiplied in at the precedence of `*` and
`/`, so `10 mm / 2 mm` was (10 mm / 2) mm = 5 mm² and `90 deg / 1 rad` a value
in angle², reported as a unit error. The number now takes the unit after it
before `*` and `/` apply; a fraction of numbers before a unit stays one value
(`1/2 in` is half an inch), and an exponent stays a plain number (`2^3 mm`).

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

* Expressions: after a division, a fraction before a unit only when closed up

`width / 10 / 2 mm` read the `10 / 2 mm` as the fraction 5 mm and gave
width / (5 mm) = 8 instead of (width / 10) / 2 mm = 2. After a `/` the
fraction rule now needs the fraction written without spaces (`1 in / 1/2 in`
is still 2); elsewhere `1/2 in` and `10 / 2 mm` are unchanged.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:24:34 +00:00
Victor Santos Oliveira 91638cb1f3 Add practical command guides and a contribution template (#5237) (#6772)
* claim: #5237 command-level guide follow-up

* docs: add practical command guides and resource contributions (#5237)

* docs: index practical guides and reject incomplete command links
2026-10-26 12:24:23 +00:00
raykholo 66b872a749 Web app: open a design from a same-origin path (#6670)
?open= now also accepts an absolute path on the page's own origin, fetched
with the page's scheme, so a self-hosted setup that serves SolidCraft and
other tools under one origin can link a design on a LAN without a public
HTTPS address. Absolute URLs keep the HTTPS-only rule (a URL on the page's
own origin is also accepted), redirects must end on HTTPS or the same
origin, and protocol-relative, dot-segment, backslash and control-character
values are refused.
2026-10-26 12:23:52 +00:00
github-actions[bot] 2016ba71f4 Regenerate generated files and fold pending files [skip ci] 2026-10-26 18:07:03 +00:00
ChrisNo 7556e23fe7 Reuse existing tabs when reopening saved designs (#2413) (#2414)
* claim: #2413

* Reuse existing model tabs when reopening saved designs (#2413)

* Expect Open Recent to reuse the existing design tab (#2413)

* Update tab-reuse regression for session visibility
2026-10-26 12:04:11 +00:00
semmelrinde 4d06daf87b Patterns: one before the feature it copies follows that feature's parameters (#6726) (#6727)
* Patterns: one before the feature it copies follows that feature's parameters (#6726)

A pattern or mirror of features made with the timeline rolled back comes
before its sources and replays them from their definitions. Its
fingerprint chained only the features before it and its own inputs, so a
change of a source's parameter left its result in place: the box grew,
its copy kept the old length, also after rolling forward.

The chain fingerprint of a pattern or mirror of features now also takes
the fingerprint of each feature it replays (and of their sources, for a
pattern of a pattern), wherever they are in the timeline.

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

* Patterns: a test for a later feature taking a deleted source's name (#6726)

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

* Patterns: fingerprint the replayed sources with the document, as fingerprint now takes it (#6726)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:01:58 +00:00
rlartot c7e53899af Updated AppImage takes the release file name (#3538) (#3658)
* claim: #3538

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

* Updated AppImage takes the release's file name (#3538)

A versioned AppImage name (solidcraft-<version>-linux-<arch>.AppImage) is
replaced by the new release's name, so zsync-based tools keep finding the
update data. Other names are kept.

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 11:58:47 +00:00
Nathan Dane 18ca30a311 fix(ui): open Appearance editor from context-menu parent (#5826) 2026-10-26 11:57:04 +00:00
Colin Faulkingham b55f30959d AI Preferences: reflect agent authentication results (#2578) (#4253)
* claim: #2578

* fix: reflect agent authentication in login badges (#2578)
2026-10-26 11:57:00 +00:00
semmelrinde 8b98f6c7e3 Sketch dimensions: a length's label says its unit (#1155) (#6131)
A sketch dimension read Ø40.00, with nothing to tell millimetres from
inches, while the value inputs and the pointer's coordinates show the
unit. Length labels now end with the design's unit (Ø40.00 mm, 1.000 in,
(25.00 mm) for a driven one); angles keep their degree sign.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:56:57 +00:00
bherbruck 3a504f5347 Agents: AGENTS.md becomes a router to focused skills, with a staleness check (Refs #6439) (#6444)
AGENTS.md drops from 16.8k to 3.7k characters: the hard rules, each naming the
check that enforces it, and a table of what to read for each kind of work.
The rest moves into eleven new skills; the five existing ones stay.

cargo xtask skills fails when AGENTS.md or a SKILL.md cites a path, an xtask
command or a skill that does not exist, when the router table and skills/
disagree, or when the router or a new skill grows past its size cap. It runs
in cargo xtask ci and the repository checks job.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:51:19 +00:00
Colin Faulkingham 7f2acbc004 Claim bot: only actual claims reserve issues (#6739) (#6775)
* claim: #6739

* fix(claim): reserve issues only for actual PR claims
2026-10-26 11:50:18 +00:00
Dahak Builds d3720041b6 CI tooling: opt-in bound for local test concurrency (#1188) (#6831)
* claim: #1188

* #1188: split per-binary test threads and matrix workers under SOLIDCRAFT_TEST_JOBS

---------

Co-authored-by: Dahak Builds <dahakbuilds@users.noreply.github.com>
2026-10-26 11:50:10 +00:00
Victor Santos Oliveira a3c7a6ff93 CI: let the paused-work bot label pull requests (#6852) (#6884)
* claim: #6852 paused-work PR labels

* fix(ci): permit paused-work labels on pull requests (#6852)
2026-10-26 11:49:59 +00:00
Christopher Koch e6fb3793dc design: a whole-boundaries loop loads in any curve order (#6499) (#6516)
* claim: #6499

* Design: a whole-boundaries loop loads in any curve order (#6499)

---------

Co-authored-by: Framewrk CI <ci@framewrklabs.ai>
2026-10-26 11:29:00 +00:00
CamWight c6d361347f Section Analysis: the cap is marked by true depth, so far edges no longer take a cap band (#6328) (#6332)
With a section shown, the marking pass decided where the cap shows (the stencil) and wrote the
depth everything after it tests against. #1768 gave it the faces' FACE_BIAS for the second job,
so edges draw over their own faces (#1434). For the first job its slope part is unfair: next to an
edge where a back face seen through the cut meets a front face, the front face won a band about a
pixel wide although the back face is nearer. The cap skipped that band, and the edge line showed
through the cut.

#6363 marked those samples again from the back faces, pulled toward the eye as far as FACE_BIAS
pushes. That closes the gaps beside the edges, but the pull also lets back faces win wherever they
lie within the push of a front face, along every body's far silhouettes, and the cap hatches that
band. On bherbruck's block, the band away from the cut went from 58 px orthographic and 534 px
perspective before #6363 to 618 and 780 with it. In the app, his filleted hub showed it in every
frame of a 36-step orbit in both projections.

This replaces that pass. While a section is shown the opaque model now draws in four steps (`Cut`):
1. mark: the faces by their true depth (no push): colour, the stencil and depth;
2. a triangle over the viewport sets the depth back to the far plane, colour and stencil kept;
3. push: the faces again with FACE_BIAS, depth only, so the depth is what unsectioned frames have
   and edges draw over their faces as before;
4. cap: depth only, as #6728 made it; the mark pass has drawn the cap's colour and hatch, as fs_solid
   draws a back face seen through the cut.
The window pass's depth reset and this one are one helper, `depth_reset_pipeline`, for either
depth direction, and its passes are built by #6728's `solid_writes`. Unsectioned frames are unchanged. The CPU rasterizer already chose the cap by
true depth.

Tests (crates/gpu/tests/depth.rs), with the device's float depth and the 24-bit fallback:
- a_cut_hides_the_edges_behind_it (new): bherbruck's block cut at y = -16, home view, orthographic
  and perspective. No edge shows through the cap, and away from the cut the picture is a box of
  just the kept part's: 0 px orthographic and 1 perspective (main: 618 and 780).
- bherbruck's pocket and hub tests from #6363 are kept and pass (pocket 0 px; hub 1 and 2 px over
  its twelve views, as with #6363's pass).
- an_edge_on_a_face_draws_whole_at_any_angle and
  an_edge_just_behind_a_face_stays_hidden_on_a_large_design also run with a section shown.

In the app (safe mode, WARP), with 625 patterned cylinders cut by a section, the frame time is
that of main (#6363): 33.5 against 34.3 ms at the home view, 34.1 against 35.2 ms with the model
filling the view.

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:16:44 +00:00
github-actions[bot] a078d4e7e9 Regenerate generated files and fold pending files [skip ci] 2026-10-26 17:30:10 +00:00
github-actions[bot] c71234edfb Regenerate generated files and fold pending files [skip ci] 2026-10-26 17:24:19 +00:00
Jan Szumiec 00ff63f75e Face pick by point: linear work budget, so bodies with many faces take a sketch (#6720) (#6721)
* claim: #6720

* Face pick by point: linear work budget, so bodies with many faces take a sketch (#6720)

A face picked by its point alone charged the whole mesh once per face, and
face_contains scanned every triangle for each candidate. A block with a
modelled thread (about 5 000 faces) went past the 2 000 000 limit. The
triangles are grouped by face once, each face's test reads only its own,
and the budget charges triangles + faces.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:16:17 +00:00
leValdemar19 82cd5cc52e Sketch Dimension: value box in the document's units (#3812) (#3878)
* claim: #3812

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

* Sketch Dimension: value box in the design's units (#3812)

A dimension added without a value restated what it measured as
"<value> mm", whatever the design's units. In an inch design the
Dimension tool's value box therefore opened at "25.4 mm" next to a
dimension whose text read 1.000 (inches).

Document::design_length writes a measured length in the design's units
("1 in"), with enough decimals to keep it to the micrometre, so a
dimension that restates the geometry still moves nothing. The Dimension
tool, AutoConstrain's dimensions and Toggle Driven (making a driven
dimension driving) use it. Millimetre designs are unchanged.

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

* Move the design-units dimension tests into sketch_ux_tests (review)

The #3812 tests go in the existing sketch dimension test file rather than
a new file of their own (AGENTS.md, #3226).

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-26 11:16:12 +00:00
Colin Faulkingham 7d7016b356 Insert Design: preserve source part properties (#6624) (#6691)
* claim: #6624

* Preserve source metadata when inserting a design (#6624)
2026-10-26 11:16:07 +00:00
Colin Faulkingham cd66495e14 Preserve plastic rules and hidden bodies through renames (#4421) (#4425)
* claim: #4421

* Fix body metadata and visibility across renames (#4421)
2026-10-26 11:15:31 +00:00
Colin Faulkingham 484f0cf8ae Sketch: keep nested circles ordered when diameters change (#6764) (#6789)
* claim: #6764

* fix(sketch): preserve nested-circle order during dimension edits (#6764)
2026-10-26 11:14:59 +00:00
SS-EN daecea6edf Evaluation cache: charge each state only for the bodies it made, so setting a parameter back restores (#6897)
The evaluation cache's 64 MB budget charged every cached feature result the whole model, though a
state shares every body it did not change with the state before it. A design whose model is a few
MB kept only its last few dozen results, so setting a parameter back recomputed what had dropped
out. Each result is now charged for the bodies and sketches it does not share with the state it
started from.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:14:57 +00:00
Homero Thompson bd38bc40be Sketch: the Midpoint Line tool shows the length and angle while drawing (#6198) (#6401)
* claim: #6198

* Sketch: show the Length/Angle readout while drawing a Midpoint Line (#6198)
2026-10-26 11:14:22 +00:00
Frank 19c8eb846e Crash reports: keep journaling through file.new and doc.open (#6550) (#6551)
* claim: #6550

* Crash reports: keep journaling through file.new and doc.open (#6550)

`Session::replace_design` swapped in the fresh session without its
`crash_journal` flag, and the app turns it back on only at its next frame.
So `file.new`/`doc.open` never recorded how they ended, the commands sent
after them in the same frame (a control-channel or MCP batch) were not
journaled, and a crash report then named `file.new` as the command in
flight. `replace_design` now keeps the flag, like the other session-wide
fields it keeps; only the design on screen has it set.

Test: `commands_right_after_a_new_design_are_journaled` (crash_tests.rs).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:14:20 +00:00
Dahak Builds 6f5f64f2d9 Viewport: perspective while orbiting, orthographic on face-on views (#2682) (#6700)
* claim: #2682

* WIP: perspective-while-orbiting with auto-ortho on face views

---------

Co-authored-by: Dahak Builds <dahakbuilds@users.noreply.github.com>
2026-10-26 11:14:14 +00:00
Homero Thompson b2525ed34e Tests: the six timing assertions do not depend on the wall clock (#6917) (#6925)
* claim: #6917

* Tests: the six timing assertions do not depend on the wall clock (#6917)

Six tests asserted on the wall clock. Their result depended on how loaded
the machine was and not only on the code under test: they passed on an idle
machine and failed when the test binaries ran beside each other, which is how
the suite runs (`cargo xtask test` starts every test binary at once).

CONTRIBUTING.md:196 says it: "Tests run beside each other on a busy machine,
so a test must not depend on wall-clock timing."

The change is confined to the six test files the issue names:

- geom: the tangent chain of a helix is a ratio (750 pieces against 3 000)
  instead of a limit of 200 ms.
- io: the placement budget is spent inside the reader, so the clock is gone.
- ui-egui: the classification of many faces is a ratio (900 against 3 600)
  instead of a limit of 10 s.
- engine: the clearance measurement drops its 30 s limit; the boolean that
  gives up reports itself.
- engine: the fillet beside a modelled thread is a ratio (a 1 mm thread
  against an 8 mm one) instead of a limit of 30 s.
- ui-egui: the window frame budget goes from 250 ms to 20 s, the limit #6072
  gave its own probe.

The helper is the one #649 wrote, copied into the crates that need it, with
the noise of a run as a parameter.
2026-10-26 11:14:11 +00:00
Homero Thompson 72a76edcf7 Shortcuts: Ctrl+Shift+S opens Save As in the workspace (#6558) (#6567)
* claim: #6558

* Shortcuts: Ctrl+Shift+S opens Save As in the workspace (#6558)

The Home page already opened Save As with Ctrl+Shift+S, but the design workspace matched Ctrl+S, so the chord saved over the current file instead of offering another one. The workspace now routes the chord to Save As, and the File menu shows that shortcut next to Save As, taken from the command registry.
2026-10-26 11:14:07 +00:00
Colin Faulkingham 710b7a664e Save a mesh larger than the design file once it is compressed (#6600) (#6701)
* claim: #6600

Co-authored-by: Cursor <cursoragent@cursor.com>

* Save an imported mesh that only fits after compression (#6600)

The design file limit was applied to the mesh before Deflate, so a mesh
that had already loaded could not be saved or autosaved.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: validate and version large mesh saves (#6600)

---------

Co-authored-by: Colin Faulkingham <colinfaulkingham@Colins-Mac-mini.local>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-26 11:13:48 +00:00
HiroKenEz 0d32821e9e Top-level cut and join reach bodies in sub-components (#5679) (#6246)
* claim: #5679

* Top-level cut and join reach bodies in sub-components (#5679)

An extrude, revolve or press-pull from a sketch on a face of a body in a
component inside the active one now cuts or joins that body, moved or
turned, in its own component, so every placement shows the change. The
automatic operation probes that body where it is shown. A linked part, or
a face on a further placement of a part placed more than once, is refused
with the reason instead of a silent no-op (#5678).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:13:45 +00:00
Homero Thompson d9c5e4c325 Sketch: a circle drawn at the origin can be moved (#5034) (#6462)
* claim: #5034

* Sketch: a circle centred on the origin can be moved (#5034)

* test(#5034): say what the assertion really checks
2026-10-26 11:13:14 +00:00
Homero Thompson 12fe17d1b7 Modeling: a copied body keeps the appearance of its original (#6423) (#6456)
* claim: #6423

* test: a copied body must keep the look of the body it came from

solid.move with copy: true leaves the copy without the appearance, the
material or the face looks of the source body, so it renders with the
default look. The tests fail for that reason: the copy has no entry in
appearances.bodies, in materials or in appearances.faces.

* fix: a copied body keeps the look of the body it came from (#6423)

A move with `copy: true` pushed a new body but left it with the default look, so the copy came out a different colour than the part it came from. The copy now carries the body's appearance and its material, as the component copy already did.

* test: keep the copy regression to the body look and the material (#6423)

* test: each copied body keeps the look and the material of its own source (#6423)

Two bodies with different appearances and different materials, copied in one solid.move: each copy must keep the look and the material of the body it came from.

Scope of the fix: the look of the BODY and its material are carried over to the copy. The per-face looks are NOT carried over: a face look is keyed by the point on the face, so the copy's faces would have to be matched and their points translated, which is a separate feature.
2026-10-26 11:12:50 +00:00
semmelrinde cf7fbeaba1 Sketch drag: a scripted drag solves the steps past a failed one at once (#6606) (#6607)
A drag that isn't live walks to the cursor in steps, each a held solve
on its own budget seeded on the last solved state. Past a failed step
the walk keeps trying every step (a stretch the geometry can't follow
can be followed by one it can), and each failing solve runs the
numerical solver to its limit: one drag in a 65-command sketch took
7.7-9 s, most of it in failing solves.

A failed step leaves the sketch as it was, so the steps after it all
start from the same state and none changes what the next one sees.
They are now solved eight at once on all cores; those that fail in a
row are taken as tried and the walk goes on at the first that doesn't,
as it would one by one. The drag above takes 3.7-4.3 s and every
command's reply is unchanged. Live frames, whose steps share one
frame's budget, are left as they are.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:12:40 +00:00
semmelrinde e2c9f908f8 Sketch: an offset slot can still be moved by the dimension that places it (#6569) (#6697)
* claim: #6569

* Sketch: an offset slot can still be moved by the dimension that places it (#6569)

An offset arc is drawn round its source's own centre, and its ends are
held while the sketch solves (the offset is rebuilt from its source
afterwards). Its arc condition, both ends as far from the centre, then
only tied that shared centre, and the source arc with it, to the middle
of the held ends: a slot offset by 1 mm lost a degree of freedom, and
the length that places it was refused as conflicting.

The solver and the constructive reading now leave out the arc condition
of an offset arc round its source's centre (Sketch::offset_arc_follows_source),
as #4466 did for the Parallel between an offset line and its source. The
source's own arc keeps the centre; nothing changes without an offset.

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

* Sketch: a test for a rounded rectangle with an offset round it (#6569)

kaievns's case from the review: the offset's corner arcs tied each corner
centre to a 45 degree line, so the height followed the width and every
width change was refused.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:12:15 +00:00
NOWN-DEV fa961eaa31 Sheet metal: a pattern of a cut made in the flat is kept like the cut (#6841) (#6853)
* claim: #6841

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

* Sheet metal: a pattern of a cut made in the flat is kept like the cut (#6841)

A rectangular, circular or path pattern of an extrude cut through sheet metal was refused
("this sheet machining cannot be preserved in its flat material"), though the cut itself is
kept as a cut-out of the flat pattern. The pattern replayed its copies through the generic
boolean, whose sheet check refuses any change the sheet model can't keep; the cut goes through
the sheet's flat machining first.

Each copy of an untapered extrude cut is now machined into the sheets the same way, in its
moved sketch plane (a moved copy keeps the extrude's prism certificate), and then cut from the
other targets. A copy that meets a folded bend is refused as the cut is there. Mirrored copies
(the sketch plane turns over) and patterns of an intersect (which cut with the union of their
copies) keep the generic path.

Fixes #6841.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:12:12 +00:00
sascha2101 aa88360357 Sketch: deleting part of a projected link no longer brings it back (#5098) (#6324)
* claim: #5098

* Sketch: deleting part of a link breaks off the rest instead of rebuilding it (#5098)

Deleting some of a link's curves left the rest linked. The next refresh saw a
different layout and rebuilt the whole link as normal geometry: the deleted
curve came back and the others lost their construction flag (a projected arc
returning as a solid arc, a projected face's lines turning solid).

sketch.delete now breaks the link for what remains, as Break Link does: those
curves stay where they are and keep their flags. Deleting a whole link, and
text links, are unchanged.

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

* Move the #5098 tests up in sketch_project_tests.rs

They sat at the end of the file, where main has since added other
tests; moving them next to the break-link test avoids the merge conflict.

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

---------

Co-authored-by: sascha2101 <221609842+sascha2101@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:11:19 +00:00
sascha2101 e1ff3fa9c0 Batch: refuse a step that would fail at its turn before the first step runs (#5853) (#6781)
* claim: #5853

* Batch: refuse a step that would fail at its turn before the first step runs (#5853)

engine.batch and the MCP batch tool checked only a batch's shape up
front. A misspelled command id in a late step was found at its turn,
after every earlier step had run, and then all of it was rolled back: a
160-step batch with a typo as its last step ran for a minute and
changed nothing.

solidcraft_engine::batch_preflight::check finds, before anything runs,
the first step the batch would certainly refuse at its turn: a step
without a command, an unknown command id (the error names the closest
ids, as at the step's turn) or, with strict parameter checking, a call
that breaks its command's typed contract. Anything that depends on the
design is left to the step's turn.

- engine.batch (control channel) checks against the app's registry and
  its session's level; view.pick, which the app answers itself, passes.
- The MCP batch tool checks when the commands run in its own process
  (headless, with --param-check from the backend). A running app may be
  another version with other commands and checks the batch itself; its
  reply's not_run is passed on.
- The refusal is the failing step's reply with completed 0, undone 0,
  not_run listing every step and a note.

Tests that used an unknown command only to make a late step fail now
use a step that fails at its turn (a bad extrude distance), so they
still test rollback, timings and file.new as before.

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

---------

Co-authored-by: sascha2101 <221609842+sascha2101@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:11:10 +00:00
yhamil04 3afcd69a5c Joints: a loop whose axes are a hair out of parallel still moves (#6211) (#6602)
* claim: #6211

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

* Joints: a loop whose axes are a hair out of parallel still moves (#6211)

Holes picked on parts are rarely exactly in line. A closed loop through
axes a few hundredths of a microradian apart (a lever, link and a rod on
a cylindrical axle) can't close exactly: it closes to nanometres, and the
gap grows as it turns. The loop solver wanted 1e-7 mm, so it threw such a
closure away (the loop stayed open, its parts at 0 dof) or stopped a drive
or drag after a fraction of a degree.

The conflict check is now 1e-4 mm / 1e-6 rad (it was below what its
message can show), and joint.drive / motion and joint.reach close the
loop at each step as at the start: to the tight tolerance where they get
there, otherwise within the conflict check.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:10:45 +00:00
yhamil04 a3483ed451 Body rename: an imported assembly's other bodies keep their names and components (#2175) (#6615)
* claim: #2175

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

* Body rename: an imported assembly's other bodies keep their names and components (#2175)

An Import keeps its body names by the file's body index, and builds no body for the
file's later instances of a repeated part (`skip`). Renaming a body stored the names by
the body's place in the model instead, so every name after the first skipped instance
landed one slot early (and the new name could land on a skipped slot). The rename looked
right until the design was rebuilt; then those bodies took other names, dropped out of
their components and showed up loose in the import's top component.

`Feature::body_name_slots` maps the bodies a feature builds to their stored slots, and
`Feature::pin_body_names` stores current names through it. The rename, the pinning of
later features' names (rename and suppress) and `Document::body_rename_from` use them.

Fixes #2175

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:10:39 +00:00
yhamil04 d7252f2240 Sketch: constrain to an edge of any body, a mirrored copy too (#6348) (#6617)
* claim: #6348

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

* Sketch: constrain to an edge of any body, a mirrored copy too (#6348)

A constraint tool ignored a click on a body edge, so only edges already in
the sketch (the face it sits on) could be constrained to; a mirrored copy's
edges never were. Constraint commands now take a model edge or vertex
({edge|vertex: [x,y,z], body?}) for an operand and project it first, in the
same undo step, and the pick tools pass a clicked edge or vertex that way.
The link keeps the edge's persistent name, so the constraint follows rebuilds.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:10:35 +00:00
raykholo 30e9b87b48 Render workspace uses the window's graphics backend on Windows (#6592) (#6629)
* claim: #6592

* Keep Windows Render setup on the viewport backend
2026-10-26 11:10:32 +00:00
github-actions[bot] b20f522f5d Regenerate generated files and fold pending files [skip ci] 2026-10-26 16:34:07 +00:00
Mateusz Sych d895fef354 Control channel: a pushed design.changed event for clients that subscribe (#6985) (#6986)
* claim: #6646

* Control channel: document.revision, a design revision and a document id (#6646)

A program that follows the open design asks document.revision and exports
only when the answer moves: document_id names the open design (kept through
edits, saves and renames, another for each design started or opened, never
shared between two tabs or two runs) and revision moves whenever the design,
what is hidden or an uncaptured component position changes. It stays put on
reads, selections and view changes, unlike the session's view revision.

The revision is read lazily: the session remembers the design it last
reported (held weakly, so Arc::make_mut can never change it in place and its
address can't be reused) and takes a new number from one counter for the
whole run when the design is another one. The control method answers at once,
also while a slow command computes; engine.execute reaches the same command
for scripts and MCP.

* document.revision: hash the run's id prefix with the process's random keys

A clock set before 1970 gave every run the prefix 0, so a restarted app could
answer a new design with the id and revision of the old one. The docs no
longer promise an answer at once: a screenshot waiting for an opened design to
be meshed still holds the requests behind it.

* document.revision: its label in the complete catalogs (ja, zh)

* claim: #6985

* Control channel: control.subscribe and design.changed events (#6985)

A connection that sends control.subscribe gets the active design's id and
revision in the reply (as document.revision answers), then a
{"event":"design.changed","params":{document_id, revision}} line whenever
either changes, so a live link need not poll.

The window publishes the design at the end of each frame's logic and
drawing while a connection is subscribed (one atomic load otherwise). Each
subscribed connection waits on that from a thread of its own and writes
the latest after a 100 ms gap, so a burst of changes is one line. Replies
and events share one lock per connection, each written whole (#6390).

* control.subscribe: the reply before any event, a second one refused

The events thread started before the subscribe reply was written, so a
change right after the window's answer could reach the client before the
reply that named the older revision. The subscription now starts holding
the connection's line lock and writes the reply under it.

A second control.subscribe on a subscribed connection is refused on the
connection itself: its answer could arrive behind a newer event. When no
thread or socket is left for the events, the reply says it did not
subscribe instead of ok.

* control.subscribe: a second one does not lose a finished late reply

The already-subscribed answer came before the busy check, so a request that timed out and had finished since lost its reply: control.pending then said the app ended it without one. It now comes after the busy check and drops a finished late reply as every other request does.

* control_server: declare the subscribe guard at the top of the loop

Clear of the lines #6643 changes (its token check moves to where the declaration was).

* control_server: the subscription's items beside serve

Away from the lines next to send, where #6643 adds token_of.

* control test: New on a blank design reuses its tab, so redo the box first

---------

Co-authored-by: MateuszSychXpl <151755914+MateuszSychXpl@users.noreply.github.com>
2026-10-26 11:10:03 +00:00
Dahak Builds 070b1414ee Fix manual vertical/horizontal constraints with circle centre (#617) (#6928)
* claim: #617

* fix(sketch): manual Horizontal/Vertical between a circle centre and an edge (#617)

The manual H/V tool silently did nothing when a point and a straight line
were picked or selected together (only two points or a lone line worked),
while auto-constrain inference applied the alignment. A point picked or
selected with a straight line now aligns the point with the line's
midpoint, in the mode the line's dominant axis suggests, through the
existing midpoint alignment in sketch.constraint.horizontal_vertical.

- tools: constraint_preselection consumes [point, line] selections; the
  interactive tool applies the alignment on the second pick (a line).
- context menu: Horizontal/Vertical Constraint items for point + line.
- engine + ui regression tests pin centre-edge alignment and the
  edge-to-centre dimension from #617.

Written by Qwen.

* style: formatting cleanup for #617 files

---------

Co-authored-by: Dahak Builds <dahakbuilds@users.noreply.github.com>
2026-10-26 11:09:52 +00:00
n0tfr3shm1lk 111c662fe4 Extend: join a stop that is already another curve's end (#6863) (#6865)
* Extend: join a stop that is already another curve's end

sketch.extend refused a target that coincided with another curve's end
("the extension target coincides with existing geometry") instead of
extending onto that point. The stop is now joined: a new line ends on the
existing point, and a free end grows onto it held by a Coincident. The
preview shows that stop too, so the line it draws is the line the click adds.

A stop at the end of a spline or conic joins that end the same way, and
extending an infinite line now refuses instead of moving one of its two
points.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>

* Extend: close an arc, take an equal stop, preview with the command's search

An arc with nothing on its path now grows all the way to its own far end and
closes into a whole circle instead of reporting that there is nothing to
extend to.

Two curves stopping the extension equally far are both on the way, so the first
stop is taken instead of refusing to choose between them.

The line preview and the command now share one stop search (`line_aim`), so the
preview can no longer draw a line the click refuses.

---------

Co-authored-by: n0tfr3shm1lk <n0tfr3shm1lk@ukr.net>
Co-authored-by: Codebuff <noreply@codebuff.com>
2026-10-26 11:09:42 +00:00
Dahak Builds ab709e6c46 Add base-height and new-body options to Snap Fit (#1994) (#6911)
* claim: #1994

* plastic.snap_fit: base_height and new_body options (#1994)

Base Height extends the snap fit's base below the picked face; New Body
makes the snap fit its own body so it can be patterned or moved. Both
optional, serde-defaulted, old designs replay identically.

* ui: snap_fit length-field defaults list gains the optional base height

---------

Co-authored-by: Dahak Builds <dahakbuilds@users.noreply.github.com>
2026-10-26 11:09:30 +00:00
Colin Faulkingham 03bf992bc1 Keep linked components valid through Undo after Save As (#6860) (#6877)
* claim: #6860

* fix: preserve linked component targets across Save As history (#6860)
2026-10-26 11:08:32 +00:00
bherbruck 586a358ed4 Release 0.23.1 2026-10-26 07:07:21 -04:00
bherbruck 4d61100534 Release 0.23.1: version bump, design format 38 (#7035)
The component colour tests no longer assume a design always carries a list of
pending format changes; once the release folds them, there is none.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 07:07:18 -04:00
tr1pp4 25ad6c7266 Browser: a feature's menu edits its profile sketch, suppresses and rolls back (#6673) (#6674)
* Browser: a feature row edits its profile sketch, suppresses and rolls back

A sketch button on feature rows with a profile sketch, and the feature menu gains
Edit Profile Sketch, Suppress/Unsuppress and Roll Back Here. Features behind the
history marker or suppressed are drawn faint, as in the timeline.

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

* Browser: the sketch button sits right after the feature's name

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:05:38 +00:00
HiroKenEz cfb3f52aec cli_link: the symlink test runs on Unix only, so the UI tests build on Windows (#6545) (#6546)
* claim: #6545

* cli_link: the Unix-only link code and its test build on Unix only, so the UI crate builds on Windows (#6545)

slot() is used only by link_in's Unix version, and links_only_where_it_may
makes its own link with std::os::unix::fs::symlink; gate both like link_in.
On Windows the test failed to compile (E0433) and clippy -D warnings flagged
slot as dead code.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:46:43 +00:00
Colin Faulkingham 7c31631ea6 Draft: preserve the selected faces’ body (#6350) (#6446)
* claim: #6350

* fix(ui): preserve the selected faces' body in Draft (#6350)
2026-10-26 10:45:09 +00:00
Frank c13e516cd5 fix: allow SpaceMouse rotation after tilting sketch view (#6687)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-26 10:44:31 +00:00
raykholo c264c7a42a Startup falls back from a Direct3D 12 that can't build the UI pipeline (#6648) (#6682) 2026-10-26 10:44:13 +00:00
HiroKenEz 56f09f3993 Frame rate readout stays beside a right-side pane, not under it (#6755) (#6767)
* claim: #6755

* Frame rate readout stays beside a right-side pane, not under it (#6755)

The readout and its details sat in the viewport's bottom-right corner even under the
floating Assistant. They now move just left of any pane over that corner and come back
when it closes.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:43:53 +00:00
Dahak Builds 6e63f8bd97 Construct toolbar regression tests: dialogs ask for picks (#204) (#6681)
* claim: #204

* Construct toolbar regression tests: dialogs ask for picks (#204)

* chore: re-trigger checks (defaults line already present in PR body)

---------

Co-authored-by: Dahak Builds <dahakbuilds@users.noreply.github.com>
2026-10-26 10:43:47 +00:00
Christopher Koch b01f361edf AI chat: keep only the newest views in the history, and explain HTTP 413 (#2298) (#2299)
Every view attached to an API-agent message stayed in the history as a
base64 data URL, and the whole history goes with every request (up to 24
per message). Requests grew by one screenshot per message until the
endpoint refused them with HTTP 413, after which every message failed.

Keep the newest two views as pictures and swap older ones for a short note;
the text sent with them and every other message stay. HTTP 413 now says the
conversation is too large for the endpoint and to start a new chat.

Fixes #2298

Main now speaks the Anthropic protocol too, where a view is an `image`
block rather than an OpenAI `image_url` part: drop_old_views trims both,
before trim_history. ui.ai_http_too_large is in every catalog that has the
other ai_http_* messages.

Co-authored-by: Framewrk CI <ci@framewrklabs.ai>
2026-10-26 10:43:30 +00:00
Colin Faulkingham c482dab4a1 Sheet Metal: open the export save picker (#6847) (#6856)
* claim: #6847

* fix(ui): open sheet export save pickers (#6847)
2026-10-26 10:43:01 +00:00
raykholo 189b47c750 Chamfer and Fillet refuse a point where several edges meet (#5173) (#5177) 2026-10-26 10:42:59 +00:00
Colin Faulkingham 1530a8e247 Route left History feature clicks into command dialogs (#6532) (#6734)
* claim: #6532

* Fix History feature picks in command dialogs (#6532)
2026-10-26 10:42:49 +00:00
sascha2101 1d2674ebc4 Sketch: a regular line on a construction line is picked before it (#6396) (#6753)
* claim: #6396

* Sketch: a regular line on a construction line is picked before it (#6396)

Coincident sketch curves tied on pick distance and the one drawn first
won, so a construction line drawn first hid the regular line on part of
it. Construction and centre lines now get half a screen point added to
their pick distance: on a tie the regular curve is hovered and picked,
and a construction line clearly nearer the cursor still wins. Clicking
again and Select Other still reach the construction line.

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

---------

Co-authored-by: sascha2101 <221609842+sascha2101@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:42:48 +00:00
momysnow 10b5cc5914 GPU: an uncaught error on the window's device is logged, not a panic (#5486) (#5667)
wgpu's default uncaptured-error handler panics ("wgpu error: Out of Memory").
The headless picture devices already report instead; the window's device had
no handler. Errors the code expects stay under error scopes.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 10:42:44 +00:00
Kai Evans 41a938e731 Sketch: a fillet stays the arc of its corner when the corner mirrors or a line is dragged through it (#5677)
A line tangent to an arc at the arc's end had no sense: the tangency held
with the line on either side of its touch point, so pinning a line's far
end across the corner left the line cutting back through its fillet, and
when the solver found the mirrored corner the arc kept its stored
direction and became its three-quarter complement, inside out.

The solver now keeps each fillet an arc of its corner. A line tangent at an
arc's end keeps its sense there (it leaves the arc as it did) and the arc
its side of its chord, compared with the positions before an interactive
move or the stored ones. A corner that mirrored gets its arc on the
mirrored side: its ends swapped where the solve ran the arc round the
other way with the lines following, or the arc reflected across the line
that stayed and solved again where a line cut back through. A solve that
would loop the arc the other way round, or fold a line onto it, is refused
as a fold is. A drag's cursor-following steps keep the fillets too, so a
line dragged through its own fillet stops at the arc.
2026-10-26 10:42:41 +00:00
N3MI-DG 9d0b509961 ui_kit slider: cover egui's own handle at the top of the range (#5708) (#5712)
The kit slider adds a real `egui::Slider`, lets egui paint it, then paints an
opaque rectangle over it and draws the kit's track and thumb on top. That mask
was exactly `slider_width` wide, and egui places its handle so the circle's
right edge lands on `rect.right()` at the maximum, so the fill stopped flush
with the handle and its last column survived as a light sliver beside the
value box. The kit's own thumb cannot hide it either: its radius is
`t.space_8` while egui's handle radius is `input_radius`, about two pixels
larger.

Extend the mask one pixel past its right edge. The gap before the editor is
`item_spacing.x`, so nothing else is covered, and only the maximum was
affected: at the minimum the handle's left edge coincides with the mask's left
edge, which a fill does cover.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-10-26 10:42:38 +00:00
sascha2101 7ae17e6dd5 Sketch display: each sketch samples within its own budget (#6524) (#6838)
* claim: #6524

* Sketch display: each sketch samples within its own budget (#6524)

After a large DXF was inserted, lines drawn in a later sketch vanished
once finished: only their end points showed and could be picked, and
hiding the DXF sketch brought them back.

The display cache had one sampling budget for every shown sketch (4096
curves, 131 072 vertices per model state and view). Sketches are drawn
and picked in timeline order, so a large imported drawing used it all
and every later curve came back empty (BudgetLimited): not drawn, not
pickable, while the points, drawn separately, stayed.

Each sketch now has its own budget with the same limits (curves,
profiles, vertices and nodes). The cache as a whole is capped at 65 536
curves and 16 384 profiles, so memory stays bounded however many
sketches are shown.

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

---------

Co-authored-by: sascha2101 <221609842+sascha2101@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:42:35 +00:00
github-actions[bot] 1cd14e2412 Regenerate generated files and fold pending files [skip ci] 2026-10-26 14:29:06 +00:00
github-actions[bot] 7014b62388 Regenerate generated files and fold pending files [skip ci] 2026-10-26 14:22:28 +00:00
HiroKenEz adc568a715 Context menu: the Appearance flyout closes when the pointer moves to another row (#6657) (#6784)
* claim: #6657

* Context menu: the Appearance flyout closes when the pointer moves to another row (#6657)

Hovering Appearance opened its quick-choice flyout, but resting on a plain
row of the same menu never closed it: only another submenu row replaced it.
Now a flyout closes once the pointer moves onto another row and rests there
for 0.3 s, so a diagonal path to the flyout across a neighbour keeps it open.
A flyout opened with the keys stays open while the pointer is still.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:42:32 +00:00
Colin Faulkingham 12b0d9dbff Preserve unchanged results when rolling the timeline (#6008) (#6067)
* claim: #6008

* Reuse rolled-back timeline results after cache eviction
2026-10-26 10:42:32 +00:00
semmelrinde 613903229e Patterns: copies of a To Object extrude follow the face it reaches (#2121) (#6093)
A feature's face points are moved onto their named faces before it is
evaluated, so a To Object extrude follows its target face when an earlier
feature moves it. A pattern's or mirror's copies were built from the
feature as stored, with the face where it was picked: after a gear's disc
was made thicker, the tooth reached the new top but its pattern copies
kept the old height. The copies now use the same moved points; the move
is one helper used in both places.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:42:28 +00:00
Jesper We 0c4eb458df macOS 3D mouse: unregister from the driver when the app ends, so the next launch gets the mouse (#6114) (#6115)
* claim: #6114

* macOS 3D mouse: unregister from the driver when the app ends, so the next launch gets the mouse (#6114)
2026-10-26 10:42:25 +00:00
Homero Thompson b00c432ac9 space-mouse: cover the 32-bit usage item (#6138)
Issue #37
2026-10-26 10:42:21 +00:00
Jesper We 8a28d13161 Perspective view as a command, so a 3D mouse button can switch it (#6143)
* claim: Perspective view as a command

* Perspective view as a command, so a 3D mouse button can switch it
2026-10-26 10:42:18 +00:00
NOWN-DEV 74321f8bbd Sketch Intersect: crossings on curved faces lie on the surface; a plane on a top face cuts it (#6272)
* Test: sketch Intersect keeps its exact cuts, crosses curved faces on the surface, cuts on a top face

Intersect's cut of a box, a cylinder, a sphere and a tilted cylinder, as it is on main, pinned.
Two cases where it is not exact: a plane through a cylinder's rim cuts the mesh's chord (its sides
come out at 6.3159 and 6.3085, not the square root of 40; a hole's walls at 25.0122), and a plane
on a body's top face finds nothing ("projects to nothing on this sketch plane").

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

* Sketch Intersect: crossings on curved faces lie on the surface; a plane on a top face cuts it

Problem: the plane cut that sketch Intersect makes of a body's mesh (project::section) took a
crossing on an edge where a curved face meets another (a cylinder's rim on its cap) from the
merged normals, which are undefined at the crease, so it fell on the chord: a cylinder's sides came
out 0.01 mm off and uneven. A plane through a vertex or on a face counted the vertices on it as
below, so a plane on a body's top face crossed no edge and found nothing.

Solution: the crossings are found by section_segments, which section fits as before: an edge where
a curved face meets another takes the curved face's normals, so the crossing lies on its surface;
a vertex within 1e-9 x the mesh's size of the plane is on it, on one side for every edge (below
when the mesh lies on or above the plane, else above), and an edge ending there crosses at that
vertex, which is an exact point for the fit. Each segment keeps its B-rep face, for a section that
groups them by face. Cuts that were exact are unchanged.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:42:08 +00:00
Colin Faulkingham a67b3bfdf3 Surface Loft: retain profiles from multiple sketches (#6400) (#6844)
* claim: #6400

* fix(ui): retain surface loft profiles across sketches (#6400)
2026-10-26 10:42:05 +00:00
Colin Faulkingham abdf9d79cf Move/Copy: show a handle for the selected rotation axis (#6822) (#6832)
* claim: #6822

* fix(ui): show selected-axis handle for Move Rotate (#6822)
2026-10-26 10:42:01 +00:00
Colin Faulkingham b1bbca97ef Translate the remaining history tooltip text (#4370) (#6905)
* claim: #4370

* fix(ui): translate history tooltips and feature status
2026-10-26 10:41:50 +00:00
Homero Thompson 2ec11cfc3a Docs: how to open the macOS build when the Gatekeeper blocks it (#5393) (#6566)
* claim: #5393

* docs: how to open the macOS build when the Gatekeeper blocks it (#5393)
2026-10-26 10:41:32 +00:00
github-actions[bot] 2205955329 Regenerate generated files and fold pending files [skip ci] 2026-10-26 13:52:28 +00:00
bherbruck bd9d0778f6 CI: keep lavapipe's shader cache for the GPU path tests (#7113)
The first test in each GPU path shard compiles the path pipeline cold
on lavapipe (335-502 s on 16 vCPU, 665 s on 8, against the 600 s setup
budget); the later ones read Mesa's disk cache in about 30 s. Put that
cache on a sticky disk that main commits, so the first test reads it
too until the shaders change.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 09:49:34 -04:00
cronos a0f7529fd6 fix(measure): select imported mesh outlines and endpoints (#7033) 2026-10-26 10:40:41 +00:00
Lars Jansen 4a0d519727 View: OK and Cancel in the corner of the view while a dialog or sketch is open (#6709) (#6714)
* claim: #6709

* feat(view): OK and Cancel in the corner of the view while a dialog or sketch is open (#6709)
2026-10-26 10:40:36 +00:00
Kai Evans ffbcdc614c History and parameter changes: the run here takes every result computed ahead (#7002)
A change computed off the window thread ran here with only the results of the evaluation
ahead's last evaluate primed. A parameter change evaluates twice (before and after its sketch
stores the new solution), so its first evaluate here found nothing to take, and on a design whose
states pass the evaluation cache's budget it recomputed the features on the window thread: the
window froze for seconds after Computing ended. The run now also takes what the evaluation ahead
kept (KeptResults, as an agent's control requests already do).
2026-10-26 10:40:33 +00:00
Thosch 06af6e1107 Pattern dialogs: Quantity is the count in every language, the motion plot keeps its own entry (#6538) (#6733)
The catalogue entry Quantity served both the pattern dialogs' number of
instances and the motion study's plotted quantity. Nine locales had
translated the second meaning, so the rectangular pattern dialog showed
e.g. "Größe" above "Anzahl 2" in German.

The motion study's row now reads motion.plot_quantity (a pending file
that keeps every locale's previous word there). Quantity takes each
catalogue's own ui.quantity text, which the "Quantity 2" row already
shows; nothing is newly translated. The glossary lists Quantity under
words with more than one meaning.

Fixes #6538

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:40:26 +00:00
bherbruck 84b1aeecae CI: GPU path tests in four shards on 16 vCPU (#7096)
On 8 vCPU the first merge-queue run after #7066 failed: one gpu path
test went past the 600 s pipeline-setup budget (665 s; 204 s for the
same test on the PR run) and shard 2 took 35 minutes. lavapipe compiles
on every core, so the shards move to 16 vCPU, and four count partitions
(27 tests each) replace three hash ones (41/40/27).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 09:23:41 -04:00
bherbruck a6354dfec5 CI: GPU path tests in three shards of their own; Windows in the queue only when a group touches it (#7066)
The 108 gpu path:: tests run one at a time (gpu-path test group) and took
about 36 of Native Linux tests' 45 minutes in a merge-queue run. They now
run in a GPU path tests job, three hash partitions on 8 vCPU, beside the
other tests; Native Linux tests leaves exactly them out. A `gpu` output of
Select checks drives both the job's `if` and the `ci` check.

The Windows jobs now run in a merge group only when its diff reaches what
they exercise (the pull request patterns and the Cargo.lock graphics-stack
check), not on pushes to main, and still nightly and by hand.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 08:22:53 -04:00
sascha2101 8108646e2c Docs: a sketch family's shortcut starts the variant used last (Refs #6630) (#6817)
Since #4308, L, R and C start their family in the variant used last, as
the toolbar button does, so L can start a Midpoint Line. The shortcuts
table still read "L | Line". Say so under the table and in the
sketching guide's Geometry section, with the preference that steps
through the variants.


Claude-Session: https://claude.ai/code/session_01FN5eEMrqWWXqi65FcK8GXq

Co-authored-by: sascha2101 <221609842+sascha2101@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:39:51 +00:00
Victor Santos Oliveira b3cbbe7bd5 CLI: reject incomplete and unsupported run/eval options (#6918) (#6919)
* claim: #6918 validate run and eval options

* fix(cli): reject invalid run and eval options (#6918)
2026-10-26 10:38:56 +00:00
Mishung ae2d2145b2 CONTRIBUTING: the desktop app runs on macOS too (#4535)
The README and docs/downloads.md ship a universal macOS disk image, but the
contributing guide listed only Windows and Linux.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:24:50 +00:00
Basil a800a40099 Fix Snap smoke startup race before viewport readiness (#5346) 2026-10-26 10:24:40 +00:00
Badger B dc3061a039 Docs: on Windows, give claude mcp add the CLI's full path (#5895) (#5896)
* Docs: on Windows, give claude mcp add the CLI's full path (#5895)

The Windows installer puts solidcraft-cli.exe in C:\Program Files\SolidCraft but not on PATH,
so the README's and docs/mcp.md's `claude mcp add solidcraft -- solidcraft-cli mcp` registers a
server that can't start there. Say so next to the command and give the full-path form.

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

* Docs: word the Windows full-path note so it holds once the installer sets PATH (#5895)

#5898 makes the MSI add its folder to PATH. Say "if a terminal doesn't find solidcraft-cli
(older Windows installers don't put it on PATH)" instead of stating that the installer never
does, so the note stays true whichever change lands first.

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

* Docs: the Windows CLI path is the default, not the only place (#5895)

The MSI installs to ProgramFiles6432Folder and lets people choose the folder
(WixUI_InstallDir), so C:\Program Files\SolidCraft\ is only the x64 installer's default; the
x86 MSI on 64-bit Windows lands in C:\Program Files (x86)\SolidCraft\. Say "by default" in
the README and docs/mcp.md (review on #5896).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:24:34 +00:00
Thosch 102314dbb2 Docs: blurry interface on Linux with display scaling, and how to fix it (#5979) (#6195)
SolidCraft runs through XWayland whenever an X display is available. A
compositor that doesn't let X11 apps scale themselves draws the window at
100 % and stretches it. docs/nix.md said so only for fractional scaling,
but GNOME 47 (RHEL and Rocky Linux 10) does it at any scale: the report
shows 1.05 physical pixels per point at 200 %.

The troubleshooting page gets a section with the cause, the Window system
preference, SOLIDCRAFT_WAYLAND=1 for 0.21.0 and earlier, and the file drop
trade-off; docs/nix.md links to it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:24:30 +00:00
Homero Thompson 4ced1d7bcf Viewport: the grid fades with the distance instead of cutting off (#4214) (#4629)
* claim: #4214

* Viewport: the grid fades with the distance instead of cutting off (#4214)

The ground grid stopped at a fixed distance from the view, so its edge showed
as a cut across the picture. It now fades out with the distance from the
middle of the view, and it is gone before either of the two lines that used to
cut it: its own edge, and the picture's far plane.

Each grid line is drawn in pieces, and each piece is as faint as the fade
makes it at the distance where it lies. The 'under' batch is drawn by the
overlay pipeline with alpha blending, so the colour's alpha reaches the
frame.

* The headless grid fades with the distance too (#4214)

The headless path (solidcraft-cli snapshot, the home-page thumbnail, the MCP
screenshot) drew the grid from the engine's palette, where a line had no
alpha: the grid still ended in a cut, and at a low camera angle the cut was
in the picture.

A SceneLine now carries an alpha, the CPU rasterizer blends with it and the
GPU capture passes it on, so the engine's grid can fade. The fade itself
moves to the engine, next to the grid's other geometry, and both paths use
it: the headless grid now reaches as far on the ground as the view is wide
(the ground stretches as it is seen at a slant) and dissolves into the
distance instead of ending in a line.

* Fix the headless/viewport grid fade divergence (#4214)

The engine stretched the grid's size with the slant of the plane it is seen at, the viewport stretched it only on the ground, so on a sketch's plane the picture's grid reached further than the window's (153.42 mm against 120.00 mm). Both now share one grid_ext, which the picture and the window draw the same grid from.

* The grid fades out before the picture's far plane cuts the ground (#4214)

The window's grid faded against a far plane of its own: the scene's radius
clipped by the projection's camera (260 mm away at 400 mm of distance), while
the picture itself cuts the ground at the far plane of its own projection
(223.7 mm at the same distance, because the projection frames the scene's
bounds and not its radius). The grid was still a third of its strength where
the picture cut the ground, so the cut showed as a straight line across the
view.

The fade now ends at the far plane the picture's projection uses, and the
grid is gone before that plane cuts the ground.

The test that watched the fade now measures the ink of each row of the
picture, and checks that the grid is already gone in the ten rows above the
row where the far plane cuts the ground: the point it compared before lay
beyond that plane, where both renders draw nothing at all, so the comparison
was always true.
2026-10-26 07:59:56 +00:00
DuBearte a82f839255 Docs: don't offer winget install before the package is in winget-pkgs (#5734) (#5827)
* claim: #5734

* Docs: don't offer winget install before the package is in winget-pkgs (#5734)

The README and docs/downloads.md said Windows users could run
`winget install bherbruck.SolidCraft`, but winget-pkgs has no manifests for
bherbruck.SolidCraft (manifests/b/bherbruck doesn't exist), so winget finds
no package. winget.yml only updates a package that is already listed, and
does nothing until then. Drop the command from both pages, and say in
docs/releasing.md that the first version has to be submitted once by hand
and the command added back when it's listed.

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

* Releasing: a first WinGet submission is already open; take it up (#5734)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 07:39:29 +00:00
Brandon Wees 08484af293 Finish the sketch when Extrude, Revolve or another feature starts (#2802) (#2815)
* fix(ui): finish the open sketch when a modeling feature starts

* fix(ui): wait for the marker roll before starting a feature from a sketch

* fix(ui): start the feature once the marker is back

* test(ui): keep the sketch open in the palette clearance test
2026-10-26 06:56:07 +00:00
raykholo d29e70bad9 docs: Save As blocked by Windows Controlled folder access (#4490)
Saving into Documents fails with the Windows dialog's "File not found" when
Controlled folder access blocks the app, and the message gives no hint.
Refs #4479.
2026-10-26 06:49:53 +00:00
Logan Night 1d482aff74 Docs: AppImageLauncher 2.x can't start the AppImage; update to 3.0 (#4640) (#4674)
* claim: #4640

* Docs: AppImageLauncher 2.x can't start the AppImage; update to 3.0 (#4640)

AppImageLauncher 2.x intercepts every AppImage through binfmt_misc and
can't start static-runtime AppImages such as SolidCraft's: it fails with
"execv error: No such file or directory" after a FUSE message. 3.0 starts
it. One paragraph in the Linux AppImage section says so.

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

* Docs: AppImageLauncher 3.0 is so far only published as betas (#4640)

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
2026-10-26 06:49:52 +00:00
raykholo 2445c0f13d Flatpak repository: always publish its HTTPS URL (#4864) (#4875)
GitHub Pages reports the base URL as http:// for the custom domain, so the
.flatpakrepo file, the remote-add line on the repository page and the mirror
of the previous repository all used plain HTTP. Force https:// once where the
base URL is read.
2026-10-26 06:49:50 +00:00
Homero Thompson 3d0e2a8b3c docs: the gate reads the working tree, and how to show a picture without committing it (#4902)
* docs: two notes for agents and contributors (the gate's working tree, and linking a picture)

* docs: how to show a picture without attaching it

The reviewer asked for the pictures to be attached to the PR (or in a gist),
not hosted on a branch of a fork, so the note no longer teaches the branch
trick.

* docs: say how to attach a picture, and what a gist is for

The review of this pull request pointed at two things in the note on
evidence:

- Attaching works in the browser for anyone who can comment: drag the
  file into the comment. The limit is the command-line agent, whose
  upload needs write access to this repository. Say that, instead of
  claiming that a contributor without write access cannot attach a file.
- A gist suits a log or another text file; it does not render a picture
  inline. Say what it is for, so nobody hits that dead end.

The note in AGENTS.md about the gate reading the working tree is
unchanged.
2026-10-26 06:49:48 +00:00
raykholo 2a01cca00e User guide: local models with the Assistant (#4851) (#4964)
* claim: #4851

* User guide: local models with the Assistant (#4851)

A page on choosing and setting up a local model (Ollama, LM Studio, or any
OpenAI-compatible server) for the Assistant: tool support, VRAM, permissions,
the measured results from the standard test in #4851, and troubleshooting
for the common failures.

* Guide: local models table with the full set of measured results
2026-10-26 06:49:46 +00:00
raykholo d7cc5621fa Troubleshooting: what to do when a design won't open (#5114)
Point to the `<file>.bak` copy every save keeps, the design.solidcraft in crash
reports, the recovery folder for unsaved designs, and how to attach the file
to an issue.
2026-10-26 06:49:44 +00:00
raykholo be6c7fc17c Bug template: ask to reproduce on a current build first (#5150) (#5154)
* claim: #5150

* Bug template: ask to reproduce on a current build first (#5150)
2026-10-26 06:49:42 +00:00
raykholo f006eefd6e docs: point AGENTS.md's starting steps at public files (#6049)
"Start every session here" sent readers to plan/STATUS.md,
plan/execution-plan.md, plan/architecture.md and
plan/adr/0001-geometry-kernel.md. plan/ is gitignored, so nobody who
clones the repository has those files.

Point the steps at what ships instead: ROADMAP.md, CONTRIBUTING.md's
"Where help is wanted most", "How the code is laid out" and "Kernel
work is paused" sections, docs/architecture.md, the help wanted, good
first issue and needs-triage labels, and the licence rules with
deny.toml and cargo xtask licences --check.

Signed-off-by: Ray <raykholo@users.noreply.github.com>
2026-10-26 06:49:38 +00:00
bherbruck 15269f85a1 docs: how to bump the Homebrew tap on a release (#6276)
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 06:49:37 +00:00
bherbruck e53390ea1b Roadmap: a short user-facing ROADMAP.md, with the developer detail moved to docs/roadmap-detail.md (#6797)
* Roadmap: a short user-facing ROADMAP.md, developer detail moves to docs/roadmap-detail.md

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

* Roadmap: feature milestones with progress counts, daily releases through the in-app updater

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

* Roadmap: count issues as pieces, show work built on the drawings and render branches

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 06:49:35 +00:00
raykholo c8314687fc Construction points and axes: Browser rows, and pickable in the view and dialogs (#4312) (#4320) 2026-10-26 04:50:46 +00:00
raykholo 878339cac8 Sketch patterns: count, spacing, and angle follow parameters (#5231) (#5332) 2026-10-26 04:31:55 +00:00
bherbruck 348bbc1dfa fix(joints): edge and corner picks follow the part when it changes (#6942)
The Joint dialog kept a round edge (a hole's rim), a straight edge and a
corner as plain points where they were picked. When the part got thicker,
a hole moved or the part got longer, the origin stayed where it was and
the jointed part overlapped the other one. An edit that left the picked
spot where it was (lengthening an arm away from the jointed hole) looked
fine, which hid it. Faces and hole walls were already found by name.

The dialog now sends a round edge as its circle's centre with its z, and a
straight edge or corner as an edge pick with its z. The engine keeps them
by name (the round end by its wall and the face across its rim, the edge
and corner by their edge and face names), with the frame the pick showed;
where it can't (no planar face beside the edge, no names), it keeps the
point as before. Edge origins get an optional axis (along the edge, or a
fixed direction) so a hinge on an edge keeps turning about it.

Tests: every joint type and as-built, for each way the dialog picks an
origin, with the arm made thicker, its hole moved and the arm lengthened,
inside one design and through Update Links.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 04:05:26 +00:00
dudedagger42 262f5c61c0 Move, Align and previews on placed components (#4455)
* Move and Align on placed components: picks and vectors where they are shown

A body in a component placed away from its own frame (an imported part
set upright) moved the wrong way: Move's vector, axis and pivot were
taken in the root's frame, so moving the upright plate up moved it along
its own frame's z. They now turn with the frame of the bodies moved.

Align mapped each pick into the frame of the body under it, so aligning
two separately placed parts mixed two frames (the plate landed at right
angles), and its snap points were never mapped at all. Align now finds
its picks, face normals and snaps where the view shows them and takes
both ends into the moved bodies' frame.

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

* Preview: a feature's tool is drawn where its component is shown

Extrude, revolve and the primitives preview their tool on its own. That
preview was built in the model's frame, so on a component placed away
from it (an imported part set upright, edited with its component
active) the tool showed at right angles to the picked face, though the
feature itself came out right.

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

* Follow main's lazy body frames and first-part grounding in the placed-component work

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-26 03:28:14 +00:00
Luke Jones ece2a8693a Cache assembly browser data between unchanged frames (#662) (#1610)
* Cache assembly browser data between unchanged frames

Extend #1762's cached component membership, placements and viewport hover
feedback with cached occurrence labels, folder entries, row keys and
tooltips. Skip offscreen row work and reuse browser hover buffers while
reading selection, visibility and locking live. Cache #2681's standard-part
configuration labels with the document snapshot instead of formatting
them on every redraw. Preserve #3009's live selection and hover marks
with highlight keys owned once by cached entries, and retain #2431's
feature rows in the reusable drawing order.

Related to:
- tangency-labs/solidcraft#662
- tangency-labs/solidcraft#1762
- tangency-labs/solidcraft#2681
- tangency-labs/solidcraft#3009
- tangency-labs/solidcraft#2431

Co-authored-by: Codex <noreply@openai.com>

* Browser cache: lock state moved to the session visibility

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

* Browser cache: reveal a placement's body by its row, and drop a needless borrow

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

---------

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 02:51:32 +00:00
dudedagger42 0f77199449 Pipe: an Operation row, so a pipe can join, cut or intersect (#2820) (#5169)
* Pipe: an Operation row, so a pipe can join, cut or intersect (#2820)

The engine's solid.pipe takes an operation, but the Pipe window had no way to
choose one, so a pipe always made a new body. The window now has the same
Operation row as Loft and the primitives, and editing a pipe keeps its
operation.

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

* Fit the Pipe operation row to main: sketch-qualified pick, length_input pattern

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-26 02:32:31 +00:00
github-actions[bot] e02ef3246f Regenerate generated files and fold pending files [skip ci] 2026-10-26 03:14:51 +00:00
raykholo ffbeb42b5c Assistant: say so when a turn ends without using SolidCraft's tools (#4846) (#5188)
* claim: #4846

* Assistant: say so when a turn ends without using SolidCraft's tools

When tools were offered and a completed turn made no tool call and no
change, the chat panel shows a short notice pointing to tool-capable
models: always for an empty reply, and for a text-only reply to an
explicit edit request. Plain answers to questions stay quiet. The
control channel exposes it as turnWarning.

* Fix the Anthropic loop test for the tools event; add ja and zh for the no-tool-call notice

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

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 02:19:57 +00:00
Matthew Knutson 816bcf10de Every new command comes with input and output schemas (#2829) (#4035)
* Every new command comes with input and output schemas

A test fails for a command without a contract or an output type, unless it is a planned!
placeholder or listed in schema/unconverted.txt, which holds the 400 commands that predate
the rule and only shrinks: the test also fails when a listed command has its schemas, when
the list names a command that doesn't exist, or when it isn't sorted.

`checked!` now takes only the command's id and checks the contract that id publishes
(`run_checked_as`), so the contract enforced is always the one described. Another test runs
every command that has a contract in strict with an unknown key, so a contract is never
published without `checked!`, under the command's own id, enforcing it.

A new book page, Command schemas, explains how to write a contract and a reply type, the
tests to add, the shared field types and how to add one, with a checklist; the failure
messages point to it. "Adding a command", CONTRIBUTING.md and AGENTS.md now include the
schemas, and the MCP tool description and docs/mcp.md no longer list the converted commands.

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

* List the commands added on main since: view.layout, view.history_layout, view.normal_to

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

* Command schemas guide: what participants and targets do, checking the evaluation, warning-free replies

- `participants` changes the result for some features and nothing for others; where it and
  another key override each other, or where it has no effect, say so in that key's
  description or in the summary.
- The example primitive's `targets` default to every body in the feature's component, not the
  bodies it meets; they're ignored when `participants` lists bodies.
- Check what each value does where the feature is evaluated, not only how the handler reads it.
- A reply test runs every form in Warn and checks for no `param_warnings` (Strict for a reply
  that isn't an object).

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

* List the 43 commands added on main since: frame, component, render and others

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

* List the 11 commands added on main since: browser folding, standard views, parameter sweep, path flange

browser.collapse_all, browser.expand_all, inspect.parameter_sweep, sheet.path_flange and the
seven view.* standard views (back, bottom, front, isometric, left, right, top) landed on main
without schemas; the gate flags them, as intended. 457 commands listed.

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

* Schema rule warns instead of failing for now

A command without input and output schemas prints a warning listing it
(test output and a CI annotation). SCHEMA_RULE_STRICT in coverage_tests.rs
(or SOLIDCRAFT_SCHEMA_RULE_STRICT=1) makes it fail again. Docs say "should,
and will become required".

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-26 01:43:02 +00:00
Nathan Dane 28689e784e fix(ui): retain macOS application bar controls alongside native menus (#6190) 2026-10-25 21:19:57 -04:00
github-actions[bot] d396be78a2 Regenerate generated files and fold pending files [skip ci] 2026-10-25 23:35:40 +00:00
github-actions[bot] cbac207852 Regenerate generated files and fold pending files [skip ci] 2026-10-25 23:29:18 +00:00
taylorhayis ff2ca2d462 Keep focus when correcting an invalid parameter name (#3965) (#4000)
* claim: #3965

* Parameters: a click can correct an invalid name (#3965)

Refusing the new row was putting focus back on the field being left, so the correction was typed into the expression.

* Parameters: the invalid-name test types its expression draft (#3965)

A new row starts with a blank expression, so the regression test types 10 mm and checks that correcting the name leaves that draft in place.
2026-10-25 22:42:30 +00:00
bherbruck 7770f8982d Release 0.23.0 2026-10-25 18:18:39 -04:00
bherbruck 8e61eb7d6f Release 0.23.0: version bump, design format 37 (#6868)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 18:18:36 -04:00
CamWight ab80a410a1 Viewport theme: a light 3D view in a dark window, and the reverse (#4146) (#4147)
* claim: #4146

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

* Viewport theme: a light 3D view in a dark window, and the reverse (#4146)

Preferences › Interface › Theme gets a Viewport row under the theme tiles:
Follow theme (the default, as before), Light or Dark. Light or Dark draws
the 3D view in the built-in Light or Dark theme's colours whatever the
window uses, so a dark window can show the model on a light view and the
other way round; it works with every theme, theme files included.

The view takes the background and grid, bodies, selection and hover, the
origin, previews, sketches with their dimensions and glyphs, the navigation
cube and triads, box selection and the analysis colours. Panels, dialogs,
menus and the chips over the view keep the window's. Text and warnings drawn
straight onto the view (the sketch status line, the loose-end and midpoint
markers, the section's cut outlines, the 3D sketch tool and the sketch tools'
previews) take the view's side, so they read on its background.

theme::ViewportTheme is the setting (saved as `viewport_theme`: theme, light
or dark; an unknown saved value reads as Follow theme, as ColorVision's
does). Tokens::get() takes the view's colours from the other side only when
the view's dark or light differs from the window's; Tokens::view() is the
view's side for colours drawn onto it. The ground shadow follows the view
(the scene and highlight caches already key on the colours themselves),
ui.render draws in the view's colours, and ui.prefs takes `viewport_theme`
and reports `viewport_dark`. An older release ignores the setting and draws
everything in the theme.

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

* Viewport theme: follow the single set of axis colours

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-25 22:17:04 +00:00
leValdemar19 b94692497e Sketch Dimension: arc length from an arc and its end point (#754) (#4245)
* claim: #754

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

* Sketch Dimension: arc length from an arc and its end point (#754)

The arc-length dimension existed (sketch.dimension type arc_length) but
the Dimension tool could only give an arc its radius. An arc and one of
its own end points, in either order, now dimension the arc's length:
click the arc, then its end. The pair was refused before ("cannot
dimension that combination"), so no existing result changes. A point
where the arc's end is (a coincident line end) counts as that end.

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-25 22:17:01 +00:00
Armin 7bcc1ee0eb Drawing: shared number formatter (#5290) (#5315)
* claim: #5290

* doc: add configurable decimal number formatter (#5290)

---------

Co-authored-by: Armin <192400139+armin0411@users.noreply.github.com>
2026-10-25 22:11:39 +00:00
raykholo 36a60a70d9 Graphics: 1x anti-aliasing where the multisampled view draws nothing (#5819, #5870) (#5832) 2026-10-25 22:10:04 +00:00
dudedagger42 5d17fa970b Sketch: hovering a circle's edge wakes its centre (#5043)
* Sketch: hovering a circle's edge wakes its centre

A circle's or arc's centre was hard to take with Line or Dimension: it is a small
point in the middle of empty space, taken only within 8 points of the pointer, and
nothing showed where it was. Hovering a circle or arc of the sketch being edited
now wakes its centre: drawn (a cross in a ring) and taken from 16 points away, so
the pointer can go from the edge across the inside to it. Hovering another curve
or point puts it back to sleep, as does leaving the sketch.

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

* A woken centre's extra reach yields to a curve under the pointer

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-25 21:57:23 +00:00
Frank b5bab3f663 Browser: surface bodies get the surface icon in the Bodies folder (#4165) (#4168)
* Browser: surface bodies get the surface icon in the Bodies folder (#4165)

Every row in the Bodies folder had the solid body icon, so a surface (an
open shell, such as a Surface Revolve of an open curve) looked like a
solid. A body whose shell does not close now shows the existing surface
icon; solids keep the body icon.

Body::is_surface walks every shell's edges, so ModelBody caches it once per
body (shared by clones, like outline_bounds) instead of the browser
checking it every frame.

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

* Add the surface icon to the requested-icons manifest

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-25 21:51:02 +00:00
Steven Huff ccea34e73c sketch: a tangent whose contact is tied by Coincident, PointOnCurve or Midpoint is solved at the contact (#2584)
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 21:37:51 +00:00
Dennis Klappe c7f86c7309 Look At in the viewport context menu for a planar face or plane (#4105) (#4497)
* Look At in the viewport context menu for a planar face or plane

Fixes #4105

* Look At tests: turn the camera without the animation

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

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 21:37:37 +00:00
raykholo 4355d117ef Assistant: warn when an Ollama model has no tool support (#4846) (#4916)
* claim: #4846

* Warn when Ollama models lack tool support (#4846)

Check Ollama model capabilities in the background and cache results per
endpoint and model. Show an advisory warning for models without tools,
expose it through ui.agent_chat, and document the behavior.

Cover capability parsing, warning selection, background probing, caching,
and selection changes with synthetic regression tests.

* Keep the Ollama capability probe out of the web build

The panel never calls it there, so it would only be dead code.

* Wrap the model warning instead of cutting it off

In a narrow panel the end of the sentence, the part that says what to do, was truncated.

* Fix AgentConfig default after rebase onto main

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

* Move the Ollama no-tools warning into a pending locale file with ja and zh

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

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 21:37:30 +00:00
dudedagger42 39951a22ff Loft: a sketch point picked in the window is an apex (#4830) (#5207)
solid.loft already closes to a point (a section {sketch, point}), but the Loft
window's Profiles input took profiles only, so a pyramid or cone tip couldn't
be made from the UI. The input now also takes a finished sketch's point, sent
as an apex section in pick order; profiles of one sketch still make one
section.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 21:17:14 +00:00
ChrisNo a3890b3bd5 Open a linked part from its assembly Browser row (#2348) (#2349)
* claim: #2348

* Open linked designs from assembly browser

* claim: #2348

* Test linked design action with multiple components selected

* Fix linked design lookup in browser storage and translate its menu action

* Add Turn Up Axis to complete UI catalogs

* Keep browser DXF download after linked-design checks
2026-10-25 21:15:48 +00:00
Vadim Kalinsky 59cad5f0bb Colored components (feature request #2507) (#2877)
* feat: add colored components inspection toggle

* feat: persist component colors and add cycling controls

* feat: assign component colors in creation order

* fix: update component color menu integration and translations

* fix: proper description for pending format change.

* fix: deleted duplicated format change description from docs/pending.

* Compare the component colour counter in the features-only check

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

---------

Co-authored-by: Vadim Kalinsky <vadim@kalinsky.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 21:11:03 +00:00
Kai Evans 5fbe058051 Sketch: a click picks the sketch's own curve over a projected one under it (#5257) (#5260)
* Sketch: a click picks the sketch's own curve over a projected one under it

* Select Other tests: follow origin visibility rename

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

* Own-curve pick test: click away from the lines' midpoints, which are picks of their own now

* Use the select-other test helpers main already has

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

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 21:10:10 +00:00
DuBearte 0752d8d79c Extrude: a profile drawn twice is cut once, not as two coincident tools (#5543) (#5789)
* claim: #5543

* Extrude: a profile drawn twice is cut once, not as two coincident tools (#5543)

A circle drawn twice at one place is two profiles of one region. Extruded
together they were two coincident tools: a cut through a 121-hole grid with
four such circles retried the boolean with the tool nudged (four times) and
took 25 s instead of 0.5 s.

merge_regions, which every extrude, revolve and surface feature runs on the
regions it builds from, now leaves out a region whose boundary repeats an
earlier one (within 1e-7 of its size; corners and segment middles on the
other loop both ways, holes alike). The selection itself keeps its
multiplicities, as profile_refs' tests require.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 21:10:02 +00:00
raykholo 0344a41dc4 Open and insert a DXF outside a sketch (#2052) (#4635) 2026-10-25 21:03:31 +00:00
DuBearte dba8108369 Timeline: a reorder evaluates the design once, from the cache it already has (#6186) (#6187)
* claim: #6186

* Timeline: a reorder evaluates the design once, from the cache it already has (#6186)

timeline.reorder checked the move on a copy of the model, then dropped it,
so setting the reordered design evaluated the moved part of the timeline
again; reorder_many (and moving features into a group) checked on an empty
model, recomputing the whole design even for a move near the end. The
check now starts from the session's own model, and the session keeps the
model it was checked on.

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

* Marker is Copy: drop the clone

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-25 20:55:52 +00:00
bherbruck 57d787f5ba Docs site: canonical links, robots.txt and sitemap.xml (#6888)
Search Console lists docs.solidcraft.io/, /introduction.html (the same page) and /guide/mcp.html as duplicates without a user-selected canonical. The Pages build now adds a canonical link to every book page (index.html and introduction.html both name /), skips the redirect stubs that already have one, marks 404 and the toc frame noindex, and writes a robots.txt pointing at a generated sitemap.xml.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 18:35:56 -04:00
James Barnette 2eaa15ea04 Context menu: a right-click on a face of a selected body keeps the selection (#5849) (#5915)
* claim: #5849

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

* Context menu: a right-click on a face of a selected body keeps the selection (#5849)

A right-click selects what is under the cursor first, so the menu is about it. The check
for "already selected" compared the pick with each selected item by identity, so a face
of a selected body never matched: right-clicking a face of a selected body replaced the
whole selection with that one face, and the face menu opened instead of the body menu.

A face, edge or vertex of a selected body now counts as part of the selection, whether the
body is selected on its own or through an occurrence. The right-click then leaves the
selection alone, as it already did for a face in a multi-selection of faces (#3733).

selection::same_item is unchanged: dialogs and Select Other compare picks with it, and
there a face of a selected body is a different pick.

Fixes #5849.

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

* Pass the held keys in the selected-body context menu test

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-25 20:41:52 +00:00
dudedagger42 f512d4a004 Move: placements of one part moved together each go the way asked (#4768) (#4849)
* Move: placements of one part moved together each go the way asked (#4768)

A part placed more than once (an imported assembly's corners, screws or
fan blades) is one body in its component. Moving several of its
placements together moved that body once per placement listed, each
placement turning the move its own way, so the copies flew apart.

When solid.move names one part through two or more placements, it now
moves those placements in the world, as occurrence.move does with space
world: a placement held by a rigid group (an imported assembly) moves with
the outermost placement holding it, one inside another being moved isn't
moved again, and root-component bodies listed are moved as bodies, all in
one undo step. Other moves are unchanged, and so is a move with copy.

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

* Free the grounded first part before the placement-move tests move it

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-25 20:28:06 +00:00
github-actions[bot] 62d55c3b29 Regenerate generated files and fold pending files [skip ci] 2026-10-25 21:31:35 +00:00
NOWN-DEV 826244bb28 Text parameters: a sketch text's words built from them follow every change (#727) (#6364)
* Test: a text's words follow its text parameters, and so does its cut

A sketch text whose words are an expression of text parameters (`Label + '-L'`): a parameter edit
and a configuration row that sets the parameter relabel the text, the text's cut follows, and the
expression is saved with the design.

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

* Text parameters: a sketch text's words built from them follow every change

A design's parameters are numbers; a text's words (a part label, a size code) could only be typed.
Text parameters hold words: `parameters.add` or `parameters.change` with `unit: "text"` (the
expression is the text, quotes optional), and `sketch.text` and `sketch.edit_text` take
`text_expression` (quoted texts and text parameters joined by `+`, `Label + '-L'`) instead of
`text`. The text is laid out anew from its expression on every evaluation, so a parameter edit,
a configuration row or a script relabels it and the features built on it (a cut, an emboss)
follow in the same rebuild. The design file keeps the text parameters and each text's expression
(one pending format change); a configuration table's parameter column can be a text parameter;
the Parameters dialog lists them under the unit `text`.

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

* Text parameters: the joints' same-but-features check compares the text parameters

The joints are solved once per change unless the design differs only in its features
(same_but_features, #6372), which names every Document field. The text parameters are a
field of their own and change what a text's words evaluate to, so two designs that differ
in them are not the same but for their features.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 20:24:23 +00:00
NOWN-DEV 111dbc6855 Render: the radiance bound check uses a range, as clippy 1.97 asks (#6650)
`cargo clippy --workspace --all-targets -- -D warnings` fails on Rust 1.97: the
reflection radiance bound in crates/render/src/raytrace/image.rs (#5494) trips
`clippy::manual_range_contains` (`v < 0.0 || v > 1e12`).

It now reads `!(0.0..=1e12).contains(&v)`. The two differ only for NaN, which the
`is_finite` check in front of it already refuses, so nothing else changes.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 20:23:04 +00:00
dudedagger42 ab7602fd0e Project: don't lay a second copy over geometry the sketch already shows (#4929)
* Project: don't lay a second copy over geometry the sketch already shows

A sketch on a face already holds the face's border, linked. Projecting that face
again, or one of the border's edges, added a second set of curves and points over
the first: on a rounded corner that shows as extra points and lines. Project now
skips a reference whose curves are all linked into the sketch already (lines,
circles, arcs), reports it as `already_projected`, and refuses a pick of nothing
new so it adds no undo step. Points on their own (the origin, a vertex) still
project.

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

* Projecting the face a sketch already shows adds no second copy in the face scenario

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-25 20:13:46 +00:00
HiroKenEz b1d4293cf0 One set of X, Y and Z colours for the origin axes, view triad and Move gizmo (#976) (#4430)
* claim: #976

* One set of X, Y and Z colours for every axis (#976)

The origin axes, the navigation cube's triad, 3D sketch triads and joint
origins drew `axis_x/y/z` (255,0,0 / 0,255,0 / 2,2,248), the view triad and
the headless picture drew `triad` (220,60,60 / 60,170,60 / 60,100,230) and
the Move gizmo drew `gizmo` (225,70,60 / 80,190,80 / 70,130,240). The
`triad` and `gizmo` tokens are gone: every axis reads `axis_x/y/z`, now the
view triad's values (the engine's `view::colors::AXIS_*`, so headless
pictures are unchanged, and the set the red-green table was measured for).
Older theme files' `triad` and `gizmo` lists still read and set the axes.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:59:36 +00:00
Homero Thompson 99d5b43024 Doc: the names of split faces no longer grow with every split (#5860) (#6045)
* claim: #5860

* Doc: stop the names of split faces from growing with every split

* Joints: the long-piece-name test takes the longest name a piece now gets

Split-face names are bounded at 512 bytes since #5860, so the test cannot
build one over 512; it takes the longest the history gives.

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

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:58:56 +00:00
raykholo f937905cf3 A sketch point on the origin can be dragged (#4719) (#4837) 2026-10-25 19:37:01 +00:00
raykholo 46f7d4d8dc Preferences: the web app's Updates section says how it updates (#5496) (#5500) 2026-10-25 19:29:04 +00:00
bherbruck 8071096a99 Docs: refresh every UI screenshot, GIF and video from the current UI (#6763)
* Docs: refresh every UI screenshot, GIF and video from the current UI

Re-record the README demo, the rover build, the sample plate and the class-A surface pictures
with 0.22.1 in the default dark theme. The demo script is fixed for the current sketch dimension
boxes and MCP file limits, and the rover replay and class-A pictures now have scripts.

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

* Docs: cut the idle time out of the demo and rover recordings

The README demo plays at 6x and the rover replay at 15x with a constant-rate camera orbit, with the stretches where nothing on screen changes cut out by the new trim_idle.py.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:21:01 +00:00
raykholo d17b70241f Command dialogs keep the value field focused after a viewport click (#4643) (#4652) 2026-10-25 19:13:51 +00:00
raykholo fbe778a1e8 Rigid Group counts picked instances and explains bodies in one component (#4790) (#4792) 2026-10-25 19:13:48 +00:00
momysnow 0252f0bf30 Render preview: test that it never paints the last Design frame (#5119) (#5120)
#5187 fixed the frozen Render preview: product frames now set ss_ready
themselves instead of keeping the flag from the last Design frame, so
paint() no longer blits that stale offscreen picture. Nothing tested it.

The new test draws a see-through Design frame (offscreen, for
order-independent transparency) and then an opaque product frame on the
same renderer, and checks the middle pixel shows the product. With the
product branch's ss_ready update removed it fails with the stale red
Design colour, [255, 129, 129, 255].

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:12:50 +00:00
Igor Bronovskyi 633ce20563 i18n(uk): translate 188 pending messages (#4051)
* i18n(uk): translate 188 pending messages

Bring the Ukrainian catalog up to date with en.json after rebasing onto the latest main. Covers the crash-report form, AI preferences, preferences pages, solid tool help, free orbit and history controls. Keys stay sorted via cargo xtask lists.

* i18n(uk): translate 114 pending messages

Rebase onto the latest main and bring the Ukrainian catalog up to date with
en.json. The new messages cover the mouse-gesture editor and navigation
preset help, the frames and sheet-metal dialogs, fillet control radii, the
timeline folder actions, the motion keyframe controls, library appearance and
library file actions, and the measure dialog.

Also correct "Add Key" to "Додати ключовий кадр": the only user of that
message is the motion keyframe button, and the catalog already translates
"Keys" as keyframes.

Keys stay sorted; cargo xtask lists --check and the i18n catalog tests pass.

* Sort the Ukrainian catalogue

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

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:10:13 +00:00
dudedagger42 0c0c4db612 Home: a theme's File view follows its own colours where it sets none (#4868) (#4924)
* Home: a theme's File view follows its own colours where it sets none (#4868)

and no built-in theme sets them, so every theme kept Dark's or Light's blue rail and grey cards
under its own app bar and headings. A theme that leaves them unset now gets them from its own
palette: page and cards from its panel and dialog, the rail from its accent deepened as Dark's
and Light's are, the primary action from its soft accent, and each ink the one of its own colours
that reads best there. Dark and Light themselves are unchanged.

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

* Leave a theme that sets no colours as its base palette

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-25 19:05:48 +00:00
shop-3d 16bb97f065 I18n danish cad help 20261021 (#4223)
* i18n(da): complete upstream catalog and align CAD help terminology

Add 371 missing Danish translations, refresh changed source messages,
and independently rewrite six help descriptions from SolidCraft sources.
Preserve all 2397 keys, canonical ordering and placeholders.

* Sort the Danish catalogue

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

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 18:59:18 +00:00
Taha Zarif 3e6c039985 Fix/recovery metadata bounds (#5578)
* test: cover malformed and unbounded recovery metadata

Add regression tests for oversized JSON, mismatched entry IDs, and
non-regular recovery metadata. These expose failures in the baseline
recovery reader before the implementation fix.

Signed-off-by: Taha Zarif <tahazarif380@gmail.com>

* fix: bound recovery metadata reads and validate entry identity

Reject oversized and non-regular recovery metadata before reading it,
and bound the read if a file grows after inspection. Require each JSON
entry ID to match its filename for orphan listing and recovery loading,
preventing unrelated save paths from being attached to a recovered design.

Signed-off-by: Taha Zarif <tahazarif380@gmail.com>

* Format recovery.rs

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

---------

Signed-off-by: Taha Zarif <tahazarif380@gmail.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 18:49:55 +00:00
Carlos Keese 7734ba2300 Offset Face: split a periodic face off its seam first, as booleans already do (#6226) (#6227)
* claim: #6226

* Offset Face: split a face off its seam and try again, as booleans do (#6226)

A hole's wall arrives from a STEP file as one whole turn bounded by one wire that
runs along its seam twice, where SolidCraft builds the same wall as two halves.
Offset Face heals the body before it moves anything, and healing such a face
fails inside truck with `This wire is not simple`, so the panic guard turned the
whole operation into a kernel internal error. Since importing is how a part with
a hole usually arrives, Offset Face did not work on imported parts with holes at
all.

`seam_faces` already answers this for booleans: split every such face in two
along a plane through its axis, run the operation, and compose the history back
over the body as it was given. This gives the offset path the same retry.

- `seam_faces::one_body` is `boolean`'s answer for an operation that takes one
  body, with `needed_one` beside `needed`.
- `offset_faces` now does its healing, its face picking and its offset inside one
  closure, so the retry runs the whole of it on the split body; the first attempt
  is guarded, so the failure is an error the retry can act on rather than an
  unwind. The faces are picked per body, because the pieces are numbered
  differently once a face is split — the points do not move, so they pick the
  same surface either way.

The regression test is in `seam_faces_tests.rs`, beside the boolean ones: a
20 x 20 x 10 block with a 6 mm hole, read back from STEP, whose top moves 2 mm
and lands on the same volume as moving the built body's top, with its history
intact.

Checked against `main` before and after: the three kernel tests that fail here
(`blend::…seam_twice`, `step_in::…parallel_body_preparation`,
`tests::a_box_cut_round_a_small_hole…`) fail the same way without this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: CarlosKeese <dudukeese@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-25 18:49:44 +00:00
Vetle Marentius Nilsen 0e916ee598 Add Norwegian Bokmål interface translation (#4544) (#4556)
* claim: #4544

* Add Norwegian Bokmål translation

* Refresh Norwegian Bokmål translations against current main

* Drop Norwegian messages whose ids are no longer in the English catalogue

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

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 18:49:00 +00:00
raykholo 87c9a37663 Cut and Intersect say which component holds the body they missed (#4639) (#4641) 2026-10-25 18:42:50 +00:00
dudedagger42 10124c8ccf Redefine Sketch Plane names the plane a sketch is on now (#5103) (#5545)
* claim: #5103

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

* Redefine Sketch Plane names the plane a sketch is on now (#5103)

For a sketch with no body-face attachment the panel only said so. It now shows
a Current plane row: the origin plane, a construction plane's name, or the
plane it is offset from, turned from or flipped from. English, Japanese and
Chinese messages added.

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

* Drop needless to_string calls in the plane name format

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-25 18:42:24 +00:00
Kai Evans 0870d6474a Themes: Okibi (dark) and Akari (light), from the Okibi design system (#5730) (#5731)
* Themes: Okibi (dark) and Akari (light), from the Okibi design system

Two built-in themes mapped from the Okibi design system v1.8
(https://kaievns.github.io/okibi-akiri-design-system-spec/): cool charcoal
ash with a single ember accent, and its warm-paper light twin. Every one of
the 156 colours is set, so nothing falls back to the Dark or Light palette.

* Give the Okibi and Akari themes an icon palette

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

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 18:42:19 +00:00
JK3D e9b9c8f697 Joints: apply a joint solve through indexes instead of searches (#6392) (#6393)
* claim: #6392

* perf: apply a joint solve with indexed joints and occurrences

Every change to a design re-solves its joints and applies the solution. Applying it searched
the joints for each value and, for each occurrence the solve places, searched the occurrences
for it and walked its parents' first occurrences by searching again.

Joints and occurrences are now indexed once, keeping the first of each id as the searches
found it, and the parents' transform is read through the index as the loop leaves it
(component_transform shares the walk, transform_through).

Synthetic design, headless (2000 components at the root with one component inside each, 4000
joints, a solution placing every occurrence): apply 14.1 -> 2.2 ms (median of 12).

The new test applies a solution that moves a component and the components inside it, and
checks each lands where the solution put it, read through its parent as the loop has just
moved it.
2026-10-25 18:42:10 +00:00
JK3D cbcf1fe716 component.list: group bodies, features and occurrences once (#6394) (#6395)
* claim: #6394

* perf: group bodies, features and occurrences once in component.list

component.list searched every body, every feature (three times) and every occurrence (twice)
for each component, so its cost grew with components times contents. Bodies, features,
occurrences and children are now grouped by component once, keeping their order, and each
component reads its own groups. The output is unchanged.

The new test lists nested components and checks each gets only its own bodies, features,
sketches, occurrences and children, in timeline order.
2026-10-25 18:42:06 +00:00
raykholo e21760e46d Sketch Move with a rotation swaps or drops horizontal and vertical constraints (#4645) (#4651) 2026-10-25 18:35:15 +00:00
raykholo df01e59716 Sketch: an x distance beside a y distance on the same points isn't refused (#4352) (#4387) 2026-10-25 18:27:40 +00:00
Jesper We 3045d0787e Dialog curve picks keep their sketch (#4057) (#4081)
* Dialog curve picks keep their sketch (#3726)

* Format

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

* Format

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

* Adapt context-menu test to Sel::SketchCurve's sketch field

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

* Path Flange picks keep their sketch too

Path Flange (#4096) landed after this PR and still looked its path up in the newest sketch with those ids.

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 18:20:03 +00:00
raykholo d876f475a6 Sketch: a distance dimension of 0 keeps the sketch fully constrained (#4368) (#4397) 2026-10-25 18:19:55 +00:00
neosandeep24 ef1a400bb3 Home: recent card no longer spins forever when its picture can't be made (#4655) (#4721)
* Home: stop a recent card spinning forever when its picture can't be made (#4655)

A recent design whose thumbnail job returned no picture (empty or unreadable design) kept the loading placeholder, which draws a spinner, with no retry. Record such keys as a terminal 'failed' state and draw a box placeholder instead; opening or saving the design clears it and tries again. Sample previews are untouched: a failed sample build still keeps its shipped picture.

* Format the gallery sample row

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

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 18:19:39 +00:00
Joseph 53c84eb3a7 Toolbar: a drop-down row's tooltip names its rebound key (#6290)
The row itself showed the key the command runs from (#1810), but its help
tooltip still named the default one.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 18:10:09 +00:00
Joseph 9a7a75788e Sketch keys: a variant with its own key leaves its family's key alone (#6289)
With G bound to the centre rectangle, G, Escape, R started the centre
rectangle again, because R's family remembered the variant used last
(#4308). A remembered variant that has a key of its own no longer takes
over the family's key; one without a key still comes back on it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 18:09:58 +00:00
ABDULLAH 92dc6bc987 Assistant: pick the provider first, then its model (#5564) (#5565)
When an agent's models come from several providers (Hermes and OpenCode
name each one "Provider · model" or say "Provider: ..." in its
description), the chat header shows a provider drop-down before the model
drop-down, and the model drop-down lists only that provider's models
without the provider in front. It opens on the provider of the model in
use; picking a provider alone changes nothing. ui.agent_chat takes
provider and reports providers and provider.
2026-10-25 18:09:32 +00:00
Luis Amaral b5af4a80fa i18n(pt-BR): translate 319 missing messages (#5776)
* i18n(pt-BR): translate 319 missing messages

Catch up with en.json: the new Preferences pages, assistant settings,
navigation preset help, the bug report form, history names, feature
folders, component links and copies, joint keys and the reference
geometry setting. Messages added to en.json after this branch started
stay in English for now.

* i18n(pt-BR): translate Delete as Apagar, not Excluir

"Excluir" (Latin excludere, to shut out) means to leave out or exclude;
"apagar" is the Portuguese for deleting, wiping something out. Use
apagar for every Delete already in the catalog (22 messages: commands,
the delete confirmation, rules, rows, sets, keys, crash reports), with
its forms: apaga, apagados, apagado(s), apagá-lo. The Delete key's name
stays as it is printed on the keyboard.

* Revert "i18n(pt-BR): translate Delete as Apagar, not Excluir"

This reverts commit 3bc73c5930.

Keep the catalog's existing word for Delete until the term is agreed
for the whole catalog in its own issue; the change can follow as a
separate PR.

* i18n(pt-BR): use Excluir in the five new Delete messages

Apply the review's suggestions: the new messages keep the catalog's
existing word for Delete.
2026-10-25 18:08:58 +00:00
raykholo 309eda87a2 Agent skill for reproducible bug reports (#3983) (#3984)
* claim: #3983

* Agent skill for reproducible bug reports (#3983)

skills/solidcraft-reporting has an agent gather the version, steps and
exact error, reproduce the problem in a fresh design (a command script or
one batch with rollback off), shrink it while it still fails, check the
numbers with measure and a screenshot, keep private designs out, search
for duplicates, and write the report in one shape: Reproduced on, Steps
with a minimal script, Expected, Actual, Attachments.

Listed in skills/README.md and docs/mcp.md; the bug template's steps
field points agents to it. The skill-name check now covers its files.

* Reporting skill: GitHub takes a design only as a .zip
2026-10-25 17:56:18 +00:00
null 00000111 c3fd7aa048 i18n: add missing Brazilian Portuguese (pt-BR) strings (#4136)
Adds the 8 messages missing from assets/locales/pt-BR.json (six help.solid.* tool descriptions and the agent off-thread setting). Catalog stays sorted; cargo xtask lists passes.
2026-10-25 17:56:15 +00:00
raykholo 2e6e05bc9a Sketch pattern edit shows its preview while dragging (#4412) (#4426) 2026-10-25 17:56:08 +00:00
Nathan Dane 7cea146438 Show scroll arrows on the theme row when more themes are hidden (#5846)
* Show scroll arrows on the theme row when more themes are hidden

* Fade the theme row edges and use the chevron icon for its scroll arrows

* Remove the outline left on the theme row arrows after a click

* Trim the theme row scroll arrow tests

* Drop the earlier theme row arrows' test and labels
2026-10-25 17:55:24 +00:00
JK3D 492bba9242 Control channel: write each reply in one piece (#6389) (#6390)
* claim: #6389

* perf: write each control reply to the socket in one piece

The control server wrote each reply with writeln!(out, "{reply}") on the unbuffered TcpStream.
Formatting a serde_json::Value hands the writer every string, number and punctuation mark
separately, so each became its own write system call. A reply of a few megabytes (component.list
on a design with thousands of components) took about half a second to send.

Every reply is now serialized to one buffer and written at once. Writing one large Value to a
loopback TcpStream: 1.79 MB took 1,052 ms with writeln! and 4.9 ms with one write_all.

The test counts the write calls for a 2000-component reply (one) and reads it back.
2026-10-25 17:55:14 +00:00
rbousigues88 9be14db8c0 Timeline: show only the active component's features (#2880) (#3543)
In an assembly the bottom timeline listed every feature of every component,
unreadable with a few dozen parts. While a component is active (or one is
picked in the browser), it shows only that component's features and those of
the components inside it. A toggle ahead of the features names the component
and switches to the whole design and back. The marker and drops map from the
shown features to the design's positions.

Tests: the_timeline_shows_only_the_active_components_features,
a_slot_among_the_shown_features_is_a_design_position.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:44:38 +00:00
raykholo b8c496f37a Gruvbox Dark and Dark Soft: readable selected tab labels (#4323) (#4324)
* claim: #4323

* Gruvbox Dark and Dark Soft: readable selected tab labels (#4323)

The Model tab and the shared tabs (Assemble, Insert, Inspect, Manage,
View) draw their selected label in selected_ink on accent_soft. Both
Gruvbox dark variants inherited the default dark selected_ink #9cc7f5,
which reaches only 4.48:1 on #48544f (Gruvbox Dark) and 4.13:1 on

Set selected_ink to the Gruvbox foreground #ebdbb2 in both files:
5.76:1 and 5.31:1. Tabs with their own accent keep their own ink.

A test checks the selected label of every workspace and contextual tab
against its soft background in Dark, Light and every built-in theme
file.
2026-10-25 17:43:55 +00:00
Colin Faulkingham aeb406188f Keep parameter rows stable while changing fields (#4461) (#4473)
* claim: #4461

* fix: keep parameter drafts stable between fields (#4461)
2026-10-25 17:43:30 +00:00
dudedagger42 ceed604498 Mesh export: a quality choice, so small round features get enough sides (#4747) (#4773)
Every STL, OBJ and 3MF export meshed each body with a chord tolerance of
2e-4 of its size, with no way to ask for finer: a 3 mm hole in a 200 mm
part came out with about 14 sides. file.export and file.save_mesh take
`quality`: standard (as before, the default), fine (5e-5) or finest
(1e-5), and Save As Mesh has a Quality row, remembered like its format.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:42:51 +00:00
raykholo 44bd29acc3 Hole: refuse a position that isn't on the face (#5024) (#5029)
* claim: #5024

* Hole: refuse a position that isn't on a face (#5024)

`solid.hole` took any `position` and drilled from the nearest face, so a
point 1 mm past a box's side was accepted and cut a hole across the edge.

A position given as a point (numbers or length expressions) must now lie on
a face of a body, within the same tolerance the plastic features use for
their placement (the check is shared with them). Holes at sketch points are
unchanged.

* Hole: a position over the mouth of a bore or opening of a planar face is on that face (#5024)

A counterbore is centred on an existing bore, in the plane of the face round it.

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-25 17:42:15 +00:00
HiroKenEz aa8e8cf69c Update Links: show which linked design it reads, off the window thread (#5329) (#5512)
* claim: #5329

* Update Links: show which linked design it reads, off the window thread (#5329)

Update Links read and rebuilt every linked design on the window thread, so the
window froze, with nothing on screen, until it finished. From the toolbar it now
runs as a file operation: off the window thread, with the overlay naming the
linked design being read (cover (2 of 3)), its rebuild step and progress, and
Cancel. The assembly stays as it was until the update is in, and it lands as one
undo step. The command reports each design through doc::progress::item.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:41:27 +00:00
Thodoris Tsagkogeorgas 669e14b3bf Preferences: search the installed plugins (#5585)
The Plugins page filters its list by name, id, description, and version.
2026-10-25 17:41:16 +00:00
github-actions[bot] 1ee65cf70a Regenerate generated files and fold pending files [skip ci] 2026-10-25 19:05:45 +00:00
raykholo 129364be5e Add the Sheet Metal Perforate dialog (#4379) (#4404) 2026-10-25 17:33:54 +00:00
github-actions[bot] 3be4727ca6 Regenerate generated files and fold pending files [skip ci] 2026-10-25 18:14:01 +00:00
github-actions[bot] a53eb927d1 Regenerate generated files and fold pending files [skip ci] 2026-10-25 18:07:53 +00:00
raykholo c3dd40405d Measure: open surface bodies report no volume or mass (#4257) (#4268) 2026-10-25 17:21:39 +00:00
bherbruck 5012f33c85 Section Analysis: bodies behind the cut no longer colour the cap (#6328) (#6728)
The cap pass redraws every back face behind a marked pixel with depth test
Always to put the section plane's depth there, and it also wrote colour.
The last back face drawn painted the cut, so a body behind the cut (one
inside another's hollow, an assembly's inner parts) showed through it in
its own colour and outline, like an X-ray. The pass now writes depth only;
the marking passes have already drawn the nearest back face's colour (the
same split as #4622).

Test: a_body_behind_the_cut_does_not_colour_the_cap in
crates/gpu/tests/capture.rs (a red body in a green box's hollow, 4 mm
behind the cut: the picture is the same with and without it, hatched and
plain, orthographic and perspective). On main 472 pixels differ.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:14:35 +00:00
raykholo eb37a82bc0 Finished sketches on a face stay visible: a dark halo under each curve (#4862) (#4956) 2026-10-25 17:08:58 +00:00
raykholo b1da434458 Graphics: choose the graphics card, honour WGPU_ADAPTER_NAME (#5986) (#6010) 2026-10-25 16:49:57 +00:00
bherbruck e1d99ef0ce Section Analysis: the pointer over a cut no longer picks what the cut hides (#6328) (#6722)
The viewport's picker took the nearest face along the ray whatever the
section removed. Over a cut it hovered the cut-away face in front of the
plane (the outer wall of a sectioned hub, say); the shader clipped that
face's tint, but its outline, drawn dimmed where the model hides it, ran
behind the cap and showed through it. While orbiting with the pointer on
the model the hovered face changed from frame to frame, so lines and
patches of the far side came and went over the hatch.

The picker now does what view.pick already does: faces, edges and corners
on the removed side are skipped (the ray walks past them), and where the
plane crosses a body nearer than any kept face, the cap hides the faces,
edges and corners behind it and nothing is hovered there. The walk that
tells whether the plane is inside a body moves into
view_pick::body_cut_at so both pickers share it.

Test: view_pick_tests::the_mouse_picker_stops_at_a_section_cap (a box
with a pocket, cut through the pocket: over the cut material nothing is
picked, over the pocket its floor, and with the section cleared the top
face). It fails on main, which picks the removed top face.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 16:37:31 +00:00
github-actions[bot] 4f2c133d01 Regenerate generated files and fold pending files [skip ci] 2026-10-25 17:16:02 +00:00
Ahmad Khan 0f90fb7e8d Edge flange: material outside and tangent bend positions (#5941) (#6060)
* claim: #5938

* Edge flange: height measured from the outer sharp, the inner sharp or the bend tangent (#5938)

An edge flange feature takes height_from (outer, inner or tangent) in sheet.flange and Height From in the Flange dialog, saved only when it is not the outer sharp. A height shorter than the bend takes is refused with the smallest height that fits.

* Edge flange: material outside and tangent bend positions (#5941)

An edge flange's position can now be material_outside (the inner virtual sharp on the edge) or tangent (the bend's outer face tangent to the edge's face), in sheet.flange and the Flange dialog. The bend starting at the edge was already the outside position, which now also answers to bend_outside.
2026-10-25 16:25:18 +00:00
amolinari-autotex 52e5bc114e Display cosmetic internal and external threads (#2367) (#2868)
* claim: #2367

* feat: distinguish cosmetic threads in shaded views

* test: keep cosmetic thread coverage compatible with current sections and assemblies
2026-10-25 14:53:24 +00:00
raykholo 4cd9a4f4d4 Web app: open a design from a link, and an embed mode (#6252) (#6253)
* claim: #6252

* Open hosted designs in the web app and add an embed layout

Fixes #6252.

Fetch HTTPS design links with bounded reads and open them as unsaved copies.
Keep model panels and joint controls in embeds without browser persistence,
application chrome, or unload prompts. Add a full-app link and iframe docs.

Cover query parsing, hostile URLs, size limits, and the compact layout.

* Translate the linked-design messages into Japanese and Chinese
2026-10-25 14:40:01 +00:00
bherbruck 72ad6383ef Release 0.22.1 2026-10-25 10:38:39 -04:00
bherbruck 2cf05388f1 Release 0.22.1: version bump, design format 36 (#6628)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 10:38:36 -04:00
CamWight 4577dffb36 Viewport: ambient occlusion no longer shows ghost copies of the model (#4167) (#4251)
* claim: #4167

* Viewport: ambient occlusion no longer shows ghost copies of the model (#4167)

The AO shader used the same 32 sample offsets at every pixel, and nothing smoothed the
result. On a flat face, each sample darkened its own shifted copy of a nearby part's outline.
Where the radius is large on screen (a big design seen close up), those copies show as faint
separate ghosts of the part. Where it is small, they pack into stepped bands along contacts
(#3407).

The sample pattern is now turned by a different angle at each pixel, using interleaved
gradient noise of the pixel position (Jimenez 2014). The copies break up into an even fine
grain, which 2x supersampling smooths further. The cost is per pixel only (one noise value,
one sine and one cosine). The 32-sample loop, the depth prepass and AO off are unchanged.

Test: ambient_occlusion_draws_no_copies_of_a_part (viewport.rs) renders a block on a plate
from above with AO off and on. AO must still darken beside the block, the plate must stay
clear beyond the radius, and the profile beside the block's straight side must have no
copy-sized steps (0.0144 before, 0.0026 after; limit 0.005). The test-only offscreen helper
gains an AO radius, like render_offscreen_ssaa.

Fixes #4167

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:57:29 +00:00
bherbruck 093571cfe4 Connect AI apps at start-up, with a preference to turn it off (#6388)
At launch the app adds the solidcraft entry to each installed AI client off the window thread,
names newly connected clients once, and refreshes a stale path. Settings > Assistant has
"Make SolidCraft available to AI apps on this computer" (on by default); off removes the entries it
added. Entries the user changed are left alone; automation runs never touch the files.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:51:55 +00:00
sascha2101 a1b9ca13e3 Edit Feature: a primitive in a moved component keeps its placement (#2926) (#5989)
* claim: #2926

* Edit Feature: a primitive in a moved component keeps its placement (#2926)

Edit Feature passes a primitive's placement (corner, base, axis, center)
through as saved, in its component's frame, as timeline.inspect reports it.
timeline.redefine read it as a world point and mapped it through the
component's placement, so in a turned component the edit moved the
primitive: in the Hinge Assembly, Leaf B's rod flipped and the revolute
joint put the leaf on the other side.

A placement equal to the saved one now stays as saved; a new one is still
mapped from the world.

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

* Move the #2926 tests up in frames_tests.rs

They sat at the end of the file, where main has since added the #6000
test; moving them avoids the merge conflict.

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

---------

Co-authored-by: sascha2101 <221609842+sascha2101@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:00:40 +00:00
Colin Faulkingham 699f526429 Show imported mesh edges in shaded and wireframe views (#5662) (#5740)
* claim: #5662

* Show imported mesh outlines in viewport (#5662)

* test: update mesh regression origin visibility setup
2026-10-25 12:54:31 +00:00
Colin Faulkingham 16a5254e97 Fix macOS fullscreen application-bar interaction (#3643) (#3690)
* claim: #3643

* fix: keep macOS fullscreen application bar clickable (#3643)

* test: load bundled fonts for fullscreen regression
2026-10-25 12:49:02 +00:00
semmelrinde e5613ebfdf Placing and meshing: no threads started for bodies that are done already (#6406)
Every command in a design with placed components places their bodies
again on up to 16 threads, though nearly all of them are taken as they
were placed before (the same body by the same matrix). Those are now
taken on the calling thread and only the rest are placed on threads.
Warming the meshes (and display plans) of a state likewise starts
threads only for the bodies that have none yet.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 12:48:00 +00:00
bherbruck dfde1e032d mcp setup: add SolidCraft to the AI clients on this computer; plain mcp finds a late app (#6379)
solidcraft-cli mcp setup (alias install) and Help > Connect AI assistants edit each installed
client's own configuration in place, with this program's absolute path: OpenCode, Claude Desktop,
Claude Code, Cursor, Codex, Gemini CLI, Copilot CLI, Windsurf, Zed and VS Code. Only the solidcraft
entries change; a stale path is refreshed and an entry the user changed is left alone.

Plain mcp keeps looking for the app while its headless session has no work, and says on every
tool result that it is headless when the app was not found.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 11:57:51 +00:00
github-actions[bot] 10da6b9efc Regenerate generated files and fold pending files [skip ci] 2026-10-25 12:27:09 +00:00
Ahmad Khan 5a5a98ee80 Sheet metal: Path Flange, a chain of bends on an existing sheet edge (#4094) (#4096)
* claim: #4094

* Sheet metal: Path Flange, a chain of bends on an existing sheet edge (#4094)

Path Flange (`sheet.path_flange`): sketch an open chain of lines across a sheet edge, pick the
edge and one line; each line becomes a flange as long as the line and each corner a bend, one
feature and one body. The edge flange takes further bends (`then`), each on the far edge of the
one before, and the Flange dialog shows them as rows with a bin each.
2026-10-25 11:33:42 +00:00
Mikalai Kukhta 4e2270517a Release: sign and notarize the macOS app (#5749) (#6016)
* Release: sign and notarize the macOS app with a Developer ID (#5749)

package.sh --sign signs the app, the CLI and the DMG with the hardened runtime and a secure
timestamp, notarizes the DMG and the CLI zip, staples the DMG and checks it with stapler and
spctl. The app carries entitlements.plist: com.apple.security.cs.disable-library-validation
lets the hardened runtime load 3Dconnexion's client framework (crates/threedx-client).

signing.sh --setup imports the certificate into a temporary keychain and writes the API key;
--cleanup removes both. The release workflow builds both architectures first (macos-build, no
secrets), then signs and packages in the join job. Without the APPLE_* secrets (forks, dry runs)
the app keeps its ad-hoc signature and the job summary says so.

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

* macOS release: staple the app before the DMG, retry stapler, document renewal (#5749)

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 11:15:35 +00:00
Colin Faulkingham 0312808b92 DXF: reject non-BMP text instead of replacing it (#5056) (#5709)
* claim: #5056

* fix: reject unsupported Unicode in DXF text (#5056)

* fix: adapt new DXF callers to fallible writer (#5056)
2026-10-25 11:01:32 +00:00
fkirill 9d45fa6b34 Respect design units across length fields (#3271) (#3315)
* claim: #3271

* fix(ui): use document units across length fields (#3271)

* Format

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

* Adapt drag-extrude and joint tests to document-unit length helpers

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

* fix(ui): preserve blank parameter drafts and cover bounded lengths

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 10:24:30 +00:00
Frank e4f11c8df6 Preview angled construction planes (#5697)
* Preview planes at an angle

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Plane at Angle: test the preview when an existing plane is edited

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

* ui: include ghost setting in plane preview test

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* test: close angle plane regression test block

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 10:04:57 +00:00
Jesper We bbc3aa4301 Offset Plane: through a point (#3765)
* claim: Offset Plane through a point

* Offset Plane: through a point instead of at a distance
2026-10-25 06:02:14 +00:00
Jesper We 611d4eb6b8 Crash reports: a repeated panic that ends the app is counted, not reported as an unclean exit (#6051) (#6052)
* claim: #6051

* Crash reports: a repeated panic that ends the app is counted, not reported as an unclean exit (#6051)
2026-10-25 06:00:57 +00:00
Kai Evans c50f5364c6 Selection fields: only the active one looks active (#5256) (#5259)
* Selection fields: only the active one looks active

* Selection fields: a filled field keeps its tinted border where a theme fixes the control border's colour
2026-10-25 05:47:23 +00:00
Kai Evans beb38b44aa Select Other: Shift and Ctrl add the choice to the selection, as a click does (#5258) (#5261)
* Select Other: Shift and Ctrl add the choice to the selection, as a click does

* Select Other: follow origin visibility rename in test

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

* Select Other test: click away from the lines' midpoints, which are picks of their own now

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 05:47:20 +00:00
Ahmad Khan a49698ee3d Value fields step with the arrow keys and the mouse wheel (#2320) (#2608)
* claim: #2320

* Value fields step with the arrow keys and the mouse wheel (#2320)

Up/Down in a focused value field step a plain number by 1 in the unit it is written in
("20 mm" -> "21 mm", "2,5 mm" -> "3,5 mm"), 10 with Shift and 0.1 with Alt; the wheel over
the focused field does the same, a step a notch, instead of scrolling the panel under it.
Expressions such as `width / 2` are left as typed, and an open completion list keeps the
arrows. Every expression field gets this through `params_dialog::complete`: the value box
beside the geometry in every dialog, the dialog rows, sketch dimensions, the dimension
editor, Drive Joint and the Parameters window.

* The value box beside the geometry stays under the pointer

The box rides on the arrow's tip, so each wheel notch that stepped its value moved it, and
after a notch or two the box slid out from under the pointer and the wheel zoomed the view
instead. While the pointer is over it the box now keeps its place, and it goes back to the tip
when the pointer leaves.

* Value stepping is a preference, off by default

Preferences › General › "Step values with the arrow keys and the wheel" (`step_values`,
also through `ui.prefs`) turns on Up/Down and wheel stepping in value fields, and the value
box staying under the pointer while the wheel steps it. Off, as by default, value fields
behave as before: Up/Down move the text cursor, the wheel scrolls, and the box beside the
geometry follows its arrow.

* Value stepping test: Bend Radius is under Advanced now

* Value stepping test: the preference is on the Design page

* Value stepping test: draw the field straight into the test's frame (#2320)

The shared outer-host rule now covers tests too; the field needs no panel around it.
2026-10-25 05:34:03 +00:00
Ahmad Khan 919f54a23e Sheet metal: bend angles as the included (press-brake) angle, set per rule (#4968) (#6040)
* claim: #4968

* Sheet metal rules: bend angles as the included angle (#4968)

A sheet metal rule's angle_display (bend, the default, or included) sets
how the DXF bend notes read each bend: a 58° bend is B1 UP 122° R1 under
an included rule. The bend table always gives included_angle_deg beside
angle_deg (CSV and JSON). Set in sheet.manage_rules and the Sheet Metal
Rules dialog, listed by sheet.rules; the flange angle stays the bend angle.

* Bend table: the included angle as the CSV's last column, so the columns before it keep their places (#4968)

A test also covers a body whose base flange names its rule, which keeps that rule's notes whichever rule is active, and a bend down.

* Sheet Metal Rules: Angle Display reads Bend or Included, so the choice fits its box (#4968)

The rule editor's row contract lists the new row and the key the dialog sends.

---------

Co-authored-by: begsaquib <saquibbeg4444@gmail.com>
2026-10-25 05:11:13 +00:00
Colin Faulkingham 1d0e371e64 Preserve lid edge references after parameter changes (#5580) (#5658)
* claim: #5580

* fix: follow named lid edges after parameter changes (#5580)

* fix: initialize viewport test frame tints

* Drop duplicate tint fields in gpu view tests (main already has them)

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

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 05:04:24 +00:00
Matt Van Horn 43565cd33b Snap drawing to where two sketch curves cross (#4967) (#5342)
While drawing, the pointer snaps to the crossing of two lines, circles or
arcs and keeps the new point on both curves. Shift still prefers a midpoint.
2026-10-25 04:50:20 +00:00
raykholo ab9d9ac50f Manage > Units opens the unit picker (#5907) (#5908)
* claim: #5907

* Fix Manage Units to open the shared unit menu (#5907)

Open the browser's existing unit choices for interactive command starts
instead of executing document.units without its required parameter. Anchor
the menu below toolbar buttons and menu rows. Choices still run the normal
command, while programmatic calls require an explicit unit.

Cover error-free opening, unit choices, button anchoring and dismissal,
the current-unit marker, one-step undo/redo, and programmatic calls.

Fixes #5907
2026-10-25 04:35:33 +00:00
rbousigues88 157c0d68d0 Sketch Dimension: point/circle to circle or arc edge (#4064) (#4065)
* Sketch Dimension: point or circle to the edge of a circle or arc (#4064)

Adds PointCircleDistance and CircleCircleDistance (edge-based, like #1863):
solver, constructive seed, drawing, command wiring, tests, UI scenarios and docs.
Saved designs gain two constraint kinds, in one pending format file.

Fixes #4064.

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

* Sketch Dimension scenario: pick the circle at its top edge (#4064)

The bottom edge sits under the Dimension tool's status hint in the scenario window, so the second click never reached the tool.

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 04:29:28 +00:00
raykholo 38d7ea4198 Agent chat: each design tab keeps its own conversation (#5439) (#5612)
* claim: #5439

* Agent chat: each design tab keeps its own conversation (#5439)

* Adapt chat-per-tab changes to the new Pending context and cursor tests

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

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 04:20:54 +00:00
dudedagger42 7c2b2f1559 Revolve takes a planar body face as its profile (#2747) (#5170)
solid.revolve already revolves a planar body face (`face`, `face_body`), but
the Revolve window's Profile input took sketch profiles only, so a face
couldn't be picked. The input now takes planar faces too, and a picked face
is sent as `face`; sketch profiles win when both are picked.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 04:06:08 +00:00
raykholo cc1e654690 Warn when a design is open elsewhere or changed on disk (#5467) (#5468)
* claim: #5467

* Warn when a design is open elsewhere or changed on disk (#5467)

The desktop app keeps a lock file next to each open design
(.~<file>.lock: computer, user, version, opened and refreshed times),
refreshes it every minute and removes it on close; a lock not
refreshed for three minutes is stale and ignored. Opening a design
that another SolidCraft has open asks: Open Read-Only, Open Anyway or
Cancel. A read-only design saves only to another file.

A design remembers its file's size and modification time when it is
opened or saved. Save over a file another program or computer changed
since asks: Save a Copy (the default), Overwrite or Cancel.

Programmatic calls never ask: doc.open reports open_elsewhere and takes
read_only, and file.save over a changed file returns an error until it
is given overwrite: true. ui.documents answers both questions.
2026-10-25 03:34:20 +00:00
raykholo 7af97e3729 Editing an Extrude or Revolve shows its sketch (#5336) (#5443)
* claim: #5336

* Editing an Extrude or Revolve shows its sketch (#5336)

A finished sketch is hidden once a feature uses it, and it stayed hidden
while that feature's dialog was open for editing, so no other profiles
could be picked. The session now holds the sketch of the feature being
edited; the edit dialog sets it while it is open and clears it however
it closes, and that sketch is drawn without changing stored visibility.
2026-10-25 03:26:36 +00:00
raykholo 138bb0261c Windows OpenGL: use the system title bar so the picture lines up with clicks (#2063) (#4403)
* claim: #2063

* Windows OpenGL: use the system title bar so the picture lines up with clicks (#2063)

On Windows with only OpenGL to start, the window opens with the system
title bar instead of using the application bar as its title bar. The
window without OS decorations keeps its caption style while having no
frame, and some OpenGL drivers (older Intel HD/UHD) show the picture a
caption's height lower than the window, so clicks land above what they
point at. Direct3D 12 and Vulkan keep the custom title bar.

The choice is made before the window opens, from the backends wgpu
starts, so the window never changes its frame while OpenGL draws into it.
2026-10-25 03:19:34 +00:00
raykholo dbe7dfa4bc Report a bug: title from the workspace and the reporter's words (#5377) (#5445)
* claim: #5377

* Report a bug: title from the workspace and the reporter's words (#5377)

With no dialog open the report was always labelled "Modeling" and its
summary pre-filled as "unexpected result", so every such report arrived
titled "Modeling: unexpected result" and the fallback to the first line
of "What happened?" never applied.

Label the report from the active workspace (Part, Sketch, Surface, Sheet
Metal, Plastic, Mesh, or Render), leave the summary empty so the title
becomes "<workspace>: <first line of the description>", and keep
"operation failed" when an error was recorded. The summary field now
hints that a short title is optional.

The triage script that asks for an empty in-app report to be pasted
recognises the new titles.
2026-10-25 03:03:08 +00:00
bherbruck 1ceab2ed4c One file per change for new UI strings and attribution rows (#6448)
New UI strings go in assets/locales/pending/<slug>.json and new ATTRIBUTION.md rows in
attribution/pending/<slug>.md. The app build, xtask lists/i18n/assets read them as part of the
catalogs, and the Generated files workflow folds and deletes them on main with
cargo xtask fold-pending.

Refs #6439

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 03:01:41 +00:00
wckdboy 743accca7a Triangulation: split a crossing boundary segment ourselves, never in spade's assert (#4031) (#5409)
* claim: #4031

* Triangulation: split a crossing boundary segment ourselves, never in spade's assert (#4031)

A trimmed face's boundary segment that crosses a constraint already in the
triangulation went to spade's add_constraint_and_split, which asserts
(cdt.rs:978, `!previous_region.is_empty() || from == final_vertex`) when a
split lands so near another vertex or constraint that the piece up to it is
blocked. The kernel guard caught it, but the whole operation failed and a
crash report was written; a STEP body imported empty or not at all (#4598,
also #4293, #4478, #5068, #5229).

PolyBoundary::add_constraints now adds every segment that can be added, then
splits each blocked one itself: a vertex at each crossing, inserted from the
crossed edge's own vertex, and the pieces between added where they can be. A
segment with crowded or very many crossings, or a face with more than four
blocked segments (a rim that runs back and forth across itself), leaves them
out, as truck did before #1653, so the face meshes slightly off instead of
not at all. Pinched faces (#1648) still split where they cross.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 02:55:24 +00:00
bherbruck 128692da0c Agent chat: no per-message limit by default (#6349)
* Agent chat: no per-message limit by default

The limit is optional now: an Unlimited checkbox on the AI page (checked by default), a
number field when unchecked. A saved 24, the old default, reads as unlimited. The API-key agent's
24-request and the gate's 400-request caps are gone; Stop still cancels.

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

* Agent chat: add the new messages to the complete Japanese and Chinese catalogs

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 02:54:13 +00:00
rbousigues88 13b4f6e98c Viewport: a selected edge's highlight no longer shows as a wedge on a grazing face (#3544)
* Viewport: lift selected and hovered edge highlights toward the eye

A selected edge's highlight (3 px core, 6 px halo) is a screen-space band that
was depth-tested at the edge's own depth. A face next to the edge seen at a
grazing angle is pushed back by only about a pixel of its slope, so it buried
part of the band, unevenly along the edge: the highlight showed as a thin
wedge instead of a line.

The band is now drawn like a thin cylinder around the edge: its depth is
pulled toward the eye by half its width plus a pixel, measured on screen so
it holds at every zoom. Plain model edges keep their depth (#1434 still
holds); the part of a highlighted edge the model really hides stays dimmed
through hidden_line.

Not yet compiled or tested.

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

* Viewport: lift an edge highlight by how steeply its faces are seen

The first lift (half the band's width plus a pixel, the same on every edge)
was not tied to the faces beside the edge. Each highlight segment now carries
the normals of the two faces it borders (octahedral, packed with its width in
the line's width slot, read as u32 by vs_line): the band is pulled toward the
eye by its half width times how steeply those faces are seen, so a grazing
face no longer buries it, and an edge seen square to its faces is not lifted
at all.

Test: a_selected_edge_on_a_grazing_face_draws_whole (tilts 60 to 88 degrees,
small and large designs, orthographic and perspective; the band 5 mm behind
a face stays hidden). Capturer::rgba_highlighted draws a highlight scene as
the viewport does, for that test.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 02:48:34 +00:00
raykholo ac53fc1e6f Viewport: bodies stay visible after switching to perspective and back (#5422) (#5614)
* claim: #5422

* Viewport: bodies stay visible after switching to perspective and back (#5422)
2026-10-25 02:46:59 +00:00
Homero Thompson 8d63a9f4cb Theme: the viewport keeps the previous colours until the camera moves (#5802) (#5835)
* claim: #5802

* fix: repaint on a theme change and key the frame cache on the theme tints (#5802)

theme::apply asks for a repaint, so the new visuals are painted on the next pass instead of waiting for another event to schedule one. The gpu frame cache key now includes the theme's hover and selection tints, so a picture cached under the previous theme's colours is not reused.

* test(ui): settle egui's startup repaints before asserting the theme's repaint (#5802)

* test(ui): name the call site that asks for the theme's repaint (#5802)

* test(ui): apply the theme outside a pass so its repaint request stands alone (#5802)
2026-10-25 02:46:41 +00:00
bherbruck a150822d9b Put solidcraft-cli on PATH from the AppImage and the macOS app (#6347)
The AppImage writes a ~/.local/bin/solidcraft-cli launcher from its desktop-integration step
(same opt-outs, never over a file it did not write). The macOS app links the CLI into
/opt/homebrew/bin or /usr/local/bin at launch when one is writable, and Help > Install
solidcraft-cli command does it with an administrator prompt otherwise. Snap and Flatpak are documented.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 02:45:45 +00:00
raykholo a9fd4b1b05 A dragged command window keeps its place (#5357) (#5444)
* claim: #5357

* A dragged command window keeps its place (#5357)

Command dialogs share one dragged offset per dock side for the session
instead of one per title, and closing a dialog no longer forgets it, so
the next command opens where the last one was left. The offset is still
kept inside the viewport, including after the window shrinks.
2026-10-25 02:38:48 +00:00
amolinari-autotex face5e2d31 Hole: independent tapping depth and recessed drill points (#4486)
* feat(hole): checkpoint independent tapped thread depth

* test(hole): cover independent tapped thread depth boundaries

* fix(doc): bind tapped depth to the useful cylindrical wall

* feat(hole): dimension independent thread depth in the section editor

* fix(hole): preserve custom tapping controls after main integration

* feat: add recessed-hole drill points and order depth dimensions

* fix(hole): match the current preferences and UI kit

* fix(hole): verify reference placement with independent tapping depth
2026-10-25 02:31:24 +00:00
Benny1496 efd0e70f7d fix(oracle): use curvature-weighted centroid moments for case 33 (#5442)
* fix(oracle): use curvature-weighted centroid moments for case 33

* style(oracle): preserve reference JSON numeric formatting

* docs(oracle): remove transient case 33 validation note
2026-10-25 02:30:39 +00:00
SiroArt 3f4c6e4571 Display refinement waits until the camera stands still (#3595) (#5446)
Choosing and swapping in a refinement rebuilds and uploads the whole model
scene. During an orbit of a large assembly that happened about ten times a
second, tens of milliseconds each. It now waits until the camera has stood
still for 200 ms.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 02:30:36 +00:00
rvmzym5xv8-star abc39685b1 Parameters: name the limit an out-of-range number or over-long expression broke (#5906)
Part of #4712.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 02:30:04 +00:00
Colin Faulkingham f93913b3d3 Sketch: accept curve snaps for Midpoint Line (#5981) (#6041)
* claim: #5981

* fix(sketch): resolve midpoint line snaps (#5981)
2026-10-25 02:29:47 +00:00
ivisaf 765c5fd143 Pictures drawn off the window thread finish before the process exits (#6062) (#6063)
A Home picture still being drawn by the detached saved-thumbnail thread
could be on the GPU as the process exited, and the process died with
SIGSEGV inside the graphics driver. capture::close stops new pictures
and waits for those in flight (the app calls it in on_exit), and
dropping SolveApp waits for the Home picture threads it started.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 02:29:40 +00:00
Colin Faulkingham 6494f8fe18 AutoConstrain: dimension construction geometry (#6173) (#6185)
* claim: #6173

* fix(sketch): dimension construction curves in AutoConstrain
2026-10-25 02:29:37 +00:00
selune 1623034973 Render: gate bundled studio-data tests in isolated engine builds (#5493) (#6202)
* claim: post-merge Render review corrections (#5493)

* fix(render): gate bundled studio tests in isolated engine builds (#5493)

Addresses jyviko review comments on #5494: compile the two positive payload-dependent regressions only with bundled-render-library. Keep all assertions and the explicit feature-absent refusals. Reproduced 5 pass / 2 fail before the fix; isolated default, CPU without pack, and CPU with pack tests plus strict scoped Clippy and repository checks pass. No production or CI configuration changes.
2026-10-25 02:29:34 +00:00
NOWN-DEV 74004841b4 Sketch text: a text on a path follows the path in the same evaluation (#6215)
* Test: a text on a path follows the path when a parameter moves it

A construction line placed by a dimension that reads a parameter, and a text laid along it: after
the parameter changes the line has moved and the text has not. It follows one evaluation late.

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

* Sketch text: a text on a path follows the path in the same evaluation

Problem: texts are laid out with the sketch's links, before the sketch is solved. A solve that
moves the curve a text runs along (a path placed by a parameter) left the text where the path had
been before the change, so it followed one evaluation late.

Solution: after a solve that keeps the sketch's orientation, every text that runs along a curve
of its sketch is laid out again along that curve as the solve left it; a text that no longer lays
out adds its warning.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 02:29:31 +00:00
NOWN-DEV be08517de2 Construction: a plane on a sheet's flat face follows the face (#6217)
* Test: an offset plane on a sheet flange's face follows the flange

An offset plane on the inner face of an edge flange, picked by body and index as model.faces
lists it. When the base gets wider the flange moves along x and the plane stays where the face
was picked; a sketch on the same face follows it.

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

* Construction: a plane on a sheet's flat face follows the face

Problem: a construction plane on a flat face of a sheet body resolved the stored plane, which is
only where the face was when it was picked. When the sheet changed the plane stayed behind, while
a sketch on the same face follows it by its sheet face name.

Solution: a construction plane on a sheet's flat face resolves that face's plane as it is now, as a
sketch on the face does.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 02:29:28 +00:00
NOWN-DEV 24ed21998b Configurations: a design keeps up to 2048 rules (#6221)
* Test: a design keeps several hundred configuration rules

An assembly that switches a few hundred parts on and off, by two rules each, needs hundreds of
rules; the saved-rule budget refused more than 128.

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

* Configurations: a design keeps up to 2048 rules

Problem: a configuration table kept at most 128 rules. A rule drives one feature's suppression,
so an assembly that switches a few hundred parts on and off (by two rules each) could not keep
its rules. Copying rules for an independent component copy looked up each new id by scanning all
rules, which a larger table makes slow.

Solution: MAX_RULES is 2048; a copy looks its new ids up in a set of the ids taken. The docs say
the new limit.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 02:29:25 +00:00
NOWN-DEV 3592031d5e fix: joints on an imported part's hole or face stay on that part (#6368)
The parts of one STEP assembly import share their face names, and each
part's body stays where the file has it, placed in its component by an
offset. A joint origin on a named face (a hole's centre, a face's
centre, an x reference by face, the end a round origin takes) looked the
name up on every body of the design and compared the pick, in the
joint's component coordinates, with each body as it lies. Another part's
face of that name could lie nearer, and the joint resolved on that
part's face: a pin jointed into one plate's hole landed where the other
plate's hole is relative to its plate.

named_face now looks only at the bodies of the joint's component, each
in its own coordinates, a face the name finds exactly before a piece of
it or a face it was merged into, then the nearest. The design-wide
lookup stays for a face whose body has gone to another component.
named_round, x_axis, pin_end and end_faces take the component, as
face_name_at and round_end_at already do.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 02:29:16 +00:00
NOWN-DEV b8d9db7440 fix: a face-centre joint origin stays put when a hole in its face moves or goes (#6370)
A joint origin at a face's centre (`joint.origin {face}`, `Snap::FaceCenter`) was the face's
area centroid, holes included. A hole cut into that face, moved or suppressed therefore moved
every part held at the face's centre, though the face's outline stayed where it was: on a
200 x 100 plate with a 20 mm hole, 0.31 mm for each 20 mm the hole moves and 0.62 mm when the
hole, 40 mm off the middle, is suppressed.

On a planar face with holes the origin is now the middle of the area the face's outer boundary
encloses (`outline_centre`: the face's triangle sides that no other triangle of the face runs
back along, corners matched by place, chained into loops in the face's plane; the centroid of
the loop enclosing the most area). A named face (`NamedFace::plane`) and the planar face
nearest a point (`nearest_plane`) both use it. A face without holes, or one whose mesh gives no
clean outline, keeps its centroid as before.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 02:29:14 +00:00
NOWN-DEV 2b620338df perf: solve the joints once per parameter change (#6372)
A parameter change evaluates the model and solves the joints inside the
command (so that it can refuse a value that breaks a sketch), and
`Session::execute` refreshes again after the command and solves every joint a
second time, although nothing has moved since. On 40 small parts jointed
rigidly to a plate's holes (half of the joints lifted by an expression),
changing the lifts' parameter took 0.85 s on main and takes 0.45 s now;
changing one part's thickness, 1.96 s and 1.52 s (CLI, medians of 5 runs).

The session now keeps the design the joints were last solved for, as the
solve left it, and the model that solve read. A refresh that finds the same
model, and a design that differs from the solved one at most in its features,
leaves the joints as they are: the joints read features only through the
model, and a value edit stores the new solutions of the sketches it drove
after its solve, so its design is a new one by the time of the second
refresh. Placements and bounding boxes come out identical.

Test: `a_parameter_change_solves_the_joints_once` counts one solve per change
(two before), checks the jointed part still follows the hole, and that a
captured move of a jointed part is still solved back.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 02:29:11 +00:00
NOWN-DEV a5b6408587 fix: an edge joint origin on a body placed by an offset lands on that edge (#6374)
An edge joint origin looked its edge and face up in the body's own coordinates with the pick in the component's,
and returned the frame in body coordinates. On a body placed by an offset (an imported part's bodies are) a pick on
the edge where it is shown found no edge ("joint origin: no edge there"), or the origin sat away from its edge by
the offset. Edge origins now take the picks into the body's coordinates through the inverse of its offset and place
the frame back by it, as named faces and round origins already do. A body without an offset is unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 02:29:08 +00:00
bherbruck 78de687902 CI: duplicate-PR check and a 'Changes a default' line (Refs #6439) (#6442)
* CI: duplicate-PR check and a required 'Changes a default' line (Refs #6439)

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

* PR checks: enforce the defaults line only on PRs opened from 2026-10-25 (Refs #6439)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 02:28:57 +00:00
bherbruck 2741cd9a8b Section Analysis: edges behind the cut no longer show through the cap (#6328) (#6363)
Since #1768 the section's stencil marking draws every face with FACE_BIAS.
Where a back face seen through the cut meets a front face along an edge (a
pocket's wall and its side wall or floor, a fillet and the face it runs
into), the slope push decided which was nearer beside that edge; a front
face winning left those samples unmarked, the cap skipped them, and the
edge, drawn at its own depth, showed through the hatch. In perspective, and
while orbiting in orthographic too, edges popped in and out from frame to
frame.

A second marking pass draws the back faces only (front faces culled, depth
test <=, no depth write), pulled toward the eye as far as FACE_BIAS pushes
faces back, before the cap. The depth buffer keeps FACE_BIAS, so edges on
visible faces still draw whole (#1434).

Tests in crates/gpu/tests/depth.rs: a pocket 2 mm behind the cut, and a
filleted hub with a bore and a cross hole seen from twelve directions, both
depths, orthographic and perspective. On main the pocket shows 45 px of
edges through the cap in perspective and the hub 25 px over the views; with
this change 0 and at most 2.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 02:03:31 +00:00
raykholo 86f5500914 Sketch: arcs and circles honour construction when they are created (#5352) (#5440)
* claim: #5352

* Sketch: circles and arcs honour construction (#5352)

sketch.arc.center_point, sketch.arc.three_point, sketch.circle.two_point,
and sketch.circle.three_point accepted construction: true but drew normal
geometry. They now mark the new curve as construction, like lines and
rectangles, and their parameter docs list construction?.
2026-10-25 01:56:44 +00:00
DuBearte 7804f9938a Intersection curves: a point that won't project no longer panics (#5562) (#5812)
* claim: #5562

* Intersection curves: a point that won't project no longer panics (#5562)

A quarter disc at a box's corner revolved 360 deg about the box's edge and
cut (a hemisphere whose pole sits on that edge) panicked in truck at
intersection_curve.rs:133: dividing one of the cut's intersection edges
met a point that projects onto neither surface, and the hinted
projection's fallback called subs, which unwraps that same failed
projection. The guard turned it into a failed cut and a crash report.

Now the leader's point stands in, the division stops there, and the
failure is recorded (truck_geometry take_projection_failure). truck-shapeops'
face division checks it after dividing an edge and gives up, so the
Boolean attempt fails as quickly as the panic made it and the kernel goes
on to its other tries: carrying the approximate points on instead made one
kernel test (equal bores crossing) run for minutes. On this design the cut
still fails with an ordinary error, leaving the box whole; nothing panics.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:56:23 +00:00
DuBearte ab03aa5bc5 Sketch: explicit planes take a frame (world or component) and the reply says which (#6000) (#6015)
* claim: #6000

* Sketch: explicit planes take a frame, world or component (#6000)

sketch.create and sketch.redefine take frame: "world" (the default,
as before) or "component" for an explicit {origin, x_dir, y_dir}
plane. The reply says which frame the plane was read in, with a note
when a world plane goes into a moved or turned component. frame on a
named plane or a face is refused: those are where they are.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:56:10 +00:00
semmelrinde 2e3f27f06e Extrude: the automatic operation is decided where the extrude starts (#467) (#6104)
With Start set to From Object (or a start offset), the automatic
operation was still decided from the profile: the engine probed for
material just past the profile plane, and the dialog kept the operation
it had latched before the start changed. A profile on a plate's top,
extruded 14 mm down from the plate's bottom face, came up as Cut instead
of Join. The engine now probes from the extrude's start (and, to an
object, reaches from there), and the dialog decides the operation afresh
when the start changes.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:56:04 +00:00
DuBearte bda32419bd Windows: a stuck icacls no longer freezes SolidCraft when it writes the control token (#6145) (#6182)
* claim: #6145

* Windows: a stuck icacls no longer freezes SolidCraft when it writes the control token (#6145)

Writing the control token runs icacls to drop the file's inherited
permissions, and waited for it without a limit. On some computers the
new icacls process stays suspended, so connecting an agent froze the app
for good. It now waits at most 5 s; an icacls still running then is ended
and the token written anyway, into the user's own local app data folder.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:56:01 +00:00
NOWN-DEV c242c484a8 Construction: the root's constructions are found once the design has a component (#6219)
* Test: a plane through a construction axis follows it in a design with a component

Two offset planes, the axis where they meet and a plane through that axis. Once the design has a
component, a parameter change fails the last plane with "unknown construction `Ax`".

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

* Construction: the root's constructions are found once the design has a component

Problem: once a design has a component, the root's construction features are built in the model
placed in the world. That state carried each component's bodies, sketches and threads, but not
its construction planes, axes and points, so a construction built on another one failed on every
evaluation with "unknown construction".

Solution: the world state carries each component's constructions where the component is placed
(moved by its occurrence's placement, as the bodies are).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:55:56 +00:00
NOWN-DEV 5d37cb01d5 Configurations: config.rule takes several rules at once and checks the rows once (#6223)
* Test: configuration rules given together are checked with all of them in place

Suppressing a box and the cut on it: the box's rule alone fails the row check (the cut has
nothing left to cut), so the two rules can't be added one at a time. Given together they pass,
in one undo step; a bad rule among them adds none.

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

* Configurations: config.rule takes several rules at once and checks the rows once

Problem: config.rule adds one rule and checks every saved row with it. Rules that only hold
together (suppressing a body and the cut on it) could not be added at all: the first one alone
fails the check. A design with many rules also rebuilt every row once per rule.

Solution: config.rule takes `rules: [...]`: each rule is added or replaced as with `rule`, then
the rows are checked once with all of them in place, in one undo step; a rule that fails
validation adds none. The docs describe it.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:55:54 +00:00
NOWN-DEV 7313fd8684 Joints: an origin takes a face name as long as the face's piece name (#6225)
* Test: joint origins take a long piece name

A face split by a cut whose edge runs in many segments is named after every face around its
piece, several hundred characters. joint.origin refused such a name, as model.faces gives it,
over 512 characters, both for a face centre and as a round end.

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

* Joints: an origin takes a face name as long as the face's piece name

Problem: joint.origin and joint.create took a face name (a face centre, a round end, an x
direction) only up to 512 characters. A face that a cut split is named after the faces around its
piece (`<face>#[<face>,…]`), which reaches several hundred characters when the cut's edge runs in
many segments (a slot with a zigzag edge, a cut of text), so a name model.faces gives was refused.

Solution: the face names a joint origin takes are capped at 16384 characters (MAX_FACE_NAME),
still a bound on hostile input.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:55:52 +00:00
NOWN-DEV 6f340ade69 Sheet metal: a refolded sheet's faces keep the names of the faces they were (#6268)
* Sheet metal: test that an unfolded sheet's flat face is named after its base at every size

An unfold keeps the base in place and lays the flanges out beside it; the flat
face spanning them and the bend strips is the base's face. It was named after
the unfold whenever few of its sample points fell on the base (the bend strips
are many small triangles, the base two big ones), and that changed with the
sizes: a size change renamed every top and bottom face the refold made of it,
and joints and sketches on them lost their faces (40 of 85 joint ends of a
real part after one change). The test unfolds and refolds a base with three
flanges at two flange heights and wants the flat face and every refolded
panel face named after the base.

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

* Sheet metal: the panel an unfold or refold keeps in place names the face it is part of

A face a sheet feature made is named by where its sample points lie on the
input's faces: in place, else taken back through the folds. Only nine points
per face are sampled, triangle centroids picked by index. An unfolded sheet's
flat face spans the base, the flanges laid out beside it and the bend strips
between them; its bend strips are many small triangles, so most samples fell
on them. Those lie on no panel, and one of them made the fold fallback give
up for the whole face. The face was named after the unfold, and the refold
passed that name to every top and bottom face; which way it went changed with
the part's sizes.

For a sheet feature that moved panels, a face the plain vote leaves unnamed
is now looked at closer (up to 256 points): if a part of it lies in place on
the body's own face before (the panel the feature kept), that face names it.
Else the fold fallback votes over the points that can be taken back, when at
least half can, instead of none when one cannot. Other features, and faces
the plain vote names, are named as before.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:55:49 +00:00
NOWN-DEV e260437846 Names: a pattern copy's piece names match as the original's do (#6270)
* Test: a pattern copy's piece names match as the original's do

A copy's faces carry the instance after the name, after any piece qualifier
(`top#[left,slot:a]@F9.1`). A stored piece name of a copy whose bounding faces have changed since
finds no face of the copy at all (None at the first assertion), where the original's identical
name matches by its stem. On a real part: the joint ends on a patterned copy fall back to their
picks after a size change, the original's resolve by name.

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

* Names: a pattern copy's piece names match as the original's do

Problem: a pattern's copy names its faces after the original's with the instance appended
after any piece qualifier (`top#[left,slot:a]@F9.1`). The piece parser expects a qualified name
to end with its qualifier, so a copy's name was its own stem: when the faces around a piece
changed, a stored name of a copy matched no face of the copy at all, where the original's
identical name still matched by its stem. Joint ends on a patterned part fell back to their picks
after a size change and flipped to the other end of their holes.

Solution: `history_names::matches` takes the pattern instances off the end of both names first
and compares the rest as before, within the same copy only (equal instances; an original's name
never matches a copy's face, nor one copy's another's).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:55:47 +00:00
NOWN-DEV f54ad0f12f Configurations: a parameter edit runs the suppression rules whose condition reads what changed (#6303)
* Test: a switch parameter alone switches the features its rules suppress

Configuration rules that switch features' suppression from a parameter (drive_suppression when
`on` = 0) ran only on config.activate: a plain parameters.change of the switch left the features
as they were, so a script had to activate a row for every change. Red until the fix.

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

* Configurations: a parameter edit runs the suppression rules whose condition reads what changed

Problem: configuration rules ran only when a row was activated or a configuration edited. A
design that switches features on and off by a parameter (a rule that suppresses a feature when
the parameter is 0) did not follow a parameters.change of that parameter.

Solution: parameters.change (one parameter or several) runs, before the rebuild, the enabled
rules that switch a feature's suppression and whose condition reads a parameter whose value
changed (or a feature switched earlier in the same pass), in their order as activation applies
them, and lists them as `switched`. Driven parameters and restrictions still wait for a
configuration edit or activation; a rule whose condition reads nothing that changed leaves a
hand-set suppression alone; a rule that cannot be evaluated is skipped and never blocks the edit.
One undo step with the edit. docs/configurations.md says so.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:55:44 +00:00
semmelrinde 43f21e8fec Sketch drag: a live frame stops once its numerical solves have done their share (#6330)
A live drag frame shares one work budget between its held solves, but
only their cluster decomposition was charged. The numerical solve (LM)
ran outside it, so in a larger sketch where the geometry can't follow
the cursor, one frame ran 64 failing held solves of about 150 ms each:
10 s per frame (68 points, 50 curves, 58 constraints, found by
randomised sketch editing). The frame budget now also counts the LM
work of its held solves, without stopping a solve: once a frame's share
is spent, the drag takes no more steps in that frame, keeps what it
reached and asks for the next frame (retry), as the viewport already
handles. The same drag now takes about 0.3 s per frame. Solves outside
live frames are unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:55:42 +00:00
semmelrinde 3e1f1a89f8 Assemblies: owners of bodies looked up once per placement pass, body frames only when picked (#6356)
Placing a design's bodies asked each body's owning component once per
placement, and each answer searched the features for the one that made
the body: placements x bodies x features on every command, twice (the
world state and the frames that map a command's picks into components).
Document::model_body_components answers for all bodies at once, and
world_state_reusing and body_frames_with_source use it. The engine's
frames now work out the per-body table only when a command picks a body
or names one; most commands pick none.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:55:39 +00:00
semmelrinde 82253c6f35 Expressions: remember what an expression names and a constant parameter's value (#6360)
Every evaluation fingerprints every feature by the parameters its
expressions name, and works out every parameter, and most of them (named
feature inputs) are literal values such as "10 mm". In a design built
command by command both were lexed and parsed again on every command.
references() now keeps its answers per thread (bounded), eval_params
keeps its last few results for identical definitions (compared in full),
and a parameter that names no other is evaluated once per expression,
unit and design length unit.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:55:37 +00:00
NOWN-DEV f4d937d8a2 parts: a standard part's sketches are fully defined (#6376)
A standard part (a nut, washer, bolt, screw, pin or bearing) drew its sketches without
constraints, so each was left with free degrees: 3 for a hex, 8 to 12 for a revolved outline.
A check of a design that holds parts listed every one of them as not fully defined. The part
builder now fixes each sketch's points and curves before finishing it: the outline is the
part's size, and a resize builds it again the same way.


Claude-Session: https://claude.ai/code/session_01XhgPofrmFDeLvMhar6Wsqw

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:55:34 +00:00
semmelrinde 75fb613e25 Names: a new feature's and input's number found without searching the design per number (#6377)
A new feature is named after the first free number of its stem
(Cylinder1, Cylinder2 …), and every number tried searched all features'
names: the thousandth cylinder of a design tried a thousand names against
a thousand features. A new input's dN name likewise wrote out every d1,
d2 … it tried. Both now gather the numbers taken after their stem once
(params::numbers_after, which counts only names written as the number
is) and take the first number not among them. The names are the same.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:55:31 +00:00
semmelrinde ce614044a7 Evaluation: a feature's fingerprint text is written only when the feature changed (#6397)
Every evaluation fingerprints every feature by its JSON, so a design
built command by command wrote all of its features out as JSON on every
command. The text is now kept (per thread, by feature id) with the
feature it was written from, and written again only when the feature
differs; the text hashed is the same, so the fingerprints are too.
Imports, whose text depends on their STEP digest, are written each time
as before.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:55:29 +00:00
semmelrinde 91549d29fb Inspect: bodies measured on all cores, and once while they stay the same (#6412)
document.inspect with measure (what inspect_design and the
solidcraft://design resource ask for) measured every body one after
the other, and again on every call: on a 1,280-body design each call
took 25 to 60 s. It now measures them on all cores, as
inspect.measure does, and each body keeps its measurement in a cell
shared by its clones (with the body it was measured on, as the outline
box is kept), so a design inspected again measures only the bodies that
changed. Placements taken as before keep their placed body's cell too.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:55:23 +00:00
NOWN-DEV b6a5a9166a perf: a pass over the joints evaluates the design's parameters once (#6418)
Every joint whose offset, angle or values are expressions is worked out again
on each solve, twice per solve (re-expressing the joints, then the check that
leaves out one whose expression no longer works out), and each expression that
names a parameter evaluated every parameter of the design anew
(`Document::eval`). The cost grows with the joints with expressions times the
design's parameters: on 40 parts jointed to a plate, each joint lifted by an
expression and each part carrying 13 parameters (522 in all), a joint solve
took 0.55 s on main and takes 0.41 s now, a change of the lifts' parameter
1.06 s and 0.81 s (CLI, medians of 5 runs).

`joints::Params` keeps the design's parameter values once the first
expression names one, and `joints::expressed` takes it, so one pass evaluates
the parameters once. Values and error messages are those of `Document::eval`.

Test: `kept_parameters_evaluate_as_the_design_does` compares values and
errors with `Document::eval`.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:55:20 +00:00
NOWN-DEV 0063954286 Sketch: a sketch whose links moved far is walked there and keeps the stored sides and turns (#6420)
* Test: a label on a path follows its source far

A rectangle of construction lines placed on three points projected from another sketch, with a
centred text on its far side: moved 270 away by the parameters that place the source, its solve
from the stored sketch failed ("the sketch constraints conflict").

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

* Sketch: a sketch whose links moved far is walked there and keeps the stored sides and turns

Problem: a change upstream moves what a sketch projects (or the points its parameters place)
while its own values stay. The solve from the stored state then either failed ("the sketch
constraints conflict") or landed on the mirror image of a side or turn and reported it solved,
because a solve after a link move checks no orientation when no value changed: a line tangent to
a moving circle hopped a diameter to the circle's other side. And a sketch moved so was stored
only when its own values changed, so every later evaluation solved it from its first stored state
again.

Solution:
- flips_from_stored: the orientations of the stored sketch (unsigned offsets, distance
  triangles, and a line's side of a circle it is tangent to) that a solved sketch turned over,
  each read where the stored sketch has its points.
- solve_after_link_move: the new values solved where the links were stored, then the moved
  points walked to where they are now (predictor-corrector: each step starts from the last two
  solutions carried along their secant; steps halve on a failure or a flip and grow while they
  solve).
- Document::solve_sketch: when a linked or fixed point moved and the jump fails or flips, the
  walk; a flip the walk cannot avoid either is reported as one.
- Model::edited_sketches: a sketch whose linked points a value edit moved is stored as solved, so
  the next evaluation starts where this one ended.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:55:17 +00:00
Colin Faulkingham 6f6b64d484 Export Current View as DXF downloads in the browser (#3431) (#3601)
* claim: #3431

* fix(engine): use browser clock for DXF view export (#3431)
2026-10-25 01:49:58 +00:00
leValdemar19 52e496d017 Sketch Symmetry: centre a line between two fixed lines (#1796) (#3895)
* claim: #1796

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

* Sketch Symmetry: centre a line between a face's edges (#1796)

Symmetry of two edges of a face (its projected outline, whose sides run
in opposite directions) about a construction line between them was
refused with "neither entity can move". The pairing of the line ends by
mirror image landed on main in #3029; this keeps the tool side and the
tests for #1796:

- The Symmetry tool and a three-line preselection take the only
  construction line or centerline as the symmetry line, whatever the
  pick order (picking it first used to mirror it out past the other
  line). Two points and a line take the line. Otherwise the last pick,
  as before.
- Tests: fixed lines drawn the opposite way, pairs by mirror image (a V,
  end to end along the axis), every pick order, a three-line
  preselection, and a scenario clicking a face's edges.

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-25 01:49:54 +00:00
CamWight d557c09c3b Sketch text: measure a line with the rule that draws it (#4813) (#4815)
* claim: #4813

* Sketch text: measure a line by the rule that draws it (#4813)

`TextMetrics`, made once from a font and a style, parses the face once and gives the scale for a capital height, the
distance between lines, a line's width and the characters the font has no glyph for (in order, each time, including
those its character map sends to `.notdef`). `text_geometry_styled` lays its lines out through it: one rule for
measured and drawn text, no kerning, a missing character still drawn as `?`. Measuring builds no outlines, so a text
too large to draw (`MAX_LINK_ENTITIES`, `MAX_TEXT_CHARS`) can still be measured. Every outline stays bit for bit the
same: a test pins five styled texts, captured before the change. The size and spacing checks are shared, and still
run before the font is looked for; a text whose place or angle is not finite now says so instead of naming its
height.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:49:22 +00:00
raykholo 7046f7f0e7 Windows resize smoke test: wait for a settled window before hit testing (#5523) (#5524)
* claim: #5523

* Windows resize smoke test: wait for a settled window before hit testing

Startup placement can move the window between CheckBorder reading its rect
and the hit tests, so correct answers for the new position failed against
stale coordinates. Wait for two reads 200 ms apart to agree before the first
pass, compare the rect after each pass, and retry the pass (up to three tries)
when it moved. A pass with a stable rect fails exactly as before.

Fixes #5523
2026-10-25 01:49:14 +00:00
Homero Thompson 16286249ed Extrude: a picked region expands across the reference geometry that cuts the drawn profile (#5302) (#5530)
* claim: #5302

* Extrude: a picked profile takes the whole drawn profile (#5302)

A sketch on a solid's side face holds the face's outline as projected
reference geometry, and that outline cuts the drawn profile into pieces
of the sketch's graph. A pick by index or by point resolved against those
pieces, so extruding a rectangle that straddles the face's edge took
only the piece under the cursor.

The pick is now expanded to the drawn profile it belongs to, so it takes
every region of it. The drawn profiles are the disjoint faces of the
drawn graph, so an explicit pick of a drawn hole still fills only that
hole (#5176).

* Extrude: a pick expands across the reference geometry that cuts the drawn profile (#5302)

The expansion crossed every region of the drawn profile, so a projected curve that bounds a region inside the profile (the rim of a counterbore) was crossed too and the cut took more material than intended; now the expansion stops at that boundary. Fixes the regression in cmd::features::prism_local_tests::a_ring_cut_round_a_counterbored_hole_in_a_chamfered_disc, and keeps the behaviour of #5302 and of the #5176 invariant.
2026-10-25 01:48:50 +00:00
rlartot 19e2800472 Sketch on the sides of a thin extrude (#2722) (#5781)
* claim: #2722

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

* Sketch on the sides of a thin extrude (#2722)

A thin wall's sides lie off the sketch curves (half the thickness either
side, say), so no curve was under them: its side faces had no role, no
exact history name, and Create Sketch refused them as "can't keep an
attachment". The roles of a thin wall's sides are now
`wall:<curve>:left|right` (the side of the curve it runs beside, in the
direction the curve is drawn), and its open ends `wall:<curve>:start|end`.
They follow the thickness and side settings and survive a save.

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 01:48:35 +00:00
DuBearte a5f7f5a921 Batch: consecutive parameter edits can rebuild the design once (#5822) (#5839)
* claim: #5822

* Batch: consecutive parameter edits can rebuild the design once (#5822)

An agent's batch of parameter edits, one parameter per step, evaluated the
design once per edit: an edit early in the timeline rebuilds nearly all of
it, and 21 such edits ran for minutes.

engine.batch and the MCP batch tool take coalesce_parameters (default
false). With it, each run of consecutive plain parameter edits
(parameters.change with only a name and an expression, or with changes) is
set as one parameters.change {changes}: one rebuild for the run. Any other
step ends a run, so later steps see the design up to date. Every step
still gets its own result (coalesced: the run's length); the run is one
undo step. A run whose combined edit is refused (a new parameter, which
changes can't make; a bad expression) runs step by step, so a refused edit
is still named by its own step. The app's off-thread evaluation computes
the run the same way. The grouping is solidcraft_engine::batch_params,
shared by both runners.

21 parameter edits on a plate with 21 extruded bosses (in-process MCP,
ci profile): 40.1 s step by step, 3.5 s coalesced, the same volume.

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

* Batch coalescing: say a run skips the states between its edits; test it against step by step (#5822)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:48:31 +00:00
Frank a402c5e502 Snap points: the centre, middle and ends an edge or face offers, in the engine (#5881) (#5882)
* claim: #5881

* Snap points: the centre, middle and ends an edge or face offers (#5881)

Phase 1 of #5881. `solidcraft_engine::snap::snap_points` gives the points
a picked item offers as picks, from Measure's exact shapes: a round edge
its centre (an arc also its ends and exact middle, joined across pieces
the kernel split it into), a straight or other edge its ends and middle,
a flat face the centre of its area, a cylinder or cone the centres of its
ends, a sphere or torus its centre, a vertex itself. Nothing uses them yet.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:48:29 +00:00
JIJINBEI cdde7d62b2 Persistent names read by one parser; four misreadings fixed (#5814) (#5966)
* doc: persistent names read as their structure; joint.inspect splits an edge with a blend face first

`persistent_name::{FaceName, EdgeName}` parse a face or edge name once (blends, inner walls,
copies, pieces, numbered pieces, `~k`) and write it back as the same text, bounded against
hostile input. A suffix after a blend's edge naming a later feature belongs to the blend face.

joint.inspect split an edge at its first `|`, so an edge whose first face is a blend
(`F4:blend:F3:+x|F3:+z#[…]|F5:-y`) came back as a cut-off blend name and a string that is no
face. It now uses `EdgeName::parse`.

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

* doc: persistent names as typed values: features, roles, suffixes, a lexer and errors that say where

`FaceName` is a base (made by a feature in a role, a blend and the edge it rounds, an inner
wall, a body's face by index, other text) and its suffixes (copy, piece, numbered piece).
Roles are typed where the vocabulary is known (top, side:<curve>, +x, face:<k>, …) and kept as
written otherwise. The text form is lexed into tokens and read by the grammar in the module's
doc; a name that doesn't read says where and what was expected.

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

* names: read what made a face, its kind and its feature ids through persistent_name

The places that took a name apart by hand to find the feature that made a face, whether it is
a blend, a copy, a numbered piece or a wall, or to remap feature ids on copy, now ask the typed
name (`made_by`, `split_feature`, `is_blend`, `is_copy`, `is_numbered_piece`, `is_wall`,
`features`, `map`). A name that doesn't read falls back to the old text check.

- An offset of a face that only names a blend inside it (`F2:top#[F5:blend:…]`) is no longer
  taken for a fillet's.
- Copying a component remaps feature ids inside a piece's `#[…]` too.
- A shell's inner wall split by a later cut keeps the qualifier on the wall.
- joint.inspect finds the sketch curve of a copied side (`side:l1@F7.2`).

Finding a face by name (strip_piece, parent_piece) is unchanged: it reads a trailing
qualifier as the whole name's and moves once the text form says where it belongs.

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

* engine: regression tests for press-pull on a wall that only names a fillet and joint.inspect on a copied side

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

* names: an edge holds one face or more; made_by reads only the leading token

An open boundary's edge has one face and an edge several faces meet at has more, so an edge name
is a list of faces (a blend's own edge still takes two at most, as before). `made_by` reads just
the leading `F<n>:`, which every name a feature made starts with, so asking it of every face
each frame stays cheap. Names are read up to 1 MiB: a face engraved with text has piece names
past 4 KiB.

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

* persistent_name: split into lexer, parser, write, text and tests

No code changed; callers' paths stay the same (`pub use text::*`).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:48:26 +00:00
DuBearte 3f6b9fe89f Timeline: reorder anyway (allow_errors) and report the features the move breaks (#5962) (#5988)
* claim: #5962

* Timeline: reorder anyway (allow_errors) and report the features the move breaks (#5962)

timeline.reorder and timeline.reorder_many refuse a move after which a
feature that worked would fail. Sometimes breaking a later feature is the
point: an agent restructuring a design wants the move made, then the list
of what broke so it can repair those features next.

Both take allow_errors (default false: refused as before). With it the
move is made and the reply lists broken: [{feature, error}] (empty when
nothing broke); one undo puts the order back. timeline.reorder_many also
takes active, as timeline.reorder does.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:48:23 +00:00
Nathalie Schönwetter b4acd83c78 A count from a length says how to get a count (#5901) (#6011)
A pattern count such as (L-2*Post)/210 is a length when the 210 is unit-less, and was refused with only "expected a unit-less number, got a length". The message now says to divide by a length, e.g. floor(L / (210 mm)).

Co-authored-by: Nathalie Schönwetter <nathalie@cfw-ag.ch>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 01:48:21 +00:00
semmelrinde f7233c4418 Render: a preview setup with exposure renders and names what it leaves out (#5089) (#6073)
render.settings.set stores exposure_ev and the environment controls, but
render.preflight and render.start refused any setup with them because the
CPU image executor doesn't apply them, so such a setup could never render.
The CPU executor now renders it and the replies list the left-out
controls in ignored_controls.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:48:20 +00:00
Colin Faulkingham 5573ae06fc Prevent body renames from reusing removed body names (#6042) (#6048)
* claim: #6042

* fix: reject body names retained by Remove (#6042)
2026-10-25 01:48:15 +00:00
raykholo 89a4a96630 Ribbon: an active contextual tab looks selected (#4230) (#4242)
* claim: #4230

* Ribbon: an active contextual tab looks selected (#4230)

Contextual tabs (Sketch while editing, Sheet Metal for a sheet body) chose
their solid accent fill before checking whether they were active, so
selected and unselected looked the same.

The selected contextual tab keeps the solid accent pill. An unselected one
now looks like any unselected tab with its accent as an outline, so it still
marks the context without outshining the tab that is selected. Ordinary tabs
are unchanged.
2026-10-25 01:26:47 +00:00
raykholo 34d1ac0376 A straight 3D sketch line can be picked as an axis (#4363) (#4381)
* claim: #4363

* A straight 3D sketch line can be picked as an axis (#4363)

3D sketch wires were always offered as curved, and axis inputs resolved
only 2D sketch lines, so a straight 3D line couldn't be an axis (Plane at
Angle and others). A single-segment wire is now offered as straight and
resolves to its endpoints in model space; splines are still refused.
2026-10-25 01:26:44 +00:00
Thodoris Tsagkogeorgas 0e9e175e79 Two-sided sketch offset shows both distances in the sketch (#4385) (#4386)
A two-sided offset drew only the Distance dimension. Distance 2 now gets
its own value box and arrow, the other way from Distance.
2026-10-25 01:26:41 +00:00
rlartot f11ad0ce28 Ribbon tooltips: written help for six more tools (#2758) (#4592)
Combine, Loft, Mirror, Circular pattern, Rectangular pattern and Sweep get the
same two-sentence help as the first six (en, fr, ja, zh). Text only.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 01:26:33 +00:00
neosandeep24 9ad96fb81b Home: keep a removed recent design removed (#4660) (#4718)
Removing a design from the Start page's recent list only changed memory, and on the web sync_recents() rebuilt the list from stored designs on each launch, adding it back. Track removed designs in a persisted 'forgotten' set that sync_recents honours; opening or saving a design, or deleting/renaming its file, clears it.
2026-10-25 01:26:31 +00:00
Herperlerpicus b671265faa Viewport: show the running tool's icon beside the pointer (#4869) (#4870)
* Viewport: show the running tool's icon beside the pointer

While an interactive tool or a command's dialog runs, its toolbar icon
rides above and right of the pointer in the viewport, so the next click's
command is visible without looking up at the ribbon. It sits clear of the
system arrow and of the sketch tools' coordinate readout below the
pointer, moves left at the right edge and below the readout at the top.

It shows only where a click reaches the model: not over panels, dialogs,
menus or the navigation bar, and not while the camera moves. The icon is
the command's own (the one the ribbon and the dialog header show), so
there is no new artwork. On by default; "Show tool at cursor" in View
settings (`ui.set {"toolCursor": false}`) turns it off.

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

* Viewport: the tool at the cursor is off by default

As asked in review: the badge starts off, and "Show tool at cursor" in
View settings (or `ui.set {"toolCursor": true}`) turns it on. The test
checks the default and turns it on through the View settings checkbox.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:26:27 +00:00
Reefwalker 832ff941d1 Edit Text: explain failed bold previews immediately (#5004) (#5155)
* claim: #5004

* fix: explain failed sketch text previews immediately (#5004)
2026-10-25 01:26:23 +00:00
raykholo 9869997733 Sketch Create menu: line, rectangle, and other families show their icons (#5244) (#5279)
* claim: #5244

* Sketch Create menu: family rows show their icons

The Line, Rectangle, Circle, Arc, Slot, Polygon, and Spline entries in a
sketch's Create drop-down were drawn as plain text buttons. Draw each as
the shared menu row with its first variant's icon and a submenu arrow,
keeping the same popup handling for its variants.
2026-10-25 01:26:20 +00:00
raykholo 745a24bbc8 Sketch: undo while drawing a spline removes the last point, not the sketch (#5304) (#5441)
* claim: #5304

* Sketch: undo and Backspace remove the last pending spline point (#5304)

While a spline tool has points placed, Cmd/Ctrl+Z and Backspace take back
the last point instead of undoing sketch history or deleting the selection.
Undo reaches sketch history again once no spline points remain.
2026-10-25 01:26:15 +00:00
raykholo 26340882c0 Mirror keeps the preselected feature when the object type changes (#5288) (#5453)
* claim: #5288

* Mirror keeps the preselected feature when the object type changes (#5288)

The pre-selection moves into the objects input when a pattern window
opens, and switching Object Type cleared that input, so switching to
Bodies and back lost the selected feature. The window now keeps the
objects of the type not shown and restores them when it is chosen
again. A plain timeline click on a feature already picked keeps it;
Ctrl+click takes it out.
2026-10-25 01:26:13 +00:00
raykholo 8e2b8f2ace Floating history: the last done feature is no longer drawn as selected (#5415) (#5455)
* claim: #5415

* Floating history: the last done feature is no longer drawn as selected (#5415)

The floating history strip passed the last feature before the marker to
HistoryChip as selected, so it looked selected with nothing selected.
Pass only the real selection, and mark the last done feature with a small
tab-accent dot under its chip instead.
2026-10-25 01:26:10 +00:00
ISHII YUKI 34e75979b2 Agent chat: hand WSL paths to agents started through wsl.exe (#5546) (#5549)
* claim: #5546

* Agent chat: hand WSL paths to agents started through wsl.exe (#5546)

An ACP agent started through wsl.exe was handed the session's working
folder and the solidcraft-app bridge's command as Windows paths, which it
can't use: the Claude adapter refuses the session, and the bridge never
starts. When the agent's program is wsl or wsl.exe, convert both to their
WSL form (C:\Users\... -> /mnt/c/Users/...) before session/new, in the
chat and in the Test button. The bridge still runs on Windows and reaches
the app at 127.0.0.1.
2026-10-25 01:26:05 +00:00
N3MI-DG d5eca6cc78 3D mouse: let the overall speed slider reach the 5x the engine allows (#5704)
The Preferences > Navigation overall speed slider stopped at 3.00x, but
`space_mouse::frame` clamps the applied speed to 0.1..=5.0, so the top of
the range the engine honours could not be reached from the UI. Raise the
slider's ceiling to 5.0x to match that clamp; the step, format and lower
bound are unchanged.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-10-25 01:25:58 +00:00
rlartot 77c77829e3 Preferences: Z up orientation figure is drawn right-handed (#5680) (#5741)
* claim: #5680

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

* Preferences: draw the Z up orientation figure right-handed (#5680)

Z up showed Y pointing toward the viewer on the left, a mirrored
(left-handed) figure. Y now points away on the right, like the axis
gizmo. The axis tips move into orientation_axes and a test checks that
X to Y and Y to Z turn counter-clockwise in both figures.

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 01:25:55 +00:00
Colin Faulkingham f01ef3c6aa Keep the Parameters window height stable when moved (#5078) (#5752)
* claim: #5078

* Keep the Parameters window height stable when moved (#5078)
2026-10-25 01:25:53 +00:00
rlartot f5a6c8fa51 Link the version number to its release notes (#5488) (#5765)
* claim: #5488

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

* Link the version number in the title bar to its release notes (#5488)

The version at the right of the title bar opens the release page of this
build (v<version>, which exists for stable, candidate and nightly
releases) in the browser; a development build opens the release history.
Its click area stops at the report button, which the long version
strings of candidates and nightlies overlap.

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 01:25:51 +00:00
rlartot 865575e410 Hem: the handle runs along the hem to its tip (#5828) (#5844)
* claim: #5828

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

* Hem: the handle runs along the hem to its tip (#5828)

The hem's on-canvas handle started on the picked edge and pointed square
to the sheet (#4794 only flipped it), so it neither sat on the hem nor
moved the way the Length grows. Length is measured from the outer face
of the fold to the tip, so the handle now starts on the centre line of
the hem layer at that face and points along the hem toward the tip.

The test builds the hem for both Flip states and checks the handle's
direction, its start on the layer's centre line, and that start plus
Length lands on the built tip. It replaces the arrow-side test of #4836,
whose rule (the arrow points to the side the hem builds on) no longer
applies.

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 01:25:49 +00:00
HiroKenEz 992c93fe5b Find in Timeline scrolls both timeline layouts to the found feature (#5850) (#5863)
* claim: #5850

* Find in Timeline scrolls both timeline layouts to the found feature (#5850)

Find in Timeline selected the feature but neither the bottom History strip
nor the history on the left scrolled to it. The handler now leaves a one-shot
reveal request (TreeState::timeline_reveal); whichever layout draws its items
next scrolls the found item into the middle of its scroll area and drops it.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:25:44 +00:00
raykholo 0343960bd6 Ignore middle-click clipboard paste during navigation (#5855) (#5924)
* claim: #5855

* Ignore middle-click clipboard paste during navigation (#5855)

Firefox on Linux sends a primary-selection paste event when the middle
button orbits or pans. Paste events on frames where the middle button is
pressed, held, or released no longer reach the design; Ctrl/Cmd+V still
pastes.
2026-10-25 01:25:40 +00:00
DuBearte 1d48707d0c Drive Joints: a Show ghost checkbox (#5905) (#5955)
* claim: #5905

* Drive Joints: a Show ghost checkbox (#5905)

Driving a joint draws the moved parts' old place as a translucent ghost,
which gets in the way when driving joints over and over. Drive Joints has
a Show ghost box under its value rows: on by default (nothing changes),
off draws only the moved parts at their new place. The stopped-short
warning stays. The choice is a preference (drive_joints_ghost), so it is
remembered between uses; it affects Drive Joints only.

The preview's Colors carry the choice (ghost), so toggling it recomputes
the preview (its key hashes the colours) and world_preview leaves the
ghost out.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:25:38 +00:00
DuBearte 443baa41f0 Design tabs: the tooltip says where the file is saved (#5868) (#5974)
* claim: #5868

* Design tabs: the tooltip says where the file is saved (#5868)

Hovering a design tab showed the design's name, the same text as the tab.
The tooltip now says where the file is saved, with a path in the home
folder shortened to ~/ (home::friendly_path, as the start page does), or
"Not saved yet" for a design never saved. When the tab is too narrow for
its name, the full name comes first, then the place.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:25:36 +00:00
Colin Faulkingham d366b8922a Keep the main sketch tool unchanged when a pinned tool starts (#4023) (#6013)
* claim: #4023

* fix(ui): preserve sketch family choices when starting pinned tools
2026-10-25 01:25:33 +00:00
Colin Faulkingham 692265980b Identify commands beside the animated agent cursor (#622) (#6026)
* claim: #622

* fix(ui): identify commands during agent cursor animation
2026-10-25 01:25:31 +00:00
Colin Faulkingham 28fe5fdb43 Extrude: accept manually selected target planes (#4974) (#6030)
* claim: #4974

* fix(ui): accept manual extrude plane targets (#4974)
2026-10-25 01:25:29 +00:00
semmelrinde 26c9d1c445 View toolbar: the Look At tooltip names its shortcut, Ctrl+8 (#2476) (#6071)
Look At already has a key (Ctrl+8, Cmd+8 on a Mac), but the button's
tooltip didn't say so, as Fit's does (F6).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:25:24 +00:00
semmelrinde f157f5489f test: the desktop-PATH agent probe waits long enough on a loaded machine (#6072)
desktop_path_can_start_an_agent_and_its_runtime gave the version probe
3 s and the first reply 5 s. Under a full test run on a loaded macOS
machine the probe came back empty (version None, twice in a row), while
the test alone passes in 0.4 s. Both limits are now 20 s; a working
agent still answers at once, so the test takes no longer.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:25:22 +00:00
semmelrinde d1ec8dfd5a Agent chat: a Mammouth AI preset for OpenAI-compatible endpoints (#5815) (#6082)
* Agent chat: a Mammouth AI preset for OpenAI-compatible endpoints (#5815)

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

* Agent chat: the key test names the Mammouth AI URL too (#5815)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:25:19 +00:00
semmelrinde 08d094db39 Shell: an empty Thickness makes no command, so OK waits for one (#960) (#6085)
With the Thickness box cleared, the dialog sent thickness: "" and the
command refused it ("`thickness` must be a number or an expression"),
shown as a red error with Create Issue. The dialog now makes no command
until a thickness is entered.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:25:17 +00:00
github-actions[bot] 5fa6361ff2 Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci] 2026-10-25 02:19:39 +00:00
Colin Faulkingham 1f93b6d39c Joint: use the picked straight edge as the motion axis (#5457) (#6086)
* claim: #5457

* fix: align new straight-edge joint snaps with the edge (#5457)
2026-10-25 01:25:14 +00:00
semmelrinde 0477e0ba4e Shell: the dialog sends the picked face's body (#4555) (#6092)
Where a body's face lies flush with another body's face, the face point
alone could resolve to the other body, and Shell then refused with
"select at least one face to remove". The dialog now passes the body of
the picked faces, as the command already accepts.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:25:12 +00:00
semmelrinde ae7176cc86 Extrude: a face picked under a picked profile is extruded too (#3926) (#6099)
With a sketch profile and a body face picked together, OK ran the
profile's extrude first and then looked the face up by its point. Where
the profile's extrude covered the face, it was gone: "no face of Body1
at face". A new extrude now runs its face picks first; an edit still
rebuilds the profile's extrude first.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:25:09 +00:00
semmelrinde eef9a68809 Dialogs: a missing pick on OK is guidance, without Create Issue (#3292) (#6101)
Plane at Angle's OK with an empty Plane or Axis input showed "select a
plane first" or "pick an axis" as a red error with a Create Issue
button, and people filed it as a bug (#3292, #3773). A refusal that only
names what to pick is now shown as a plain hint, with no issue button,
in every dialog (Sketch Modify dialogs already hid it).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:25:06 +00:00
Colin Faulkingham f06875128b Preserve the history marker when switching sketch editors (#5435) (#6103)
* claim: #5435

* fix(ui): preserve history when switching sketch editors
2026-10-25 01:25:04 +00:00
semmelrinde 9d226b3535 Sheet metal DXF: options to leave out the bend lines and to use one layer (#4314) (#6130)
sheet.export_dxf always wrote the dashed bend lines (BEND_UP,
BEND_DOWN) and split the flat pattern over a layer for each role. A
cutter that cuts every line it is given cut the bend lines too.
bend_lines: false now leaves them out, and layers: single writes every
entity on layer 0. The defaults keep today's output.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:25:02 +00:00
Colin Faulkingham 7a1f903216 Close the File menu with Escape (#6126) (#6132)
* claim: #6126

* fix: close File menu on Escape (#6126)
2026-10-25 01:25:00 +00:00
NOWN-DEV b88673f366 Model cache: each sheet body is written once, not with every state (#6152)
* Test: a saved model keeps each sheet body once

A sheet-metal design wrote its sheet bodies again with every later feature's state in the saved
model: each feature that leaves the sheet alone (a box beside it) adds more than the whole sheet
to the records. A design of many features after its sheet metal passed the cache's 32 MiB
manifest limit that way and kept only some of its states (#3220). The test also opens the saved
file and wants the sheets taken from it as evaluated.

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

* Model cache: each sheet body is written once, not with every state

Problem: a saved model writes every feature's state, and each state wrote its sheet bodies in
full; most features leave the sheets as they were, so a sheet-metal design of many features
repeated them over and over (a 280-feature part: 30.5 MB of manifest, 29.6 MB of it 69 distinct
sheet records written 243 times). Past the manifest's 32 MiB limit the save thinned the cache
(#3220) and opening evaluated the left-out features again, although the distinct data was small.

Solution: the records keep a list of sheet bodies, each written once; a state names its sheets by
their place in it, as it already does for bodies and sketches. A thinned model keeps the whole
list and counts it once against its budget; the reader counts sheets with the bodies and sketches
a state holds; the cache format is now /4 (an older cache is a miss).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:24:57 +00:00
DuBearte 67adf5abb5 Preferences: the theme strip shows arrows where more themes are (#6161) (#6167)
* claim: #6161

* Preferences: the theme strip shows arrows where more themes are (#6161)

The strip scrolls sideways with a hidden scrollbar, so nothing showed that
more themes were past its end. An end with themes past it now shows a
chevron button over the tiles there; a click scrolls most of a page.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:24:54 +00:00
Dahak Builds adbd2adf89 view.pick: map screenshot pixels to exact geometry (#6304) (#6329)
* claim: #6304

* view.pick: map screenshot pixels to exact geometry (#6304)

A read-only command that answers, for screenshot pixels (window or model
frame) or world-space rays: body, face index, exact world point and
outward normal, depth, occurrence/component for placed parts, and the
nearest edge/vertex within edge_px. Faces are raycast through the same
per-body pick index the mouse uses; flat faces are solved exactly
against their plane, curved faces take the mesh hit with the normal
interpolated from the mesh's surface normals. Section-aware: faces on
the removed side do not stop a pick and the cut cap answers with
section_cap. Never touches the selection, camera, document or undo.

Exposed as control ui.pick and as view.pick through engine.execute and
engine.batch (and through them MCP execute and batch).

---------

Co-authored-by: Dahak Builds <dahakbuilds@users.noreply.github.com>
2026-10-25 01:24:39 +00:00
Dahak Builds 96303873fe Sketch: right-click toggle between driving and driven dimensions (#320) (#6357)
* claim: #320

* Sketch: toggle a selected dimension between driving and driven (#320)

A selected sketch dimension now offers 'Toggle Driven Dimension' in the
viewport right-click menu, running the existing engine command
sketch.toggle_driven. The over-constrain -> driven placement half landed
in #366; this exposes the engine's toggle in the UI, the remaining gap
from the retest notes on #320.

- context_menu: sketch_entity_items collects selected SketchConstraint
  ids that are dimensions (kind.value() is Some) and, for a single
  selected dimension, prepends the toggle item aimed at that constraint.
  Non-dimension constraints and plain entities get no item.
- Label reuses the existing catalog entry cmd.sketch_toggle_driven, so
  every locale translates it with no catalog change.
- Test: the menu offers the item for a selected dimension, running it
  round-trips driving -> driven -> driving, and a horizontal constraint
  or a plain curve gets no item.

---------

Co-authored-by: Dahak Builds <dahakbuilds@users.noreply.github.com>
2026-10-25 01:24:36 +00:00
NOWN-DEV b925a1af3d Configurations: a long table builds only the rows on screen (#6366)
* Test: a long configuration table builds the cells of the rows on screen

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

* Configurations: the table builds the rows on screen and its cells once per change

A table of hundreds of configurations was slow to open and to use: every frame cloned the
whole config.table answer, rebuilt every cell's text, looked each parameter's unit up once
per cell and built an editor for every cell of every row, on screen or not.

The answer and its cells (with units, looked up once per column) are now kept per design
revision, and rows off screen keep their space without building their cells, as the
parameters table already does. A row with an edit in progress stays live, so leaving its
cell still commits the edit.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:24:33 +00:00
Matthew Knutson 05d43d3542 Windows: the taskbar button shows the mark when the first frames are slow (#6391)
The taskbar asks a window for its icon once, when the window appears. eframe
sets the icon while the window is still hidden and shows it after the first
frame; when that frame is slow (safe mode's software renderer) or startup kept
the UI thread busy long enough for Windows to mark the window as not
responding (building the viewport pipelines), the request isn't answered in
time, the taskbar falls back to the window class's icon, and winit's class has
none: the button showed Windows' generic icon for as long as the window was
open.

Give the class the exe's embedded icon (resource 1) before the window is shown.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:24:26 +00:00
NOWN-DEV c0640cb95e inspect.parameter_sweep: check a design across a parameter's range (#2273) (#2276)
* inspect.parameter_sweep: check a design across a parameter's range

Rebuilds a copy of the design at each value of a parameter and reports what
no longer holds compared with the design as it is: features that fail or
warn, joint origins that are lost or turn over on their own part, sketch
planes that turn over, and sketch solves that fail or fold. The design is
not changed; each value's rebuild time is reported too.

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

* inspect.parameter_sweep: params doc matches what it takes and returns

`steps` takes at most 199 (200 values), as the check says; failed and
warned entries carry the feature's name; a lost origin says which one; a
value the parameter refuses gives {value, ms, ok: false, error}. The
refusal test covers steps 200.

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

* inspect.parameter_sweep: Japanese and Chinese labels

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-25 01:19:00 +00:00
Tyler Chiriboga 2ff17c0fcb 3D mouse: follow the device that moves, not the first one found (#3988) (#4339)
* claim: #3988 (3D mouse: follow the device that moves, not the first one found)

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

* 3D mouse: follow the device that moves, not the first one found (#3988)

On Windows and macOS the reader opened the first 3D-mouse interface it
could and read it until it failed. A 3Dconnexion Universal Receiver with
nothing paired opens fine, has a valid layout and never fails, so it kept
the reader for good. A SpaceMouse on a cable next to it was never read
("Not calibrated (hid 256f:c652)", every axis at zero).

The reader now opens every candidate and follows the one in use. The
first is followed from the start, as before. Another takes over once it
shows use (an axis off zero or a button down) while the followed one has
shown none for STALE_AFTER, so two devices moved together don't trade
the reading. A candidate that fails is dropped; if it was the followed
one the reading resets and the next is read. With one device the reader
behaves as before, down to the 100 ms read timeout. Several take 10 ms
turns, and reports queue in the system meanwhile.

The selection is `follow()`, written against a small `ReportSource`
trait, so it is tested with simulated devices on every platform: an idle
receiver next to a SpaceMouse, two devices in use at once, and devices
failing.

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

* 3D mouse: a press that makes a device the one in use counts as a press

Since #3825 a HID device's first button report after it connects is its
baseline: a button down in it is held, not pressed. A device taking over
from another one was connected at that moment, so the press that took
over was swallowed. With an idle Universal Receiver found first, that is
the first button press on the SpaceMouse.

follow() now keeps the buttons each candidate last reported while another
was read, and the reader hands them to the reading as the baseline when
that candidate takes over. A device that reported no buttons before it
took over keeps the held-not-pressed rule.

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-25 01:18:34 +00:00
Victor Santos Oliveira ecf57d131a Make the generated command reference easier to browse (#5237) (#5721)
* claim: #5237

* docs: make the generated command reference easier to browse (#5237)
2026-10-25 01:17:36 +00:00
N3MI-DG 32ad6e0832 Command search: the closed field draws with the ribbon, under system windows (#5804)
The ribbon's command-search field had its own area at `Order::Foreground`, so
it painted above every system window: a Preferences card moved under the top
right corner showed the search field floating on top of it, while the rest of
the ribbon went under the card as it should.

Closed, the field is ribbon chrome and now draws at `Order::Background` with
the rest of the ribbon; open (or animating open or shut), it keeps
`Order::Foreground` so the results sheet still floats over every window.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-10-25 01:17:30 +00:00
Matt Ruhland 13c4a62a71 fix(skills): correct statements that no longer match 0.21.0 (#5899)
On 0.21.0 the facts test failed on rectangle symmetry, which now works
on two sides. Other statements in the modeling and mechanisms skills
also no longer match: edge picks have a distance limit, concentric
circles extrude as a ring, a hole sketch below a part drills into it,
slot ids count on from earlier curves, shell refuses curved blends and
lofts, measure and inspect_design grow with the body count, and
joint.sweep needs occurrences with against.

Each correction has a test in test_facts.py.
2026-10-25 01:17:23 +00:00
bherbruck bba1953623 Fillet/chamfer: find each edge on its own body (#6378)
* Fillet/chamfer: find each edge on its own body

A blend picked over edges of several bodies was evaluated on one body,
the one nearest the first pick, and every edge was named on that body
when the feature was made. Picks on the other bodies were named after
that body's nearest edge and rounded it instead (or moved to it by
position once an upstream edit moved them). With two stacked boxes, a
height change put the upper box's picks on the lower box's rounded
corners: the upper box's fillet resolved there and its own edges lost
their rounds.

Now each edge is named on the body it was picked on, and evaluation
finds each edge's body by its stored name (a gone name stays on the
feature's other bodies), groups the edges by body and blends each body.
A name an older build recorded on the wrong body (the pick lies outside
the box its reference recorded) is not used; the pick finds the edge.
A pick an upstream edit left at an end of its named edge goes to the
edge's middle, so the kernel cannot take the neighbouring edge.

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

* Fillet/chamfer: skip the per-body lookup when there is one body; name each body's picks in one pass

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 00:57:53 +00:00
fugazister 4b119dd471 Performance overlay: frame-time graph and histogram, triangles, draw calls (#2548) (#2549)
* Performance overlay: frame-time graph and histogram, triangles, draw calls (#2548)

Net change of the perf-overlay branch, rebased onto main as one commit.

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

* Document frame_rate_details in ui.prefs

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-24 22:50:43 +00:00
raykholo 6224cbf526 Thread: the refusal for a fine-only size says what to type (#4971) (#4977)
* claim: #4971

* Thread: the refusal for a fine-only size says what to type (#4971)

M28 and other sizes without an ISO coarse pitch were refused as an
unknown thread, with examples of other sizes. Thread and threaded Hole
now say that the size has no coarse pitch and suggest two to give, such
as M28x2 or M28x1.5. The coarse table is unchanged.

* Thread: automatic sizing past M56 says what to type (#5055)

Automatic thread sizing only knows the ISO coarse sizes up to M56, so a
75 mm shaft was refused with "no metric thread fits a 75.00 mm
cylinder", although M75x2 and M75x3 work when typed. Past the end of
the coarse range, the refusal now says so and suggests two sizes with
their pitch, scaled to the diameter (a hole's size adds one pitch to its
bore). The coarse table is unchanged.
2026-10-24 22:49:52 +00:00
dudedagger42 e89a8f56c1 MidPoint: the point goes to the line's middle, whichever is picked first (#5011)
MidPoint on a point and a line keeps the first pick still and moves the second.
With the point picked first (an end of one line, then the line it should sit in
the middle of), the line turned about its far end to bring its middle to the
point: a line from (0,0) to (20,0) went to (0,0)-(10,10) for a point at (5,5).
A point and a line now put the line first, so the point moves and the line
stays; the line still comes to the point when the point can't move.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 22:49:49 +00:00
raykholo 4cd1a51f48 Assistant: a request timeout per endpoint for slow models (#5009) (#5010)
* claim: #5009

* Assistant: a request timeout per endpoint for slow models (#5009)

An OpenAI-compatible endpoint now stores how long one model request may
take (request_timeout_s, 300 s by default, clamped to 30 s ..= 1 h). It
is set in the Add agent dialog and in the endpoint's entry under Agents
(a running conversation uses a change from its next request), through
ui.agent_chat add_agent, and reported in the chat's state. A request
that runs out of time says the limit and where to raise it instead of
"timeout: global". The desktop HTTP cap goes from 900 s to an hour.

The tool-call timeout stays 330 s: it covers SolidCraft's own tool work
and the user's Allow, never the model.

* Assistant settings: line up an endpoint's Request timeout with Permissions

The label column of an agent's entry is wide enough for "Request timeout",
so the label isn't cut and both rows share one column.
2026-10-24 22:24:42 +00:00
raykholo 88f079bd8c Timeline: the tooltip hint is translated (Part of #4370) (#4398)
* claim: #4370

* Timeline: the tooltip hint is translated (Part of #4370)
2026-10-24 22:12:28 +00:00
imanu86 dbdeeec2e9 Undo restores what a hide put away (#2219) (#4780)
* Undo: what is hidden lives in the snapshot, so hiding is a step (#2219)

`Session::execute` records a step only when the document `Arc` changes, and what is
hidden lives in `Session::visibility` — saved with the design, but outside the
document. `browser.visibility` was also declared `.noundo()`, and `Snapshot` had no
field for it, so even a recorded step could not put a hidden body back: undo went to
the command before the hide and left it hidden (#2219).

`Snapshot` and `Checkpoint` now carry the `Visibility`, `go_to` restores it, and
`Session::execute` treats a visibility change as a change of its own. That change is
display-only, so it refreshes the view with a revision bump (`refresh_display`)
instead of evaluating a model a hide does not touch.

The command also takes `folder: "all"` (every body, sketch, construction item, origin
item and joint, plus the browser folders and occurrence hides) so Show All can be one
step, and asking for the state an item already has no longer reorders the hidden
lists: a redundant hide is not a step and leaves no unsaved changes.

* Show/Hide: the menus run browser.visibility, so the history has it (#2219)

The hide and show actions wrote `session.visibility` directly instead of running the
command: the body and face menus (`ui.hide` / `ui.show`), the `V` key
(`context_menu::toggle_visibility`), the browser rows' eyes, the Origin rows and
`ui.originAll` / `ui.originPart`. The engine could record a step and there was
nothing to record — which is why the release still answered "nothing to undo" after
hiding from the Browser.

They all run `browser.visibility` now (the repo rule that the UI acts through
commands), and `ui.showAll` is that one `folder: "all"` call, so Show All is one undo
step rather than three. `docs/occurrence-workflows.md` says what visibility does in
the history.
2026-10-24 22:12:17 +00:00
Sain2d 1dbb13cad2 Korean: translate the 297 messages added since the last pass (#5147)
assets/locales/ko.json had 298 ids that en.json has and it lacks (the
help.solid.* texts, the whole Preferences page, the issue-report dialog,
history names, ribs, mouse gestures and navigation presets). Add Korean for
297 of them; "Of" is left untranslated because its context is unclear.

Wording follows the existing ko.json (필렛, 모따기, 돌출, 쉘, 바디, 피처,
비활성화, 환경 설정, 어시스턴트, 에이전트). The file stays in the canonical
sorted one-message-per-line form; no existing message changed.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 22:11:59 +00:00
raykholo 0b91ff8a34 Measure picks projected and intersection sketch curves (#4337) (#4427)
* claim: #4337

* Measure picks projected and intersection sketch curves (#4337)

Dialog inputs turned away every curve or point of a sketch that is not the
active one, which includes projected and intersection curves, so Measure
took the nearest item it did accept instead. Give Measure explicit
acceptance for these references, keep the source sketch on a click, and
accept them from a pre-selection and a box selection. Other inputs keep
their restrictions.
2026-10-24 22:05:58 +00:00
raykholo 1e81e65e7e Browser tree hover stays fast on a nested STEP assembly (#3892) (#3894)
* claim: #3892

* Browser tree hover stays fast on a nested STEP assembly (#3892)

Hovering a placement row lit its whole subtree by walking it again
(Session::occurrence_bodies) on every frame, and opening a STEP file
makes its top placement active, so every highlight rebuild walked the
whole assembly again for the active placement's bodies. On a nested
assembly each walk resolves every placement below it, which cost
0.5 s or more per walk.

- Session::occurrence_bodies remembers its answers per placement until
  the document, the evaluated model or the pending moves change
  (occurrence_bodies_shared hands out the shared list).
- The highlight looks hovered bodies and faces up by name instead of
  scanning the hovered list once per body.
2026-10-24 21:59:55 +00:00
Christian Svensson 9227685483 Smooth sketch dragging on large imported faces (#3222) (#3480)
* claim: #3222

* Sketch drag: project a face sketch's links once per live gesture (#3222)

Every live sketch.drag frame refreshed the sketch's links before solving
the drag. For a sketch on a face that projects all of the face's edges
again: 115-460 ms per frame on the top face of a 600 x 500 mm plate with
120 holes imported from STEP, against about 1 ms for the drag's own solve.
The model the links come from cannot change during the gesture.

The session now remembers the sketch, document and revision a live frame
left. The gesture's next frame reuses the links while nothing else has
run: any other command (undo included) or any other change to the design
makes the next frame project them again. A test counts the projections.

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

* Sketch drag test: drop an unread assignment (#3222)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 21:53:32 +00:00
N3MI-DG 582e6097be Find in Browser finds the body; Find in Timeline opens the feature's folders (#3883) (#3884)
* claim: #3883

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

* Find in Browser finds the body; Find in Timeline opens the feature's folders (#3883)

A right-click on a body in the viewport picks a face, and the face menu's
Find in Browser selected the face's creating feature in the Features folder
instead of the body. It now reveals and selects the body, as the edge and
body menus already did.

Find in Timeline now opens any collapsed history folders around the feature,
and opens the Browser's Features folder for the picked occurrence, scrolled
to the feature (the reveal formerly behind Find in Browser).

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

* Find in Browser opens collapsed rows above the body and finds later placements (#3883)

Open the document, placement, Bodies and group rows when the command runs, not while the
Bodies folder is drawn, so a collapsed ancestor no longer hides the body. Match the scroll
target by the placed body's world name, and fix the group row key ({scope}/g{id}).

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

* Find in Browser picks the body's own placement; Find in Timeline leaves the Browser alone from rows (#3883)

Every component's first placement keeps the definition's body name, so the root placement
matched before the body's own one and Find in Browser on a first copy opened nothing above it:
the placement must belong to the body's component too.

Find in Timeline from a sketch, construction plane or feature row has no body to say which copy
was meant, so it opened the Features folder of whichever placement came first (the other copy),
or one that never lists sketches. From those rows it now only reveals the feature in the
History; from a face, edge or body it still opens that body's copy's Features folder.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 21:53:27 +00:00
raykholo 22190e703c Revolve, sweep, loft and hole from a nested STEP part's face join and drill it (#3223) (#3987)
With an opened STEP assembly's imported root active (it owns no body), a
Join revolve, sweep or loft from a sketch on a nested part's face made a
separate body, and a hole at the points of such a sketch found no body to
cut. A Join with no targets now takes the body its profile sketch is on,
through the same active scope as an extrude, and a hole counts as a cut
for the rule that a cut from a component with no body works on every body.

Refs #3223
2026-10-24 21:53:23 +00:00
leValdemar19 f7b08ab8c4 Dimension values: #name = value defines a parameter too (#2371) (#4212)
* claim: #2371

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

* Dimension values: `#name = value` defines a parameter too (#2371)

`#width = 40` in a dimension's value now works like `width = 40` (#2916):
it adds the user parameter `width` and dimensions with it. On `main` the
`#` made it an ordinary expression that failed to parse.

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-24 21:53:16 +00:00
TheMiddleCompany ae0b1edeac Coil: a section from a sketch profile (#4236) (#4239)
* claim: #4236

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

* Coil: store an optional sketch and profile selection

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

* Coil: sweep a sketch profile along the helix

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

* Coil: accept a sketch and profile selection

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

* Coil dialog: pick a sketch profile for the section

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

* Coil: cargo fmt

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

* Coil: refuse a loaded sketch coil that also sets the primitive section

A saved design could add section, section_position or start_angle to a
coil that already has a sketch; eval ignored them instead of refusing the
contradiction as the command does.

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

* Coil: assert the kernel refusal and exercise hostile profile selections

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

* Coil dialog: keep the sketch-profile check after rebase

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

* Format

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

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-24 21:53:13 +00:00
Nathalie Schönwetter cf1b0d67cc Extrude by pressing on a face or profile and pulling (#4767)
* feat: extrude a face or profile by pressing on it and pulling

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

* feat: pull-to-extrude keeps the window open and sets the step by sideways motion

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

* Pull to extrude: coarser step changes, a step ladder below the pointer, the step in the distance box

The step label sat on top of the distance box at the arrow tip. It is now a ladder of the
step in use between its neighbours, below the pointer, and the distance box also names the
step ("Step 1 mm"). One step change takes 44 px of sideways travel instead of 22.

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

* Pull to extrude: a setting, off by default (Preferences > Navigation)

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

* Rename the pull setting to Direct interaction

It will also cover other direct gestures in the viewport later (grabbing an edge to round it).

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

---------

Co-authored-by: Nathalie Schönwetter <nathalie@cfw-ag.ch>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 21:52:58 +00:00
Nathalie Schönwetter b702ce8c7d fix: start the window on Intel Macs without wgpu's indirect-call validation (#3024) (#4808)
Co-authored-by: Nathalie Schönwetter <nathalie@cfw-ag.ch>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 21:52:55 +00:00
raykholo 60105172b2 Sketch on a turned occurrence: reuse moved bodies across edits (#4853) (#4863)
* claim: #4853

* Sketch on a turned occurrence: reuse moved bodies across edits (#4853)

A sketch with a face anchor is evaluated in its component's frame.
state_in_frame_at moved every body placed differently from that frame
with a fresh transform on every evaluation, and the face-anchor frame
moved the anchored body again, so each sketch edit on a turned or moved
part transformed and re-meshed it.

The model now keeps the bodies it moved into a feature's frame, keyed on
the source body and the exact matrix (and checked against the source's
mesh, geometry, colour, paint and split lines, as world placement does).
An unchanged body under an unchanged placement is moved and meshed once.
Each evaluation starts a new generation and drops entries that neither
it nor the one before used, so the cache holds only placements in use.
Model states hold a weak reference, so the cache never keeps earlier
states alive.

Fixes #4853
2026-10-24 21:52:52 +00:00
Nathalie Schönwetter e2b8bf5a05 fix: copy a pattern's features in timeline order, not in the order they were picked (#4873)
A circular or rectangular pattern of a hole and the chamfers on its edges failed with
"no edge near ..." when a chamfer was picked before the hole: the chamfer was copied
first, onto a body that had no hole there yet.

Co-authored-by: Nathalie Schönwetter <nathalie@cfw-ag.ch>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 21:52:49 +00:00
dudedagger42 9e065aa0d5 Viewport: keep the sketches' box per world state, not every frame (#5223)
projection() reads the model's box for the scene's depth, several times a
frame, and model_bounds() sampled every sketch's curves for it each time,
hidden sketches too. A 533-feature design with 445 sketches spent about 45 ms
of every frame there (stack samples: model_bounds -> Sketch::bounds -> segs ->
spline polylines), so orbiting ran near 16 fps. The sketches' box is now kept
for the world state it was made from, as the bodies' boxes already are.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 21:39:33 +00:00
raykholo 2fd7b6f08d Measure: a face or edge picked by its point alone (#4099) (#4114)
* claim: #4099

* Measure: a face or edge picked by its point alone (#4099)

inspect.measure (and the MCP measure tool) refused a face or edge item
without an index ("missing field `index`"). Face and edge items are now
read as body + point with an optional index: the point finds the face
(the same lookup face picking uses) or the nearest edge within the pick
tolerance, before the selection is built. A missing body or a point on
no face or edge is a clear error. Explicit indices still work.
2026-10-24 21:27:36 +00:00
raykholo 59795872f6 Extrude: document which way a positive taper goes (#4098) (#4115)
* claim: #4098

* Extrude: document which way a positive taper goes (#4098)

A positive taper slopes the walls outward (the far end is larger along the
extrusion direction), a negative one inward. Say so in the params doc, the
JSON schema and the guide, and pin the sign with a test.
2026-10-24 21:27:34 +00:00
Alexander Puzynia 37b7b0bd3e Offset free-form curves and mixed chains: trim folds, round corners (#2149, #2676, #2761, #3159, #3828) (#4156)
* Offset of free-form curves: trim folds, round chain corners

Offsetting a spline by more than its tightest bend failed with "offset spline
has no interior certificate correspondence": the plain offset folds over itself
there and a single linked spline cannot follow it. A chain of Bézier curves (any
imported SVG outline) was refused outright.

Such an offset is now the parallel boundary: the plain offset of every piece,
joined round each corner by an arc about it, trimmed where it folds or crosses
itself (a sharp corner is left), each kept run fitted with an open spline and
certified to the offset tolerance, and the whole proven free of crossings. Tiny
kinks are joined directly; zero-length Bézier handles (infinite curvature at
the end) are replaced by their chord within a quarter of the tolerance. A
single curve whose plain offset is smooth still gets the linked offset; the
trimmed or chained result is ordinary curves, not linked to the source, so the
command adds no distance parameter for it.

* Offset chains mixing lines or arcs with free-form curves

A chain offset read every curve with Sketch::exact_segs, which only covers free-form
curves, so a chain with straight or circular pieces (a text glyph, an SVG path with
L commands) failed with "offset source changed shape". Read them with exact_pieces,
which gives lines and arcs from their analytic form; the trimming already handles
those pieces.
2026-10-24 21:27:31 +00:00
dudedagger42 7e24f0e35a Sketch: a group too large to solve says so, not "conflicts with" nothing (#5280) (#5347)
The solver takes at most 4000 unknowns in one connected group, and a
pattern joins all its copies into one, so a hexagon patterned 17 × 20
(4000+ unknowns) failed as "that would over-constrain the sketch
(conflicts with )", naming no constraint. The solve report now carries
the size of the largest group it could not take, and the failure says
how many unknowns are joined, the limit, and that fewer copies or
several patterns or sketches stay within it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 21:26:40 +00:00
bherbruck 152efb5774 Windows installer: add the install folder to the system PATH (#6340)
MCP clients configured with "command": "solidcraft-cli" could not find it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 21:26:33 +00:00
rlartot e726f780d1 Sketch: the first dimension scales only a connected drawing (#3786) (#3815)
* claim: #3786

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

* Sketch: the first dimension scales only a connected drawing (#3786)

The first length dimension scaled the whole sketch about its origin, so a
second rectangle that nothing links to the first was resized and moved too.
Now the sketch scales only when its curves form one chain, joined by shared
points or constraints (the origin counts as a point); separate shapes keep
the previous behaviour: the dimension sizes the shape it measures.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 21:21:08 +00:00
Dennis Klappe b201c283c7 Model-cache tests trust an unknown source commit, so they pass without git (#4221)
Fixes #4045
2026-10-24 21:20:56 +00:00
dudedagger42 a2ae3ffbe0 Sketch drag: keep the far end of a tangent line still (#4925)
Dragging one end of a line tangent to a circle or arc let the solver move the
line's other end too, sometimes far ("goes flying"): only the dragged point was
held, and the least-squares step spread the change over the free points. The
drag now first tries holding the line's far end as well, and falls back to the
old solve only when that can't be met. Dragging the end of a tangent line to
(5, 12) moved its other end 0.399 mm on 0.19.0; it now stays put.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 21:20:15 +00:00
Dennis Klappe 96661111f9 OBJ export shares vertices and keeps body names as written (#4462)
Fixes #4451
2026-10-24 21:13:49 +00:00
1493 changed files with 366505 additions and 15662 deletions
+3 -1
View File
@@ -1,5 +1,6 @@
# cargo-nextest settings for CI (`cargo xtask test` does not use nextest).
# CI builds the tests once and runs them in one job on a 32 vCPU runner (.github/workflows/ci.yml).
# CI builds the tests once and runs them in one job on a 32 vCPU runner (.github/workflows/ci.yml),
# except the GPU path tracer's tests (the gpu-path group below), which run in four shards of their own.
[profile.ci]
# Heavy tests (one to twelve minutes each on a CI runner) that pull requests skip. They run on
@@ -72,6 +73,7 @@ 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.
# CI runs these in their own job (GPU path tests in ci.yml), split into count partitions.
[test-groups.gpu-path]
max-threads = 1
+3
View File
@@ -1,3 +1,6 @@
# Preserve canonical source receipts and the runtime icon-discovery snapshot.
.gitattributes text eol=lf
/assets/icons/ui/design/requested_icons.txt text eol=lf
# SolidCraft designs (.sc): a JSON document or a ZIP container, never Scala source, so they
# stay out of GitHub's language statistics. Git's own text/binary detection handles both kinds.
*.sc -linguist-detectable
+3 -1
View File
@@ -6,6 +6,7 @@ body:
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.
Before reporting a boolean or modelling failure, please try to reproduce it on the latest nightly or a build from current `main`, and say which build you used.
- type: textarea
id: what
attributes:
@@ -18,7 +19,8 @@ body:
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.
What you clicked or typed, in order. For a problem on a particular design, `solidcraft-cli script design.sc --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.
Using an AI agent with SolidCraft's MCP server? The `solidcraft-reporting` skill in [`skills/`](https://github.com/tangency-labs/solidcraft/tree/main/skills) has it reproduce the problem in a fresh design and write these steps for you.
placeholder: |
1. Sketch a circle on the XY plane
2. Extrude it 10 mm
+6
View File
@@ -2,6 +2,12 @@
Describe the problem and resulting behavior. Link the issue when applicable.
Changes a default: no
<!-- Required line, replace "no" if this changes what a user gets out of the box (a default value, key binding, setting, tool behaviour):
Changes a default: yes — <what>, old behaviour kept behind preference <name>
Docs-only and bot PRs are exempt. -->
## Validation
What did you run, and what were the results? Include expected and measured values for geometry changes.
+8 -7
View File
@@ -9,9 +9,10 @@ parameter to watch the timeline rebuild. [Documentation](https://docs.solidcraft
[ROADMAP](https://github.com/bherbruck/solidcraft/blob/main/ROADMAP.md#known-limitations) lists
the known limitations. Designs from every earlier version open in this one.
**Upgrading from 0.7 or earlier.** 0.8 changed the Linux app ID to `ai.storyteller.solidcraft`:
`flatpak update` offers to replace the old app, and the `.deb`, `.rpm` and AppImage remove the old
menu entry.
**New app ID.** The app ID is now `io.tangencylabs.solidcraft` (it was `ai.storyteller.solidcraft`,
and `ai.storyteller.solvecraft` up to 0.7): `flatpak update` offers to replace the old app, the
`.deb`, `.rpm` and AppImage remove the old menu entry, and settings stay. On macOS the bundle ID
changed too, so macOS asks once more for the permissions it had granted.
**Upgrading from 0.5 or earlier.** 0.6 renamed SolveCraft to SolidCraft and made the zip
container the default save format. `.solvecraft` designs, old settings folders and
@@ -42,16 +43,16 @@ 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://docs.solidcraft.io/flatpak/solidcraft.flatpakrepo
flatpak install --user solidcraft ai.storyteller.solidcraft
flatpak run ai.storyteller.solidcraft
flatpak install --user solidcraft io.tangencylabs.solidcraft
flatpak run io.tangencylabs.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.
Flatpak installs under an old ID (`ai.storyteller.solidcraft`, or `ai.storyteller.solvecraft` from 0.7 or
earlier): `flatpak update --user` offers to replace it with `io.tangencylabs.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://docs.solidcraft.io/guide/getting-started.html#linux-flatpak).
+315 -28
View File
@@ -9,12 +9,16 @@
# 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.
# checks (no archive to upload and download). The GPU path tracer's tests run one at a time
# (.config/nextest.toml), so they have a job of their own in four shards (GPU path tests). A few
# heavy tests (see .config/nextest.toml) are skipped on pull requests and run on pushes to main and
# nightly. The reference tests and the doc tests run in the merge queue, on main and nightly, not
# on pull requests (the fast lane, in Select checks); a break there is fixed forward on main. The
# Windows jobs (D3D12 shaders and app startup) run nightly, and in the merge queue only for a group
# that changes what they exercise. The wasm compile check runs in the merge queue for a group that
# touches the crates below the apps, the web app or the manifests. Dependency advisories run nightly
# and on any pull request or merge group that changes Cargo.lock or a Cargo.toml. 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.
@@ -30,7 +34,7 @@
# 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.
# per job: 32 vCPU for the tests, 16 for Clippy, the GPU path tests, 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:
@@ -76,8 +80,13 @@ env:
jobs:
dependency-advisories:
# Nightly and by hand: advisories, bans and sources for everything. A pull request or merge
# group that changes Cargo.lock or a Cargo.toml (Select checks' `deps`) checks the advisories
# only, so a dependency with a known vulnerability is caught before it lands, not the next
# morning; other pull requests skip it. `ci` requires it whenever `deps` is true.
name: Dependency advisories
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
needs: changes
if: ${{ !cancelled() && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || needs.changes.outputs.deps == 'true') }}
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 20
steps:
@@ -86,7 +95,12 @@ jobs:
- name: Install advisory scanner
run: cargo install cargo-deny --version 0.20.2 --locked
- name: Check locked dependencies
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
run: cargo deny --locked check --warn unmaintained advisories bans sources
# deny.toml denies unmaintained crates; like the nightly, they are reported, not fatal.
- name: Check advisories for the changed dependencies
if: github.event_name != 'schedule' && github.event_name != 'workflow_dispatch'
run: cargo deny --locked check --warn unmaintained advisories
changes:
name: Select checks
@@ -96,19 +110,27 @@ jobs:
runs-on: blacksmith-2vcpu-ubuntu-2404
permissions:
contents: read
# To ask whether the merge queue already tested a commit pushed to main.
# To ask whether the merge queue already tested a commit pushed to main, and which labels the
# pull requests of a merge group carry (the render-tests opt-in).
pull-requests: read
actions: read
outputs:
native: ${{ steps.paths.outputs.native }}
kernel: ${{ steps.paths.outputs.kernel }}
goldens: ${{ steps.paths.outputs.goldens }}
windows: ${{ steps.paths.outputs.windows }}
# The GPU path tests run (see the rule where it is computed). The job's `if` and the `ci` job's check both read this, so they cannot disagree.
gpu: ${{ steps.paths.outputs.gpu }}
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 }}
# Cargo.lock or a Cargo.toml changed (pull requests and merge groups): Dependency advisories.
deps: ${{ steps.paths.outputs.deps }}
# A merge group reaches the wasm build: Compile WebAssembly.
wasm: ${{ steps.paths.outputs.wasm }}
# The passing run a merge of main carries forward (its URL), when this push is only that.
carried: ${{ steps.paths.outputs.carried }}
steps:
@@ -122,8 +144,11 @@ jobs:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PUSH_SHA: ${{ github.sha }}
GROUP_BASE_SHA: ${{ github.event.merge_group.base_sha }}
GROUP_HEAD_SHA: ${{ github.event.merge_group.head_sha }}
PR_NUMBER: ${{ github.event.pull_request.number }}
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
RENDER_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'render-tests') }}
HEAD_REF: ${{ github.event.pull_request.head.ref }}
REPO: ${{ github.repository }}
APPROVED: ${{ contains(github.event.pull_request.labels.*.name, 'approved-for-ci') }}
@@ -133,6 +158,9 @@ jobs:
kernel=false
goldens=false
windows=false
gpu_paths=false
deps=false
wasm=false
full=true
packages=
# Whether the NUL-separated paths in $1 reach any code: scripts/ci/affected.py selects
@@ -144,6 +172,61 @@ jobs:
full=$(sed -n 's/^full=//p' "$RUNNER_TEMP/ci-affected")
packages=$(sed -n 's/^packages=//p' "$RUNNER_TEMP/ci-affected")
}
# Whether the NUL-separated paths in $1 (the diff $2) reach 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, or a lockfile change that moves the
# graphics stack (wgpu, naga, egui, winit, windows). Pull requests and merge groups share it.
windows_paths() {
while IFS= read -r -d '' path; do
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 < "$1"
if [ "$windows" = false ] && grep -qxz 'Cargo.lock' "$1" \
&& git diff "$2" -- Cargo.lock \
| grep -Eq '^[ +-]name = "(wgpu[a-z_-]*|naga|egui[a-z_-]*|eframe|winit|windows[a-z_-]*|d3d12)"'; then
windows=true
fi
}
# Whether the NUL-separated paths in $1 are render code: the render crate, the path
# tracer and its shaders in the GPU crate, the render workspace UI, the render assets.
# Core, kernel and CI changes do not trigger the path tests (about 56 runner-minutes):
# the tracer reads the scene through interfaces the other tests cover, and the nightly
# runs them for everything.
gpu_paths() {
while IFS= read -r -d '' path; do
case "$path" in
crates/render/*|crates/gpu/src/path/*|crates/ui-egui/src/render_workspace*|assets/render/*) gpu_paths=true ;;
esac
done < "$1"
}
# Whether the NUL-separated paths in $1 change the dependencies (Cargo.lock or any
# Cargo.toml: deps), and whether they reach what `cargo xtask wasm` compiles (wasm): the
# crates below the apps, the web app, the vendored crates, the manifests and lockfile, the
# toolchain and cargo config, and this workflow. #8227 put a native-only call in
# crates/ui-egui and the merge queue passed it, because nothing built for wasm there.
deps_wasm_paths() {
while IFS= read -r -d '' path; do
case "$path" in
Cargo.lock|Cargo.toml|*/Cargo.toml) deps=true ;;
esac
case "$path" in
crates/*|apps/solidcraft-web/*|vendor/*|Cargo.lock|Cargo.toml|rust-toolchain*|.cargo/*|.github/workflows/ci.yml) wasm=true ;;
esac
done < "$1"
}
# Opt-in for a big render change: a pull request labelled render-tests, or a merge group
# holding one (its pull requests are the (#N) of its squash commits), runs them whatever
# the paths say.
group_has_render_label() {
for pr in $(git log --format=%s "$1" | grep -o '(#[0-9]*)$' | tr -d '(#)'); do
if gh api "repos/$REPO/issues/$pr" --jq '.labels[].name' | grep -qx render-tests; then
return 0
fi
done
return 1
}
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
@@ -164,15 +247,36 @@ jobs:
# 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.
# Code changed (or the push can't be compared): main runs everything but Windows.
# The merge queue ran Windows for the changes that reach it, and the nightly runs it
# for everything else (it passed 129 of 129 non-PR runs before this).
full=true
packages=
native=true
windows=true
fi
elif [ "$EVENT_NAME" = merge_group ]; then
native=true
# Windows (about 16 runner-minutes) only for a group that changes what it exercises;
# the nightly runs it whatever changed. A group that can't be compared runs it.
if git diff --no-renames --name-only -z "$GROUP_BASE_SHA...$GROUP_HEAD_SHA" > "$RUNNER_TEMP/ci-paths"; then
windows_paths "$RUNNER_TEMP/ci-paths" "$GROUP_BASE_SHA...$GROUP_HEAD_SHA"
gpu_paths "$RUNNER_TEMP/ci-paths"
deps_wasm_paths "$RUNNER_TEMP/ci-paths"
if [ "$gpu_paths" = false ] && group_has_render_label "$GROUP_BASE_SHA..$GROUP_HEAD_SHA"; then
gpu_paths=true
fi
else
windows=true
gpu_paths=true
deps=true
wasm=true
fi
echo "windows=$windows gpu_paths=$gpu_paths deps=$deps wasm=$wasm for $GROUP_BASE_SHA...$GROUP_HEAD_SHA"
elif [ "$EVENT_NAME" != pull_request ]; then
# The nightly run and Run workflow: everything, Windows included.
native=true
windows=true
gpu_paths=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"
@@ -185,19 +289,13 @@ jobs:
case "$path" in
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
# Computed as for a merge group, then turned off below (the fast lane).
windows_paths "$RUNNER_TEMP/ci-paths" "$BASE_SHA...$HEAD_SHA"
# The advisories run on the pull request itself (it builds nothing of ours: about two minutes, most of it installing cargo-deny);
# the wasm compile waits for the merge queue.
deps_wasm_paths "$RUNNER_TEMP/ci-paths"
wasm=false
# Pull requests build and test the touched packages and their dependents; nothing
# selected means no code changed.
select_packages "$RUNNER_TEMP/ci-paths"
@@ -242,13 +340,35 @@ jobs:
goldens=false
fi
fi
# The GPU path tests (their own job, about 56 runner-minutes) are for render changes only:
# - on a pull request, when it carries the render-tests label (opt-in for a big render
# change) or touches render code (gpu_paths); no other pull request runs them;
# - in the merge queue, when the group's diff touches render code or one of its pull
# requests carries render-tests;
# - in the nightly and by hand, always, so anything the above skipped is caught within a day;
# - not on a push to main: the queue tested that commit, or the nightly will.
gpu=false
if [ "$native" = true ]; then
case "$EVENT_NAME" in
pull_request)
gpu_paths "$RUNNER_TEMP/ci-paths"
gpu=$gpu_paths
if [ "$RENDER_LABEL" = true ]; then gpu=true; fi ;;
merge_group) gpu=$gpu_paths ;;
push) gpu=false ;;
*) gpu=true ;;
esac
fi
{
echo "native=$native"
echo "gpu=$gpu"
echo "kernel=$kernel"
echo "goldens=$goldens"
echo "windows=$windows"
echo "oracle=$oracle"
echo "doctests=$doctests"
echo "deps=$deps"
echo "wasm=$wasm"
echo "carried=$carried"
echo "full=$full"
echo "quick=$quick"
@@ -286,6 +406,13 @@ jobs:
cargo test -p xtask ui_containers --locked
- name: Sorted lists
run: cargo xtask lists
# New UI text must reach every complete catalog; report it here, before review (#8542).
- name: Translations in the complete catalogs
run: cargo xtask i18n --check
- name: Agent router and skills cite real files
run: |
cargo xtask skills
cargo test -p xtask skills --locked
- name: Design format pending changes
run: bash scripts/test-format-pending.sh
- name: Web app offline use and updates (headless Chrome)
@@ -295,7 +422,7 @@ jobs:
- 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
run: node --test scripts/claim/*.test.js scripts/triage/*.test.js scripts/kernel-paused/*.test.js scripts/pr-checks/*.test.js
# Fast feedback for a pull request that has not been approved for the full CI yet.
quick-compile:
@@ -487,8 +614,10 @@ jobs:
scope=(--workspace)
if [ "$FULL" != true ]; then scope=(); for p in $PACKAGES; do scope+=(-p "$p"); done; fi
status=0
# The GPU path tracer's tests run in their own job (GPU path tests): one at a time, they
# took about 36 of this job's 45 minutes.
cargo nextest run "${scope[@]}" --cargo-profile ci --locked --profile ci --no-tests=pass \
--ignore-default-filter -E "$base" || status=1
--ignore-default-filter -E "($base) & not (package(=solidcraft-gpu) & test(/^path::/))" || 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
@@ -508,6 +637,96 @@ jobs:
path: ${{ env.CARGO_TARGET_DIR || 'target' }}/nextest/ci/junit.xml
retention-days: 7
gpu-path-tests:
name: GPU path tests (${{ matrix.shard }}/4)
# The GPU path tracer's tests (about 108) compile their pipelines on lavapipe, one test at a
# time (the gpu-path test group in .config/nextest.toml keeps them within the 600 s pipeline
# setup budget). Serial, they took about 36 minutes, the longest chain in Native Linux tests;
# here four shards run beside it. Their filter is the one Native Linux tests leaves out, so
# every test runs exactly once.
# 16 vCPU, not 8: lavapipe compiles on every core, and on 8 vCPU one test went past the 600 s
# budget (665 s, against 204 s for the same test in another run) and a shard took 35 minutes.
needs: changes
if: ${{ !cancelled() && needs.changes.result == 'success' && needs.changes.outputs.gpu == 'true' }}
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
env:
# As in Native Linux tests: lavapipe draws, and a test without an adapter fails.
SOLIDCRAFT_GPU: software
SOLIDCRAFT_REQUIRE_GPU: "1"
# lavapipe's own shader cache (below).
MESA_SHADER_CACHE_DIR: /home/runner/.cache/mesa-gpu
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.
# The shards build the same thing, so only the first one commits it (and saves the cache).
- name: Mount the compiler cache
uses: useblacksmith/stickydisk@3f6be1451e3cad893b778bc93495c80452a5da26 # v1.7.2
continue-on-error: true
with:
key: ${{ github.repository }}-sccache-gpu
path: /home/runner/.cache/sccache
commit: ${{ github.ref == 'refs/heads/main' && matrix.shard == 1 && 'on-change' || 'false' }}
# The first test in a shard to build the path pipeline compiles it cold on lavapipe (335 to
# 665 s, against the 600 s setup budget); every later one reads Mesa's disk cache (about
# 30 s). Keeping that cache from main's last run lets the first test read it too, until the
# shaders change. Same sticky disk rules as the compiler cache.
- name: Mount lavapipe's shader cache
uses: useblacksmith/stickydisk@3f6be1451e3cad893b778bc93495c80452a5da26 # v1.7.2
continue-on-error: true
with:
key: ${{ github.repository }}-mesa-gpu
path: /home/runner/.cache/mesa-gpu
commit: ${{ github.ref == 'refs/heads/main' && matrix.shard == 1 && '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-gpu
save-if: ${{ github.ref == 'refs/heads/main' && matrix.shard == 1 }}
- 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
- name: Run one shard of the GPU path tests
env:
SHARD: ${{ matrix.shard }}
run: |
# None of these is in the default filter (the heavy tests), so pull requests, the merge
# queue, main and nightly all run every one of them, as before. `count` deals the tests
# out in turn (27 each); `hash` split them 41/40/27 across three shards.
cargo nextest run -p solidcraft-gpu --cargo-profile ci --locked --profile ci --no-tests=pass \
--ignore-default-filter -E 'test(/^path::/)' --partition "count:$SHARD/4"
- 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-gpu-${{ matrix.shard }}
path: ${{ env.CARGO_TARGET_DIR || 'target' }}/nextest/ci/junit.xml
retention-days: 7
action-matrix:
# Every ordinary action on every face, edge, vertex and body of the design corpus, against
# tests/action_matrix/baseline.json (docs/action-matrix.md). Every test run checks a sample;
@@ -769,7 +988,7 @@ jobs:
# 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]
needs: [changes, checks, quick-compile, clippy, build-tests, tests, gpu-path-tests, doc-tests, oracle, windows-fxc, windows-startup, dependency-advisories, wasm]
if: always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft)
runs-on: blacksmith-2vcpu-ubuntu-2404
permissions:
@@ -817,10 +1036,16 @@ jobs:
BUILD: ${{ needs.build-tests.result }}
EVENT_NAME: ${{ github.event_name }}
TESTS: ${{ needs.tests.result }}
GPU_RUN: ${{ needs.changes.outputs.gpu }}
GPU: ${{ needs.gpu-path-tests.result }}
DOCS: ${{ needs.doc-tests.result }}
ORACLE: ${{ needs.oracle.result }}
WINDOWS_FXC: ${{ needs.windows-fxc.result }}
WINDOWS_STARTUP: ${{ needs.windows-startup.result }}
DEPS_RUN: ${{ needs.changes.outputs.deps }}
ADVISORIES: ${{ needs.dependency-advisories.result }}
WASM_RUN: ${{ needs.changes.outputs.wasm }}
WASM: ${{ needs.wasm.result }}
run: |
test "$CHANGES" = success
test "$CHECKS" = success
@@ -836,6 +1061,10 @@ jobs:
fi
test "$TESTS" = success
fi
# The same output decides whether the GPU path tests run (their job's `if`).
if [ "$GPU_RUN" = true ]; then
test "$GPU" = success
fi
if [ "$DOCTESTS" = true ]; then
test "$DOCS" = success
fi
@@ -846,6 +1075,14 @@ jobs:
test "$WINDOWS_FXC" = success
test "$WINDOWS_STARTUP" = success
fi
# Cargo.lock or a Cargo.toml changed on a pull request or in a merge group.
if [ "$DEPS_RUN" = true ]; then
test "$ADVISORIES" = success
fi
# A merge group that reaches the wasm build.
if [ "$WASM_RUN" = true ]; then
test "$WASM" = success
fi
- name: Note a carried result
if: needs.changes.outputs.carried != ''
env:
@@ -853,9 +1090,13 @@ jobs:
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"
# Every crate below the apps and the web app must build for the browser (`cargo xtask wasm`, a
# cargo check, no tests). In the merge queue for a group that reaches the wasm build (Select
# checks' `wasm`), where `ci` requires it; by hand with cross_compile.
wasm:
name: Compile WebAssembly (manual)
if: github.event_name == 'workflow_dispatch' && inputs.cross_compile
name: Compile WebAssembly
needs: changes
if: ${{ !cancelled() && ((github.event_name == 'workflow_dispatch' && inputs.cross_compile) || needs.changes.outputs.wasm == 'true') }}
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 30
steps:
@@ -916,3 +1157,49 @@ jobs:
. "$HOME/.cargo/env"
export CRAFT_FONTS_DIR="$PWD/craft-fonts"
cargo check --all-targets --locked --target riscv64gc-unknown-linux-gnu
# Nobody watches the nightly run, and it failed every day from 2026-10-16 to 2026-10-28 before
# anyone noticed. When a nightly fails, open one issue, "Nightly CI is failing" (label `ci`), or
# comment on it if it is already open; when a nightly passes, close it. A nightly that neither
# failed nor fully passed (cancelled) leaves the issue as it is. The only job that may write issues.
nightly-alert:
name: Nightly alert
needs: [dependency-advisories, changes, checks, ci, build-tests, action-matrix, action-matrix-determinism, property-tests]
if: always() && github.event_name == 'schedule'
runs-on: blacksmith-2vcpu-ubuntu-2404
timeout-minutes: 5
permissions:
issues: write
steps:
- name: Open, update or close the nightly issue
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
RESULTS: ${{ toJSON(needs) }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
SHA: ${{ github.sha }}
run: |
title="Nightly CI is failing"
failed=$(jq -r 'to_entries[] | select(.value.result == "failure") | .key' <<<"$RESULTS")
unfinished=$(jq -r 'to_entries[] | select(.value.result != "success" and .value.result != "failure") | "\(.key) (\(.value.result))"' <<<"$RESULTS")
issue=$(gh issue list --state open --label ci --limit 100 --json number,title \
--jq "[.[] | select(.title == \"$title\")] | first | .number // empty")
if [ -n "$failed" ]; then
body="The nightly run on ${SHA:0:9} failed: $RUN_URL
Failed jobs: $(paste -sd , - <<<"$failed" | sed 's/,/, /g')."
if [ -n "$issue" ]; then
gh issue comment "$issue" --body "$body"
else
gh issue create --title "$title" --label ci --body "$body
This issue is opened and updated by the nightly run (the nightly-alert job in .github/workflows/ci.yml) and closed by the first nightly that passes."
fi
elif [ -z "$unfinished" ]; then
if [ -n "$issue" ]; then
gh issue close "$issue" --comment "The nightly run on ${SHA:0:9} passed: $RUN_URL"
fi
echo "The nightly passed."
else
echo "Nothing failed, but not every job finished: $unfinished. The issue is left as it is."
fi
+42
View File
@@ -0,0 +1,42 @@
# Nightly import checks on real-world STEP files (#1397): the external corpus in
# tests/external-corpus.toml is downloaded (hash-verified), imported headless, and checked
# against tolerance-based expectations — body counts, total volume and area, time and memory
# budgets. It is not part of `cargo xtask ci`: it downloads from the internet.
#
# Import results should improve over time (healing, edge merging, meshing). When a check fails
# because the numbers moved for the better (or the source changed upstream), re-record with
# `cargo xtask external-corpus --bless "why"` in a pull request, never by editing numbers.
name: External corpus
on:
schedule:
- cron: '40 3 * * *'
workflow_dispatch:
inputs:
only:
description: "Run only this corpus entry name (empty = every entry)"
type: string
default: ""
permissions:
contents: read
jobs:
corpus:
name: Import the external STEP corpus
runs-on: ubuntu-24.04
timeout-minutes: 120
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
- name: Build the CLI
run: cargo build --profile ci -p solidcraft-cli
- name: Import and check every entry
env:
ONLY: ${{ inputs.only }}
run: |
if [ -z "$ONLY" ]; then
cargo run -q -p xtask -- external-corpus --cli target/ci/solidcraft-cli
else
cargo run -q -p xtask -- external-corpus --cli target/ci/solidcraft-cli --only "$ONLY"
fi
+9 -3
View File
@@ -1,6 +1,8 @@
# 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`).
# Pending files (assets/locales/pending, attribution/pending) are folded into the catalogs and
# ATTRIBUTION.md by `cargo xtask fold-pending`.
name: Generated files
on:
@@ -17,6 +19,8 @@ on:
- "crates/engine/**"
- "apps/solidcraft-cli/**"
- "xtask/**"
- "assets/locales/pending/*.json"
- "attribution/pending/*.md"
- ".github/workflows/generated.yml"
workflow_dispatch:
@@ -48,13 +52,15 @@ jobs:
run: cargo xtask licences
- name: docs/features.md
run: cargo xtask features
- name: Pending locale strings and attribution rows
run: cargo xtask fold-pending
- name: Commit if changed
run: |
if git diff --quiet -- THIRD-PARTY-LICENSES.txt docs/features.md; then echo "unchanged"; exit 0; fi
if [ -z "$(git status --porcelain)" ]; 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]"
git add -A THIRD-PARTY-LICENSES.txt docs/features.md assets/locales ATTRIBUTION.md attribution
git commit -m "Regenerate generated files and fold pending files [skip ci]"
for _ in 1 2 3; do
git pull --rebase origin main && git push origin HEAD:main && exit 0
sleep 10
+1 -1
View File
@@ -16,7 +16,7 @@ on:
permissions:
contents: read # to check out scripts/kernel-paused
issues: write
pull-requests: read # to list a PR's changed paths
pull-requests: write # to list a PR's changed paths and add its label
discussions: write
concurrency:
+11 -7
View File
@@ -39,6 +39,8 @@ jobs:
run: python3 packaging/tests/nightly_test.py
- name: Windows executable icon validation regression
run: python3 packaging/tests/windows_icon_test.py
- name: AppImage CLI wrapper regression
run: python3 packaging/tests/apprun_test.py
- name: Flatpak desktop smoke validation regression
run: python3 packaging/tests/flatpak_test.py
- name: PowerShell
@@ -53,13 +55,15 @@ jobs:
- name: XML, desktop entry, AppStream, Flatpak manifest
run: |
xmllint --noout assets/platforms/windows/solidcraft.wxs assets/platforms/windows/solidcraft.en-us.wxl assets/platforms/macos/*.plist* assets/platforms/linux/*.xml*
desktop-file-validate assets/platforms/linux/ai.storyteller.solidcraft.desktop
# The window's X11 class is its app id; the old id stays only as the rename shims.
grep -qx 'StartupWMClass=ai.storyteller.solidcraft' assets/platforms/linux/ai.storyteller.solidcraft.desktop
grep -q '<id>ai.storyteller.solvecraft</id>' assets/platforms/linux/ai.storyteller.solidcraft.metainfo.xml.in
desktop-file-validate assets/platforms/linux/io.tangencylabs.solidcraft.desktop
# The window's X11 class is its app id; the old ids stay only as the rename shims.
grep -qx 'StartupWMClass=io.tangencylabs.solidcraft' assets/platforms/linux/io.tangencylabs.solidcraft.desktop
grep -q '<id>ai.storyteller.solidcraft</id>' assets/platforms/linux/io.tangencylabs.solidcraft.metainfo.xml.in
grep -q '<id>ai.storyteller.solvecraft</id>' assets/platforms/linux/io.tangencylabs.solidcraft.metainfo.xml.in
grep -qx 'X-Flatpak-RenamedFrom=ai.storyteller.solidcraft.desktop;ai.storyteller.solvecraft.desktop;' assets/platforms/linux/io.tangencylabs.solidcraft.desktop
grep -qF -e '--end-of-life-rebase=' packaging/flatpak-site.sh
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' assets/platforms/linux/ai.storyteller.solidcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.solidcraft.metainfo.xml"
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.solidcraft.metainfo.xml"
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.solidcraft.bundle.yml')); assert m['id'] == 'ai.storyteller.solidcraft' and m['command'] == 'solidcraft'"
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' assets/platforms/linux/io.tangencylabs.solidcraft.metainfo.xml.in >"$RUNNER_TEMP/io.tangencylabs.solidcraft.metainfo.xml"
appstreamcli validate --no-net --explain "$RUNNER_TEMP/io.tangencylabs.solidcraft.metainfo.xml"
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/io.tangencylabs.solidcraft.bundle.yml')); assert m['id'] == 'io.tangencylabs.solidcraft' and m['command'] == 'solidcraft'"
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/snap/snapcraft.yaml')); assert m['name'] == 'solidcraft' and m['confinement'] == 'strict' and m['apps']['solidcraft']['command'] == 'usr/bin/solidcraft'"
python3 -m py_compile packaging/linux/snap-smoke.py
+1
View File
@@ -65,6 +65,7 @@ jobs:
run: |
mkdir -p site
cp -r target/book/. site/
bash docs/book/seo.sh site # canonical links, robots.txt, sitemap.xml
cp -r dist/web site/app
- uses: actions/configure-pages@v5
id: pages
+70
View File
@@ -0,0 +1,70 @@
# Two quick checks on every pull request (CONTRIBUTING.md, "Sending a change"): no second open PR
# for an issue a lower-numbered one already closes, and the "Changes a default:" line is filled in.
# Neither is a required status check. The logic is in scripts/pr-checks/pr-checks.js (tested in ci.yml).
name: PR checks
on:
# pull_request, never pull_request_target: nothing here checks out or runs the PR's code, and the
# token is read-only for fork PRs. Only scripts/pr-checks from the PR's own merge commit is run.
pull_request:
types: [opened, edited, synchronize, reopened, ready_for_review, labeled, unlabeled]
permissions:
contents: read
pull-requests: read
concurrency:
group: pr-checks-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
duplicate:
name: Duplicate PR check
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
sparse-checkout: scripts/pr-checks
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const checks = require('./scripts/pr-checks/pr-checks.js');
const r = await checks.checkDuplicate({ github, context, core });
core.info(JSON.stringify(r));
defaults:
name: Defaults line check
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
sparse-checkout: scripts/pr-checks
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const checks = require('./scripts/pr-checks/pr-checks.js');
const r = await checks.checkDefaults({ github, context, core });
core.info(JSON.stringify(r));
# Labelling needs write access, which a fork PR's token never has, so it is its own job and best
# effort: a fork PR just doesn't get the `changes-default` label from here.
label:
name: Label changes-default
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-24.04
timeout-minutes: 5
continue-on-error: true
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
sparse-checkout: scripts/pr-checks
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const checks = require('./scripts/pr-checks/pr-checks.js');
await checks.syncDefaultLabel({ github, context, core });
+230 -11
View File
@@ -4,11 +4,15 @@
# comes from [workspace.package] in Cargo.toml. An agent verifies the artifacts and publishes
# the draft separately; see docs/releasing.md. Tags and pushes to main do not trigger releases.
# At midnight Eastern, changed main commits build and publish a nightly pre-release.
# A version with a suffix (0.2.0-rc.1) is a pre-release. Nothing is signed yet.
# A version with a suffix (0.2.0-rc.1) is a pre-release.
# The macOS app is signed and notarized when the APPLE_* secrets exist. The Windows binaries and
# MSI are Authenticode-signed through SignPath Foundation when the SIGNPATH_API_TOKEN secret exists
# (see the windows job). Nothing else is signed yet.
#
# Linux x86_64, aarch64 AppImage (+ .zsync), .deb, .rpm, .flatpak, .snap, .tar.gz packaging/linux
# Arch Linux x86_64, aarch64 .pkg.tar.zst, repackaged from the .tar.gz the arch job
# FreeBSD x86_64 .tar.gz (built in a FreeBSD VM) packaging/freebsd
# macOS universal .dmg, and the CLI as a .zip packaging/macos
# macOS universal .dmg, and the CLI as a .zip (both archs built in parallel) packaging/macos
# Windows x64, x86, arm64 .msi and a portable .zip; x64 also the plain .exe packaging/windows
# Web a .zip of the static site packaging/web
#
@@ -17,6 +21,8 @@
# releases/latest/download/<name> links in the README keep working across releases.
#
# A dry run: dispatch with draft=false (the default). Only the release branch may create drafts.
# A dry run from any branch also test-signs the Windows files when SIGNPATH_API_TOKEN exists:
# gh workflow run release.yml --ref <branch> -f draft=false [-f signpath_signing_policy=test-signing]
name: Release
on:
@@ -31,6 +37,10 @@ on:
description: "Create/update a draft (release branch only; off: build artifacts only)"
type: boolean
default: false
signpath_signing_policy:
description: "SignPath signing policy for the Windows files (empty: the repository variable, else test-signing)"
type: string
default: ""
permissions:
contents: read
@@ -197,6 +207,45 @@ jobs:
path: dist/release/*.flatpak
if-no-files-found: error
arch:
name: Arch Linux ${{ matrix.arch }}
needs: [version, linux]
if: needs.version.outputs.build == 'true'
# makepkg only repackages the tarball's prebuilt binaries, so both architectures build here.
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
arch: [x86_64, aarch64]
env:
VERSION: ${{ needs.version.outputs.version }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: release-linux-${{ matrix.arch }}
path: dist/linux
# The Craft apps' shared Arch packaging (storytold/craft-common-actions, MIT OR Apache-2.0):
# makepkg in an archlinux:base-devel container. The package is named after the tarball's
# .desktop file (io.tangencylabs.solidcraft) and written as
# dist/release/solidcraft-<version>-linux-<arch>.pkg.tar.zst.
- uses: storytold/craft-common-actions/actions/arch-package@fa5ef18780cf5e7d5d3b8de068de56cffc83cac5 # main
with:
tarball: dist/linux/solidcraft-${{ env.VERSION }}-linux-${{ matrix.arch }}.tar.gz
arch: ${{ matrix.arch }}
- name: Check the package
env:
PKG: dist/release/solidcraft-${{ env.VERSION }}-linux-${{ matrix.arch }}.pkg.tar.zst
run: |
tar --zstd -tf "$PKG" | grep -E '^usr/bin/solidcraft$|^usr/bin/solidcraft-cli$|^usr/share/applications/io\.tangencylabs\.solidcraft\.desktop$'
tar --zstd -xOf "$PKG" .PKGINFO | grep -E '^(pkgname|pkgver|arch) = '
tar --zstd -xOf "$PKG" .PKGINFO | grep -qx 'pkgname = io.tangencylabs.solidcraft'
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-arch-${{ matrix.arch }}
path: dist/release/*.pkg.tar.zst
if-no-files-found: error
snap:
@@ -288,18 +337,26 @@ jobs:
export CARGO_TARGET_DIR=/var/tmp/solidcraft-target CARGO_BUILD_JOBS=4
bash packaging/freebsd/package.sh
tar -tzf dist/release/solidcraft-*-freebsd-x86_64.tar.gz \
| grep -E '/bin/solidcraft$|/bin/solidcraft-cli$|/share/applications/ai\.storyteller\.solvecraft\.desktop$|/share/doc/solidcraft/THIRD-PARTY-LICENSES\.txt$'
| grep -E '/bin/solidcraft$|/bin/solidcraft-cli$|/share/applications/io\.tangencylabs\.solidcraft\.desktop$|/share/doc/solidcraft/THIRD-PARTY-LICENSES\.txt$'
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-freebsd-x86_64
path: dist/release/*
if-no-files-found: error
macos:
name: macOS universal
# The two halves of the universal binary build in parallel on their own runners (each alone
# took about half of the old single job's 45 minutes); `macos` below joins and packages them.
macos-build:
name: macOS build ${{ matrix.arch }}
needs: version
if: needs.version.outputs.build == 'true'
runs-on: macos-14
strategy:
fail-fast: false
matrix:
include:
- { arch: aarch64, target: aarch64-apple-darwin }
- { arch: x86_64, target: x86_64-apple-darwin }
env:
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
@@ -307,12 +364,58 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin, x86_64-apple-darwin
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-macos-universal
- name: Build and package (dmg, CLI zip; the dmg is mounted and checked)
run: packaging/macos/package.sh --arch universal
key: release-macos-${{ matrix.arch }}
- name: Build
run: packaging/macos/package.sh --build-only ${{ matrix.target }}
# Not named release-*: the release job collects every release-* artifact as an asset.
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: macos-bin-${{ matrix.arch }}
path: |
target/${{ matrix.target }}/release/solidcraft
target/${{ matrix.target }}/release/solidcraft-cli
target/${{ matrix.target }}/release/solvecraft-cli
if-no-files-found: error
macos:
name: macOS universal
needs: [version, macos-build]
if: needs.version.outputs.build == 'true'
runs-on: macos-14
env:
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: macos-bin-aarch64
path: target/aarch64-apple-darwin/release
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: macos-bin-x86_64
path: target/x86_64-apple-darwin/release
- name: Set up Developer ID signing and notarization
# Optional until the APPLE_* secrets exist (docs/releasing.md, "macOS signing"). Without
# them (forks, dry runs) the app keeps its ad-hoc signature and is not notarized.
env:
APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
run: packaging/macos/signing.sh --setup
- name: Join, sign, package and check (dmg, CLI zip; the dmg is mounted and checked)
run: |
# Artifacts don't keep the executable bit.
chmod +x target/*-apple-darwin/release/solidcraft target/*-apple-darwin/release/solidcraft-cli target/*-apple-darwin/release/solvecraft-cli
packaging/macos/package.sh --arch universal --skip-build ${MACOS_SIGN_IDENTITY:+--sign}
- name: Remove the signing keychain and API key
if: always()
run: packaging/macos/signing.sh --cleanup
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-macos
@@ -323,7 +426,14 @@ jobs:
name: Windows ${{ matrix.arch }}
needs: [version, icons]
if: needs.version.outputs.build == 'true'
# Must stay a GitHub-hosted runner, like every job before it: SignPath's open-source policy
# only signs builds whose jobs all ran on GitHub-hosted runners.
runs-on: windows-latest
# actions: read lets the SignPath action read this job's details and fetch the uploaded
# artifact with GITHUB_TOKEN (the workflow default above grants contents: read only).
permissions:
contents: read
actions: read
strategy:
fail-fast: false
matrix:
@@ -334,6 +444,16 @@ jobs:
- { arch: arm64, target: aarch64-pc-windows-msvc }
env:
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
# Authenticode signing through SignPath Foundation (docs/releasing.md, "Windows signing").
# Only when the API token secret exists: forks and repositories without it build unsigned,
# exactly as before. `if:` can't read secrets, so the check goes through this variable.
SIGN_WINDOWS: ${{ secrets.SIGNPATH_API_TOKEN != '' }}
# The one switch between the self-signed test certificate (test-signing) and the real one
# (release-signing): a dispatch input, else the SIGNPATH_SIGNING_POLICY repository variable,
# else test-signing.
SIGNPATH_SIGNING_POLICY: ${{ inputs.signpath_signing_policy || vars.SIGNPATH_SIGNING_POLICY || 'test-signing' }}
SIGNPATH_ORGANIZATION_ID: fa603ff4-c702-404b-8588-a524371c7a4e
SIGNPATH_PROJECT_SLUG: solidcraft
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
@@ -352,10 +472,109 @@ jobs:
dotnet tool install --global wix --version 5.0.2
wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
- name: Build and package (msi, portable zip)
if: env.SIGN_WINDOWS != 'true'
shell: pwsh
env:
SOLIDCRAFT_ICONS: ${{ github.workspace }}/icons
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
# ---- signed path ---------------------------------------------------------------------------
# SignPath's GitHub integration (docs.signpath.io/trusted-build-systems/github): the files go
# up as a workflow artifact, SignPath fetches that artifact itself (so it can verify it came
# from this workflow run), signs it, and the action downloads the signed copy. GitHub stores
# artifacts as zips, so packaging/windows/signpath-artifact-configuration.xml (pasted into the
# SignPath project) describes a zip holding the files at its top level.
# Two rounds: the .exe files first, so the MSI, the portable zip and the plain .exe all carry
# signed binaries; then the MSI built from them.
- name: Build and stage the binaries for signing
if: env.SIGN_WINDOWS == 'true'
shell: pwsh
env:
SOLIDCRAFT_ICONS: ${{ github.workspace }}/icons
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }} -Phase stage
- name: Upload the unsigned binaries
id: unsigned-exes
if: env.SIGN_WINDOWS == 'true'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: signpath-unsigned-exes-windows-${{ matrix.arch }}
path: target/windows-package/${{ matrix.arch }}/*.exe
if-no-files-found: error
retention-days: 3
- name: Sign the binaries (SignPath, ${{ env.SIGNPATH_SIGNING_POLICY }})
if: env.SIGN_WINDOWS == 'true'
uses: signpath/github-action-submit-signing-request@f6d04783b4569d051e0c80105fe66e82819d0092 # v3.0
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: ${{ env.SIGNPATH_ORGANIZATION_ID }}
project-slug: ${{ env.SIGNPATH_PROJECT_SLUG }}
signing-policy-slug: ${{ env.SIGNPATH_SIGNING_POLICY }}
github-artifact-id: ${{ steps.unsigned-exes.outputs.artifact-id }}
wait-for-completion: true
# release-signing requests wait for a person to approve them in SignPath; give that an
# hour instead of the default 10 minutes. test-signing requests finish in seconds.
wait-for-completion-timeout-in-seconds: 3600
output-artifact-directory: target/windows-package/signed-exes-${{ matrix.arch }}
- name: Put the signed binaries in place
if: env.SIGN_WINDOWS == 'true'
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$stage = "target/windows-package/${{ matrix.arch }}"
foreach ($exe in 'solidcraft.exe', 'solidcraft-cli.exe', 'solvecraft-cli.exe') {
$signed = "target/windows-package/signed-exes-${{ matrix.arch }}/$exe"
# The artifact configuration matches each file optionally, so check that every file
# actually came back signed rather than trusting the request's success.
$sig = Get-AuthenticodeSignature $signed
if (-not $sig.SignerCertificate) { throw "$exe came back from SignPath without a signature ($($sig.Status))" }
# The test certificate is self-signed, so Status is not Valid until release-signing.
Write-Output "$exe signed by $($sig.SignerCertificate.Subject) (status: $($sig.Status))"
Copy-Item -Force $signed "$stage/$exe"
}
- name: Package the signed binaries (msi, portable zip)
if: env.SIGN_WINDOWS == 'true'
shell: pwsh
env:
SOLIDCRAFT_ICONS: ${{ github.workspace }}/icons
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }} -Phase package
- name: Upload the unsigned MSI
id: unsigned-msi
if: env.SIGN_WINDOWS == 'true'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: signpath-unsigned-msi-windows-${{ matrix.arch }}
path: dist/release/*.msi
if-no-files-found: error
retention-days: 3
- name: Sign the MSI (SignPath, ${{ env.SIGNPATH_SIGNING_POLICY }})
if: env.SIGN_WINDOWS == 'true'
uses: signpath/github-action-submit-signing-request@f6d04783b4569d051e0c80105fe66e82819d0092 # v3.0
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: ${{ env.SIGNPATH_ORGANIZATION_ID }}
project-slug: ${{ env.SIGNPATH_PROJECT_SLUG }}
signing-policy-slug: ${{ env.SIGNPATH_SIGNING_POLICY }}
github-artifact-id: ${{ steps.unsigned-msi.outputs.artifact-id }}
wait-for-completion: true
wait-for-completion-timeout-in-seconds: 3600
output-artifact-directory: target/windows-package/signed-msi-${{ matrix.arch }}
- name: Put the signed MSI in place
if: env.SIGN_WINDOWS == 'true'
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$msi = Get-Item "dist/release/*.msi"
$signed = "target/windows-package/signed-msi-${{ matrix.arch }}/$($msi.Name)"
$sig = Get-AuthenticodeSignature $signed
if (-not $sig.SignerCertificate) { throw "$($msi.Name) came back from SignPath without a signature ($($sig.Status))" }
Write-Output "$($msi.Name) signed by $($sig.SignerCertificate.Subject) (status: $($sig.Status))"
Copy-Item -Force $signed $msi.FullName
"Windows ${{ matrix.arch }}: Authenticode-signed through SignPath (policy $env:SIGNPATH_SIGNING_POLICY, $($sig.SignerCertificate.Subject))." >> $env:GITHUB_STEP_SUMMARY
- name: Note the unsigned build
if: env.SIGN_WINDOWS != 'true'
shell: pwsh
run: '"Windows ${{ matrix.arch }}: unsigned (no SIGNPATH_API_TOKEN secret)." >> $env:GITHUB_STEP_SUMMARY'
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-windows-${{ matrix.arch }}
@@ -392,7 +611,7 @@ jobs:
release:
name: GitHub Release or dry-run artifacts
needs: [version, linux, flatpak, snap, freebsd, macos, windows, web]
needs: [version, linux, flatpak, arch, snap, freebsd, macos, windows, web]
if: needs.version.outputs.build == 'true'
runs-on: ubuntu-latest
permissions:
@@ -418,7 +637,7 @@ jobs:
shopt -s nullglob
for f in "solidcraft-$VERSION-"*; do
case "$f" in
*.msi | *-portable.zip | *.dmg | *.AppImage | *.deb | *.rpm | *.flatpak | *.snap)
*.msi | *-portable.zip | *.dmg | *.AppImage | *.deb | *.rpm | *.flatpak | *.snap | *.pkg.tar.zst)
cp -- "$f" "solidcraft-${f#"solidcraft-$VERSION-"}" ;;
esac
done
+4
View File
@@ -11,3 +11,7 @@ __pycache__/
# cargo-mutants output (docs/mutation-testing.md)
mutants.out*/
# external corpus downloads (cargo xtask external-corpus, #1397): fetched at test time,
# hash-pinned in tests/external-corpus.toml; never commit case data
/corpus/
+43 -212
View File
@@ -1,216 +1,47 @@
# SolidCraft — instructions for agents
# SolidCraft: instructions for agents
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.
SolidCraft is a clean-room, pure-Rust parametric 3D CAD app (sketch solver, feature timeline, B-rep kernel, an egui UI that agents drive). This file is a router: the hard rules, then the skill to read for your task. Humans: CONTRIBUTING.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 here
Work: ROADMAP.md, "Where help is wanted most" in CONTRIBUTING.md, issues labelled `help wanted`, `good first issue`, `needs-triage`; architecture in `docs/architecture.md`. Act when the code, docs and rules settle a question; ask only when it changes what users see. Local `plan/` notes are optional.
## 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.
## Hard rules (what enforces each)
- Independent: never copy another product's code, icons, text or templates; never drive or take data from commercial CAD. `skills/solidcraft-ground-rules/SKILL.md`.
- No GPL/LGPL/AGPL code, not even reading it (OpenCASCADE, FreeCAD, SolveSpace, CadQuery, LibreCAD, QCAD): `cargo xtask licences --check`.
- Every asset is original or openly licensed with a row in ATTRIBUTION.md; read `assets/AGENTS.md` before icon work: `cargo xtask assets`, `cargo xtask icons`.
- Never crash: no unwrap/expect/panic/unsafe in non-test code (unsafe allowlist in crash-safety), errors are `Result`, input is hostile: clippy `-D warnings`, `hostile_params_never_panic`.
- Everything is a command and programmatic calls never open dialogs: `skills/solidcraft-commands/SKILL.md`.
- Layering (geom, sketch/kernel/render, doc, io, engine, ui-egui, apps); only `crates/kernel` uses truck: `cargo xtask layers`.
- One outer UI container, one camera path (`navigation::handle`), colours from theme tokens: `cargo xtask ui-containers`.
- Application code is Rust only and builds for wasm: `cargo xtask wasm`, `cargo xtask web-offline-test`.
- The product name is SolidCraft; the old name lives only in `docs/compatibility.md`.
- Sorted lists (message catalogs, `mod` lines, languages) stay sorted: `cargo xtask lists`. Never edit generated files (`docs/features.md`, `THIRD-PARTY-LICENSES.txt`) in a PR.
- A design-format change is ONE new file in `crates/doc/src/format/pending/`: `scripts/test-format-pending.sh`.
- A command has input and output schemas (will become required; `schema/unconverted.txt` only shrinks): `skills/solidcraft-commands/SKILL.md`.
- New UI strings go in `assets/locales/pending/<slug>.json`, ATTRIBUTION rows in `attribution/pending/<slug>.md`: `cargo xtask lists`.
- A changed default keeps the old behaviour behind a preference and fills the PR's `Changes a default:` line, and no second PR for an issue a lower-numbered one closes: `pr-checks.yml`.
- A regression test goes in the area's existing test file, never a per-bug file (#3226).
- Look at open PRs first, claim with a draft PR (lowest number wins), one topic per PR, never force-push `main`, no pushes just to catch up with `main`: `skills/solidcraft-contributing/SKILL.md`.
- Show observable changes with real screenshots or a recording, captured yourself.
- Run `cargo xtask ci` before every commit, without switching branches while it runs. `cargo xtask skills` keeps this file and `skills/` honest.
`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 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), 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) 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
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.
- **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)]`.
## 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.** 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/*`
(`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`.
- **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).
- **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**: 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`).
## Showing and following through on changes
- Help reviewers see what is broken or changed. For observable CAD, UI or model behavior,
issues and PRs must include actual descriptive screenshots or a short recording from a
small reproduction. This includes kernel, geometry, assembly and backend changes whose
effects can be shown in the app. Coding agents should run the app and capture this evidence
themselves; do not ask a human for evidence the agent can produce. Show the actual failure
or result, preferably before and after, with brief reproduction steps, expected/actual
behavior and annotations that point out the relevant detail.
- Flowcharts and Mermaid diagrams cannot substitute for screenshots or recordings of visible
behavior. Use diagrams for architectural reasoning when actual screenshots cannot illustrate
the point. Changes with no useful observable behavior do not need screenshot or video evidence.
- 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 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
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. `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.
## Working on X: read the skill first
| Working on | Read |
|---|---|
| A PR, a claim, review, CI or evidence | `skills/solidcraft-contributing/SKILL.md` |
| Licences, assets, clean-room, the name | `skills/solidcraft-ground-rules/SKILL.md` |
| Non-test Rust, hostile input, a crash report | `skills/solidcraft-crash-safety/SKILL.md` |
| A command, a crate boundary | `skills/solidcraft-commands/SKILL.md` |
| A dialog, panel, camera, icon, colour | `skills/solidcraft-ui/SKILL.md` |
| Labels, messages, translations | `skills/solidcraft-ui-text/SKILL.md` |
| Saving something new in a design, import, export | `skills/solidcraft-file-formats/SKILL.md` |
| Preferences, a changed default | `skills/solidcraft-preferences/SKILL.md` |
| Writing or running tests | `skills/solidcraft-tests/SKILL.md` |
| Slow work, the window thread, wasm | `skills/solidcraft-performance/SKILL.md` |
| Releases, installers, packages | `skills/solidcraft-packaging/SKILL.md` |
| Running the app, control channel, MCP tools | `skills/solidcraft-agent-tools/SKILL.md` |
| Building a part over MCP | `skills/solidcraft-modeling/SKILL.md` |
| Changing an existing design | `skills/solidcraft-editing/SKILL.md` |
| A bug report, a crash, a wrong result | `skills/solidcraft-reporting/SKILL.md` |
| Checking, porting, designs in git | `skills/solidcraft-checking/SKILL.md` |
| Assemblies, joints, motion | `skills/solidcraft-mechanisms/SKILL.md` |
+142 -19
View File
@@ -51,10 +51,10 @@ Editable drawing code, native projects and shipped exports are collected under `
| 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/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 |
| docs/screenshots/class-a-blend.png | SolidCraft contributors | screenshot of SolidCraft itself: a 24 mm and a 10 mm box bridged by a `surface.blend` sheet (G1 at both edges), rendered by the app's `ui.render` in the dark theme with examples/demo/class_a_shots.py | 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 skew four-line rim, rendered by the app's `ui.render` in the dark theme with examples/demo/class_a_shots.py | MIT OR Apache-2.0 |
| docs/screenshots/class-a-boundary.png | SolidCraft contributors | screenshot of SolidCraft itself: a surface through two 3D sketch splines built by `surface.boundary`, rendered by the app's `ui.render` in the dark theme with examples/demo/class_a_shots.py | MIT OR Apache-2.0 |
| docs/screenshots/sample-plate.png | SolidCraft contributors | screenshot of SolidCraft 0.22.1 itself (`solidcraft --sample`, default dark theme, 1600x1000) | 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 |
@@ -62,10 +62,10 @@ Editable drawing code, native projects and shipped exports are collected under `
| 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/screenshots/demo.gif | SolidCraft contributors | original screen recording of the actual SolidCraft 0.22.1 English desktop UI (default dark theme, software renderer) 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 (6× playback, idle pauses cut) | MIT OR Apache-2.0 |
| docs/screenshots/demo.mp4 | SolidCraft contributors | original screen recording of the actual SolidCraft 0.22.1 English desktop UI (default dark theme, software renderer) 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 (6× playback, idle pauses cut) | MIT OR Apache-2.0 |
| docs/screenshots/rover-build.gif | 0xlillith (model); SolidCraft contributors (recording) | screen recording of the actual SolidCraft 0.22.1 desktop UI (default dark theme, software renderer) 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 (15× playback, idle pauses cut, constant-rate camera orbit) | MIT OR Apache-2.0 |
| docs/screenshots/rover-build.mp4 | 0xlillith (model); SolidCraft contributors (recording) | screen recording of the actual SolidCraft 0.22.1 desktop UI (default dark theme, software renderer) 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 |
@@ -84,6 +84,8 @@ Editable drawing code, native projects and shipped exports are collected under `
| 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 |
| assets/fonts/craft-ui-sc/CraftUI-SC.otf | Adobe / Google Inc. (Noto Sans SC); SolidCraft subset | https://github.com/notofonts/noto-cjk Sans/SubsetOTF/SC Regular, glyph-subset for assets/locales/zh.json and renamed Craft UI SC (OFL reserved-name); licence text in assets/fonts/craft-ui-sc/OFL.txt; web UI Simplified Chinese | SIL OFL 1.1 |
| assets/fonts/craft-ui-tc/CraftUI-TC.otf | Adobe / Google Inc. (Noto Sans TC); SolidCraft subset | https://github.com/notofonts/noto-cjk Sans/SubsetOTF/TC Regular, glyph-subset for assets/locales/zh-TW.json and renamed Craft UI TC (OFL reserved-name); licence text in assets/fonts/craft-ui-tc/OFL.txt; web UI Traditional Chinese | 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 |
@@ -101,6 +103,7 @@ Editable drawing code, native projects and shipped exports are collected under `
| 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/step-regressions/rod_doubled_edge.step | SolidCraft contributors | written by SolidCraft from the commands of #4598 (a bored rod moved 100 mm and exported); import regression fixture | MIT OR Apache-2.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 |
@@ -124,17 +127,19 @@ Editable drawing code, native projects and shipped exports are collected under `
| assets/appearances/library.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/appearances/opaque_finishes.rs | SolidCraft contributors | original illustrative opaque finish values; no external dataset, textures or measured engineering data | 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/craft-ui-sc/OFL.txt | Adobe / Google Inc. (Noto CJK); SolidCraft header | Accompanying OFL for CraftUI-SC.otf | SIL OFL 1.1 |
| assets/fonts/craft-ui-tc/OFL.txt | Adobe / Google Inc. (Noto CJK); SolidCraft header | Accompanying OFL for CraftUI-TC.otf | 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 |
| assets/icons/app/linux/exports/hicolor/24x24/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/256x256/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/32x32/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/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/linux/exports/hicolor/128x128/apps/io.tangencylabs.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/io.tangencylabs.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/24x24/apps/io.tangencylabs.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/256x256/apps/io.tangencylabs.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/32x32/apps/io.tangencylabs.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/48x48/apps/io.tangencylabs.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/io.tangencylabs.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/io.tangencylabs.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 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 |
@@ -157,6 +162,7 @@ Editable drawing code, native projects and shipped exports are collected under `
| 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/nb.json | SolidCraft contributors | Original Norwegian Bokmål translation of SolidCraft's English catalog; no commercial CAD text used | 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 |
@@ -169,9 +175,13 @@ Editable drawing code, native projects and shipped exports are collected under `
| 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 |
| assets/platforms/linux/ai.storyteller.solidcraft.mime.xml | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
| assets/platforms/linux/io.tangencylabs.solidcraft.desktop | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
| assets/platforms/linux/io.tangencylabs.solidcraft.metainfo.xml.in | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
| assets/platforms/linux/io.tangencylabs.solidcraft.mime.xml | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
| assets/platforms/macos/dmg/background.tiff | SolidCraft contributors | original disk image window background (blueprint sheet: grid, double border, circles, centre lines, leader arrow, title block) drawn by assets/platforms/macos/dmg/make_background.py in IBM Plex Sans/Mono; 1x and 2x representations | MIT OR Apache-2.0 |
| assets/platforms/macos/dmg/make_background.py | SolidCraft contributors | authoring script that draws the disk image background (Pillow); not part of any build | MIT OR Apache-2.0 |
| assets/platforms/macos/dmg/layout.json | SolidCraft contributors | disk image window size, icon size and icon positions shared by the background script and settings.py | MIT OR Apache-2.0 |
| assets/platforms/macos/dmg/settings.py | SolidCraft contributors | dmgbuild settings (MIT, https://github.com/dmgbuild/dmgbuild) for the SolidCraft disk image; original settings, no dmgbuild code copied | MIT OR Apache-2.0 |
| assets/platforms/macos/Info.plist.in | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
| assets/platforms/macos/README.txt | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
| assets/platforms/windows/solidcraft.en-us.wxl | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
@@ -198,6 +208,8 @@ Editable drawing code, native projects and shipped exports are collected under `
| 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/ui/themes/okibi.json | SolidCraft contributors (Kai Evans) | original SolidCraft theme file mapping SolidCraft's UI colours to the Okibi palette (dark) of the Okibi design system by Kai Evans (https://kaievns.github.io/okibi-akiri-design-system-spec/) | MIT OR Apache-2.0 |
| assets/styles/ui/themes/akari.json | SolidCraft contributors (Kai Evans) | original SolidCraft theme file mapping SolidCraft's UI colours to the Akari palette (light) of the Okibi design system by Kai Evans (https://kaievns.github.io/okibi-akiri-design-system-spec/) | 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 |
@@ -280,8 +292,57 @@ Editable drawing code, native projects and shipped exports are collected under `
| 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/colored_components.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/colored_components_off.svg | SolidCraft contributors | original icon master (64 grid) | 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 |
### Render workspace icon drafts
Original SolidCraft drawings, Open for owner review; no runtime assignments.
| File | Author | Source | Licence |
|---|---|---|---|
| assets/icons/ui/design/svg/model/render_all_shots.svg | SolidCraft contributors | original Camera icon master (64 grid): stacked camera bodies with a lower-right play badge | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/render_appearance.svg | SolidCraft contributors | original Looks icon master (64 grid): shaded look sphere on the top corner of an accent-shaded cube | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/render_aspect_frame.svg | SolidCraft contributors | original Camera icon master (64 grid): wide rounded frame with two opposite corner brackets | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/render_backdrop.svg | SolidCraft contributors | original Studio icon master (64 grid): grey cube in front of a tall upright accent panel | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/render_cancel.svg | SolidCraft contributors | original Output icon master (64 grid): picture with a lower-right round stop badge | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/render_check.svg | SolidCraft contributors | original Output icon master (64 grid): picture with a lower-right round check badge | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/render_compare.svg | SolidCraft contributors | original Output icon master (64 grid): two grey pictures split by a vertical slider with opposed arrows | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/render_copy_look.svg | SolidCraft contributors | original Looks icon master (64 grid): look sphere in a square frame offset from a second frame outline | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/render_execute.svg | SolidCraft contributors | original Output icon master (64 grid): picture with a lower-right round play badge | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/render_export_image.svg | SolidCraft contributors | original Output icon master (64 grid): picture with a large outward diagonal arrow at the upper right | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/render_exposure.svg | SolidCraft contributors | original Camera icon master (64 grid): split light and accent disc with plus and minus marks | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/render_fit_product.svg | SolidCraft contributors | original Camera icon master (64 grid): grey cube inside four corner brackets | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/render_frame_guides.svg | SolidCraft contributors | original Camera icon master (64 grid): rounded frame with a rule-of-thirds grid and four accent nodes | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/render_from_material.svg | SolidCraft contributors | original Looks icon master (64 grid): look sphere struck through above a plain grey slab | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/render_ground.svg | SolidCraft contributors | original Studio icon master (64 grid): grey cube on a slab with an accent contact patch | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/render_lens.svg | SolidCraft contributors | original Camera icon master (64 grid): concentric lens rings around an accent centre | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/render_library.svg | SolidCraft contributors | original Studio icon master (64 grid): row of three look spheres, middle one accent, on a shelf bar | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/render_light_area.svg | SolidCraft contributors | original Lights icon master (64 grid): slanted flat panel above a dotted accent stem | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/render_light_by_highlight.svg | SolidCraft contributors | original Lights icon master (64 grid): shaded sphere with a ringed highlight target and dotted stem arriving from above | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/render_light_point.svg | SolidCraft contributors | original Lights icon master (64 grid): round source disc with centre dot above a dotted accent stem | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/render_light_spot.svg | SolidCraft contributors | original Lights icon master (64 grid): cone source with curved rim above a dotted accent stem | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/render_lighting_presets.svg | SolidCraft contributors | original Studio icon master (64 grid): two stacked cards, the front one holding a light bulb | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/render_output_size.svg | SolidCraft contributors | original Output icon master (64 grid): picture frame with a diagonal double-headed dimension arrow | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/render_paint.svg | SolidCraft contributors | original Looks icon master (64 grid): flat brush with an accent tip, light ferrule and slate handle | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/render_pick_up.svg | SolidCraft contributors | original Looks icon master (64 grid): eyedropper with a grey bulb, slate crossbar and accent sampling tip | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/render_quality.svg | SolidCraft contributors | original Output icon master (64 grid): three rising bars, the tallest in accent | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/render_replace_all.svg | SolidCraft contributors | original Looks icon master (64 grid): grey sample sphere with an arrow to an accent sphere beside a second one | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/render_results.svg | SolidCraft contributors | original Output icon master (64 grid): stack of two pictures, the front one showing a landscape | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/render_same_look.svg | SolidCraft contributors | original Looks icon master (64 grid): look sphere and three cubes, the two matching cubes ringed | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/render_shot.svg | SolidCraft contributors | original Camera icon master (64 grid): camera body with a lower-right plus badge | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/render_shots_strip.svg | SolidCraft contributors | original Camera icon master (64 grid): strip of three small frames, the middle one in accent | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/render_solo_light.svg | SolidCraft contributors | original Lights icon master (64 grid): point source and dotted stem inside four corner focus brackets | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/render_sun.svg | SolidCraft contributors | original Lights icon master (64 grid): source disc with eight short radial rays | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/render_transparent_background.svg | SolidCraft contributors | original Output icon master (64 grid): grey cube on a checker tile | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/render_update_shot.svg | SolidCraft contributors | original Camera icon master (64 grid): camera body with a lower-right refresh badge | MIT OR Apache-2.0 |
### UI icon masters (continued)
| File | Author | Source | Licence |
|---|---|---|---|
| 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 |
@@ -371,6 +432,21 @@ Editable drawing code, native projects and shipped exports are collected under `
| 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/render_environment.svg | SolidCraft contributors | original Studio icon master (64 grid): dome with a grey cube inside | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/style_shaded.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/style_wireframe.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/style_hidden_edges.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/style_translucent.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/select_faces_edges.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/select_bodies.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/select_features.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/select_parts.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/select_components.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/select_sketch.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/display.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/grid.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/ground_shadow.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/ambient_occlusion.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 |
@@ -512,6 +588,52 @@ Editable drawing code, native projects and shipped exports are collected under `
| 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 |
| assets/icons/ui/design/svg/model/path_flange.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| docs/book/src/generated/hem.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/hem.json`; gitignored), original sheet metal demonstration | MIT OR Apache-2.0 |
| docs/book/scripts/hem.json | SolidCraft contributors | original parametric recipe for an 80 × 50 mm sheet with an 8 mm returned edge, authored for the command guide | MIT OR Apache-2.0 |
| assets/styles/ui/themes/clay.json | SolidCraft contributors | original SolidCraft theme file (Clay, light): warm paper palette chosen for SolidCraft | MIT OR Apache-2.0 |
| assets/styles/ui/themes/clay-dark.json | SolidCraft contributors | original SolidCraft theme file (Clay Dark): warm charcoal palette chosen for SolidCraft | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/cone.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/locales/ar.json | Abdullah (A-AL-ANAZI) | Original Arabic translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
| assets/locales/he.json | SolidCraft contributors | Original Hebrew translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
| assets/styles/ui/themes/cga.json | SolidCraft contributors | original SolidCraft theme file using the 16 colours of the IBM Color Graphics Adapter (black, cyan, magenta, and white of its high-intensity palette 1 for the interface) | MIT OR Apache-2.0 |
| assets/styles/ui/themes/1-bit.json | SolidCraft contributors | original SolidCraft theme file | MIT OR Apache-2.0 |
| assets/styles/ui/themes/1-bit-dark.json | SolidCraft contributors | original SolidCraft theme file | MIT OR Apache-2.0 |
| `assets/locales/pending/navbar-customisation.json` | SolidCraft contributors | Original navigation bar editor translations | MIT OR Apache-2.0 |
| `assets/locales/pending/navbar-slots.json` | SolidCraft contributors | Original navigation slot translations | MIT OR Apache-2.0 |
| assets/locales/bs.json | Tarik Sijercic (tsijercic1) | Original Bosnian translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
| assets/locales/hr.json | Tarik Sijercic (tsijercic1) | Original Croatian translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
| assets/locales/sr.json | Tarik Sijercic (tsijercic1) | Original Serbian (ekavian, Cyrillic) translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/finish_edit.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| `assets/locales/pending/sketch-tracking-guides.json` | SolidCraft contributors | Original alignment-guide preference labels and help text | MIT OR Apache-2.0 |
| assets/locales/pending/point-clouds.json | SolidCraft contributors | Original point-cloud UI strings and translations | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/feature_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/mesh_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/timeline_play.svg | SolidCraft contributors | Original history playback icon; canonical grey_cube base and original action cue | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/timeline_stop.svg | SolidCraft contributors | Original history playback icon; canonical grey_cube base and original action cue | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/flat/status_pin.svg | SolidCraft contributors | Original analytic pin icon for persistent viewport results | MIT OR Apache-2.0 |
| assets/locales/pending/render-wear.json | SolidCraft contributors | Original UI labels and translations | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/equation_curve.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/locales/pending/sketch-recent-snap.json | SolidCraft contributors | Original preference messages for the recent centre and midpoint snap | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/construction_line.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/construction_line_horizontal.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/construction_line_vertical.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/construction_line_angle.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/construction_line_parallel.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/construction_line_perpendicular.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/construction_line_tangent_external.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/construction_line_tangent_at.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/construction_line_tangent_two.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/construction_line_bisector.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| docs/book/src/generated/equation-circle.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/equation-circle.json`; gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/equation-flower.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/equation-flower.json`; gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/equation-involute.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/equation-involute.json`; gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/equation-line.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/equation-line.json`; gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/equation-parabola.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/equation-parabola.json`; gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/equation-spiral.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/equation-spiral.json`; gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/equation-wave.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/equation-wave.json`; gitignored) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/print_face.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/locales/fi.json | SolidCraft contributors | Original Finnish translation of SolidCraft's English catalog, AI-assisted (Claude Code) clean-room from the English and not yet reviewed line by line by a native speaker; no commercial CAD text used | MIT OR Apache-2.0 |
## Code adapted from sibling projects
@@ -571,6 +693,7 @@ copied into SolidCraft.
| Directory | Source | Licence | Changes |
|---|---|---|---|
| vendor/accesskit_macos | accesskit_macos 0.26.3, https://github.com/AccessKit/accesskit (The AccessKit contributors) | MIT OR Apache-2.0 | the subclassing adapter and its tests from 0.27.2: dropping the adapter keeps AppKit's key-value observers (vendor/README.md) |
| 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; reuse of an operand's meshes (vendor/README.md) |
+52 -9
View File
@@ -1,8 +1,10 @@
# Contributing to SolidCraft
Agents: start from the router in [AGENTS.md](AGENTS.md), which points to a short skill in [`skills/`](skills/README.md) for each kind of work. This page stays the full human guide.
SolidCraft is an open-source parametric 3D CAD app written in pure Rust.
You sketch on a plane, constrain the sketch, turn profiles into solids, and
change a parameter to watch the timeline rebuild. It runs as a desktop app (Windows, Linux), in
change a parameter to watch the timeline rebuild. It runs as a desktop app (Windows, macOS, Linux), in
the browser (wasm + WebGPU/WebGL2), headless from a CLI, and as an MCP server that AI agents can
model with.
@@ -11,7 +13,7 @@ build it, how the code is laid out, the few rules that aren't negotiable, and wh
needed most.
Before choosing a task, check the [command inventory](docs/features.md) and
[roadmap](ROADMAP.md) for implemented capabilities and current priorities.
[roadmap](docs/roadmap-detail.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).
@@ -115,7 +117,8 @@ The [architecture page](docs/architecture.md) has the layer table. The short ver
not.) Permissive
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
Nightly/manual CI also scans advisories, bans and sources with cargo-deny, and a PR that
changes Cargo.lock or a Cargo.toml checks the advisories; 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
[ATTRIBUTION.md](ATTRIBUTION.md).** Icons are drawn in code (`crates/ui-egui/src/icons.rs`).
@@ -130,8 +133,11 @@ The [architecture page](docs/architecture.md) has the layer table. The short ver
- Every call into truck goes through `solidcraft_kernel::guard`.
- Every crash fix comes with a regression test.
5. **Everything is a command, with a test.** A new feature means a `CommandSpec` in
`crates/engine/src/cmd/`, engine tests that measure the result (volume, area,
`crates/engine/src/cmd/` with input and output schemas (see
[Adding a command](docs/book/src/dev/commands.md#adding-a-command); a test warns about a
command without them, and will fail it once required), engine tests that measure the result (volume, area,
face/edge/vertex counts against a value you can derive), and for UI work a scenario (below).
A command on the toolbar also ships with its help tooltip (step 6 of Adding a command).
6. **One set of camera controls.** Every page or canvas that shows a view (the modelling
viewport, the Render preview, and any new page such as a flat pattern or drawing) calls
`navigation::handle` (`crates/ui-egui/src/navigation.rs`) for its camera input, with
@@ -190,9 +196,11 @@ in the PR. `cargo xtask test` builds the tests with it and then runs every test
shared bound on top-level test/side commands for `cargo xtask test` and `cargo xtask ci`.
For example, `SOLIDCRAFT_TEST_JOBS=2` allows two commands at once; the sequential wasm/doc
worker consumes one of those slots. Every binary and side check still runs after failures.
Without the variable the existing concurrency is unchanged. `RUST_TEST_THREADS`,
`ACTION_MATRIX_JOBS` and Cargo's build-job setting remain independent caller overrides;
this limit does not choose their values or change test filters, timeouts or baselines.
Without the variable the existing concurrency is unchanged. With it set, each test binary
inherits `RUST_TEST_THREADS` and `ACTION_MATRIX_JOBS` of `max(1, cpus / jobs)` — the machine's
parallelism shared between the binaries in flight — unless you set either one explicitly;
explicit caller overrides always win. Cargo's build-job setting remains an independent caller
override; this limit does not change test filters, timeouts or baselines.
Tests run beside each other on a busy machine, so a test must not depend on wall-clock timing.
**Ready pull requests test what they touch.** CI maps changed files to workspace packages and
@@ -261,7 +269,9 @@ 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.
[docs/releasing.md](docs/releasing.md)). Never add a file to a `v<N>/` folder afterwards. Drawings
follow the same rules with their own numbers, from 1, in `crates/doc/src/drawing/format/` (see its
`pending/README.md`), and the same script folds them.
## Lists many changes add to
@@ -269,17 +279,36 @@ Some files gain an item in many pull requests at once. They are kept so that two
each add an item only meet when their items are neighbours, which spares everyone a conflict
and a fresh CI run:
- **Growing Rust lists**: use slices (`const ITEMS: &[Item] = &[...];`) without a written
length. `cargo xtask lists` rejects non-test const/static arrays with more than ten lines
of entries, except fixed standard or algorithm tables listed with reasons in `xtask/src/lists.rs`.
Built-in themes need only a JSON file in `assets/styles/ui/themes/`, with a unique nonnegative
integer `order` (the picker order) and its own `provenance` line; keep existing order values.
- **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.
- **Commands without schemas** (`crates/engine/src/schema/unconverted.txt`): sorted, one id per
line, and it only shrinks. Converting a command removes its line; a new command is never added
to it.
- **Toolbar commands without help** (`BACKLOG` in `crates/ui-egui/src/command_help.rs`): sorted,
one id per line, and it only shrinks. Writing a command's help removes its line; a new command
is never added to it.
- **`ui.prefs` settings** (`docs/control-protocol.md`): one line per setting, sorted by name, in
the list under the protocol table; add a new setting on its own line rather than editing a shared
row.
- **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).
- **New UI strings and attribution rows**: one new file per change, `assets/locales/pending/<slug>.json`
and `attribution/pending/<slug>.md`, instead of editing the catalogs or the ATTRIBUTION.md table;
`cargo xtask fold-pending` folds them on `main` (see the README in each folder). A new English
message also needs every catalog delivered complete (`COMPLETE` in `crates/ui-egui/src/i18n.rs`:
today ar, he, ja, vi, and zh) in the same pending file; `cargo xtask i18n --check` reports any gap.
## Sending a change
@@ -331,6 +360,16 @@ 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.
To show a picture without committing it, attach the media to the PR (it goes to the PR's own
attachment storage and renders inline in the comment). A gist is the better home for a log or
another text file, and you link it; a gist does not render a picture inline. The review artifacts
stay out of the diff that way, and nothing has to be pushed to a branch of its own.
Attaching works in the browser for anyone who can comment: drag the file into a comment on the
pull request. A command-line agent without a browser session usually cannot attach a file (the
upload needs write access to this repository). Say so in the pull request, and the person running
the agent or a maintainer can attach it.
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
@@ -346,6 +385,10 @@ owning one area, with a human setting direction and reporting bugs. Their workin
[AGENTS.md](AGENTS.md). Human contributors are just as welcome. Use whatever tools you like, as
long as the change meets the rules above.
Two quick checks run on every pull request (`.github/workflows/pr-checks.yml`, not required): it
fails when a lower-numbered open PR by someone else already closes the same issue (the label
`not-a-duplicate` skips it), or when the description lacks the `Changes a default: no` / `Changes a default: yes — <what>, old behaviour kept behind preference <name>` line (docs-only and bot PRs are exempt).
## Labels and triage
The triage bot labels every new issue and PR (`.github/workflows/triage.yml`). It only adds labels;
@@ -382,7 +425,7 @@ In order of how much they'd help users:
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. **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.
[ROADMAP.md](docs/roadmap-detail.md)), but anyone who wants to own one is welcome.
## Updating the generated feature list
Generated
+53 -30
View File
@@ -60,8 +60,6 @@ dependencies = [
[[package]]
name = "accesskit_macos"
version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076"
dependencies = [
"accesskit",
"accesskit_consumer 0.38.0",
@@ -1692,9 +1690,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 6.0.0",
"rand_core 0.10.1",
"wasm-bindgen",
]
[[package]]
@@ -3771,9 +3771,9 @@ checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
[[package]]
name = "rustls-webpki"
version = "0.103.15"
version = "0.103.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
dependencies = [
"ring",
"rustls-pki-types",
@@ -4208,7 +4208,7 @@ dependencies = [
[[package]]
name = "solidcraft"
version = "0.22.0"
version = "0.28.0"
dependencies = [
"block2 0.6.2",
"eframe",
@@ -4217,6 +4217,7 @@ dependencies = [
"getrandom 0.3.4",
"hidapi",
"keyring",
"libc",
"log",
"muda",
"objc2 0.6.5",
@@ -4228,23 +4229,27 @@ dependencies = [
"rfd",
"ron",
"rustls",
"serde",
"serde_json",
"solidcraft-3dx-client",
"solidcraft-engine",
"solidcraft-gpu",
"solidcraft-space-mouse",
"solidcraft-ui-egui",
"ureq",
"windows-sys 0.61.2",
"winit",
"x11rb",
"zbus 5.19.0",
]
[[package]]
name = "solidcraft-3dx-client"
version = "0.22.0"
version = "0.28.0"
[[package]]
name = "solidcraft-cli"
version = "0.22.0"
version = "0.28.0"
dependencies = [
"image",
"log",
@@ -4256,7 +4261,7 @@ dependencies = [
[[package]]
name = "solidcraft-derive"
version = "0.22.0"
version = "0.28.0"
dependencies = [
"proc-macro2",
"quote",
@@ -4265,7 +4270,7 @@ dependencies = [
[[package]]
name = "solidcraft-doc"
version = "0.22.0"
version = "0.28.0"
dependencies = [
"log",
"serde",
@@ -4282,8 +4287,9 @@ dependencies = [
[[package]]
name = "solidcraft-engine"
version = "0.22.0"
version = "0.28.0"
dependencies = [
"image",
"log",
"schemars",
"serde",
@@ -4296,12 +4302,13 @@ dependencies = [
"solidcraft-render",
"solidcraft-sketch",
"thiserror 2.0.21",
"toml_edit",
"web-time",
]
[[package]]
name = "solidcraft-geom"
version = "0.22.0"
version = "0.28.0"
dependencies = [
"serde",
"serde_json",
@@ -4310,7 +4317,7 @@ dependencies = [
[[package]]
name = "solidcraft-gpu"
version = "0.22.0"
version = "0.28.0"
dependencies = [
"image",
"log",
@@ -4330,13 +4337,14 @@ dependencies = [
[[package]]
name = "solidcraft-io"
version = "0.22.0"
version = "0.28.0"
dependencies = [
"cadmpeg-core",
"cadmpeg-ir",
"cadmpeg-registry",
"chardetng",
"encoding_rs",
"gethostname",
"gltf",
"log",
"quick-xml",
@@ -4353,7 +4361,7 @@ dependencies = [
[[package]]
name = "solidcraft-kernel"
version = "0.22.0"
version = "0.28.0"
dependencies = [
"log",
"proptest",
@@ -4373,7 +4381,7 @@ dependencies = [
[[package]]
name = "solidcraft-mcp"
version = "0.22.0"
version = "0.28.0"
dependencies = [
"serde_json",
"solidcraft-engine",
@@ -4382,7 +4390,7 @@ dependencies = [
[[package]]
name = "solidcraft-render"
version = "0.22.0"
version = "0.28.0"
dependencies = [
"image",
"moxcms",
@@ -4396,25 +4404,27 @@ dependencies = [
[[package]]
name = "solidcraft-sketch"
version = "0.22.0"
version = "0.28.0"
dependencies = [
"harfrust",
"serde",
"serde_json",
"solidcraft-geom",
"thiserror 2.0.21",
"ttf-parser",
"unicode-bidi",
]
[[package]]
name = "solidcraft-space-mouse"
version = "0.22.0"
version = "0.28.0"
dependencies = [
"serde",
]
[[package]]
name = "solidcraft-ui-egui"
version = "0.22.0"
version = "0.28.0"
dependencies = [
"arboard",
"egui",
@@ -4436,6 +4446,7 @@ dependencies = [
"tiny-skia",
"toml",
"ttf-parser",
"unicode-bidi",
"web-time",
"winreg",
"zip",
@@ -4443,15 +4454,17 @@ dependencies = [
[[package]]
name = "solidcraft-web"
version = "0.22.0"
version = "0.28.0"
dependencies = [
"eframe",
"egui",
"js-sys",
"log",
"percent-encoding",
"serde_json",
"solidcraft-engine",
"solidcraft-ui-egui",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
@@ -4552,7 +4565,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"getrandom 0.3.4",
"once_cell",
"rustix 1.1.5",
"windows-sys 0.61.2",
@@ -4698,6 +4711,7 @@ dependencies = [
"indexmap",
"toml_datetime",
"toml_parser",
"toml_writer",
"winnow",
]
@@ -4909,9 +4923,9 @@ dependencies = [
[[package]]
name = "typed-path"
version = "0.12.3"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e"
[[package]]
name = "typeid"
@@ -4942,6 +4956,12 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
[[package]]
name = "unicode-bidi"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
[[package]]
name = "unicode-general-category"
version = "1.1.0"
@@ -5342,9 +5362,9 @@ dependencies = [
[[package]]
name = "webpki-root-certs"
version = "1.0.9"
version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
checksum = "58ec80e73fd8baf57bf0d9615554c75348bda990d6d5bbeab964dd068319fecb"
dependencies = [
"rustls-pki-types",
]
@@ -5825,8 +5845,6 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winit"
version = "0.30.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d"
dependencies = [
"ahash",
"android-activity",
@@ -5979,12 +5997,15 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.22.0"
version = "0.28.0"
dependencies = [
"proc-macro2",
"serde",
"serde_json",
"sha2 0.10.9",
"syn 2.0.119",
"toml",
"zip",
]
[[package]]
@@ -6274,16 +6295,18 @@ dependencies = [
[[package]]
name = "zip"
version = "9.0.4"
version = "9.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a954114b47a54df49f83d2722814e86b23d4bb1b54a1b90fb7d92b41619ddc22"
checksum = "1156b39d142a34b999c2310a50455e0cb3760bf7efad0122f796d813bee26ccb"
dependencies = [
"crc32fast",
"flate2",
"getrandom 0.4.3",
"indexmap",
"memchr",
"nix 0.31.3",
"typed-path",
"windows-sys 0.61.2",
"zopfli",
]
+12 -2
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["vendor"]
[workspace.package]
version = "0.22.0"
version = "0.28.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -67,6 +67,9 @@ 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"] }
# The computer's name in a design's "open elsewhere" lock file (MIT OR Apache-2.0; already in the
# tree through x11rb).
gethostname = "1"
# 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
@@ -77,7 +80,7 @@ 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"] }
ttf-parser = { version = "0.25", default-features = false, features = ["std", "variable-fonts"] }
# Ends a process group with the system call (MIT; already in the tree through zip).
nix = { version = "0.31", default-features = false, features = ["signal"] }
@@ -158,6 +161,13 @@ matext4cgmath = { path = "vendor/matext4cgmath" }
# Keep OpenGL pipelines, resolve textures and CAD callbacks at the same safe sample count.
egui-wgpu = { path = "vendor/egui-wgpu" }
# Deliver trackpad pinch and rotation gestures on Wayland (zwp_pointer_gestures_v1), which
# winit 0.30 never binds and eframe 0.36 still requires winit 0.30.
winit = { path = "vendor/winit" }
# macOS: dropping the accessibility adapter leaves AppKit's key-value observers intact (#7535).
accesskit_macos = { path = "vendor/accesskit_macos" }
# 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]
+44
View File
@@ -0,0 +1,44 @@
# Disclaimer
This disclaimer covers the software, models, data, documentation and every other file in this
repository and in the releases built from it (together, "the files").
## No warranty
The files are provided "as is", without warranty of any kind, express or implied, including
warranties of accuracy, merchantability, fitness for a particular purpose and non-infringement.
Results produced with the files, such as geometry, meshes, analyses and simulations, can be
wrong. A qualified person must check them before anyone relies on them for a design,
manufacturing or safety decision.
## No liability
To the fullest extent permitted by law, Tangency Labs and the contributors are not liable for any
claim, damage, loss or other liability arising from the files or from their use, modification or
distribution, whether in contract, tort or otherwise.
## No indemnity
Neither Tangency Labs nor any contributor indemnifies, defends or holds harmless anyone who uses,
modifies or distributes the files, including against claims that the files infringe someone
else's rights.
## Harmful or unlawful use
Tangency Labs and the contributors do not authorise, endorse or accept any responsibility for use
of the files to harm people, animals, property or the environment, or for any unlawful use.
Whoever uses the files is solely responsible for how they use them, for what they make with them
and for complying with the laws that apply to them. Tangency Labs and the contributors must not be
named or held out as responsible for any such use.
## No endorsement
Using, modifying or distributing the files does not mean that Tangency Labs or any contributor
endorses you, your product or your results. Do not use the name of Tangency Labs or of a
contributor to suggest such an endorsement without written permission.
## Relation to the licence
This disclaimer adds to the warranty and liability terms of the licence the files are distributed
under. It does not restrict any right that licence grants; where the two differ, the licence
governs.
+21 -12
View File
@@ -1,5 +1,5 @@
<p align="center">
<img src="assets/icons/app/linux/exports/hicolor/256x256/apps/ai.storyteller.solidcraft.png" width="96" height="96" alt="SolidCraft" />
<img src="assets/icons/app/linux/exports/hicolor/256x256/apps/io.tangencylabs.solidcraft.png" width="96" height="96" alt="SolidCraft" />
</p>
<h1 align="center">SolidCraft</h1>
@@ -42,25 +42,25 @@
</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>
<a href="https://trendshift.io/repositories/296178?utm_source=trendshift-badge&utm_medium=badge&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&utm_medium=badge&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)
![SolidCraft: 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.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).*
*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. 6× playback with the idle pauses cut. [Full video](https://github.com/tangency-labs/solidcraft/raw/refs/heads/main/docs/screenshots/demo.mp4), recorded with the [demo script](examples/demo/record_ui.sh).*
## Download
- **Windows:** the [installer][windows-msi], or `winget install bherbruck.SolidCraft`.
- **Windows:** the [installer][windows-msi].
- **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.
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
[downloads page](docs/downloads.md). The macOS app is signed with a Developer ID and notarized by
Apple. The Windows downloads aren't signed yet: choose **More info**, then **Run anyway** in
SmartScreen. 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).
## What works
@@ -74,7 +74,7 @@ other is a tracked bug.
- **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.
- **Files:** STEP, IGES, 3MF, STL, OBJ and glTF in and out where supported, URDF export, `.sc` designs (`.solidcraft` files from earlier releases still open).
- **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);
@@ -96,8 +96,10 @@ More commands, the Windows cross-build and the browser build are in [docs/buildi
`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).
Claude Code: `claude mcp add solidcraft -- solidcraft-cli mcp`. If a terminal doesn't find
`solidcraft-cli` (older Windows installers don't put it on `PATH`), give its full path (by default
`"C:\\Program Files\\SolidCraft\\solidcraft-cli.exe"`). Tools, access rules and an example session:
[docs/mcp.md](docs/mcp.md).
## Scripts
@@ -119,6 +121,12 @@ the clean-room and licence rules, and where help is wanted most. Open work is in
SolidCraft's continuous integration runs on [Blacksmith](https://www.blacksmith.sh), which
sponsors its CI minutes.
## Disclaimer
The files in this repository are provided as is, without warranty, liability or indemnity, and
Tangency Labs and the contributors accept no responsibility for any harmful or unlawful use of
them: read [DISCLAIMER.md](DISCLAIMER.md).
## Licence
MIT OR Apache-2.0, at your option. Developed under the
@@ -142,3 +150,4 @@ Tinkercad are trademarks of their respective owners, named only to describe comp
[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://solidcraft.io/app
+91 -1
View File
@@ -1,5 +1,85 @@
# SolidCraft roadmap
The short version, for people who use SolidCraft. The detailed developer status (milestone
tables, limitations, measurements) is in [docs/roadmap-detail.md](docs/roadmap-detail.md).
## What SolidCraft is
SolidCraft is open-source parametric 3D CAD written in Rust. You sketch, constrain, extrude, and
change a parameter to watch the timeline rebuild. It runs as a desktop app and in the browser
([try it](https://solidcraft.io/app)), and it has a command-line tool and an MCP server so that
scripts and AI agents can drive the same commands you use.
## Works today
- Sketching with a constraint solver, and the usual create and modify tools.
- Extrude, revolve, loft, sweep, coil, fillet, chamfer, shell, draft, holes, threads,
patterns, mirror and split.
- Parameters with units and expressions, and configurations.
- Faces and edges keep their names through edits, so references survive changes.
- Components, assemblies, joints, motion studies and exploded views.
- Sheet metal with flat patterns and DXF export, and a plastic-enclosure subset.
- A Render workspace with GPU rendering, studio lighting, materials and image export
(first release in 0.22.0).
- Import and export of STEP, IGES, 3MF, STL, OBJ and DXF, and FreeCAD and Rhino files.
- Autosave and crash recovery, and the browser build of the same app.
The full list of commands is in [docs/features.md](docs/features.md). Release notes for each
version are on the [releases page](https://github.com/tangency-labs/solidcraft/releases).
## In progress now
Each feature below is a [milestone](https://github.com/tangency-labs/solidcraft/milestones) holding
the issues for it, so the count shows how far along it is. Counts are as of 2026-10-25, one per
feature or request. Work merged on a feature branch (`drawings`, `render`) counts as built but
reaches the app only when that branch merges. They are rough, not a promise.
- **[Rendering & materials](https://github.com/tangency-labs/solidcraft/milestone/17)**: in progress, 9 of 34 pieces done, 3 of them built only on the `render` branch and in the app when it merges. The basic Render workspace is already in releases (0.22.0); the interface rework (#6281) and the material and light libraries are what is left.
- **[Drawings](https://github.com/tangency-labs/solidcraft/milestone/8)**: in progress, 3 of 21 pieces built (2 in releases, 1 on the `drawings` branch), and 3 more partly built there (sheets and borders, bill-of-materials types, the paper scene). Not in the app yet: it lands when the `drawings` branch merges. Today you can export the current view as a DXF.
- **[Sheet metal](https://github.com/tangency-labs/solidcraft/milestone/15)**: in progress, 1 of 30 requests done. Flanges, hems, unfold and flat patterns already work; the 30 are extensions, mostly the per-edge flange options (segments, miters, partial flanges, reliefs) and bend allowance.
- **[Sketching](https://github.com/tangency-labs/solidcraft/milestone/14)**: in progress, 12 of 50 pieces done. Dimension types, selection and snapping options.
- **[Solid modeling](https://github.com/tangency-labs/solidcraft/milestone/2)**: in progress, 9 of 50 pieces done. Blends, holes, threads and patterns.
- **[Assemblies & joints](https://github.com/tangency-labs/solidcraft/milestone/3)**: in progress, 4 of 19 pieces done. Joints, motion studies and exploded views already work; placement rules, linked sub-assemblies, fastener stacks and weldment corners are the pieces left.
- **[Import & export](https://github.com/tangency-labs/solidcraft/milestone/5)**: in progress, 7 of 39 pieces done. FreeCAD, SolidWorks and point-cloud import, and mesh export options.
- **[AI agents & MCP](https://github.com/tangency-labs/solidcraft/milestone/18)**: in progress, 2 of 8 pieces done. Agent chat and the MCP server.
- **[Interface, web app & plugins](https://github.com/tangency-labs/solidcraft/milestone/4)**: in progress, 31 of 96 pieces done. Ribbon redesign, navigation, themes, the web app and plugins.
- **[SMLib kernel migration](https://github.com/tangency-labs/solidcraft/milestone/1)**: in progress, 1 tracking issue open. The kernel is moving from truck to NVIDIA's SMLib (usd-brep, Apache-2.0) on the `kernel-smlib` branch; it is not in releases yet. Tracked in [#4012](https://github.com/tangency-labs/solidcraft/issues/4012).
## Next
- **[Surfacing](https://github.com/tangency-labs/solidcraft/milestone/16)**: next, 6 of 14 pieces done. Patch, trim, blend and match exist; the rest of the surface toolset is planned.
- **[Manufacturing / CAM](https://github.com/tangency-labs/solidcraft/milestone/9)**: next, 1 pieces tracked, none done. There is no CAM in SolidCraft yet. Send to Slicer and a first toolpath slice are in review; proposals are [#1153](https://github.com/tangency-labs/solidcraft/issues/1153) and [#1621](https://github.com/tangency-labs/solidcraft/issues/1621).
Features are not tied to a release or a date. Work lands in the daily releases as it is finished.
Anything without a milestone, such as bug fixes, is not a feature and is not listed here.
## How releases work
- Releases ship daily, as numbered versions (v0.22.0, v0.22.1) with nightly pre-releases in
between. They are delivered through the in-app updater.
- SolidCraft tells you when a newer stable release exists and never updates without asking.
Where it can replace itself safely (AppImage, Windows installer and portable builds) there is
an **Update now** button; downloads are checked against the release's SHA-256 list. See
[updates and privacy](docs/book/src/guide/updates.md).
- Design files carry a format number. A new version opens designs from older versions, but an
older version refuses a design saved by a newer one (0.22.0 writes format 35, and 0.21.0 and
earlier will not open it). Each release's notes say when the format changed. Keep a copy of
an important design before you move to a new version.
## Licence
SolidCraft is licensed MIT OR Apache-2.0, at your option; see [LICENSE-MIT](LICENSE-MIT) and
[LICENSE-APACHE](LICENSE-APACHE). Versions already released stay available under those licences
permanently.
## How to follow along
- Feature milestones: <https://github.com/tangency-labs/solidcraft/milestones>
- Issues: <https://github.com/tangency-labs/solidcraft/issues> (area labels such as
`area:kernel`, `area:sketch` and `area:ui` group them)
- Discord: <https://discord.gg/FtU4NQNVDH>
- Releases: <https://github.com/tangency-labs/solidcraft/releases>
- Contributing: [CONTRIBUTING.md](CONTRIBUTING.md)
Status as of **2026-10-14**. SolidCraft supports parametric modelling (sketch →
features → parametric timeline) as an open, pure-Rust, agent-drivable application.
@@ -52,8 +132,10 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
| 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, 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 |
| 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), drawing snaps to where two lines, circles or arcs cross (#4967), 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; 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 |
| 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, PLY/XYZ point clouds as a modelling reference and PLY meshes (done — [docs/point-clouds.md](docs/point-clouds.md), #5151) | 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; 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; Browser Expand All / Collapse All (`browser.expand_all`, `browser.collapse_all`) on the design and component menus; 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 |
@@ -204,6 +286,11 @@ completed drawings publish atomically. The browser still exports synchronously.
## Dimensioning and error reporting
- Operation length fields share design-unit defaults and input evaluation, including secondary
distances, thin walls, blend controls, sheet/plastic rules, joints and motion keys. Canvas
handles and sketch previews use the same lengths as committed geometry. Existing stored
millimetre values retain their size when edited in a design using another unit (#3271).
- MCP `execute` accepts `verbose: false`, sharing the compact reply helper used by
`batch`: body changes and created sketch IDs stay, while repeated lists are omitted.
Both tools retain full replies by default.
@@ -247,6 +334,9 @@ completed drawings publish atomically. The browser still exports synchronously.
- 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 Dimension takes a point and a circle or arc, or two circles or arcs (#4064): it
measures to the circle's edge (from the point, or between the two edges: the gap, 0 when
they touch, how far they overlap, or the narrowest gap between nested circles).
- 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.
+65 -43
View File
@@ -1,6 +1,6 @@
Third-party software in SolidCraft
SolidCraft (the desktop app, solidcraft-cli and the web app) is built from the 559 crates below.
SolidCraft (the desktop app, solidcraft-cli and the web app) is built from the 560 crates below.
Generated by `cargo xtask licences` from Cargo.lock and the crates' own licence files.
SolidCraft's own licence is in LICENSE-MIT and LICENSE-APACHE, and NOTICE.
@@ -353,7 +353,7 @@ rustls-native-certs 0.8.5 (Apache-2.0 OR ISC OR MIT)
rustls-pki-types 1.15.1 (MIT OR Apache-2.0)
rustls-platform-verifier 0.7.1 (MIT OR Apache-2.0)
rustls-platform-verifier-android 0.2.0 (MIT OR Apache-2.0)
rustls-webpki 0.103.15 (ISC)
rustls-webpki 0.103.16 (ISC)
rustversion 1.0.23 (MIT OR Apache-2.0)
ruzstd 0.9.1 (MIT)
schannel 0.1.29 (MIT)
@@ -435,10 +435,11 @@ truck-topology 0.6.0 (Apache-2.0)
ttf-parser 0.25.1 (MIT OR Apache-2.0)
twox-hash 2.1.5 (MIT)
type-map 0.5.1 (MIT/Apache-2.0)
typed-path 0.12.3 (MIT OR Apache-2.0)
typed-path 0.13.0 (MIT OR Apache-2.0)
typeid 1.0.3 (MIT OR Apache-2.0)
typenum 1.20.1 (MIT OR Apache-2.0)
uds_windows 1.2.1 (MIT)
unicode-bidi 0.3.18 (MIT OR Apache-2.0)
unicode-general-category 1.1.0 (Apache-2.0)
unicode-ident 1.0.26 ((MIT OR Apache-2.0) AND Unicode-3.0)
unicode-segmentation 1.13.3 (MIT OR Apache-2.0)
@@ -473,7 +474,7 @@ wayland-sys 0.31.11 (MIT)
web-sys 0.3.106 (MIT OR Apache-2.0)
web-time 1.1.0 (MIT OR Apache-2.0)
webbrowser 1.2.4 (MIT OR Apache-2.0)
webpki-root-certs 1.0.9 (CDLA-Permissive-2.0)
webpki-root-certs 1.0.10 (CDLA-Permissive-2.0)
weezl 0.1.12 (MIT OR Apache-2.0)
wgpu 30.0.1 (MIT OR Apache-2.0)
wgpu-core 30.0.1 (MIT OR Apache-2.0)
@@ -551,7 +552,7 @@ zeroize_derive 1.5.0 (Apache-2.0 OR MIT)
zerotrie 0.2.5 (Unicode-3.0)
zerovec 0.11.8 (Unicode-3.0)
zerovec-derive 0.11.6 (Unicode-3.0)
zip 9.0.4 (MIT)
zip 9.1.3 (MIT)
zlib-rs 0.6.8 (Zlib)
zmij 1.0.23 (MIT)
zopfli 0.8.3 (Apache-2.0)
@@ -595,7 +596,7 @@ pin-project 1.1.13, pin-project-internal 1.1.13, pin-project-lite 0.2.17, piper
rustix 0.38.44, rustix 1.1.5, rustversion 1.0.23, semver 1.0.28, serde 1.0.229, serde_core 1.0.229,
serde_derive 1.0.229, serde_derive_internals 0.30.0, serde_json 1.0.151, serde_path_to_error 0.1.20,
serde_repr 0.1.21, simd_cesu8 1.2.0, smol_str 0.2.2, syn 1.0.109, syn 2.0.119, syn 3.0.6, thiserror
1.0.69, thiserror 2.0.21, thiserror-impl 1.0.69, thiserror-impl 2.0.21, typed-path 0.12.3, typeid
1.0.69, thiserror 2.0.21, thiserror-impl 1.0.69, thiserror-impl 2.0.21, typed-path 0.13.0, typeid
1.0.3, unicode-ident 1.0.26, utf8-zero 0.8.1, wasi 0.11.1+wasi-snapshot-preview1, wasip2
1.0.4+wasi-0.2.12, wit-bindgen 0.57.1, x11-dl 2.21.0, xdg-home 1.3.0, zbus-lockstep 0.5.2,
zbus-lockstep-macros 0.5.2, zbus_names 3.0.0, zmij 1.0.23, zvariant_utils 2.1.0, zvariant_utils
@@ -1123,9 +1124,9 @@ parking_lot_core 0.9.12, percent-encoding 2.3.2, piper 0.2.5, plain 0.2.3, png 0
rustls-native-certs 0.8.5, scoped-tls 1.0.1, scopeguard 1.2.0, secret-service 4.0.0,
security-framework 2.11.1, security-framework 3.7.0, security-framework-sys 2.17.0,
signal-hook-registry 1.4.8, simd_cesu8 1.2.0, smallvec 1.16.2, smol_str 0.2.2, stable_deref_trait
1.2.1, syn 1.0.109, tempfile 3.27.0, ttf-parser 0.25.1, unicode-segmentation 1.13.3, unicode-width
0.2.2, url 2.5.8, uuid 1.27.0, wasi 0.11.1+wasi-snapshot-preview1, wasip2 1.0.4+wasi-0.2.12,
wasm-bindgen 0.2.129, wasm-bindgen-futures 0.4.79, wasm-bindgen-macro 0.2.129,
1.2.1, syn 1.0.109, tempfile 3.27.0, ttf-parser 0.25.1, unicode-bidi 0.3.18, unicode-segmentation
1.13.3, unicode-width 0.2.2, url 2.5.8, uuid 1.27.0, wasi 0.11.1+wasi-snapshot-preview1, wasip2
1.0.4+wasi-0.2.12, wasm-bindgen 0.2.129, wasm-bindgen-futures 0.4.79, wasm-bindgen-macro 0.2.129,
wasm-bindgen-macro-support 0.2.129, wasm-bindgen-shared 0.2.129, web-sys 0.3.106, weezl 0.1.12,
wit-bindgen 0.57.1
================================================================================
@@ -1338,7 +1339,7 @@ libc 0.2.190, litrs 1.0.0, num_enum 0.7.6, num_enum_derive 0.7.6, proc-macro2 1.
ref-cast 1.0.27, ref-cast-impl 1.0.27, rustc-hash 2.1.3, rustversion 1.0.23, semver 1.0.28, serde
1.0.229, serde_core 1.0.229, serde_derive 1.0.229, serde_derive_internals 0.30.0, serde_json
1.0.151, serde_path_to_error 0.1.20, serde_repr 0.1.21, syn 2.0.119, syn 3.0.6, thiserror 1.0.69,
thiserror 2.0.21, thiserror-impl 1.0.69, thiserror-impl 2.0.21, typed-path 0.12.3, typeid 1.0.3,
thiserror 2.0.21, thiserror-impl 1.0.69, thiserror-impl 2.0.21, typed-path 0.13.0, typeid 1.0.3,
unicode-ident 1.0.26, utf8-zero 0.8.1
================================================================================
@@ -12430,7 +12431,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================================================
rustls-webpki 0.103.15
rustls-webpki 0.103.16
================================================================================
Except as otherwise noted, this project is licensed under the following
@@ -14524,6 +14525,49 @@ MIT License
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE
================================================================================
unicode-bidi 0.3.18
================================================================================
This project is copyright 2015, The Servo Project Developers (given in the
file AUTHORS).
Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
http://www.apache.org/licenses/LICENSE-2.0> or the MIT license <LICENSE-MIT or
http://opensource.org/licenses/MIT>, at your option. All files in the project
carrying such notice may not be copied, modified, or distributed except
according to those terms.
================================================================================
unicode-bidi 0.3.18, unicode-segmentation 1.13.3, unicode-width 0.2.2
================================================================================
Copyright (c) 2015 The Rust Project Developers
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
unicode-ident 1.0.26
================================================================================
@@ -14572,36 +14616,6 @@ authorization of the copyright holder.
unicode-segmentation 1.13.3, unicode-width 0.2.2
================================================================================
Copyright (c) 2015 The Rust Project Developers
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
unicode-segmentation 1.13.3, unicode-width 0.2.2
================================================================================
Licensed under the Apache License, Version 2.0
<LICENSE-APACHE or
http://www.apache.org/licenses/LICENSE-2.0> or the MIT
@@ -15260,7 +15274,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
webpki-root-certs 1.0.9
webpki-root-certs 1.0.10
================================================================================
# Community Data License Agreement - Permissive - Version 2.0
@@ -16387,7 +16401,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
================================================================================
zip 9.0.4
zip 9.1.3
================================================================================
The MIT License (MIT)
@@ -16785,6 +16799,14 @@ Bundled fonts (SIL Open Font License, Version 1.1)
Copyright 2022 The BIZ UDGothic Project Authors (https://github.com/googlefonts/morisawa-biz-ud-mincho)
Source: https://github.com/googlefonts/morisawa-biz-ud-gothic
- CraftUI-SC.otf (assets/fonts/craft-ui-sc/CraftUI-SC.otf)
Copyright 2014-2021 Adobe / Google Inc. (Noto Sans SC subset for SolidCraft web zh)
Source: https://github.com/notofonts/noto-cjk (Sans SubsetOTF/SC)
- CraftUI-TC.otf (assets/fonts/craft-ui-tc/CraftUI-TC.otf)
Copyright 2014-2021 Adobe / Google Inc. (Noto Sans TC subset for SolidCraft web zh-TW)
Source: https://github.com/notofonts/noto-cjk (Sans SubsetOTF/TC)
- IBMPlexSans-Variable.ttf (assets/fonts/ibm-plex-sans/IBMPlexSans-Variable.ttf)
Copyright 2017 IBM Corp. with Reserved Font Name "Plex"
Source: https://github.com/IBM/plex
@@ -66,9 +66,9 @@ fn run() -> Result<(), String> {
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 (commands, command_timings) = session.run_script_timed(&recipe, false, &mut |_, _| {}).map_err(|e| e.to_string())?;
let generation_seconds = generation_start.elapsed().as_secs_f64();
let design_path = output.with_extension("solidcraft");
let design_path = output.with_extension(solidcraft_engine::io::DESIGN_EXT);
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
@@ -87,7 +87,7 @@ fn run() -> Result<(), String> {
let gpu_host_start = Instant::now();
let policy = solidcraft_gpu::select::Policy::from_env().map_err(|e| e.to_string())?;
let (adapter, report) =
solidcraft_gpu::select::pick_vulkan(policy, |_| true).ok_or("no Vulkan adapter satisfies the requested GPU policy")?;
solidcraft_gpu::select::pick_path(policy, |_| true).ok_or("no Vulkan or Metal adapter satisfies the requested GPU policy")?;
session.register_render_job_host(solidcraft_gpu::path::host_on_adapter(&adapter).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
(Some(report), Some(gpu_host_start.elapsed().as_secs_f64()))
} else {
+5 -2
View File
@@ -8,7 +8,7 @@
//! no fixture.
//!
//! ```text
//! solidcraft-cli bench-agent [--scale F] [--script-out script.json] [--save design.solidcraft]
//! solidcraft-cli bench-agent [--scale F] [--script-out script.json] [--save design.sc]
//! ```
//! prints one JSON line per hundred commands (mean and max milliseconds, bodies so far), then the
//! build total, then the phases `bench-open` prints for the saved design.
@@ -205,7 +205,10 @@ pub fn run(args: &[String]) -> Result<(), String> {
);
let path = match flag("--save") {
Some(p) => p.clone(),
None => std::env::temp_dir().join(format!("bench-agent-{}.solidcraft", std::process::id())).to_string_lossy().into_owned(),
None => std::env::temp_dir()
.join(format!("bench-agent-{}.{}", std::process::id(), solidcraft_engine::io::DESIGN_EXT))
.to_string_lossy()
.into_owned(),
};
s.execute("file.save", &json!({"path": path})).map_err(|e| e.to_string())?;
let r = crate::cmd_bench_open(std::slice::from_ref(&path));
+1 -1
View File
@@ -419,7 +419,7 @@ fn wait_for_turn(control: &mut Control, mut chat: Value, began: Instant, end: In
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) {
if idle && (seen_busy || answered || matches!(state, "exited" | "failed" | "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)) };
+132 -65
View File
@@ -1,10 +1,11 @@
//! 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] [--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] [--save OUT.solidcraft] [--display LEVEL [--edit NAME EXPR]] [--ops script.json] loading timings as JSON lines
//! solidcraft-cli run <script.json|design.sc|part.step|mesh.3mf> [--in design|part.step|mesh.3mf] [--out FILE]... [--save FILE] [--quiet] [--results] [--timings] [--profile] [--json-out FILE] [--allow-errors]
//! [--param-check off|warn|strict]
//! solidcraft-cli eval <script.json|design.sc|part.step> [--in design.sc|part.step] [--json-out FILE] (alias: inspect) measurements as JSON
//! solidcraft-cli script <design.sc> [--out script.json] the design's history as a command script
//! solidcraft-cli bench-open <design.sc | part.step> [--parse-only] [--save OUT.sc] [--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]
@@ -18,10 +19,12 @@
//! 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 mcp setup [--dry-run] [--client NAME] [--live] (alias: mcp install) add it to OpenCode, Claude Desktop, Claude Code, Cursor
//! 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)
//! --max-file-size SIZE (with any command) read STEP and design files up to SIZE (2GB, 1500MB; at most 16GB)
//! ```
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
@@ -55,19 +58,19 @@ struct Command {
}
/// `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";
const USAGE_RUN: &str = " solidcraft-cli run <script.json|design.sc|part.step> [--in design.sc|part.step] [--out FILE]... [--save FILE] [--quiet] [--results] [--timings] [--profile] [--json-out FILE] [--allow-errors]\n [--param-check off|warn|strict]\n solidcraft-cli run --in <design.sc|part.step> [--out FILE]...\n (a timeline feature that fails fails the run and nothing is written; --allow-errors writes anyway)\n (--param-check checks each step against its command's contract: warn reports on stderr, strict stops the run)\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";
const USAGE_EVAL: &str = " solidcraft-cli eval <script.json|design.sc|part.step> [--in design.sc|part.step] [--json-out FILE] (alias: inspect)\n solidcraft-cli eval --in <script.json|design.sc|part.step> [--json-out FILE]\n (with both inputs, runs the positional script on --in before measuring; does not automatically save the modified design)\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";
const USAGE_SCRIPT: &str = " solidcraft-cli script <design.sc|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";
const USAGE_BENCH_OPEN: &str = " solidcraft-cli bench-open <design.sc | part.step> [--parse-only] [--save OUT.sc] [--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";
const USAGE_BENCH_AGENT: &str = " solidcraft-cli bench-agent [--scale F] [--save design.sc] [--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";
const USAGE_SNAPSHOT: &str = " solidcraft-cli snapshot <script|design> [--in design.sc|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.sc|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.
@@ -82,7 +85,7 @@ const USAGE_ORACLE: &str = " solidcraft-cli oracle <case-dir>... [--json]\n";
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] [--render-gpu]\n";
const USAGE_MCP: &str = " solidcraft-cli mcp [--headless] [--in design.sc|script.json] [--connect 127.0.0.1:PORT [--token T]] [--allow-path DIR]... [--allow-any-path]\n [--param-check off|warn|strict] [--render-gpu]\n solidcraft-cli mcp setup [--dry-run] [--client opencode|claude-desktop|claude-code|cursor] [--live]\n (adds a `solidcraft` server, with this program's path, to each installed AI client; --live adds `solidcraft-live` for the running app; alias: mcp install)\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.
@@ -98,7 +101,7 @@ const USAGE_BENCH_EDIT: &str = " solidcraft-cli bench-edit <script.json|recipe.
/// `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";
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 --max-file-size SIZE, with any command: read STEP and import files and design files up to SIZE for this run\n (for example 2GB or 1500MB; at most 16GB; the defaults are 512 MB and 256 MB, and a smaller SIZE keeps them)\n";
/// The commands the CLI takes, in the order the top-level usage lists them.
const COMMANDS: &[Command] = &[
@@ -130,6 +133,10 @@ const COMMANDS: &[Command] = &[
fn main() -> ExitCode {
let mut args: Vec<String> = std::env::args().skip(1).collect();
logger::init(&mut args);
if let Err(e) = max_file_size(&mut args) {
eprintln!("error: {e}");
return ExitCode::FAILURE;
}
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).
@@ -204,6 +211,28 @@ fn main() -> ExitCode {
}
}
/// Take `--max-file-size SIZE` (or `--max-file-size=SIZE`), which any command takes, out of the
/// arguments and raise the file size limits for this run (#4086). A bad size is an error.
fn max_file_size(args: &mut Vec<String>) -> Result<(), String> {
let mut size = None;
let mut i = 0;
while let Some(arg) = args.get(i) {
if arg == "--max-file-size" {
let value = args.get(i + 1).cloned().ok_or("--max-file-size needs a size, for example --max-file-size 2GB")?;
args.drain(i..i + 2);
size = Some(value);
} else if let Some(value) = arg.strip_prefix("--max-file-size=") {
size = Some(value.to_string());
args.remove(i);
} else {
i += 1;
}
}
let Some(size) = size else { return Ok(()) };
let bytes = solidcraft_engine::io::limits::parse_flag(&size).map_err(|e| format!("--max-file-size: {e}"))?;
solidcraft_engine::io::limits::set_override(Some(bytes))
}
fn pretty(v: &Value) -> String {
serde_json::to_string_pretty(v).unwrap_or_default()
}
@@ -240,11 +269,12 @@ fn wants_help(args: &[String]) -> bool {
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.
let s = solidcraft_engine::io::vfs::read_to_string(path, solidcraft_engine::io::MAX_DESIGN_BYTES).map_err(|e| format!("{path}: {e}"))?;
let s = solidcraft_engine::io::vfs::read_to_string(path, solidcraft_engine::io::limits::max_design_bytes())
.map_err(|e| format!("{path}: {}", solidcraft_engine::io::limits::explain(solidcraft_engine::io::limits::Kind::Design, e)))?;
serde_json::from_str(&s).map_err(|e| format!("{path}: {e}"))
}
/// A session from a design file, a STEP/3MF/STL file, a command script, or an oracle recipe.
/// A session from a design file, a STEP/3MF/STL file, a point cloud, a command script, or an oracle recipe.
pub fn load(path: &str) -> Result<Session, String> {
load_checked(path, solidcraft_engine::schema::ParamCheck::Off)
}
@@ -254,15 +284,14 @@ pub fn load(path: &str) -> Result<Session, String> {
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) {
if solidcraft_engine::io::is_step_path(path) || solidcraft_engine::io::is_mesh_path(path) || solidcraft_engine::io::is_point_cloud_path(path) {
s.execute("doc.open", &json!({"path": path})).map_err(|e| e.to_string())?;
return Ok(s);
}
let bytes = solidcraft_engine::io::vfs::read(path, solidcraft_engine::io::MAX_DESIGN_BYTES).map_err(|e| format!("{path}: {e}"))?;
let bytes = solidcraft_engine::io::limits::read(path, solidcraft_engine::io::limits::Kind::Design).map_err(|e| format!("{path}: {e}"))?;
if solidcraft_engine::io::is_design_container(&bytes) {
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());
let mut session = Session::opened_at(doc, None, Some(path));
session.param_check = check;
return Ok(session);
}
@@ -271,18 +300,36 @@ fn load_checked(path: &str, check: solidcraft_engine::schema::ParamCheck) -> Res
s.execute("doc.open", &json!({"path": path})).map_err(|e| e.to_string())?;
} else {
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())?;
run_script_warning(&mut s, path, &script)?;
}
Ok(s)
}
/// Run `script`, read from `path`, on `s`. At the warn level each step's `param_warnings` go to
/// stderr as the step completes, even when a later step fails (stdout stays the caller's).
fn run_script_warning(s: &mut Session, path: &str, script: &Value) -> Result<Vec<Value>, String> {
s.run_script_each(script, &mut |step, r| print_param_warnings(path, step, r)).map_err(|e| e.to_string())
}
/// Step `step`'s `param_warnings` on stderr, one per line, named after the script at `path`.
fn print_param_warnings(path: &str, 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());
}
}
/// `--param-check off|warn|strict`: how calls that break a command's parameter contract are
/// treated (docs/mcp.md); off when the option isn't given.
fn parse_param_check(cmd: &str, args: &[String]) -> Result<solidcraft_engine::schema::ParamCheck, String> {
match flag(args, "--param-check") {
None if args.iter().any(|a| a == "--param-check") => Err(format!("{cmd}: --param-check wants off, warn or strict")),
None => Ok(solidcraft_engine::schema::ParamCheck::Off),
Some(v) => {
solidcraft_engine::schema::ParamCheck::parse(v).ok_or_else(|| format!("{cmd}: --param-check wants off, warn or strict, not `{v}`"))
}
}
}
fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).map(String::as_str)
}
@@ -339,6 +386,15 @@ 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)?;
// Before the option check, so a bare `--param-check` gets the words `mcp` uses.
let check = parse_param_check("run", args)?;
known_args(
"run",
args,
&["--in", "--out", "--save", "--json-out", "--param-check"],
&["--quiet", "--results", "--timings", "--profile", "--allow-errors"],
true,
)?;
let base = flag(args, "--in");
let path = positional(args);
if path.is_none() && base.is_none() {
@@ -351,23 +407,25 @@ fn cmd_run(args: &[String]) -> Result<(), String> {
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)?,
Some(b) => load_checked(b, check)?,
None => Session::default(),
};
s.param_check = check;
let needs = if want_results { "--results needs a script" } else { "--timings needs a script" };
let v = read_json(path.ok_or(needs)?)?;
let script = path.ok_or(needs)?;
let v = read_json(script)?;
let out = if timings {
let (r, mut t) = s.run_script_timed(&v, profile).map_err(|e| e.to_string())?;
let (r, mut t) = s.run_script_timed(&v, profile, &mut |step, r| print_param_warnings(script, step, r)).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())?)
Value::Array(run_script_warning(&mut s, script, &v)?)
};
(s, Some(out))
} else {
(open(base, path)?, None)
(open(base, path, check)?, None)
};
// A feature that fails leaves the bodies as they were before it (a parameter change can
// break a feature without failing the command that made it): writing them would hand out
@@ -391,17 +449,17 @@ fn cmd_run(args: &[String]) -> Result<(), String> {
Ok(())
}
/// The session `run` and `snapshot` start from: `--in` (a design or STEP file) with the
/// positional script run on top of it, or either one alone.
fn open(base: Option<&str>, path: Option<&String>) -> Result<Session, String> {
/// The session `run`, `eval` and `snapshot` start from: `--in` (a design or STEP file) with the
/// positional script run on top of it, or either one alone, with scripts run at `check`.
fn open(base: Option<&str>, path: Option<&String>, check: solidcraft_engine::schema::ParamCheck) -> Result<Session, String> {
match (base, path) {
(Some(b), Some(p)) => {
let mut s = load(b)?;
s.run_script(&read_json(p)?).map_err(|e| e.to_string())?;
let mut s = load_checked(b, check)?;
run_script_warning(&mut s, p, &read_json(p)?)?;
Ok(s)
}
(Some(b), None) => load(b),
(None, Some(p)) => load(p),
(Some(b), None) => load_checked(b, check),
(None, Some(p)) => load_checked(p, check),
(None, None) => Err(usage()),
}
}
@@ -443,8 +501,8 @@ 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)?;
let mut s = load(path)?;
known_args("eval", args, &["--in", "--json-out"], &[], true)?;
let mut s = open(flag(args, "--in"), positional(args), solidcraft_engine::schema::ParamCheck::Off)?;
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())?;
let out = json!({
@@ -538,7 +596,7 @@ fn cmd_snapshot(args: &[String]) -> Result<(), String> {
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))?;
let mut s = open(flag(args, "--in"), positional(args), solidcraft_engine::schema::ParamCheck::Off)?;
// 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"))?;
@@ -625,10 +683,23 @@ fn cmd_exec(args: &[String]) -> Result<(), String> {
Ok(())
}
/// `mcp setup`: add the server to each installed AI client, with this program's own path.
fn cmd_mcp_setup(args: &[String]) -> Result<(), String> {
use solidcraft_engine::agent_setup as setup;
let (lines, ok) = setup::setup(args, &setup::Env::from_process(), &setup::current_exe()?)?;
for l in lines {
println!("{l}");
}
if ok { Ok(()) } else { Err("mcp setup: some clients could not be configured".into()) }
}
/// MCP server on stdio: headless (optionally starting from a design or script) or bridged to
/// 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> {
if matches!(args.first().map(String::as_str), Some("setup" | "install")) {
return cmd_mcp_setup(&args[1..]);
}
let render_gpu = args.iter().any(|arg| arg == "--render-gpu");
if render_gpu && flag(args, "--connect").is_some() {
return Err("mcp: --render-gpu installs a headless image host; a connected application owns its own device".into());
@@ -639,7 +710,7 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> {
if let Some(s) = &scope {
let roots: Vec<String> = s.roots().iter().map(|r| r.display().to_string()).collect();
eprintln!(
"solidcraft mcp: files limited to {} and the open design's folder (--allow-path DIR adds one, --allow-any-path lifts the limit)",
"solidcraft mcp: files limited to {} and the folders of designs opened or saved here (--allow-path DIR adds one, --allow-any-path lifts the limit)",
roots.join(", ")
);
let design_dir = flag(args, "--in")
@@ -649,17 +720,8 @@ 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");
}
// How calls that break a command's parameter contract are treated, headless or bridged.
let param_check = parse_param_check("mcp", args)?;
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) => {
@@ -667,7 +729,7 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> {
return Err(format!("mcp: --connect wants HOST:PORT, such as 127.0.0.1:7878, not `{addr}`"));
};
// Serve without the app too: the tools connect once it runs, and say it isn't until then.
let mut remote = solidcraft_mcp::Remote::new(addr, flag(args, "--token"));
let mut remote = solidcraft_mcp::Remote::new(addr, flag(args, "--token")).with_param_check(param_check);
if let Err(e) = remote.try_connect() {
eprintln!(
"solidcraft mcp: no SolidCraft app at {addr} yet ({e}); start it with `solidcraft --control {port}` \
@@ -684,18 +746,23 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> {
Box::new(headless)
}
(None, None) => {
if !args.iter().any(|a| a == "--headless")
&& let Some(port) = discover_app()
{
let auto = !args.iter().any(|a| a == "--headless");
if auto && 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 remote = solidcraft_mcp::Remote::new(&format!("127.0.0.1:{port}"), flag(args, "--token")).with_param_check(param_check);
return serve_mcp(Box::new(remote), scope);
}
let mut headless = solidcraft_mcp::Headless::default();
headless.session.param_check = param_check;
if render_gpu {
headless.session.register_render_job_host(solidcraft_gpu::path::host().map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
}
Box::new(headless)
if auto {
// The app may start after the agent: look for it again until there is work here.
Box::new(solidcraft_mcp::Auto::new(headless, Box::new(discover_app), flag(args, "--token")))
} else {
Box::new(headless)
}
}
};
serve_mcp(backend, scope)
@@ -799,11 +866,11 @@ fn bench_ops(mut s: Session, script: &str) -> Result<Session, String> {
/// 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]")?;
let path = args.first().ok_or("usage: solidcraft-cli bench-open <design.sc | part.step> [--parse-only]")?;
let t = std::time::Instant::now();
let step = solidcraft_engine::io::is_step_path(path);
let limit = if step { solidcraft_engine::io::MAX_STEP_BYTES } else { solidcraft_engine::io::MAX_DESIGN_BYTES };
let bytes = solidcraft_engine::io::vfs::read(path, limit).map_err(|e| format!("{path}: {e}"))?;
let kind = if step { solidcraft_engine::io::limits::Kind::Step } else { solidcraft_engine::io::limits::Kind::Design };
let bytes = solidcraft_engine::io::limits::read(path, kind).map_err(|e| format!("{path}: {e}"))?;
let read_ms = t.elapsed().as_secs_f64() * 1000.0;
println!("{}", json!({"step": "read", "ms": read_ms, "bytes": bytes.len()}));
let t = std::time::Instant::now();
@@ -825,7 +892,7 @@ pub(crate) fn cmd_bench_open(args: &[String]) -> Result<(), String> {
return Ok(());
}
let t = std::time::Instant::now();
let s = Session::opened(doc, saved.as_ref());
let s = Session::opened_at(doc, saved.as_ref(), (!step).then_some(path.as_str()));
drop(read);
// Taken from the file: nothing evaluated (SOLIDCRAFT_NO_MODEL_CACHE evaluates anyway), or
// only the features whose state a thinned file left out.
@@ -845,13 +912,13 @@ pub(crate) fn cmd_bench_open(args: &[String]) -> Result<(), String> {
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
// `--save PATH`: save the opened design without its model cache (to PATH.plain.sc) 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 plain = format!("{out}.plain.{}", solidcraft_engine::io::DESIGN_EXT);
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;
+42 -5
View File
@@ -92,7 +92,7 @@ fn designs() -> Vec<(String, PathBuf)> {
add("regressions", root.join("examples/regressions"), "json");
add("demo", root.join("examples/demo"), "json");
add("typical", root.join("tests/action_matrix/designs"), "json");
add("demo", root.join("tests/action_matrix/designs"), "solidcraft");
add("demo", root.join("tests/action_matrix/designs"), solidcraft_engine::io::DESIGN_EXT);
let mut cases: Vec<PathBuf> =
std::fs::read_dir(root.join("tests/oracle")).unwrap().flatten().map(|e| e.path()).filter(|p| p.join("recipe.json").is_file()).collect();
cases.sort();
@@ -105,7 +105,7 @@ fn designs() -> Vec<(String, PathBuf)> {
/// Build a design from its file.
fn load(path: &Path) -> Result<Session, String> {
let mut s = Session::default();
if path.extension().is_some_and(|x| x == "solidcraft") {
if solidcraft_engine::io::is_design_container_path(path) {
s.execute("doc.open", &json!({"path": path.to_string_lossy()})).map_err(|e| e.to_string())?;
return Ok(s);
}
@@ -395,8 +395,10 @@ fn actions(s: &Session) -> Vec<Action> {
let r = round3((shortest * 0.1).max(0.01));
let mut add = |action, act| out.push(Action { entity: entity.clone(), action, act });
if !smooth(e, mid) {
add("edge.fillet", Act::Run("solid.fillet", json!({"edges": [pt(mid)], "radius": r})));
add("edge.chamfer", Act::Run("solid.chamfer", json!({"edges": [pt(mid)], "distance": r})));
// The midpoint is interior to this edge, but other bodies (including component
// copies in their local frames) can have coincident edges. Retain the picked body.
add("edge.fillet", Act::Run("solid.fillet", json!({"body": world, "edges": [pt(mid)], "radius": r})));
add("edge.chamfer", Act::Run("solid.chamfer", json!({"body": world, "edges": [pt(mid)], "distance": r})));
}
let dir = straight(pts);
if dir.is_some() {
@@ -1278,6 +1280,41 @@ fn rectangle(plane: Value, p0: [f64; 2], p1: [f64; 2]) -> [Value; 3] {
]
}
#[test]
fn edge_round_actions_keep_the_picked_body() {
for (corner, length) in [([10, 0, 20], 4), ([0, 0, 0], 20)] {
let mut s = Session::default();
s.execute("solid.box", &json!({"length": 20, "width": 20, "height": 20, "body_name": "Plate"})).unwrap();
s.execute(
"solid.box",
&json!({"corner": corner, "length": length, "width": length, "height": length, "body_name": "Block", "operation": "new"}),
)
.unwrap();
let mut tried = 0;
for a in actions(&s) {
if !a.entity.starts_with("Plate/") || !matches!(a.action, "edge.chamfer" | "edge.fillet") {
continue;
}
let Act::Run(id, ref p) = a.act else { continue };
let q = &p["edges"][0];
let x = q[0].as_f64().unwrap();
if q[1] != 0.0 || q[2] != 20.0 || !(0.0..20.0).contains(&x) {
continue;
}
// Block either meets this midpoint or coincides with the whole edge.
assert_eq!(p["body"], "Plate", "{}: {p}", a.action);
assert_eq!(q, &json!([10.0, 0.0, 20.0]));
let mut unscoped = p.clone();
unscoped.as_object_mut().unwrap().remove("body");
let err = s.scratch().execute(id, &unscoped).unwrap_err().to_string();
assert!(err.contains("edges meet"), "{err}");
perform(&mut s.scratch(), &a.act, &errors(&s)).unwrap();
tried += 1;
}
assert_eq!(tried, 2);
}
}
#[test]
fn a_fillet_or_chamfer_on_one_edge_ending_at_a_cylinder() {
for (cmd, size) in [("solid.fillet", "radius"), ("solid.chamfer", "distance")] {
@@ -1521,7 +1558,7 @@ fn sketches_on_loft_and_sweep_faces() {
#[test]
fn press_pull_on_the_pi_case_floor() {
reproduces(
Some("tests/action_matrix/designs/pi-case.solidcraft"),
Some("tests/action_matrix/designs/pi-case.sc"),
json!([{"command": "solid.press_pull", "params": {"face": [30, 20, 0], "distance": 1}}]),
);
}
+31
View File
@@ -157,3 +157,34 @@ fn render_help_keeps_the_registered_gpu_request_controls() {
}
}
}
/// `--max-file-size` (#4086), with any command: the usage names it, a bad size stops the run
/// with the reason, and a design over the default limit opens once the flag raises it.
#[test]
fn max_file_size_raises_the_limit_and_refuses_bad_sizes() {
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).arg("--help").output().unwrap();
assert!(String::from_utf8_lossy(&output.stdout).contains("--max-file-size"));
for size in ["17GB", "2", "lots"] {
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).args(["--max-file-size", size, "exec", "file.new"]).output().unwrap();
assert!(!output.status.success(), "{size} was accepted");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.starts_with("error: --max-file-size"), "{size}: {stderr}");
}
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).args(["exec", "file.new", "--max-file-size=2GB"]).output().unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
// A sparse design file just over the default 256 MB: refused unread, then read with the flag
// (and refused for what it holds, which is no design).
let dir = std::env::temp_dir().join(format!("solidcraft-cli-4086-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("big.solidcraft");
std::fs::File::create(&path).unwrap().set_len((256 << 20) + 1).unwrap();
let path = path.to_string_lossy().to_string();
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).args(["eval", &path]).output().unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(!output.status.success() && stderr.contains("too large") && stderr.contains("--max-file-size"), "{stderr}");
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).args(["--max-file-size", "1GB", "eval", &path]).output().unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(!output.status.success() && !stderr.contains("too large"), "{stderr}");
let _ = std::fs::remove_dir_all(&dir);
}
+7 -5
View File
@@ -139,11 +139,13 @@ fn eval_json_out_holds_what_stdout_would() {
fn json_out_needs_a_file_and_fails_when_it_cannot_write() {
let dir = Scratch::new();
let script = dir.script();
for cmd in ["run", "eval"] {
let output = cli().arg(cmd).arg(&script).arg("--json-out").output().unwrap();
assert_eq!(output.status.code(), Some(1), "{cmd}");
assert!(String::from_utf8_lossy(&output.stderr).contains("--json-out needs a FILE"), "{cmd}");
assert!(output.stdout.is_empty(), "{cmd}");
for cmd in ["run", "eval", "inspect"] {
for trailing in [&[][..], &["--in"][..]] {
let output = cli().arg(cmd).arg(&script).arg("--json-out").args(trailing).output().unwrap();
assert_eq!(output.status.code(), Some(1), "{cmd}");
assert!(String::from_utf8_lossy(&output.stderr).contains("--json-out needs a FILE"), "{cmd}");
assert!(output.stdout.is_empty(), "{cmd}");
}
let missing = dir.0.join("missing").join("out.json");
let output = cli().arg(cmd).arg(&script).arg("--json-out").arg(&missing).output().unwrap();
+112
View File
@@ -151,3 +151,115 @@ fn mcp_without_arguments_finds_the_running_app() {
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();
}
/// `mcp setup` against a temporary home: the client folders it finds are the only ones it writes.
mod setup {
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
use std::process::Command;
fn home(name: &str) -> PathBuf {
let h = std::env::temp_dir().join(format!("solidcraft-mcp-setup-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&h);
std::fs::create_dir_all(&h).unwrap();
h
}
fn run(home: &Path, args: &[&str]) -> std::process::Output {
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
.args(["mcp", "setup"])
.args(args)
.env_clear()
.env("HOME", home)
.env("USERPROFILE", home)
.env("APPDATA", home.join("AppData/Roaming"))
.env("LOCALAPPDATA", home.join("AppData/Local"))
.env("XDG_CONFIG_HOME", home.join(".config"))
.env("PATH", "")
.output()
.unwrap()
}
fn json_of(p: &Path) -> Value {
serde_json::from_str(&std::fs::read_to_string(p).unwrap()).unwrap()
}
fn exe() -> String {
let p = Path::new(env!("CARGO_BIN_EXE_solidcraft-cli")).canonicalize().unwrap();
p.to_string_lossy().trim_start_matches(r"\\?\").to_owned()
}
#[test]
fn merges_into_opencode_and_cursor_keeping_other_servers() {
let h = home("merge");
let oc = h.join(".config/opencode");
std::fs::create_dir_all(&oc).unwrap();
let old = json!({"$schema": "https://opencode.ai/config.json", "theme": "x", "mcp": {"other": {"type": "local", "command": ["o"]}}});
std::fs::write(oc.join("opencode.json"), old.to_string()).unwrap();
std::fs::create_dir_all(h.join(".cursor")).unwrap();
std::fs::write(h.join(".cursor/mcp.json"), r#"{"mcpServers": {"other": {"command": "o"}}}"#).unwrap();
let out = run(&h, &["--live"]);
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let v = json_of(&oc.join("opencode.json"));
assert_eq!(v["theme"], "x");
assert_eq!(v["mcp"]["other"], old["mcp"]["other"]);
assert_eq!(v["mcp"]["solidcraft"], json!({"type": "local", "command": [exe(), "mcp"], "enabled": true}));
assert_eq!(v["mcp"]["solidcraft-live"]["command"], json!([exe(), "mcp", "--connect", "127.0.0.1:7878"]));
assert_eq!(json_of(&oc.join("opencode.json.bak")), old);
let c = json_of(&h.join(".cursor/mcp.json"));
assert_eq!(c["mcpServers"]["other"], json!({"command": "o"}));
assert_eq!(c["mcpServers"]["solidcraft"], json!({"command": exe(), "args": ["mcp"]}));
// Codex (TOML) and Claude Code (a big file the CLI owns) are edited in place too.
std::fs::create_dir_all(h.join(".codex")).unwrap();
std::fs::write(h.join(".codex/config.toml"), "# mine\nmodel = \"x\"\n").unwrap();
let cc = "{\n \"numStartups\": 3,\n \"projects\": {}\n}\n";
std::fs::write(h.join(".claude.json"), cc).unwrap();
assert!(run(&h, &[]).status.success());
let toml = std::fs::read_to_string(h.join(".codex/config.toml")).unwrap();
assert!(toml.starts_with("# mine\nmodel = \"x\"\n") && toml.contains("[mcp_servers.solidcraft]"), "{toml}");
let c = json_of(&h.join(".claude.json"));
assert_eq!(c["numStartups"], 3);
assert_eq!(c["mcpServers"]["solidcraft"], json!({"type": "stdio", "command": exe(), "args": ["mcp"]}));
assert_eq!(std::fs::read_to_string(h.join(".claude.json.bak")).unwrap(), cc);
// Running again updates the same entries and leaves one of each.
assert!(run(&h, &[]).status.success());
assert_eq!(json_of(&h.join(".cursor/mcp.json"))["mcpServers"].as_object().unwrap().len(), 3);
}
#[test]
fn dry_run_writes_nothing_and_missing_clients_are_skipped() {
let h = home("dry");
std::fs::create_dir_all(h.join(".cursor")).unwrap();
let out = run(&h, &["--dry-run"]);
assert!(out.status.success());
let text = String::from_utf8_lossy(&out.stdout);
assert!(text.contains("would write") && text.contains("solidcraft"), "{text}");
assert!(!h.join(".cursor/mcp.json").exists());
assert!(!h.join(".config/opencode").exists(), "OpenCode was not installed, so nothing is created for it");
let out = run(&h, &["--client", "claude-desktop"]);
assert!(String::from_utf8_lossy(&out.stdout).contains("not found"));
}
#[test]
fn a_file_that_is_not_json_is_left_alone() {
let h = home("bad");
std::fs::create_dir_all(h.join(".cursor")).unwrap();
std::fs::write(h.join(".cursor/mcp.json"), "{ // comment").unwrap();
let out = run(&h, &[]);
assert!(!out.status.success());
assert_eq!(std::fs::read_to_string(h.join(".cursor/mcp.json")).unwrap(), "{ // comment");
}
#[test]
fn claude_desktop_msix_folder_is_found() {
let h = home("msix");
let pkg = h.join("AppData/Local/Packages/Claude_abc/LocalCache/Roaming/Claude");
std::fs::create_dir_all(&pkg).unwrap();
// The MSIX folder is only read through LOCALAPPDATA, set for every platform here.
assert!(run(&h, &["--client", "claude-desktop"]).status.success());
assert!(json_of(&pkg.join("claude_desktop_config.json"))["mcpServers"]["solidcraft"].is_object());
}
}
+44
View File
@@ -288,3 +288,47 @@ fn undoing_an_edit_after_opening_reuses_the_saved_results() {
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(format!("{}.bak", path.display()));
}
/// A design saved before body names were pinned (#6403) pins them as it opens, as its first
/// command would, and opening it is no change. That command pinned them as part of itself, so
/// undoing it went back to the unpinned design, which no evaluated result matched: the first
/// undo after opening from the command line evaluated every feature again (82-93 s in a
/// 5,484-feature design); `doc.open` pinned them after opening, which marked the design changed.
#[test]
fn a_design_saved_before_names_were_pinned_opens_pinned_and_unchanged() {
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 mut doc = built.design().into_owned();
for f in &mut doc.features {
f.body_names.clear();
}
doc.reserved_body_names.clear();
let unpinned = Session::new(doc);
let design = unpinned.design();
let saved = unpinned.model.saved(&design).unwrap();
let bytes = solidcraft_engine::io::write_design_container_with_cache(&design, solidcraft_engine::io::DesignCompression::Fast, None, Some(&saved))
.unwrap();
let path = temp("unpinned");
std::fs::write(&path, &bytes).unwrap();
let file = path.to_string_lossy().to_string();
// As the command line opens a design (`load`), and as the app does (`doc.open`).
let (doc, saved) = solidcraft_engine::io::read_design_file(&file, None::<fn(solidcraft_engine::io::DesignPreview)>).unwrap();
for (how, mut s) in [("opened_at", Session::opened_at(doc, saved.as_ref(), Some(&file))), ("doc.open", open(&path))] {
assert_eq!(s.model.last_recomputed, 0, "{how}: taken from the file");
assert!(s.doc.features.iter().any(|f| !f.body_names.is_empty()), "{how}: the names are pinned");
assert!(!s.is_dirty(), "{how}: opening is no change");
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();
// As in a large design, whose evaluation cache keeps only a few results: undo finds the
// results in the model itself or evaluates them again.
*s.model.cache.lock().unwrap() = Default::default();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.model.last_recomputed, 0, "{how}: the first undo evaluates nothing");
assert!(s.model.results.iter().zip(&read).all(|(r, st)| Arc::ptr_eq(&r.state, st)), "{how}: the states read from the file");
assert!(!s.is_dirty(), "{how}: undone to the design as opened");
}
let _ = std::fs::remove_file(&path);
}
@@ -75,12 +75,15 @@ fn designs() -> Vec<(String, Session)> {
.unwrap()
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|x| x == "solidcraft" || x == "json") && !p.to_string_lossy().ends_with(".expected.json"))
.filter(|p| {
(solidcraft_engine::io::is_design_container_path(p) || p.extension().is_some_and(|x| x == "json"))
&& !p.to_string_lossy().ends_with(".expected.json")
})
.collect();
files.sort();
for p in files {
let name = p.file_stem().unwrap_or_default().to_string_lossy().to_string();
let s = if p.extension().is_some_and(|x| x == "solidcraft") {
let s = if solidcraft_engine::io::is_design_container_path(&p) {
open(&p)
} else {
let mut s = Session::default();
@@ -150,7 +153,7 @@ fn pi_case_faces_per_step_at_both_heights() {
const FACES: [(usize, usize); 13] =
[(2, 6), (3, 10), (4, 19), (5, 19), (6, 25), (7, 43), (8, 43), (9, 55), (10, 55), (11, 59), (12, 59), (13, 63), (14, 79)];
let dir = scratch_dir("pi");
let design: Value = serde_json::from_slice(&std::fs::read(root().join("tests/action_matrix/designs/pi-case.solidcraft")).unwrap()).unwrap();
let design: Value = serde_json::from_slice(&std::fs::read(root().join("tests/action_matrix/designs/pi-case.sc")).unwrap()).unwrap();
let at = |height: &str, file: &str| {
let mut d = design.clone();
for p in d["params"].as_array_mut().unwrap() {
+281 -1
View File
@@ -22,6 +22,19 @@ impl Scratch {
std::fs::write(&path, json!({"commands": [{"command": "solid.box", "params": {"length": 2, "width": 3, "height": 4}}]}).to_string()).unwrap();
path
}
fn saving_script(&self) -> (PathBuf, PathBuf) {
let path = self.0.join("saving.json");
let saved = self.0.join("script-ran.solidcraft");
std::fs::write(
&path,
json!({"commands": [{"command": "solid.box", "params": {"length": 2, "width": 3, "height": 4}},
{"command": "file.save_as", "params": {"path": saved}}]})
.to_string(),
)
.unwrap();
(path, saved)
}
}
impl Drop for Scratch {
@@ -43,10 +56,96 @@ fn body_count(path: &Path) -> Value {
json_output(cli().arg("eval").arg(path).output().unwrap())["body_count"].clone()
}
#[test]
fn run_rejects_invalid_options_before_script_execution_or_output_writes() {
let dir = Scratch::new();
let (script, script_saved) = dir.saving_script();
let cases = [
(&["--out"][..], "--out needs a value"),
(&["--save"][..], "--save needs a value"),
(&["--in"][..], "--in needs a value"),
(&["--out", "--quiet"][..], "--out needs a value"),
(&["--ouut", "part.step"][..], "doesn't take --ouut"),
(&["--unknown"][..], "doesn't take --unknown"),
];
for existing in [false, true] {
let saved = dir.0.join(format!("{existing}.solidcraft"));
let exported = dir.0.join(format!("{existing}.step"));
let report = dir.0.join(format!("{existing}.json"));
let outputs = [&saved, &exported, &report];
if existing {
for path in outputs {
std::fs::write(path, b"existing output").unwrap();
}
}
for (extra, message) in cases {
let output = cli()
.arg("run")
.arg(&script)
.arg("--save")
.arg(&saved)
.arg("--out")
.arg(&exported)
.arg("--json-out")
.arg(&report)
.args(extra)
.output()
.unwrap();
assert_eq!(output.status.code(), Some(1), "{extra:?}");
assert!(String::from_utf8_lossy(&output.stderr).contains(message), "{extra:?}: {}", String::from_utf8_lossy(&output.stderr));
assert!(output.stdout.is_empty());
assert!(!script_saved.exists(), "invalid arguments must be rejected before the script runs");
for path in outputs {
if existing {
assert_eq!(std::fs::read(path).unwrap(), b"existing output");
} else {
assert!(!path.exists());
}
}
}
}
}
#[test]
fn eval_and_inspect_reject_invalid_options_before_script_execution_or_reporting() {
let dir = Scratch::new();
let (script, script_saved) = dir.saving_script();
let cases = [
(&["--in"][..], "--in needs a value"),
(&["--in", "--quiet"][..], "--in needs a value"),
(&["--out", "part.step"][..], "doesn't take --out"),
(&["--unknown"][..], "doesn't take --unknown"),
];
for command in ["eval", "inspect"] {
for existing in [false, true] {
let report = dir.0.join(format!("{command}-{existing}.json"));
if existing {
std::fs::write(&report, b"existing report").unwrap();
}
for (extra, message) in cases {
let output = cli().arg(command).arg(&script).arg("--json-out").arg(&report).args(extra).output().unwrap();
assert_eq!(output.status.code(), Some(1), "{command} {extra:?}");
assert!(
String::from_utf8_lossy(&output.stderr).contains(message),
"{command} {extra:?}: {}",
String::from_utf8_lossy(&output.stderr)
);
assert!(output.stdout.is_empty());
assert!(!script_saved.exists(), "invalid arguments must be rejected before the script runs");
if existing {
assert_eq!(std::fs::read(&report).unwrap(), b"existing report");
} else {
assert!(!report.exists());
}
}
}
}
}
#[test]
fn cli_loads_both_design_encodings_by_contents() {
let dir = Scratch::new();
for extension in ["solidcraft", "solvecraft", "json"] {
for extension in ["sc", "solidcraft", "solvecraft", "json"] {
let saved = dir.0.join(format!("box.{extension}"));
json_output(cli().arg("run").arg(dir.script()).arg("--save").arg(&saved).output().unwrap());
let bytes = std::fs::read(&saved).unwrap();
@@ -168,6 +267,110 @@ fn results_with_base_design_and_quiet_still_report_and_save() {
assert_eq!(body_count(&base), 1);
}
/// `eval` and its alias must measure the script applied to the input design, rather than
/// silently reporting the input alone (#6861).
#[test]
fn eval_runs_its_script_on_the_in_design() {
let dir = Scratch::new();
let base = dir.0.join("base.solidcraft");
let script = dir.0.join("add.json");
json_output(cli().arg("run").arg(dir.script()).arg("--save").arg(&base).output().unwrap());
let original = std::fs::read(&base).unwrap();
std::fs::write(
&script,
json!({"commands": [{"command": "solid.box", "params": {"length": 5, "width": 3, "height": 4, "corner": [10, 0, 0]}}]}).to_string(),
)
.unwrap();
for command in ["eval", "inspect"] {
// Both existing one-input forms still measure just the original body.
for input in [&base, &dir.script()] {
for named_input in [false, true] {
let mut c = cli();
c.arg(command);
if named_input {
c.arg("--in");
}
let measured = json_output(c.arg(input).output().unwrap());
assert_eq!(measured["body_count"], 1);
assert!((measured["total"]["volume_mm3"].as_f64().unwrap() - 24.0).abs() < 1e-6);
}
}
for input_first in [false, true] {
for to_file in [false, true] {
let mut c = cli();
c.arg(command);
if input_first {
c.arg("--in").arg(&base).arg(&script);
} else {
c.arg(&script).arg("--in").arg(&base);
}
let report = dir.0.join("measurement.json");
if to_file {
c.arg("--json-out").arg(&report);
}
let output = c.output().unwrap();
let measured = if to_file {
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
assert!(output.stdout.is_empty());
serde_json::from_slice(&std::fs::read(&report).unwrap()).unwrap()
} else {
json_output(output)
};
assert_eq!(measured["body_count"], 2, "{command}, input_first={input_first}, to_file={to_file}");
assert!((measured["total"]["volume_mm3"].as_f64().unwrap() - 84.0).abs() < 1e-6);
assert_eq!(measured["timeline"].as_array().unwrap().len(), 2, "the script runs exactly once");
assert_eq!(std::fs::read(&base).unwrap(), original, "evaluation never saves its changes to the input");
}
}
}
}
#[test]
fn eval_propagates_script_errors_before_writing_a_report() {
let dir = Scratch::new();
let base = dir.script();
let script = dir.0.join("bad.json");
for command in ["eval", "inspect"] {
for contents in ["{", r#"{"commands":[{"command":"missing.command","params":{}}]}"#] {
std::fs::write(&script, contents).unwrap();
let report = dir.0.join("report.json");
let output = cli().arg(command).arg(&script).arg("--in").arg(&base).arg("--json-out").arg(&report).output().unwrap();
assert_eq!(output.status.code(), Some(1));
assert!(!output.stderr.is_empty());
assert!(output.stdout.is_empty());
assert!(!report.exists());
}
}
}
#[test]
fn eval_rejects_unknown_options_and_missing_input_values_before_writing_a_report() {
let dir = Scratch::new();
let script = dir.script();
let report = dir.0.join("report.json");
for command in ["eval", "inspect"] {
for (options, message) in [(&["--frobnicate", "1"][..], "doesn't take --frobnicate"), (&["--in"][..], "--in needs a value")] {
for options_first in [false, true] {
std::fs::write(&report, b"previous report").unwrap();
let mut c = cli();
c.arg(command);
if options_first {
c.args(options).arg("--json-out").arg(&report).arg(&script);
} else {
c.arg(&script).args(options).arg("--json-out").arg(&report);
}
// A following option also proves that --in cannot consume a flag as its value.
let output = c.output().unwrap();
assert_eq!(output.status.code(), Some(1), "{command}, {options:?}");
let error = String::from_utf8_lossy(&output.stderr);
assert!(error.contains(message), "{command}: {error}");
assert!(output.stdout.is_empty());
assert_eq!(std::fs::read(&report).unwrap(), b"previous report");
}
}
}
}
#[test]
fn results_propagate_output_errors() {
let dir = Scratch::new();
@@ -209,6 +412,11 @@ fn legacy_extension_and_format_tag_still_open() {
assert_eq!(body_count(&resaved), 2);
let reopened = solidcraft_engine::io::read_design(&std::fs::read(&resaved).unwrap()).unwrap();
assert!(reopened.to_json().contains("\"solidcraft/"));
// `.solidcraft` (before `.sc`) opens as `--in` and as the design to evaluate; `.sc` too.
let current = dir.0.join("current.sc");
json_output(cli().args(["run", "--in"]).arg(&resaved).arg(dir.script()).arg("--save").arg(&current).output().unwrap());
assert_eq!(body_count(&current), 3);
assert!(solidcraft_engine::io::is_design_container(&std::fs::read(&current).unwrap()), "a .sc file is a design container");
}
/// `snapshot` starts from `--in` as `run` does (its script runs on that design), and refuses an
@@ -288,6 +496,78 @@ fn timings_and_profile_report_each_command() {
assert!(String::from_utf8_lossy(&output.stderr).contains("--timings needs a script"));
}
/// #6921: `--param-check` checks a script's steps against their contracts on every path `run`
/// takes (a script alone, on an `--in` design or script, with `--results`, `--timings` or
/// `--profile`), and a warning reaches stderr even when a later step fails.
#[test]
fn param_check_reaches_every_script_run_takes() {
let dir = Scratch::new();
let typo = dir.0.join("typo.json");
let step = json!({"command": "solid.box", "params": {"length": 2, "width": 3, "height": 4, "operaton": "new"}});
std::fs::write(&typo, json!({"commands": [step.clone()]}).to_string()).unwrap();
let base = dir.0.join("base.solidcraft");
json_output(cli().arg("run").arg(dir.script()).arg("--save").arg(&base).output().unwrap());
let saved = dir.0.join("saved.solidcraft");
let start = dir.script();
let (typo, base, saved_arg, start) = (typo.to_str().unwrap(), base.to_str().unwrap(), saved.to_str().unwrap(), start.to_str().unwrap());
let paths = [&[][..], &["--in", base], &["--in", start], &["--results"], &["--timings"], &["--timings", "--profile"]];
let run = |level: Option<&str>, extra: &[&str]| {
std::fs::remove_file(&saved).ok();
let check = level.map(|l| ["--param-check", l]).into_iter().flatten();
cli().args(["run", typo, "--save", saved_arg]).args(check).args(extra).output().unwrap()
};
// Off, the default: the step runs as it always has, without a word.
let off = run(None, &[]);
assert!(off.status.success() && !String::from_utf8_lossy(&off.stderr).contains("operaton"), "{off:?}");
assert_eq!(body_count(&saved), 1);
// Warn: each path runs the step, saves, and reports it on stderr; `--results` keeps it too.
for extra in paths {
let output = run(Some("warn"), extra);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(output.status.success(), "{extra:?}: {stderr}");
assert!(stderr.contains("step 1: unknown key `operaton`; did you mean `operation`?"), "{extra:?}: {stderr}");
assert!(saved.exists(), "{extra:?}");
if extra == ["--results"] {
let results: Value = serde_json::from_slice(&output.stdout).unwrap();
assert!(results[0]["param_warnings"][0].as_str().is_some_and(|w| w.contains("operaton")), "{results}");
}
}
// Strict: on each path the run stops at the step, and nothing is written.
for extra in paths {
let output = run(Some("strict"), extra);
let stderr = String::from_utf8_lossy(&output.stderr);
assert_eq!(output.status.code(), Some(1), "{extra:?}: {stderr}");
assert!(stderr.contains("unknown key `operaton`"), "{extra:?}: {stderr}");
assert!(!saved.exists(), "{extra:?}");
}
// Warn, with a later step that fails: the run fails, and the first step's warning is still
// reported, timed or not.
let failing = dir.0.join("failing.json");
let fail = json!({"command": "solid.extrude", "params": {"sketch": "Nope"}});
std::fs::write(&failing, json!({"commands": [step, fail]}).to_string()).unwrap();
for extra in [&[][..], &["--timings"]] {
let output = cli().arg("run").arg(&failing).args(["--param-check", "warn"]).args(extra).output().unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert_eq!(output.status.code(), Some(1), "{extra:?}: {stderr}");
assert!(stderr.contains("step 1: unknown key `operaton`") && stderr.contains("no sketch `Nope`"), "{extra:?}: {stderr}");
}
// A level it doesn't know and a missing value fail.
for (bad, says) in [
(&["--param-check", "loud"][..], "--param-check wants off, warn or strict, not `loud`"),
(&["--param-check"], "run: --param-check wants off, warn or strict"),
] {
let output = cli().args(["run", typo]).args(bad).output().unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert_eq!(output.status.code(), Some(1), "{bad:?}: {stderr}");
assert!(stderr.contains(says), "{bad:?}: {stderr}");
}
}
fn render_setup_script(dir: &Scratch, job: Value) -> PathBuf {
let path = dir.0.join("render.json");
std::fs::write(
@@ -1,5 +1,5 @@
//! Straight edges stay one edge after many cuts (#1049). The owner's design
//! (`tests/action_matrix/designs/crazy-geom-cuts.solidcraft`, saved by 0.10.0): five extrudes,
//! (`tests/action_matrix/designs/crazy-geom-cuts.sc`, saved by 0.10.0): five extrudes,
//! joins and cuts, over four sketches, three of them on faces of the body with the faces'
//! edges projected. Cuts used to leave the straight edges of the walls they sliced split at
//! vertices nothing else used, so an edge that is one straight line was drawn (and lit on hover)
@@ -14,7 +14,7 @@ use solidcraft_engine::doc::kernel::{FaceSurface, face_surface};
use solidcraft_engine::geom::Vec3;
fn design() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/action_matrix/designs/crazy-geom-cuts.solidcraft")
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/action_matrix/designs/crazy-geom-cuts.sc")
}
fn open(path: &Path) -> Session {
+5 -2
View File
@@ -22,6 +22,8 @@ solidcraft-engine = { workspace = true }
solidcraft-ui-egui = { workspace = true }
egui = { workspace = true }
log = { workspace = true }
url = "2"
percent-encoding = "2"
[target.'cfg(target_arch = "wasm32")'.dependencies]
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
@@ -32,7 +34,8 @@ serde_json = { workspace = true }
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",
"WritableStream", "File", "FileList", "Blob", "BlobPropertyBag", "Url", "HtmlAnchorElement", "HtmlInputElement", "Storage", "StorageEvent",
"IdbFactory", "IdbDatabase", "IdbObjectStore", "IdbOpenDbRequest", "IdbRequest", "IdbTransaction", "IdbTransactionMode",
"BeforeUnloadEvent", "Event", "EventTarget", "KeyboardEvent", "Response", "MediaQueryList", "Gamepad", "Performance",
"Headers", "ReadableStream", "ReadableStreamDefaultReader",
"BeforeUnloadEvent", "Event", "EventTarget", "KeyboardEvent", "Response", "FormData", "RequestInit", "MediaQueryList", "Gamepad", "Performance",
] }
+9 -5
View File
@@ -22,17 +22,21 @@
<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="copy-file" href="../../assets/fonts/craft-ui-sc/CraftUI-SC.otf" data-target-path="fonts" />
<link data-trunk rel="copy-file" href="../../assets/fonts/craft-ui-sc/OFL.txt" data-target-path="fonts/craft-ui-sc" />
<link data-trunk rel="copy-file" href="../../assets/fonts/craft-ui-tc/CraftUI-TC.otf" data-target-path="fonts" />
<link data-trunk rel="copy-file" href="../../assets/fonts/craft-ui-tc/OFL.txt" data-target-path="fonts/craft-ui-tc" />
<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">
<link rel="icon" type="image/png" sizes="32x32" href="icons/32x32/ai.storyteller.solidcraft.png">
<link rel="apple-touch-icon" href="icons/256x256/ai.storyteller.solidcraft.png">
<link rel="icon" type="image/png" sizes="32x32" href="icons/32x32/io.tangencylabs.solidcraft.png">
<link rel="apple-touch-icon" href="icons/256x256/io.tangencylabs.solidcraft.png">
<link data-trunk rel="copy-file" href="manifest.webmanifest" />
<link data-trunk rel="copy-file" href="../../assets/icons/app/linux/exports/hicolor/32x32/apps/ai.storyteller.solidcraft.png" data-target-path="icons/32x32" />
<link data-trunk rel="copy-file" href="../../assets/icons/app/linux/exports/hicolor/256x256/apps/ai.storyteller.solidcraft.png" data-target-path="icons/256x256" />
<link data-trunk rel="copy-file" href="../../assets/icons/app/linux/exports/hicolor/512x512/apps/ai.storyteller.solidcraft.png" data-target-path="icons/512x512" />
<link data-trunk rel="copy-file" href="../../assets/icons/app/linux/exports/hicolor/32x32/apps/io.tangencylabs.solidcraft.png" data-target-path="icons/32x32" />
<link data-trunk rel="copy-file" href="../../assets/icons/app/linux/exports/hicolor/256x256/apps/io.tangencylabs.solidcraft.png" data-target-path="icons/256x256" />
<link data-trunk rel="copy-file" href="../../assets/icons/app/linux/exports/hicolor/512x512/apps/io.tangencylabs.solidcraft.png" data-target-path="icons/512x512" />
</head>
<body>
<canvas id="solidcraft_canvas"></canvas>
+3 -3
View File
@@ -9,8 +9,8 @@
"theme_color": "#343840",
"categories": ["productivity", "utilities"],
"icons": [
{ "src": "icons/32x32/ai.storyteller.solidcraft.png", "sizes": "32x32", "type": "image/png" },
{ "src": "icons/256x256/ai.storyteller.solidcraft.png", "sizes": "256x256", "type": "image/png" },
{ "src": "icons/512x512/ai.storyteller.solidcraft.png", "sizes": "512x512", "type": "image/png" }
{ "src": "icons/32x32/io.tangencylabs.solidcraft.png", "sizes": "32x32", "type": "image/png" },
{ "src": "icons/256x256/io.tangencylabs.solidcraft.png", "sizes": "256x256", "type": "image/png" },
{ "src": "icons/512x512/io.tangencylabs.solidcraft.png", "sizes": "512x512", "type": "image/png" }
]
}
+111 -14
View File
@@ -114,10 +114,70 @@ 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") }
/// Where an Open for `extensions` (what the command reads; `&[]`: anything) is answered.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum OpenRoute {
/// The browser's open dialog for a design: the file keeps its handle, so Save writes back.
Disk,
/// The "Files in this browser" window, where the browser can't open a design from the disk.
Panel,
/// A plain file input that offers these types: the file is read into the engine's files and
/// the command runs on it (Insert DXF or SVG, images, meshes, imported settings).
Upload,
}
/// Choose how an Open for `extensions` asks for its file; `api`: the browser can open a file
/// from the disk and keep its handle.
pub fn open_route(api: bool, extensions: &[&str]) -> OpenRoute {
match (opens_design(extensions), api) {
(true, true) => OpenRoute::Disk,
(true, false) => OpenRoute::Panel,
(false, _) => OpenRoute::Upload,
}
}
/// The `accept` list of a file input for `extensions` (with or without their dot): ".dxf,.svg".
pub fn accept_list(extensions: &[&str]) -> String {
let list: Vec<String> = extensions
.iter()
.map(|e| e.trim().trim_start_matches('.'))
.filter(|e| !e.is_empty())
.map(|e| format!(".{}", e.to_ascii_lowercase()))
.collect();
list.join(",")
}
/// The `accept` of a file input for `extensions` on this device (`apple_touch`: an iPhone or
/// iPad). The picker there offers only files of a type the system knows from the list and greys
/// out the rest, `.solidcraft` designs among them, since no system type has that extension
/// (#6977). So it offers every file there, and the opener checks what it gets.
pub fn input_accept(extensions: &[&str], apple_touch: bool) -> String {
if apple_touch { String::new() } else { accept_list(extensions) }
}
/// The browser runs on an iPhone or iPad (every browser there is WebKit), from its
/// `navigator.userAgent` and `navigator.maxTouchPoints`. iPadOS presents itself as a Mac; only its
/// touch screen tells them apart.
pub fn apple_touch(user_agent: &str, touch_points: i32) -> bool {
["iPhone", "iPad", "iPod"].iter().any(|d| user_agent.contains(d)) || (user_agent.contains("Macintosh") && touch_points > 1)
}
/// The largest file an upload for `extensions` reads: images go into a canvas, which has a size
/// limit, so a bigger one is refused before it is read. `None`: the importer checks the file.
pub fn upload_limit(extensions: &[&str]) -> Option<usize> {
let image = |e: &&str| ["png", "jpg", "jpeg"].contains(&e.trim_start_matches('.').to_ascii_lowercase().as_str());
(!extensions.is_empty() && extensions.iter().all(image)).then_some(solidcraft_engine::doc::canvas::MAX_CANVAS_BYTES)
}
/// The file type a Save As dialog offers for `name`: (description, MIME type, extension). A
/// design is `.sc` unless the name already has an older design extension, which it keeps.
pub fn save_type(name: &str) -> (&'static str, &'static str, String) {
let ext = std::path::Path::new(name).extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase);
match ext.as_deref() {
Some("json") => ("Plain JSON design", "application/json", ".json".into()),
Some(e) if solidcraft_engine::io::is_design_ext(e) => ("SolidCraft design", "application/octet-stream", format!(".{e}")),
_ => ("SolidCraft design", "application/octet-stream", format!(".{}", solidcraft_engine::io::DESIGN_EXT)),
}
}
/// Is this a cancelled dialog (the browser's `AbortError`)? Not an error to show.
@@ -140,7 +200,7 @@ mod web {
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 super::{HANDLE_MAX_AGE_MS, is_abort, make_id, parse, path, plan_prune, save_type};
use crate::files::{Files, err, read_handle};
const DB: &str = "solidcraft-disk-handles";
@@ -209,7 +269,7 @@ mod web {
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));
let _ = Reflect::set(&options, &"types".into(), &types(description, mime, &extension));
options
}
@@ -294,7 +354,9 @@ mod web {
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"] {
let designs = solidcraft_engine::io::DESIGN_EXTS.iter().map(|e| format!(".{e}"));
let others = [".json", ".step", ".stp", ".3mf", ".stl", ".obj", ".glb", ".gltf", ".fcstd", ".3dm", ".dxf", ".svg"].map(String::from);
for e in designs.chain(others) {
extensions.push(&e.into());
}
let accept = Object::new();
@@ -315,11 +377,6 @@ mod web {
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
@@ -577,6 +634,8 @@ mod tests {
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);
// The temporary folder (a screenshot without a path, #7192) stays in memory.
assert_eq!(route(&format!("{}/solidcraft-shot.png", solidcraft_engine::io::vfs::BROWSER_TEMP)), WriteTo::Memory);
}
#[test]
@@ -615,17 +674,55 @@ mod tests {
#[test]
fn only_design_opens_use_the_disk_dialog() {
assert!(opens_design(&[]));
assert!(opens_design(&["sc"]));
assert!(opens_design(&["solidcraft"]));
assert!(opens_design(&["solvecraft"]));
assert!(!opens_design(&["step", "stp"]));
assert!(!opens_design(&["json"]));
}
/// Insert DXF or SVG, images and other non-design files get a file input that offers their
/// types in every browser (#2052); designs keep the disk dialog or the browser's files.
#[test]
fn opens_of_other_files_use_a_file_input_for_their_types() {
assert_eq!(open_route(true, &["dxf"]), OpenRoute::Upload);
assert_eq!(open_route(false, &["png", "jpg", "jpeg"]), OpenRoute::Upload);
assert_eq!(open_route(true, &[]), OpenRoute::Disk);
assert_eq!(open_route(false, &["solidcraft"]), OpenRoute::Panel);
assert_eq!(accept_list(&["dxf"]), ".dxf");
assert_eq!(accept_list(&["png", ".JPG", "", "jpeg"]), ".png,.jpg,.jpeg");
assert_eq!(accept_list(&[]), "");
assert!(upload_limit(&["png", "jpg", "jpeg"]).is_some());
assert_eq!(upload_limit(&["dxf"]), None);
}
/// An iPhone or iPad greys out a `.solidcraft` design the `accept` list names, as it knows no
/// type for that extension, so its inputs offer every file; other devices keep the list (#6977).
#[test]
fn file_inputs_on_an_iphone_or_ipad_offer_every_file() {
let iphone =
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1";
let mac = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15";
let chrome_ios = "Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/138.0.7204.156 Mobile/15E148 Safari/604.1";
let chrome = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36";
assert!(apple_touch(iphone, 5));
assert!(apple_touch(chrome_ios, 5), "every browser on iOS has the same picker");
assert!(apple_touch(mac, 5), "an iPad presents itself as a Mac");
assert!(!apple_touch(mac, 0), "a Mac has no touch screen");
assert!(!apple_touch(chrome, 10), "a touch screen elsewhere filters as before");
assert!(!apple_touch("", 0), "hostile input is just not an iPhone");
let designs = ["solidcraft", "json", "step"];
assert_eq!(input_accept(&designs, true), "");
assert_eq!(input_accept(&designs, false), ".solidcraft,.json,.step");
}
#[test]
fn the_save_dialog_offers_the_type_of_the_name() {
assert_eq!(save_type("Plate.solidcraft").2, ".solidcraft");
assert_eq!(save_type("Plate.sc").2, ".sc");
assert_eq!(save_type("Plate.JSON").2, ".json");
assert_eq!(save_type("Plate").2, ".solidcraft");
assert_eq!(save_type("Plate").2, ".sc");
// A design opened under an older extension keeps it.
assert_eq!(save_type("Plate.solidcraft").2, ".solidcraft");
}
#[test]
+40
View File
@@ -0,0 +1,40 @@
//! Open with EasyPrint in the browser: upload the model to Printables' import endpoint with
//! `fetch` (it allows any origin) and answer with the address to open.
use std::sync::mpsc::Sender;
use js_sys::{Array, Uint8Array};
use solidcraft_engine::easyprint::{IMPORT_URL, parse_import_response};
use wasm_bindgen::JsCast as _;
use wasm_bindgen_futures::{JsFuture, spawn_local};
use crate::files::err;
/// Start the upload; the answer arrives on `done`.
pub fn upload(name: &str, bytes: Vec<u8>, done: Sender<Result<String, String>>) {
let name = name.to_string();
spawn_local(async move {
let _ = done.send(post(&name, &bytes).await);
});
}
async fn post(name: &str, bytes: &[u8]) -> Result<String, String> {
let window = web_sys::window().ok_or("no window")?;
let parts = Array::new();
parts.push(&Uint8Array::from(bytes));
let blob = web_sys::Blob::new_with_u8_array_sequence(&parts).map_err(err)?;
let form = web_sys::FormData::new().map_err(err)?;
form.append_with_blob_and_filename("file", &blob, name).map_err(err)?;
form.append_with_str("file_name", name).map_err(err)?;
let headers = web_sys::Headers::new().map_err(err)?;
headers.set("Accept", "application/json").map_err(err)?;
let init = web_sys::RequestInit::new();
init.set_method("POST");
init.set_body(&form);
init.set_headers(&headers);
let response = JsFuture::from(window.fetch_with_str_and_init(IMPORT_URL, &init)).await.map_err(err)?;
let response: web_sys::Response = response.dyn_into().map_err(|_| "response cast failed".to_string())?;
let status = response.status();
let text = JsFuture::from(response.text().map_err(err)?).await.map_err(err)?;
parse_import_response(status, text.as_string().unwrap_or_default().as_bytes())
}
+104 -13
View File
@@ -24,11 +24,13 @@ pub const OPFS: &str = disk::OPFS_PREFIX;
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`).
const RECOVERY: &str = "recovery.solidcraft";
/// The autosave earlier releases kept (compatibility shim, docs/compatibility.md): recovered
/// when there is no current one.
const LEGACY_RECOVERY: &str = "recovery.solvecraft";
/// The autosave of unsaved work (in OPFS: `recovery.sc` and `recovery.json`), named with
/// `solidcraft_engine::io::DESIGN_EXT`.
const RECOVERY: &str = "recovery.sc";
/// The autosaves earlier releases kept, newest first, one per entry of
/// `solidcraft_engine::io::LEGACY_DESIGN_EXTS` (compatibility shim, docs/compatibility.md): the
/// newest is recovered when there is no current one.
const LEGACY_RECOVERY: [&str; 2] = ["recovery.solidcraft", "recovery.solvecraft"];
const RECOVERY_META: &str = "recovery.json";
/// What to do with a file brought in from the computer.
@@ -38,6 +40,8 @@ pub enum Intent {
Open,
/// Add its bodies to the open design (STEP, 3MF, STL).
Insert,
/// Import it as a theme: it was dropped on Preferences' Import tile (#8226).
Theme,
}
/// A stored design, as the file panel lists it.
@@ -282,9 +286,10 @@ pub fn start(files: &Rc<Files>) {
}
let mut entries = Vec::new();
let has_recovery = all.iter().any(|(n, _, _)| n == RECOVERY);
let legacy = (!has_recovery).then(|| LEGACY_RECOVERY.into_iter().find(|l| all.iter().any(|(n, _, _)| n == l))).flatten();
for (name, bytes, modified) in all {
if name == LEGACY_RECOVERY {
if !has_recovery {
if LEGACY_RECOVERY.contains(&name.as_str()) {
if legacy == Some(name.as_str()) {
vfs::insert(&format!("{OPFS}{RECOVERY}"), bytes);
}
continue;
@@ -424,7 +429,9 @@ pub fn autosave(files: &Rc<Files>, doc: Option<(&solidcraft_engine::doc::Documen
}
None => spawn_local(async move {
let _ = remove_file(RECOVERY).await;
let _ = remove_file(LEGACY_RECOVERY).await;
for legacy in LEGACY_RECOVERY {
let _ = remove_file(legacy).await;
}
let _ = remove_file(RECOVERY_META).await;
}),
}
@@ -448,6 +455,18 @@ pub fn recovery_path() -> String {
format!("{OPFS}{RECOVERY}")
}
/// A recovery file lives in OPFS, but its relative links belong to the original design's
/// folder (which can be on disk). Resolve them before Open sees the temporary file location.
pub fn prepare_recovery(original_path: Option<&str>) -> Result<String, String> {
let path = recovery_path();
let bytes = vfs::read(&path, solidcraft_engine::io::MAX_DESIGN_BYTES).map_err(|e| e.to_string())?;
let mut doc = solidcraft_engine::io::read_design(&bytes).map_err(|e| e.to_string())?;
solidcraft_engine::resolve_design_links(&mut doc, original_path);
let bytes = solidcraft_engine::io::write_design_container(&doc).map_err(|e| e.to_string())?;
vfs::insert(&path, bytes);
Ok(path)
}
/// Pick files on the computer: the File System Access picker where there is one, else an
/// `<input type=file>`. Read files arrive in `files.incoming`.
pub fn pick_from_disk(files: &Rc<Files>, intent: Intent) {
@@ -500,9 +519,10 @@ fn input_picker(files: &Rc<Files>, intent: Intent) {
let Ok(input) = doc.create_element("input").map(|e| e.unchecked_into::<web_sys::HtmlInputElement>()) else { return };
input.set_type("file");
// 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);
// FreeCAD and Rhino files are part of it; plus the DXF and SVG drawings Open and Insert take.
let mut extensions = solidcraft_engine::io::formats::open_extensions(true);
extensions.extend(["dxf", "svg"]);
input.set_accept(&disk::input_accept(&extensions, apple_touch()));
// 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() {
@@ -527,14 +547,85 @@ fn input_picker(files: &Rc<Files>, intent: Intent) {
input.click();
}
/// Ask for one file of these types (`extensions`, e.g. `["dxf"]`) with a plain file input, the
/// dialog every browser has. The answer is the file's path in the engine's files (under
/// [`UPLOAD`], as for a file picked in the disk dialog), so the command that asked runs on it;
/// `None` when the dialog was closed or the file could not be read.
pub fn pick_upload(files: &Rc<Files>, extensions: &[&str]) -> impl std::future::Future<Output = Option<String>> + use<> {
// Shown now, in the click: a file dialog needs the user's gesture.
let chosen = choose_file(&disk::input_accept(extensions, apple_touch()));
let limit = disk::upload_limit(extensions);
let files = files.clone();
async move {
let read = async {
let value = JsFuture::from(chosen?).await.map_err(err)?;
// Closing the dialog answers no file: nothing to say.
let Ok(file) = value.dyn_into::<web_sys::File>() else { return Ok(None) };
let name = file.name();
if limit.is_some_and(|max| !file.size().is_finite() || file.size() > max as f64) {
return Err(format!("{name}: the file is too large"));
}
let bytes = blob_bytes(&file).await.map_err(|e| format!("{name}: {e}"))?;
let path = format!("{UPLOAD}{name}");
vfs::insert(&path, bytes);
Ok(Some(path))
};
match read.await {
Ok(path) => path,
Err(e) => {
files.say(format!("open: {e}"), true);
None
}
}
}
}
/// The page runs on an iPhone or iPad, whose file inputs offer every file (`disk::input_accept`).
fn apple_touch() -> bool {
web_sys::window().is_some_and(|w| {
let nav = w.navigator();
disk::apple_touch(&nav.user_agent().unwrap_or_default(), nav.max_touch_points())
})
}
/// Show a hidden `<input type=file>` offering `accept` (call in the click). The promise answers
/// the picked `File`, or `null` when the dialog is closed (the input's `cancel` event).
fn choose_file(accept: &str) -> Result<js_sys::Promise, String> {
let doc = web_sys::window().and_then(|w| w.document()).ok_or("this page can't show a file dialog")?;
let input = doc.create_element("input").map_err(err)?.dyn_into::<web_sys::HtmlInputElement>().map_err(|_| "no file input".to_string())?;
input.set_type("file");
input.set_accept(accept);
// Attached (hidden) while it is open: some browsers ignore a click on a detached input.
input.set_attribute("style", "display:none").map_err(err)?;
doc.body().ok_or("this page has no body")?.append_child(&input).map_err(err)?;
let promise = js_sys::Promise::new(&mut |resolve: Function, _reject: Function| {
// One answer for a pick or a cancel; read from the event, so it does not keep the input.
let done = Closure::once_into_js(move |event: web_sys::Event| {
let file = event.target().and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok()).and_then(|input| {
input.set_onchange(None);
input.set_oncancel(None);
input.remove();
input.files().and_then(|list| list.get(0))
});
let _ = resolve.call1(&JsValue::UNDEFINED, &file.map_or(JsValue::NULL, JsValue::from));
});
input.set_onchange(Some(done.unchecked_ref()));
input.set_oncancel(Some(done.unchecked_ref()));
});
input.click();
Ok(promise)
}
/// Read files dropped on the page.
pub fn take_drops(files: &Rc<Files>, raw: &mut egui::RawInput) {
/// `theme`: the pointer is over Preferences' Import tile, so the files are themes.
pub fn take_drops(files: &Rc<Files>, raw: &mut egui::RawInput, theme: bool) {
let intent = if theme { Intent::Theme } else { Intent::Insert };
for f in std::mem::take(&mut raw.dropped_files) {
let files = files.clone();
spawn_local(async move {
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
match f.bytes_async().await {
Ok(b) => files.arrive(name, b, Intent::Insert),
Ok(b) => files.arrive(name, b, intent),
Err(e) => files.say(format!("{name}: {e}"), true),
}
});
+142
View File
@@ -0,0 +1,142 @@
//! Bounded HTTPS or same-origin downloads, handed to the normal design-open command as unsaved copies.
use std::{cell::RefCell, rc::Rc};
use js_sys::{Reflect, Uint8Array};
use solidcraft_engine::io::vfs;
use solidcraft_ui_egui::{SolveApp, ui_kit};
use wasm_bindgen::{JsCast as _, JsValue};
use wasm_bindgen_futures::{JsFuture, spawn_local};
use crate::{check_download_size, design_url};
type Reply = Result<(String, Vec<u8>), String>;
#[derive(Default)]
pub struct Download {
reply: Rc<RefCell<Option<Reply>>>,
pending: Option<(String, Vec<u8>)>,
}
impl Download {
pub fn start(url: Option<&str>, ctx: &egui::Context) -> Self {
let download = Self::default();
if let Some(url) = url {
let url = url.to_owned();
let slot = download.reply.clone();
let ctx = ctx.clone();
spawn_local(async move {
*slot.borrow_mut() = Some(fetch(&url).await);
ctx.request_repaint();
});
}
download
}
pub fn tick(&mut self, app: &mut SolveApp, ctx: &egui::Context) {
if let Some(reply) = self.reply.borrow_mut().take() {
match reply {
Ok(file) => self.pending = Some(file),
Err(error) => app.set_status(format!("Could not open design link: {error}"), true),
}
}
if self.pending.is_none() || app.file_picker_busy() {
return;
}
if solidcraft_ui_egui::documents::any_dirty(app) {
let mut answer = None;
let closed = ui_kit::Window::new(egui::Id::new("open_link"), "status_warning", solidcraft_ui_egui::i18n::tr("web.open_link"))
.required()
.show_body(ctx, |ui| {
ui.label(solidcraft_ui_egui::i18n::tr("web.open_link_confirm"));
ui.horizontal(|ui| {
if ui_kit::button(ui, solidcraft_ui_egui::i18n::tr("ui.open"), ui_kit::Button::Normal).clicked() {
answer = Some(true);
}
if ui_kit::button(ui, solidcraft_ui_egui::i18n::tr("ui.cancel"), ui_kit::Button::Normal).clicked() {
answer = Some(false);
}
});
});
match answer {
Some(true) => {}
Some(false) => {
self.pending = None;
return;
}
None if closed => {
self.pending = None;
return;
}
None => return,
}
}
let Some((name, bytes)) = self.pending.take() else { return };
// The extension selects the design loader even when the URL has no extension.
let path = &format!("/linked/design.{}", solidcraft_engine::io::DESIGN_EXT);
vfs::insert(path, bytes);
app.open_path(path);
if app.session.path.as_deref() == Some(path.as_str()) {
app.session.path = None;
app.session.doc_mut().name = name;
app.session.mark_unsaved();
} else {
app.set_status("Could not open design link: the downloaded file is not a valid SolidCraft design", true);
}
let _ = vfs::remove(path);
}
}
async fn fetch(value: &str) -> Reply {
let window = web_sys::window().ok_or("No browser window")?;
let page_origin = window.location().origin().map_err(|_| "Cannot read page origin")?;
let url = design_url(value, &page_origin)?;
let name = url.path_segments().and_then(|mut segments| segments.next_back()).filter(|name| !name.is_empty()).unwrap_or("Design");
let decoded = js_sys::decode_uri_component(name).ok().and_then(|name| name.as_string()).unwrap_or_else(|| name.to_owned());
// `arm.sc` (or a legacy `arm.solidcraft`) is named `arm`.
let stem = match decoded.rsplit_once('.') {
Some((stem, ext)) if !stem.is_empty() && solidcraft_engine::io::is_design_ext(ext) => stem,
_ => decoded.as_str(),
};
let name: String = stem.chars().take(255).collect();
let response = JsFuture::from(window.fetch_with_str(url.as_str())).await.map_err(|_| "Network request failed or the host refused CORS access")?;
let response: web_sys::Response = response.dyn_into().map_err(|_| "Invalid download response")?;
design_url(&response.url(), &page_origin).map_err(|_| "Design redirected to a URL that does not use HTTPS or the page's own origin")?;
if !response.ok() {
return Err(format!("HTTP {} {}", response.status(), response.status_text()));
}
let length = response
.headers()
.get("Content-Length")
.map_err(|_| "Cannot read download size")?
.map(|value| value.parse::<u64>().map_err(|_| "Invalid Content-Length"))
.transpose()?;
if let Some(length) = length {
check_download_size(0, length)?;
}
let stream = response.body().ok_or("Download has no body")?;
let reader: web_sys::ReadableStreamDefaultReader = stream.get_reader().dyn_into().map_err(|_| "Cannot read download stream")?;
let result = read(&reader).await;
if result.is_err() {
let _ = JsFuture::from(reader.cancel()).await;
}
// Fetch rejects interrupted HTTP bodies and decodes Content-Encoding. Content-Length
// can describe compressed bytes, so the streamed limit also checks the decoded size.
let bytes = result?;
Ok((name, bytes))
}
async fn read(reader: &web_sys::ReadableStreamDefaultReader) -> Result<Vec<u8>, String> {
let mut bytes = Vec::new();
// Bound even a stream which keeps returning empty chunks.
for _ in 0..1_000_000 {
let chunk = JsFuture::from(reader.read()).await.map_err(|_| "Download interrupted")?;
if Reflect::get(&chunk, &JsValue::from_str("done")).map_err(|_| "Invalid download stream")?.as_bool() == Some(true) {
return Ok(bytes);
}
let value = Reflect::get(&chunk, &JsValue::from_str("value")).map_err(|_| "Invalid download chunk")?;
let chunk: Uint8Array = value.dyn_into().map_err(|_| "Invalid download bytes")?;
check_download_size(bytes.len(), u64::from(chunk.length()))?;
bytes.extend_from_slice(&chunk.to_vec());
}
Err("Download returned too many chunks".into())
}
+566 -106
View File
@@ -11,9 +11,13 @@ mod api;
#[cfg(any(target_arch = "wasm32", test))]
mod disk;
#[cfg(target_arch = "wasm32")]
mod easyprint;
#[cfg(target_arch = "wasm32")]
mod files;
#[cfg(target_arch = "wasm32")]
mod install;
#[cfg(target_arch = "wasm32")]
mod linked;
#[cfg(any(target_arch = "wasm32", test))]
mod space_mouse;
@@ -25,6 +29,133 @@ fn browser_switches_tabs(key: &str, ctrl: bool) -> bool {
ctrl && key == "Tab"
}
#[cfg(any(target_arch = "wasm32", test))]
fn query_flag(query: &str, name: &str) -> bool {
url::form_urlencoded::parse(query.trim_start_matches('?').as_bytes()).any(|(key, _)| key == name)
}
/// Launch options use exact query keys; URL values are decoded only once.
#[cfg(any(target_arch = "wasm32", test))]
#[derive(Default, Debug)]
struct Launch {
open: Option<String>,
embed: bool,
}
#[cfg(any(target_arch = "wasm32", test))]
impl Launch {
fn parse(query: &str) -> Self {
let mut launch = Self::default();
let mut embed = false;
for (key, value) in url::form_urlencoded::parse(query.trim_start_matches('?').as_bytes()) {
match key.as_ref() {
"open" if launch.open.is_none() => launch.open = Some(value.into_owned()),
"embed" => embed = true,
_ => {}
}
}
launch.embed = embed && launch.open.is_some();
launch
}
fn persists(&self) -> bool {
!self.embed
}
fn full_app_url(&self, mut url: url::Url) -> String {
url.set_query(None);
url.set_fragment(None);
if let Some(open) = &self.open {
url.query_pairs_mut().append_pair("open", open);
}
url.to_string()
}
}
#[cfg(any(target_arch = "wasm32", test))]
fn design_url(value: &str, page_origin: &str) -> Result<url::Url, String> {
if value.len() > 16_384 {
return Err("Design URL is too long".into());
}
let decoded = percent_encoding::percent_decode_str(value).decode_utf8().map_err(|_| "Invalid design URL encoding")?;
if decoded.chars().any(|ch| ch.is_control() || ch == '\\') {
return Err("Design links must not contain backslashes or control characters".into());
}
// Check before URL parsing normalizes away dot segments. Decode escaped slashes too.
let path = value.split(['?', '#']).next().unwrap_or_default();
let path = percent_encoding::percent_decode_str(path).collect::<Vec<_>>();
if path.split(|byte| *byte == b'/').any(|segment| segment == b"..") {
return Err("Design links must not contain parent path segments".into());
}
let page = url::Url::parse(page_origin).map_err(|_| "Invalid page origin")?;
let url = if value.starts_with('/') && !value.starts_with("//") {
page.join(value).map_err(|_| "Invalid design path")?
} else {
url::Url::parse(value).map_err(|_| "Invalid design URL")?
};
if (url.scheme() != "https" && url.origin() != page.origin())
|| url.host_str().is_none()
|| !url.username().is_empty()
|| url.password().is_some()
{
return Err("Design links must use HTTPS or the page's own origin without credentials".into());
}
Ok(url)
}
#[cfg(any(target_arch = "wasm32", test))]
fn check_download_size(current: usize, incoming: u64) -> Result<(), String> {
let cap = solidcraft_engine::io::MAX_DESIGN_BYTES as u64;
if current as u64 > cap || incoming > cap.saturating_sub(current as u64) {
return Err(format!("Design exceeds the {} MB size limit", cap / (1024 * 1024)));
}
Ok(())
}
/// Ctrl+W (⌘W, without Shift or Alt) while a design is shown closes it in the app, so the
/// browser must not close the tab (#5366). On the start page the browser closes the tab.
#[cfg(any(target_arch = "wasm32", test))]
fn closes_design(key: &str, command: bool, shift: bool, alt: bool, design_shown: bool) -> bool {
design_shown && command && !shift && !alt && key.eq_ignore_ascii_case("w")
}
/// The settings a tab last wrote to the browser's storage, or loaded from another tab (#8644).
/// Every open tab of the app shares that storage, and eframe saves each tab's whole copy every
/// 30 s and when the page loses focus, so a tab still holding old settings put them back over a
/// change made in another. A change is now written in the frame it is made, and the other tabs
/// load it ([`settings_written`]).
#[cfg(any(target_arch = "wasm32", test))]
#[derive(Default, Debug)]
struct PrefsWriter {
written: Option<String>,
}
#[cfg(any(target_arch = "wasm32", test))]
impl PrefsWriter {
/// `prefs` if they are not the settings written or loaded last (they count as written from
/// now on).
fn take(&mut self, prefs: String) -> Option<String> {
if self.written.as_deref() == Some(prefs.as_str()) {
return None;
}
self.written = Some(prefs.clone());
Some(prefs)
}
/// The settings the tab holds after loading another tab's: the tab doesn't write them back.
fn loaded(&mut self, prefs: String) {
self.written = Some(prefs);
}
}
/// The settings another tab wrote, from a `storage` event (the browser sends one to every other
/// tab of the page when one tab writes). Other keys, and settings taken away (clearing the site's
/// data), leave this tab's settings as they are.
#[cfg(any(target_arch = "wasm32", test))]
fn settings_written(key: Option<&str>, value: Option<String>) -> Option<String> {
value.filter(|_| key == Some(solidcraft_ui_egui::prefs::STORAGE_KEY))
}
#[cfg(target_arch = "wasm32")]
mod web {
use std::rc::Rc;
@@ -76,6 +207,23 @@ mod web {
thread_local! {
/// An open design has unsaved changes: leaving the page asks first.
static UNSAVED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
/// A design is shown (not the start page): Ctrl+W closes it, not the browser tab.
static DESIGN_SHOWN: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
/// Ctrl+W (⌘W) closes the design shown, as on the desktop; on the start page it is left to
/// the browser, which closes the tab (#5366). Browsers that keep Ctrl+W to themselves still
/// close the tab, after asking when a design is unsaved ([`ask_before_unload`]).
fn keep_ctrl_w_for_the_design(window: &web_sys::Window) {
let on_key = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::KeyboardEvent)>::new(|e: web_sys::KeyboardEvent| {
let command = e.ctrl_key() || e.meta_key();
if crate::closes_design(&e.key(), command, e.shift_key(), e.alt_key(), DESIGN_SHOWN.with(std::cell::Cell::get)) {
e.prevent_default();
}
});
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();
}
/// The browser's "leave site? changes you made may not be saved" prompt while a design has
@@ -106,12 +254,35 @@ mod web {
on_key.forget();
}
const JAPANESE_FONT_PATH: &str = "./fonts/BIZUDPGothic-Regular.ttf";
/// Settings another tab wrote, waiting for this tab's next frame (#8644).
type PrefsInbox = Rc<std::cell::Cell<Option<String>>>;
type FontLoadResult = Rc<std::cell::RefCell<Option<Result<Vec<u8>, String>>>>;
/// Settings another tab of the app writes go to `inbox`, and this tab draws a frame, which
/// loads them.
fn hear_settings_from_other_tabs(window: &web_sys::Window, ctx: &egui::Context, inbox: PrefsInbox) {
let ctx = ctx.clone();
let on_storage = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::StorageEvent)>::new(move |e: web_sys::StorageEvent| {
if let Some(prefs) = crate::settings_written(e.key().as_deref(), e.new_value()) {
inbox.set(Some(prefs));
ctx.request_repaint();
}
});
let _ = window.add_event_listener_with_callback("storage", on_storage.as_ref().unchecked_ref());
// The listener lives as long as the page.
on_storage.forget();
}
const JAPANESE_FONT_PATH: &str = "./fonts/BIZUDPGothic-Regular.ttf";
const CHINESE_SC_FONT_PATH: &str = "./fonts/CraftUI-SC.otf";
const CHINESE_TC_FONT_PATH: &str = "./fonts/CraftUI-TC.otf";
type FontLoadResult = Rc<std::cell::RefCell<Option<Result<(String, Vec<u8>), String>>>>;
struct Shell {
app: SolveApp,
launch: crate::Launch,
linked: crate::linked::Download,
full_url: Option<String>,
api: Api,
files: Rc<Files>,
/// When the last autosave was (seconds), and the revision it holds (none stored: `None`).
@@ -123,20 +294,68 @@ mod web {
rename_focus: bool,
/// The start page's recent designs were filled from the stored ones.
recents_synced: bool,
/// On-demand CJK font: fetched only when a CJK language is selected.
/// On-demand script fonts: 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.
/// The fetched Japanese font's bytes: kept so a language change can re-order the fallbacks.
cjk_bytes: Option<Vec<u8>>,
/// The fetched Chinese font's bytes (Simplified or Traditional for this session).
chinese_bytes: Option<Vec<u8>>,
/// Which Chinese face `chinese_bytes` is: `"zh"` or `"zh-TW"`.
chinese_kind: Option<&'static str>,
/// Kind requested by the in-flight fetch (`"ja"`, `"zh"`, or `"zh-TW"`).
font_fetch_kind: Option<&'static str>,
/// 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,
/// The settings this tab wrote or loaded last, and those another tab wrote since (#8644).
prefs_writer: crate::PrefsWriter,
other_tab_prefs: PrefsInbox,
}
impl Shell {
/// The settings as they are kept between visits: with the user's own language, not a
/// session-only English fallback.
fn persisted_prefs(&mut self) -> String {
let saved = self.font.persisted_language(&self.app.preferences.language).to_owned();
let live = std::mem::replace(&mut self.app.preferences.language, saved);
let prefs = self.app.prefs();
self.app.preferences.language = live;
prefs
}
/// A settings change goes to the browser's storage in the frame it is made, and from
/// there to the other open tabs (#8644). Input is what changes settings, so a frame
/// without any is skipped; eframe's own save still catches anything else.
fn store_prefs(&mut self, ctx: &egui::Context) {
if !self.launch.persists() || ctx.input(|i| i.events.is_empty()) {
return;
}
let prefs = self.persisted_prefs();
let Some(prefs) = self.prefs_writer.take(prefs) else { return };
let stored =
web_sys::window().and_then(|w| w.local_storage().ok().flatten()).map(|s| s.set_item(solidcraft_ui_egui::prefs::STORAGE_KEY, &prefs));
if !matches!(stored, Some(Ok(()))) {
log::warn!("settings: the browser's storage did not take them; they are saved again later");
self.prefs_writer = crate::PrefsWriter::default();
}
}
/// Settings another tab wrote replace this tab's, so this tab's own saves keep them
/// instead of putting its older copy back (#8644).
fn load_other_tabs_prefs(&mut self) {
let Some(prefs) = self.other_tab_prefs.take() else { return };
self.app.load_prefs(&prefs);
let held = self.persisted_prefs();
self.prefs_writer.loaded(held);
}
/// Files from the computer, writes to store, messages and the autosave.
fn files_tick(&mut self, ctx: &egui::Context) {
if !self.launch.persists() {
return;
}
if self.files.ctx.borrow().is_none() {
*self.files.ctx.borrow_mut() = Some(ctx.clone());
}
@@ -178,6 +397,10 @@ mod web {
!companion
});
for (name, bytes, intent) in incoming {
if intent == Intent::Theme {
solidcraft_ui_egui::prefs::import_theme_bytes(&mut self.app, bytes);
continue;
}
let design = solidcraft_engine::io::is_design_path(&name);
let path = if design && intent == Intent::Open {
// A design from the computer is kept in the browser (a copy), so Save works.
@@ -194,7 +417,7 @@ mod web {
};
match intent {
Intent::Open => self.app.open_path(&path),
Intent::Insert => self.app.drop_path(&path),
Intent::Insert | Intent::Theme => self.app.drop_path(&path),
}
self.files.panel.set(false);
}
@@ -230,13 +453,15 @@ mod web {
fn sync_recents(&mut self) {
let mut entries = self.files.entries.borrow().clone();
entries.sort_by(|a, b| b.modified.total_cmp(&a.modified));
let forgotten = self.app.home.forgotten.clone();
let recent = &mut self.app.home.recent;
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) {
// A design the user removed from the list is not added back while it is stored.
if !recent.contains(&p) && !forgotten.contains(&p) {
recent.push(p);
}
}
@@ -245,6 +470,10 @@ mod web {
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);
// Drop forgotten entries whose stored design is gone, so the set does not grow without bound.
let stored: std::collections::BTreeSet<String> =
entries.iter().filter(|e| solidcraft_engine::io::is_design_path(&e.name)).map(|e| format!("{}{}", files::OPFS, e.name)).collect();
self.app.home.forgotten.retain(|p| stored.contains(p));
}
/// The stored designs: open, insert, rename, delete, download; open from the computer.
@@ -254,14 +483,15 @@ mod web {
}
let mut act: Option<(&'static str, String)> = None;
let closed = solidcraft_ui_egui::ui_kit::Window::new(egui::Id::new("sc_browser_files"), "folder", "Files in this browser")
.cards(3.0)
// Room for long names; the kit still caps the window at the browser's width.
.cards(4.0)
.show_body(ctx, |ui| {
ui.horizontal(|ui| {
if ui_kit::button(ui, "Open from computer…", ui_kit::Button::Normal).clicked() {
files::pick_from_disk(&self.files, Intent::Open);
}
if ui_kit::button(ui, "Insert from computer…", ui_kit::Button::Normal)
.on_hover_text("STEP, 3MF or STL into the open design")
.on_hover_text(solidcraft_ui_egui::i18n::tr("web.files.insert_drawings_hint"))
.clicked()
{
files::pick_from_disk(&self.files, Intent::Insert);
@@ -282,61 +512,62 @@ mod web {
return;
}
let entries = self.files.entries.borrow().clone();
if entries.is_empty() {
ui.label("Designs you save appear here. They stay in this browser.");
}
egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| {
egui::Grid::new("opfs_files").num_columns(3).spacing([12.0, 6.0]).show(ui, |ui| {
// The name takes the width the size and the actions leave (#8097).
let empty = "Designs you save appear here. They stay in this browser.";
ui_kit::name_list(ui, "opfs_files", &["Name", "Size", "Actions"], empty, |list| {
for e in &entries {
match &mut self.renaming {
Some((from, to)) if *from == e.name => {
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();
}
if r.lost_focus() {
act = Some(if ui.input(|i| i.key_pressed(egui::Key::Enter)) {
("rename", to.clone())
} else {
("rename_cancel", String::new())
});
list.row(&e.name, |row| {
match &mut self.renaming {
Some((from, to)) if *from == e.name => row.cell(|ui| {
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();
}
if r.lost_focus() {
act = Some(if ui.input(|i| i.key_pressed(egui::Key::Enter)) {
("rename", to.clone())
} else {
("rename_cancel", String::new())
});
}
}),
_ => {
row.name(&e.name);
}
}
_ => {
ui.label(&e.name);
}
}
ui.label(format!("{:.1} KB", e.size as f64 / 1024.0));
ui.horizontal(|ui| {
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_kit::button(ui, "Insert", ui_kit::Button::Small)
.on_hover_text("Into the open design (STEP, 3MF, STL)")
row.cell(|ui| ui.label(format!("{:.1} KB", e.size as f64 / 1024.0)));
row.cell(|ui| {
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_kit::button(ui, "Insert", ui_kit::Button::Small)
.on_hover_text(solidcraft_ui_egui::i18n::tr("web.files.insert_stored_drawings_hint"))
.clicked()
{
act = Some(("insert", e.name.clone()));
}
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_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(("insert", e.name.clone()));
}
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_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_kit::button(ui, "Delete", ui_kit::Button::Small).clicked() {
act = Some(("delete", e.name.clone()));
}
{
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_kit::button(ui, "Delete", ui_kit::Button::Small).clicked() {
act = Some(("delete", e.name.clone()));
}
});
});
ui.end_row();
}
});
});
@@ -383,6 +614,8 @@ mod web {
"delete" => {
files::delete(&self.files, &name);
self.app.home.recent.retain(|r| *r != path);
// The file is gone, so there is nothing left to keep forgotten.
self.app.home.forgotten.remove(&path);
}
"rename_cancel" => self.renaming = None,
"rename" => {
@@ -396,6 +629,10 @@ mod web {
*r = new.clone();
}
}
// A forgotten design follows its new name.
if self.app.home.forgotten.remove(&old) {
self.app.home.forgotten.insert(new);
}
}
Err(e) => self.app.set_status(e, true),
}
@@ -423,44 +660,82 @@ mod web {
});
});
let Some(recover) = answer else { return };
*self.files.recovery.borrow_mut() = None;
if recover {
self.app.open_path(&files::recovery_path());
let recovery = match files::prepare_recovery(path.as_deref()) {
Ok(recovery) => recovery,
Err(error) => {
self.app.set_status(error, true);
return;
}
};
self.app.open_path(&recovery);
if self.app.session.path.as_deref() != Some(recovery.as_str()) {
return;
}
// It belongs to its own file again (saving goes there); it is unsaved.
self.app.session.path = path;
self.app.session.doc_mut().name = name;
self.app.session.mark_unsaved();
self.app.home.open = false;
}
*self.files.recovery.borrow_mut() = None;
files::autosave(&self.files, None);
}
/// Fetch and install the CJK font when a CJK language is selected. If that fails, this
/// Whether the shell already has the on-demand face the live language needs.
fn script_font_ready(&self) -> bool {
match solidcraft_ui_egui::fonts::web_script_font_kind(&self.app.preferences.language) {
Some("zh") => self.chinese_bytes.is_some() && self.chinese_kind == Some("zh"),
Some("zh-TW") => self.chinese_bytes.is_some() && self.chinese_kind == Some("zh-TW"),
Some("ja") => self.cjk_bytes.is_some(),
_ => true,
}
}
/// Fetch and install a script font when a CJK language is selected. If that fails, this
/// session falls back to English (no tofu); the saved language preference is kept.
fn tick_font(&mut self, ctx: &egui::Context) {
let done = self.font_result.borrow_mut().take();
if let Some(res) = done {
let kind = self.font_fetch_kind.take();
let installed = match res {
Ok(bytes) => {
self.cjk_bytes = Some(bytes);
Ok((path, bytes)) => {
match kind {
Some("zh") => {
self.chinese_bytes = Some(bytes);
self.chinese_kind = Some("zh");
}
Some("zh-TW") => {
self.chinese_bytes = Some(bytes);
self.chinese_kind = Some("zh-TW");
}
_ => {
self.cjk_bytes = Some(bytes);
}
}
log::info!("loaded web script font from {path}");
true
}
Err(err) => {
log::warn!("failed to fetch CJK font from {JAPANESE_FONT_PATH}: {err}");
let path = match kind {
Some("zh") => CHINESE_SC_FONT_PATH,
Some("zh-TW") => CHINESE_TC_FONT_PATH,
_ => JAPANESE_FONT_PATH,
};
log::warn!("failed to fetch script font from {path}: {err}");
false
}
};
if installed {
log::info!("loaded web CJK font from {JAPANESE_FONT_PATH}");
} else if solidcraft_ui_egui::fonts::needs_cjk_font(&self.app.preferences.language) {
log::warn!("CJK font unavailable; showing English for this session");
if !installed && solidcraft_ui_egui::fonts::needs_cjk_font(&self.app.preferences.language) {
log::warn!("script 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);
let ready = self.script_font_ready();
if self.font.tick(&mut self.app.preferences.language, ready) {
self.fetch_font(ctx, solidcraft_ui_egui::fonts::web_script_font_kind(&self.app.preferences.language).unwrap_or("ja"));
}
// 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.
@@ -469,31 +744,42 @@ mod web {
}
}
/// 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`]).
/// Set up the UI fonts for the live language, keeping fetched script fonts as fallbacks
/// (Chinese leads a Chinese UI; see [`solidcraft_ui_egui::fonts::install_web_script_fonts`]).
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),
if self.cjk_bytes.is_none() && self.chinese_bytes.is_none() {
solidcraft_ui_egui::fonts::setup_fonts(ctx);
} else {
solidcraft_ui_egui::fonts::install_web_script_fonts(
ctx,
self.cjk_bytes.clone(),
self.chinese_bytes.clone(),
&self.app.preferences.language,
);
}
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):
/// fetch the font, so the text does not stay tofu (#720).
/// CJK text on screen with no Japanese font yet (an English UI showing a Japanese name):
/// fetch the Japanese font, so the text does not stay tofu (#720).
fn watch_text_for_font(&mut self, ctx: &egui::Context) {
if self.font.watching_text() && solidcraft_ui_egui::fonts::painted_cjk(ctx) && self.font.text_needs_font() {
self.fetch_font(ctx);
self.fetch_font(ctx, "ja");
}
}
fn fetch_font(&self, ctx: &egui::Context) {
fn fetch_font(&mut self, ctx: &egui::Context, kind: &'static str) {
let path = match kind {
"zh" => CHINESE_SC_FONT_PATH,
"zh-TW" => CHINESE_TC_FONT_PATH,
_ => JAPANESE_FONT_PATH,
};
self.font_fetch_kind = Some(kind);
let slot = self.font_result.clone();
let ctx = ctx.clone();
let path = path.to_owned();
wasm_bindgen_futures::spawn_local(async move {
let res = fetch_font_bytes(JAPANESE_FONT_PATH).await;
let res = fetch_font_bytes(&path).await.map(|bytes| (path, bytes));
*slot.borrow_mut() = Some(res);
ctx.request_repaint();
});
@@ -502,36 +788,61 @@ mod web {
impl eframe::App for Shell {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// First: a language another tab chose fetches its font this frame.
self.load_other_tabs_prefs();
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)));
DESIGN_SHOWN.with(|d| d.set(solidcraft_ui_egui::toolbar::design_shown(&self.app)));
}
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);
if self.launch.persists() {
let theme = solidcraft_ui_egui::prefs::theme_drop_target(&self.app, ctx);
files::take_drops(&self.files, raw, theme);
} else {
raw.dropped_files.clear();
}
// 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) {
self.linked.tick(&mut self.app, ui.ctx());
UNSAVED.with(|u| u.set(self.launch.persists() && solidcraft_ui_egui::documents::any_dirty(&self.app)));
if self.launch.embed {
ui_kit::Container::new(ui.ctx(), ui_kit::Surface::Chrome).edge("embed_header", ui_kit::Edge::Top).show(ui, |ui| {
if ui_kit::button(ui, solidcraft_ui_egui::i18n::tr("web.open_full"), ui_kit::Button::Normal).clicked()
&& let (Some(window), Some(url)) = (web_sys::window(), &self.full_url)
{
let _ = window.open_with_url_and_target_and_features(url, "_blank", "noopener");
}
});
}
self.app.ui(ui);
let ctx = ui.ctx().clone();
self.file_panel(&ctx);
self.recovery_prompt(&ctx);
if self.launch.persists() {
self.file_panel(&ctx);
self.recovery_prompt(&ctx);
}
self.watch_text_for_font(&ctx);
self.store_prefs(&ctx);
}
fn persist_egui_memory(&self) -> bool {
self.launch.persists()
}
fn save(&mut self, storage: &mut dyn eframe::Storage) {
// Persist the user's own language, not a session-only English fallback.
let saved = self.font.persisted_language(&self.app.preferences.language).to_owned();
let live = std::mem::replace(&mut self.app.preferences.language, saved);
storage.set_string(solidcraft_ui_egui::prefs::STORAGE_KEY, self.app.prefs());
self.app.preferences.language = live;
if !self.launch.persists() {
return;
}
let prefs = self.persisted_prefs();
storage.set_string(solidcraft_ui_egui::prefs::STORAGE_KEY, prefs);
}
}
@@ -545,15 +856,15 @@ mod web {
/// Render with WebGL2: asked for now (`?webgl`, remembered) or before (until `?webgpu`).
fn webgl() -> bool {
let storage = web_sys::window().and_then(|w| w.local_storage().ok().flatten());
let storage = crate::Launch::parse(&query()).persists().then(|| web_sys::window().and_then(|w| w.local_storage().ok().flatten())).flatten();
let q = query();
if q.contains("webgl") {
if crate::query_flag(&q, "webgl") {
if let Some(s) = &storage {
let _ = s.set_item(WEBGL_KEY, "1");
}
return true;
}
if q.contains("webgpu") {
if crate::query_flag(&q, "webgpu") {
if let Some(s) = &storage {
let _ = s.remove_item(WEBGL_KEY);
}
@@ -579,8 +890,12 @@ mod web {
eframe::WebLogger::init(log::LevelFilter::Info).ok();
wasm_bindgen_futures::spawn_local(async {
let Some(window) = web_sys::window() else { return };
ask_before_unload(&window);
let launch = crate::Launch::parse(&query());
if launch.persists() {
ask_before_unload(&window);
}
leave_tab_switching_to_browser(&window);
keep_ctrl_w_for_the_design(&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}\">");
@@ -599,19 +914,24 @@ mod web {
Box::new(move |cc| {
solidcraft_ui_egui::fonts::setup_fonts(&cc.egui_ctx);
let files = Rc::new(Files::default());
files::start(&files);
if launch.persists() {
files::start(&files);
}
let (panel, opener, saver, shown) = (files.clone(), files.clone(), files.clone(), files.clone());
let (restorable, restorer) = (files.clone(), files.clone());
let services = Services {
let services = if launch.embed { Services::default() } else { Services {
// 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));
// (the file keeps its handle, so Save writes back to it), else the
// browser's files window. A command that reads another kind of file
// (Insert DXF or SVG, an image, a mesh, settings) gets a file dialog
// offering that kind, and runs on the picked file.
pick_open: Some(Box::new(move |exts: &[&str]| match disk::open_route(disk::supported(), exts) {
disk::OpenRoute::Disk => Box::pin(disk::pick_open(&opener)),
disk::OpenRoute::Upload => Box::pin(files::pick_upload(&opener, exts)),
disk::OpenRoute::Panel => {
panel.panel.set(true);
Box::pin(std::future::ready(None))
}
panel.panel.set(true);
Box::pin(std::future::ready(None))
})),
// 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.
@@ -633,29 +953,37 @@ mod web {
})),
restore_file: Some(Box::new(move |path: &str| disk::restore(&restorer, path))),
download_design: Some(Box::new(files::download)),
easyprint_upload: Some(std::sync::Arc::new(crate::easyprint::upload)),
browser_files: Some(Box::new(move || shown.panel.set(true))),
install_app: Some(crate::install::service(&cc.egui_ctx)),
..Default::default()
};
}};
// 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.embedded = launch.embed;
// Listening before reading: settings another tab writes from now on are
// newer than those read here.
let other_tab_prefs = PrefsInbox::default();
if launch.persists() {
hear_settings_from_other_tabs(&window, &cc.egui_ctx, other_tab_prefs.clone());
}
if launch.persists() && let Some(p) = cc.storage.and_then(|s| solidcraft_ui_egui::prefs::stored(|k| s.get_string(k))) {
app.load_prefs(&p);
}
if let Some(rs) = &cc.wgpu_render_state {
// eframe's web painter has no stencil buffer.
app.set_wgpu(rs, DEPTH_BITS, 0, 1);
}
if query().contains("sample") {
if launch.open.is_none() && crate::query_flag(&query(), "sample") {
if let Err(e) = app.session.run_script(&solidcraft_engine::sample::script()) {
app.set_status(format!("sample: {e}"), true);
}
app.session.doc_mut().name = "Sample Plate".into();
app.session.mark_saved();
}
if !query().contains("sample") {
if launch.open.is_none() && !crate::query_flag(&query(), "sample") {
// The empty design takes Units of new designs (#1205). The sample
// part is built in millimetres and is left that way.
solidcraft_ui_egui::prefs::apply_new_design(&mut app);
@@ -664,8 +992,13 @@ mod web {
}
let api = Api::install(sender, cc.egui_ctx.clone()).map_err(|e| format!("SolidCraft browser API: {e:?}"))?;
let font_language = app.preferences.language.clone();
let linked = crate::linked::Download::start(launch.open.as_deref(), &cc.egui_ctx);
let full_url = window.location().href().ok().and_then(|value| url::Url::parse(&value).ok()).map(|url| launch.full_app_url(url));
Ok(Box::new(Shell {
app,
launch,
linked,
full_url,
api,
files,
autosaved_at: f64::NEG_INFINITY,
@@ -676,9 +1009,14 @@ mod web {
font: Default::default(),
font_result: Rc::new(std::cell::RefCell::new(None)),
cjk_bytes: None,
chinese_bytes: None,
chinese_kind: None,
font_fetch_kind: None,
font_language,
space_mouse: SpaceMouse::default(),
wheel_gestures: solidcraft_ui_egui::WebWheelGestures::default(),
prefs_writer: crate::PrefsWriter::default(),
other_tab_prefs,
}))
}),
)
@@ -709,6 +1047,90 @@ fn main() {
mod tests {
use super::*;
#[test]
fn link_query_decodes_once_and_embed_disables_persistence() {
assert!(!query_flag("?open=https://example.org/webgl?sample", "webgl"));
assert!(!query_flag("?open=https://example.org/webgl?sample", "sample"));
assert!(query_flag("?embed&webgl", "webgl"));
let launch = Launch::parse("?embed&open=https%3A%2F%2Fexample.org%2Farm.solidcraft%3Fx%3Da%2520b%26y%3D1");
assert_eq!(launch.open.as_deref(), Some("https://example.org/arm.solidcraft?x=a%20b&y=1"));
assert!(launch.embed);
assert!(!launch.persists());
assert!(!Launch::parse("?embed").embed);
assert!(!Launch::parse("?other=embed&reopen=https://example.org").embed);
assert!(Launch::parse("?open=https://example.org/a").persists());
assert_eq!(Launch::parse("?open=first&open=second").open.as_deref(), Some("first"));
let launch = Launch::parse("?embed&open=/dutchman/jobs/abc/part.solidcraft%3Fx%3Da%2520b");
assert!(launch.embed);
assert!(!launch.persists());
let page = url::Url::parse("http://solidcraft.io/?embed#view").expect("synthetic page");
let full = url::Url::parse(&launch.full_app_url(page)).expect("full app URL");
let reopened = Launch::parse(full.query().unwrap_or_default());
assert_eq!(reopened.open, launch.open);
assert_eq!(reopened.open.as_deref(), Some("/dutchman/jobs/abc/part.solidcraft?x=a%20b"));
assert!(!reopened.embed);
assert!(reopened.persists());
assert!(full.fragment().is_none());
}
#[test]
fn design_links_refuse_hostile_schemes_credentials_and_sizes() {
assert!(design_url("https://example.org/arm.solidcraft?download=1", "http://solidcraft.io").is_ok());
for value in [
"http://example.org/a",
"data:application/json,{}",
"blob:https://example.org/a",
"javascript:alert(1)",
"https://user:secret@example.org/a",
"//evil.example/x",
"/../x",
"/%2e%2e/x",
"/a/%2E./x",
"/a%2f..%2fx",
"/a\\x",
"/a%5cx",
"/a\nx",
"/a%00x",
"/a%7fx",
"/a%C2%85x",
"/a%3f/../x",
"/a/..#view",
"/a/..?download=1",
"https://solidcraft.io/a/../x",
"http://user:secret@solidcraft.io/x",
"http://solidcraft.io.evil.example/x",
"http://solidcraft.io:8080/x",
"relative.solidcraft",
"",
] {
assert!(design_url(value, "http://solidcraft.io").is_err(), "{value}");
}
assert!(design_url(&format!("https://example.org/{}", "x".repeat(16_384)), "http://solidcraft.io").is_err());
assert!(design_url("http://redirect.example.org/a", "http://solidcraft.io").is_err());
let cap = solidcraft_engine::io::MAX_DESIGN_BYTES;
assert!(check_download_size(0, cap as u64).is_ok());
assert!(check_download_size(0, cap as u64 + 1).is_err());
assert!(check_download_size(cap - 2, 2).is_ok());
assert!(check_download_size(cap - 2, 3).is_err());
assert!(check_download_size(0, u64::MAX).is_err());
assert!(check_download_size(cap, 1).is_err());
assert!(check_download_size(usize::MAX, 0).is_err());
}
#[test]
fn design_links_allow_same_origin_paths_and_redirects() {
for origin in ["http://solidcraft.io", "https://solidcraft.io", "http://solidcraft.io:8080"] {
assert_eq!(design_url("/a/b.solidcraft", origin).map(|url| url.to_string()), Ok(format!("{origin}/a/b.solidcraft")));
assert!(design_url(&format!("{origin}/x"), origin).is_ok());
// Final response URLs follow the same origin and scheme rules as the request.
assert!(design_url("https://docs.solidcraft.io/x", origin).is_ok());
assert!(design_url("http://other/x", origin).is_err());
}
assert!(design_url("http://solidcraft.io/x", "https://solidcraft.io").is_err());
assert!(design_url("http://SOLIDCRAFT.io:80/x", "http://solidcraft.io").is_ok());
}
#[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.
@@ -717,4 +1139,42 @@ mod tests {
assert!(!browser_switches_tabs("Tab", false));
assert!(!browser_switches_tabs("s", true));
}
#[test]
fn ctrl_w_closes_the_design_not_the_tab() {
// Ctrl+W and ⌘W (either case of the key) close the design shown.
assert!(closes_design("w", true, false, false, true));
assert!(closes_design("W", true, false, false, true));
// On the start page the browser closes the tab.
assert!(!closes_design("w", true, false, false, false));
// Ctrl+Shift+W (close the window), plain W and other keys stay the browser's or the app's.
assert!(!closes_design("W", true, true, false, true));
assert!(!closes_design("w", true, false, true, true));
assert!(!closes_design("w", false, false, false, true));
assert!(!closes_design("s", true, false, false, true));
}
/// A tab writes its settings when they change, and not again while they stay the same; the
/// settings it loaded from another tab aren't written back (#8644).
#[test]
fn changed_settings_are_written_once_and_loaded_ones_not_at_all() {
let mut writer = PrefsWriter::default();
assert_eq!(writer.take(r#"{"a":1}"#.into()).as_deref(), Some(r#"{"a":1}"#), "the first settings are written");
assert_eq!(writer.take(r#"{"a":1}"#.into()), None, "the same settings are not written again");
assert_eq!(writer.take(r#"{"a":2}"#.into()).as_deref(), Some(r#"{"a":2}"#), "a change is written at once");
writer.loaded(r#"{"a":3}"#.into());
assert_eq!(writer.take(r#"{"a":3}"#.into()), None, "settings from another tab are not written back");
assert_eq!(writer.take(r#"{"a":4}"#.into()).as_deref(), Some(r#"{"a":4}"#), "this tab's next change is written");
}
/// Only the settings another tab wrote are loaded: not other keys, and not settings taken
/// away by clearing the site's data (#8644).
#[test]
fn only_settings_another_tab_wrote_are_loaded() {
let key = solidcraft_ui_egui::prefs::STORAGE_KEY;
assert_eq!(settings_written(Some(key), Some("{}".into())).as_deref(), Some("{}"));
assert_eq!(settings_written(Some("solidcraft.webgl"), Some("1".into())), None);
assert_eq!(settings_written(Some(key), None), None, "removed settings leave this tab's");
assert_eq!(settings_written(None, None), None, "clearing all storage leaves this tab's settings");
}
}
+24 -5
View File
@@ -13,8 +13,9 @@ bundled-render-library = ["solidcraft-engine/bundled-render-library"]
raytrace-cpu = ["solidcraft-engine/raytrace-cpu"]
# The workspace lints, except unsafe_code: denied, not forbidden, so that src/open_documents.rs
# (macOS file-open events), src/native_crash.rs (Windows exception filter) and src/native_frame.rs
# (Windows resize border) can allow it: none has a safe API. Keep the rest in sync with [workspace.lints].
# (macOS file-open events), src/native_crash.rs (Windows exception filter), src/native_crash_macos.rs
# (macOS crash signal handler) and src/native_frame.rs (Windows resize border, taskbar icon) can
# allow it: none has a safe API. Keep the rest in sync with [workspace.lints].
[lints.rust]
unsafe_code = "deny"
@@ -27,10 +28,13 @@ neg_cmp_op_on_partial_ord = "allow"
[dependencies]
solidcraft-engine = { workspace = true }
# The D3D12 shader compiler the window starts with (#6756).
solidcraft-gpu = { 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 = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
rfd = { workspace = true }
@@ -54,7 +58,8 @@ hidapi = { version = "2.6.7", default-features = false, features = ["windows-nat
# The main window's handle for src/native_frame.rs (already in the tree through eframe and winit).
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.
# The window's resize border, DPI size and class icon (src/native_frame.rs): the window procedure, hit
# test, DPI, the exe's icon.
# 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",
@@ -81,14 +86,28 @@ 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.
# Files opened from Finder and the Dock (src/open_documents.rs), and the fullscreen title-bar
# inset (src/main.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-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSWindow"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSNotification", "NSOperation", "NSProcessInfo", "NSString", "NSURL"] }
block2 = "0.6"
# Native crash capture (src/native_crash_macos.rs): the signal handler, the faulting module
# (already in the tree through many crates).
libc = "0.2"
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
winit = { version = "0.30", default-features = false, features = ["x11"] }
# Bringing the window forward on X11 when a close asks about unsaved designs (src/come_forward.rs):
# the window's id, and the window manager request winit doesn't send (MIT OR Apache-2.0; both
# already in the tree through eframe and winit).
raw-window-handle = "0.6"
x11rb = { version = "0.13", default-features = false }
[target.'cfg(target_os = "linux")'.dependencies]
# The desktop's dark or light preference through the XDG Desktop Portal (src/desktop_theme.rs,
# #7944); already in the tree through eframe's accessibility support (accesskit_unix).
zbus = { version = "5", default-features = false, features = ["async-io", "blocking-api"] }
[build-dependencies]
embed-resource = "3.0.11"
+16 -1
View File
@@ -4,6 +4,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let icon = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/icons/app/windows/exports/solidcraft-executable.ico");
println!("cargo:rerun-if-changed={}", icon.display());
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-env-changed=CARGO_PKG_VERSION");
if std::env::var("CARGO_CFG_TARGET_OS")? != "windows" {
return Ok(());
}
@@ -11,7 +12,21 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let copy = out.join("solidcraft.ico");
std::fs::copy(icon, &copy)?;
let rc = out.join("solidcraft.rc");
std::fs::write(&rc, format!("1 ICON \"{}\"\n", copy.display().to_string().replace('\\', "/")))?;
let icon_line = format!("1 ICON \"{}\"\n", copy.display().to_string().replace('\\', "/"));
std::fs::write(&rc, format!("{icon_line}{}", version_resource(&std::env::var("CARGO_PKG_VERSION")?)))?;
embed_resource::compile_for(&rc, ["solidcraft"], embed_resource::NONE).manifest_required()?;
Ok(())
}
/// The Windows version resource (what Explorer's Details tab shows and what a launcher reads).
/// `FileVersion` and `ProductVersion` are the package version verbatim (`0.25.1`, `0.26.0-rc.1`):
/// ArtCraft's launcher parses the `ProductVersion` string as semver, so a fourth `.0` would not
/// read. The binary `FILEVERSION` fields need four numbers and take the leading `major.minor.patch`.
fn version_resource(version: &str) -> String {
let mut numbers = version.split(['-', '+']).next().unwrap_or("").split('.').map(|p| p.parse::<u16>().unwrap_or(0));
let mut next = || numbers.next().unwrap_or(0);
let (major, minor, patch) = (next(), next(), next());
format!(
"1 VERSIONINFO\nFILEVERSION {major},{minor},{patch},0\nPRODUCTVERSION {major},{minor},{patch},0\nFILEOS 0x40004\nFILETYPE 0x1\n{{\nBLOCK \"StringFileInfo\"\n{{\nBLOCK \"040904B0\"\n{{\nVALUE \"CompanyName\", \"SolidCraft contributors\"\nVALUE \"FileDescription\", \"SolidCraft, parametric 3D CAD\"\nVALUE \"FileVersion\", \"{version}\"\nVALUE \"InternalName\", \"solidcraft\"\nVALUE \"OriginalFilename\", \"solidcraft.exe\"\nVALUE \"ProductName\", \"SolidCraft\"\nVALUE \"ProductVersion\", \"{version}\"\n}}\n}}\nBLOCK \"VarFileInfo\"\n{{\nVALUE \"Translation\", 0x0409, 1200\n}}\n}}\n"
)
}
+234
View File
@@ -0,0 +1,234 @@
//! Bringing the window forward when a close stops at "save changes?".
//!
//! A close from the taskbar or a window switcher reaches the window while another app has the
//! focus. The prompt it opens is only seen once the window comes forward, so the window asks for
//! the focus then. A window the user is already in is left alone.
//!
//! winit's Focus asks as an application, which a window manager with focus-stealing prevention
//! (KWin's default) refuses for a window used before the switch away: it only marks the taskbar
//! entry. On X11 the request is sent again with the source EWMH gives "direct user actions"
//! (`_NET_ACTIVE_WINDOW`, source 2), which is what this is: the prompt is the rest of the user's
//! close. Native Wayland has no way for a window to raise itself, so there the attention request
//! (xdg-activation) lets the compositor raise it or mark its taskbar entry.
//!
//! When: not at once. KWin raises the window, then the taskbar's closing context menu hands the
//! focus back to the app that had it, a few milliseconds later, and the window would flicker
//! forward and back. So the window asks once the menu is gone, `FIRST_AFTER` the close. It asks
//! again only if the focus is taken back right after it came (a slow menu) or never came; a later
//! switch is the user's own, and the window stays where the user put it.
use std::time::{Duration, Instant};
/// From the close to the first request: past the taskbar menu's focus hand-back (2 to 11 ms on
/// KDE Plasma 6), too short to notice.
const FIRST_AFTER: Duration = Duration::from_millis(120);
/// After the first request, how long the window asks again when it loses the focus: short of
/// anything the user does next.
const KEEP_ASKING: Duration = Duration::from_millis(400);
/// The least time between two requests.
const AGAIN_AFTER: Duration = Duration::from_millis(100);
/// Focus lost this soon after a request was taken back by the taskbar menu; later, by the user.
const TAKEN_BACK_WITHIN: Duration = Duration::from_millis(50);
/// The window coming forward after a close that asks about unsaved designs.
#[derive(Default)]
pub struct ComeForward {
/// The first request and the end of asking; `None`: not asking.
window: Option<(Instant, Instant)>,
/// The last request.
last: Option<Instant>,
/// The window has had the focus since the last request.
held: bool,
}
/// What a frame does while the window is coming forward.
#[derive(Debug, PartialEq)]
enum Step {
/// Ask for the focus now.
Ask,
/// Look again after this long.
Wait(Duration),
/// Done asking.
Done,
}
impl ComeForward {
/// A close stopped at the prompt: ask for the focus shortly.
pub fn start(&mut self, now: Instant) {
let first = now + FIRST_AFTER;
self.window = Some((first, first + KEEP_ASKING));
self.last = None;
self.held = false;
}
/// Every frame: ask once the taskbar menu is gone, and again while the focus is taken back.
pub fn tick(&mut self, ctx: &egui::Context, frame: &eframe::Frame) {
if self.window.is_none() {
return;
}
let (focused, minimized) = ctx.input(|i| (i.viewport().focused, i.viewport().minimized));
// The app may be idle: a frame to look again.
match self.step(Instant::now(), focused) {
Step::Ask => {
bring(ctx, frame, minimized);
ctx.request_repaint_after(AGAIN_AFTER);
}
Step::Wait(after) => ctx.request_repaint_after(after),
Step::Done => {}
}
}
fn step(&mut self, now: Instant, focused: Option<bool>) -> Step {
let Some((first, until)) = self.window else { return Step::Done };
if now >= until {
self.window = None;
return Step::Done;
}
if now < first {
return Step::Wait(first - now);
}
let Some(last) = self.last else {
if focused == Some(true) {
// The user went back to the window already.
self.window = None;
return Step::Done;
}
self.last = Some(now);
return Step::Ask;
};
if focused == Some(true) {
self.held = true;
return Step::Wait(until - now);
}
if self.held {
if now.duration_since(last) >= TAKEN_BACK_WITHIN {
// The user switched away: leave the window there.
self.window = None;
return Step::Done;
}
self.held = false;
}
let next = last + AGAIN_AFTER;
if now < next {
return Step::Wait(next - now);
}
self.last = Some(now);
Step::Ask
}
}
/// Ask for the focus once.
fn bring(ctx: &egui::Context, frame: &eframe::Frame, minimized: Option<bool>) {
#[cfg(all(unix, not(target_os = "macos")))]
let raised = match x11_activate(frame) {
Ok(sent) => sent,
Err(e) => {
log::warn!("bringing the window forward through X11: {e}");
false
}
};
#[cfg(not(all(unix, not(target_os = "macos"))))]
let raised = {
let _ = frame;
false
};
for c in commands(minimized, raised) {
ctx.send_viewport_cmd(c);
}
}
/// The viewport commands for a window in the background; `raised`: the X11 request went out,
/// which also restores a minimized window.
fn commands(minimized: Option<bool>, raised: bool) -> Vec<egui::ViewportCommand> {
if raised {
return vec![egui::ViewportCommand::Focus];
}
let mut commands = Vec::new();
if minimized == Some(true) {
commands.push(egui::ViewportCommand::Minimized(false));
}
commands.push(egui::ViewportCommand::Focus);
commands.push(egui::ViewportCommand::RequestUserAttention(egui::UserAttentionType::Informational));
commands
}
/// Ask the X11 window manager to activate the window as a user action. `Ok(false)`: the window
/// isn't an X11 window (native Wayland).
#[cfg(all(unix, not(target_os = "macos")))]
fn x11_activate(frame: &eframe::Frame) -> Result<bool, String> {
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
use x11rb::connection::Connection;
use x11rb::protocol::xproto::{ClientMessageEvent, ConnectionExt, EventMask};
let handle = frame.window_handle().map_err(|e| e.to_string())?;
let window = match handle.as_raw() {
RawWindowHandle::Xlib(h) => u32::try_from(h.window).map_err(|e| e.to_string())?,
RawWindowHandle::Xcb(h) => h.window.get(),
_ => return Ok(false),
};
// A connection of its own: winit's isn't reachable, and a close that asks is rare.
let (conn, screen) = x11rb::connect(None).map_err(|e| e.to_string())?;
let root = conn.setup().roots.get(screen).ok_or("no X11 screen")?.root;
let atom = conn.intern_atom(false, b"_NET_ACTIVE_WINDOW").map_err(|e| e.to_string())?.reply().map_err(|e| e.to_string())?.atom;
// Source 2: a direct user action; the time is the server's current time.
let event = ClientMessageEvent::new(32, window, atom, [2, x11rb::CURRENT_TIME, 0, 0, 0]);
conn.send_event(false, root, EventMask::SUBSTRUCTURE_REDIRECT | EventMask::SUBSTRUCTURE_NOTIFY, event).map_err(|e| e.to_string())?;
conn.flush().map_err(|e| e.to_string())?;
Ok(true)
}
#[cfg(test)]
mod tests {
use super::{AGAIN_AFTER, ComeForward, FIRST_AFTER, KEEP_ASKING, Step};
use eframe::egui::{UserAttentionType, ViewportCommand as C};
use std::time::{Duration, Instant};
/// The first request waits until the taskbar menu has handed the focus back. The window asks
/// again when the focus is taken back right after it came, or never came, no more often than
/// every `AGAIN_AFTER` and only for `KEEP_ASKING`; a later switch away is the user's.
#[test]
fn the_window_asks_once_the_taskbar_menu_is_gone() {
let t0 = Instant::now();
let ms = |n: u64| Duration::from_millis(n);
let at = |n: u64| t0 + ms(n);
let (first, again, keep) = (FIRST_AFTER.as_millis() as u64, AGAIN_AFTER.as_millis() as u64, KEEP_ASKING.as_millis() as u64);
let mut f = ComeForward::default();
assert_eq!(f.step(t0, Some(false)), Step::Done, "not started");
f.start(t0);
assert_eq!(f.step(t0, Some(false)), Step::Wait(FIRST_AFTER), "the menu is still closing");
assert_eq!(f.step(at(5), Some(false)), Step::Wait(ms(first - 5)));
assert_eq!(f.step(at(first), Some(false)), Step::Ask);
assert_eq!(f.step(at(first + 3), Some(true)), Step::Wait(ms(keep - 3)), "raised");
// A slow menu takes the focus back 10 ms later: ask again, not before AGAIN_AFTER.
assert_eq!(f.step(at(first + 10), Some(false)), Step::Wait(ms(again - 10)));
assert_eq!(f.step(at(first + again), Some(false)), Step::Ask);
assert_eq!(f.step(at(first + again + 3), Some(true)), Step::Wait(ms(keep - again - 3)));
// The user clicks another app 200 ms later: the window stays behind.
assert_eq!(f.step(at(first + again + 200), Some(false)), Step::Done);
assert_eq!(f.step(at(first + again + 300), Some(false)), Step::Done);
// Never raised (the request was refused): ask again every AGAIN_AFTER, for KEEP_ASKING.
f.start(t0);
assert_eq!(f.step(at(first), None), Step::Ask, "unknown focus counts as out of focus");
assert_eq!(f.step(at(first + again), Some(false)), Step::Ask);
assert_eq!(f.step(at(first + keep), Some(false)), Step::Done);
// Back in the window before the first request: nothing to do.
f.start(t0);
assert_eq!(f.step(at(first), Some(true)), Step::Done);
}
/// Without the X11 request: focus, and the attention mark where focus is refused; a minimized
/// window is restored first.
#[test]
fn a_window_closed_from_the_background_comes_forward() {
let forward = [C::Focus, C::RequestUserAttention(UserAttentionType::Informational)];
assert_eq!(super::commands(Some(false), false), forward);
assert_eq!(super::commands(None, false), forward, "unknown counts as not minimized");
let minimized = super::commands(Some(true), false);
assert_eq!(minimized.first(), Some(&C::Minimized(false)));
assert_eq!(minimized.get(1..), Some(&forward[..]));
// The window manager took the X11 request: nothing left to mark.
assert_eq!(super::commands(Some(true), true), [C::Focus]);
}
}
+430 -38
View File
@@ -13,16 +13,20 @@ use solidcraft_engine::MAX_LINE;
use solidcraft_engine::control_auth;
use solidcraft_engine::control_pending;
use solidcraft_ui_egui::ControlRequest;
use solidcraft_ui_egui::control::{DESIGN_WATCH, DesignWatch, Subscriber};
/// Connections served at once, so a flood must not be able to spawn threads without end.
const MAX_CONNECTIONS: usize = 16;
/// Lines that are not JSON before the connection is closed, so garbage cannot loop forever.
const MAX_BAD_LINES: usize = 8;
/// How long a subscribed connection waits after a `design.changed` line before the next: what
/// changes meanwhile (a parameter dragged) goes out as one line, the latest (#6985).
const EVENT_GAP: Duration = Duration::from_millis(100);
static OPEN: AtomicUsize = AtomicUsize::new(0);
/// 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());
/// The token files this launch wrote (its control port, the agent chat's endpoint) and the
/// token in each, removed when the app ends unless another launch has written the file since.
static TOKEN_FILES: std::sync::Mutex<Vec<(std::path::PathBuf, String)>> = std::sync::Mutex::new(Vec::new());
/// A new random token: 32 bytes from the operating system, as hex.
fn new_token() -> Option<String> {
@@ -31,20 +35,21 @@ fn new_token() -> Option<String> {
Some(bytes.iter().map(|b| format!("{b:02x}")).collect())
}
/// Remove this launch's token file (at exit).
/// Remove this launch's token files and discovery file (at exit), leaving any that another
/// launch has written since (#8794).
pub fn remove_token_file() {
if let Ok(files) = TOKEN_FILES.lock() {
for p in files.iter() {
control_auth::remove_token_file(p);
for (path, token) in files.iter() {
control_auth::remove_token_file(path, token);
}
}
if let Some(port) = APP_PORT.lock().ok().and_then(|p| *p) {
control_auth::remove_current_port(port);
if let Some((port, token)) = APP_PORT.lock().ok().and_then(|p| p.clone()) {
control_auth::remove_current_port(port, &token);
}
}
/// 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);
/// The port the app's own channel listens on and its token, for removing the discovery file at exit.
static APP_PORT: std::sync::Mutex<Option<(u16, String)>> = std::sync::Mutex::new(None);
pub fn start(listen_on: control_auth::Listen, ctx: egui::Context) -> Receiver<ControlRequest> {
let (tx, rx) = channel::<ControlRequest>();
@@ -57,7 +62,7 @@ pub fn start(listen_on: control_auth::Listen, ctx: egui::Context) -> Receiver<Co
};
match listener {
Ok(l) => {
if let Some(port) = listen_on_listener(l, token, tx, ctx, false) {
if let Some(port) = listen_on_listener(l, token.clone(), 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)");
}
@@ -65,7 +70,7 @@ pub fn start(listen_on: control_auth::Listen, ctx: egui::Context) -> Receiver<Co
match control_auth::write_current_port(port) {
Ok(_) => {
if let Ok(mut p) = APP_PORT.lock() {
*p = Some(port);
*p = Some((port, token));
}
}
Err(e) => eprintln!("solidcraft: could not write the control discovery file ({e})"),
@@ -108,7 +113,7 @@ fn listen_on_listener(listener: TcpListener, token: String, tx: Sender<ControlRe
Ok(()) => {
eprintln!("solidcraft: control server listening on 127.0.0.1:{port} (token in {})", path.display());
if let Ok(mut files) = TOKEN_FILES.lock() {
files.push(path);
files.push((path, token.clone()));
}
}
Err(e) => eprintln!(
@@ -127,7 +132,7 @@ fn listen_on_listener(listener: TcpListener, token: String, tx: Sender<ControlRe
if OPEN.fetch_add(1, Ordering::Relaxed) >= MAX_CONNECTIONS {
OPEN.fetch_sub(1, Ordering::Relaxed);
let mut refused = stream;
let _ = writeln!(refused, "{}", json!({"ok": false, "error": "too many connections"}));
let _ = send(&mut refused, &json!({"ok": false, "error": "too many connections"}));
continue;
}
let tx = tx.clone();
@@ -174,6 +179,44 @@ fn read_line(r: &mut impl BufRead, max: usize) -> std::io::Result<Option<Vec<u8>
}
}
/// A reply line, written to the socket at once. `writeln!` with a JSON value hands the socket each
/// of its pieces apart, a system call for every string, number and comma: half a second for a
/// design's component tree when it has thousands of components.
fn send(out: &mut impl Write, reply: &Value) -> std::io::Result<()> {
let mut line = serde_json::to_vec(reply).map_err(std::io::Error::other)?;
line.push(b'\n');
out.write_all(&line)
}
/// The `"token"` of a request line that is a JSON object, read without building the rest of it.
/// Until a connection shows the token, a line costs only its own bytes: parsed whole, a 16 MiB
/// line of `[0,0,…]` is hundreds of MB, and 16 such connections could exhaust memory before any
/// token was checked.
fn token_of(line: &str) -> Option<String> {
struct Token;
impl<'de> serde::de::Visitor<'de> for Token {
type Value = Option<String>;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a request object")
}
fn visit_map<A: serde::de::MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
let mut token = None;
while let Some(key) = map.next_key::<String>()? {
if key == "token" {
token = Some(map.next_value::<String>()?);
} else {
map.next_value::<serde::de::IgnoredAny>()?;
}
}
Ok(token)
}
}
let mut de = serde_json::Deserializer::from_str(line);
let token = serde::Deserializer::deserialize_map(&mut de, Token).ok()?;
de.end().ok()?;
token
}
/// The reply to a connection that didn't show the token; the connection is then closed.
fn unauthorized() -> Value {
json!({"ok": false, "error": "unauthorized: send the control token as \"token\" (see docs/control-protocol.md)"})
@@ -184,7 +227,7 @@ fn unauthorized() -> Value {
/// the connection, and a reset can reach the client before it has read the refusal.
fn refuse(out: &mut TcpStream, reader: &mut BufReader<TcpStream>) {
use std::io::Read;
let _ = writeln!(out, "{}", unauthorized());
let _ = send(out, &unauthorized());
let _ = out.shutdown(std::net::Shutdown::Write);
let _ = reader.get_ref().set_read_timeout(Some(Duration::from_millis(500)));
let _ = std::io::copy(&mut reader.by_ref().take(64 * 1024), &mut std::io::sink());
@@ -356,10 +399,137 @@ fn busy_message(p: &Pending, method: &str) -> String {
)
}
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context, patient: bool) {
serve_with(stream, token, tx, ctx, patient, WAITS, &RUNNING);
/// A subscribed connection's socket, for its replies and its events: each line is written whole
/// and alone, so an event never lands inside a reply (#6985).
struct Lines {
socket: TcpStream,
turn: Mutex<()>,
}
impl Lines {
fn send(&self, line: &Value) -> std::io::Result<()> {
let _turn = self.turn.lock().unwrap_or_else(PoisonError::into_inner);
send(&mut &self.socket, line)
}
}
/// A subscribed connection's seat on the watch: it leaves exactly once, whichever thread sees
/// the connection's end first — the connection's own thread at the end of the stream, or the
/// events thread at the first write a closed client refuses. The seat used to wait for the
/// events thread alone, and that thread only leaves its wait when the design moves, so the
/// count could outlive the connection by a second or more (#8315).
struct Seat(Mutex<Option<Subscriber>>);
impl Seat {
fn new(counted: Subscriber) -> Self {
Seat(Mutex::new(Some(counted)))
}
/// Leave the watch now: the seat goes once, whoever gets here first.
fn release(&self) {
let counted = self.0.lock().unwrap_or_else(PoisonError::into_inner).take();
drop(counted);
}
}
impl Drop for Seat {
fn drop(&mut self) {
self.release();
}
}
/// A connection's `control.subscribe` (#6985): its `design.changed` lines, sent from a thread of
/// their own until the connection ends.
struct Subscription {
lines: Arc<Lines>,
stop: Arc<AtomicBool>,
seat: Arc<Seat>,
}
impl Subscription {
/// Write `reply`, the window's answer to `control.subscribe` naming the design `seen`, on
/// `out`, then send `design`'s changes after it: the reply goes out before any event. `None`,
/// with nothing written, when no thread or socket was left for the events.
fn start(
design: &'static DesignWatch,
counted: Subscriber,
out: &TcpStream,
seen: (String, u64),
reply: &Value,
) -> Option<(Self, std::io::Result<()>)> {
let lines = Arc::new(Lines { socket: out.try_clone().ok()?, turn: Mutex::new(()) });
let stop = Arc::new(AtomicBool::new(false));
let seat = Arc::new(Seat::new(counted));
let first = lines.turn.lock().unwrap_or_else(PoisonError::into_inner);
let (l, s, watch_seat) = (lines.clone(), stop.clone(), Arc::clone(&seat));
// The connection's seat on the watch rides the events thread: it leaves when the thread
// does, so the next failed write to a closed client retires it even while the connection's
// own thread is slow to see the end of the stream (#7226). The connection's own thread
// leaves it too, so a closed connection stops counting at once (#8315).
std::thread::Builder::new().stack_size(64 * 1024).spawn(move || send_events(design, &l, &s, seen, watch_seat)).ok()?;
let sent = send(&mut &lines.socket, reply);
drop(first);
Some((Subscription { lines, stop, seat }, sent))
}
}
/// The design a `control.subscribe` reply names.
fn design_of(reply: &Value) -> Option<(String, u64)> {
let result = reply.get("result")?;
Some((result.get("document_id")?.as_str()?.to_string(), result.get("revision")?.as_u64()?))
}
impl Drop for Subscription {
fn drop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
// The seat on the watch goes now: the connection is over, and nothing counts it any more
// (#8315).
self.seat.release();
// The socket closes now, not when the thread next looks; a line blocked on a client that
// stopped reading ends too.
let _ = self.lines.socket.shutdown(std::net::Shutdown::Both);
}
}
/// The `design.changed` lines of a subscribed connection, each time the design differs from the
/// last one it was told of (`seen`), until `stop`. Holds the connection's seat on the watch
/// (`seat`), which it leaves when the thread ends: on `stop`, or on the first write a closed
/// client refuses (#7226).
fn send_events(design: &DesignWatch, lines: &Lines, stop: &AtomicBool, mut seen: (String, u64), seat: Arc<Seat>) {
while !stop.load(Ordering::Relaxed) {
let Some(now) = design.wait(&seen, stop, Duration::from_secs(1)).filter(|now| *now != seen) else { continue };
if lines.send(&json!({"event": "design.changed", "params": {"document_id": now.0, "revision": now.1}})).is_err() {
break;
}
seen = now;
std::thread::sleep(EVENT_GAP);
}
// The client is gone: it is not counted on the watch any more, whatever the connection's own
// thread still has to notice (#8315).
seat.release();
}
/// What this app's control channel does beyond the methods every version has, for a client to
/// find out before it relies on it (an older app answers `control.capabilities` with "unknown
/// method"). `param_check`: the levels `engine.execute`, `engine.batch` and `engine.script` take
/// as a request's own parameter check (#9184).
fn capabilities() -> Value {
json!({"param_check": solidcraft_engine::schema::ParamCheck::ALL.map(|l| l.name())})
}
/// Write `reply` on the connection: through its subscription once it has one.
fn answer(out: &mut TcpStream, events: Option<&Subscription>, reply: &Value) -> std::io::Result<()> {
match events {
Some(e) => e.lines.send(reply),
None => send(out, reply),
}
}
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context, patient: bool) {
serve_with(stream, token, tx, ctx, patient, WAITS, &RUNNING, &DESIGN_WATCH);
}
#[allow(clippy::too_many_arguments)]
fn serve_with(
stream: TcpStream,
token: &str,
@@ -368,6 +538,7 @@ fn serve_with(
patient: bool,
waits: Waits,
running: &'static Running,
design: &'static DesignWatch,
) {
let connection = running.connections.fetch_add(1, Ordering::Relaxed) + 1;
let Ok(read) = stream.try_clone() else { return };
@@ -376,42 +547,50 @@ fn serve_with(
let mut bad = 0usize;
let mut authenticated = false;
let mut pending: Option<Pending> = None;
let mut events: Option<Subscription> = None;
loop {
// A first `control.subscribe` on its way to the window (#6985).
let mut joining = None;
let line = match read_line(&mut reader, MAX_LINE) {
Ok(Some(l)) => String::from_utf8_lossy(&l).into_owned(),
Ok(None) => break,
Err(e) => {
let _ = writeln!(out, "{}", json!({"ok": false, "error": e.to_string()}));
let _ = answer(&mut out, events.as_ref(), &json!({"ok": false, "error": e.to_string()}));
break;
}
};
if line.trim().is_empty() {
continue;
}
// The first request must carry the token; later ones on the same connection may.
if !authenticated {
match token_of(&line) {
Some(t) if control_auth::tokens_match(&t, token) => authenticated = true,
_ => {
refuse(&mut out, &mut reader);
break;
}
}
}
let reply = match serde_json::from_str::<Value>(&line) {
Ok(msg) => {
// The first request must carry the token; later ones on the same connection may.
if !authenticated {
match msg.get("token").and_then(Value::as_str) {
Some(t) if control_auth::tokens_match(t, token) => authenticated = true,
_ => {
refuse(&mut out, &mut reader);
break;
}
}
}
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 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() {
// A request that timed out may have finished since (#1618). `control.running` and
// `control.capabilities` only look, so they leave that reply where it is.
let late = pending.as_ref().filter(|_| !matches!(method.as_str(), "control.running" | "control.capabilities")).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.capabilities" {
json!({"ok": true, "result": capabilities()})
} else if method == "control.pending" {
match (pending.take(), late) {
(None, _) => json!({"ok": true, "result": {"pending": null}}),
@@ -425,6 +604,14 @@ fn serve_with(
} 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 method == "control.subscribe" && events.is_some() {
if late.is_some() {
// As below: finished, and the client moved on without asking.
pending = None;
}
// Its events already say how the design moves; a second answer could only
// arrive behind a newer one.
json!({"ok": false, "error": "already subscribed: this connection is sent design.changed lines; ask document.revision for the design now"})
} else {
if late.is_some() {
// Finished, and the client moved on without asking: it is dropped.
@@ -434,6 +621,9 @@ fn serve_with(
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);
// Counted before the window answers, so it publishes every change from its
// answer on.
joining = (method == "control.subscribe").then(|| design.join());
let (req, rrx) = ControlRequest::new(method.clone(), params);
let ticket = running.add(connection, &id, &req);
if tx.send(req).is_err() {
@@ -454,16 +644,25 @@ fn serve_with(
}
r
}
Err(_) if !authenticated => {
refuse(&mut out, &mut reader);
break;
}
Err(e) => {
bad += 1;
json!({"ok": false, "error": format!("bad JSON: {e}")})
}
};
if writeln!(out, "{reply}").is_err() {
// Subscribed once the window named the design (not when it refused or timed out).
let sent = match joining.and_then(|counted| design_of(&reply).map(|seen| (counted, seen))) {
Some((counted, seen)) => match Subscription::start(design, counted, &out, seen, &reply) {
Some((subscription, sent)) => {
events = Some(subscription);
sent
}
None => {
send(&mut out, &json!({"id": reply.get("id"), "ok": false, "error": "not subscribed: no thread or socket left for the events"}))
}
},
None => answer(&mut out, events.as_ref(), &reply),
};
if sent.is_err() {
break;
}
if bad >= MAX_BAD_LINES {
@@ -503,7 +702,7 @@ mod tests {
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);
serve_with(stream, token, tx, egui::Context::default(), false, waits, &RUNNING, &DESIGN_WATCH);
}
});
let s = TcpStream::connect(addr).unwrap();
@@ -569,7 +768,7 @@ mod tests {
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));
std::thread::spawn(move || serve_with(stream, token, tx, egui::Context::default(), false, waits, &RUNNING, &DESIGN_WATCH));
}
});
let open = || {
@@ -598,6 +797,9 @@ mod tests {
let mut watcher = open();
let (r, rows) = running(&mut watcher, &|_| true);
assert!(r["ok"] == true && rows.is_empty(), "idle: {r}");
// What the app's control channel does beyond the methods every version has (#9184).
let r = watcher(json!({"id": 0, "method": "control.capabilities", "token": token}));
assert_eq!((&r["ok"], &r["result"]["param_check"]), (&json!(true), &json!(["off", "warn", "strict"])), "{r}");
let mut export = open();
let path = "/private/part.step";
@@ -629,6 +831,9 @@ mod tests {
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}");
// So may it ask what the app can do: answered at once, not refused as busy.
let r = export(json!({"id": 8, "method": "control.capabilities"}));
assert_eq!(r["result"]["param_check"], json!(["off", "warn", "strict"]), "{r}");
gate_tx.send(()).unwrap();
assert_eq!(queued.join().unwrap()["result"], "engine.batch");
@@ -639,6 +844,153 @@ mod tests {
assert_eq!((&r["result"]["pending"], &r["result"]["done"]), (&json!(7), &json!(true)), "{r}");
}
/// #6985: a connection that sends `control.subscribe` gets the design in the reply, then a
/// `design.changed` line each time the window publishes another: never before the reply,
/// none for the same design, the latest after a burst, each line whole between its replies.
/// A second subscribe is refused without reaching the window. A connection that did not
/// subscribe gets no events, and one that closed is no longer counted.
#[test]
fn subscribed_connections_are_told_of_design_changes() {
static RUNNING: Running = Running::new();
static WATCH: DesignWatch = DesignWatch::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: it answers `control.subscribe` as the app does, publishing the design first;
// then the design changes at once, before the reply may have been written.
std::thread::spawn(move || {
for req in rx {
if req.method == "control.subscribe" {
WATCH.publish("d-1", 3);
let _ = req.reply.send(json!({"ok": true, "result": {"document_id": "d-1", "revision": 3}}));
WATCH.publish("d-1", 4);
} else {
let _ = req.reply.send(json!({"ok": true, "result": req.method}));
}
}
});
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, &WATCH));
}
});
let open = || {
let s = TcpStream::connect(addr).unwrap();
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
(s.try_clone().unwrap(), BufReader::new(s))
};
let next = |lines: &mut BufReader<TcpStream>| -> Value {
let mut l = String::new();
lines.read_line(&mut l).unwrap();
serde_json::from_str(&l).unwrap()
};
let changed = |id: &str, revision: u64| json!({"event": "design.changed", "params": {"document_id": id, "revision": revision}});
let (mut quiet_out, mut quiet) = open();
writeln!(quiet_out, "{}", json!({"id": 1, "method": "ui.view", "token": token})).unwrap();
assert_eq!(next(&mut quiet)["id"], 1);
let (mut out, mut lines) = open();
writeln!(out, "{}", json!({"id": 1, "method": "control.subscribe", "token": token})).unwrap();
assert_eq!(next(&mut lines), json!({"id": 1, "ok": true, "result": {"document_id": "d-1", "revision": 3}}));
assert_eq!(next(&mut lines), changed("d-1", 4), "the change after the window's answer comes after the reply");
assert!(WATCH.subscribed());
// The design it was told of is no news; another revision is.
WATCH.publish("d-1", 4);
WATCH.publish("d-1", 5);
assert_eq!(next(&mut lines), changed("d-1", 5));
// A burst ends with its latest.
for revision in 6..=10 {
WATCH.publish("d-1", revision);
}
let mut last = 5;
while last < 10 {
let e = next(&mut lines);
let revision = e["params"]["revision"].as_u64().unwrap();
assert!(e["event"] == "design.changed" && revision > last, "{e} after {last}");
last = revision;
}
// Another tab: another id, whatever its revision.
WATCH.publish("d-2", 2);
assert_eq!(next(&mut lines), changed("d-2", 2));
// Subscribed already: refused here (the window would have published d-1 again).
writeln!(out, "{}", json!({"id": 2, "method": "control.subscribe"})).unwrap();
let r = next(&mut lines);
assert!(r["id"] == 2 && r["ok"] == false && r["error"].as_str().is_some_and(|e| e.starts_with("already subscribed")), "{r}");
// Replies still come, whole, and nothing else was sent meanwhile.
writeln!(out, "{}", json!({"id": 3, "method": "ui.view"})).unwrap();
assert_eq!(next(&mut lines), json!({"id": 3, "ok": true, "result": "ui.view"}));
writeln!(quiet_out, "{}", json!({"id": 2, "method": "ui.view"})).unwrap();
assert_eq!(next(&mut quiet), json!({"id": 2, "ok": true, "result": "ui.view"}), "no events without subscribing");
// Closed, it is no longer counted. Each publish asks the events thread for one more line,
// and its write to the closed connection is what retires the seat; that keeps the wait off
// the connection's own thread, which anything else this process is doing may hold up past
// the deadline (#7226).
drop((out, lines));
let mut revision = 11;
let deadline = Instant::now() + Duration::from_secs(10);
while WATCH.subscribed() && Instant::now() < deadline {
WATCH.publish("d-1", revision);
revision += 1;
std::thread::sleep(Duration::from_millis(20));
}
assert!(!WATCH.subscribed());
}
/// #8315: a closed connection leaves the watch at once. The seat of a subscribed connection
/// goes when the connection's own thread sees the end of the stream, not only when the
/// events thread notices: that thread waits for the design to move, so a connection that
/// closed while the design stood still kept the watch counting it for a second or more.
#[test]
fn a_closed_connection_leaves_the_watch_at_once() {
static RUNNING: Running = Running::new();
static WATCH: DesignWatch = DesignWatch::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 names the design once and then leaves it alone: nothing moves it, so the
// events thread has nothing to write and only the end of the connection can retire it.
std::thread::spawn(move || {
for req in rx {
if req.method == "control.subscribe" {
WATCH.publish("d-1", 3);
let _ = req.reply.send(json!({"ok": true, "result": {"document_id": "d-1", "revision": 3}}));
} else {
let _ = req.reply.send(json!({"ok": true, "result": req.method}));
}
}
});
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, &WATCH));
}
});
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);
writeln!(out, "{}", json!({"id": 1, "method": "control.subscribe", "token": token})).unwrap();
let mut l = String::new();
lines.read_line(&mut l).unwrap();
let reply: Value = serde_json::from_str(&l).unwrap();
assert!(reply["ok"] == true, "{reply}");
assert!(WATCH.subscribed(), "the connection is subscribed");
// Let the events thread settle into its wait: the design stands still, so it has nothing
// to write, and only the end of the connection can retire it.
std::thread::sleep(Duration::from_millis(200));
// The client goes away without asking anything else.
drop((out, lines));
let deadline = Instant::now() + Duration::from_millis(400);
while WATCH.subscribed() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(10));
}
assert!(!WATCH.subscribed(), "a closed connection is not counted on the watch");
}
/// 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]
@@ -658,6 +1010,19 @@ mod tests {
assert!(render.contains("will still write the file"), "{render}");
}
/// The token is read from a request object without building the rest of it: other fields,
/// however large or nested, are only skipped. Anything but an object with a string token is
/// no token.
#[test]
fn token_is_read_without_parsing_the_request() {
assert_eq!(token_of(r#"{"id":1,"token":"abc","params":{"a":[1,[2,{"b":null}]]}}"#).as_deref(), Some("abc"));
let flood = format!(r#"{{"params":[{}0],"token":"t"}}"#, "0,".repeat(1 << 20));
assert_eq!(token_of(&flood).as_deref(), Some("t"));
for no in [r#"["abc"]"#, r#"{"token":5}"#, r#"{"token":"a"} x"#, r#"{"id":1}"#, "GET / HTTP/1.1", r#""token""#] {
assert_eq!(token_of(no), None, "{no}");
}
}
/// #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.
@@ -750,4 +1115,31 @@ mod tests {
let mut r = BufReader::with_capacity(64, text.as_slice());
assert!(read_line(&mut r, 1000).is_err());
}
/// A large reply reaches the socket in one write, not one per JSON piece (a component tree of
/// thousands of components took half a second to send), and reads back as it was.
#[test]
fn a_large_reply_is_written_at_once() {
struct Counted {
writes: usize,
bytes: Vec<u8>,
}
impl Write for Counted {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.writes += 1;
self.bytes.extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
let components: Vec<Value> = (0..2000).map(|i| json!({"id": i, "name": format!("Part {i}"), "bodies": ["a", "b"], "parent": 0})).collect();
let reply = json!({"ok": true, "id": 7, "result": {"components": components}});
let mut out = Counted { writes: 0, bytes: Vec::new() };
send(&mut out, &reply).unwrap();
assert_eq!(out.writes, 1);
assert_eq!(out.bytes.last(), Some(&b'\n'));
assert_eq!(serde_json::from_slice::<Value>(&out.bytes).unwrap(), reply);
}
}
+195
View File
@@ -0,0 +1,195 @@
//! Linux: the desktop's dark or light preference, for Theme: System (#7944).
//!
//! The window toolkit does not pass it on everywhere: on GNOME (Wayland, or X11 through XWayland)
//! the window never learns it, nor when it changes, so the app kept its colours. This reads the
//! freedesktop setting through the XDG Desktop Portal's Settings interface on the session bus
//! (`org.freedesktop.appearance color-scheme`, then GNOME's `color-scheme` and `gtk-theme`), the
//! same setting other applications follow, and inside a Flatpak too. It runs on a thread of its
//! own: the first reading, then the portal's `SettingChanged` signal; where the signal can't be
//! had, a reading every few seconds. Without a portal it asks `gsettings` instead, on the same
//! thread. The window reads the last answer with an atomic load
//! (`solidcraft_ui_egui::Services::desktop_dark`), and the decision from the raw values is
//! `solidcraft_ui_egui::prefs::desktop_prefers_dark`.
use std::io::Read;
use std::sync::Arc;
use std::sync::atomic::{AtomicU8, Ordering};
use std::time::Duration;
use zbus::zvariant::{OwnedValue, Value};
const UNKNOWN: u8 = 0;
const DARK: u8 = 1;
const LIGHT: u8 = 2;
/// How often to read again where the portal's change signal is not available.
const POLL: Duration = Duration::from_secs(5);
/// How long one `gsettings` reading may take before it is given up.
const GSETTINGS_TIMEOUT: Duration = Duration::from_secs(2);
const PORTAL: &str = "org.freedesktop.portal.Desktop";
const PORTAL_PATH: &str = "/org/freedesktop/portal/desktop";
const SETTINGS: &str = "org.freedesktop.portal.Settings";
const APPEARANCE: &str = "org.freedesktop.appearance";
const GNOME: &str = "org.gnome.desktop.interface";
/// Start following the desktop's preference; the closure answers the latest reading (`None`
/// until there is one, or when the desktop says nothing).
pub fn start(ctx: &egui::Context) -> Box<dyn Fn() -> Option<bool>> {
let state = Arc::new(AtomicU8::new(UNKNOWN));
let (thread_state, ctx) = (state.clone(), ctx.clone());
if let Err(e) = std::thread::Builder::new().name("desktop-theme".into()).spawn(move || follow(&thread_state, &ctx)) {
log::warn!("desktop theme: no thread to follow it: {e}");
}
Box::new(move || match state.load(Ordering::Relaxed) {
DARK => Some(true),
LIGHT => Some(false),
_ => None,
})
}
/// Keep `state` on the desktop's preference, asking for a frame whenever it changes.
fn follow(state: &AtomicU8, ctx: &egui::Context) {
let publish = |dark: Option<bool>| {
let code = match dark {
Some(true) => DARK,
Some(false) => LIGHT,
None => UNKNOWN,
};
if state.swap(code, Ordering::Relaxed) != code {
ctx.request_repaint();
}
};
let portal = match portal() {
Ok(p) => Some(p),
Err(e) => {
log::info!("desktop theme: no settings portal ({e}); asking gsettings");
None
}
};
// The portal first; gsettings where there is none or it says nothing (its backend missing).
let read = |portal: Option<&zbus::blocking::Proxy<'static>>| portal.and_then(read_portal).or_else(read_gsettings);
let from_portal = portal.as_ref().and_then(read_portal);
let first = from_portal.or_else(read_gsettings);
publish(first);
// Wait for the portal's signal only when the portal answers: else the reading is gsettings',
// which only a new reading notices.
if let Some(p) = portal.as_ref().filter(|_| from_portal.is_some()) {
match p.receive_signal("SettingChanged") {
Ok(changes) => {
for message in changes {
if relevant(&message) {
publish(read(Some(p)));
}
}
log::info!("desktop theme: the portal's change signal ended; reading every few seconds");
}
Err(e) => log::info!("desktop theme: no change signal ({e}); reading every few seconds"),
}
} else if portal.is_none() && first.is_none() && !gsettings_available() {
// Neither the portal nor gsettings: nothing to follow; the window toolkit's theme stays.
return;
}
loop {
std::thread::sleep(POLL);
publish(read(portal.as_ref()));
}
}
/// The portal's Settings interface on the session bus.
fn portal() -> zbus::Result<zbus::blocking::Proxy<'static>> {
let connection = zbus::blocking::Connection::session()?;
zbus::blocking::Proxy::new(&connection, PORTAL, PORTAL_PATH, SETTINGS)
}
/// One reading through the portal.
fn read_portal(portal: &zbus::blocking::Proxy<'static>) -> Option<bool> {
let appearance = read_setting(portal, APPEARANCE, "color-scheme").and_then(|v| as_u32(&v));
// The GNOME settings only matter when the freedesktop one has no preference.
if matches!(appearance, Some(1 | 2)) {
return solidcraft_ui_egui::prefs::desktop_prefers_dark(appearance, None, None);
}
let gnome = read_setting(portal, GNOME, "color-scheme").and_then(|v| as_string(&v));
let gtk_theme = read_setting(portal, GNOME, "gtk-theme").and_then(|v| as_string(&v));
solidcraft_ui_egui::prefs::desktop_prefers_dark(appearance, gnome.as_deref(), gtk_theme.as_deref())
}
/// A setting through `ReadOne` (portal version 2), else the older `Read`.
fn read_setting(portal: &zbus::blocking::Proxy<'static>, namespace: &str, key: &str) -> Option<OwnedValue> {
let one: zbus::Result<OwnedValue> = portal.call("ReadOne", &(namespace, key));
one.or_else(|_| -> zbus::Result<OwnedValue> { portal.call("Read", &(namespace, key)) }).ok()
}
/// Whether a `SettingChanged` signal is about a setting the preference is read from.
fn relevant(message: &zbus::Message) -> bool {
let body = message.body();
let Ok((namespace, key, _)) = body.deserialize::<(String, String, OwnedValue)>() else { return false };
matches!((namespace.as_str(), key.as_str()), (APPEARANCE, "color-scheme") | (GNOME, "color-scheme" | "gtk-theme"))
}
/// The value inside the variants `Read` wraps it in (a portal answers one or two deep).
fn unwrap_variants<'v, 'a>(value: &'v Value<'a>) -> &'v Value<'a> {
let mut value = value;
for _ in 0..4 {
match value {
Value::Value(inner) => value = &**inner,
_ => break,
}
}
value
}
fn as_u32(value: &Value<'_>) -> Option<u32> {
match unwrap_variants(value) {
Value::U32(n) => Some(*n),
Value::I32(n) => u32::try_from(*n).ok(),
Value::U8(n) => Some(u32::from(*n)),
_ => None,
}
}
fn as_string(value: &Value<'_>) -> Option<String> {
match unwrap_variants(value) {
Value::Str(s) => Some(s.as_str().to_string()),
_ => None,
}
}
/// Without a portal: GNOME's settings through `gsettings` (`None` when it is not there).
fn read_gsettings() -> Option<bool> {
let gnome = gsettings("color-scheme");
let gtk_theme = gsettings("gtk-theme");
solidcraft_ui_egui::prefs::desktop_prefers_dark(None, gnome.as_deref(), gtk_theme.as_deref())
}
fn gsettings_available() -> bool {
gsettings("gtk-theme").is_some()
}
/// `gsettings get org.gnome.desktop.interface <key>`, its quoted answer, or `None` when the
/// program is missing, fails or does not answer in time.
fn gsettings(key: &str) -> Option<String> {
let mut child = std::process::Command::new("gsettings")
.args(["get", GNOME, key])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.spawn()
.ok()?;
let started = std::time::Instant::now();
loop {
match child.try_wait() {
Ok(Some(status)) if status.success() => break,
Ok(None) if started.elapsed() < GSETTINGS_TIMEOUT => std::thread::sleep(Duration::from_millis(20)),
_ => {
let _ = child.kill();
let _ = child.wait();
return None;
}
}
}
let mut out = String::new();
child.stdout.take()?.take(4096).read_to_string(&mut out).ok()?;
let value = out.trim().trim_matches('\'').to_string();
(!value.is_empty()).then_some(value)
}
+93 -10
View File
@@ -5,12 +5,25 @@
//! 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.
//!
//! The old process ends without tearing down its GPU objects ([`end`]): on a lost NVIDIA device
//! destroying the window's swap chain busy-waits in the driver and never returns (#9044).
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";
/// Set on the start after a lost device: why it was lost, as wgpu said it. The old process's
/// session journal, which recorded it, is gone by then.
pub const REASON_ENV: &str = "SOLIDCRAFT_DEVICE_LOST_REASON";
/// The longest reason passed on ([`REASON_ENV`]) and shown, in characters.
const REASON_CHARS: usize = 300;
/// How long the process may take to end ([`end`]) before it is aborted.
const END_GRACE: std::time::Duration = std::time::Duration::from_secs(3);
/// 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.
@@ -32,27 +45,54 @@ pub fn restart_on_software(after_recent_loss: bool, cpu_renderer: bool) -> bool
after_recent_loss || cpu_renderer
}
/// `reason` on one line, at most [`REASON_CHARS`] long; None when there is nothing to say.
pub fn reason(reason: Option<&str>) -> Option<String> {
let line = reason?.split_whitespace().collect::<Vec<_>>().join(" ");
(!line.is_empty()).then(|| line.chars().take(REASON_CHARS).collect())
}
/// What the start after a lost device tells the user, `software` when it runs on the software
/// renderer.
pub fn note(software: bool) -> String {
/// renderer, with what the driver reported ([`REASON_ENV`]).
pub fn note(software: bool, reason: Option<&str>) -> 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.");
}
if let Some(reason) = self::reason(reason) {
note.push_str(&format!(" The graphics driver reported: {reason}"));
}
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 };
/// The command that starts `exe` again with `args`, the loss stamped at `lost_at` and its
/// `reason`, on the software renderer when `software`.
fn command(
exe: std::path::PathBuf,
args: impl IntoIterator<Item = std::ffi::OsString>,
software: bool,
lost_at: u64,
reason: &str,
) -> std::process::Command {
let mut command = std::process::Command::new(exe);
command.args(std::env::args_os().skip(1)).env(LOST_ENV, lost_at.to_string());
command.args(args).env(LOST_ENV, lost_at.to_string());
match self::reason(Some(reason)) {
Some(reason) => command.env(REASON_ENV, reason),
// Not one inherited from an earlier loss.
None => command.env_remove(REASON_ENV),
};
if software {
command.env(startup::RENDERER_ENV, "cpu");
}
command
}
/// Start SolidCraft again with the same arguments, the loss stamped at `lost_at` and its `reason`,
/// on the software renderer when `software`.
pub fn restart(software: bool, lost_at: u64, reason: &str) {
let Ok(exe) = std::env::current_exe() else { return };
let mut command = command(exe, std::env::args_os().skip(1), software, lost_at, reason);
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
@@ -64,6 +104,23 @@ pub fn restart(software: bool, lost_at: u64) {
}
}
/// End the process now, without tearing down what is left on the lost device: the window's swap
/// chain, the viewport's buffers and pipelines. On NVIDIA's Vulkan driver destroying the swap
/// chain of a lost device busy-waits forever, which left the old window frozen at 100% of a core
/// beside the new one (#9044). Should the exit itself get stuck in a library's exit handler, the
/// process is aborted after [`END_GRACE`].
pub fn end() -> ! {
let watchdog = std::thread::Builder::new().name("solidcraft-exit".into()).spawn(|| {
std::thread::sleep(END_GRACE);
eprintln!("SolidCraft graphics: still closing after the device was lost; aborting");
std::process::abort();
});
if let Err(e) = watchdog {
log::warn!("graphics: no exit watchdog: {e}");
}
std::process::exit(0)
}
#[cfg(test)]
mod tests {
/// Only a recent, well-formed stamp counts: a later start that inherited it says nothing.
@@ -84,8 +141,34 @@ mod tests {
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"));
assert!(super::note(true, None).contains("software rendering"));
assert!(!super::note(false, None).contains("software rendering"));
assert!(super::note(false, None).contains("kept for recovery"));
}
/// The start after a loss is told why (#9044): the old process's journal held the reason, and
/// it is deleted as that process closes.
#[test]
fn the_next_start_is_told_why_the_device_was_lost() {
let exe = std::path::PathBuf::from("solidcraft");
let args = ["--control".into(), "7979".into()];
let command = super::command(exe.clone(), args, true, 1_000, "Lost: Device lost\n (vkQueueSubmit)");
let env = |command: &std::process::Command, name: &str| {
command.get_envs().find(|(k, _)| *k == name).map(|(_, v)| v.map(|v| v.to_string_lossy().into_owned()))
};
assert_eq!(env(&command, super::LOST_ENV), Some(Some("1000".into())));
assert_eq!(env(&command, super::REASON_ENV), Some(Some("Lost: Device lost (vkQueueSubmit)".into())), "on one line");
assert_eq!(env(&command, crate::startup::RENDERER_ENV), Some(Some("cpu".into())));
assert_eq!(command.get_args().collect::<Vec<_>>(), ["--control", "7979"]);
// No reason: none inherited from an earlier loss either.
let command = super::command(exe, Vec::new(), false, 1_000, " ");
assert_eq!(env(&command, super::REASON_ENV), Some(None));
assert_eq!(env(&command, crate::startup::RENDERER_ENV), None);
let note = super::note(false, Some("Lost: Device lost"));
assert!(note.ends_with("The graphics driver reported: Lost: Device lost"), "{note}");
assert!(!super::note(false, Some(" \n ")).contains("reported"));
let long = "x".repeat(1_000);
assert_eq!(super::reason(Some(&long)).map(|r| r.chars().count()), Some(300));
}
}
+525 -61
View File
@@ -1,6 +1,6 @@
//! SolidCraft desktop app.
//!
//! Usage: `solidcraft [--control <port>] [--sample] [--crash-log] [--safe-mode] [design.solidcraft | part.step]`
//! Usage: `solidcraft [--control <port>] [--sample] [--crash-log] [--safe-mode] [design.sc | part.step]`
//!
//! `--safe-mode` (or `SOLIDCRAFT_SAFE_MODE=1`) starts with default settings, no recovery offer and
//! the software renderer; see `startup` and docs/book/src/guide/troubleshooting.md.
@@ -20,14 +20,20 @@
#[cfg(not(target_os = "macos"))]
#[path = "../../../assets/icons/app/shared/source/app_icon.rs"]
mod app_icon;
mod come_forward;
mod control_server;
#[cfg(target_os = "linux")]
mod desktop_theme;
mod device_lost;
#[cfg(target_os = "windows")]
mod native_crash;
#[cfg(target_os = "macos")]
mod native_crash_macos;
#[cfg(target_os = "windows")]
mod native_frame;
#[cfg(target_os = "macos")]
mod native_menu;
mod nav_bridge;
#[cfg(target_os = "macos")]
mod open_documents;
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
@@ -81,12 +87,18 @@ struct App {
frames: u32,
/// Started over the control channel: the window doesn't ask for the foreground.
take_focus_unwanted: bool,
/// Coming forward after a close that asks about unsaved designs.
come_forward: come_forward::ComeForward,
/// 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>,
/// A device's own app moving the view (Preferences › Navigation, off by default).
nav_bridge: nav_bridge::NavBridge,
/// Where the next file dialog opens: it follows the open design (#7756).
dialog_folder: DialogFolder,
}
impl eframe::App for App {
@@ -111,6 +123,14 @@ impl eframe::App for App {
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
}
solidcraft_engine::crash::phase(solidcraft_engine::crash::RUNNING);
// A DMG install has no installer step: put solidcraft-cli on PATH, quietly.
#[cfg(target_os = "macos")]
std::thread::spawn(solidcraft_ui_egui::cli_link::install_on_launch);
// Automation runs (a control port was asked for) leave the user's AI apps alone.
if !self.take_focus_unwanted {
solidcraft_ui_egui::ai_connect::begin(&mut self.solve);
solidcraft_ui_egui::agent_chat::begin(&mut self.solve);
}
if let Some(dir) = self.starting.take() {
startup::started(&dir);
}
@@ -124,7 +144,13 @@ impl eframe::App for App {
if ctx.input(|i| i.viewport().close_requested()) && !self.solve.quit_requested {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
solidcraft_ui_egui::documents::request_quit(&mut self.solve);
// Closed from the taskbar or a window switcher while another app had the focus: the
// "save changes?" prompt is only seen once the window comes forward.
if !self.solve.quit_requested {
self.come_forward.start(std::time::Instant::now());
}
}
self.come_forward.tick(ctx, _frame);
#[cfg(target_os = "macos")]
for f in if self.solve.file_picker_busy() { Vec::new() } else { open_documents::take() } {
self.solve.open_path_async(&f);
@@ -134,10 +160,13 @@ impl eframe::App for App {
menu.tick(&mut self.solve, ctx);
}
self.solve.logic(ctx);
self.dialog_folder.design(self.solve.session.path.as_deref());
#[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);
}
// Every frame, so switching the bridge off reaches the reader while a picker is open too.
self.nav_bridge.tick(&mut self.solve, ctx);
if self.solve.quit_requested {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
@@ -147,6 +176,8 @@ impl eframe::App for App {
if let Some(menu) = &mut self.native_menu {
menu.raw_input_hook(&mut self.solve, ctx, raw);
}
#[cfg(target_os = "macos")]
macos_fullscreen_content(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);
@@ -156,7 +187,7 @@ impl eframe::App for App {
self.solve.raw_input_hook(raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.solve.ui(ui);
content_ui(&mut self.solve, ui);
}
fn save(&mut self, storage: &mut dyn eframe::Storage) {
storage.set_string(solidcraft_ui_egui::prefs::STORAGE_KEY, self.solve.prefs());
@@ -166,10 +197,36 @@ impl eframe::App for 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);
// Pictures still being drawn off the window thread (Home cards, file previews) end
// before the GPU is torn down: one drawn while the process exits can crash the driver.
if !solidcraft_engine::capture::close(std::time::Duration::from_secs(5)) {
log::warn!("a picture was still being drawn as the app closed");
}
solidcraft_ui_egui::recovery_ui::close(&mut self.solve);
solidcraft_ui_egui::open_elsewhere::close(&mut self.solve);
// Before a restart (`Drop for App`) starts the next instance, which would otherwise get
// nothing from the 3D mouse driver (`solidcraft_3dx_client::stop`).
#[cfg(target_os = "macos")]
solidcraft_3dx_client::stop();
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);
// The next start (after a lost device, or into an update) begins once the app is dropped
// (`Drop for App`), when the settings are on disk.
}
}
impl Drop for App {
/// eframe drops its settings storage first, which waits for `app.ron` to be written, then the
/// app, then the window and its GPU surface. So the next start begins here: started from
/// `on_exit`, it read `app.ron` while the old process was still writing it, found it
/// unreadable and moved it aside, resetting every setting (#9044).
fn drop(&mut self) {
if let Some(gpu) = &self.gpu
&& let Some(lost_at) = gpu.lost_at
{
device_lost::restart(gpu.restart_on_software, lost_at, &gpu.reason);
// The window's GPU surface is still to be dropped, and on a lost device that may
// never return (#9044).
device_lost::end();
}
// 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) {
@@ -197,6 +254,8 @@ struct LostWatch {
restart_on_software: bool,
/// When the loss was seen: the app is closing to start again.
lost_at: Option<u64>,
/// Why, as wgpu said it: passed to the next start ([`device_lost::REASON_ENV`]).
reason: String,
}
impl App {
@@ -210,10 +269,65 @@ impl App {
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());
gpu.reason = reason;
self.solve.quit_requested = true;
}
}
/// Where the next file dialog opens (#7756): the folder of the file last chosen in one, or of the
/// design last opened or saved, whichever came last. Linux dialogs otherwise open in the home
/// folder every time; macOS and Windows remember a folder of their own, which this agrees with.
#[derive(Clone, Default)]
struct DialogFolder(std::sync::Arc<std::sync::Mutex<DialogFolderState>>);
#[derive(Default)]
struct DialogFolderState {
folder: Option<std::path::PathBuf>,
/// The open design's path when last looked at, to notice another design opened or saved.
design: Option<String>,
}
impl DialogFolder {
/// A file was chosen in a dialog: the next one opens in its folder.
fn chosen(&self, file: &std::path::Path) {
if let (Some(folder), Ok(mut state)) = (file.parent().filter(|f| !f.as_os_str().is_empty()), self.0.lock()) {
state.folder = Some(folder.to_path_buf());
}
}
/// The open design's path, every frame: a design opened or saved somewhere else moves the
/// next dialog to its folder; the same design again leaves a folder chosen since alone.
fn design(&self, path: Option<&str>) {
let Ok(mut state) = self.0.lock() else { return };
if state.design.as_deref() == path {
return;
}
state.design = path.map(str::to_string);
if let Some(folder) = path.and_then(|p| std::path::Path::new(p).parent()).filter(|f| !f.as_os_str().is_empty()) {
state.folder = Some(folder.to_path_buf());
}
}
/// A dialog's answer as a path, its folder remembered for the next dialog.
fn answer(&self, picked: Option<rfd::FileHandle>) -> Option<String> {
let picked = picked?;
self.chosen(picked.path());
Some(picked.path().to_string_lossy().into_owned())
}
fn folder(&self) -> Option<std::path::PathBuf> {
self.0.lock().ok().and_then(|state| state.folder.clone())
}
/// The dialog, opening in the remembered folder when there is one.
fn start(&self, dialog: rfd::AsyncFileDialog) -> rfd::AsyncFileDialog {
match self.folder() {
Some(folder) => dialog.set_directory(folder),
None => dialog,
}
}
}
/// 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> {
@@ -232,26 +346,61 @@ fn case_variants(exts: &[&str]) -> Vec<String> {
all
}
fn services(cc: &eframe::CreationContext<'_>) -> Services {
/// egui's root Ui spans the whole viewport, including native safe areas. Lay out the app
/// inside the unobscured rectangle so its application bar can receive native pointer input.
fn content_ui(app: &mut SolveApp, ui: &mut egui::Ui) {
let content = ui.ctx().content_rect();
let above = egui::Rect::from_min_max(ui.max_rect().min, egui::pos2(ui.max_rect().right(), content.top()));
ui.painter().rect_filled(above, 0.0, solidcraft_ui_egui::theme::Tokens::get().app_bar);
ui.scope_builder(egui::UiBuilder::new().max_rect(content), |ui| app.ui(ui));
}
/// A transparent macOS title bar still intercepts clicks at the top in fullscreen (#3643).
/// `contentLayoutRect` reports the whole content view once that bar auto-hides, so reserve
/// the native titled-window height even then. AppKit supplies the height in logical points;
/// egui's UI zoom is separate from the display's Retina scale.
#[cfg(target_os = "macos")]
fn macos_fullscreen_content(ctx: &egui::Context, raw: &mut egui::RawInput) {
let Some(mtm) = objc2::MainThreadMarker::new() else { return };
let frame = objc2_app_kit::NSWindow::frameRectForContentRect_styleMask(Default::default(), objc2_app_kit::NSWindowStyleMask::Titled, mtm);
fullscreen_content_insets(raw, frame.size.height as f32, ctx.zoom_factor());
}
#[cfg(any(target_os = "macos", test))]
fn fullscreen_content_insets(raw: &mut egui::RawInput, title_height: f32, zoom: f32) {
let fullscreen = raw.viewports.get(&raw.viewport_id).and_then(|v| v.fullscreen).unwrap_or(false);
let mut insets = raw.safe_area_insets.unwrap_or_default();
if fullscreen && title_height.is_finite() && title_height > 0.0 && zoom.is_finite() && zoom > 0.0 {
insets.0.top = insets.0.top.max(title_height / zoom);
}
// Explicitly reset on leaving fullscreen: None would retain the preceding frame's inset.
raw.safe_area_insets = Some(insets);
}
fn services(cc: &eframe::CreationContext<'_>, folder: &DialogFolder) -> Services {
let parent = rfd::AsyncFileDialog::new().set_parent(cc);
let open_parent = parent.clone();
let program_parent = parent.clone();
let file_parent = parent.clone();
let (open_folder, file_folder, save_folder) = (folder.clone(), folder.clone(), folder.clone());
Services {
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();
let mut dialog = open_folder.start(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)));
let mut everything = io::formats::open_extensions(true);
everything.extend(["dxf", "svg"]);
dialog = dialog.add_filter(solidcraft_ui_egui::i18n::tr("file.open.drawing_filter"), &case_variants(&everything));
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()) })
let folder = open_folder.clone();
Box::pin(async move { folder.answer(future.await) })
})),
// 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).
@@ -266,8 +415,9 @@ fn services(cc: &eframe::CreationContext<'_>) -> Services {
}
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()) })
let future = file_folder.start(file_parent.clone()).add_filter(label, &all).add_filter("All files", &["*"]).pick_file();
let folder = file_folder.clone();
Box::pin(async move { folder.answer(future.await) })
})),
pick_program: Some(Box::new(move || {
let dialog = program_parent.clone().set_title("Choose your slicer");
@@ -276,7 +426,7 @@ fn services(cc: &eframe::CreationContext<'_>) -> Services {
Box::pin(async move { future.await.map(|p| p.path().to_string_lossy().into_owned()) })
})),
pick_save: Some(Box::new(move |name: &str, exts: &[&str]| {
let dialog = parent.clone().set_file_name(name);
let dialog = save_folder.start(parent.clone()).set_file_name(name);
let dialog = if exts == [io::DESIGN_EXT, "json"] {
if name.ends_with(".json") {
dialog.add_filter("Plain JSON design", &["json"]).add_filter("SolidCraft container", &[io::DESIGN_EXT])
@@ -287,10 +437,18 @@ fn services(cc: &eframe::CreationContext<'_>) -> Services {
dialog.add_filter("File", exts)
};
let future = dialog.save_file();
Box::pin(async move { future.await.map(|p| p.path().to_string_lossy().into_owned()) })
let folder = save_folder.clone();
Box::pin(async move { folder.answer(future.await) })
})),
graphics_adapters: Vec::new(),
graphics_path: graphics_path().map(|p| p.to_string_lossy().to_string()),
window_system: window_system_now(),
// Linux: the desktop's dark or light setting, which the window is not told on every
// desktop (#7944). Windows and macOS: the window's own theme.
#[cfg(target_os = "linux")]
desktop_dark: Some(desktop_theme::start(&cc.egui_ctx)),
#[cfg(not(target_os = "linux"))]
desktop_dark: None,
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.
@@ -298,6 +456,7 @@ fn services(cc: &eframe::CreationContext<'_>) -> Services {
can_restore: None,
restore_file: None,
download_design: None,
easyprint_upload: Some(std::sync::Arc::new(easyprint_upload)),
browser_files: None,
agent_http: Some(std::sync::Arc::new(agent_http)),
secrets: Some(std::sync::Arc::new(Keychain)),
@@ -369,7 +528,7 @@ fn agent_http(r: &solidcraft_ui_egui::agent_chat::backend::HttpRequest) -> Resul
let agent: ureq::Agent = ureq::Agent::config_builder()
.tls_config(tls)
.http_status_as_error(false)
.timeout_global(Some(Duration::from_secs(r.timeout_s.clamp(1, 900))))
.timeout_global(Some(Duration::from_secs(r.timeout_s.clamp(1, 3600))))
.timeout_connect(Some(Duration::from_secs(10)))
.user_agent("SolidCraft")
.max_redirects(0)
@@ -394,6 +553,24 @@ fn agent_http(r: &solidcraft_ui_egui::agent_chat::backend::HttpRequest) -> Resul
Ok((status, body))
}
/// Open with EasyPrint: upload the model to Printables' import endpoint on a worker and answer on
/// `done` with the address to open. Through [`agent_http`]: no redirects, a timeout, no cookies.
fn easyprint_upload(name: &str, bytes: Vec<u8>, done: std::sync::mpsc::Sender<Result<String, String>>) {
use solidcraft_engine::easyprint;
let name = name.to_string();
std::thread::spawn(move || {
let (content_type, body) = easyprint::multipart(&name, &bytes);
let request = solidcraft_ui_egui::agent_chat::backend::HttpRequest {
method: "POST",
url: easyprint::IMPORT_URL.into(),
headers: vec![("Content-Type".into(), content_type), ("Accept".into(), "application/json".into())],
body: Some(body),
timeout_s: 180,
};
let _ = done.send(agent_http(&request).and_then(|(status, body)| easyprint::parse_import_response(status, &body)));
});
}
/// API keys in the system keychain (service "SolidCraft agent chat", one entry per agent).
struct Keychain;
@@ -474,26 +651,26 @@ fn graphics_path() -> Option<std::path::PathBuf> {
/// Compatibility shim (docs/compatibility.md): on the first launch after the rename, copy the
/// settings of earlier releases into the new folder. They kept `graphics.json` (and, on Linux
/// and macOS, the recovery folder) under the old product name, and eframe's `app.ron` under the
/// window's old app id. In the Flatpak sandbox, releases before 0.8 kept everything under the
/// old app id's `~/.var/app` folder; that copy comes first, as it is the newest. The old folders
/// window's old app id. In the Flatpak sandbox, earlier releases kept everything under an
/// old app id's `~/.var/app` folder; those copies come first, newest first. The old folders
/// are never changed.
fn adopt_legacy_settings() {
let (Some(current), Some(legacy)) = (settings_dir().ok().flatten(), eframe::storage_dir("SolveCraft")) else { return };
let legacy_eframe = eframe::storage_dir(solidcraft_engine::io::LEGACY_APP_ID);
let old_flatpak = solidcraft_engine::io::legacy_flatpak_path(&current);
if let Err(e) = adopt_settings(&current, old_flatpak.as_deref(), &legacy, legacy_eframe.as_deref()) {
let old_flatpak = solidcraft_engine::io::legacy_flatpak_paths(&current);
if let Err(e) = adopt_settings(&current, &old_flatpak, &legacy, legacy_eframe.as_deref()) {
log::warn!("settings: could not copy the previous release's settings into {}: {e}", current.display());
}
}
fn adopt_settings(
current: &std::path::Path,
old_flatpak: Option<&std::path::Path>,
old_flatpak: &[std::path::PathBuf],
legacy: &std::path::Path,
legacy_eframe: Option<&std::path::Path>,
) -> std::io::Result<()> {
let fresh = !current.exists();
if let Some(old) = old_flatpak {
for old in old_flatpak {
solidcraft_engine::io::adopt_legacy_dir(current, old)?;
}
solidcraft_engine::io::adopt_legacy_dir(current, legacy)?;
@@ -529,13 +706,22 @@ fn scene_samples(backend: eframe::wgpu::Backend, samples: u32, supported: &[u32]
}
}
/// Anti-aliasing samples from Preferences (4 when unset).
fn msaa() -> u16 {
/// Keep the default until the installed renderer's startup check proves a fallback is needed.
fn scene_msaa_request(saved: Option<u16>) -> u16 {
saved.unwrap_or(MSAA)
}
/// Query the scene's colour/depth intersection, rather than the depthless window's counts.
fn scene_sample_choices(depth_bits: u8, stencil_bits: u8, query: impl FnOnce(Option<eframe::wgpu::TextureFormat>) -> Vec<u32>) -> Vec<u32> {
query(eframe::egui_wgpu::depth_format_from_bits(depth_bits, stencil_bits))
}
/// An explicitly saved anti-aliasing choice, absent until Graphics changes it.
fn msaa() -> Option<u16> {
graphics()
.and_then(|v| v.get("msaa").and_then(serde_json::Value::as_u64))
.and_then(|m| u16::try_from(m).ok())
.filter(|m| [1, 2, 4, 8].contains(m))
.unwrap_or(MSAA)
}
/// Windows and Linux: no OS title bar; the application bar is the title bar
@@ -551,7 +737,8 @@ const FLOAT_DEPTH_BITS: u8 = 32;
const STENCIL_BITS: u8 = 8;
const MSAA: u16 = 4;
fn app_viewport() -> Result<egui::ViewportBuilder, eframe::Error> {
/// `custom_titlebar`: no OS decorations, the application bar is the title bar.
fn app_viewport(custom_titlebar: bool) -> Result<egui::ViewportBuilder, eframe::Error> {
// Let macOS use the bundle's layered icon; eframe's default bitmap would replace it.
#[cfg(target_os = "macos")]
let icon = egui::IconData::default();
@@ -564,7 +751,7 @@ fn app_viewport() -> Result<egui::ViewportBuilder, eframe::Error> {
.with_inner_size([1600.0, 1000.0])
.with_min_inner_size([960.0, 600.0])
.with_drag_and_drop(true)
.with_decorations(!CUSTOM_TITLEBAR)
.with_decorations(!custom_titlebar)
.with_fullsize_content_view(true)
.with_titlebar_shown(false)
.with_title_shown(false)
@@ -574,10 +761,32 @@ fn app_viewport() -> Result<egui::ViewportBuilder, eframe::Error> {
/// The window's app id: it names the desktop entry, so it is the packages' reverse-DNS id.
const APP_ID: &str = solidcraft_engine::io::APP_ID;
/// Disconnects from the 3D mouse driver when dropped, as `main` returns or a panic unwinds out
/// of it (`solidcraft_3dx_client::stop`).
#[cfg(target_os = "macos")]
struct DriverClient;
#[cfg(target_os = "macos")]
impl Drop for DriverClient {
fn drop(&mut self) {
solidcraft_3dx_client::stop();
}
}
fn main() -> eframe::Result {
#[cfg(target_os = "macos")]
objc2_foundation::NSProcessInfo::processInfo().setProcessName(objc2_foundation::ns_string!("SolidCraft"));
mark("main");
// On OCLP-patched Macs, wgpu picks a Metal Shading Language standard that the system's
// compiler refuses (`invalid value 'metal3.2'`), because the patched OS reports a newer
// macOS version than the GPU's Metal compiler supports. Re-launching with
// SYSTEM_VERSION_COMPAT=1 makes the OS report the real (older) version so wgpu selects
// a standard the compiler accepts. The variable must be set before the process starts, so
// we detect OCLP here and re-exec immediately, before any macOS framework call (#3080, #4605).
#[cfg(target_os = "macos")]
if let Err(e) = startup::relaunch_with_version_compat() {
log::warn!("startup: OCLP check failed (continuing normally): {e}");
}
// Validate before legacy adoption, crash markers or any user preference read.
let settings = settings_dir().map_err(|error| eframe::Error::AppCreation(error.into()))?;
if std::env::var_os("SOLIDCRAFT_SETTINGS_DIR").is_none() {
@@ -587,11 +796,17 @@ fn main() -> eframe::Result {
let mut control_port: Option<u16> = solidcraft_engine::geom::env::var("CONTROL_PORT").and_then(|p| p.parse().ok());
let mut files = Vec::new();
let mut sample = false;
// `--theme-file PATH`: follow that theme file for this run without saving it (#7573).
let mut theme_file: Option<String> = None;
let mut max_file_size = None;
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match a.as_str() {
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
"--sample" => sample = true,
"--theme-file" => theme_file = args.next(),
"--max-file-size" => max_file_size = Some(args.next()),
s if s.starts_with("--max-file-size=") => max_file_size = Some(s.strip_prefix("--max-file-size=").map(str::to_string)),
"--safe-mode" => safe_mode = true,
"--version" | "-V" => {
println!("solidcraft {}", env!("CARGO_PKG_VERSION"));
@@ -611,12 +826,22 @@ fn main() -> eframe::Result {
return Ok(());
}
"--help" | "-h" => {
println!("usage: solidcraft [--control PORT] [--sample] [--crash-log] [--safe-mode] [design.solidcraft | part.step]");
println!(
"usage: solidcraft [--control PORT] [--sample] [--theme-file THEME.json] [--crash-log] [--safe-mode] [--max-file-size SIZE] [design.sc | part.step]"
);
return Ok(());
}
_ => files.push(a),
}
}
// `--max-file-size 2GB`: the largest files this run reads, over Preferences, never saved (#4086).
if let Some(value) = max_file_size {
let applied = startup::max_file_size(value.as_deref()).and_then(|bytes| solidcraft_engine::io::limits::set_override(Some(bytes)));
if let Err(error) = applied {
eprintln!("SolidCraft: {error}");
return Err(eframe::Error::AppCreation(std::io::Error::other(error).into()));
}
}
// Settings that don't read are moved aside; launches that never got a window are counted,
// and after a few in a row this one starts in safe mode (docs/book/src/guide/troubleshooting.md).
let mut notes: Vec<String> = Vec::new();
@@ -633,12 +858,22 @@ fn main() -> eframe::Result {
// 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));
let reason = std::env::var(device_lost::REASON_ENV).ok();
if let Some(reason) = device_lost::reason(reason.as_deref()) {
log::warn!("graphics: the last launch lost its device ({reason})");
}
notes.push(device_lost::note(cpu_renderer, reason.as_deref()));
}
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 fallback_note = std::env::var(startup::FALLBACK_NOTE_ENV).ok().map(|s| s.chars().take(4096).collect::<String>());
if let Some(note) = &fallback_note {
log::warn!("{note}");
eprintln!("SolidCraft: {note}");
notes.push(note.clone());
}
let saved_samples = if safe_mode { None } 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).
@@ -652,14 +887,22 @@ fn main() -> eframe::Result {
solidcraft_engine::crash::install(dir, build)
})
.unwrap_or_default();
// A crash in native code (a graphics driver) says where it happened: the exception and the
// module on Windows, and on every system the startup step the app was in.
// A crash in native code (a graphics driver) says where it happened: the exception or signal
// and the module on Windows and macOS, and on every system the startup step the app was in.
#[cfg(target_os = "windows")]
native_crash::install();
#[cfg(target_os = "macos")]
native_crash_macos::install();
// Windows with only OpenGL to start: the system title bar instead of ours (#2063).
let started = match &wgpu_options.wgpu_setup {
eframe::egui_wgpu::WgpuSetup::CreateNew(setup) => setup.instance_descriptor.backends,
_ => eframe::wgpu::Backends::all(),
};
let custom_titlebar = CUSTOM_TITLEBAR && !startup::system_title_bar(cfg!(target_os = "windows"), started);
eframe::egui_wgpu::set_startup_hook(startup_step);
solidcraft_engine::crash::phase("window");
let options = eframe::NativeOptions {
viewport: app_viewport()?,
viewport: app_viewport(custom_titlebar)?,
// 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,
@@ -710,6 +953,10 @@ 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();
// A panic on the window's thread ends the app without `on_exit`; this disconnects from the
// 3D mouse driver then too.
#[cfg(target_os = "macos")]
let _driver_client = DriverClient;
mark("run_native");
let result = eframe::run_native(
"SolidCraft",
@@ -721,42 +968,73 @@ fn main() -> eframe::Result {
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));
let dialog_folder = DialogFolder::default();
let mut app = SolveApp::new(Session::default(), services(cc, &dialog_folder));
app.apply_off_thread = Some(solidcraft_ui_egui::dialogs::APPLY_AT_ONCE);
app.custom_titlebar = CUSTOM_TITLEBAR;
// Windows: the OS hit test offers the resize border, at the window's DPI, from the
// first message (#1381).
app.custom_titlebar = custom_titlebar;
#[cfg(target_os = "windows")]
{
app.native_resize_border = CUSTOM_TITLEBAR && native_frame::install(cc);
if CUSTOM_TITLEBAR && !app.native_resize_border {
// Windows: the taskbar button shows the mark even when slow first frames keep the
// taskbar waiting for the window's icon.
if !native_frame::set_class_icon(cc) {
eprintln!("SolidCraft window: no class icon; the taskbar may show a generic one");
}
// The OS hit test offers the resize border, at the window's DPI, from the first
// message (#1381).
app.native_resize_border = custom_titlebar && native_frame::install(cc);
if custom_titlebar && !app.native_resize_border {
eprintln!("SolidCraft window: no native resize border; egui edge zones instead");
}
}
app.integrated_titlebar = cfg!(target_os = "macos");
app.cpu_meshing = safe_mode;
app.graphics_fallback = fallback_note.clone();
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);
}
if let Some(path) = &theme_file
&& let Err(e) = solidcraft_ui_egui::prefs::follow_theme_file_for_run(&mut app, path)
{
app.set_status(format!("--theme-file: {e}"), true);
}
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,
reason: String::new(),
});
if !safe_mode {
app.preferences.graphics_adapter =
graphics().and_then(|v| v.get("adapter").and_then(serde_json::Value::as_str).map(str::to_string)).unwrap_or_default();
}
if let Some(rs) = &cc.wgpu_render_state {
let scene_samples = scene_samples(rs.adapter.get_info().backend, u32::from(samples), &rs.msaa_supported);
app.services.graphics_adapters = rs
.available_adapters
.iter()
.map(|adapter| {
let info = adapter.get_info();
(info.name.clone(), format!("{} ({:?}, {:?})", info.name, info.device_type, info.backend))
})
.collect();
let info = rs.adapter.get_info();
let samples = scene_msaa_request(saved_samples);
let supported = scene_sample_choices(depth_bits, STENCIL_BITS, |depth| {
eframe::egui_wgpu::supported_msaa_samples(&rs.adapter, &rs.device, rs.target_format, depth)
});
let scene_samples = scene_samples(info.backend, u32::from(samples), &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_split(rs, depth_bits, STENCIL_BITS, scene_samples);
let scene_samples = app.set_wgpu_split(rs, depth_bits, STENCIL_BITS, scene_samples);
app.msaa_supported = supported;
app.preferences.msaa = u32::from(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")]
native_crash::reinstall();
// A saved anti-aliasing the device can't do: back to the default, with a notice.
// Reflect a confirmed probe fallback or an unsupported saved count in Graphics.
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 {scene_samples}x",
info.backend, info.name, info.driver
@@ -780,13 +1058,24 @@ fn main() -> eframe::Result {
app.session.doc_mut().name = "Sample Plate".into();
app.session.mark_saved();
app.session.undo.clear();
// Built straight into the session, so nothing asked for a fit: frame it at the
// home view as any opened design is (#9195). The fit waits for the first layout.
app.cam.set_view(solidcraft_engine::render::StandardView::Iso);
app.fit_view();
}
// Lock files next to open designs: a design open on another computer says so (#5467),
// the files given at launch included.
solidcraft_ui_egui::open_elsewhere::start(&mut app);
// A launch from Finder: its files arrived before the window.
#[cfg(target_os = "macos")]
let files = {
open_documents::set_ui(&cc.egui_ctx);
[files, open_documents::take()].concat()
};
// No design given: the designs open at the last quit, when the preference asks (#5542).
// Not when driven over the control channel, whose clients expect a fresh design.
let files =
if files.is_empty() && !sample && control_port.is_none() { solidcraft_ui_egui::documents::designs_to_reopen(&app) } else { files };
for f in &files {
app.open_path_async(f);
}
@@ -815,8 +1104,9 @@ fn main() -> eframe::Result {
if !notes.is_empty() {
app.set_status(notes.join(" "), true);
}
let nav_bridge = nav_bridge::NavBridge::start(&cc.egui_ctx);
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
let space_mouse = space_mouse::SpaceMouse::start(&cc.egui_ctx);
let space_mouse = space_mouse::SpaceMouse::start(&cc.egui_ctx, nav_bridge.claim());
#[cfg(target_os = "macos")]
let native_menu = match native_menu::NativeMenu::new(&app, &cc.egui_ctx) {
Ok(menu) => Some(menu),
@@ -836,28 +1126,32 @@ fn main() -> eframe::Result {
starting,
frames: 0,
take_focus_unwanted: control_port.is_some(),
come_forward: come_forward::ComeForward::default(),
gpu,
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
space_mouse,
#[cfg(target_os = "macos")]
native_menu,
nav_bridge,
dialog_folder,
}))
}),
);
control_server::remove_token_file();
match result {
Err(e) if !created.load(std::sync::atomic::Ordering::SeqCst) => no_window(e, cpu_renderer, settings.as_deref()),
Err(e) if !created.load(std::sync::atomic::Ordering::SeqCst) => no_window(e, cpu_renderer, started, settings.as_deref()),
r => r,
}
}
/// egui-wgpu is about to start a step of getting the graphics device and the window's surface up:
/// the crash journal keeps it, and the adapter once it is picked.
/// the crash journal keeps it until the app runs (a window opened later goes through the same
/// steps), and the adapter once it is picked.
fn startup_step(step: &'static str, detail: &str) {
if step == eframe::egui_wgpu::STEP_ADAPTER_CHOSEN {
solidcraft_engine::crash::set_environment(&format!("Renderer: {detail}"));
}
solidcraft_engine::crash::phase(step);
solidcraft_engine::crash::startup_step(step);
}
/// The renderer settings: with `cpu`, the software adapter (WARP, lavapipe, llvmpipe). The
@@ -867,13 +1161,21 @@ fn wgpu_options(cpu: bool, preference: Option<&str>) -> eframe::egui_wgpu::WgpuC
let mut options = eframe::egui_wgpu::WgpuConfiguration::default();
if let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &mut options.wgpu_setup {
let env_set = std::env::var(startup::WGPU_BACKEND_ENV).is_ok_and(|v| !v.trim().is_empty());
// A retry applies only to this launch. WGPU_BACKEND always has priority.
let retry = std::env::var(startup::RETRY_BACKEND_ENV).ok();
let preference = if cpu { preference } else { retry.as_deref().or(preference) };
if let Some(backends) = startup::choose_backends(cfg!(target_os = "windows"), env_set, cpu, preference, startup::has_hardware_adapter) {
eprintln!("SolidCraft graphics: starting {backends:?}");
setup.instance_descriptor.backends = backends;
}
if cpu {
setup.native_adapter_selector = Some(std::sync::Arc::new(startup::pick_cpu_adapter));
}
let intel_mac = cfg!(all(target_os = "macos", target_arch = "x86_64"));
let indirect = std::env::var(startup::INDIRECT_VALIDATION_ENV).ok();
setup.instance_descriptor.flags = startup::window_instance_flags(setup.instance_descriptor.flags, intel_mac, indirect.as_deref());
// Never a `dxcompiler.dll` another program put on PATH (#6756).
setup.instance_descriptor.backend_options.dx12.shader_compiler = solidcraft_gpu::dx12_compiler();
let environment = std::env::var("WGPU_ADAPTER_NAME").ok();
let adapter = graphics().and_then(|v| v.get("adapter").and_then(serde_json::Value::as_str).map(str::to_string));
startup::configure_adapter(setup, cpu, environment, 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| {
@@ -885,24 +1187,47 @@ fn wgpu_options(cpu: bool, preference: Option<&str>) -> eframe::egui_wgpu::WgpuC
options
}
/// The window never opened (no graphics adapter, a device that wouldn't start): once more on
/// the software renderer, else a message box saying what failed instead of a silent exit.
fn no_window(error: eframe::Error, tried_cpu: bool, settings: Option<&std::path::Path>) -> eframe::Result {
/// A failed renderer returns here before app creation. A fresh process avoids reusing winit's
/// event loop or a failed device; each retry advances through the APIs, then software once.
fn no_window(error: eframe::Error, tried_cpu: bool, failed: eframe::wgpu::Backends, settings: Option<&std::path::Path>) -> eframe::Result {
log::error!("the window could not open: {error}");
eprintln!("SolidCraft: the window could not open: {error}");
if !tried_cpu && let Ok(exe) = std::env::current_exe() {
eprintln!("SolidCraft: trying again with the software renderer ({}=cpu)", startup::RENDERER_ENV);
match std::process::Command::new(exe).args(std::env::args_os().skip(1)).env(startup::RENDERER_ENV, "cpu").status() {
// This failure was handled, so neither the marker nor the journal should call it a crash.
if let Some(dir) = settings {
startup::started(dir);
}
solidcraft_engine::crash::end_session();
let explicit = std::env::var(startup::WGPU_BACKEND_ENV).is_ok_and(|v| !v.trim().is_empty());
if let Some(next) = startup::fallback_backend(cfg!(target_os = "windows"), explicit, tried_cpu, failed)
&& let Ok(exe) = std::env::current_exe()
{
let previous = std::env::var(startup::FALLBACK_NOTE_ENV).unwrap_or_default();
let note = startup::fallback_note(&previous, failed, next, &error.to_string());
log::warn!("{note}");
eprintln!("SolidCraft: {note}");
let mut command = std::process::Command::new(exe);
command.args(std::env::args_os().skip(1)).env(startup::FALLBACK_NOTE_ENV, note);
if next == "cpu" {
command.env(startup::RENDERER_ENV, "cpu").env_remove(startup::RETRY_BACKEND_ENV);
} else {
command.env(startup::RETRY_BACKEND_ENV, next);
}
match command.status() {
Ok(status) if status.success() => return Ok(()),
// The second try showed its own message.
// The final try showed its own message.
Ok(_) => return Err(error),
Err(e) => log::error!("could not start again with the software renderer: {e}"),
Err(e) => log::error!("could not start again on {next}: {e}"),
}
}
let message = if explicit {
format!("SolidCraft could not open its window with WGPU_BACKEND.\n\n{error}\n\nChange or remove WGPU_BACKEND to try another graphics API.")
} else {
startup::no_window_message(&error.to_string(), settings)
};
let _ = rfd::MessageDialog::new()
.set_level(rfd::MessageLevel::Error)
.set_title("SolidCraft could not start")
.set_description(startup::no_window_message(&error.to_string(), settings))
.set_description(message)
.set_buttons(rfd::MessageButtons::Ok)
.show();
Err(error)
@@ -991,11 +1316,45 @@ mod backend_tests {
}
#[cfg(test)]
mod icon_tests {
mod dialog_folder_tests {
use super::DialogFolder;
use std::path::{Path, PathBuf};
/// #7756: a file dialog opens where the last one left off, not in the home folder.
#[test]
fn the_next_dialog_opens_in_the_folder_of_the_file_chosen_last() {
let folder = DialogFolder::default();
assert_eq!(folder.folder(), None, "before any choice the system decides");
folder.chosen(Path::new("/work/prints/bracket.stl"));
assert_eq!(folder.folder(), Some(PathBuf::from("/work/prints")));
// A bare file name has no folder to remember.
folder.chosen(Path::new("bracket.step"));
assert_eq!(folder.folder(), Some(PathBuf::from("/work/prints")));
}
/// Save As starts beside the open design (#7756), and a folder chosen since wins until another
/// design is opened or saved.
#[test]
fn the_open_design_moves_the_folder_only_when_it_changes() {
let folder = DialogFolder::default();
folder.design(Some("/work/lid/lid.solidcraft"));
assert_eq!(folder.folder(), Some(PathBuf::from("/work/lid")));
folder.chosen(Path::new("/exports/lid.3mf"));
folder.design(Some("/work/lid/lid.solidcraft"));
assert_eq!(folder.folder(), Some(PathBuf::from("/exports")), "the same design again leaves the export folder");
folder.design(None);
assert_eq!(folder.folder(), Some(PathBuf::from("/exports")), "a new, unsaved design has no folder of its own");
folder.design(Some("/work/base/base.solidcraft"));
assert_eq!(folder.folder(), Some(PathBuf::from("/work/base")));
}
}
#[cfg(test)]
mod viewport_tests {
#[cfg(target_os = "macos")]
#[test]
fn macos_preserves_the_bundle_icon_instead_of_setting_a_runtime_bitmap() {
let viewport = super::app_viewport().expect("viewport");
let viewport = super::app_viewport(super::CUSTOM_TITLEBAR).expect("viewport");
assert_eq!(viewport.icon.as_deref(), Some(&eframe::egui::IconData::default()));
assert_eq!(viewport.app_id.as_deref(), Some(super::APP_ID));
}
@@ -1003,12 +1362,89 @@ mod icon_tests {
#[cfg(not(target_os = "macos"))]
#[test]
fn desktop_viewport_supplies_the_original_mark_to_the_window() {
let viewport = super::app_viewport().expect("viewport");
let viewport = super::app_viewport(super::CUSTOM_TITLEBAR).expect("viewport");
let icon = viewport.icon.expect("running window must have an icon");
assert_eq!((icon.width, icon.height), (64, 64));
assert_eq!(icon.rgba, super::app_icon::rgba(64).expect("original mark"));
assert_eq!(viewport.app_id.as_deref(), Some(super::APP_ID));
}
#[test]
fn fullscreen_bar_clears_native_chrome_and_undo_redo_keep_working() {
use egui::{Event, PointerButton, Pos2, RawInput, Rect, ViewportId, vec2};
use serde_json::json;
use solidcraft_ui_egui::scenario::handle_at;
// AppKit points are independent of Retina scale and of the user's UI zoom.
for (zoom, retina) in [(1.0, 1.0), (1.0, 2.0), (1.5, 2.0), (2.0, 2.0)] {
let ctx = egui::Context::default();
solidcraft_ui_egui::fonts::setup_bundled_fonts(&ctx);
ctx.set_zoom_factor(zoom);
let mut app = super::SolveApp::new(super::Session::default(), Default::default());
app.integrated_titlebar = true;
for (command, params) in [
("sketch.create", json!({"plane":"XY"})),
("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[40,30]})),
("sketch.finish", json!({})),
("solid.extrude", json!({"distance":15})),
] {
app.run(command, params).unwrap();
}
let mut time = 0.0;
let mut frame = |app: &mut super::SolveApp, fullscreen, events| {
time += 0.05;
let mut raw = RawInput {
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 1000.0))),
time: Some(time),
events,
..Default::default()
};
let viewport = raw.viewports.get_mut(&ViewportId::ROOT).unwrap();
viewport.fullscreen = Some(fullscreen);
viewport.native_pixels_per_point = Some(retina);
super::fullscreen_content_insets(&mut raw, 32.0, zoom);
let _ = ctx.run_ui(raw, |ui| super::content_ui(app, ui));
};
// Enter, leave, and enter again: no stale inset when returning to a normal window.
for fullscreen in [false, true, false, true] {
frame(&mut app, fullscreen, vec![]);
frame(&mut app, fullscreen, vec![]);
let top = if fullscreen { 32.0 / zoom } else { 0.0 };
for name in ["appbar:file", "appbar:undo", "appbar:redo", "appbar:help"] {
let at = handle_at(name).unwrap();
assert!((at.y - top - 17.0).abs() < 0.1, "{name}: {at:?}, fullscreen={fullscreen}, zoom={zoom}");
}
for (handle, bodies) in [("appbar:undo", 0), ("appbar:redo", 1)] {
let at = handle_at(handle).unwrap();
frame(&mut app, fullscreen, vec![Event::PointerMoved(at)]);
for pressed in [true, false] {
frame(
&mut app,
fullscreen,
vec![Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }],
);
}
assert_eq!(app.session.model.state().bodies.len(), bodies, "{handle}, fullscreen={fullscreen}, zoom={zoom}");
}
}
}
}
#[test]
fn fullscreen_inset_preserves_existing_safe_areas_and_resets_on_exit() {
use egui::epaint::MarginF32;
use egui::{RawInput, SafeAreaInsets, ViewportId};
let mut raw = RawInput::default();
raw.viewports.get_mut(&ViewportId::ROOT).unwrap().fullscreen = Some(true);
raw.safe_area_insets = Some(SafeAreaInsets(MarginF32 { top: 40.0, left: 5.0, right: 6.0, bottom: 7.0 }));
super::fullscreen_content_insets(&mut raw, 32.0, 2.0);
assert_eq!(raw.safe_area_insets.unwrap().0, MarginF32 { top: 40.0, left: 5.0, right: 6.0, bottom: 7.0 });
raw.viewports.get_mut(&ViewportId::ROOT).unwrap().fullscreen = Some(false);
raw.safe_area_insets = None;
super::fullscreen_content_insets(&mut raw, 32.0, 1.0);
assert_eq!(raw.safe_area_insets, Some(SafeAreaInsets::default()));
}
}
#[cfg(test)]
@@ -1022,14 +1458,14 @@ mod msaa_tests {
std::fs::create_dir_all(&legacy_eframe).unwrap();
std::fs::write(legacy.join("graphics.json"), r#"{"msaa":8}"#).unwrap();
std::fs::write(legacy_eframe.join("app.ron"), "(prefs)").unwrap();
super::adopt_settings(&current, None, &legacy, Some(&legacy_eframe)).unwrap();
super::adopt_settings(&current, &[], &legacy, Some(&legacy_eframe)).unwrap();
assert_eq!(std::fs::read_to_string(current.join("graphics.json")).unwrap(), r#"{"msaa":8}"#);
assert_eq!(std::fs::read_to_string(current.join("app.ron")).unwrap(), "(prefs)");
assert!(legacy.join("graphics.json").is_file() && legacy_eframe.join("app.ron").is_file());
// Later launches leave the current settings alone.
std::fs::write(current.join("app.ron"), "(mine)").unwrap();
std::fs::remove_file(current.join("graphics.json")).unwrap();
super::adopt_settings(&current, None, &legacy, Some(&legacy_eframe)).unwrap();
super::adopt_settings(&current, &[], &legacy, Some(&legacy_eframe)).unwrap();
assert_eq!(std::fs::read_to_string(current.join("app.ron")).unwrap(), "(mine)");
assert!(!current.join("graphics.json").exists());
let _ = std::fs::remove_dir_all(&d);
@@ -1046,6 +1482,34 @@ mod msaa_tests {
}
}
#[test]
fn scene_sample_choices_include_the_scene_depth_format() {
use eframe::wgpu::TextureFormat;
for (bits, expected) in [(24, TextureFormat::Depth24PlusStencil8), (32, TextureFormat::Depth32FloatStencil8)] {
// A synthetic colour target supports 8x, but the scene depth supports only 1x/4x.
let supported = super::scene_sample_choices(bits, 8, |depth| match depth {
Some(format) if format == expected => vec![1, 4],
None => vec![1, 4, 8],
other => panic!("unexpected scene depth: {other:?}"),
});
assert_eq!(supported, [1, 4]);
assert_eq!(super::scene_samples(eframe::wgpu::Backend::Dx12, 8, &supported), 4);
}
}
#[test]
fn scene_msaa_keeps_four_samples_by_default_and_explicit_choices() {
use eframe::wgpu::Backend;
let default = super::scene_msaa_request(None);
assert_eq!(default, 4);
for backend in [Backend::Dx12, Backend::Vulkan, Backend::Metal] {
assert_eq!(super::scene_samples(backend, u32::from(default), &[1, 4]), 4);
}
for saved in [1, 2, 4, 8] {
assert_eq!(super::scene_msaa_request(Some(saved)), saved);
}
}
#[test]
fn the_scene_follows_the_backend_policy_too() {
use eframe::wgpu::Backend;
+879
View File
@@ -0,0 +1,879 @@
//! macOS: what a crash in native code leaves behind (docs/crash-reports.md).
//!
//! A crash inside a graphics driver or another library (a segfault, an `abort()`, a stack
//! overflow) is no Rust panic: the panic hook never runs and the next start only found "an
//! unclean exit". A signal handler for SIGSEGV, SIGBUS, SIGILL, SIGFPE, SIGTRAP (how a trap
//! instruction arrives on Apple silicon) and SIGABRT now writes, before the process goes, the
//! same small JSON file as the Windows filter (`native_crash.rs`): the signal, the faulting
//! module and the offset into it, and a short frame-pointer walk, next to the run's crash journal
//! (`solidcraft_engine::crash::native_paths`). The next start puts it into the unclean exit's
//! report. The signal then goes on to the handler that was there before (the standard library's
//! stack overflow message) or to the default action, so the process still dies of it and macOS
//! still writes its own crash report (`~/Library/Logs/DiagnosticReports`; the file names the
//! `pid` the JSON records).
//!
//! Installing a signal handler and asking dyld which image holds an address have no safe API,
//! hence the `unsafe_code` allowance on this module (the app crate denies it). The handler runs
//! on a thread that may have a broken heap, or on the small alternate stack after a stack
//! overflow: it doesn't allocate, formats into a buffer on the stack, and only reads memory on
//! the thread's own stack. It writes what it knows from the signal first and only then calls
//! `dladdr` (not on POSIX's async-signal-safe list; it takes dyld's recursive reader lock and
//! doesn't allocate) to name the modules, rewriting the file, so a hang or a fault in there
//! still leaves a record.
#![allow(unsafe_code)]
use std::ffi::{CStr, CString, c_int, c_void};
use std::fmt::Write as _;
use std::os::unix::ffi::OsStrExt;
use std::sync::OnceLock;
use std::sync::atomic::{AtomicBool, AtomicI32, AtomicUsize, Ordering};
use libc::{SIGABRT, SIGBUS, SIGFPE, SIGILL, SIGSEGV, SIGTRAP, siginfo_t};
/// The signals a crash arrives as, in the order of [`PREVIOUS`].
const SIGNALS: [c_int; 6] = [SIGSEGV, SIGBUS, SIGILL, SIGFPE, SIGTRAP, SIGABRT];
/// Frames the walk records (the faulting instruction first).
const MAX_FRAMES: usize = 24;
/// A fault this far below a thread's stack is taken for a stack overflow.
const GUARD: usize = 64 << 10;
/// Where the handler writes (NUL-terminated), set once by [`install`].
static PATH: OnceLock<CString> = OnceLock::new();
/// The handler that was installed before ours for each of [`SIGNALS`] (called after ours), as an
/// address (`SIG_DFL` is 0, `SIG_IGN` 1), and its `sa_flags`.
static PREVIOUS: [AtomicUsize; 6] = [const { AtomicUsize::new(libc::SIG_DFL) }; 6];
static PREVIOUS_FLAGS: [AtomicI32; 6] = [const { AtomicI32::new(0) }; 6];
/// The handler has started writing (a second signal while it does is only passed on).
static BUSY: AtomicBool = AtomicBool::new(false);
/// Catch native crashes from now on (after `solidcraft_engine::crash::install`; nothing without it).
pub fn install() {
let Some((info, _)) = solidcraft_engine::crash::native_paths() else { return };
let Ok(path) = CString::new(info.as_os_str().as_bytes()) else { return };
if PATH.set(path).is_err() {
return;
}
for (slot, &signal) in SIGNALS.iter().enumerate() {
// SAFETY: `sigaction` is plain data; all zeros is a valid (empty) value.
let mut ours: libc::sigaction = unsafe { std::mem::zeroed() };
ours.sa_sigaction = handler as *const () as usize;
// On the alternate stack the standard library gives each of its threads, so that a stack
// overflow can still be recorded.
ours.sa_flags = libc::SA_SIGINFO | libc::SA_ONSTACK;
// SAFETY: as above.
let mut old: libc::sigaction = unsafe { std::mem::zeroed() };
// SAFETY: `handler` has the signature SA_SIGINFO calls it with and stays valid for the
// process's life; both structs are valid for the call.
if unsafe { libc::sigaction(signal, &ours, &mut old) } == 0
&& old.sa_sigaction != ours.sa_sigaction
&& let (Some(p), Some(f)) = (PREVIOUS.get(slot), PREVIOUS_FLAGS.get(slot))
{
f.store(old.sa_flags, Ordering::SeqCst);
p.store(old.sa_sigaction, Ordering::SeqCst);
}
}
}
/// The signal handler: record, then pass the signal on.
unsafe extern "C" fn handler(signal: c_int, info: *mut siginfo_t, context: *mut c_void) {
// SAFETY: `__error` is this thread's errno, restored for whatever runs after us.
let errno = unsafe { *libc::__error() };
let slot = SIGNALS.iter().position(|&s| s == signal).unwrap_or(0);
let previous = PREVIOUS.get(slot).map_or(libc::SIG_DFL, |p| p.load(Ordering::SeqCst));
// SAFETY: the system passes a valid siginfo and the interrupted thread's context for the
// duration of an SA_SIGINFO handler.
let f = unsafe { fault(signal, info, context) };
// Someone sent a signal the app ignored before: still nothing to record.
let ignored = previous == libc::SIG_IGN && !f.fault;
if !ignored && !BUSY.swap(true, Ordering::SeqCst) {
record(&f);
}
// SAFETY: as above.
unsafe { pass_on(signal, slot, previous, f.fault, info, context) };
// SAFETY: as above.
unsafe { *libc::__error() = errno };
}
/// The signal comes from the faulting instruction itself (not `kill`, `raise` or `abort`), so
/// returning runs it again: on arm64, the exception the thread last took matches the signal.
/// macOS gives a SIGSEGV sent with `kill` the same `si_code` as a real one (SEGV_ACCERR), but
/// the exception is then the thread's last system call (or something long past, whose fault
/// address doesn't match).
#[cfg(any(target_arch = "aarch64", test))]
fn arm64_fault(signal: c_int, si_code: c_int, addr: usize, esr: u32, far: u64) -> bool {
const ABORTS: [u32; 4] = [0x20, 0x21, 0x24, 0x25];
const SVC: u32 = 0x15;
let class = esr >> 26;
signal != SIGABRT
&& (1..0x10000).contains(&si_code)
&& match signal {
SIGSEGV | SIGBUS => ABORTS.contains(&class) && far as usize == addr,
_ => class != SVC && !ABORTS.contains(&class),
}
}
/// As [`arm64_fault`], from the x86 trap number and page-fault address.
#[cfg(any(target_arch = "x86_64", test))]
fn x86_fault(signal: c_int, si_code: c_int, addr: usize, trapno: u16, faultvaddr: u64) -> bool {
signal != SIGABRT
&& (1..0x10000).contains(&si_code)
&& match signal {
SIGSEGV | SIGBUS => (trapno == 14 && faultvaddr as usize == addr) || matches!(trapno, 12 | 13),
SIGILL => trapno == 6,
SIGFPE => matches!(trapno, 0 | 16 | 19),
SIGTRAP => matches!(trapno, 1 | 3),
_ => false,
}
}
/// Hand the signal to the previous handler, or to the default action. A crash signal that was
/// only sent goes to the default action: the handler before ours (the standard library's, for
/// SIGSEGV and SIGBUS) expects a fault that happens again when it returns.
unsafe fn pass_on(signal: c_int, slot: usize, previous: usize, fault: bool, info: *mut siginfo_t, context: *mut c_void) {
let ours = handler as *const () as usize;
if previous != libc::SIG_DFL && previous != libc::SIG_IGN && previous != ours && (fault || signal == SIGABRT) {
let flags = PREVIOUS_FLAGS.get(slot).map_or(0, |f| f.load(Ordering::SeqCst));
if flags & libc::SA_SIGINFO != 0 {
// SAFETY: the address came from `sigaction` as an SA_SIGINFO handler.
let previous: unsafe extern "C" fn(c_int, *mut siginfo_t, *mut c_void) = unsafe { std::mem::transmute(previous) };
// SAFETY: as for this handler.
unsafe { previous(signal, info, context) };
} else {
// SAFETY: the address came from `sigaction` as a plain handler.
let previous: unsafe extern "C" fn(c_int) = unsafe { std::mem::transmute(previous) };
// SAFETY: as for this handler.
unsafe { previous(signal) };
}
return;
}
if previous == libc::SIG_IGN && !fault {
return;
}
// SAFETY: as in `install`.
let mut default: libc::sigaction = unsafe { std::mem::zeroed() };
default.sa_sigaction = libc::SIG_DFL;
// SAFETY: a valid struct; restoring the default action is async-signal-safe.
unsafe { libc::sigaction(signal, &default, std::ptr::null_mut()) };
// A fault happens again when we return, now with the default action, so macOS reports it at
// the instruction; anything else is raised again, and arrives once this handler returns.
if !fault {
// SAFETY: async-signal-safe.
unsafe { libc::raise(signal) };
}
}
/// The darwin `ucontext_t`, up to the pointer to the machine context (`<sys/_types/_ucontext.h>`).
#[repr(C)]
struct UContext {
_onstack: c_int,
_sigmask: u32,
_stack: libc::stack_t,
_link: *mut c_void,
_mcsize: usize,
mcontext: *const MContext,
}
/// The darwin arm64 machine context: exception state, then the general registers
/// (`<mach/arm/_structs.h>`; the floating-point state that follows isn't read).
#[cfg(target_arch = "aarch64")]
#[repr(C)]
struct MContext {
far: u64,
esr: u32,
_exception: u32,
_x: [u64; 29],
fp: u64,
_lr: u64,
_sp: u64,
pc: u64,
}
/// The darwin x86_64 machine context: exception state, then the general registers
/// (`<mach/i386/_structs.h>`; rax rbx rcx rdx rdi rsi rbp rsp r8–r15, then rip).
#[cfg(target_arch = "x86_64")]
#[repr(C)]
struct MContext {
trapno: u16,
_cpu: u16,
err: u32,
faultvaddr: u64,
gpr: [u64; 16],
rip: u64,
}
/// What the faulting instruction did to its address, when the processor says.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Access {
Unknown,
Read,
Write,
Execute,
}
/// What a crash signal says, read out of the signal before anything is looked up.
#[derive(Clone, Copy, Debug)]
struct Fault {
signal: c_int,
si_code: c_int,
/// `si_addr`: the address that faulted (for SIGILL, SIGFPE and SIGTRAP, the instruction).
addr: usize,
pc: usize,
fp: usize,
access: Access,
/// It comes from the faulting instruction (not `kill`, `raise` or `abort`).
fault: bool,
/// The process that sent the signal (0 or our own for a fault or an `abort()`).
sender: i32,
pid: i32,
thread: u64,
thread_name: [u8; 64],
/// The thread's stack, `[low, high)` (0, 0 when unknown).
stack: (usize, usize),
}
impl Fault {
/// The fault hit just below the thread's stack: the stack ran out.
fn overflow(&self) -> bool {
let (low, high) = self.stack;
matches!(self.signal, SIGSEGV | SIGBUS) && high > low && self.addr < low.saturating_add(4096) && self.addr >= low.saturating_sub(GUARD)
}
fn signal_name(&self) -> &'static str {
match self.signal {
SIGSEGV => "SIGSEGV",
SIGBUS => "SIGBUS",
SIGILL => "SIGILL",
SIGFPE => "SIGFPE",
SIGTRAP => "SIGTRAP",
SIGABRT => "SIGABRT",
_ => "signal",
}
}
fn name(&self) -> &'static str {
if self.overflow() {
return "stack overflow";
}
match self.signal {
SIGSEGV => "segmentation fault",
SIGBUS => "bus error",
SIGILL => "illegal instruction",
SIGFPE => "arithmetic exception",
SIGTRAP => "trap",
SIGABRT => "abort",
_ => "signal",
}
}
/// The `si_code` names that say something about a crash.
fn code_name(&self) -> Option<&'static str> {
Some(match (self.signal, self.si_code) {
(SIGSEGV, 1) => "SEGV_MAPERR",
(SIGSEGV, 2) => "SEGV_ACCERR",
(SIGBUS, 1) => "BUS_ADRALN",
(SIGBUS, 2) => "BUS_ADRERR",
(SIGBUS, 3) => "BUS_OBJERR",
(SIGFPE, 7) => "FPE_INTDIV",
(SIGFPE, 8) => "FPE_INTOVF",
_ => return None,
})
}
/// One line on what happened: the address read or written, a stack overflow, who sent it.
fn detail(&self, b: &mut Buf) {
if self.sender != 0 && self.sender != self.pid {
let _ = write!(b, "sent by process {}", self.sender);
return;
}
if !self.fault {
if self.signal != SIGABRT {
let _ = b.write_str("sent with kill or raise, not a fault");
}
return;
}
let what = match self.access {
Access::Read => "read of",
Access::Write => "write to",
Access::Execute => "execution at",
Access::Unknown => "access to",
};
if self.overflow() {
let _ = write!(b, "{what} 0x{:016X}, below the thread's stack", self.addr);
} else if matches!(self.signal, SIGSEGV | SIGBUS) {
let _ = write!(b, "{what} 0x{:016X}", self.addr);
} else {
let _ = write!(b, "at 0x{:016X}", self.addr);
}
if let Some(c) = self.code_name() {
let _ = write!(b, " ({c})");
}
}
}
/// An image that holds an address, as `dladdr` names it.
struct Module<'a> {
path: &'a [u8],
base: usize,
symbol: Option<(&'a [u8], usize)>,
}
/// A fixed buffer to format into without allocating (what doesn't fit is cut off).
struct Buf {
bytes: [u8; 8192],
len: usize,
}
impl Buf {
fn new() -> Buf {
Buf { bytes: [0; 8192], len: 0 }
}
fn room(&self) -> usize {
self.bytes.len().saturating_sub(self.len)
}
fn as_bytes(&self) -> &[u8] {
self.bytes.get(..self.len).unwrap_or_default()
}
/// `s` as the inside of a JSON string, at most `max` bytes of it (bytes that aren't UTF-8
/// and control characters become `?`).
fn escaped(&mut self, s: &[u8], max: usize) {
for chunk in s.get(..s.len().min(max)).unwrap_or_default().utf8_chunks() {
for c in chunk.valid().chars() {
let _ = match c {
'"' => self.write_str("\\\""),
'\\' => self.write_str("\\\\"),
c if (c as u32) < 0x20 => self.write_str("?"),
c => self.write_char(c),
};
}
if !chunk.invalid().is_empty() {
let _ = self.write_str("?");
}
}
}
}
impl std::fmt::Write for Buf {
fn write_str(&mut self, s: &str) -> std::fmt::Result {
let n = s.len().min(self.room());
if let (Some(to), Some(from)) = (self.bytes.get_mut(self.len..self.len + n), s.as_bytes().get(..n)) {
to.copy_from_slice(from);
self.len += n;
}
Ok(())
}
}
/// The last part of a path.
fn file_name(path: &[u8]) -> &[u8] {
path.rsplit(|&c| c == b'/').next().unwrap_or(path)
}
/// The report: what the signal says, then (with `resolve`) the module of the faulting
/// instruction and the `frames`. Always valid JSON: frames stop while there is room to close it.
fn json(b: &mut Buf, f: &Fault, frames: &[usize], resolve: impl Fn(usize, bool) -> Option<Module<'static>>) {
let signal = f.signal_name();
let _ = write!(b, "{{\"signal\":\"{signal}\",\"number\":{},\"code\":\"{signal}\",\"name\":\"{}\"", f.signal, f.name());
let _ = write!(b, ",\"si_code\":{},\"address\":\"0x{:016X}\",\"thread\":{},\"pid\":{}", f.si_code, f.pc, f.thread, f.pid);
let name = f.thread_name.split(|&c| c == 0).next().unwrap_or_default();
if !name.is_empty() {
let _ = b.write_str(",\"thread_name\":\"");
b.escaped(name, 64);
let _ = b.write_str("\"");
}
let mark = b.len;
let _ = b.write_str(",\"detail\":\"");
let start = b.len;
f.detail(b);
if b.len == start {
b.len = mark;
} else {
let _ = b.write_str("\"");
}
if let Some(m) = resolve(f.pc, false) {
let _ = write!(b, ",\"offset\":\"0x{:X}\",\"module\":\"", f.pc.wrapping_sub(m.base));
b.escaped(m.path, 300);
let _ = b.write_str("\"");
}
if !frames.is_empty() {
let _ = b.write_str(",\"frames\":[");
for (i, &at) in frames.iter().enumerate() {
// A frame is at most ~300 bytes; keep room for it and the closing `]}`.
if b.room() < 400 {
break;
}
if i > 0 {
let _ = b.write_str(",");
}
let _ = b.write_str("\"");
// A return address points after the call: look up the call itself.
match resolve(at, i > 0) {
Some(m) => {
b.escaped(file_name(m.path), 100);
let _ = write!(b, "+0x{:X}", at.wrapping_sub(m.base));
if let Some((symbol, start)) = m.symbol {
let _ = b.write_str(" ");
b.escaped(symbol, 160);
let _ = write!(b, "+0x{:X}", at.wrapping_sub(start));
}
}
None => {
let _ = write!(b, "0x{at:016X}");
}
}
let _ = b.write_str("\"");
}
let _ = b.write_str("]");
}
let _ = b.write_str("}");
}
/// The return addresses up the frame-pointer chain from `fp`, after `pc`. `read` gives the word
/// at an address; frames outside `stack`, misaligned or not going up end the walk.
fn walk(pc: usize, fp: usize, stack: (usize, usize), read: impl Fn(usize) -> Option<usize>, out: &mut [usize; MAX_FRAMES]) -> usize {
let (low, high) = stack;
let mut n = 0;
if let Some(slot) = out.get_mut(n) {
*slot = pc;
n += 1;
}
let mut fp = fp;
while n < MAX_FRAMES {
let in_stack = fp >= low && fp.checked_add(16).is_some_and(|end| end <= high);
if !fp.is_multiple_of(8) || !in_stack {
break;
}
let (Some(next), Some(ret)) = (read(fp), read(fp + 8)) else { break };
let ret = strip_pointer_auth(ret);
if ret == 0 {
break;
}
if let Some(slot) = out.get_mut(n) {
*slot = ret;
n += 1;
}
if next <= fp {
break;
}
fp = next;
}
n
}
/// Return addresses saved by arm64e code carry a signature in their top bits.
fn strip_pointer_auth(addr: usize) -> usize {
if cfg!(target_arch = "aarch64") { addr & 0x0000_7FFF_FFFF_FFFF } else { addr }
}
/// Read what the signal and the interrupted thread's registers say.
unsafe fn fault(signal: c_int, info: *const siginfo_t, context: *const c_void) -> Fault {
let mut f = Fault {
signal,
si_code: 0,
addr: 0,
pc: 0,
fp: 0,
access: Access::Unknown,
fault: false,
sender: 0,
// SAFETY: async-signal-safe.
pid: unsafe { libc::getpid() },
thread: 0,
thread_name: [0; 64],
stack: (0, 0),
};
if !info.is_null() {
// SAFETY: the system passes a valid siginfo for the duration of the handler.
let info = unsafe { &*info };
f.si_code = info.si_code;
f.addr = info.si_addr as usize;
f.sender = info.si_pid;
// Without registers (never seen), the signal's own code has to do.
f.fault = signal != SIGABRT && (1..0x10000).contains(&f.si_code);
}
let context = context as *const UContext;
// SAFETY: an SA_SIGINFO handler's third argument is the interrupted thread's ucontext, whose
// machine context pointer the kernel fills in.
let mcontext = if context.is_null() { std::ptr::null() } else { unsafe { (*context).mcontext } };
if !mcontext.is_null() {
// SAFETY: as above.
let m = unsafe { &*mcontext };
#[cfg(target_arch = "aarch64")]
{
f.pc = strip_pointer_auth(m.pc as usize);
f.fp = m.fp as usize;
f.fault = arm64_fault(signal, f.si_code, f.addr, m.esr, m.far);
// A data abort says whether it was a write (WnR); an instruction abort is execution.
f.access = match m.esr >> 26 {
0x24 | 0x25 if m.esr & (1 << 6) != 0 => Access::Write,
0x24 | 0x25 => Access::Read,
0x20 | 0x21 => Access::Execute,
_ => Access::Unknown,
};
}
#[cfg(target_arch = "x86_64")]
{
f.pc = m.rip as usize;
f.fp = m.gpr.get(6).copied().unwrap_or(0) as usize;
f.fault = x86_fault(signal, f.si_code, f.addr, m.trapno, m.faultvaddr);
// A page fault's error code: bit 4 instruction fetch, bit 1 write.
f.access = match (m.trapno, m.err) {
(14, e) if e & 0x10 != 0 => Access::Execute,
(14, e) if e & 0x2 != 0 => Access::Write,
(14, _) => Access::Read,
_ => Access::Unknown,
};
}
}
if f.pc == 0 && matches!(signal, SIGILL | SIGFPE | SIGTRAP) {
f.pc = f.addr;
}
// SAFETY: these read the calling thread's own pthread structure; the buffers are as long as
// we say.
unsafe {
let me = libc::pthread_self();
libc::pthread_threadid_np(me, &mut f.thread);
libc::pthread_getname_np(me, f.thread_name.as_mut_ptr().cast(), f.thread_name.len());
let high = libc::pthread_get_stackaddr_np(me) as usize;
let size = libc::pthread_get_stacksize_np(me);
if let Some(low) = high.checked_sub(size) {
f.stack = (low, high);
}
}
f
}
/// The image that holds `addr` (the call before it, for a return address).
fn resolve(addr: usize, return_address: bool) -> Option<Module<'static>> {
let at = if return_address { addr.saturating_sub(1) } else { addr };
// SAFETY: `Dl_info` is plain data; all zeros is a valid value.
let mut info: libc::Dl_info = unsafe { std::mem::zeroed() };
// SAFETY: `dladdr` only reads dyld's image list; the strings it returns live as long as the
// image, which a crashing process never unloads.
unsafe {
if libc::dladdr(at as *const c_void, &mut info) == 0 || info.dli_fname.is_null() {
return None;
}
let symbol = (!info.dli_sname.is_null()).then(|| (CStr::from_ptr(info.dli_sname).to_bytes(), info.dli_saddr as usize));
Some(Module { path: CStr::from_ptr(info.dli_fname).to_bytes(), base: info.dli_fbase as usize, symbol })
}
}
/// Write what the signal says into the info file, then again with the modules and frames.
fn record(f: &Fault) {
let Some(path) = PATH.get() else { return };
// SAFETY: NUL-terminated path; the descriptor is closed below.
let fd = unsafe { libc::open(path.as_ptr(), libc::O_WRONLY | libc::O_CREAT | libc::O_TRUNC | libc::O_CLOEXEC, 0o600) };
if fd < 0 {
return;
}
let mut b = Buf::new();
json(&mut b, f, &[], |_, _| None);
let bytes = b.as_bytes();
// SAFETY: the buffer is as long as we say.
unsafe { libc::write(fd, bytes.as_ptr().cast(), bytes.len()) };
let mut frames = [0usize; MAX_FRAMES];
// SAFETY: `walk` only reads inside the thread's own stack, 8-byte aligned.
let n = walk(f.pc, f.fp, f.stack, |at| Some(unsafe { (at as *const usize).read() }), &mut frames);
b.len = 0;
json(&mut b, f, frames.get(..n).unwrap_or_default(), resolve);
let bytes = b.as_bytes();
// SAFETY: as above.
unsafe {
if libc::pwrite(fd, bytes.as_ptr().cast(), bytes.len(), 0) == bytes.len() as isize {
libc::ftruncate(fd, bytes.len() as libc::off_t);
}
libc::close(fd);
}
}
#[cfg(test)]
mod tests {
use std::os::unix::process::ExitStatusExt;
use std::path::{Path, PathBuf};
use super::*;
fn fault(signal: c_int, si_code: c_int, addr: usize) -> Fault {
let mut thread_name = [0u8; 64];
thread_name[..4].copy_from_slice(b"main");
Fault {
signal,
si_code,
addr,
pc: 0x1_0000_1234,
fp: 0,
access: Access::Read,
fault: true,
sender: 0,
pid: 77,
thread: 4242,
thread_name,
stack: (0x7000_0000, 0x7080_0000),
}
}
fn report(f: &Fault, frames: &[usize]) -> serde_json::Value {
let mut b = Buf::new();
json(&mut b, f, frames, |at, _| {
(at >= 0x1_0000_0000).then_some(Module {
path: b"/Applications/SolidCraft.app/Contents/MacOS/solidcraft",
base: 0x1_0000_0000,
symbol: Some((b"_ZN4main17h0123456789abcdefE", 0x1_0000_1200)),
})
});
serde_json::from_slice(b.as_bytes()).unwrap()
}
#[test]
fn a_segfault_says_the_signal_the_address_read_and_the_module() {
let v = report(&fault(SIGSEGV, 1, 0x10), &[0x1_0000_1234, 0x1_0000_2000, 0x5000]);
assert_eq!(v["signal"], "SIGSEGV");
assert_eq!(v["code"], "SIGSEGV");
assert_eq!(v["name"], "segmentation fault");
assert_eq!(v["detail"], "read of 0x0000000000000010 (SEGV_MAPERR)");
assert_eq!(v["address"], "0x0000000100001234");
assert_eq!(v["module"], "/Applications/SolidCraft.app/Contents/MacOS/solidcraft");
assert_eq!(v["offset"], "0x1234");
assert_eq!(v["thread"], 4242);
assert_eq!(v["thread_name"], "main");
assert_eq!(v["pid"], 77);
let frames: Vec<&str> = v["frames"].as_array().unwrap().iter().map(|f| f.as_str().unwrap()).collect();
assert_eq!(
frames,
["solidcraft+0x1234 _ZN4main17h0123456789abcdefE+0x34", "solidcraft+0x2000 _ZN4main17h0123456789abcdefE+0xE00", "0x0000000000005000"]
);
}
#[test]
fn a_fault_below_the_stack_is_a_stack_overflow() {
let mut f = fault(SIGBUS, 2, 0x7000_0000 - 0x100);
f.access = Access::Write;
let v = report(&f, &[]);
assert_eq!(v["name"], "stack overflow");
assert_eq!(v["code"], "SIGBUS");
assert_eq!(v["detail"], "write to 0x000000006FFFFF00, below the thread's stack (BUS_ADRERR)");
// Far below it, or inside it, it is not.
assert_eq!(report(&fault(SIGBUS, 2, 0x10), &[])["name"], "bus error");
assert_eq!(report(&fault(SIGSEGV, 2, 0x7040_0000), &[])["name"], "segmentation fault");
// Nor without knowing the stack.
let mut f = fault(SIGSEGV, 1, 0x7000_0000 - 0x100);
f.stack = (0, 0);
assert_eq!(report(&f, &[])["name"], "segmentation fault");
}
#[test]
fn an_abort_or_a_signal_sent_by_another_process_has_no_address_detail() {
let mut f = fault(SIGABRT, 0, 0);
f.fault = false;
f.sender = 77;
let v = report(&f, &[]);
assert_eq!(v["signal"], "SIGABRT");
assert_eq!(v["name"], "abort");
assert!(v.get("detail").is_none(), "{v}");
let mut f = fault(SIGSEGV, 0x10001, 0);
f.fault = false;
f.sender = 501;
assert_eq!(report(&f, &[])["detail"], "sent by process 501");
// `kill` without a sender, or `raise`.
f.sender = 0;
assert_eq!(report(&f, &[])["detail"], "sent with kill or raise, not a fault");
// A trap names the instruction.
assert_eq!(report(&fault(SIGTRAP, 1, 0x1_0000_1234), &[])["detail"], "at 0x0000000100001234");
}
#[test]
fn only_a_fault_runs_again_when_the_handler_returns() {
// Measured on an M5 Pro, macOS 27: a read of address 16, then `kill -SEGV` while the
// thread waits in a system call. Both have si_code 2.
assert!(arm64_fault(SIGSEGV, 2, 0x10, 0x9200_0046, 0x10));
assert!(!arm64_fault(SIGSEGV, 2, 0, 0x5600_0080, 0));
assert!(!arm64_fault(SIGBUS, 1, 0, 0x5600_0080, 0));
// A data abort long past, while the signal names no address.
assert!(!arm64_fault(SIGSEGV, 2, 0, 0x9200_0046, 0x10));
// A trap instruction (BRK) or an undefined one; but not a system call.
assert!(arm64_fault(SIGTRAP, 1, 0x1000, 0xF200_0001, 0));
assert!(arm64_fault(SIGILL, 1, 0x1000, 0x0200_0000, 0));
assert!(!arm64_fault(SIGILL, 1, 0x1000, 0x5600_0080, 0));
// Sent: SI_USER, no code, or an abort.
assert!(!arm64_fault(SIGSEGV, 0x10001, 0x10, 0x9200_0046, 0x10));
assert!(!arm64_fault(SIGTRAP, 0, 0, 0xF200_0001, 0));
assert!(!arm64_fault(SIGABRT, 1, 0, 0x5600_0080, 0));
// x86_64: a page fault at its address, a general protection fault, ud2, int3.
assert!(x86_fault(SIGSEGV, 1, 0x10, 14, 0x10));
assert!(!x86_fault(SIGSEGV, 1, 0, 14, 0x10));
assert!(x86_fault(SIGSEGV, 1, 0, 13, 0));
assert!(x86_fault(SIGILL, 1, 0x1000, 6, 0));
assert!(x86_fault(SIGTRAP, 1, 0x1000, 3, 0));
assert!(!x86_fault(SIGILL, 1, 0x1000, 14, 0));
}
#[test]
fn names_with_quotes_control_characters_or_bad_utf8_stay_valid_json() {
let mut f = fault(SIGILL, 1, 0x1_0000_1234);
f.thread_name = [0; 64];
f.thread_name[..7].copy_from_slice(b"a\"b\\\n\xFFc");
let v = report(&f, &[]);
assert_eq!(v["thread_name"], "a\"b\\??c");
}
#[test]
fn a_report_with_long_names_still_closes_its_json() {
let long: &'static [u8] = Box::leak(vec![b'x'; 4000].into_boxed_slice());
let f = fault(SIGSEGV, 1, 0x10);
let mut b = Buf::new();
json(&mut b, &f, &[0x1_0000_1234; MAX_FRAMES], |_, _| Some(Module { path: long, base: 0x1_0000_0000, symbol: Some((long, 0x1_0000_0000)) }));
let v: serde_json::Value = serde_json::from_slice(b.as_bytes()).unwrap();
assert_eq!(v["module"].as_str().unwrap().len(), 300);
let frames = v["frames"].as_array().unwrap();
assert!(!frames.is_empty() && frames.len() <= MAX_FRAMES, "{}", frames.len());
}
#[test]
fn the_frame_walk_stays_on_the_stack_and_goes_up() {
let stack = (0x1000, 0x2000);
// Three frames: 0x1100 -> 0x1200 -> 0x1300 -> 0 (end).
let memory = |at: usize| match at {
0x1100 => Some(0x1200),
0x1108 => Some(0xA),
0x1200 => Some(0x1300),
0x1208 => Some(0xB),
0x1300 => Some(0),
0x1308 => Some(0xC),
_ => None,
};
let mut out = [0; MAX_FRAMES];
let n = walk(0x9, 0x1100, stack, memory, &mut out);
assert_eq!(&out[..n], &[0x9, 0xA, 0xB, 0xC]);
// A frame pointer off the stack, misaligned, or one that doesn't go up ends the walk.
assert_eq!(walk(0x9, 0x3000, stack, memory, &mut out), 1);
assert_eq!(walk(0x9, 0x1101, stack, memory, &mut out), 1);
let looping = |at: usize| if at.is_multiple_of(16) { Some(at) } else { Some(0xA) };
assert_eq!(walk(0x9, 0x1100, stack, looping, &mut out), 2);
// A chain that never ends stops at MAX_FRAMES.
let endless = |at: usize| if at.is_multiple_of(16) { Some(at + 16) } else { Some(0xA) };
assert_eq!(walk(0x9, 0x1000, stack, endless, &mut out), MAX_FRAMES);
}
/// The child half: install crash reporting and the handler, then crash the way
/// `SOLIDCRAFT_NATIVE_CRASH_FAULT` says.
#[test]
fn child() {
let Some(dir) = std::env::var_os("SOLIDCRAFT_NATIVE_CRASH_CHILD") else { return };
let build = solidcraft_engine::crash::Build { version: "9.9.9", commit: "abc1234", built: "2026-10-25" };
let fresh = solidcraft_engine::crash::install(PathBuf::from(dir), build);
println!("FRESH {}", serde_json::to_string(&fresh).unwrap_or_default());
let Some(fault) = std::env::var_os("SOLIDCRAFT_NATIVE_CRASH_FAULT") else {
solidcraft_engine::crash::end_session();
return;
};
super::install();
solidcraft_engine::crash::phase("device creation");
match fault.to_str() {
Some("abort") => std::process::abort(),
Some("overflow") => println!("unreachable {}", recurse(0)),
Some("raise") => {
// SAFETY: none needed; this is the crash under test.
unsafe { libc::raise(SIGSEGV) };
println!("survived");
}
_ => segfault(),
}
}
/// Runs out of stack.
#[inline(never)]
fn recurse(n: u64) -> u64 {
let a = std::hint::black_box([n; 64]);
if n == u64::MAX { 0 } else { recurse(n + 1).wrapping_add(a[0]) }
}
/// The crash under test: a read of address 16, as a null struct pointer's field would be.
#[inline(never)]
fn segfault() {
// SAFETY: none; this is the crash under test.
let v = unsafe { std::ptr::read_volatile(std::hint::black_box(16usize) as *const u64) };
println!("unreachable {v}");
}
fn run(dir: &Path, fault: Option<&str>) -> std::process::Output {
let mut c = std::process::Command::new(std::env::current_exe().unwrap());
c.args(["--exact", "native_crash_macos::tests::child", "--test-threads=1", "--nocapture"]).env("SOLIDCRAFT_NATIVE_CRASH_CHILD", dir);
if let Some(f) = fault {
c.env("SOLIDCRAFT_NATIVE_CRASH_FAULT", f);
}
c.output().unwrap()
}
/// Crash a child the way `fault` says; returns what the handler wrote and the next start's report.
fn crash(name: &str, fault: &str, signal: c_int) -> (serde_json::Value, serde_json::Value, String) {
let dir = std::env::temp_dir().join(format!("solidcraft-native-crash-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let out = run(&dir, Some(fault));
assert_eq!(out.status.signal(), Some(signal), "the child died of the signal itself: {out:?}");
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
let info =
std::fs::read_dir(dir.join("sessions")).unwrap().flatten().map(|e| e.path()).find(|p| p.to_string_lossy().ends_with(".native.json"));
let info = info.expect("the handler wrote what it caught");
let v: serde_json::Value = serde_json::from_slice(&std::fs::read(info).unwrap()).unwrap();
// The next start reports it.
let out = run(&dir, None);
assert!(out.status.success(), "{out:?}");
let reports = solidcraft_engine::crash::list(&dir);
assert_eq!(reports.len(), 1, "{reports:?}");
let r = solidcraft_engine::crash::read(&dir, &reports[0].id).unwrap();
let _ = std::fs::remove_dir_all(&dir);
(v, r, stderr)
}
#[test]
fn a_segfault_is_reported_with_its_signal_module_frames_and_step() {
let (v, r, _) = crash("segv", "segv", SIGSEGV);
assert_eq!(v["signal"], "SIGSEGV", "{v}");
// Address 16 is in __PAGEZERO, mapped without access: SEGV_ACCERR.
assert_eq!(v["detail"], "read of 0x0000000000000010 (SEGV_ACCERR)", "{v}");
assert!(v["module"].as_str().unwrap().contains("solidcraft"), "the fault is in the test binary: {v}");
let frames = v["frames"].as_array().unwrap();
assert!(frames.len() >= 3, "{v}");
assert!(frames.iter().any(|f| f.as_str().unwrap().contains("child")), "the walk reaches the test that crashed: {v}");
let message = r["message"].as_str().unwrap();
assert!(message.contains("segmentation fault (SIGSEGV) in solidcraft"), "{r}");
assert!(message.contains("during startup (device creation"), "{r}");
assert_eq!(r["kind"], "unclean_exit");
assert_eq!(r["native"]["signal"], "SIGSEGV");
assert!(r["backtrace"].as_array().unwrap().iter().any(|f| f.as_str().unwrap().contains("segfault")), "{r}");
}
#[test]
fn an_abort_is_reported_as_sigabrt_with_the_code_that_called_it() {
let (v, r, _) = crash("abrt", "abort", SIGABRT);
assert_eq!(v["signal"], "SIGABRT", "{v}");
assert!(v.get("detail").is_none(), "{v}");
let frames = v["frames"].as_array().unwrap();
assert!(frames.iter().any(|f| f.as_str().unwrap().contains("abort")), "{v}");
assert!(r["message"].as_str().unwrap().contains("abort (SIGABRT)"), "{r}");
}
#[test]
fn a_stack_overflow_is_recorded_and_the_standard_library_still_says_so() {
// The fault on the guard page is recorded on the alternate stack; the standard library's
// handler, called after ours, prints its message and aborts.
let (v, r, stderr) = crash("overflow", "overflow", SIGABRT);
assert!(matches!(v["signal"].as_str(), Some("SIGSEGV" | "SIGBUS")), "{v}");
assert_eq!(v["name"], "stack overflow", "{v}");
assert!(v["detail"].as_str().unwrap().contains("below the thread's stack"), "{v}");
assert!(stderr.contains("has overflowed its stack"), "{stderr}");
assert!(r["message"].as_str().unwrap().contains("stack overflow ("), "{r}");
}
#[test]
fn a_segfault_signal_that_was_only_sent_still_ends_the_app() {
// The standard library's handler, which comes before ours, returns expecting the fault
// to happen again; a sent signal never does, so it goes to the default action instead.
let (v, r, _) = crash("raise", "raise", SIGSEGV);
assert_eq!(v["detail"], "sent with kill or raise, not a fault", "{v}");
assert!(r["message"].as_str().unwrap().contains("(SIGSEGV)"), "{r}");
}
}
+57 -5
View File
@@ -15,10 +15,14 @@
//! window rescales once at the crossing and stays under the pointer, instead of the OS's move
//! loop and winit disagreeing about a frame the window doesn't have.
//!
//! Replacing a window procedure has no safe API, hence the `unsafe_code` allowance on this
//! module (the app crate denies it; see `native_crash.rs` and `open_documents.rs` for the others).
//! Replacing a window procedure and setting a window class's icon have no safe API, hence the
//! `unsafe_code` allowance on this module (the app crate denies it; see `native_crash.rs` and
//! `open_documents.rs` for the others).
//! The procedure is installed once, on the thread that owns the window, for the main window only,
//! and calls the previous one (winit's) for everything it doesn't answer.
//!
//! [`set_class_icon`] gives the window's class the exe's icon, so the taskbar button shows the
//! mark even when the window is too busy to answer the taskbar's request for its icon.
#![allow(unsafe_code)]
use std::sync::atomic::{AtomicIsize, Ordering};
@@ -27,12 +31,18 @@ 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::System::LibraryLoader::GetModuleHandleW;
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,
HTRIGHT, HTTOP, HTTOPLEFT, HTTOPRIGHT, IsIconic, IsZoomed, SM_CXPADDEDBORDER, SM_CXSIZEFRAME, SetWindowLongPtrW, WM_GETDPISCALEDSIZE,
WM_NCDESTROY, WM_NCHITTEST, WNDPROC, WS_THICKFRAME,
CallWindowProcW, GCLP_HICON, GCLP_HICONSM, GWL_STYLE, GWLP_WNDPROC, GetClientRect, GetWindowLongPtrW, GetWindowRect, HTBOTTOM, HTBOTTOMLEFT,
HTBOTTOMRIGHT, HTLEFT, HTRIGHT, HTTOP, HTTOPLEFT, HTTOPRIGHT, IMAGE_ICON, IsIconic, IsZoomed, LR_DEFAULTCOLOR, LoadImageW, SM_CXICON,
SM_CXPADDEDBORDER, SM_CXSIZEFRAME, SM_CXSMICON, SetWindowLongPtrW, WM_GETDPISCALEDSIZE, WM_NCDESTROY, WM_NCHITTEST, WNDPROC, WS_THICKFRAME,
};
// windows-sys has SetClassLongPtrW only on 64-bit targets; on 32-bit Windows it is SetClassLongW.
#[cfg(target_pointer_width = "64")]
use windows_sys::Win32::UI::WindowsAndMessaging::SetClassLongPtrW;
#[cfg(target_pointer_width = "32")]
use windows_sys::Win32::UI::WindowsAndMessaging::SetClassLongW as SetClassLongPtrW;
/// The work area (the monitor without the taskbar) of the monitor nearest the window, in physical
/// pixels: left, top, width, height.
@@ -54,6 +64,48 @@ pub fn work_area(window: &impl HasWindowHandle) -> Option<(i32, i32, i32, i32)>
Some((rc.left, rc.top, rc.right - rc.left, rc.bottom - rc.top))
}
/// The exe's icon resource (`build.rs` embeds it as `1 ICON`).
const ICON_RESOURCE: usize = 1;
/// Give the main window's class the exe's icon, big and small. Call it before the window is
/// shown (eframe's app creator).
///
/// The taskbar asks a window for its icon when the window appears. eframe sets the window's
/// icon before that, while the window is still hidden, and then shows the window at the end of
/// its first frame; if that frame is slow, or startup kept the thread busy long enough for
/// Windows to mark the window as not responding, the request isn't answered in time. The taskbar then
/// takes the window class's icon and doesn't ask again. winit registers its class without one,
/// so the button showed Windows' generic icon for as long as the window was open.
/// `false` if it isn't a Win32 window or the exe's icon doesn't load; SetClassLongPtrW's own
/// failure can't be told from a class that had no icon (both return 0), so it isn't reported.
pub fn set_class_icon(window: &impl HasWindowHandle) -> bool {
let Ok(handle) = window.window_handle() else { return false };
let RawWindowHandle::Win32(w) = handle.as_raw() else { return false };
let hwnd = w.hwnd.get() as HWND;
// SAFETY: a plain query on the live main window.
let dpi = unsafe { GetDpiForWindow(hwnd) };
let (Some(big), Some(small)) = (exe_icon(SM_CXICON, dpi), exe_icon(SM_CXSMICON, dpi)) else { return false };
// SAFETY: `hwnd` is the live main window and both icons are valid handles. The class keeps
// them for the rest of the process, so they are never destroyed. `as _`: on 32-bit Windows
// this is SetClassLongW (see the imports), which takes an i32.
unsafe {
SetClassLongPtrW(hwnd, GCLP_HICON, big as _);
SetClassLongPtrW(hwnd, GCLP_HICONSM, small as _);
}
true
}
/// The exe's icon at the size of system metric `metric` (`SM_CXICON`, `SM_CXSMICON`) at `dpi`.
fn exe_icon(metric: i32, dpi: u32) -> Option<windows_sys::Win32::Foundation::HANDLE> {
// SAFETY: GetModuleHandleW(null) is this exe, loaded for the life of the process; the name
// is an integer resource id (MAKEINTRESOURCE), which LoadImageW accepts in place of a string.
let icon = unsafe {
let size = GetSystemMetricsForDpi(metric, dpi);
LoadImageW(GetModuleHandleW(std::ptr::null()), std::ptr::without_provenance(ICON_RESOURCE), IMAGE_ICON, size, size, LR_DEFAULTCOLOR)
};
(!icon.is_null()).then_some(icon)
}
/// The window procedure ours replaced (winit's), as an address; 0 before [`install`].
static PREVIOUS: AtomicIsize = AtomicIsize::new(0);
+9 -2
View File
@@ -101,10 +101,12 @@ impl NativeMenu {
])?;
for (action, label) in [
("docs", "Documentation"),
("updates", app.tr("update.check_for_updates")),
("shortcuts", "Keyboard Shortcuts"),
("messages", "Messages\u{2026}"),
("report", "Report an Issue\u{2026}"),
("crashes", "Crash Reports\u{2026}"),
("install_cli", "Install solidcraft-cli command"),
] {
let item = MenuItem::new(app.tr_label(label), true, None);
help.append(&item)?;
@@ -145,8 +147,7 @@ impl NativeMenu {
}
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))
action.menu_enabled(app, ctx.egui_wants_keyboard_input())
}
pub fn tick(&mut self, app: &mut SolveApp, ctx: &egui::Context) {
@@ -156,6 +157,12 @@ impl NativeMenu {
item.set_enabled(enabled);
}
}
for (item, action) in &self.help_actions {
let enabled = solidcraft_ui_egui::help::available(app, action);
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)
{
+463
View File
@@ -0,0 +1,463 @@
//! The navigation bridge: a 3D input device's own program (a homemade device's driver app) moves
//! the view over a loopback connection, the way a 3D mouse driver hands its axes to the programs
//! it supports, so the device emulates no mouse and the pointer stays the user's.
//!
//! The device app listens on `127.0.0.1:<port>`; with Preferences › Navigation › "Navigate with a
//! device app" on (off by default), SolidCraft connects to it, says hello and reads one JSON
//! object per line (docs/3d-mouse.md). The reader thread only sums the motion; the egui thread
//! hands it to the view once per frame (`space_mouse::bridge_frame`). A device the app says it
//! reads is left to it by the 3D mouse reader ([`Claim`]), so the view doesn't move twice.
use std::io::{ErrorKind, Read, Write};
use std::net::{Ipv4Addr, SocketAddr, TcpStream};
use std::sync::{Arc, Mutex, MutexGuard};
use std::time::Duration;
use egui::Context;
use serde_json::{Value, json};
use solidcraft_ui_egui::SolveApp;
use solidcraft_ui_egui::space_mouse::{BRIDGE_VIEWS, BridgeMotion, bridge_frame, bridge_step};
/// The longest line read; a longer one is skipped to its end.
const MAX_LINE: usize = 4096;
/// Standard views waiting for the next frame; past this many, more are a flood and dropped.
const MAX_VIEWS: usize = 8;
/// The longest name a device app may give for itself or its device, in characters.
const MAX_NAME: usize = 64;
/// How long a connection attempt waits.
const CONNECT_TIMEOUT: Duration = Duration::from_millis(500);
/// How long a read waits for data before the reader looks at the preferences again.
const READ_WAKE: Duration = Duration::from_millis(250);
/// The wait before connecting again when no device app answered or one went away.
const RETRY_AFTER: Duration = Duration::from_secs(1);
/// In zoom mode, a motion of this many points along Y is one zoom step: with 8 % a step, the 1 %
/// a point that a zoom drag moves.
const POINTS_PER_ZOOM_STEP: f64 = 8.0;
/// What the device's motion drives, as the device app last said (`mode`).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
enum Mode {
#[default]
Orbit,
Pan,
Zoom,
}
/// What the reader thread and the egui thread share.
#[derive(Debug, Default)]
struct Shared {
/// The port the preferences ask to connect to; none while the bridge is off.
wanted: Option<u16>,
/// The device app connected, by the name it gave ("?" until it gives one).
connected: Option<String>,
/// The device the connected app reads, as it named it: the 3D mouse reader leaves that one alone.
device: Option<String>,
/// Motion since the egui thread last took it.
motion: BridgeMotion,
/// Standard views asked for since then.
views: Vec<String>,
}
fn lock(shared: &Mutex<Shared>) -> Option<MutexGuard<'_, Shared>> {
shared.lock().ok()
}
/// A name a device app gave, without control characters and cut to [`MAX_NAME`]; none when empty.
fn name(value: Option<&Value>) -> Option<String> {
let name: String = value?.as_str()?.chars().filter(|c| !c.is_control()).take(MAX_NAME).collect();
let name = name.trim();
(!name.is_empty()).then(|| name.to_string())
}
/// A number of a message, finite and in range; none when it isn't one.
fn number(msg: &Value, key: &str) -> f64 {
bridge_step(msg.get(key).and_then(Value::as_f64).unwrap_or(0.0))
}
/// Takes one line from the device app into `shared`. Lines that aren't a message this version
/// knows are ignored. Returns whether the view has something new to show.
fn consume(line: &[u8], mode: &mut Mode, shared: &mut Shared) -> bool {
let Ok(msg) = serde_json::from_slice::<Value>(line) else { return false };
let add = |shared: &mut Shared, motion: BridgeMotion| {
shared.motion = shared.motion.plus(motion);
!motion.is_still()
};
match msg.get("t").and_then(Value::as_str) {
Some("axis") => {
let (x, y) = (number(&msg, "x"), number(&msg, "y"));
let motion = match mode {
Mode::Orbit => BridgeMotion { orbit: [x, y], ..Default::default() },
Mode::Pan => BridgeMotion { pan: [x, y], ..Default::default() },
// Up zooms in, as a zoom drag does.
Mode::Zoom => BridgeMotion { zoom: -y / POINTS_PER_ZOOM_STEP, ..Default::default() },
};
add(shared, motion)
}
Some("zoom") => add(shared, BridgeMotion { zoom: number(&msg, "d"), ..Default::default() }),
Some("mode") => {
let on = msg.get("on").and_then(Value::as_bool).unwrap_or(false);
*mode = match msg.get("mode").and_then(Value::as_str) {
Some("pan") if on => Mode::Pan,
Some("zoom") if on => Mode::Zoom,
_ => Mode::Orbit,
};
false
}
Some("view") => {
let view = msg.get("name").and_then(Value::as_str).and_then(|n| BRIDGE_VIEWS.iter().find(|v| **v == n));
match view {
Some(view) if shared.views.len() < MAX_VIEWS => {
shared.views.push((*view).to_string());
true
}
_ => false,
}
}
Some("hello") => {
shared.connected = name(msg.get("server")).or_else(|| shared.connected.clone());
shared.device = name(msg.get("device"));
true
}
_ => false,
}
}
/// Splits what arrives into lines, across reads. A line longer than [`MAX_LINE`] is dropped up to
/// its newline, so a peer that never sends one can't make the reader hold more than that.
#[derive(Default)]
struct Lines {
line: Vec<u8>,
skipping: bool,
}
impl Lines {
fn feed(&mut self, mut data: &[u8], mut each: impl FnMut(&[u8])) {
while !data.is_empty() {
let end = data.iter().position(|b| *b == b'\n');
let (part, rest) = match end {
Some(i) => (data.get(..i).unwrap_or_default(), data.get(i + 1..).unwrap_or_default()),
None => (data, &[][..]),
};
if !self.skipping {
let room = (MAX_LINE + 1).saturating_sub(self.line.len());
self.line.extend_from_slice(part.get(..room.min(part.len())).unwrap_or_default());
if self.line.len() > MAX_LINE {
self.line.clear();
self.skipping = true;
}
}
if end.is_some() {
if !self.skipping {
each(self.line.strip_suffix(b"\r").unwrap_or(&self.line));
}
self.line.clear();
self.skipping = false;
}
data = rest;
}
}
}
/// What SolidCraft says first, so the device app knows which program it is driving.
fn hello() -> Value {
json!({"t": "hello", "app": "solidcraft", "version": env!("CARGO_PKG_VERSION")})
}
/// Reads one connection until the device app closes it, it fails, or the preferences no longer
/// ask for this port; then forgets what it had.
fn serve(mut stream: TcpStream, port: u16, ctx: &Context, shared: &Mutex<Shared>) {
// Without a read timeout the reader couldn't notice the bridge being switched off.
if stream.set_read_timeout(Some(READ_WAKE)).is_err() || writeln!(stream, "{}", hello()).is_err() {
return;
}
let _ = stream.set_nodelay(true);
log::info!("navigation bridge: connected to 127.0.0.1:{port}");
if let Some(mut shared) = lock(shared) {
shared.connected = Some("?".into());
}
ctx.request_repaint();
let (mut lines, mut mode) = (Lines::default(), Mode::default());
let mut chunk = [0_u8; 4096];
while lock(shared).is_some_and(|shared| shared.wanted == Some(port)) {
match stream.read(&mut chunk) {
Ok(0) => break,
Ok(size) => {
let mut news = false;
if let Some(mut shared) = lock(shared) {
lines.feed(chunk.get(..size).unwrap_or_default(), |line| news |= consume(line, &mut mode, &mut shared));
}
if news {
ctx.request_repaint();
}
}
Err(error) if matches!(error.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut | ErrorKind::Interrupted) => {}
Err(error) => {
log::info!("navigation bridge: connection lost: {error}");
break;
}
}
}
if let Some(mut shared) = lock(shared) {
*shared = Shared { wanted: shared.wanted, ..Shared::default() };
}
ctx.request_repaint();
}
/// Connects to the device app while the preferences ask for it, again whenever it goes away.
fn reader(ctx: Context, shared: Arc<Mutex<Shared>>) {
loop {
let Some(port) = lock(&shared).and_then(|shared| shared.wanted) else {
std::thread::sleep(READ_WAKE);
continue;
};
if let Ok(stream) = TcpStream::connect_timeout(&SocketAddr::from((Ipv4Addr::LOCALHOST, port)), CONNECT_TIMEOUT) {
serve(stream, port, &ctx, &shared);
}
std::thread::sleep(RETRY_AFTER);
}
}
/// Which 3D mouse the connected device app reads, for the 3D mouse reader to leave alone. Only
/// the Windows and macOS readers know a device's strings to compare.
#[derive(Clone)]
#[cfg_attr(not(any(target_os = "windows", target_os = "macos")), allow(dead_code))]
pub struct Claim(Arc<Mutex<Shared>>);
#[cfg_attr(not(any(target_os = "windows", target_os = "macos")), allow(dead_code))]
impl Claim {
/// Whether a device with these manufacturer and product strings is the one the connected
/// device app reads (its name appears in one, ignoring case).
pub fn claims(&self, strings: &[String]) -> bool {
let Some(device) = lock(&self.0).and_then(|shared| shared.device.clone()) else { return false };
let device = device.to_lowercase();
strings.iter().any(|s| s.to_lowercase().contains(&device))
}
}
pub struct NavBridge {
shared: Arc<Mutex<Shared>>,
}
impl NavBridge {
pub fn start(ctx: &Context) -> Self {
let shared = Arc::new(Mutex::new(Shared::default()));
let (worker_ctx, worker_state) = (ctx.clone(), Arc::clone(&shared));
if let Err(error) = std::thread::Builder::new().name("solidcraft-nav-bridge".into()).spawn(move || reader(worker_ctx, worker_state)) {
log::warn!("navigation bridge: reader could not start: {error}");
}
Self { shared }
}
#[cfg_attr(not(any(target_os = "windows", target_os = "macos")), allow(dead_code))]
pub fn claim(&self) -> Claim {
Claim(Arc::clone(&self.shared))
}
/// Each frame: passes the preferences to the reader, and the motion and views since the last
/// frame to the view. Motion that arrived while the window had no focus is dropped with it.
pub fn tick(&mut self, app: &mut SolveApp, ctx: &Context) {
let wanted = Some(app.preferences.nav_bridge_port).filter(|port| app.preferences.nav_bridge_enabled && *port != 0);
let Some((motion, views, connected)) = lock(&self.shared).map(|mut shared| {
shared.wanted = wanted;
(std::mem::take(&mut shared.motion), std::mem::take(&mut shared.views), shared.connected.clone())
}) else {
return;
};
app.space_mouse.bridge = connected.filter(|_| wanted.is_some());
if !motion.is_still() || !views.is_empty() {
bridge_frame(app, ctx, motion, &views);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{BufRead, BufReader};
use std::net::TcpListener;
use std::time::Instant;
fn feed(shared: &mut Shared, mode: &mut Mode, text: &str) -> bool {
let mut news = false;
Lines::default().feed(text.as_bytes(), |line| news |= consume(line, mode, shared));
news
}
#[test]
fn axis_motion_orbits_pans_or_zooms_as_the_mode_says() {
let (mut shared, mut mode) = (Shared::default(), Mode::default());
assert!(feed(&mut shared, &mut mode, "{\"t\":\"axis\",\"x\":3,\"y\":-2}\n{\"t\":\"axis\",\"x\":1.5,\"y\":0}\n"));
assert_eq!(shared.motion, BridgeMotion { orbit: [4.5, -2.0], ..Default::default() });
feed(&mut shared, &mut mode, "{\"t\":\"mode\",\"mode\":\"pan\",\"on\":true}\n{\"t\":\"axis\",\"x\":5,\"y\":6}\n");
assert_eq!(mode, Mode::Pan);
assert_eq!(shared.motion.pan, [5.0, 6.0]);
feed(&mut shared, &mut mode, "{\"t\":\"mode\",\"mode\":\"zoom\",\"on\":true}\n{\"t\":\"axis\",\"x\":0,\"y\":-16}\n");
assert_eq!(shared.motion.zoom, 2.0, "up zooms in, 8 points a step");
// Off, whichever mode it names, is back to orbit.
feed(&mut shared, &mut mode, "{\"t\":\"mode\",\"mode\":\"pan\",\"on\":false}\n");
assert_eq!(mode, Mode::Orbit);
// The ring's steps add up with the zoom mode's.
feed(&mut shared, &mut mode, "{\"t\":\"zoom\",\"d\":-0.5}\n");
assert_eq!(shared.motion.zoom, 1.5);
}
#[test]
fn views_are_queued_by_their_known_names_only() {
let (mut shared, mut mode) = (Shared::default(), Mode::default());
assert!(feed(&mut shared, &mut mode, "{\"t\":\"view\",\"name\":\"iso\"}\n{\"t\":\"view\",\"name\":\"fit\"}\n"));
assert!(!feed(&mut shared, &mut mode, "{\"t\":\"view\",\"name\":\"ISO\"}\n{\"t\":\"view\",\"name\":\"../etc\"}\n{\"t\":\"view\"}\n"));
assert_eq!(shared.views, ["iso", "fit"]);
let flood = "{\"t\":\"view\",\"name\":\"top\"}\n".repeat(100);
feed(&mut shared, &mut mode, &flood);
assert_eq!(shared.views.len(), MAX_VIEWS);
}
#[test]
fn hostile_lines_never_move_the_view_out_of_range() {
let (mut shared, mut mode) = (Shared::default(), Mode::default());
let lines = [
"not json",
"{\"t\":\"axis\",\"x\":\"5\",\"y\":null}",
"{\"t\":\"axis\",\"x\":1e308,\"y\":-1e308}",
"{\"t\":\"axis\",\"x\":1e999}",
"{\"t\":\"zoom\",\"d\":[1]}",
"{\"t\":42}",
"[1,2,3]",
"{\"t\":\"mode\",\"mode\":{\"x\":1},\"on\":\"yes\"}",
"\u{0}\u{1}\u{2}",
];
for line in lines {
Lines::default().feed(format!("{line}\n").as_bytes(), |l| {
consume(l, &mut mode, &mut shared);
});
}
let limit = solidcraft_ui_egui::space_mouse::BRIDGE_MAX_STEP;
let values = shared.motion.orbit.into_iter().chain(shared.motion.pan).chain([shared.motion.zoom]);
for value in values {
assert!(value.is_finite() && value.abs() <= limit, "{value}");
}
assert_eq!(mode, Mode::Orbit);
// Invalid UTF-8 is no message either.
assert!(!consume(&[0xff, 0xfe, b'{', b'}'], &mut mode, &mut shared));
}
#[test]
fn lines_split_across_reads_and_long_ones_are_skipped_to_their_end() {
let mut got = Vec::new();
let mut lines = Lines::default();
for part in ["{\"t\":", "\"zoom\"}\r\n{\"a\"", ":1}\n"] {
lines.feed(part.as_bytes(), |line| got.push(String::from_utf8_lossy(line).to_string()));
}
assert_eq!(got, ["{\"t\":\"zoom\"}", "{\"a\":1}"]);
got.clear();
let long = vec![b'x'; MAX_LINE * 3];
lines.feed(&long, |line| got.push(String::from_utf8_lossy(line).to_string()));
lines.feed(&long, |line| got.push(String::from_utf8_lossy(line).to_string()));
assert!(lines.line.len() <= MAX_LINE + 1, "never holds more than one line");
lines.feed(b"tail\nnext\n", |line| got.push(String::from_utf8_lossy(line).to_string()));
assert_eq!(got, ["next"], "the long line is dropped to its newline, the next one read");
}
#[test]
fn the_device_app_names_itself_and_the_device_it_leaves_alone() {
let shared = Arc::new(Mutex::new(Shared::default()));
let claim = Claim(Arc::clone(&shared));
let strings = ["STMicroelectronics".to_string(), "MAKMOUSE 3D".to_string()];
assert!(!claim.claims(&strings), "nothing is claimed before an app says so");
let mut mode = Mode::default();
if let Ok(mut shared) = shared.lock() {
let hello = format!("{{\"t\":\"hello\",\"server\":\"Dev\\u0007App {}\",\"device\":\"makmouse\"}}\n", "x".repeat(200));
feed(&mut shared, &mut mode, &hello);
assert_eq!(shared.connected.as_deref().map(|n| n.chars().count()), Some(MAX_NAME));
assert!(shared.connected.as_deref().is_some_and(|n| n.starts_with("DevApp")));
}
assert!(claim.claims(&strings), "the name matches ignoring case");
assert!(!claim.claims(&["3Dconnexion".to_string(), "SpaceMouse Compact".to_string()]), "another 3D mouse stays the reader's");
}
/// Waits until `check` holds, or fails after a few seconds.
fn wait_for(what: &str, mut check: impl FnMut() -> bool) {
let deadline = Instant::now() + Duration::from_secs(5);
while !check() {
assert!(Instant::now() < deadline, "timed out waiting for {what}");
std::thread::sleep(Duration::from_millis(10));
}
}
#[test]
fn a_connection_says_hello_reads_the_stream_and_ends_when_the_bridge_is_switched_off() {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
let port = listener.local_addr().unwrap().port();
let shared = Arc::new(Mutex::new(Shared { wanted: Some(port), ..Default::default() }));
let client = TcpStream::connect((Ipv4Addr::LOCALHOST, port)).unwrap();
let (mut server, _) = listener.accept().unwrap();
let worker = {
let shared = Arc::clone(&shared);
std::thread::spawn(move || serve(client, port, &Context::default(), &shared))
};
let mut hello_line = String::new();
BufReader::new(server.try_clone().unwrap()).read_line(&mut hello_line).unwrap();
let hello: Value = serde_json::from_str(&hello_line).unwrap();
assert_eq!((hello["t"].as_str(), hello["app"].as_str()), (Some("hello"), Some("solidcraft")));
server
.write_all(b"{\"t\":\"hello\",\"server\":\"MAKMOUSE\",\"device\":\"MAKMOUSE\"}\n{\"t\":\"axis\",\"x\":2,\"y\":1}\n{\"t\":\"view\",\"name\":\"top\"}\n")
.unwrap();
wait_for("the stream", || shared.lock().is_ok_and(|s| s.motion.orbit == [2.0, 1.0] && s.views == ["top"]));
assert_eq!(shared.lock().unwrap().connected.as_deref(), Some("MAKMOUSE"));
// Switched off: the connection ends and what it had is forgotten.
shared.lock().unwrap().wanted = None;
worker.join().unwrap();
let after = shared.lock().unwrap();
assert!(after.connected.is_none() && after.device.is_none() && after.motion.is_still() && after.views.is_empty());
}
#[test]
fn a_closed_connection_forgets_the_app() {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
let port = listener.local_addr().unwrap().port();
let shared = Arc::new(Mutex::new(Shared { wanted: Some(port), ..Default::default() }));
let client = TcpStream::connect((Ipv4Addr::LOCALHOST, port)).unwrap();
let (server, _) = listener.accept().unwrap();
let worker = {
let shared = Arc::clone(&shared);
std::thread::spawn(move || serve(client, port, &Context::default(), &shared))
};
wait_for("the connection", || shared.lock().is_ok_and(|s| s.connected.is_some()));
drop(server);
worker.join().unwrap();
let after = shared.lock().unwrap();
assert_eq!((after.connected.as_deref(), after.wanted), (None, Some(port)), "still wanted, so the reader connects again");
}
#[test]
fn each_frame_hands_the_view_the_motion_and_passes_the_preferences_on() {
let mut app = SolveApp::new(solidcraft_engine::Session::default(), solidcraft_ui_egui::Services::default());
app.home.open = false;
let ctx = Context::default();
let _ = ctx.run_ui(egui::RawInput { focused: true, ..Default::default() }, |_| {});
let mut bridge = NavBridge { shared: Arc::new(Mutex::new(Shared::default())) };
let queue = |bridge: &NavBridge| {
let mut shared = bridge.shared.lock().unwrap();
shared.connected = Some("MAKMOUSE".into());
shared.motion = BridgeMotion { orbit: [10.0, 0.0], ..Default::default() };
};
// Off (the default): nothing is wanted, nothing reaches the view.
queue(&bridge);
bridge.tick(&mut app, &ctx);
assert_eq!(bridge.shared.lock().unwrap().wanted, None);
assert!(app.viewport.bridge_motion.is_none() && app.space_mouse.bridge.is_none());
// On: the port is passed to the reader and the motion to the view, once.
app.preferences.nav_bridge_enabled = true;
app.preferences.nav_bridge_port = 9123;
queue(&bridge);
bridge.tick(&mut app, &ctx);
assert_eq!(bridge.shared.lock().unwrap().wanted, Some(9123));
assert_eq!(app.space_mouse.bridge.as_deref(), Some("MAKMOUSE"));
assert_eq!(app.viewport.bridge_motion.map(|m| m.orbit), Some([10.0, 0.0]));
assert!(bridge.shared.lock().unwrap().motion.is_still(), "taken, not handed over twice");
// Port 0 is no port.
app.preferences.nav_bridge_port = 0;
bridge.tick(&mut app, &ctx);
assert_eq!(bridge.shared.lock().unwrap().wanted, None);
}
}
+336 -31
View File
@@ -521,43 +521,175 @@ fn hid_api() -> hidapi::HidResult<HidApi> {
HidApi::new()
}
/// An open 3D-mouse interface: the device, how its reports are laid out, and its vendor and
/// product ids.
#[cfg(any(target_os = "windows", target_os = "macos", test))]
struct Candidate<D> {
device: D,
layout: Layout,
ids: (u16, u16),
/// The device's manufacturer and product strings ([`device_strings`]).
#[cfg_attr(not(any(target_os = "windows", target_os = "macos")), allow(dead_code))]
strings: Vec<String>,
}
/// Input reports read with a timeout in milliseconds: hidapi's devices, and the tests' simulated
/// ones. `Ok(0)` is a timeout; an error means the device is gone.
#[cfg(any(target_os = "windows", target_os = "macos", test))]
trait ReportSource {
fn read_report(&self, report: &mut [u8], timeout_ms: i32) -> Result<usize, String>;
}
#[cfg(any(target_os = "windows", target_os = "macos"))]
fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
impl ReportSource for hidapi::HidDevice {
fn read_report(&self, report: &mut [u8], timeout_ms: i32) -> Result<usize, String> {
self.read_timeout(report, timeout_ms).map_err(|error| error.to_string())
}
}
/// What following the candidates tells the reader.
#[cfg(any(target_os = "windows", target_os = "macos", test))]
enum Followed<'a> {
/// This device is the one read now, so its calibration applies. With the buttons it last
/// reported down, when it reported them while another was read: they are its baseline, so a
/// press that makes it the device in use counts as a press, not as a button already held.
Device((u16, u16), Option<u32>),
/// An input report from it.
Report(&'a Layout, &'a [u8]),
/// It is gone.
Gone,
}
/// Whether a report shows its device in use: an axis off zero or a button down. A cap at rest
/// sends zeros at most, and an idle 3Dconnexion Universal Receiver sends nothing at all.
#[cfg(any(target_os = "windows", target_os = "macos", test))]
fn shows_use(layout: Layout, report: &[u8]) -> bool {
layout.decode(report).is_some_and(|decoded| decoded.axes.iter().flatten().any(|axis| *axis != 0) || decoded.buttons.is_some_and(|down| down != 0))
}
/// Reads every candidate and follows the one in use (#3988). The first is followed from the start,
/// as when it was the only one read. Another takes over once it shows use while the followed one
/// has shown none for `STALE_AFTER`, so two devices moved together don't hand the reading back
/// and forth. An idle Universal Receiver opens fine and never fails; read on its own it kept a
/// SpaceMouse next to it from ever being read. A candidate that fails is dropped. If it was the
/// followed one, the reading is reset and the next takes over. Returns when none is left.
#[cfg(any(target_os = "windows", target_os = "macos", test))]
fn follow<D: ReportSource>(mut candidates: Vec<Candidate<D>>, now: impl Fn() -> Instant, mut emit: impl FnMut(Followed<'_>)) {
let Some(first) = candidates.first() else { return };
emit(Followed::Device(first.ids, None));
let mut followed = 0;
// When the followed device last showed use.
let mut used_at: Option<Instant> = None;
// The buttons each candidate last reported down, by index, as long as it has reported them.
let mut buttons: Vec<Option<u32>> = candidates.iter().map(|_| None).collect();
let mut report = [0_u8; REPORT_BUFFER_BYTES];
while !candidates.is_empty() {
// One device is waited on as before; several take turns, briefly, so none waits behind
// an idle one. Reports queue in the system meanwhile, so none is lost.
let timeout_ms = if candidates.len() == 1 { 100 } else { 10 };
let mut index = 0;
while let Some(candidate) = candidates.get(index) {
match candidate.device.read_report(&mut report, timeout_ms) {
Ok(0) => {}
Ok(size) => {
let bytes = report.get(..size).unwrap_or_default();
let before = buttons.get(index).copied().flatten();
if let (Some(seen), Some(down)) = (buttons.get_mut(index), candidate.layout.decode(bytes).and_then(|decoded| decoded.buttons)) {
*seen = Some(down);
}
if shows_use(candidate.layout, bytes) {
let at = now();
if index != followed && used_at.is_none_or(|used| at.duration_since(used) >= STALE_AFTER) {
let (vendor, product) = candidate.ids;
log::info!("3D mouse: reading {vendor:04x}:{product:04x}, the device in use");
followed = index;
emit(Followed::Device(candidate.ids, before));
}
if index == followed {
used_at = Some(at);
}
}
if index == followed {
emit(Followed::Report(&candidate.layout, bytes));
}
}
Err(error) => {
log::info!("3D mouse disconnected: {error}");
candidates.remove(index);
if index < buttons.len() {
buttons.remove(index);
}
if index == followed {
emit(Followed::Gone);
followed = 0;
used_at = None;
if let Some(next) = candidates.first() {
emit(Followed::Device(next.ids, buttons.first().copied().flatten()));
}
} else if index < followed {
followed -= 1;
}
continue;
}
}
index += 1;
}
}
}
/// The device's manufacturer and product strings, which say whether a device app has it
/// ([`crate::nav_bridge::Claim`]). Asked of the opened 3D mouse only.
#[cfg(any(target_os = "windows", target_os = "macos"))]
fn device_strings(device: &hidapi::HidDevice) -> Vec<String> {
[device.get_manufacturer_string(), device.get_product_string()].into_iter().filter_map(|s| s.ok().flatten()).collect()
}
#[cfg(any(target_os = "windows", target_os = "macos"))]
fn reader(ctx: Context, shared: Arc<Mutex<Reading>>, bridge: crate::nav_bridge::Claim) {
let Ok(mut api) = hid_api() else {
log::warn!("3D mouse: HID initialization failed");
return;
};
loop {
if let Some((device, layout, ids)) = candidates(&mut api).into_iter().find_map(|path| {
let device = api
.open_path(&path)
.inspect_err(|error| {
// On macOS: missing Input Monitoring permission, or the 3DxWare helper holding the device.
// The driver having the device is the reason it cannot be opened, not a problem.
if !DRIVER_CONNECTED.load(Ordering::Relaxed) && !DRIVER_REGISTERED.load(Ordering::Relaxed) {
log::warn!("3D mouse: cannot open the device: {error}");
}
})
.ok()?;
// Read when the device was opened, from this device alone.
let info = device.get_device_info().ok()?;
let layout = device_layout(&device, info.vendor_id())?;
Some((device, layout, (info.vendor_id(), info.product_id())))
}) {
connected(&ctx, &shared, Source::Hid(Some(ids)));
let mut report = [0_u8; REPORT_BUFFER_BYTES];
loop {
match device.read_timeout(&mut report, 100) {
Ok(0) => {}
Ok(size) => publish(&ctx, &shared, layout, report.get(..size).unwrap_or_default()),
Err(error) => {
log::info!("3D mouse disconnected: {error}");
break;
}
let open: Vec<_> = candidates(&mut api)
.into_iter()
.filter_map(|path| {
let device = api
.open_path(&path)
.inspect_err(|error| {
// On macOS: missing Input Monitoring permission, or the 3DxWare helper holding the device.
// The driver having the device is the reason it cannot be opened, not a problem.
if !DRIVER_CONNECTED.load(Ordering::Relaxed) && !DRIVER_REGISTERED.load(Ordering::Relaxed) {
log::warn!("3D mouse: cannot open the device: {error}");
}
})
.ok()?;
// Read when the device was opened, from this device alone.
let info = device.get_device_info().ok()?;
let layout = device_layout(&device, info.vendor_id())?;
let strings = device_strings(&device);
Some(Candidate { device, layout, ids: (info.vendor_id(), info.product_id()), strings })
})
.collect();
let names: Vec<_> = open.iter().map(|candidate| (candidate.ids, candidate.strings.clone())).collect();
// Whether the device read now is one a device app has: it moves the view over the navigation
// bridge, so read here as well the device would move it twice. What was read before ages out.
let mut strings: &[String] = &[];
follow(open, Instant::now, |event| match event {
Followed::Device(ids, buttons) => {
strings = names.iter().find(|(known, _)| *known == ids).map_or(&[][..], |(_, strings)| strings.as_slice());
connected(&ctx, &shared, Source::Hid(Some(ids)));
// Taken over from another device: what it reported meanwhile is its baseline.
if let Some(buttons) = buttons
&& let Ok(mut reading) = shared.lock()
{
reading.set_buttons(buttons);
}
}
reset(&ctx, &shared);
}
Followed::Report(..) if bridge.claims(strings) => {}
Followed::Report(layout, report) => publish(&ctx, &shared, *layout, report),
Followed::Gone => reset(&ctx, &shared),
});
std::thread::sleep(RETRY_AFTER);
}
}
@@ -939,7 +1071,8 @@ pub struct SpaceMouse {
}
impl SpaceMouse {
pub fn start(ctx: &Context) -> Self {
/// Starts the reader. A device that `bridge` says its own app reads is left to that app.
pub fn start(ctx: &Context, bridge: crate::nav_bridge::Claim) -> Self {
let now = Instant::now();
let shared = Arc::new(Mutex::new(Reading::new(now)));
let worker_state = Arc::clone(&shared);
@@ -948,11 +1081,14 @@ impl SpaceMouse {
let drivers = Arc::new(Drivers::default());
#[cfg(target_os = "linux")]
let worker_drivers = Arc::clone(&drivers);
// hidraw names no device's strings to compare (`Claim`).
#[cfg(target_os = "linux")]
let _ = bridge;
let spawned = std::thread::Builder::new().name("solidcraft-3d-mouse".into()).spawn(move || {
#[cfg(target_os = "linux")]
reader(worker_ctx, worker_state, worker_drivers, Machine);
#[cfg(not(target_os = "linux"))]
reader(worker_ctx, worker_state);
reader(worker_ctx, worker_state, bridge);
});
if let Err(error) = spawned {
log::warn!("3D mouse: reader could not start: {error}");
@@ -1294,6 +1430,175 @@ mod tests {
assert!(shared.lock().unwrap().arrived, "but the report did arrive");
}
/// A simulated 3D-mouse interface. Each read takes the next scripted step, a report or a
/// timeout (empty), and sets the shared clock to the step's time. Once the script runs out,
/// the device is unplugged.
struct Simulated<'a> {
clock: &'a std::cell::Cell<Instant>,
start: Instant,
steps: std::cell::RefCell<std::collections::VecDeque<(u64, Vec<u8>)>>,
}
impl ReportSource for Simulated<'_> {
fn read_report(&self, report: &mut [u8], _timeout_ms: i32) -> Result<usize, String> {
let (ms, bytes) = self.steps.borrow_mut().pop_front().ok_or_else(|| "unplugged".to_string())?;
self.clock.set(self.start + Duration::from_millis(ms));
report.get_mut(..bytes.len()).ok_or_else(|| "report too long".to_string())?.copy_from_slice(&bytes);
Ok(bytes.len())
}
}
#[derive(Debug, PartialEq)]
enum Seen {
/// The product id now read.
Device(u16),
/// The buttons it had reported down before it was read, its baseline.
Baseline(u32),
/// A report published, the product id read when it was, and whether it showed use.
Report(u16, Vec<u8>, bool),
Gone,
}
/// Follows simulated devices, given as (product id, scripted steps in ms), and returns what
/// the reader was told.
fn follow_simulated(devices: &[(u16, &[(u64, &[u8])])]) -> Vec<Seen> {
follow_simulated_with(Layout::default(), devices)
}
/// As `follow_simulated`, with every device's reports laid out as `layout`.
fn follow_simulated_with(layout: Layout, devices: &[(u16, &[(u64, &[u8])])]) -> Vec<Seen> {
let start = Instant::now();
let clock = std::cell::Cell::new(start);
let candidates = devices
.iter()
.map(|(product, steps)| Candidate {
device: Simulated {
clock: &clock,
start,
steps: std::cell::RefCell::new(steps.iter().map(|(ms, bytes)| (*ms, bytes.to_vec())).collect()),
},
layout,
ids: (0x256f, *product),
strings: Vec::new(),
})
.collect();
let (mut seen, mut read) = (Vec::new(), 0);
follow(
candidates,
|| clock.get(),
|event| match event {
Followed::Device((_, product), buttons) => {
read = product;
seen.push(Seen::Device(product));
seen.extend(buttons.map(Seen::Baseline));
}
Followed::Report(layout, bytes) => seen.push(Seen::Report(read, bytes.to_vec(), shows_use(*layout, bytes))),
Followed::Gone => seen.push(Seen::Gone),
},
);
seen
}
const RECEIVER: u16 = 0xc652;
const SPACEMOUSE: u16 = 0xc63a;
const PUSH_X: &[u8] = &[1, 100, 0, 0, 0, 0, 0];
const AT_REST: &[u8] = &[1, 0, 0, 0, 0, 0, 0];
const TILT: &[u8] = &[2, 0, 0, 50, 0, 0, 0];
/// #3988: a Universal Receiver with nothing paired is found first and never fails, and a wired
/// SpaceMouse next to it was never read. The receiver is followed only until the SpaceMouse
/// moves; the SpaceMouse's reports, its zeros included, are read from then on.
#[test]
fn an_idle_receiver_does_not_keep_a_spacemouse_from_being_read() {
let receiver: &[(u64, &[u8])] = &[(0, &[]), (10, &[]), (20, &[]), (30, &[])];
let spacemouse: &[(u64, &[u8])] = &[(5, &[]), (15, PUSH_X), (25, AT_REST), (35, TILT)];
assert_eq!(
follow_simulated(&[(RECEIVER, receiver), (SPACEMOUSE, spacemouse)]),
[
Seen::Device(RECEIVER),
Seen::Device(SPACEMOUSE),
Seen::Report(SPACEMOUSE, PUSH_X.to_vec(), true),
Seen::Report(SPACEMOUSE, AT_REST.to_vec(), false),
Seen::Report(SPACEMOUSE, TILT.to_vec(), true),
// The receiver is unplugged first, then the SpaceMouse, the one being read.
Seen::Gone,
]
);
}
/// Two devices in use at once don't trade the reading: the other takes over only after the
/// followed one has shown no use for `STALE_AFTER`. A report at rest or with an id the layout
/// doesn't know shows no use, and only the followed device's reports are published.
#[test]
fn another_device_takes_over_only_once_the_followed_one_is_still() {
let unknown_id: &[u8] = &[0x17, 80];
let first: &[(u64, &[u8])] = &[(0, PUSH_X), (100, AT_REST), (200, &[])];
let second: &[(u64, &[u8])] = &[(50, PUSH_X), (150, unknown_id), (250, TILT)];
assert_eq!(
follow_simulated(&[(0xc635, first), (SPACEMOUSE, second)]),
[
Seen::Device(0xc635),
Seen::Report(0xc635, PUSH_X.to_vec(), true),
// The second moves 50 ms after the first did: not yet. The first's own rest
// report is read, and the second's unknown report is not.
Seen::Report(0xc635, AT_REST.to_vec(), false),
// 250 ms after the first last moved, the second takes over.
Seen::Device(SPACEMOUSE),
Seen::Report(SPACEMOUSE, TILT.to_vec(), true),
Seen::Gone,
]
);
}
/// One device is read as before: every report, until it fails. When the followed one of
/// several fails, the reading is reset and the next one is read.
#[test]
fn a_device_that_fails_hands_the_reading_to_the_next() {
let only: &[(u64, &[u8])] = &[(0, &[]), (1, AT_REST), (2, &[0x17, 80])];
assert_eq!(
follow_simulated(&[(SPACEMOUSE, only)]),
[
Seen::Device(SPACEMOUSE),
Seen::Report(SPACEMOUSE, AT_REST.to_vec(), false),
Seen::Report(SPACEMOUSE, vec![0x17, 80], false),
Seen::Gone
]
);
let unplugged: &[(u64, &[u8])] = &[];
let next: &[(u64, &[u8])] = &[(1, PUSH_X)];
assert_eq!(
follow_simulated(&[(SPACEMOUSE, unplugged), (RECEIVER, next)]),
[Seen::Device(SPACEMOUSE), Seen::Gone, Seen::Device(RECEIVER), Seen::Report(RECEIVER, PUSH_X.to_vec(), true), Seen::Gone]
);
assert_eq!(follow_simulated(&[]), []);
}
/// A button press that makes a device the one in use is a press. The buttons it reported while
/// another was read are its baseline; the reader keeps them, so the press isn't taken for a
/// button already held, as a device's first button report is.
#[test]
fn a_press_that_takes_over_counts_as_a_press() {
let mut layout = Layout::default();
layout.buttons[0] =
Some(solidcraft_space_mouse::Field { report_id: 3, bit_offset: 0, bits: 1, logical_min: 0, logical_max: 1, relative: false });
let (none_down, first_down): (&[u8], &[u8]) = (&[3, 0], &[3, 1]);
let receiver: &[(u64, &[u8])] = &[(0, &[]), (10, &[]), (20, &[])];
let spacemouse: &[(u64, &[u8])] = &[(5, none_down), (15, first_down), (25, &[])];
assert_eq!(
follow_simulated_with(layout, &[(RECEIVER, receiver), (SPACEMOUSE, spacemouse)]),
[Seen::Device(RECEIVER), Seen::Device(SPACEMOUSE), Seen::Baseline(0), Seen::Report(SPACEMOUSE, first_down.to_vec(), true), Seen::Gone,]
);
// What the reader does with that: the press after the baseline is a tap.
let mut reading = Reading { buttons_known: false, ..Reading::new(Instant::now()) };
reading.set_buttons(0);
assert!(decode_report(first_down, layout, &mut reading, Instant::now()));
assert_eq!((reading.buttons, reading.taps), (1, 1));
// Without one, the same report is the baseline: held, not pressed.
let mut unseen = Reading { buttons_known: false, ..Reading::new(Instant::now()) };
assert!(decode_report(first_down, layout, &mut unseen, Instant::now()));
assert_eq!((unseen.buttons, unseen.taps), (1, 0));
}
#[test]
fn a_held_reading_stays_until_the_next_one_and_a_hid_reading_goes_stale() {
let now = Instant::now();
+327 -8
View File
@@ -8,8 +8,9 @@
//! row the next launch starts in safe mode by itself.
//! - **Safe mode** (`--safe-mode` or `SOLIDCRAFT_SAFE_MODE=1`): default settings (the saved ones
//! are left untouched), no recovery offer, the software renderer and CPU meshing.
//! - **No usable graphics device**: one retry on the software renderer
//! (`SOLIDCRAFT_RENDERER=cpu`), then a message box saying what failed and what to try.
//! - **A renderer that cannot start**: on Windows, try the remaining graphics APIs in order,
//! then the software renderer (`SOLIDCRAFT_RENDERER=cpu`). An explicit `WGPU_BACKEND` fails
//! cleanly without switching. Exhausted retries show a message box with the error.
use std::path::{Path, PathBuf};
@@ -129,6 +130,48 @@ pub fn pick_cpu_adapter(adapters: &[eframe::wgpu::Adapter], surface: Option<&efr
})
}
/// Adapter properties used to choose a card without opening a device.
#[derive(Clone, Debug)]
pub struct AdapterChoice {
pub name: String,
pub compatible: bool,
}
/// Choose only surface-compatible adapters. Names saved in preferences are exact; the
/// environment override follows wgpu's case-insensitive substring convention.
/// No match leaves selection to the normal wgpu request.
pub fn choose_adapter(adapters: &[AdapterChoice], environment: Option<&str>, preference: Option<&str>) -> Option<usize> {
// wgpu's environment helper panics on a missing name; an override must remain recoverable.
let compatible = || adapters.iter().enumerate().filter(|(_, a)| a.compatible);
if let Some(name) = environment.filter(|s| !s.is_empty()) {
let name = name.to_lowercase();
if let Some((index, _)) = compatible().find(|(_, a)| a.name.to_lowercase().contains(&name)) {
return Some(index);
}
}
if let Some(name) = preference.filter(|s| !s.is_empty())
&& let Some((index, _)) = compatible().find(|(_, a)| a.name == name)
{
return Some(index);
}
None
}
/// Leave Automatic on wgpu's normal request path; named choices may defer to it too.
pub fn configure_adapter(setup: &mut eframe::egui_wgpu::WgpuSetupCreateNew, cpu: bool, environment: Option<String>, preference: Option<String>) {
if cpu {
setup.native_adapter_selector = Some(std::sync::Arc::new(|adapters, surface| pick_cpu_adapter(adapters, surface).map(Some)));
} else if environment.as_deref().is_some_and(|s| !s.is_empty()) || preference.as_deref().is_some_and(|s| !s.is_empty()) {
setup.native_adapter_selector = Some(std::sync::Arc::new(move |adapters, surface| {
let choices: Vec<_> = adapters
.iter()
.map(|a| AdapterChoice { name: a.get_info().name, compatible: surface.is_none_or(|s| a.is_surface_supported(s)) })
.collect();
Ok(choose_adapter(&choices, environment.as_deref(), preference.as_deref()).and_then(|i| adapters.get(i)).cloned())
}));
}
}
/// `WGPU_BACKEND`: when set, wgpu picks the backends from it and the order below is not used.
pub const WGPU_BACKEND_ENV: &str = "WGPU_BACKEND";
@@ -176,6 +219,34 @@ pub fn choose_backends(
Some(WINDOWS_ORDER.into_iter().find(|b| has_hardware(*b)).unwrap_or(Backends::DX12))
}
/// Internal, process-local retry choice; never written to the graphics preference.
pub const RETRY_BACKEND_ENV: &str = "SOLIDCRAFT_STARTUP_BACKEND";
/// Why this launch is using a fallback, carried to the new process for status and Preferences.
pub const FALLBACK_NOTE_ENV: &str = "SOLIDCRAFT_GRAPHICS_FALLBACK";
/// A handled startup failure tries each remaining Windows API once, then software rendering.
/// An explicit environment choice is final, including when software rendering was requested.
pub fn fallback_backend(windows: bool, explicit: bool, cpu: bool, failed: eframe::wgpu::Backends) -> Option<&'static str> {
if explicit || cpu {
return None;
}
if windows {
let last = WINDOWS_ORDER.iter().rposition(|backend| failed.intersects(*backend));
if let Some(next) = last.and_then(|i| WINDOWS_ORDER.get(i + 1)) {
return Some(if *next == eframe::wgpu::Backends::VULKAN { "vulkan" } else { "gl" });
}
}
Some("cpu")
}
/// Keep driver diagnostics on one bounded line, including across the finite retry chain.
pub fn fallback_note(previous: &str, failed: eframe::wgpu::Backends, next: &str, error: &str) -> String {
let previous: String = previous.chars().take(2048).collect();
let error: String = error.chars().take(1024).collect();
let line = format!("{previous} Graphics fallback: {failed:?} failed ({error}); trying {next}.");
line.split_whitespace().collect::<Vec<_>>().join(" ").chars().take(4096).collect()
}
/// 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;
@@ -184,7 +255,7 @@ pub const FLOAT_DEPTH_STENCIL: eframe::wgpu::Features = eframe::wgpu::Features::
/// 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();
let mut desc = solidcraft_gpu::instance_descriptor_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));
@@ -201,6 +272,7 @@ pub fn has_hardware_adapter(backends: eframe::wgpu::Backends) -> bool {
let mut desc = eframe::wgpu::InstanceDescriptor::new_without_display_handle();
desc.backends = backends;
desc.flags = eframe::wgpu::InstanceFlags::from_build_config().with_env();
desc.backend_options.dx12.shader_compiler = solidcraft_gpu::dx12_compiler();
let instance = eframe::wgpu::Instance::new(desc);
let adapters = pollster::block_on(instance.enumerate_adapters(backends));
let found: Vec<String> = adapters.iter().map(|a| format!("{} ({:?})", a.get_info().name, a.get_info().device_type)).collect();
@@ -208,6 +280,102 @@ pub fn has_hardware_adapter(backends: eframe::wgpu::Backends) -> bool {
adapters.iter().any(|a| a.get_info().device_type != eframe::wgpu::DeviceType::Cpu)
}
/// The environment variable that switches wgpu's indirect-call validation on or off.
pub const INDIRECT_VALIDATION_ENV: &str = "WGPU_VALIDATION_INDIRECT_CALL";
/// The instance flags for the window. wgpu's indirect-call validation compiles a compute shader
/// when the device is created, and on Intel Macs (macOS 12 with Intel Iris graphics, #3024,
/// #3080, #4605) the Metal compiler refuses it: the device is lost and the window never opens.
/// So on an Intel Mac the validation is left off, unless the user turned it on or off with
/// `WGPU_VALIDATION_INDIRECT_CALL` themselves.
pub fn window_instance_flags(flags: eframe::wgpu::InstanceFlags, intel_mac: bool, env: Option<&str>) -> eframe::wgpu::InstanceFlags {
if intel_mac && env.map(str::trim).is_none_or(str::is_empty) { flags - eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL } else { flags }
}
/// The environment variable that makes the macOS compatibility shim report an older OS
/// version to wgpu's Metal backend. On OCLP-patched machines the Metal compiler accepts
/// only up to the MSL standard of the original GPU, not the one the patched OS reports,
/// so wgpu picks a standard that the compiler refuses (`invalid value 'metal3.2'`).
/// Setting this variable before the process starts causes the OS to report the older
/// version and wgpu selects a supported MSL standard (#3080, #4605).
#[cfg(target_os = "macos")]
pub const SYSTEM_VERSION_COMPAT_ENV: &str = "SYSTEM_VERSION_COMPAT";
/// The NVRAM variable that OpenCore Legacy Patcher writes on every OCLP-booted system.
/// Its presence is the primary signal that `SYSTEM_VERSION_COMPAT=1` is needed.
#[cfg(target_os = "macos")]
pub const OCLP_NVRAM_KEY: &str = "4D1FDA02-38C7-4A6A-9CC6-4BCCA8B30102:OCLP-Version";
/// `true` if the OCLP NVRAM key is present on this system.
///
/// Runs `nvram <key>` and treats exit 0 as present. Any error (missing binary,
/// permission denied, key absent) returns `false` — this is a best-effort probe.
#[cfg(target_os = "macos")]
pub fn oclp_nvram_present() -> bool {
std::process::Command::new("nvram").arg(OCLP_NVRAM_KEY).output().map(|o| o.status.success()).unwrap_or(false)
}
/// Whether to re-launch with `SYSTEM_VERSION_COMPAT=1`: it is not set yet and OCLP shows through
/// the NVRAM key. The probe runs only when it is needed, so a re-launched process (variable
/// set) never starts `nvram`.
#[cfg(target_os = "macos")]
fn compat_wanted(compat_set: bool, nvram: impl FnOnce() -> bool) -> bool {
!compat_set && nvram()
}
/// If this process is running under OCLP and `SYSTEM_VERSION_COMPAT` has not been set,
/// re-launch the current binary with `SYSTEM_VERSION_COMPAT=1` added to the environment,
/// then exit with the child's status code.
///
/// `SYSTEM_VERSION_COMPAT=1` must be present **before** the process starts: setting it
/// with `std::env::set_var` inside `main` is too late because the macOS frameworks have
/// already queried the OS version by then. Re-launching is the only safe approach that
/// works without `unsafe` (a true `execve` replacement would require it).
///
/// Returns `Ok(false)` immediately when:
/// - `SYSTEM_VERSION_COMPAT` is already set (re-launched process, no loop), or
/// - the OCLP NVRAM key is absent.
///
/// Returns `Err` only when `std::env::current_exe()` fails or the child process cannot
/// be spawned; the caller should log the error and continue normally.
///
/// Never returns `Ok(true)`: a successful re-launch exits the current process.
#[cfg(target_os = "macos")]
pub fn relaunch_with_version_compat() -> Result<bool, String> {
// A set variable means this is the re-launched process: no loop.
if !compat_wanted(std::env::var_os(SYSTEM_VERSION_COMPAT_ENV).is_some(), oclp_nvram_present) {
return Ok(false);
}
log::info!("startup: OCLP detected; re-launching with {}=1", SYSTEM_VERSION_COMPAT_ENV);
eprintln!("SolidCraft: OCLP detected; re-launching with {}=1", SYSTEM_VERSION_COMPAT_ENV);
let exe = std::env::current_exe().map_err(|e| format!("could not find own executable: {e}"))?;
let status = std::process::Command::new(&exe)
.args(std::env::args_os().skip(1))
.env(SYSTEM_VERSION_COMPAT_ENV, "1")
.status()
.map_err(|e| format!("could not re-launch with {}: {e}", SYSTEM_VERSION_COMPAT_ENV))?;
std::process::exit(status.code().unwrap_or(1));
}
/// Windows with OpenGL: the window keeps the system title bar (#2063). The window without OS
/// decorations keeps its caption style while having no frame, and some OpenGL drivers (older
/// Intel HD/UHD) then show the picture a caption's height lower than the window, so it no longer
/// lines up with the pointer. Direct3D 12 and Vulkan draw it in place and keep the application bar
/// as the title bar. `backends` are the ones wgpu starts: OpenGL alone is decided before the
/// window opens.
pub fn system_title_bar(windows: bool, backends: eframe::wgpu::Backends) -> bool {
use eframe::wgpu::Backends;
windows && backends.contains(Backends::GL) && !backends.intersects(Backends::DX12 | Backends::VULKAN)
}
/// The `--max-file-size` value (#4086): a size such as `2GB` or `1500MB`, at most 16 GB. It raises
/// the largest STEP/import and design file this run reads, over the saved preference, without
/// saving anything. A missing or bad value stops the start with this error.
pub fn max_file_size(value: Option<&str>) -> Result<u64, String> {
let value = value.ok_or("--max-file-size needs a size, for example --max-file-size 2GB")?;
solidcraft_engine::io::limits::parse_flag(value).map_err(|e| format!("--max-file-size: {e}"))
}
/// The message shown when no window could be opened at all.
pub fn no_window_message(error: &str, settings: Option<&Path>) -> String {
let folder = settings.map(|d| d.display().to_string()).unwrap_or_else(|| "the SolidCraft settings folder".into());
@@ -226,6 +394,58 @@ pub fn no_window_message(error: &str, settings: Option<&Path>) -> String {
mod tests {
use super::*;
fn cards() -> Vec<AdapterChoice> {
["Integrated", "Discrete small", "Discrete large"].into_iter().map(|name| AdapterChoice { name: name.into(), compatible: true }).collect()
}
#[test]
fn max_file_size_parses_sizes_and_refuses_bad_ones() {
assert_eq!(max_file_size(Some("2GB")), Ok(2 << 30));
assert_eq!(max_file_size(Some("1500MB")), Ok(1500 << 20));
for bad in [None, Some(""), Some("2"), Some("17GB"), Some("lots"), Some("-1GB")] {
let error = max_file_size(bad).unwrap_err();
assert!(error.contains("--max-file-size"), "{bad:?}: {error}");
}
}
#[test]
fn adapter_names_environment_override_and_missing_card_fallback() {
let cards = cards();
assert_eq!(choose_adapter(&cards, None, Some("Discrete small")), Some(1));
assert_eq!(choose_adapter(&cards, Some("LARGE"), Some("Discrete small")), Some(2));
assert_eq!(choose_adapter(&cards, None, Some("removed")), None);
assert_eq!(choose_adapter(&cards, Some("missing"), Some("Discrete small")), Some(1));
assert_eq!(choose_adapter(&cards, Some(""), Some("Discrete small")), Some(1));
assert_eq!(choose_adapter(&cards, None, Some("Discrete")), None, "saved names are exact");
}
#[test]
fn automatic_adapter_leaves_selection_to_wgpu() {
let mut cards = cards();
assert_eq!(choose_adapter(&cards, None, None), None);
assert_eq!(choose_adapter(&cards, Some(""), Some("")), None);
cards[2].compatible = false;
assert_eq!(choose_adapter(&cards, Some("large"), Some("Discrete large")), None);
assert_eq!(choose_adapter(&cards, Some("small"), None), Some(1));
assert_eq!(choose_adapter(&[], None, None), None);
}
#[test]
fn automatic_adapter_preserves_the_previous_request_configuration() {
use eframe::egui_wgpu::WgpuSetupCreateNew;
let previous = WgpuSetupCreateNew::without_display_handle();
for preference in [None, Some(String::new())] {
let mut setup = WgpuSetupCreateNew::without_display_handle();
configure_adapter(&mut setup, false, None, preference);
assert!(setup.native_adapter_selector.is_none(), "Automatic must use the normal wgpu request");
assert_eq!(setup.power_preference, previous.power_preference);
assert_eq!(setup.instance_descriptor.backends, previous.instance_descriptor.backends);
}
let mut setup = WgpuSetupCreateNew::without_display_handle();
configure_adapter(&mut setup, false, Some("missing".into()), Some("removed".into()));
assert!(setup.native_adapter_selector.unwrap()(&[], None).unwrap().is_none(), "missing names must use the normal request too");
}
fn scratch(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("solidcraft-startup-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
@@ -246,6 +466,40 @@ mod tests {
assert!(settings_directory(Some(file.as_os_str()), Some(default)).is_err());
}
#[test]
fn intel_macs_start_without_indirect_call_validation() {
use eframe::wgpu::InstanceFlags as F;
let on = F::VALIDATION | F::VALIDATION_INDIRECT_CALL;
assert_eq!(window_instance_flags(on, true, None), F::VALIDATION);
assert_eq!(window_instance_flags(on, true, Some(" ")), F::VALIDATION);
// The user's own choice stands, on or off.
assert_eq!(window_instance_flags(on, true, Some("1")), on);
assert_eq!(window_instance_flags(F::VALIDATION, true, Some("0")), F::VALIDATION);
// Every other machine keeps what it had.
assert_eq!(window_instance_flags(on, false, None), on);
}
#[cfg(target_os = "macos")]
#[test]
fn oclp_relaunch_needs_the_signal_and_no_compat_variable() {
use super::compat_wanted;
let probed = std::cell::Cell::new(false);
let probe = |found: bool| {
let probed = &probed;
move || {
probed.set(true);
found
}
};
// The re-launched process has the variable: no probe, no second re-launch.
assert!(!compat_wanted(true, probe(true)));
assert!(!probed.get());
// The OCLP NVRAM key is the signal.
assert!(compat_wanted(false, probe(true)));
// No signal: an ordinary Mac starts as it is.
assert!(!compat_wanted(false, probe(false)));
}
#[test]
fn switches_read_from_the_environment() {
assert!(env_on(Some("1")) && env_on(Some("yes")) && !env_on(Some("0")) && !env_on(Some(" ")) && !env_on(None) && !env_on(Some("false")));
@@ -322,6 +576,54 @@ mod tests {
assert_eq!(choose_backends(true, false, false, Some(""), probe(&[Backends::GL])), Some(Backends::GL));
}
#[test]
fn failed_windows_backends_fall_back_in_order_and_stop_after_software() {
use eframe::wgpu::Backends as B;
assert_eq!(fallback_backend(true, false, false, B::DX12), Some("vulkan"));
assert_eq!(fallback_backend(true, false, false, B::VULKAN), Some("gl"));
assert_eq!(fallback_backend(true, false, false, B::GL), Some("cpu"));
assert_eq!(fallback_backend(true, false, true, B::DX12), None);
assert_eq!(fallback_backend(true, false, false, B::DX12 | B::VULKAN), Some("gl"));
assert_eq!(fallback_backend(false, false, false, B::VULKAN), Some("cpu"));
let note = fallback_note("", B::DX12, "vulkan", "egui_pipeline\n0x80070057");
assert!(note.contains("egui_pipeline 0x80070057") && note.contains("vulkan") && !note.contains('\n'));
let long = fallback_note(&"x".repeat(5000), B::VULKAN, "gl", &"y".repeat(5000));
assert!(long.chars().count() <= 4096 && long.ends_with("trying gl."));
}
#[test]
fn explicit_backend_failure_never_switches_api_or_retries_software() {
use eframe::wgpu::Backends as B;
for windows in [false, true] {
for cpu in [false, true] {
for backend in [B::DX12, B::VULKAN, B::GL, B::all()] {
assert_eq!(fallback_backend(windows, true, cpu, backend), None);
let mut probed = false;
assert_eq!(
choose_backends(windows, true, cpu, Some("vulkan"), |_| {
probed = true;
false
}),
None
);
assert!(!probed);
}
}
}
}
#[test]
fn only_opengl_on_windows_gets_the_system_title_bar() {
use eframe::wgpu::Backends;
assert!(system_title_bar(true, Backends::GL));
for b in [Backends::DX12, Backends::VULKAN, Backends::DX12 | Backends::GL, Backends::VULKAN | Backends::GL, Backends::all()] {
assert!(!system_title_bar(true, b), "{b:?}");
}
for b in [Backends::GL, Backends::VULKAN, Backends::METAL] {
assert!(!system_title_bar(false, b), "{b:?}");
}
}
#[test]
fn wgpu_backend_the_preference_and_the_software_renderer_override_the_order() {
use eframe::wgpu::Backends;
@@ -346,9 +648,16 @@ mod tests {
#[test]
fn probing_a_backend_alone_never_panics() {
// Whatever this machine has (often nothing): an answer, not a crash.
for b in WINDOWS_ORDER {
let _ = has_hardware_adapter(b);
// Whatever this machine has (often nothing): an answer, not a crash. On Windows the probe
// goes the way the app's does, stopping at the first backend with a GPU: a machine with
// D3D12 never loads its OpenGL driver into the test process, and AMD's (`atio6axx.dll`)
// aborted the process as it exited (#1156).
if cfg!(target_os = "windows") {
let _ = choose_backends(true, false, false, None, has_hardware_adapter);
} else {
for b in WINDOWS_ORDER {
let _ = has_hardware_adapter(b);
}
}
}
@@ -361,8 +670,18 @@ mod tests {
#[test]
fn the_software_adapter_is_preferred_and_its_absence_is_an_error_not_a_panic() {
assert!(pick_cpu_adapter(&[], None).unwrap_err().contains("none"));
let instance = eframe::wgpu::Instance::new(eframe::wgpu::InstanceDescriptor::new_without_display_handle());
let adapters = pollster::block_on(instance.enumerate_adapters(eframe::wgpu::Backends::all()));
// The adapters the software renderer chooses among: it starts D3D12 alone on Windows
// (WARP), not every backend, so the test doesn't load the OpenGL driver either (#1156).
let options = crate::wgpu_options(true, None);
let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &options.wgpu_setup else { panic!("the software renderer makes its own device") };
let backends = setup.instance_descriptor.backends;
if cfg!(target_os = "windows") && std::env::var_os(WGPU_BACKEND_ENV).is_none() {
assert_eq!(backends, eframe::wgpu::Backends::DX12);
}
let mut desc = solidcraft_gpu::instance_descriptor_from_env();
desc.backends = backends;
let instance = eframe::wgpu::Instance::new(desc);
let adapters = pollster::block_on(instance.enumerate_adapters(backends));
if adapters.is_empty() {
return;
}
+2 -1
View File
@@ -25,6 +25,7 @@ assets/
fonts/{liberation-sans,biz-udp-gothic}/ # unmodified fonts and OFL licences
locales/{en,de,ja,ko}.json # shared offline catalogs
platforms/{macos,linux,windows}/ # bundle, freedesktop and WiX templates
platforms/macos/dmg/ # disk image window: layout, dmgbuild settings, background art
archive/icons/macos-v1/
source/icon.vectorcraft # earlier editable work, retained
# redundant historical exports are retired
@@ -75,7 +76,7 @@ The CLI still supports arbitrary PNG/ICO exports with `solidcraft-cli icon`.
| macOS | Composer master and paired exports; bundle installs `Assets.car` and `SolidCraft.icns` |
| Windows executable | `solidcraft-executable.ico`: original DIB/mask representations at 16/24/32/48/64/128/256 px |
| Windows installer | `solidcraft.ico`: PNG representations at 16/20/24/32/40/48/64/128/256 px; `SOLIDCRAFT_ICONS` override retained |
| Linux, Nix, deb/rpm, tar, Flatpak, Snap | Copy canonical hicolor images; icon name stays `ai.storyteller.solidcraft` |
| Linux, Nix, deb/rpm, tar, Flatpak, Snap | Copy canonical hicolor images; icon name stays `io.tangencylabs.solidcraft` |
| AppImage | Uses the Linux 256 px image and `.DirIcon` alias |
| FreeBSD | Shares Linux hicolor images and freedesktop metadata |
| Web | HTML/Trunk and `manifest.webmanifest` stay in `apps/solidcraft-web/`; copies Japanese font/licence to `fonts/`, uses the collected CSS, and copies the Linux 32/256/512 px images to `icons/` for the favicon and installed app |
+1 -1
View File
@@ -1,5 +1,5 @@
//! Built-in model appearances: names, sRGB colours and metalness.
pub const LIBRARY: [(&str, [u8; 3], f64); 20] = [
pub const LIBRARY: &[(&str, [u8; 3], f64)] = &[
("Aluminum - Satin", [204, 208, 214], 1.0),
("Aluminum - Anodized Black", [44, 46, 50], 1.0),
("Aluminum - Anodized Blue", [52, 92, 160], 1.0),
+1 -1
View File
@@ -1,7 +1,7 @@
//! Original illustrative opaque finishes; not measured optical or engineering data.
//! (name, sRGB color, metal fraction, perceptual roughness). IOR is an illustrative 1.5.
//! Separate names preserve the scalar library's appearance and saved assignments.
pub const OPAQUE_FINISHES: [(&str, [u8; 3], f64, f64); 6] = [
pub const OPAQUE_FINISHES: &[(&str, [u8; 3], f64, f64)] = &[
("Steel - Satin PBR", [158, 164, 172], 1.0, 0.4),
("Aluminum - Satin PBR", [204, 208, 214], 1.0, 0.3),
("Copper - Clean PBR", [204, 122, 80], 1.0, 0.25),
+1 -1
View File
@@ -16,7 +16,7 @@ const fn glass(transmission_linear_rgb: [f64; 3], ior: f64, reference_path_mm: f
GlassRecipe { transmission_linear_rgb, dielectric_ior: ior, reference_path_mm, boundary }
}
pub const STARTER_PACK: [VisualPreset; 40] = [
pub const STARTER_PACK: &[VisualPreset] = &[
VisualPreset::opaque(
"starter.wood.oak-honey",
"Wood - Honey Oak Uniform Preview",
Binary file not shown.
+96
View File
@@ -0,0 +1,96 @@
Copyright 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'.
Copyright 2014-2021 Google Inc. with Reserved Font Name 'Noto'.
Subset for SolidCraft web UI (glyphs used by the zh locale); renamed Craft UI SC (no Reserved Font Name).
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.
+96
View File
@@ -0,0 +1,96 @@
Copyright 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'.
Copyright 2014-2021 Google Inc. with Reserved Font Name 'Noto'.
Subset for SolidCraft web UI (glyphs used by the zh-TW locale); renamed Craft UI TC (no Reserved Font Name).
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.
+93
View File
@@ -0,0 +1,93 @@
# Render workspace icon drafts
All ids below are **Open** for owner review, except `render_scene` and `render_image`,
which are existing accepted icons used only as references. No commands, toolbar
entries, browser selectors or runtime request manifests are changed by this draft set.
`render_remove_override` is the one remaining alias (of `render_from_material`);
`render_execute`, `render_environment` and `render_fit_product` now have their own
masters in `design/svg/model/`. Accepted source masters stay unchanged.
Review with `python3 -B assets/icons/ui/design/tools/icon_status.py --serve`,
search `render_`, and select Open. See [STYLE_GUIDE.md](STYLE_GUIDE.md) for
acceptance; drafts do not imply implemented commands.
The finished references in each row were read and previewed in both themes
before authoring. Draft contact sheets (96, 24 and 16 px, both themes) are local
review evidence, not shipped assets. The icons are grouped in five families, each
with one shared design rule.
## Lights
Rule: a slate source glyph with light passive fill, 2.55-unit outlines and a dotted
accent stem (three dots, 4.25 diameter, 7 units apart) showing the direction.
| Meaning | Id | Finished references | Drawing |
|---|---|---|---|
| Light area | `render_light_area` | `box`, `camera` | Slanted flat panel on the canonical axes above the dotted stem. |
| Light spot | `render_light_spot` | `camera`, `material` | Cone with a curved rim above the dotted stem. |
| Light point | `render_light_point` | `camera`, `material` | Round source disc with a centre dot above the dotted stem. |
| Sun | `render_sun` | `sphere`, `camera` | Source disc of the same size with eight short radial rays instead of a stem. |
| Light by highlight | `render_light_by_highlight` | `sphere`, `material` | Shaded sphere with a ringed highlight target; the dotted stem arrives from above. |
| Solo light | `render_solo_light` | `fit`, `camera` | Point source and stem inside four corner focus brackets. |
## Looks
Rule: the lit look sphere (top-left highlight, bottom-right shade) plus one cue that
says what happens to it; receiving bodies stay canonical grey or cube shapes.
| Meaning | Id | Finished references | Drawing |
|---|---|---|---|
| Appearance | `render_appearance` | `material`, `box` | Shaded look sphere on the top corner of an accent-shaded cube: a look assigned to a body. |
| Paint | `render_paint` | `material`, `paste` | Flat brush with an accent tip, light ferrule and slate handle. |
| Pick up | `render_pick_up` | `material`, `copy` | Eyedropper with a grey bulb, slate crossbar and accent sampling tip. |
| Replace all | `render_replace_all` | `copy`, `box` | Grey sample sphere with an arrow to an accent sphere, beside a second accent sphere. |
| Copy look | `render_copy_look` | `material`, `copy` | Look sphere in a square frame, offset from a second frame outline. |
| Remove override | `render_remove_override` | `appearance`, `box` | Alias of `render_from_material`: the custom look is crossed off. |
| Same look | `render_same_look` | `select`, `box` | Look sphere and three cubes; the two matching cubes are ringed. |
| From material | `render_from_material` | `material`, `extrude` | Look sphere struck through above a plain grey slab, so the material shows. |
## Studio
Rule: the standard grey cube stands in the set-up it is lit or placed in.
| Meaning | Id | Finished references | Drawing |
|---|---|---|---|
| Library | `render_library` | `library`, `copy` | Row of three look spheres, the middle one accent, on a shelf bar. |
| Lighting presets | `render_lighting_presets` | `new`, `render_scene` | Two stacked cards, the front one holding a light bulb. |
| Environment | `render_environment` | `environment_map`, `render_scene` | Dome with a grey cube inside. |
| Ground | `render_ground` | `box`, `render_scene` | Grey cube on a slab with an accent contact patch. |
| Backdrop | `render_backdrop` | `render_scene`, `render_image` | Grey cube in front of a tall upright accent panel. |
## Camera and shots
Rule: one chunky camera body with a lower-right round badge for shot actions; the
other camera icons are simple frames.
| Meaning | Id | Finished references | Drawing |
|---|---|---|---|
| Shot | `render_shot` | `camera`, `new` | Camera body with a plus badge: save a shot. |
| Update shot | `render_update_shot` | `camera`, `undo` | Camera body with a refresh badge. |
| Render all shots | `render_all_shots` | `copy`, `render_image` | Stacked camera bodies with a play badge. |
| Shots strip | `render_shots_strip` | `camera`, `copy` | Strip of three small frames, the middle one in accent. |
| Fit product | `render_fit_product` | `fit`, `camera` | Grey cube inside four corner brackets. |
| Lens / field of view | `render_lens` | `camera`, `fit` | Concentric lens rings around an accent centre. |
| Exposure | `render_exposure` | `material`, `environment_map` | Disc split light and accent, with plus and minus marks. |
| Aspect frame | `render_aspect_frame` | `render_image`, `fit` | Wide rounded frame with two opposite corner brackets. |
| Frame guides | `render_frame_guides` | `render_image`, `fit` | Rounded frame with a rule-of-thirds grid and four accent nodes. |
## Output and result
Rule: one picture base (frame with landscape) with a lower-right round badge; the
size, background and quality icons draw their own simple subject.
| Meaning | Id | Finished references | Drawing |
|---|---|---|---|
| Render image | `render_execute` | `render_image`, `camera` | Picture with a play badge: create a rendered image. |
| Render output size | `render_output_size` | `render_image`, `fit` | Picture frame with a diagonal double-headed dimension arrow. |
| Transparent background | `render_transparent_background` | `material`, `render_image` | Grey cube on a checker tile. |
| Quality | `render_quality` | `list`, `library` | Three rising bars, the tallest in accent. |
| Check render | `render_check` | `render_image`, `new` | Picture with a check badge. |
| Cancel render | `render_cancel` | `render_image`, `delete` | Picture with a square stop badge. |
| Export image | `render_export_image` | `render_image`, `copy` | Picture with a large outward diagonal arrow at the upper right. |
| Compare | `render_compare` | `render_image`, `material` | Two grey pictures split by a vertical slider with opposed arrows. |
| Results | `render_results` | `render_image`, `copy` | Stack of two pictures, the front one showing a landscape. |
+7
View File
@@ -27,6 +27,9 @@ error = { master = "status_error", accent = "surface" }
warning = { master = "status_warning", accent = "mesh" }
ground = "c_fix"
# Visual style: Shaded with edges is the generic style icon.
style_shaded_edges = "style"
# Same finished drawing, different semantic operation. Colours live only in palette.toml.
sketch_break = { master = "break", accent = "sketch" }
sketch_cancel = { master = "close", accent = "sketch" }
@@ -45,3 +48,7 @@ sketch_chamfer_equal = { master = "sketch_chamfer", accent = "sketch" }
sketch_chamfer_two = { master = "sketch_chamfer_two_distances", accent = "sketch" }
offset_one_side = { master = "offset", accent = "sketch" }
bom = "list"
# Render workspace drafts: shared meanings; owner review remains Open.
# Removing a look override returns the body to its material look.
render_remove_override = "render_from_material"
+40 -2
View File
@@ -48,6 +48,38 @@ source = "#236ead"
light = "#236ead"
dark = "#2976b8"
# Simultaneous component colours for inspection icons; existing workspace ramps stay unchanged.
[roles.component-orange-1]
source = "#f1d2af"
light = "#f1d2af"
dark = "#ba8f6a"
[roles.component-orange-2]
source = "#c06f00"
light = "#c06f00"
dark = "#b86f04"
[roles.component-orange-3]
source = "#a45000"
light = "#a45000"
dark = "#95682d"
[roles.component-yellow-1]
source = "#e5d99b"
light = "#e5d99b"
dark = "#a9985f"
[roles.component-yellow-2]
source = "#a28200"
light = "#a28200"
dark = "#9a8100"
[roles.component-yellow-3]
source = "#826700"
light = "#826700"
dark = "#857100"
[strokes] # on the 64 grid
geometry = 2.55
action = 4.25
@@ -156,7 +188,7 @@ surface = [
sketch = [
"arc3", "arc_center", "arc_slot", "arc_slot_center", "arc_tangent",
"blend", "circle", "circle2", "circle3", "circle_tt",
"circle_ttt", "conic", "constrainer", "dimension", "ellipse",
"circle_ttt", "conic", "constrainer", "dimension", "ellipse", "equation_curve",
"extend", "finish", "fit_section", "include",
"intersect", "intersection_curve", "iso_curve", "line", "line_mid",
"offset", "offset_two_sides", "offset_symmetric", "point", "polygon", "polygon_circumscribed", "polygon_edge",
@@ -172,5 +204,11 @@ constraint = [
"c_polygon", "c_smooth", "c_symmetry", "c_tangent",
]
construction = [
"centerline", "construction", "infinite",
"centerline", "construction", "construction_line", "construction_line_angle",
"construction_line_bisector", "construction_line_horizontal", "construction_line_parallel",
"construction_line_perpendicular", "construction_line_tangent_at", "construction_line_tangent_external",
"construction_line_tangent_two", "construction_line_vertical", "infinite",
]
mesh = [
"mesh_body",
]
@@ -1,6 +1,8 @@
accessibility false
activate false
agent_chat false
align false
ambient_occlusion false
appearance false
arc3 false
arc_center false
@@ -57,14 +59,27 @@ circle_tt false
circle_ttt false
close false
coil false
colored_components false
colored_components_off false
combine false
compare false
component false
compute false
cone false
configuration_table false
conic false
constrainer false
construction false
construction_line false
construction_line_angle false
construction_line_bisector false
construction_line_horizontal false
construction_line_parallel false
construction_line_perpendicular false
construction_line_tangent_at false
construction_line_tangent_external false
construction_line_tangent_two false
construction_line_vertical false
contact_all false
contact_new false
contact_off false
@@ -80,6 +95,8 @@ decal false
delete false
delete_face false
dimension false
display false
dots false
draft false
draft_analysis false
drive false
@@ -88,6 +105,7 @@ edit false
ellipse false
emboss false
environment_map false
equation_curve false
error false
explode false
export false
@@ -96,8 +114,10 @@ extend false
extrude false
eye false
fastener false
feature_group false
fillet false
finish false
finish_edit false
fit false
fit_section false
flange false
@@ -107,7 +127,9 @@ folder false
frame_member false
gear false
graph false
grid false
ground false
ground_shadow false
help false
hem false
hole false
@@ -133,6 +155,7 @@ loft false
lookat false
material false
measure false
mesh_body false
mesh_export false
min_radius false
mirror false
@@ -148,6 +171,7 @@ origin false
pan false
params false
paste false
path_flange false
pattern_circ false
pattern_path false
pattern_rect false
@@ -177,6 +201,7 @@ polygon false
polygon_circumscribed false
polygon_edge false
presspull false
print_face false
project false
project_surface false
rack_pinion false
@@ -206,6 +231,12 @@ section false
select false
send false
settings false
settings_keyboard false
settings_monitor false
settings_mouse false
settings_palette false
settings_plug false
sheet false
sheet_rules false
shell false
shield false
@@ -241,9 +272,16 @@ split_face false
spun false
star false
status_error false
status_pin false
status_recovery false
style false
style_hidden_edges false
style_shaded false
style_shaded_edges false
style_translucent false
style_wireframe false
suppress false
surface false
surface_blend false
surface_boundary false
surface_extend false
@@ -266,6 +304,8 @@ svg false
sweep false
text false
thread false
timeline_play false
timeline_stop false
torus false
trace false
trim false
+62
View File
@@ -6,6 +6,7 @@
"agent_chat": "4a9ae0e7e2c360b4858b3175674c93d096705c92318fecd481e849f3ae650bf4",
"agent_link": "2f20d89a0e3e9b28744910dddf7f282a1099ccd91aa160c9841c5b1d9ea9ed0f",
"align": "05615db7dd9e4be56ae898e59a68c548b8a8447794442a9a394940651204bc4f",
"ambient_occlusion": "973f8ef2326aeaf0face576e5a2c79c7ef3d13638b2c51a5aad199e4ef7edb02",
"appearance": "b3162edd92291a5ef402e8499cd52be05beb2fa7eda641163c3f6e98325de2bf",
"arc3": "fdca3d501fd557ac989376a12e9c4fa8231fde2b455c20724d1079a466153e9c",
"arc_center": "3a5fdeb1d64fc6741f944c4435870b05fccab62293e270da692c6681155cbc51",
@@ -88,6 +89,7 @@
"dimension": "a926b7c64bc8ca99d8d6dcbb81b95235bd3fc34fbed049aca8b01b4d1147c15d",
"disclosure_closed": "49a350c6f2efae78de0be96c524ebee1fd1291ea449a84224b88d37164a4e807",
"disclosure_open": "d13ce3f97e3e362a9904fa2bf3412f3f00e4136bc23538b92d41483961d280de",
"display": "58b45e5e2230eaf2c80f74b3be2e02a23714c90d6fb03b59ed093a1949993163",
"dots": "600f7ff209d93145fc938bc4f6a1e34473e6b9ee22a5b4718f133bc7da5ee58d",
"draft": "9c0fc361cacd0ad95ccd54794f4825dfc9901a83a3de752a588faec365d0ee09",
"draft_analysis": "eb39c0f90e9cf226da6307365a76015e900dd692dc3f0fdc8f02fe717226261f",
@@ -101,6 +103,7 @@
"error": "b7188fcdfadf1512ddd2896805a14618179789f87047b219560a7dab74bd360f",
"explode": "2aecb05d5f05aff1ab629066cb9bea8718ea213ac70948ef71a3ced3321f4cb2",
"export": "c8c2abf007e43e896479d78798e3976ba9eda809507c820bb38538f77db696ac",
"export_tray": "f18606e1f63726c57a0787dced4fb8a221b4a4fc18b88b0bbdee743e546cb489",
"extend": "28c63df352c77d02feffe44cf09fb0fc1d48f7905aaabbfa6dba601dbb70f8e8",
"extrude": "9cc1538971c5d25467aaed23f739899d9d102fd07f86472ac3197c54ca2e1144",
"eye": "2449167c8410826dc1106b687c12a309d5bea8f7817b897943e80f182a163c2d",
@@ -116,12 +119,15 @@
"folder": "c19f5230df45a1bda1e92e89de38bba21c91e6eca0517eb15847a7712012d1e5",
"gear": "25a1ca48a587850da1104a46005a330873ed196246e3d04e12cf61f26ad2b0d5",
"graph": "7156caedd2dbfa2ae53da18b7a2204b8db66c45126859d36caf049a3fd4335a4",
"grid": "084b5040d57e9d5183b3db1fad0d17a13ab5bae64676eef703cc06622f9c7438",
"ground": "23e2471a9d6872e83028e0e219e330cef1930a4d89456a0fefa1bda8a7e3eb81",
"ground_shadow": "a96809ed87f5047c2d7f1274908ed7b65b180613322ac9c10eb0f9d6296c1b66",
"help": "8ce4060577483dbdb510be147d2827de50ac20029354008fca55abc0c7edf05d",
"hem": "f9b1aafbedcf3dc90c26be308814638e0095fbe31b0647092507343fa60af011",
"hole": "f82d47751f826e08682db847a270897474661bfe9f35b6d3678f49a203172563",
"home": "ab5f1c41d377687e4c5a808dfdccf2757b33dd43f934d44df43d6e9118176853",
"import": "e343540eb49fdb1e53c4d54b9e60ae513b3103df6c9133048bed878e58067455",
"import_tray": "c6aec9556dc8ecb6c953d6bf1f384f220c68167a92e444950c24ded81f66a0b6",
"include": "f5b80ad3e06e3b863fcfbe415ec912e4e09653dd519f9ac207819339fe34730f",
"infinite": "53fb781131078d2f8e71be23aa610d9f9d9c8bb79ba5f97fa397475e9ff8c3bd",
"inspect": "d2b4644b2d5f1aa50cb576b9265e275fa91201e296c2bc79865f810067d538e9",
@@ -206,8 +212,45 @@
"redo": "28e925493fa9e5f52e2c8e3beeb288af13fdb4f85f3cd92270096a10561e7a1a",
"refold": "1fa6a8604af7d04c01bc31d0e5875eb2cc91f5fa6bfc5491dd6680d3299eb26f",
"rename": "6887c5467101dca1d213154020d3b03be9b2704ac310de9d0a8ebde62c013bf5",
"render_all_shots": "9c04f49624cc2685472041cb3f3e95da2c5f32deeebb9723f4a8e3c369680a5b",
"render_appearance": "53287b30915a266d8a3858d5bef24b93ed11f8d612c20c445d3032f400a925cc",
"render_aspect_frame": "5d74b263eeb03eb0494b3774817135b8f119caff205a8b31c480d7dd0e5a8554",
"render_backdrop": "d27f7d6da7a31c5944dd0ff67c21838690f000f2d87b0bf5274bef0744de34ce",
"render_cancel": "2f1d8b3039007ccfc224390758e05d6202fa2eabf63724c744217dcab5d58e5e",
"render_check": "65ccf0407d80937c6931cbf87b6d0e628cfb62ca3e841483d02dd9de19c3717c",
"render_compare": "1b2d3f3920782490b96b4d14a7cc9506eff98ff2efae00f86d911fabcabc7cb0",
"render_copy_look": "25e9138a839a7e6b8225b48cd2f36daba42a928eff541fe616f3df39a1bc82b7",
"render_environment": "50cb18380d9310f6e04084c24ac85529599966b1d90c6bef514f4e63f9b30854",
"render_execute": "e4d9a66f4569e00c4b4b2ac05a80a9f3eba4e59802c62d3af5bb951ba9153cbf",
"render_export_image": "2456f55fa3b50ab84ae871963d4a6b6e92f486cf6ed3039f3a2f07f11192a655",
"render_exposure": "60eb5fa040aefe872ab2de66099493513a494ab758a23d443753d033eaf3a05d",
"render_fit_product": "8e58b293a39a06fca4cda0543adc2afef3d0041fe34b0bc0c800afb2d5635123",
"render_frame_guides": "cb1df01051de42047e38de9dd2a96fcb5b67bd5979cb5756412890400b69e5bc",
"render_from_material": "c928daa803a28f90ce24096731b8de711c6cd3215259ac9cd761b6be2e1b9618",
"render_ground": "2b900b433870cbc4bd0282e54c604ec696aa28354c753a13b09806f5e87d99c3",
"render_image": "e3dd622591de4fdc211f9bd3c6dcb54b01a308e8bcebd02bb80dd6b3982ce2a5",
"render_lens": "79bb615d46a05ea5ffc9e2e905cc25f266b3b7e761c160f7d25bbdb3dea0a430",
"render_library": "828af74034a62616c774ab0cbe70b7cf9fd42778b574febfb1812f8aa17fd49b",
"render_light_area": "b1f3ee596ee1d35f6e8444b54e94f36a34c2129697c88e5286dbf38565f50816",
"render_light_by_highlight": "5b37c8c6aa27ac2002dd8c8094e415c64e9eb315a55c4f146e328314e8f639a2",
"render_light_point": "a602cb5fa26f04f1bb9be7c14e5a9a9ce52b37550dfd3211a55e915f941f5803",
"render_light_spot": "a9413de906266bec0165b4cb06ecac8e36dce0b6ddec4bc4cc6ff4f152601483",
"render_lighting_presets": "e0a08433f548a9dd139296107b04b1d4c57ff04303c9bee32d896e2ea06a8d07",
"render_output_size": "f08fe354e4d5a6dd21af29cb077ceed96c413c68f48d9f72548ae1614337f88b",
"render_paint": "e3d3202205a5a90906348cae519340d38f475e42039a7f60c835cf7a21081da1",
"render_pick_up": "df67d10f333a3c9e367897d599a4be510f10ed36c24763c26817f22d24ff35b7",
"render_quality": "28fe44cdd2b2f58b91d91dcf20a6dfbd22cf3db6008e1db179717523ecc4889e",
"render_remove_override": "c928daa803a28f90ce24096731b8de711c6cd3215259ac9cd761b6be2e1b9618",
"render_replace_all": "3bbf830619a91be186b9dc91726b8e47c293556d2ac8c93d87c71014c9f3abcd",
"render_results": "94e78898d88eef415e56ca5ec1e056089a2a5aa709abc69e5b8103d6096a019c",
"render_same_look": "2b5c69c523a1c949c22638c378803e77123095fddd36cfa58e94b2fd91ca61d7",
"render_scene": "1c4d7350f6f17ad637a7d633cb65a64c298af20321b1dbdac01b51d47a0e2822",
"render_shot": "4428080faa94f276c8a1d20eb38d04835ee5ae9d6337402371a5edf09f97ddc5",
"render_shots_strip": "1dcf5cc8e704691985284e0ca06e03b22540ea9d4cd23f4fb8675801ae7e69ec",
"render_solo_light": "abb9f8993be108a79d491bfaca32cde59d07a69db3ead58195e8ca0d8e8d909b",
"render_sun": "d86b51f3d4d83df4e932a22bb20d567513529a2eaaa42b93f744412eed2be901",
"render_transparent_background": "71dab4763516053734d4eec964b0899716d617597fe0d09e3bfa0cc36224dd7a",
"render_update_shot": "22d82ad332f8325395596c4bec4c1ec9bbb72d9fc4cd62ad658a93d7f5f803b9",
"reorder": "c45755c8f23575415397bffb316eed9f2d8c014f451ab6f8c3123aca1a99b675",
"replace_face": "fcee556c684a1138a30a2574ea565c98ff29f9368c2e861d0fb861155a57d28b",
"rest": "eab64961b5340bdbbd92affe0c9b8477dc297a3b49e05e39d5fa71c0f2fda66c",
@@ -224,10 +267,23 @@
"screw": "5e30570785360d6a591ea22862a82f1cf62228385c1e96382536db8a453ac791",
"section": "cdd13f99f15f6d59bfa21c747810608d6afd00cb8207faf0337928c8521e7eae",
"select": "fdf6431270006ca7409c60c37cfed59572de04229f104ec723e6010ffa2b7163",
"select_bodies": "8e6e61e083e6097535da3b98d41b607b40fc81b72bbd8c7fb903618c5b35c568",
"select_components": "3e6cc847b334e92ce7ec5e5f35dfa929572e0974239790d76563ca4e20b26789",
"select_faces_edges": "7b99b5370b1647bf8cea57df7a1053caab641c2f4a94c4d797b9679e6f38d6f1",
"select_features": "b050bc19ce86d135cbe50998be681555110e92e179c4c7b101170c6b62c19943",
"select_parts": "569adc5595a64187411d8ffeea69eab3b76ddfa33aeead6fd728efdd993bcccc",
"select_sketch": "b0605b34a14d4173f7ed80d877109ec0b891a1b395bcab5743a603ed2eae240a",
"send": "f83b6cdab792f9567a81d46d7828ed20795513c57a2c7d00fbbe6bd14d2f5848",
"settings": "9193dd3a1007c43660d0d47b6499cb2571338b67949e1a99df112057b9bf2b8a",
"settings_keyboard": "4968c70788094365af9c6e2b1c15b2aac45cf21212a14b8a256daf1ffe17f41a",
"settings_monitor": "6fcea26e2fc1673c76c6e7fadac4a91812ed43250cfb363c4f40c0cacfbd7aa5",
"settings_mouse": "47754752ea6fb37ec9686c0460740935a4db30fb267f1136a9246aec9a17b3f1",
"settings_palette": "a060015a750ff4367c65c04b83bf43720e13a38d8dee35330e1d8880f89eaa22",
"settings_plug": "79c00b84b2d1d28e3bf6096639c75476b7e1ee0a326c34ad76a79ad866c0102e",
"sheet": "774e30923ef6f37dd9c0b2bc3e45705e83b3a76700bf071cd41155a365d0daa4",
"sheet_rules": "5d3a2f37d3b4a4262b3ddbdae53d61f29e0f8e284dd54006da25a7acebfa1114",
"shell": "a1959ea0d0441a479bf654b6e549e2e44a0bfdc9cda4eb46fe1fbfd884de7e38",
"shield": "5e2d2d157309e0daa72f7c53073dfdb0a474def972a041cd172567f1d1e485ec",
"silhouette": "2a848aed2f247b1c0439f830a34a513c93bc5493c40044c821d493d863ad996e",
"sketch": "b52e5862b1709a9a7e3ca00d7436d126fbfc01b38080006fa4e17dc581dfa103",
"sketch_break": "67a5fab332616e6d6a9aeae3bedc04ee0768179143ba34b795bdd98cea04bb24",
@@ -261,7 +317,13 @@
"status_error": "b7188fcdfadf1512ddd2896805a14618179789f87047b219560a7dab74bd360f",
"status_warning": "1ac4f0ccfac2c01f6f66cb42188f9ae053c4aef9c5bbd249f1fb09611eb4d825",
"stitch": "6c3205d64007b771ed78d6f8055867578439fb25d591563b32dbd3343c98732d",
"stop": "36967eb4d423370d0d6c679b0a71f8d09f4fb2c73eed3e8cc50c604c44b0c32b",
"style": "6c2b04234b82ea4858c80190cc7208c60f549358253005afc35ab4e29506b578",
"style_hidden_edges": "a707dfc0bdcabe9144865f569abfcb478e493143cbd4331efc1404cf3082f6d2",
"style_shaded": "6e5f8f9a9030ab3717f8b2192bbfd3f6b515ad398acf6cb41bc24f1298bc73b4",
"style_shaded_edges": "6c2b04234b82ea4858c80190cc7208c60f549358253005afc35ab4e29506b578",
"style_translucent": "c46b0cd485cfb9023514908357048b5cba9017a0c4976239da1e98f50e4ab5f9",
"style_wireframe": "af7e575d378b5b6850bee9d3ca384efab95c8d4c876c5e3524f3ea771c4380a3",
"suppress": "c54fbc01c12c0175d78cd63f692e8aaf73fc2cac79752b2a3ad821ac32734343",
"surface": "ba0e839ce0ee03e7ff95a2389a5ecb20bd80c713064d9b1bd5f461f4a80d1921",
"surface_blend": "0642f3b4af7d22f379a3c74f54c7b1c9eef0bdda19372ce4df73c0e56bdd7bbc",
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Ambient occlusion</title>
<g opacity="0.22"><path d="M13 51L51 51A38 38 0 0 0 13 13Z" fill="currentColor" stroke="currentColor" stroke-width="0" stroke-linejoin="round" stroke-linecap="round"/></g>
<g opacity="0.38"><path d="M13 51L40 51A27 27 0 0 0 13 24Z" fill="currentColor" stroke="currentColor" stroke-width="0" stroke-linejoin="round" stroke-linecap="round"/></g>
<g opacity="0.62"><path d="M13 51L29 51A16 16 0 0 0 13 35Z" fill="currentColor" stroke="currentColor" stroke-width="0" stroke-linejoin="round" stroke-linecap="round"/></g>
<path d="M13 10L13 51L54 51" fill="none" stroke="currentColor" stroke-width="5.6" stroke-linejoin="round" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 758 B

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Display</title>
<path d="M10 22L54 22M10 42L54 42" fill="none" stroke="currentColor" stroke-width="5.6" stroke-linejoin="round" stroke-linecap="round"/>
<circle cx="24" cy="22" r="10.5" fill="#ffffff"/>
<circle cx="42" cy="42" r="10.5" fill="#ffffff"/>
<circle cx="24" cy="22" r="6.4" fill="currentColor"/>
<circle cx="42" cy="42" r="6.4" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 448 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Grid</title>
<path d="M12 12L52 12L52 52L12 52Z" fill="none" stroke="currentColor" stroke-width="5.6" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M25.3 12L25.3 52M38.7 12L38.7 52M12 25.3L52 25.3M12 38.7L52 38.7" fill="none" stroke="currentColor" stroke-width="4" stroke-linejoin="round" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 407 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Ground shadow</title>
<path d="M32 4.8 L48.1 13.9 L48.1 32.1 L32 41.2 L15.9 32.1 L15.9 13.9Z" fill="currentColor" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M9.34 31.35A25 11 0 1 0 54.66 31.35" fill="none" stroke="currentColor" stroke-width="5.2" stroke-linejoin="round" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 433 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Bodies</title>
<path d="M32 6 L55 19 L55 45 L32 58 L9 45 L9 19Z" fill="currentColor" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 254 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Components</title>
<path d="M22.91 5.75 L41.09 16.25 L22.91 26.75 L4.72 16.25ZM4.72 16.25 L22.91 26.75 L22.91 47.75 L4.72 37.25ZM41.09 16.25 L59.28 26.75 L41.09 37.25 L22.91 26.75ZM22.91 26.75 L41.09 37.25 L41.09 58.25 L22.91 47.75ZM59.28 26.75 L41.09 37.25 L41.09 58.25 L59.28 47.75Z" fill="currentColor" stroke="currentColor" stroke-width="4.2" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M37.46 18.35L22.91 26.75L22.91 43.55" fill="none" stroke="#ffffff" stroke-width="3.6" stroke-linejoin="round" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 621 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Faces and edges</title>
<path d="M32 6 L55 19 L32 32 L9 19Z" fill="currentColor" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M32 6 L55 19 L55 45 L32 58 L9 45 L9 19Z" fill="none" stroke="currentColor" stroke-width="4.6" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M32 32L9 19M32 32L55 19M32 32L32 58" fill="none" stroke="currentColor" stroke-width="3.6" stroke-linejoin="round" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 554 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Features</title>
<path d="M32 6 L55 19 L55 45 L32 58 L9 45 L9 19Z" fill="none" stroke="currentColor" stroke-width="4.6" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M9 19L32 32M32 32L55 19M32 32L32 58" fill="none" stroke="currentColor" stroke-width="3.6" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M42 19C42 22.2 37.52 24.8 32 24.8C26.48 24.8 22 22.2 22 19C22 15.8 26.48 13.2 32 13.2C37.52 13.2 42 15.8 42 19Z" fill="currentColor" stroke="currentColor" stroke-width="0.6" stroke-linejoin="round" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 632 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Parts</title>
<path d="M22.91 5.75 L41.09 16.25 L22.91 26.75 L4.72 16.25ZM4.72 16.25 L22.91 26.75 L22.91 47.75 L4.72 37.25Z" fill="none" stroke="currentColor" stroke-width="4.2" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M41.09 16.25 L59.28 26.75 L41.09 37.25 L22.91 26.75ZM22.91 26.75 L41.09 37.25 L41.09 58.25 L22.91 47.75ZM59.28 26.75 L41.09 37.25 L41.09 58.25 L59.28 47.75Z" fill="currentColor" stroke="currentColor" stroke-width="4.2" stroke-linejoin="round" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 585 B

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Sketch features</title>
<path d="M10 46C18 14 46 14 54 46" fill="none" stroke="currentColor" stroke-width="5" stroke-linejoin="round" stroke-linecap="round"/>
<circle cx="10" cy="46" r="8.6" fill="#ffffff"/>
<circle cx="54" cy="46" r="8.6" fill="#ffffff"/>
<circle cx="32" cy="22.5" r="8.6" fill="#ffffff"/>
<circle cx="10" cy="46" r="5.8" fill="currentColor"/>
<circle cx="54" cy="46" r="5.8" fill="currentColor"/>
<circle cx="32" cy="22.5" r="5.8" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 563 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Pinned result</title>
<path d="M32 57C25 48 14 35 14 24A18 18 0 0 1 50 24C50 35 39 48 32 57Z" fill="none" stroke="currentColor" stroke-width="5.6" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M25 24A7 7 0 1 0 39 24A7 7 0 1 0 25 24Z" fill="none" stroke="currentColor" stroke-width="5.6" stroke-linejoin="round" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 429 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Shaded with hidden edges</title>
<path d="M32 6 L55 19 L55 45 L32 58 L9 45 L9 19Z" fill="currentColor" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M32 32L15.44 22.64M32 32L48.56 22.64M32 32L32 50.72" fill="none" stroke="#ffffff" stroke-width="3.6" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M32 32L32 13.28M32 32L48.56 41.36M32 32L15.44 41.36" fill="none" stroke="#ffffff" stroke-width="3.2" stroke-linejoin="round" stroke-linecap="round" stroke-dasharray="4.5 4"/>
</svg>

After

Width:  |  Height:  |  Size: 619 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Shaded</title>
<g opacity="0.5"><path d="M32 6 L55 19 L32 32 L9 19Z" fill="currentColor" stroke="currentColor" stroke-width="0.8" stroke-linejoin="round" stroke-linecap="round"/></g>
<g opacity="0.8"><path d="M9 19 L32 32 L32 58 L9 45Z" fill="currentColor" stroke="currentColor" stroke-width="0.8" stroke-linejoin="round" stroke-linecap="round"/></g>
<path d="M32 32 L55 19 L55 45 L32 58Z" fill="currentColor" stroke="currentColor" stroke-width="0.8" stroke-linejoin="round" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 583 B

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Translucent</title>
<defs>
<clipPath id="tr-clip"><path d="M32 8.6 L52.7 20.3 L52.7 43.7 L32 55.4 L11.3 43.7 L11.3 20.3Z"/></clipPath>
</defs>
<g clip-path="url(#tr-clip)"><path d="M-71 72L9 -8M-60.5 72L19.5 -8M-50 72L30 -8M-39.5 72L40.5 -8M-29 72L51 -8M-18.5 72L61.5 -8M-8 72L72 -8M2.5 72L82.5 -8M13 72L93 -8M23.5 72L103.5 -8M34 72L114 -8M44.5 72L124.5 -8M55 72L135 -8" fill="none" stroke="currentColor" stroke-width="2.8" stroke-linejoin="round" stroke-linecap="round"/></g>
<path d="M32 6 L55 19 L55 45 L32 58 L9 45 L9 19Z" fill="none" stroke="currentColor" stroke-width="5.2" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M32 32L11.76 20.56M32 32L52.24 20.56M32 32L32 54.88" fill="none" stroke="#ffffff" stroke-width="6.4" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M32 32L11.3 20.3M32 32L52.7 20.3M32 32L32 55.4" fill="none" stroke="currentColor" stroke-width="3.6" stroke-linejoin="round" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Wireframe</title>
<path d="M32 6 L55 19 L55 45 L32 58 L9 45 L9 19Z" fill="none" stroke="currentColor" stroke-width="5.2" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M32 32L32 6M32 32L55 19M32 32L55 45M32 32L32 58M32 32L9 45M32 32L9 19" fill="none" stroke="currentColor" stroke-width="3.6" stroke-linejoin="round" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 433 B

@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Colored components</title>
<path d="M9.235 17.706L15.059 20.882L15.059 48.941L9.235 44.926Z" fill="#c06f00"/>
<path d="M35.176 9.235L15.059 20.882L15.059 48.941L36.765 35.139Z" fill="#a45000"/>
<path d="M29.046 5L35.176 8.176L15.196 19.718L9.066 16.541Z" fill="#f1d2af"/>
<path d="M29.046 5.529L35.176 8.706L35.176 36.235L15.059 49.471L9.235 45.765L9.066 17.071Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.066 17.6L15.059 20.882L34.647 9.235" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M15.059 20.882L15.059 47.067" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M32.222 18.595L44.949 25.944L44.949 51.122L32.222 43.774Z" fill="#328bd3"/>
<path d="M54.934 20.173L44.949 25.944L44.949 51.122L54.934 45.352Z" fill="#236ead"/>
<path d="M42.059 12.412L54.934 20.173L44.949 25.944L32 17.706Z" fill="#b9ddf8"/>
<path d="M41.159 11.766L54.934 20.173L54.934 45.352L44.949 51.122L31.164 42.715L31.164 17.536Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M32.222 18.595L44.949 25.944L54.934 20.173" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M44.949 25.944L44.949 51.122" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M18.299 39.062L31.015 46.411L31.015 59L18.299 51.652Z" fill="#a28200"/>
<path d="M41.921 40.121L31.015 46.411L31.015 59L41.921 52.711Z" fill="#826700"/>
<path d="M29.194 32.773L41.921 40.121L31.015 46.411L18.299 39.062Z" fill="#e5d99b"/>
<path d="M29.194 32.773L41.921 40.121L41.921 52.711L31.015 59L18.299 51.652L18.299 39.062Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M18.299 39.062L31.015 46.411L41.921 40.121" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M31.015 46.411L31.015 59" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Disable component colors</title>
<path d="M9.235 17.706L15.059 20.882L15.059 48.941L9.235 44.926Z" fill="#328bd3"/>
<path d="M35.176 9.235L15.059 20.882L15.059 48.941L36.765 35.139Z" fill="#236ead"/>
<path d="M29.046 5L35.176 8.176L15.196 19.718L9.066 16.541Z" fill="#b9ddf8"/>
<path d="M29.046 5.529L35.176 8.706L35.176 36.235L15.059 49.471L9.235 45.765L9.066 17.071Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.066 17.6L15.059 20.882L34.647 9.235" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M15.059 20.882L15.059 47.067" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M32.222 18.595L44.949 25.944L44.949 51.122L32.222 43.774Z" fill="#328bd3"/>
<path d="M54.934 20.173L44.949 25.944L44.949 51.122L54.934 45.352Z" fill="#236ead"/>
<path d="M42.059 12.412L54.934 20.173L44.949 25.944L32 17.706Z" fill="#b9ddf8"/>
<path d="M41.159 11.766L54.934 20.173L54.934 45.352L44.949 51.122L31.164 42.715L31.164 17.536Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M32.222 18.595L44.949 25.944L54.934 20.173" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M44.949 25.944L44.949 51.122" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M18.299 39.062L31.015 46.411L31.015 59L18.299 51.652Z" fill="#328bd3"/>
<path d="M41.921 40.121L31.015 46.411L31.015 59L41.921 52.711Z" fill="#236ead"/>
<path d="M29.194 32.773L41.921 40.121L31.015 46.411L18.299 39.062Z" fill="#b9ddf8"/>
<path d="M29.194 32.773L41.921 40.121L41.921 52.711L31.015 59L18.299 51.652L18.299 39.062Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M18.299 39.062L31.015 46.411L41.921 40.121" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M31.015 46.411L31.015 59" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Cone</title>
<path d="M32 5L12.7 45.97A20 11.55 0 1 0 51.3 45.97Z" fill="#328bd3"/>
<path d="M32 5L39 59.82A20 11.55 0 0 0 51.3 45.97Z" fill="#236ead"/>
<path d="M32 5L12.7 45.97A20 11.55 0 1 0 51.3 45.97Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 388 B

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Construction line through two points</title>
<path d="M8 52L56 12" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round" stroke-dasharray="10 5"/>
<circle cx="20" cy="42" r="5.8" fill="#ffffff"/>
<circle cx="20" cy="42" r="3.4" fill="#236ead"/>
<circle cx="44" cy="22" r="5.8" fill="#ffffff"/>
<circle cx="44" cy="22" r="3.4" fill="#236ead"/>
</svg>

After

Width:  |  Height:  |  Size: 472 B

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Construction line at an angle</title>
<path d="M10.26 48.66L58.26 48.66" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M28.65 33.23A24 24 0 0 1 34.26 48.66" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M10.26 48.66L54.26 11.66" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
<circle cx="10.26" cy="48.66" r="5.8" fill="#ffffff"/>
<circle cx="10.26" cy="48.66" r="3.4" fill="#236ead"/>
</svg>

After

Width:  |  Height:  |  Size: 646 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Angle bisector</title>
<path d="M13.92 12.18L9.92 52.17L54.92 44.17" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M12.11 30.29A22 22 0 0 1 31.58 48.33" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M9.92 52.17L47.3 11.83" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 526 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Horizontal construction line</title>
<path d="M8 32L56 32" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round" stroke-dasharray="10 5"/>
<circle cx="32" cy="32" r="5.8" fill="#ffffff"/>
<circle cx="32" cy="32" r="3.4" fill="#236ead"/>
</svg>

After

Width:  |  Height:  |  Size: 362 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Parallel construction line</title>
<path d="M9 37L41 9" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M23 55L55 27" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round" stroke-dasharray="10 5"/>
</svg>

After

Width:  |  Height:  |  Size: 380 B

Some files were not shown because too many files have changed in this diff Show More