Compare commits

...

310 Commits

Author SHA1 Message Date
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
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
bherbruck 37a586dd61 Release 0.25.1 2026-10-27 20:58:30 -04:00
bherbruck a79f40a711 Release 0.25.0 2026-10-27 18:05:39 -04:00
bherbruck b5728f284b Release 0.24.1 2026-10-27 08:34:27 -04:00
bherbruck 5965439a15 Release 0.24.0 2026-10-26 19:23:21 -04:00
bherbruck 586a358ed4 Release 0.23.1 2026-10-26 07:07:21 -04:00
bherbruck 7770f8982d Release 0.23.0 2026-10-25 18:18:39 -04:00
bherbruck 72ad6383ef Release 0.22.1 2026-10-25 10:38:39 -04:00
bherbruck 0537623a75 Release 0.22.0 2026-10-24 18:53:43 -04:00
bherbruck 65d288227e Release 0.21.0 (rc.2's code, version only)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 20:51:08 -04:00
bherbruck fedb3bcdb1 Release 0.21.0-rc.2 2026-10-23 17:14:38 -04:00
bherbruck 097b8b7550 Release 0.21.0-rc.1
The tree is main at f120285c9 exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-23 13:48:05 -04:00
bherbruck af7ca3a8a2 Release 0.21.0-rc.1
The tree is main at b31fddb98 exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-23 13:37:43 -04:00
bherbruck 27ddb725c7 Release 0.21.0-rc.1
The tree is main at 2bf3d4e11 exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-23 12:10:02 -04:00
bherbruck a06bdf5cfc Release 0.20.0
The tree is main at 030187452 exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-22 18:34:40 -04:00
bherbruck 33fdcdb3a0 Release 0.19.0
The tree is main at 5899e5b6c exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-21 21:13:33 -04:00
bherbruck c07ab5f8f9 Release 0.18.4
The tree is main at 728f753b8 exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-21 12:26:34 -04:00
bherbruck fea32f8aac Release 0.18.3
The tree is main at 034aa60d4 exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-21 08:46:37 -04:00
bherbruck 4927214976 Release 0.18.2
The tree is main at 57bcc4083 exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-20 22:09:18 -04:00
bherbruck b621819c0c Release 0.18.1
The tree is main at 522ae7755 exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-20 17:39:11 -04:00
bherbruck ec552a5300 Release 0.18.0
The tree is main at c3151fb7f exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-20 16:28:59 -04:00
bherbruck e0cc740cc3 Release 0.17.0
The tree is main at 511faedcf exactly; the 0.16.x release commits are kept as a parent so release only moves forward.
2026-10-20 11:41:48 -04:00
bherbruck 5de48e916d Release 0.16.2 2026-10-19 00:39:30 +00:00
bherbruck 80af7bbe3e Release 0.16.2
Version 0.16.2: 0.16.1 with the rigid orbit fix (#2753). Same design format (27).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:38:53 +00:00
bherbruck 191e983423 Fix orbit about the picked point (regression in 0.16.0) (#2753)
Orbiting about the point under the cursor (or the origin) turned the camera
about its target and then panned to put the pivot back. The pan works at the
target's depth with the parallel-view scale, so in a perspective view the
pivot slid off its pixel and the model swung about the camera.

The camera now turns rigidly about the pivot: Camera::orbit_about keeps the
pivot's coordinates in the camera's frame and rebuilds the target from the
new orientation, so the pivot stays exactly on its pixel in both projections.
The Shift snap spring and view.navigate's pivot orbit use the same.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit e88779218)
2026-10-19 00:38:52 +00:00
bherbruck 2b9445b049 Release 0.16.1 2026-10-18 20:16:21 +00:00
bherbruck 7ddf48ca16 Release 0.16.1
Version 0.16.1: 0.16.0 with the orbit fix (reverts #2142). Same design format (27).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:12:04 +00:00
bherbruck d2f6848377 Revert #2142: orbit about the picked point like 0.15.0
#2142 made orbiting about the point under the cursor turn the camera in place instead of turning the part about that point. Restore the 0.15.0 behaviour.

This reverts commit cac4bccea8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:12:00 +00:00
bherbruck 0b6d6fecd8 Release 0.16.0 2026-10-18 18:16:11 +00:00
bherbruck fa691707c2 Release 0.15.0 2026-10-17 13:54:24 +00:00
bherbruck b9a55c74e2 Release 0.15.0
Version 0.15.0; the pending design-format changes are folded into format 26.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:53:44 +00:00
bherbruck 2bb9c81069 Release 0.14.0 2026-10-16 09:19:14 +00:00
bherbruck cedb51b8ea Release 0.14.0
Version 0.14.0; the pending design-format changes are folded into format 25.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 09:19:10 +00:00
bherbruck 66d13059dd Release 0.13.1 2026-10-15 21:56:43 +00:00
bherbruck 800ea40c0d Release 0.13.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:56:35 +00:00
bherbruck 20442eecd3 Agent chat on Windows: start npm agents through their .cmd shim
npm puts a Unix shell script with the bare name (codex-acp,
claude-code-acp, gemini) beside the .cmd shim. The lookup tried the bare
name first, so every npm-installed agent failed with "not a valid Win32
application (os error 193)". Windows now only considers .exe/.cmd/.bat,
and a command saved with the bare path runs the shim beside it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 60ea8c69d2)
2026-10-15 21:51:01 +00:00
bherbruck 22d1cd7032 Release 0.13.0 2026-10-15 20:34:13 +00:00
bherbruck 6a0dc18655 Fix the Windows 32-bit and macOS release builds
On 32-bit Windows SetWindowLongPtrW is SetWindowLongW, which takes and
returns i32, so the window-procedure hook didn't compile for i686.
The macOS app bundle now signs the bundled solidcraft-cli before the
bundle itself; codesign refuses a bundle with an unsigned helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 94e76cd97e)
2026-10-15 20:34:12 +00:00
bherbruck e006dbf1af Release 0.13.0
The tree is the 0.13.0 version commit (8241a750fa) on main exactly; the previous release is kept as a parent so release only moves forward.
2026-10-15 19:59:50 +00:00
bherbruck 8241a750fa Release 0.13.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:59:42 +00:00
bherbruck 41779cf7bb Release 0.12.0: release branch follows main again after the 0.11.1 patch
The tree is main at c36a6f9f exactly; the 0.11.1 patch commit is kept as a parent so release only moves forward.
2026-10-15 13:32:01 +00:00
bherbruck b01b9689c8 Release 0.11.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:41:41 +00:00
bherbruck 4426da6f5d Windows: start Direct3D 12 first, and Vulkan only without a D3D12 GPU (#1190)
* Windows: start Direct3D 12 first, and Vulkan only without a D3D12 GPU

wgpu started Vulkan, D3D12 and OpenGL together and preferred Vulkan, so
every Windows start loaded all installed Vulkan drivers. An old Intel
Vulkan driver (igvk64.dll) crashed 0.10.1 at startup inside the driver.

The app now probes D3D12, then Vulkan, then OpenGL, one backend at a
time, and starts only the first with a hardware adapter (WARP when none
has one). WGPU_BACKEND still wins; graphics.json's "backend" sets the
order by hand; the software renderer (safe mode, the retry) uses D3D12
alone. A new Windows CI job starts the real app under several settings.

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

* CI: a test filter YAML parses

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

* CI: the startup smoke test writes graphics.json where the app reads it

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

* CI: run the WARP shader tests one at a time

WARP's shader JIT (d3d10warp.dll) crashed twice in 124 runs of the gpu
tests with four test threads, with a null read inside its own optimizer
while several devices compiled compute pipelines at once. Serial runs
did not crash.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 95ea05e7c8)
2026-10-14 20:41:37 +00:00
bherbruck 5ead82e627 Release 0.11.0: release branch follows main again after the 0.10.1 patch
The tree is main at cccbe125 exactly; c29b0d2f (0.10.1) is kept as a parent so release only moves forward.
2026-10-14 20:07:41 +00:00
bherbruck c29b0d2f13 Release 0.10.1 2026-10-14 14:41:19 +00:00
bherbruck 480fd8d517 Fix the startup crash on D3D12 with FXC: CPU fallback for the tessellation pipeline, FXC-friendly shader (#1098) (#1099)
* GPU tessellation: a pipeline the device cannot build falls back to CPU meshing instead of crashing (#1098)

Build the tessellation compute pipeline inside error scopes; TessGpu::new returns an error,
and the viewport logs it and meshes bodies on the CPU. A Windows CI job builds the pipeline
with D3D12's FXC on the WARP adapter.

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

* Tessellation shader: basis functions in one-dimensional local arrays, so D3D12's FXC compiles it (#1098)

FXC has to unroll loops that write an array inside a struct (Basis.n0/n1) or a 2-D array
(ndu) by a counter, and cannot with run-time trip counts (X3511). A2.2 basis functions in a
local 1-D array returned whole; derivatives from the degree p-1 functions; the pole step
offsets computed rather than indexed.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:41:16 +00:00
bherbruck 4c09fa5355 Hovered and selected faces light solidly again over GPU-drawn bodies (#1064)
Since the model's mesh went back to the reference mesher (#940), the GPU viewport drew
opaque bodies from their display plans while hover and selection lit faces with the
reference mesh's triangles. Two triangulations of the same face at equal depth fought, so
a hovered face shimmered in wedges instead of lighting whole.

Faces lit over a body drawn from its plan now use the plan's own triangles
(ModelBody::plan_mesh, evaluated once on the CPU), and the highlight pass gets a small
depth bias toward the eye so f32 rounding between the GPU and CPU evaluations of the same
triangles cannot speckle it either.

Test: every face of a box with a pocket and a round boss, hovered and rendered offscreen
through the viewport's real pipelines on the GPU path, lights the same pixels as on the
CPU path (skipped without a GPU adapter).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:56:47 +00:00
776 changed files with 161240 additions and 5803 deletions
+3
View File
@@ -364,6 +364,9 @@ 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
+12
View File
@@ -597,6 +597,18 @@ Original SolidCraft drawings, Open for owner review; no runtime assignments.
| 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 |
## Code adapted from sibling projects
+11 -2
View File
@@ -268,7 +268,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
@@ -276,6 +278,11 @@ 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.
@@ -298,7 +305,9 @@ and a fresh CI run:
- **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).
`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
Generated
+34 -28
View File
@@ -1692,9 +1692,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 +3773,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 +4210,7 @@ dependencies = [
[[package]]
name = "solidcraft"
version = "0.25.1"
version = "0.26.1"
dependencies = [
"block2 0.6.2",
"eframe",
@@ -4233,20 +4235,22 @@ dependencies = [
"serde_json",
"solidcraft-3dx-client",
"solidcraft-engine",
"solidcraft-gpu",
"solidcraft-space-mouse",
"solidcraft-ui-egui",
"ureq",
"windows-sys 0.61.2",
"winit",
"x11rb",
]
[[package]]
name = "solidcraft-3dx-client"
version = "0.25.1"
version = "0.26.1"
[[package]]
name = "solidcraft-cli"
version = "0.25.1"
version = "0.26.1"
dependencies = [
"image",
"log",
@@ -4258,7 +4262,7 @@ dependencies = [
[[package]]
name = "solidcraft-derive"
version = "0.25.1"
version = "0.26.1"
dependencies = [
"proc-macro2",
"quote",
@@ -4267,7 +4271,7 @@ dependencies = [
[[package]]
name = "solidcraft-doc"
version = "0.25.1"
version = "0.26.1"
dependencies = [
"log",
"serde",
@@ -4284,7 +4288,7 @@ dependencies = [
[[package]]
name = "solidcraft-engine"
version = "0.25.1"
version = "0.26.1"
dependencies = [
"image",
"log",
@@ -4305,7 +4309,7 @@ dependencies = [
[[package]]
name = "solidcraft-geom"
version = "0.25.1"
version = "0.26.1"
dependencies = [
"serde",
"serde_json",
@@ -4314,7 +4318,7 @@ dependencies = [
[[package]]
name = "solidcraft-gpu"
version = "0.25.1"
version = "0.26.1"
dependencies = [
"image",
"log",
@@ -4334,7 +4338,7 @@ dependencies = [
[[package]]
name = "solidcraft-io"
version = "0.25.1"
version = "0.26.1"
dependencies = [
"cadmpeg-core",
"cadmpeg-ir",
@@ -4358,7 +4362,7 @@ dependencies = [
[[package]]
name = "solidcraft-kernel"
version = "0.25.1"
version = "0.26.1"
dependencies = [
"log",
"proptest",
@@ -4378,7 +4382,7 @@ dependencies = [
[[package]]
name = "solidcraft-mcp"
version = "0.25.1"
version = "0.26.1"
dependencies = [
"serde_json",
"solidcraft-engine",
@@ -4387,7 +4391,7 @@ dependencies = [
[[package]]
name = "solidcraft-render"
version = "0.25.1"
version = "0.26.1"
dependencies = [
"image",
"moxcms",
@@ -4401,25 +4405,27 @@ dependencies = [
[[package]]
name = "solidcraft-sketch"
version = "0.25.1"
version = "0.26.1"
dependencies = [
"harfrust",
"serde",
"serde_json",
"solidcraft-geom",
"thiserror 2.0.21",
"ttf-parser",
"unicode-bidi",
]
[[package]]
name = "solidcraft-space-mouse"
version = "0.25.1"
version = "0.26.1"
dependencies = [
"serde",
]
[[package]]
name = "solidcraft-ui-egui"
version = "0.25.1"
version = "0.26.1"
dependencies = [
"arboard",
"egui",
@@ -4449,7 +4455,7 @@ dependencies = [
[[package]]
name = "solidcraft-web"
version = "0.25.1"
version = "0.26.1"
dependencies = [
"eframe",
"egui",
@@ -4560,7 +4566,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",
@@ -4918,9 +4924,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"
@@ -5357,9 +5363,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",
]
@@ -5840,8 +5846,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",
@@ -5994,7 +5998,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.25.1"
version = "0.26.1"
dependencies = [
"proc-macro2",
"serde",
@@ -6292,16 +6296,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",
]
+6 -2
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["vendor"]
[workspace.package]
version = "0.25.1"
version = "0.26.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -80,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"] }
@@ -161,6 +161,10 @@ 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" }
# 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]
+3 -3
View File
@@ -58,9 +58,9 @@
- **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
+694
View File
@@ -80,3 +80,697 @@ permanently.
- 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.
## At a glance
| Question | Answer |
|---|---|
| Where are we? | **M0 and M13 done; M1, M2, M5–M7, M9, M10, M14 in progress.** Sketch with solver and the full SKETCH create/modify set; extrude, revolve (two sides, symmetric, to object), fillet/chamfer (variable radius, chord length, a radius per edge, corner patches), holes, threads (cosmetic and modelled), patterns, mirror, shell (inside/outside/both), draft, loft, sweep, coil, split body and split face, delete face, construction planes, axes and points; persistent naming of faces and edges; parameters and configurations; components, joints, motion studies, exploded views; sheet metal and a plastic enclosure subset; STEP/IGES/3MF/STL/OBJ/DXF; FreeCAD/Rhino import; autosave and crash recovery; desktop and browser (wasm) app, CLI, MCP server. |
| Features | Every registered command by area — [docs/features.md](docs/features.md) (`cargo xtask features`); registry breadth does not establish workflow or geometric depth |
| Reference tests | [Current case count and results](docs/oracle.md): public cases in `tests/oracle/` with analytic or FreeCAD-computed expected values, checked by the reference-test job in [CI](.github/workflows/ci.yml). |
| Tests | Solver, profiles, kernel booleans/blends/measures, expressions, timeline, persistent naming, file formats and migrations, assemblies, sheet metal, engine end-to-end, hostile-input fuzz over every command and headless UI scenarios. `cargo xtask ci` reports the current passing counts. |
| Gates | `cargo xtask ci`: fmt, clippy -D warnings, tests, asset attribution, layering, wasm32 build — green |
| Time to a useful alpha (M0–M6) | ≈ **82 agent hours** remain (sum of the M1–M6 estimates below) |
Optional GPU viewport ambient occlusion is available in View settings and Graphics preferences,
with persistent settings, Performance/Quality graphics presets (Quality combines AO, Fine tessellation and adaptive 2× supersampling; manual combinations show Custom), and the
`view.ambient_occlusion` and `view.graphics_preset` commands. See [display settings](docs/ambient-occlusion.md).
## Scope (owner decision, 2026-10-07)
The issue batch adds preservation of blind pockets in through cuts, associative projected
offsets and plastic face placements, XZ (+Y normal) with a v6 migration preserving older
designs, interactive angle placement, file-picker sketch imports, Home/recent controls,
toolbar overflow, and the remaining component-frame and assembly WIP work. The enclosure
acceptance scenarios now include board resizes; STEP reopening is automated. See [WIP reconciliation](docs/wip-reconciliation.md).
Face-based Extrude Join (including outward Press Pull) now defaults to the picked body,
so edge-touching assembly neighbours stay separate regardless of body order (#226).
In scope, in priority order:
1. SOLID and SKETCH (now).
2. Parameters / functional constraints (done: see `parameters.*`, `expr.evaluate`; unit-aware,
cycles reported with their path, rename follows references, deletes list their users):
user parameters with units and expressions (+-*/, ^,
parentheses, sin/cos/sqrt/min/max/floor/ceil/round, unit conversion), model parameters
d1, d2… for every dimension and feature input, references between parameters with a
dependency graph and cycle detection, re-evaluation on change, comments, favourites,
CSV/JSON import and export.
3. Components and assemblies with joints (M9).
4. SHEET METAL: flange, contour flange, bend, unfold/refold, flat pattern with DXF export, sheet
metal rules (thickness, K-factor, bend radius) (M10).
5. A small PLASTIC subset for enclosures, after sheet metal: Boss, Rib, Web, Lip/Groove, Snap Fit.
Deferred until further notice: MESH, FORM (T-splines), SURFACE beyond what solids need (scope proposed in #772), PCB / 3D
PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
## Milestones
| # | Milestone | Status | Hours left |
|---|---|---|---|
| M0 | Vertical slice | done | — |
| M1 | Sketch depth: command coverage implemented — projection/intersect/include as linked geometry (auto-project on faces, lost-reference warnings, break link), full SKETCH create/modify set (slots, polygons, tangent arcs/circles, ellipse, fit/control splines, conic, text, trim/extend/break, fillet/chamfer, offset, mirror, patterns, move/scale, blend curve, centerline), constraints incl. curvature and polygon with over-constraint refusal, 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, 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 |
| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0, plus `crown`), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane, a face or sketch curves swept onto the surface, keeping a side or the picked regions; mutual trim of two surfaces, extended to meet and stitched, #767), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis, Surface Extrude/Revolve/Sweep/Loft from open and closed curves, Offset, Unstitch and Reverse Normal; the continuity-driven core (`kernel/src/bridge.rs`, G0–G3 Hermite rows) with **Blend** (`surface.blend`, #764: edge chains along one or several faces, closed rims paired seam to seam or from picked seams, a dialog), **Match** (`surface.match`), **Boundary** (`surface.boundary`, #763 — two sketch curves exact with G0–G3, three or four as the flat patch of their loop, and curve networks: Coons and Gordon surfaces through sketch, 3D-sketch or edge curves, a point for a three-sided side, G1/G2 against the faces along one direction's edges, joined into their body on request; G1/G2 on two sides that meet at a corner waits for the Gregory-type corner), **Fair** (`surface.fair`, CV fairing), **continuity analysis** (`inspect.continuity`, #770; a toolbar tool with edge markers and a legend, and curvature combs along face isocurves) and a multi-section **loft** with per-end continuity and crown, and guide rails on solid and surface lofts (one or two, scaling or stretching the sections between; #201). Also in the Class-A pass: G1…G4 blend curves in the sketch, chord and variable-radius fillets on curved edges (with honest refusals where the fit cannot hold), and a G2 corner patch where filleted edges meet (kernel; the command refuses until the patch's sections can be sewn to the blends' contacts). Left: bias/form-factor fillet sections (design recorded), a Gregory-type corner, surface Fillet between separate surface bodies (#769), Ruled (#762), Untrim (#761), curved-face Extend (#760), and every remaining #757–#772 item | deferred | — |
| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, closed kinematic loops (four-bar, slider-crank: the loop's other joints follow the driven one on its branch, and driving stops where a loop can no longer close), cams (`joint.cam`: a revolute cam drives a slider or revolute follower along a closed sketch profile or the cam's edges, knife, roller or flat followers, worked out again when the profile changes; drives, studies, closed loops, contact sets), As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), motion analysis (joint values and a point's path charted over a study as value, velocity and acceleration; the parts that collide along a study, with the steps where they do), Exploded views (automatic or by hand, shown without changing placements, and exported as an animation coming apart and back together); interference, per-component physical properties; STEP with assembly structure; URDF export for robot simulators (links with meshes, mass and inertia; joint chains whose values match Drive Joints; docs/urdf.md). Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link, Cam and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done. PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line); flanges meeting at a corner closed at any angle with the rule's gap; edge flanges with their own angle and height per edge in one feature; corner reliefs (notch, round, rectangular, tear) chosen by the rule; Convert to Sheet Metal from a solid or shelled body by its bend edges (flat faces, sharp bends) | in progress | 36 |
| M11 | 2D drawings | deferred | — |
| M12 | Release: cross-platform packages, verification and deliberate publication implemented; original current-UI demo refreshed. Trusted distribution signing and broader performance work remain | in progress | 16 |
| M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (config.configure, config.table, config.column/row/activate). Per-row body materials and appearances (library looks, custom colour/opacity and clearing overrides), undoable activation and save/reopen: done. Ordered typed configuration rules, atomic validation and structured rule editing: done. Component appearance and certified semantic face style columns, atomic preview/apply and independent-copy remapping: done. Exact constructed sketch-side and immutable imported-face lineage now preserve legacy names and reject retargeting. Left: ambiguous shifted/two-sided caps | in progress | 4 |
| M13 | Never lose work: atomic saves (temporary file, flush, rename; a crash at any moment leaves the old file or the new one) with the replaced version kept as `<file>.bak`; autosave of unsaved changes to the recovery folder every 5 minutes (preference) and after big operations, only when something changed; "Recover unsaved design?" on the next launch (`doc.recovery_list`, `doc.recover`, `doc.recovery_discard`); a kill-mid-save test (process killed while saving a 0.5 MB design 25 times). File format: `"format": "solidcraft/12"`, older formats upgraded step by step (`doc::format`), newer ones refused with a message, out-of-range numbers and broken placements refused; a corpus of designs saved by M0, components-era, joints-era and current builds (apps/solidcraft-cli/tests/corpus) that must open and evaluate as saved; damaged and hostile variants (truncation, bit flips, hostile values, removed keys, repeated features, deep nesting) never panic | done | 6 |
| M15 | Frames and weldments (#4003): structural members along 2D and 3D sketch lines, butt and mitre corners, trim to faces and bodies, end caps, gussets, cosmetic welds and a cut list. Done: the profile library (`frame.profile.list`, `frame.profile.properties`, `frame.profile.insert`): bar, round bar, rectangular and round tube, angle, channel and I-beam, each SolidCraft's own parametric definition with exact fillet arcs and exact section properties (area, centroid, second moments), tested against hand-computed values; members (`frame.member`): one body per straight line of a 2D or 3D frame sketch, following parameter edits, with a built-in profile (its dimensions parameter expressions) or a profile sketch (its drawn material by alternating nested loops), anchor, angle and mirror; each member's name kept with its line. Left: corners, trims, end caps, gussets, welds, cut list, FRAME tab and dialogs | in progress | 60 |
M6 navigation preferences include **Touchpad**: Shift + pointer movement pans, Alt/Option +
pointer movement orbits without a mouse button, and vertical scrolling zooms. Other presets
retain their saved two-finger scroll preference; camera motion is available through
`view.navigate` and modifier-aware `ui.move` automation.
With the **Fusion** preset and two-finger pan enabled, two fingers pan and Shift + two fingers
orbit freely, with pinch zoom and mouse bindings unchanged. The shared navigation handler
applies the same gestures to model and Render views and blocks orbit on flat pages.
UI redesign (#2016): the File/start view (#2027) uses shared light/dark navigation,
action tiles, recent rows and sample cards, with recovery review and Save As/Export
entry points. Document loading, sample caches and recovery safety retain their
existing command paths.
M11 precursor: File → Export Current View as DXF exports shown placed bodies and sketches from the current
camera as a 2D millimetre drawing, with hidden edges omitted or dashed. Vector occlusion splits
partly hidden edges and includes curved silhouettes. Repeated part meshes and triangle
hierarchies are shared; bounded desktop preparation and occlusion use up to eight workers.
Curves are approximated; drawing sheets,
annotations and exact projected curves remain deferred. See [view DXF](docs/view-dxf.md).
Drawing foundations: [RFC #860](https://github.com/tangency-labs/solidcraft/issues/860)
and the [foundation ledger](docs/drawing-foundations.md) track the bounded implementation
packages. The first geometry increment defines validated section records in a plane-local
frame; the ledger separates that contract from section producers and the Drawing workspace.
Desktop view DXF export runs in the file-operation worker with five-stage progress and
cooperative cancellation. Curve tolerance (0.0001–1 mm, default 0.01 mm) is remembered alongside
the hidden-edge setting. Cancelled exports preserve the model and the previous destination;
completed drawings publish atomically. The browser still exports synchronously.
## Interactive modelling
- Modelled threads preview their cut before OK using the background worker on desktop.
Browser users apply the cut with OK to avoid blocking on every edit.
- Thread resolves a suggested metric size when a cylindrical face is picked. Diameter and
pitch selectors expose the existing metric designation parameter; custom designations stay
editable, and Automatic size returns to the face's suggestion. Cosmetic/modelled and
full/partial-length options remain available. This does not add tolerance classes or
left-hand threads.
- New Body extrusion and face-offset previews show completed intermediate geometry while a
drag continues, then catch up to the exact final value. Changes to the selected geometry,
operation or feature still discard obsolete results. Distance arrows and their value boxes
follow the current frame's input.
- Large fine modelled threads use pitch-based helix resolution and tighter faceted
reconstruction fallbacks, preserving thin crests and closed shells on large shafts.
## Physical material data
- Physical material card validation (#923/#922) provides typed SI mechanical and
thermal data, per-property sources/conditions and isotropic/orthotropic stability
checks through read-only `material.properties.validate`. It does not assign or
persist properties, provide a solver or enable rendering; see
[the contract and staged follow-ups](docs/physical-material-cards.md).
- Directional elasticity (#994) queries the validated card in material-local axes
through read-only `material.properties.directional`, with source/condition review.
It does not assign body frames, predict structural behavior or enable a solver.
## Library foundations (#785, #786, #787)
- Local scalar libraries (#846) inspect/export bounded portable catalogs and copy exact
selected appearance/material revisions into existing design definitions in one undo step.
Appearance offers local loading/search/export; physical materials use existing commands.
This slice is independent of the render stack and does not change the document format.
See [the contract and limits](docs/book/src/dev/local-libraries.md).
- Proposed phases cover offline bundled/project/user packs, physical materials and
appearances, editable wood mapping, and parameterized component/assembly families.
Stable identity/revision, copied design definitions, provenance and resource limits
apply from the first implementation. Existing material, appearance and standard-part
commands remain the application paths. See [the proposal](docs/book/src/dev/libraries.md)
for source/license review and acceptance gates. This is planning only: pack loading,
texture rendering and expanded component families are not implemented by this entry.
## Render foundation
- Original starter appearances (#1722): 34 opaque recipes through existing commands, with six explicitly unavailable glass recipes and preserved legacy looks; see [the contract](docs/render-starter-appearances.md).
- Bounded linear film accumulation (#1696) supplies caller-admitted mean/variance storage and complete-pass views; actual progressive consumers remain separate. See [the film contract](docs/render-film.md).
- Definition-local visual mapping (#1214) supplies a bounded numerical frame, physical repeat coordinates and a deterministic one-time grain-axis suggestion. It adds no saved record or texture consumer; see [the mapping contract](docs/render-visual-mapping.md).
- Explicit texture normalization (#1218) produces completed float RGBA with
role-aware linear color/data semantics, bounded matrix/TRC ICC conversion and
opt-in EXIF orientation. It retains high-precision raw samples and records lossy
gamut clipping only when explicitly requested. It is not a renderer/resource
consumer; see [the supported policy and limits](docs/render-texture-normalization.md).
- Opt-in opaque PBR (#1046) connects persisted surface parameters to the integrated
product viewport and CPU PNG, using a fixed direct-light studio. Geometry and mass
stay unchanged. HDRI, transmission and textures remain separate gates.
See [the consumer and limitations](docs/render-opaque-pbr.md).
- Combined integration is tracked in #842, with independent scalar libraries in
#863 and richer PBR/material-card transport in #1086. See [phase boundaries and validation gates](docs/render-integration.md).
Render/library consumers use the merged asynchronous picker (#1127). Material-card
UI (#1094), texture mapping and HDRI lighting remain explicit follow-ups.
- R4a adds bounded CPU product-image jobs, cancellation and atomic PNG export through
engine commands, the Render panel, CLI and persistent MCP. Immutable captures and
results stay outside persistence/undo; native workers and cooperative browser tasks
share the same work protocol. See [jobs, limits and export](docs/render-jobs.md).
- R3 adds an overlay-free product scene and the integrated Render workspace, with
runtime GPU host capabilities, per-document drafts and named camera/background
controls. Design keeps its camera and drawing policy. Raster transparency remains
approximate; PBR and environments are later slices. See
[workspace and scene contracts](docs/render-workspace.md).
- R2 adds bounded named render setups in document format 21 and the read/save
`render.settings.get/set` commands. Proven render-only changes preserve geometry
and prepared world data through undo/redo and checkpoint restore. This slice does
not add a Render workspace, PBR, environments or image jobs; see
[settings and refresh policy](docs/render-settings.md).
- Bounded PNG/JPEG texture admission (#900) validates in-memory sources and returns
completed RGBA8 pixels with explicit color/data roles. It is an input utility only:
ICC/16-bit normalization, local wood mapping, resource persistence and the renderer
consumer remain separate work. See [limits and licensed-file probes](docs/render-texture-input.md).
- The initial opaque surface numerical contract for #1004 provides bounded linear
color/metallic/roughness/IOR admission and an original tested direct-light BRDF.
The numerical module alone advertises no capabilities; the installed consumer above
provides rendering. See
[the model, evidence and remaining consumer gates](docs/render-surface.md).
- A bounded cooperative Radiance RGBE input utility is available in L1 render;
it does not yet enable HDRI/PBR rendering or a document import command. See
[the input contract](docs/render-hdr-input.md) and #813 for integration work.
- R1 adds renderer-independent backend/profile metadata, strict request resolution and
bounded image/scratch admission checks, exposed by the read-only `render.backends`
command. It describes the current CPU raster snapshot path without evaluating the model.
The legacy Design snapshot route remains unchanged. Later slices above provide the
workspace/jobs and opaque PBR; HDRI and optional engines remain future work. See [the contract](docs/render-backends.md)
and [the phased proposal (#620)](https://github.com/bherbruck/solidcraft/issues/620).
## 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.
- Sketch Create groups drawing variants under Line, Rectangle, Circle, Arc, Slot,
Polygon and Spline. Each open part remembers its last interactive choice per family
across sketch sessions and document-tab switches; closing the part resets these UI choices.
- Inscribed, circumscribed and edge polygon tools preview their actual outline before
placement, including orientation and the selected side count.
- Construction (X) preserves the active sketch drawing tool, picked anchors and inline
dimension values, so line type can change while a shape is in progress.
- Planar sketch construction previews and hover feedback keep the same dashed pattern as
committed geometry; centerlines retain their dash-dot pattern.
- Point has a default Sketch CREATE toolbar button alongside the existing drawing tools.
Saved custom layouts are preserved; Reset Toolbar restores the default Point button.
- Construction planes cannot take an origin-plane name (XY, XZ, YZ, Top, Front, Right). Plane
arguments reject such a name when an older design's construction plane already has it. Use
the other alias, the construction feature's numeric ID or an explicit plane; existing
stored references keep their saved meaning. Section Analysis uses the same check.
- Sketch text can be edited from a letter interior or outline's context menu or the Edit Text command.
Interior selection requires every profile boundary to belong to the same text source;
the editor highlights the whole source without replacing profile selections.
Content, baseline X/Y, height and rotation update the original text source in one undo step;
cancelling or rejecting an invalid value preserves the sketch. Position and height fields
use the document units captured when editing starts; generated outlines remain associated
with their text source, and untouched inputs preserve their saved internal values.
Dragging a text outline moves the entire source in one undo step; Escape restores the
gesture. Move/Copy on a selected text outline provides unit-aware X/Y distances and rotation
about its baseline; copies keep an independent editable text source.
- Repeating the initial point of a planar multi-point sketch drawing tool is neutral: the tool
waits for a distinct point without a degenerate-shape error or history entry. Typed input
and programmatic commands retain their validation.
- Circular-pattern and angled-plane axes reject explicitly supplied malformed or expression
origins instead of silently rotating about zero. Numeric origins remain in millimetres;
associative axis expressions and construction-axis references remain unsupported.
- Single-line and rectangle-side dimensioning supports selecting a line or its two endpoints.
Type a value and press Enter to commit one undo step; rejected values remain editable and
cancellation leaves the sketch unchanged. Fit keeps geometry clear of the Sketch Palette
and navigation controls, including enlarged displays and nested pending placements.
- Sketch Dimension takes a line and a circle or arc (#1803): it measures from the line to the
circle's edge (the gap, 0 when tangent, or how far the edge reaches past a line that cuts
it), drives it like any other dimension and keeps the circle on its side of the line.
- Sketch 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.
Component/canvas distance controls, sketch cursor coordinates, flat-pattern dimensions
and pocket depths, interference volumes and parts-library lengths also use design units.
- Curvature and Polygon constraints accept complete preselected operands. Fix/Unfix
consumes complete point/curve selections consistently; mixed or ambiguous selections
retain the picking workflow. Rejected constraints preserve selection and undo history.
- Contextual error reporting: a **Create Issue** action beside feature errors opens a
reviewable GitHub issue draft with the error, operation settings, build diagnostics and
useful reproduction context. The draft is editable, with optional capture of the actual
viewport and copy/save controls. Opening GitHub does not submit the issue. Help also exposes
reporting for unexpected results that did not produce an error.
- The header exposes **Report a bug**, including an accessible compact control on
narrow windows. It opens the same editable report and screenshot review without
changing the active model or a pending dimension.
- Crash reports: every panic (in a command, in the kernel, anywhere else) and every unclean
exit (abort, stack overflow, kill: found at the next start through a locked per-run command
journal) leaves a local report with the backtrace, version and commit, the command and the
design that reproduce it (`solidcraft-cli crashes replay <id>`). Repeats are counted, not
rewritten; at most 20 reports, 50 MB and 90 days. The app shows them after a crash (Help >
Crash Reports…) with a reviewed issue draft; agents read them through
`diagnostics.crash_reports`, the MCP `crash_reports` tool or the CLI. Nothing is sent
anywhere. See [crash reports](docs/crash-reports.md).
- Agent chat (off by default, desktop): a side panel that drives a coding agent the user
already has (Claude Code through its ACP adapter, Codex, Gemini CLI, OpenCode, Grok Build, …) over the
Agent Client Protocol, under the user's own login; SolidCraft stores no keys. The agent works
through SolidCraft's MCP tools on a gated endpoint of its own: file actions wait for Allow or
Deny, crash reports are off limits, 24 changes per message, one undo step per message, and
the agent's whole process group ends with the panel or the app. Docks left or right,
minimises to a strip. Grok Build discovery includes `~/.grok/bin` and reuses the user's
Grok login. See [agent chat](docs/agent-chat.md).
- Command scripts from designs (#886): `timeline.export_script` (CLI `solidcraft-cli script`)
writes a design's history as a script that `eval` and `run` replay into the same design —
user parameters, units, sheet metal and plastic rules and materials as their own commands,
then each feature through `timeline.add` with its ids, names and dimension parameters kept,
so every feature kind replays exactly and a failing feature fails the same way. Trimming the
script after a step gives the design at that step. Appearances, folders, views and
configurations are listed as left out; components and joints are not supported yet.
## 0.2.0 release batch
- Includes the new axis navigation, adaptive sketch drawing/picking, origin-corner repairs,
durable supported profile/face references, endpoint dimensioning and contextual reporting.
- Smooth ruled-wall display joins are hidden only within the supported geometric certificate;
authored splits and actual creases remain protected. Wireframe retains physical edges.
- Paste New uses a bounded current-geometry comparison for supported convex line/circle
prisms with separated circular holes; unsupported shapes retain the existing strict check.
- Common-bore Union retries check additional boundary curves before recutting the bore,
preventing the reproduced thin-web loss. Unknown additional trims refuse; inherited
common-cylinder trims retain their existing policy, so this is not a general Boolean proof.
- Exact full-path assembly joint editing remains separate work and is not part of this batch.
- Stable 0.2.0 is published: all release payload checksums, native cold reopening, deployed
web app and both Flatpak architectures were verified. The replacement README demo uses
the original electronics enclosure story and animated pointer in the actual English
0.2.0 UI, at 4× playback. Publication follows [the release workflow](docs/releasing.md).
## 0.2.1 critical fixes
- Separated multi-profile Join/Cut extrusions into one solid target batch their tools using
conservative construction bounds, avoiding a Boolean rebuild for every profile. Live
extrusion previews build only their tools; confirmation validates the final Boolean.
- Coplanar selected body faces share one linked sketch and one extrusion. Automatic Join/Cut
keeps the evaluator's participant scope, including parts of an empty imported component. Desktop OK/Enter
computes on a scratch session in the background, with atomic acceptance and cancellation.
Planar section stitching projects lines/arcs analytically and removes duplicate endpoints.
- Imported rotated face groups can check all full start-face seams in one guarded
construction. Separated tools may batch a Join when each meets the same single body;
nearby uninvolved bodies retain the original contact decisions. Regression comparisons
cover all four extrusion operations.
- Inward multi-face cuts retain every eligible target and subtract separated tools together.
Safe interior classification handles roundoff at shared imported line/arc endpoints.
Straight extrusion feedback caches existing face/profile triangles and updates the display
directly, including a newly opened STEP's single identity component placement; other
extents, moved or repeated components and curved sketch profiles keep the Engine preview.
- The extrusion fixes retain tracked Boolean history and the current `solidcraft` packages.
Confirmation and tool previews use main's shared background calculation. Main's collapsed
blend-wall refusal and its tests are retained, including smaller valid radii and mirrored parts.
- Axis view and navigation controls stay anchored to the viewport when feature dialogs or
the command palette open, at normal and enlarged display scales. Fit still accounts for
dialog coverage; covered controls do not receive clicks through dialogs.
- Eligible planar faces survive fillet, chamfer and shell operations with exact source
certificates. Sketches placed directly on the enclosure wall and inner floor follow
width and wall changes, including after the USB cut and cold reopening.
- Remove artificial straight-wall and rounded-corner subdivisions left by shell and
extrusion cuts. Identical generated wall segments span adjacent slabs as one physical
face, with exact full-height supports and boundary stitching. Preserve authored face
splits and face appearances. Permanent regressions cover material, STEP, reopening and
unaffected bodies across the extrusion end plane.
- Feature dialogs use the available screen height for their fields, with OK and Cancel
outside the scrolling region, including enlarged display scales.
- Design format 13 records shell-face attachments. Older designs remain readable;
newly saved designs require 0.2.1 or later. Ambiguous or unsupported faces still refuse
attachment rather than guessing a plane.
- Saved projections from a removed strip retain their geometry and report the lost
reference. Explicitly reselect the wall to repair that projection.
## Workspace layouts (#2029)
- General preferences save Floating or Compact using the universal UI containers.
Compact joins the workspace selector and ribbon, docks the content-hugging Browser
flush beneath them with a square join, and opens tool sheets below the compass.
Sheets remain draggable, rounding all corners away from their right dock; footer
backgrounds follow the same corners. A separate preference hides ribbon icon labels
for a shorter ribbon in either layout.
Assistant keeps the outermost edge and History keeps its chosen dock. Switching
retains the active tool, camera, selection and design; system windows and menus
keep their existing behavior.
## View navigation
- Navigation has an opt-in Free orbit preference for mouse and 3D-mouse rotation through
both poles with roll. It defaults off, preserving existing preset snapping and pole limits.
The SolidWorks face menu offers Normal to Selection (`view.normal_to`), shared with Look At
and Ctrl+8, including repeat-to-flip, preserving scale and projection.
- Desktop Ctrl/Cmd +/- and 0 change or reset the saved UI scale. The scale slider keeps
its layout stable during a drag and applies the selected scale on release.
- OpenSCAD navigation preset: left drag orbits, right/middle drag pans, Shift+right/middle
drag zooms, and the wheel zooms. Available in the shared desktop/web Navigation preferences.
- Compact clickable signed X/Y/Z axis tips, smooth drag-to-orbit, and Shift-orbit snapping
to 26 standard and diagonal views. Pointer motion accumulates independently of a lightly
damped spring, with a boundary margin to prevent flicker. Z-up/Y-up and the nearest square
top/bottom heading are preserved. Diagonals include 45-degree edge and isometric corner views.
## Known limitations
- Scheduled and manual CI scan locked dependency advisories, bans and sources with
cargo-deny. Vulnerabilities fail the scan; unmaintained crates warn. Local vendored
patches require separate upstream review because advisory databases cannot version-match
their path dependencies. Pull requests retain the existing licence checks.
- Associative shell-face sketches require a unique certified planar source. Nested shell
attachments and ambiguous coincident sources remain unsupported.
- G2 fillets: exact quintic blends of straight edges between planar faces, with one planar
end face at each end, including sloped ends (#2708). The UI and `solid.fillet` expose
`curvature` (alias `g2`); the size is an equivalent circular setback. Curved faces/edges,
multi-face ends and corner junctions remain unsupported.
- Fillets and chamfers: constant, variable-radius and chord-length fillets; a radius per edge,
rounds next to earlier rounds of another radius, and smooth corner patches where three rounds
meet (a sphere for equal radii); perpendicular corner fillets after a closed ring of earlier
rounds preserve the ring's radii and the enclosed planar face (#1557);
mitres between separate rounds of the same radius keep their endpoint tangent, so a later
fillet can select an upright and its adjoining bottom mitre as one chain (#2422);
straight edges (convex or concave) between planar faces with perpendicular
planar end faces (equal-distance chamfers also meet sloped planar end faces, including inner
basin rims within the rim width); convex fillets also meet sloped planar ends and reflex
corners around pocket rims, with unequal wall slopes and extensions confined to the face;
connected equal-distance chamfers also trim each other across unequal sloped rim walls;
curved chamfer chains with straight planar ends can terminate across two existing planar
bevel faces, splitting their shared seam; picks on the same tangent chain are applied once (#1303);
whole smooth loops of a planar face, picked whole or by one edge (tangent
chain), including circles and walls at any angle (boss bases and tops, bores, pocket floors,
plate outlines), loops with sharp corners between straight or mixed arc/line edges (straight
blends meet in mitres; perpendicular curved walls use offset intersections, with D-rim removal
checked within 0.1% of analytic offset slices), runs of
neighbouring edges along a planar face's loop (two top edges of a box: mitred, ending square;
with the edge between them too, the corner becomes a sphere octant),
rounded corners rounded again (sphere octants, tori at concave corners); every edge of a
convex planar body or of an extruded part; fillets inside patterns; closed chains of curved
edges between any two smooth sides (a branch pipe on a main pipe), by a rolling ball (the
blend a rational B-spline through exact arcs), closed or ending at planar faces square to the
edges, where straight or curved cap edges are trimmed to the contacts (the straight edge
between two overlapping extruded arcs); knife edges, where the two faces have opposite
normals (a wall that runs out tangent into a face: the ball that fits there); an edge that
runs out along an end face tangent to one side (the arc top edge of that wall), cut where the
blend meets it; rounds that end in an earlier round on curved faces, in any order (the rounds
of two edges at a vertex meet in a crease, as on a box), picked together or one after the
other, except a round after a round of the edge between the wall and a flat face that it
would run over; the three edges at a vertex of any smooth faces, of one radius, end in a
piece of sphere. A limiting blend that collapses a side wall into coincident boundary
curves is refused as too large rather than returning an unmeshable face (#1113).
- Booleans: prisms along one direction (plates with holes, slots, patterns of them, equal
sections stacked end to end) are combined exactly in 2D. Qualified axial cuts preserve
annular holes with overrun cutters, including inward Press Pull after a joined extrusion;
curved wall qualification rejects false transverse axes instead of filling holes.
Bodies touching along one whole face
(a part and its mirror image) are stitched; coincident planar faces are pushed apart when
their neighbours are planes or cylinders. Other coincident curved configurations can still
fail. Bounded containment classification preserves fully internal cavities and nested
material islands independently of shell order, with STEP round-trip and material-membership
regressions. Near-contact curved shells use the general boolean path rather than a
containment shortcut. Spheres are built from six pole-free patches.
- Mass properties come from fine tessellation (curved faces within ~1e-4 relative); triangles
that cut through curved faces (trimmed faces after booleans) are split onto the surface first.
- STEP import (our own reader, crates/kernel/src/step_in) reads solids (B-rep with analytic and B-spline geometry), units,
product names, colours and assemblies; analytic offsets on planes, cylinders, cones, spheres
and regular tori are read exactly (signed/nested distances, units and face sense). Freeform
offset surfaces and pcurve-only edges are not read yet,
and assembly components are flattened into bodies. Closed periodic faces (torus, B-spline
bands) are split in two for meshing; measure() uses a finer tolerance for small radii.
- Persistent naming follows the kernel's exact face history (docs/book/src/dev/naming.md):
references resolve by name first, follow splits and merges, and warn when the entity split,
merged or vanished, or was found by geometry only (operations that report no history yet).
New planar sketch attachments use exact box, extrusion cap/side and immutable imported-face
source certificates with checked target and author occurrence paths. A missing or changed
source fails explicitly; Redefine Sketch Plane previews reattachment or a fixed plane while
retaining current geometry and dimension identities. Unsupported modified faces need an
explicit fixed plane. Legacy attachments retain their original resolver behavior.
- Modelled threads are cut as a faceted body (curved faces within 1/200 of the pitch); Extrude
and Revolve To Object on a curved face cut the tool back to the target's surface.
- Explicit sketch breaks preserve exact ellipses, fit/control splines (including high degrees)
and conics; analytic trim/break also sees free-form cutters. Free-form curves cut at a tangent
touch (no clean crossing) enter that profile as polylines.
Sweeps along 3D sketch curves follow their polyline (smooth walls when the path is smooth).
A sweep with a twist, an end scale or a parallel or direction orientation (#771) follows its
path that way too, even along sketch lines and arcs: B-spline walls through stations, ruled
walls through denser stations where the path has corners or the profile has holes.
Guide rails (one or two, #771) do the same: each station meets every rail once, and the rail
turns the profile and scales it (or stretches it across to the rail) against the start; two
rails carry it between them. Rails along a path with sharp corners are refused for now.
- Extend intersects exact ellipses and rational/high-degree splines without moving the line or
circular support. Associative free-form offsets have a whole-span 0.002 mm normal-distance
certificate and refine within bounded node limits while retaining existing IDs.
At the node limit, geometric certificate subdivisions accommodate unequal spline parameter
speeds without changing stored nodes or relaxing the distance tolerance (#971).
Associated offset fit nodes stay hidden from display, picking and snapping; dragging the
editable source updates the derived boundary live. Break Association restores point editing.
Singular, self-intersecting and ambiguous curves refuse atomically; mixed junctions and nonsmooth
closed natural-spline seams remain limitations. New explicitly closed fit splines and closed
offset outputs use periodic C2 interpolation, supporting repeated certified offsets; older
natural splines are preserved. Clicking the starting fit point deliberately closes a new loop.
Newly captured adaptive profile references with complete source coverage retain their outer
and hole identities through span-count changes, including parameter edits after extrusion.
Partial, intersected or ambiguous boundaries do not qualify for these whole-source references.
Legacy curve-span multisets remain exact and unchanged; already-lost selections may need
reselection. Format 12 stores the new bounded whole-boundary references.
Projected analytic boundaries follow nested loop identities through resizing, including
stationary holes and rounded corners. Supported convex, tangent line-and-arc boundaries
can lose or regain corner arcs during an inset while preserving reserved curve identities
and linked profile selections. Format 12 saves those associations; compatible older closed
offsets upgrade structurally without changing their curve IDs. Breaking an association
freezes its current boundary and materializes downstream profile references. Child offsets
of a boundary with retired arcs still require breaking its association first; mixed or
nonconvex junctions and retained references to disappearing individual arcs remain limited.
- Extrude and Revolve editors retain original direction, extent expressions, explicit targets,
axes, From/To inputs and All-profile intent. No-op edits preserve downstream dimension names
and selected occurrence frames; cancel and successful edits preserve one-step undo history.
Placed feature edits preview through the edited timeline entry, and cylindrical Emboss guides
use the sketch's actual target, author or uniquely placed third-component frame.
Revolve rotators follow the actual signed axis and support symmetric and two-sided extents;
dragging a To Object extent converts its derived value to an explicit angle. Legacy reversed-axis
symmetric geometry is preserved through format 11's explicit legacy convention. New revolves
use the sketch-normal direction for each selected profile, including To Object and curved
target trimming, independently of the selected axis sign.
- Cylindrical emboss/deboss wraps bounded orthogonal line profiles, including holes, concave
outlines and nested islands, onto complete exterior cylinders or coaxial sleeves with exact
radial walls. New features infer a unique eligible cylinder when the face field is empty;
ambiguous targets require an explicit face. Saved target
and author occurrence paths retain the chosen placements; missing/split faces refuse rather
than retarget. General free-form profiles, seams, stepped cylinders and other curved surfaces
remain unsupported. STEP round-trip and independent radial/volume checks cover this subset.
- Shell and offset faces work on bodies of planes, cylinders, cones, spheres and tori (convex or not:
filleted boxes, bosses, domes, countersinks; corners where four or more planes meet, such as a
floor falling to a drain, as long as the corner's edges are all convex or all concave); draft on convex planar bodies and on walls (planes, cylinders → cones) between caps; lofts through three or more sections are smooth (B-spline sides through every section), two
sections ruled. Circular-meridian barrels retain curved inner walls through either axial
opening (#2421). Solid lofts preserve holes through sections with matching hole counts
(#2435), including inner walls and caps with openings; a hole cannot appear or disappear
between sections, and sections with holes cannot collapse to a point.
- Sketch corner tools: fillet and all three chamfer modes pick a connected corner even when
its endpoint overlaps the fixed origin or a loose point. Ordinary drawing still snaps to
the origin; clicked UI regressions verify geometry, closed profiles and undo.
- Sketch display: drawing, picking, highlighting, tool previews and profile shading share
adaptive projected curve boundaries. Fit, zoom, display scale and placed sketch frames
invalidate the display cache without changing CAD geometry or timeline history. Work
budgets and clipped paths prevent unbounded sampling or invented edges across the eye plane.
The Sketch Palette scrolls its options on short windows and enlarged displays while
keeping constraint status and Finish Sketch visible.
- Face projection: closed boundaries have a deterministic outer-first order. Refresh matches
existing geometry before assigning positions, retaining saved curve IDs and constraints
across enumeration changes and resize, including concentric circles. Ambiguous identities
retain the last geometry and report a lost reference; permutation matching is bounded.
- Project accepts complete supported preselection in one undo step. Visible external sketch
curves and points retain their source sketch identity; model geometry, axes and planes use
the existing linked projection commands. Rejection preserves the document and selection;
mixed selections retain ordinary tool-first picking.
- Inline sketch dimensions: Tab and Shift+Tab cycle within the active shape's value boxes,
selecting visited values for replacement. Expression completion retains forward-Tab
priority; real keyboard regressions cover rectangles, lines and single-value circles.
Typed lengths, radii, line angles, signed arc sweeps and slot lengths persist as constraints
and expressions through save/reopen. Shape creation and its dimensions form one undo step;
rejected values keep the editable field focused.
- Repeated components: Browser selection, visibility, properties, copy and Move/Copy use
checked occurrence paths. Active nested occurrences author planar sketches and solids in
their selected frame. Pending moves compose through parents; dialog cancellation restores
its selected move. Source materials and body/face appearances follow transformed instances.
Shared nested definitions remain shared. Deep Paste New copies reachable definition graphs,
independent feature/input histories, styles and internal motion records, with bounded geometry
checks and explicit external-link notices. Paste New selects the copy while preserving the active
authoring context. Imported periodic STEP seams survive placement and reexport with shared
topology and appearances intact. Recorded body transfers preserve historical Join/Cut membership,
material, placements and machining data through edits, copying, undo and reopen. New 3D wires
record their coordinate frame; legacy wires migrate from known provenance without changing
coordinates. Broken legacy links with missing provenance can require explicit refresh or relink.
See [occurrence workflows](docs/occurrence-workflows.md).
- Toolbar drops on panel labels, gaps and margins append commands with insertion feedback;
button drops keep insert-before ordering.
- Parameters and Configurations have distinct actions in the SOLID toolbar's CONFIGURE panel.
Parameters opens the user/model variable editor; Configurations opens the configuration table.
Configure remains available in the panel dropdown.
- Parameter creation supports Enter with validation and continued keyboard entry. Shared
expression autocomplete supports Up/Down selection, Enter/Tab acceptance and Escape dismissal;
accepting a suggestion does not submit a parameter or trigger viewport shortcuts.
- Movable window title bars follow the app's dark/light header palette. Radial right-click
menus are opt-in in Preferences > Navigation; ordinary context menus remain the default.
- Desktop distribution: running Windows windows and executable resources use the app icon;
packaging validates icon resources. Nix installs launchers, MIME data and generated icons,
with explicit launch apps and Vulkan-loader/driver discovery. Flatpak feed instructions
register the required runtime source; release checks exercise desktop modelling on X11/Wayland.
- OpenGL rendering clamps effective anti-aliasing to 1x across UI pipelines, textures and CAD
callbacks to prevent black frames. Other backends retain the requested sample count, and
stored preferences remain unchanged.
- Configuration rules: ordered typed conditions can drive parameters and suppressions or
restrict values. Preview explains effective values without modifying the document; applying
rules validates rows atomically. Length expressions retain their authoring units across
document-unit changes. The UI supports structured All/Any/Not conditions and rule ordering.
Component styles and certified semantic face styles are available; unsupported lineage refuses
atomically, and stale certified face paint cannot jump to another surface. See [configurations](docs/configurations.md).
- Sheet-metal material: inherited holes, crossing edge notches, overlapping cuts and retained
islands share the same boundaries in flat preview, DXF and unfolded solids. Detached material
pieces survive unfold/refold as one logical sheet group, including split flanged sheets.
Cut-outs made in the flat pattern cross bends at any angle or curved: their edges fold to
helices and curves on the bend, their walls square to the sheet. Free-form bases use bounded
sampling.
Parallel prismatic partial-depth cuts retain pocket side and depth through transfer and
unfold/refold, with separate pocket contours in preview and DXF. Planar Intersect preserves
exact holes and retained islands, including the union of multiple selected profiles, and
records complementary depth layers as pockets while retaining the stock gauge. Unsupported
tapered or bend-crossing machining refuses atomically instead of restoring material later.
Sketches attach to a sheet's flat faces (base and flanges, either side) by panel, in the
flat pattern's coordinates, and follow it through rebuilds and unfold/refold.
A flange on another flange's far edge (a return) follows that flange's closed end and keeps
clear of the flange beside it, and the returns of one feature are mitred toward each other
with the rule's gap where their flanges meet at a corner; returns of separate features, and
flanges on returns, are not closed toward each other.
- Rolling-ball fillet accuracy: independent offset-cylinder tee references check surface
distance, actual trimmed tessellation, supporting-cylinder contacts and tangency at two
scales. The measured trimmed-part error is below 0.0001 mm; this is an analytic geometry
check. Under-resolved fits return an error.
- Contact driving checks the trajectory between clear endpoints, including linked joints,
playback and sparse motion exports. Checks use bounded tessellation samples, not exact
continuous collision detection; local thin walls and small details remain limitations.
See [contact-set limits and recovery](docs/contact-sets.md).
## Assembly dragging
The design's first part is grounded when it is made, and a left-button drag repositions a free part (`occurrence.drag`: one undo step, exact occurrence paths including repeated jointed definitions, any floating-chain member carries the chain, grounded parts and joints hold where present, Escape cancels).
## 3D sketch bridges
3D sketch drawing snaps to points on other visible sketch planes without flattening them onto
the active plane. Bridge endpoints capture their source positions; associative endpoint links,
construction-plane selection of 3D wires and loft guide rails remain separate work.
Feature dialog header chevrons collapse and restore the form without discarding selections
or edited values, including the Appearance panel.
## Construction on 3D paths
Plane Along Path has a viewport path selector, fractional position, live preview and timeline
editing. Construction planes and points resolve drawn 3D wires without applying their sketch
plane a second time. Curved paths use sampled segments; multi-curve chains, absolute distance
and reference-object placement remain separate work, as do loft rails and centerlines.
## Design properties (#1015)
- Designs carry project, part number, revision, author, organization, description and licence
in an optional `metadata` object (File → Design Properties…, `document.metadata.*`,
`inspect_design`). STEP and IGES headers and 3MF model metadata take the fields their format
has a place for. Other tools keep their own data under a reverse-DNS key, kept as it is
through open, edit and save and never exported. See [design properties](docs/book/src/guide/files.md#design-properties).
Per-component properties (a part number per component) can follow with the same type.
## Document-opening performance
- Desktop STEP import prepares consecutive independent solids in bounded batches with up
to eight workers, retaining serial traversal, result order and persistent source identities.
The web path remains serial. Original multi-solid fixtures show 64–81% lower complete
import times; single-solid assembly parts are not scheduled across nodes. See
[measurements and limits](docs/perf.md#parallel-step-body-preparation-2026-10-12).
- Design containers (#303, phase one) separate and deduplicate STEP, raw images and
lossless binary mesh arrays while keeping plain JSON as a supported Save As/export
format. Generated-mesh storage measurements reduce serialization and decoding work;
a seekable reader defers original blobs until after provisional display.
See [container format](docs/design-container.md) and [performance measurements](docs/perf.md).
- Warm saves with explicit `cache: true` can include a versioned native B-rep cache through SolidCraft-owned topology
and geometry records, with a separate truck adapter and bounded binary reader. Exact
face-source certificates and operation history survive codec round trips. A provisional
picture can be generated before original blobs are read; editing and measurement wait
for authoritative reconstruction. Cached metadata binds preview and reconstruction to
the same document despite concurrent file writes. Default saves omit this cache and never force cold
evaluation or meshing; explicit `preview: true` remains a separate warm-mesh option.
Keys, resource budgets and document/revision guards reject incompatible, hostile or
stale results. A second modelling kernel still requires its own tested adapter.
- Warm saves with explicit `cache: true` can also carry a bounded PNG thumbnail from already cached meshes.
Home reads it without rebuilding the design when its local picture is missing;
mismatched or malformed thumbnails fall back to normal generation. The image is
kernel-independent and saving never forces cold meshing to produce it.
- Cold rebuild profiling separates parsing, geometry, placement, display meshing and
thumbnails. Straight-line closest-point projection and exact curved-surface evaluation
reuse reduce repeated work without relaxing geometry checks. Three alternating optimized
benchmark runs reduce median rebuild time by 26% on the public sensor enclosure and 22%
on a local case assembly; shape measurements and topology match the original build.
See [performance measurements](docs/perf.md). Further cold Boolean and import costs remain.
- Prepared assembly geometry survives selection and view changes while real geometry and
placement changes invalidate it. A Pi assembly edge-selection probe drops from 9.24 s to
0.57 s; initial opening is unchanged. Actual Windows desktop profiling measures 15.87 s
median opening, with 82% spent rebuilding geometry; halving full opening remains open.
## Kernel
- Successful Boolean retries report their strategy and numerical adjustments as
feature warnings and structured inspection data. Diagnostics survive cached
evaluation, undo/redo and cold reopening; the robustness runner counts retry
events by strategy and reports any bounded-log truncation. Existing geometry
acceptance checks remain in force.
- Cylinders of one radius whose axes cross (a second bore across a bore of the same size, a pipe
tee of equal pipes) touch at two points, where the mesh intersection cannot trace the curves
and a boolean cannot tell the four arms of the crossing apart. When the ordinary boolean fails
on such a pair, both faces are split along the line of their cylinder through the touching
points (the solids are unchanged), the boolean is taken of those, and its history is composed
back over the operands as given (#788). The result keeps the split lines as edges of the bore
walls. A crossing the ordinary route fails on and the split faces do not help (a radius of 17.5
in a 45 mm block with a tool of 80 mm) is still refused.
- Boolean intersection-curve construction refuses point-only, collapsed and non-finite
polylines without panicking. Direct constructor regressions retain valid open endpoints
and closed spherical intersections; the original interactive trigger remains unidentified.
- Shell of a stepped, joined planar body no longer creates horizontal subdivisions across
continuous walls. Fresh axis-aligned Cut walls are stitched using their exact construction
support and shared topology, preserving real steps and intentional boundaries. The local
reported case returns from 86 to 49 faces with unchanged material and bounds; regressions
cover stepped stock, STEP round trips and saved projections from removed pieces.
truck (Apache-2.0) behind our own `kernel` crate, with our own blend operation and boolean
retry/validation; see `plan/adr/0001-geometry-kernel.md` (local) — summary in README.
+9 -9
View File
@@ -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,7 +435,7 @@ 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)
@@ -474,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)
@@ -552,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)
@@ -596,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
@@ -1339,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
================================================================================
@@ -12431,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
@@ -15274,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
@@ -16401,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)
+36 -8
View File
@@ -23,6 +23,7 @@
//! 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)]
@@ -99,7 +100,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] = &[
@@ -131,6 +132,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).
@@ -205,6 +210,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()
}
@@ -241,11 +268,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)
}
@@ -255,11 +283,11 @@ 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::opened_at(doc, None, Some(path));
@@ -653,7 +681,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")
@@ -820,8 +848,8 @@ pub(crate) fn cmd_bench_open(args: &[String]) -> Result<(), String> {
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();
+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);
}
+6 -2
View File
@@ -40,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.
@@ -615,13 +617,15 @@ fn choose_file(accept: &str) -> Result<js_sys::Promise, String> {
}
/// 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),
}
});
+99 -52
View File
@@ -112,6 +112,13 @@ fn check_download_size(current: usize, incoming: u64) -> Result<(), String> {
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")
}
#[cfg(target_arch = "wasm32")]
mod web {
use std::rc::Rc;
@@ -163,6 +170,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
@@ -271,6 +295,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.
@@ -287,7 +315,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);
}
@@ -353,7 +381,8 @@ 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() {
@@ -381,61 +410,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(solidcraft_ui_egui::i18n::tr("web.files.insert_stored_drawings_hint"))
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();
}
});
});
@@ -623,6 +653,7 @@ mod web {
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));
@@ -630,7 +661,8 @@ mod web {
ctx.request_repaint_after(delay);
}
if self.launch.persists() {
files::take_drops(&self.files, raw);
let theme = solidcraft_ui_egui::prefs::theme_drop_target(&self.app, ctx);
files::take_drops(&self.files, raw, theme);
} else {
raw.dropped_files.clear();
}
@@ -722,6 +754,7 @@ mod web {
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}\">");
@@ -954,4 +987,18 @@ 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));
}
}
+7
View File
@@ -28,6 +28,8 @@ 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"] }
@@ -96,6 +98,11 @@ 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 }
[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]);
}
}
+24 -1
View File
@@ -20,6 +20,7 @@
#[cfg(not(target_os = "macos"))]
#[path = "../../../assets/icons/app/shared/source/app_icon.rs"]
mod app_icon;
mod come_forward;
mod control_server;
mod device_lost;
#[cfg(target_os = "windows")]
@@ -84,6 +85,8 @@ 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"))]
@@ -139,7 +142,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);
@@ -752,12 +761,15 @@ fn main() -> eframe::Result {
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"));
@@ -778,13 +790,21 @@ fn main() -> eframe::Result {
}
"--help" | "-h" => {
println!(
"usage: solidcraft [--control PORT] [--sample] [--theme-file THEME.json] [--crash-log] [--safe-mode] [design.sc | part.step]"
"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();
@@ -1060,6 +1080,7 @@ 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,
@@ -1103,6 +1124,8 @@ fn wgpu_options(cpu: bool, preference: Option<&str>) -> eframe::egui_wgpu::WgpuC
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);
+21 -2
View File
@@ -255,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));
@@ -272,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();
@@ -302,6 +303,14 @@ pub fn system_title_bar(windows: bool, backends: eframe::wgpu::Backends) -> bool
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());
@@ -324,6 +333,16 @@ mod tests {
["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();
@@ -573,7 +592,7 @@ mod tests {
if cfg!(target_os = "windows") && std::env::var_os(WGPU_BACKEND_ENV).is_none() {
assert_eq!(backends, eframe::wgpu::Backends::DX12);
}
let mut desc = eframe::wgpu::InstanceDescriptor::new_without_display_handle_from_env();
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));
+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",
+3
View File
@@ -206,3 +206,6 @@ constraint = [
construction = [
"centerline", "construction", "infinite",
]
mesh = [
"mesh_body",
]
@@ -103,8 +103,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
@@ -142,6 +144,7 @@ loft false
lookat false
material false
measure false
mesh_body false
mesh_export false
min_radius false
mirror false
@@ -256,6 +259,7 @@ split_face false
spun false
star false
status_error false
status_pin false
status_recovery false
style false
style_hidden_edges false
@@ -287,6 +291,8 @@ svg false
sweep false
text false
thread false
timeline_play false
timeline_stop false
torus false
trace false
trim false
@@ -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,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Feature group</title>
<path d="M28.5 11L50.5 11A4.5 4.5 0 0 1 55 15.5L55 28.5A4.5 4.5 0 0 1 50.5 33L28.5 33A4.5 4.5 0 0 1 24 28.5L24 15.5A4.5 4.5 0 0 1 28.5 11Z" fill="#8e9faf" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M21 20L43 20A4.5 4.5 0 0 1 47.5 24.5L47.5 37.5A4.5 4.5 0 0 1 43 42L21 42A4.5 4.5 0 0 1 16.5 37.5L16.5 24.5A4.5 4.5 0 0 1 21 20Z" fill="#b8c3cf" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M13.5 29L35.5 29A4.5 4.5 0 0 1 40 33.5L40 46.5A4.5 4.5 0 0 1 35.5 51L13.5 51A4.5 4.5 0 0 1 9 46.5L9 33.5A4.5 4.5 0 0 1 13.5 29Z" fill="#b9ddf8" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M16 40L33 40" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 947 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Finish Edit</title>
<path d="M50 32L14 32M25 21L14 32L25 43" fill="none" stroke="#236ead" stroke-width="5.6" stroke-linejoin="round" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 237 B

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Mesh body</title>
<path d="M8.62 18.5L32 32L32 59L8.62 45.5Z" fill="#328bd3"/>
<path d="M55.38 18.5L32 32L32 59L55.38 45.5Z" fill="#236ead"/>
<path d="M32 5L55.38 18.5L32 32L8.62 18.5Z" fill="#b9ddf8"/>
<path d="M8.62 18.5L55.38 18.5" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M8.62 18.5L32 59" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M55.38 18.5L32 59" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M32 5L55.38 18.5L55.38 45.5L32 59L8.62 45.5L8.62 18.5Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M8.62 18.5L32 32L55.38 18.5" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M32 32L32 59" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><title>Play History</title><path d="M11.22 20L32 32L32 56L11.22 44Z" fill="#b8c3cf"/><path d="M52.78 20L32 32L32 56L52.78 44Z" fill="#8e9faf"/><path d="M32 8L52.78 20L32 32L11.22 20Z" fill="#e2e8ef"/><path d="M32 8L52.78 20L52.78 44L32 56L11.22 44L11.22 20Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/><path d="M11.22 20L32 32L52.78 20" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/><path d="M32 32L32 56" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/><path d="M35 34L56 46L35 58Z" fill="#ffffff" stroke="#ffffff" stroke-width="4.55" stroke-linejoin="round" stroke-linecap="round"/><path d="M35 34L56 46L35 58Z" fill="#236ead" stroke="#236ead" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 935 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><title>Stop History</title><path d="M11.22 20L32 32L32 56L11.22 44Z" fill="#b8c3cf"/><path d="M52.78 20L32 32L32 56L52.78 44Z" fill="#8e9faf"/><path d="M32 8L52.78 20L32 32L11.22 20Z" fill="#e2e8ef"/><path d="M32 8L52.78 20L52.78 44L32 56L11.22 44L11.22 20Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/><path d="M11.22 20L32 32L52.78 20" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/><path d="M32 32L32 56" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/><path d="M36 35L53 35A2 2 0 0 1 55 37L55 54A2 2 0 0 1 53 56L36 56A2 2 0 0 1 34 54L34 37A2 2 0 0 1 36 35Z" fill="#ffffff" stroke="#ffffff" stroke-width="4.55" stroke-linejoin="round" stroke-linecap="round"/><path d="M36 35L53 35A2 2 0 0 1 55 37L55 54A2 2 0 0 1 53 56L36 56A2 2 0 0 1 34 54L34 37A2 2 0 0 1 36 35Z" fill="#236ead" stroke="#236ead" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+11
View File
@@ -15,6 +15,17 @@ pub(crate) fn transport_glyph(p: &egui::Painter, c: egui::Pos2, what: &str, col:
}
"back" => tri(-1.0, 0.0),
"forward" => tri(1.0, 0.0),
// Play History: larger than the step arrows so it doesn't read as Forward, and two bars
// while it plays.
"play" => {
let pts = vec![pos2(c.x - 4.5, c.y - 7.0), pos2(c.x + 6.5, c.y), pos2(c.x - 4.5, c.y + 7.0)];
p.add(egui::Shape::convex_polygon(pts, col, Stroke::NONE));
}
"pause" => {
for dx in [-3.0, 3.0] {
p.rect_filled(Rect::from_center_size(pos2(c.x + dx, c.y), vec2(3.0, 12.0)), 0.5, col);
}
}
_ => {
tri(1.0, -1.0);
bar(5.0);
+727 -16
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+20
View File
@@ -33,6 +33,7 @@
"All open designs": "Alle åbne designs",
"Allowed expressions, separated by semicolons": "Tilladte udtryk adskilt af semikolon",
"Also deletes": "Sletter også",
"Also place them from the origin (fully constrained)": "Placer dem også fra origo (fuldt fastlåst)",
"Anchor": "Forankring",
"Angle 2": "Vinkel 2",
"Animate": "Animér",
@@ -158,6 +159,7 @@
"Designs you open or save appear here.": "Designs, du åbner eller gemmer, vises her.",
"Determined by To Object. Choose Angle to edit the angular extent.": "Bestemmes af Til objekt. Vælg Vinkel for at ændre vinkeludstrækningen.",
"Diameter": "Diameter",
"Dimension new shapes": "Mål nye figurer",
"Direction": "Retning",
"Discard": "Kassér",
"Distance": "Afstand",
@@ -169,6 +171,8 @@
"Drag to move the hole; click to remove it": "Træk for at flytte hullet; klik for at fjerne det",
"Drive Joint": "Bevæg led",
"Duration": "Varighed",
"Each new shape is also dimensioned from the sketch origin until nothing of it is left free. A shape already attached to the origin or to placed geometry gets none.": "Hver ny figur får også mål fra skitsens origo, indtil intet af den er frit. En figur, der allerede er knyttet til origo eller til placeret geometri, får ingen.",
"Each shape you draw gets all its dimensions, at the sizes you drew (typed values win). A size the sketch already sets is left out.": "Hver figur, du tegner, får alle sine mål i de størrelser, du tegnede (indtastede værdier vinder). En størrelse, som skitsen allerede fastlægger, udelades.",
"Edges": "Kanter",
"Edit": "Redigér",
"Edit Profile Sketch": "Redigér profilskitse",
@@ -614,6 +618,7 @@
"appearance.pbr_opaque": "Uigennemsigtig PBR",
"appearance.roughness": "Ruhed",
"appearance.surface_response": "Overfladens reaktion på lys",
"at the face": "ved fladen",
"browser.body_name": "Emne{number}",
"browser.calibrate_title": "Kalibrér: {name}",
"browser.capture_many": "{count} komponenter er flyttet, men deres positioner er ikke registreret.",
@@ -919,6 +924,7 @@
"cmd.sketch_spline_control_point": "Spline med kontrolpunkter",
"cmd.sketch_spline_control_point_5": "Spline med kontrolpunkter (grad 5)",
"cmd.sketch_spline_fit_point": "Spline gennem punkter",
"cmd.sketch_spline_reverse": "Vend splinens retning",
"cmd.sketch_spun_profile": "Omdrejningsprofil",
"cmd.sketch_toggle_driven": "Skift mellem styrende og afledt mål",
"cmd.sketch_trim_path": "Trim langs bane",
@@ -1286,6 +1292,13 @@
"prefs.left_click": "Venstreklik",
"prefs.live_input": "Løbende input",
"prefs.live_preview": "Løbende forhåndsvisning",
"prefs.max_file_size": "Største fil, der kan åbnes",
"prefs.max_file_size_design": "Designfiler",
"prefs.max_file_size_flag": "Denne kørsel blev startet med --max-file-size {size}, som gælder, indtil SolidCraft lukkes. Disse indstillinger gælder ved start uden denne valgmulighed.",
"prefs.max_file_size_note": "Standard: 512 MB og 256 MB; højst 16 GB. Åbning af en fil kræver flere gange dens størrelse i hukommelse. Et design med en stor import kan kræve en højere grænse for designfiler for at kunne gemmes og åbnes igen.",
"prefs.max_file_size_step": "STEP- og importfiler",
"prefs.max_file_size_web": "Webappen åbner STEP- og importfiler op til 512 MB og design op til 256 MB: en browserfane kan ikke rumme større filer. Grænserne kan hæves i skrivebordsappen.",
"prefs.megabytes": "MB",
"prefs.minutes": "minutter",
"prefs.minutes_suffix": " min",
"prefs.mouse": "Mus og pegefelt",
@@ -1395,6 +1408,13 @@
"render.return_design": "Tilbage til Design",
"render.save_setup": "Gem opsætning",
"render.setup_name": "Opsætningsnavn",
"render.shots_camera_changed": "Kameraet er ændret, siden denne optagelse blev gemt",
"render.shots_name_help": "Indtast et unikt navn til den aktuelle optagelse.",
"render.shots_no_result": "Ingen rendering endnu",
"render.shots_result": "Rendering {number} · {width} × {height}",
"render.shots_revert": "Gendan",
"render.shots_save": "Gem optagelse",
"render.shots_update": "Opdater optagelse",
"render.unavailable_preview": "Denne opsætning bruger en type forhåndsvisning, der ikke er tilgængelig.",
"render.unsaved": "Ændringer i forhåndsvisning er ikke gemt",
"render.use_preview": "Brug integreret forhåndsvisning",
+826 -2
View File
File diff suppressed because it is too large Load Diff
+61
View File
@@ -3,11 +3,18 @@
"Center X": "Centre X",
"Center Y": "Centre Y",
"Center of mass": "Centre of mass",
"Circle center": "Circle centre",
"Customize Toolbar": "Customise Toolbar",
"Customize…": "Customise…",
"Face center": "Face centre",
"Favorites": "Favourites",
"Mass Center": "Mass Centre",
"Modeled": "Modelled",
"cmd.component_cycle_color": "Cycle Component Colour",
"cmd.construct_point_center": "Point At Centre Of Circle/Sphere/Torus",
"cmd.inspect_center_of_mass": "Centre of Mass",
"cmd.inspect_colored_components": "Enable Component Colours",
"cmd.inspect_colored_components_disable": "Disable Component Colours",
"cmd.render_aging_describe": "Layered Surface Aging",
"cmd.render_effects_describe": "Lens and Creative Effects",
"cmd.sketch_arc_center_point": "Centre Point Arc",
@@ -16,13 +23,34 @@
"cmd.sketch_slot_arc_center": "Centre Point Arc Slot",
"cmd.sketch_slot_center_point": "Centre Point Slot",
"cmd.sketch_slot_center_to_center": "Centre to Centre Slot",
"color.copy": "Click to copy colour values",
"color.edit": "Click to edit colour",
"color.gamma": "Showing colour values in 0-255 gamma space",
"color.linear": "Showing colour values in 0-1 linear space",
"color.selected": "Selected colour",
"pattern.alignment_hint": "Counts include the original. Centre balances copies; Flip chooses the extra side for even totals.",
"prefs.logo_theme": "Theme's colours",
"prefs.max_file_size": "Largest file to open",
"prefs.max_file_size_design": "Design files",
"prefs.max_file_size_flag": "This run started with --max-file-size {size}, which applies until SolidCraft closes. These settings apply when it starts without it.",
"prefs.max_file_size_note": "Defaults: 512 MB and 256 MB; at most 16 GB. Opening a file takes several times its size in memory. A design that keeps a large import may need a higher design file limit to save and reopen.",
"prefs.max_file_size_step": "STEP and import files",
"prefs.max_file_size_web": "The web app opens STEP and import files up to 512 MB and designs up to 256 MB: a browser tab can't hold larger files. The desktop app can raise these limits.",
"prefs.megabytes": "MB",
"prefs.sketch_conflict_colour": "Conflicting geometry in the conflict colour",
"prefs.sketch_conflict_colour_note": "While a sketch's constraints conflict, the curves and points a failing constraint holds are drawn in the conflict colour too, not only its glyph or dimension. Off: they keep their usual colours.",
"prefs.sketch_state_icons": "Sketch icons in the colour of their state",
"prefs.sketch_state_icons_note": "In History and the browser, a sketch's icon takes the colour of its state (under constrained, fully constrained or conflicting) and its tooltip names the state. Off: every sketch has the same icon.",
"prefs.sketch_tracking_guides": "Centre and midpoint alignment guides",
"prefs.sketch_tracking_guides_note": "Hover over a circle centre or straight-edge midpoint, then move horizontally or vertically to show a temporary dotted guide.",
"prefs.status_bar_note": "A thin bar along the window's bottom edge shows what is being edited, the open sketch's state in its colour and the design's units; a click on the units changes them. The sketch's state then no longer shows in the view's corner.",
"prefs.theme_follow": "Follow a theme file",
"prefs.theme_follow_error": "Not applied: {error}. The last good theme stays on.",
"prefs.theme_follow_launch": "Followed for this run only (--theme-file); reloaded whenever another tool rewrites it.",
"prefs.theme_follow_note": "Reloaded whenever another tool rewrites it. Choosing a theme stops following it.",
"prefs.theme_follow_off": "Off",
"prefs.theme_follow_stop": "Stop following",
"prefs.view": "View behaviour",
"render.aging_add": "Add aging layer",
"render.aging_airborne": "Airborne dust / participating medium",
"render.aging_airborne_help": "Airborne dust is a separate finite homogeneous scattering volume. It changes transport and raw EXR, not the surface-aging stack. Existing volume/glass/denoising restrictions remain explicit; no atmosphere is added automatically.",
@@ -50,6 +78,8 @@
"render.aging_target_help": "A named target must still match its captured index and name. Global masks are world-anchored; they do not follow moving parts. This is neither cavity detection nor true edge wear. Filter the list to reach additional bodies.",
"render.aging_target_search": "Filter body names",
"render.aging_upward": "Upward-facing deposition",
"render.backdrop_first": "Start / centre colour",
"render.backdrop_second": "End / edge colour",
"render.backdrop_x": "Centre X",
"render.backdrop_y": "Centre Y",
"render.background_camera_section": "Camera background · PNG presentation",
@@ -57,10 +87,15 @@
"render.background_hdr_separate": "The current HDR resources illuminate and reflect on surfaces; they are not automatically displayed as a panorama behind the subject. Changing a gradient does not rotate or recolour the lighting.",
"render.background_lighting_section": "HDR illumination · separate from camera background",
"render.background_no_alpha_override": "A camera background is not added to transparent output silently. Raw-master regrading uses captured coverage and does not reapply denoising; the source EXR remains unchanged.",
"render.background_not_light": "Background colour is presentation, not an additional lighting source.",
"render.background_regrade": "Apply to retained raw master",
"render.background_stage_occlusion": "This shot includes physical stage geometry. A large floor or wall can fill the frame and hide a camera-only background. Clear or resize the stage deliberately, or change its physical reflectance. Regrading cannot remove captured geometry.",
"render.background_workflow": "Choose the background you intend: a camera-only colour/gradient, a physical stage that receives light, or HDR illumination. They are independent. Live Preview is a modelling view; render a new image or regrade a retained master to see camera finishing.",
"render.base_color": "Base colour · sRGB",
"render.bloom_help": "Creative output-only glow from positive linear highlights, spread with three normalised box passes. Radius is in output pixels. Added in linear display light before sRGB; not a lens, volumetric effect or convergence improvement. The raw EXR is unchanged.",
"render.chromatic_offset": "Chromatic edge offset",
"render.cpu_light_help": "Up to eight directional lights. An empty custom list turns them off; selecting a preset restores its lights. Colours are linear RGB and intensity is relative, not watts.",
"render.cpu_linear_color": "Linear colour (R, G, B)",
"render.effects_gpu_only": "These effects require GPU path tracing. Changing the page never switches the renderer automatically.",
"render.effects_help": "Combine ordered colour looks and image-space lens effects. Rendering and raw-master regrading use the same saved settings. Raw EXR stays unchanged. These are original artistic controls, not calibrated lens or branded-film emulations.",
"render.effects_reset_lens": "Reset lens effects",
@@ -71,6 +106,9 @@
"render.flare_streak": "Anamorphic-style streak",
"render.flare_strength": "Highlight flare strength",
"render.flare_threshold": "Scene-linear flare threshold",
"render.hdr_primaries_declared": "The source declares its colour primaries.",
"render.hdr_primaries_srgb": "Colour primaries: sRGB/D65",
"render.hdr_primaries_undeclared": "The source does not declare primaries; review the renderer colour requirements.",
"render.layer_down": "Move down",
"render.layer_enabled": "Layer enabled",
"render.layer_remove": "Remove layer",
@@ -78,6 +116,13 @@
"render.layer_up": "Move up",
"render.lens_effects_help": "Flare uses up to four separated bright sources in the actual linear image. Ghosts, halo, streaks, chromatic offsets and radial distortion are artistic image-space approximations. Spatial effects need opaque output; outside samples extend the image edge. Grain is deterministic. No physics, EXR or geometry is altered.",
"render.lens_effects_title": "Lens-inspired & photographic",
"render.library_center": "Rig centre",
"render.library_favorite": "Add favourite",
"render.library_favorites": "Favourites",
"render.library_inspect_only": "Select to inspect; nothing is applied automatically. Favourites and recent history belong to this open document session.",
"render.library_rig_help": "Fit once at an explicit centre and physical extent. Complete rigs include passive cards; calibration blockers still refuse. Conservative emitter/card clearance is checked before saving.",
"render.library_source_preview_help": "Authored base-colour source map. This is not a rendered or measured material preview.",
"render.library_unfavorite": "Remove favourite",
"render.look_add": "Add colour look",
"render.look_bleach": "Bleach-bypass-inspired",
"render.look_cool": "Cool",
@@ -89,6 +134,11 @@
"render.look_teal_orange": "Teal / orange",
"render.look_title": "Creative look stack",
"render.look_warm": "Warm",
"render.material_color_map": "Base colour image (sRGB)",
"render.material_color_replace": "Base map replaces body colour",
"render.material_map_required": "Choose a colour image or a normal image before using maps.",
"render.material_maps": "Planar colour and normal maps",
"render.material_maps_help": "PNG/JPEG up to 1024×1024; 16 MiB encoded total. U and V are orthogonal world-mm repeat vectors (maximum 128 repeats). Colour is sRGB; normal data uses OpenGL +Y. Files are captured at Check/Start.",
"render.opaque_preview_help": "Opaque PBR and simple colours; blended transparency. Refraction, HDRI and final rendering are not available yet.",
"render.page_aging": "Aging & contamination",
"render.page_background": "Backgrounds",
@@ -98,15 +148,26 @@
"render.ribbon_effects": "Effects",
"render.sharpening": "Detail sharpening",
"render.soft_focus": "Soft focus",
"render.studio_center": "Centre X, Y, Z (mm)",
"render.studio_hdr_help": "Radiance .hdr, 2:1 panorama, up to 2048 × 1024. EXR is unavailable. The colour assumption applies only when primaries are unspecified.",
"sketch_text.align_center": "Centre",
"ui.3d_mouse_kind_cap": "Return to Centre",
"ui.3d_mouse_looks_like_cap": "The reading stays put while you hold it, the way a Return to Centre device does.",
"ui.3d_mouse_step_pull": "Pull the cap towards you, hold it, then let go.",
"ui.3d_mouse_switch_cap": "Calibrate as Return to Centre",
"ui.center": "Centre",
"ui.center_arc_follows_cursor": "Centre point arc follows the cursor's direction",
"ui.center_arc_follows_cursor_tip": "A centre point arc runs clockwise or counter-clockwise the way the cursor goes round its centre. Off: always counter-clockwise from the start.",
"ui.centerline": "Centreline",
"ui.customizeToolbar": "Customise Toolbar…",
"ui.design_properties.license": "Licence",
"ui.design_properties.organization": "Organisation",
"ui.layout_compact": "Compact",
"ui.layout_floating": "Floating",
"ui.point_cloud_color_by": "Colour By",
"ui.point_cloud_file_colors": "File Colours",
"ui.point_cloud_no_file_colors": "The file has no colours",
"ui.point_cloud_single_color": "Single Colour",
"ui.ribbon_labels": "Show text under ribbon icons",
"ui.workspace_layout": "Workspace layout",
"ui.zoom_to_cursor_tip": "The wheel zooms towards the point under the cursor (off: towards the view's centre)",
+715 -4
View File
File diff suppressed because it is too large Load Diff
+787 -41
View File
File diff suppressed because it is too large Load Diff
+851 -1
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+740 -29
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+13
View File
@@ -26,6 +26,7 @@
"All open designs": "Wszystkie otwarte projekty",
"Allowed expressions, separated by semicolons": "Dozwolone wyrażenia, rozdzielone średnikami",
"Also deletes": "Usuwa również",
"Also place them from the origin (fully constrained)": "Umieść je też względem początku (w pełni związane)",
"Angle 2": "Kąt 2",
"Animate": "Animuj",
"App/system information and command settings are included. Review private details before sharing.": "Dołączone są informacje o aplikacji i systemie oraz ustawienia poleceń. Przed udostępnieniem sprawdź, czy nie ma tam prywatnych danych.",
@@ -118,6 +119,7 @@
"Describe what happened before continuing.": "Zanim przejdziesz dalej, opisz, co się stało.",
"Designs you open or save appear here.": "Tutaj pojawiają się otwierane lub zapisywane projekty.",
"Determined by To Object. Choose Angle to edit the angular extent.": "Wynika z opcji Do obiektu. Wybierz Kąt, aby edytować zakres kątowy.",
"Dimension new shapes": "Wymiaruj nowe kształty",
"Direction": "Kierunek",
"Discard": "Odrzuć",
"Do not export hidden edges": "Nie eksportuj krawędzi niewidocznych",
@@ -131,6 +133,8 @@
"Drag to turn the rest in 5° steps. Hold Alt or Ctrl to turn it freely.": "Przeciągnij, aby obrócić podparcie co 5°. Przytrzymaj Alt lub Ctrl, aby obracać swobodnie.",
"Drive Joint": "Steruj złączem",
"Duration": "Czas trwania",
"Each new shape is also dimensioned from the sketch origin until nothing of it is left free. A shape already attached to the origin or to placed geometry gets none.": "Każdy nowy kształt otrzymuje też wymiary od początku szkicu, aż nic nie pozostanie swobodne. Kształt już połączony z początkiem lub z umieszczoną geometrią nie otrzymuje żadnych.",
"Each shape you draw gets all its dimensions, at the sizes you drew (typed values win). A size the sketch already sets is left out.": "Każdy narysowany kształt otrzymuje wszystkie swoje wymiary w narysowanych rozmiarach (wpisane wartości mają pierwszeństwo). Rozmiar, który szkic już określa, jest pomijany.",
"Edges": "Krawędzie",
"Edit": "Edytuj",
"Edit Profile Sketch": "Edytuj szkic profilu",
@@ -475,6 +479,7 @@
"appearance.pbr_opaque": "Nieprzezroczysty PBR",
"appearance.roughness": "Chropowatość",
"appearance.surface_response": "Reakcja powierzchni",
"at the face": "przy ścianie",
"browser.body_name": "Bryła{number}",
"browser.calibrate_title": "Kalibruj: {name}",
"browser.capture_many": "Przesunięto komponenty ({count}), ale ich położenia nie zostały zapamiętane.",
@@ -775,6 +780,7 @@
"cmd.sketch_spline_control_point": "Splajn z punktów kontrolnych",
"cmd.sketch_spline_control_point_5": "Splajn z punktów kontrolnych (stopień 5)",
"cmd.sketch_spline_fit_point": "Splajn przez punkty",
"cmd.sketch_spline_reverse": "Odwróć kierunek splajnu",
"cmd.sketch_spun_profile": "Profil obrotowy",
"cmd.sketch_toggle_driven": "Przełącz wymiar sterowany",
"cmd.sketch_trim_path": "Przytnij wzdłuż ścieżki",
@@ -1132,6 +1138,13 @@
"prefs.left_click": "Kliknięcie lewym",
"prefs.live_input": "Bieżące wejście",
"prefs.live_preview": "Podgląd na żywo",
"prefs.max_file_size": "Maksymalny rozmiar otwieranego pliku",
"prefs.max_file_size_design": "Pliki projektów",
"prefs.max_file_size_flag": "To uruchomienie używa --max-file-size {size} aż do zamknięcia SolidCraft. Te ustawienia obowiązują przy uruchomieniu bez tej opcji.",
"prefs.max_file_size_note": "Domyślnie: 512 MB i 256 MB; maksymalnie 16 GB. Otwarcie pliku wymaga pamięci kilkakrotnie większej od jego rozmiaru. Projekt z dużym importem może wymagać wyższego limitu pliku projektu, aby go zapisać i ponownie otworzyć.",
"prefs.max_file_size_step": "Pliki STEP i importu",
"prefs.max_file_size_web": "Aplikacja internetowa otwiera pliki STEP i importu do 512 MB oraz projekty do 256 MB: karta przeglądarki nie pomieści większych plików. W aplikacji komputerowej można zwiększyć te limity.",
"prefs.megabytes": "MB",
"prefs.minutes": "minut",
"prefs.minutes_suffix": " min",
"prefs.mouse": "Mysz i gładzik",
+13
View File
@@ -30,6 +30,7 @@
"All open designs": "Todos os projetos abertos",
"Allowed expressions, separated by semicolons": "Expressões permitidas, separadas por ponto e vírgula",
"Also deletes": "Também exclui",
"Also place them from the origin (fully constrained)": "Posicioná-las também a partir da origem (totalmente restritas)",
"Angle 2": "Ângulo 2",
"Animate": "Animar",
"App/system information and command settings are included. Review private details before sharing.": "Informações do aplicativo/sistema e configurações estão incluídas. Revise detalhes privados antes de compartilhar.",
@@ -145,6 +146,7 @@
"Designs you open or save appear here.": "Os projetos que você abrir ou salvar aparecem aqui.",
"Determined by To Object. Choose Angle to edit the angular extent.": "Determinado por Até o objeto. Escolha Ângulo para editar a extensão angular.",
"Diameter": "Diâmetro",
"Dimension new shapes": "Cotar novas formas",
"Direction": "Direção",
"Discard": "Descartar",
"Distance": "Distância",
@@ -155,6 +157,8 @@
"Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing": "Clique duplo com o botão do meio na vista: Início (a vista inicial, enquadrando o que é mostrado), Ajustar (enquadra o que é mostrado, mantendo a orientação) ou nada",
"Drive Joint": "Acionar junta",
"Duration": "Duração",
"Each new shape is also dimensioned from the sketch origin until nothing of it is left free. A shape already attached to the origin or to placed geometry gets none.": "Cada nova forma também recebe cotas a partir da origem do esboço até nada ficar livre. Uma forma já ligada à origem ou a uma geometria posicionada não recebe nenhuma.",
"Each shape you draw gets all its dimensions, at the sizes you drew (typed values win). A size the sketch already sets is left out.": "Cada forma que você desenha recebe todas as suas cotas nos tamanhos desenhados (valores digitados têm prioridade). Um tamanho que o esboço já define é omitido.",
"Edges": "Arestas",
"Edit": "Editar",
"Edit Profile Sketch": "Editar esboço do perfil",
@@ -578,6 +582,7 @@
"appearance.pbr_opaque": "PBR opaco",
"appearance.roughness": "Rugosidade",
"appearance.surface_response": "Resposta da superfície",
"at the face": "na face",
"browser.body_name": "Corpo{number}",
"browser.calibrate_title": "Calibrar: {name}",
"browser.capture_many": "{count} componentes foram movidos, mas suas posições não foram capturadas.",
@@ -881,6 +886,7 @@
"cmd.sketch_spline_control_point": "Spline por pontos de controle",
"cmd.sketch_spline_control_point_5": "Spline por pontos de controle (grau 5)",
"cmd.sketch_spline_fit_point": "Spline por pontos de ajuste",
"cmd.sketch_spline_reverse": "Inverter direção do spline",
"cmd.sketch_spun_profile": "Perfil de revolução",
"cmd.sketch_toggle_driven": "Alternar cota conduzida",
"cmd.sketch_trim_path": "Aparar ao longo do caminho",
@@ -1255,6 +1261,13 @@
"prefs.left_click": "Clique esquerdo",
"prefs.live_input": "Entrada ao vivo",
"prefs.live_preview": "Visualização ao vivo",
"prefs.max_file_size": "Tamanho máximo de arquivo a abrir",
"prefs.max_file_size_design": "Arquivos de projeto",
"prefs.max_file_size_flag": "Esta execução foi iniciada com --max-file-size {size}, que vale até fechar o SolidCraft. Estas configurações se aplicam ao iniciar sem essa opção.",
"prefs.max_file_size_note": "Padrões: 512 MB e 256 MB; máximo de 16 GB. Abrir um arquivo exige várias vezes seu tamanho em memória. Um projeto que mantém uma importação grande pode precisar de um limite maior para ser salvo e reaberto.",
"prefs.max_file_size_step": "Arquivos STEP e de importação",
"prefs.max_file_size_web": "O aplicativo web abre arquivos STEP e de importação de até 512 MB e projetos de até 256 MB: uma aba do navegador não comporta arquivos maiores. O aplicativo para desktop permite aumentar esses limites.",
"prefs.megabytes": "MB",
"prefs.minutes": "minutos",
"prefs.minutes_suffix": " min",
"prefs.mouse": "Mouse e trackpad",
+2289 -1
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1869 -921
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -7,7 +7,7 @@ pub const DEFAULT_MATERIAL: &str = "Default";
pub const DEFAULT_DENSITY: f64 = 1.29;
/// Physical materials: name and density (g/cm³).
pub const MATERIALS: [(&str, f64); 14] = [
pub const MATERIALS: &[(&str, f64)] = &[
(DEFAULT_MATERIAL, DEFAULT_DENSITY),
("Steel", 7.85),
("Stainless Steel", 8.0),
+86 -4
View File
@@ -10,7 +10,7 @@ struct U {
acc_u: vec4<f32>, // accessibility map axes (xyz, offset)
acc_v: vec4<f32>,
eye: vec4<f32>, // eye position, w = 1 for perspective (0: orthographic, looking along -back)
ao: vec4<f32>, // radius in world units (zero: disabled)
ao: vec4<f32>, // x: radius in world units (zero: disabled), y: samples per pixel
// analysis colours (`theme::AnalysisColors`): draft positive, between, negative; reachable,
// blocked; curvature flat, middle, tight
ana_c: array<vec4<f32>, 8>,
@@ -18,6 +18,8 @@ struct U {
depth: vec4<f32>, // x = 1: reversed depth (1 near, 0 far); y, z: the faces' push (`FACE_BIAS`)
tint: vec4<f32>, // xyz added to a whole hovered body's colour before lighting
sel: vec4<f32>, // xyz a whole selected body's colour before lighting
product_lights_meta: vec4<f32>, // explicit preview flag and bounded count
product_lights: array<vec4<f32>,84>, // position/kind, radiance, axis/cone, attenuation
};
fn reversed() -> bool {
@@ -333,9 +335,12 @@ fn occlusion(i: TOut) -> f32 {
// the buffer runs reversed). The map is cleared to its far plane either way, so a texel
// with nothing in it reads as 1 and the `d < 1.0` test below skips it.
let flip = reversed();
// Fewer samples while the view moves (#7165), spread over the same hemisphere. 32 is the
// still picture's count, `AO_SAMPLES` in view.rs.
let count = u32(clamp(u.ao.y, 1.0, 32.0));
var blocked = 0.0;
for (var j = 0u; j < 32u; j = j + 1u) {
let f = (f32(j) + 0.5) / 32.0;
for (var j = 0u; j < count; j = j + 1u) {
let f = (f32(j) + 0.5) / f32(count);
let angle = f32(j) * 2.39996323;
let direction = t * (cos(angle) * sqrt(f)) + b * (sin(angle) * sqrt(f)) + n * sqrt(1.0 - f);
let reach = radius * (0.2 + 0.8 * fract(f32(j) * 0.61803399 + 0.5));
@@ -356,7 +361,7 @@ fn occlusion(i: TOut) -> f32 {
blocked = blocked + smoothstep(0.005, 0.15, delta) * (1.0 - smoothstep(0.5, 1.0, delta));
}
}
return 1.0 - 0.85 * blocked / 32.0;
return 1.0 - 0.85 * blocked / f32(count);
}
/// How fast the normal turns across the pixel (per mm).
@@ -468,6 +473,36 @@ fn shade(i: TOut) -> vec4<f32> {
return lit(i, 1.0);
}
fn product_light_unit(v: vec3<f32>) -> vec3<f32> {
let scale=max(max(abs(v.x),abs(v.y)),abs(v.z));
if scale == 0.0 { return vec3<f32>(0.0,0.0,1.0); }
return normalize(v/scale);
}
// Direction and incident radiance for an unshadowed analytic preview sample.
fn product_light_sample(k: u32, p: vec3<f32>) -> mat2x3<f32> {
let source=u.product_lights[k*4u];
let colour=u.product_lights[k*4u+1u];
let axis=u.product_lights[k*4u+2u];
let falloff=u.product_lights[k*4u+3u];
var direction=product_light_unit(source.xyz);
var weight=1.0;
if source.w > 0.5 {
let delta=source.xyz-p;
let distance=max(length(delta),falloff.y);
direction=product_light_unit(delta);
weight=falloff.x/max(distance*distance,0.000001);
if source.w == 2.0 {
let cosine=dot(product_light_unit(axis.xyz),-direction);
var cone=select(0.0,1.0,cosine>=colour.w);
if axis.w-colour.w>0.000001 {cone=smoothstep(colour.w,axis.w,cosine);}
weight *= cone;
}
if source.w == 3.0 {weight *= max(dot(product_light_unit(axis.xyz),-direction),0.0);}
}
return mat2x3<f32>(direction,colour.xyz*weight);
}
fn lit(i: TOut, visibility: f32) -> vec4<f32> {
return lit_color(i, visibility, i.c);
}
@@ -482,6 +517,17 @@ fn lit_color(i: TOut, visibility: f32, base: vec4<f32>) -> vec4<f32> {
let v = normalize(u.back.xyz);
var n = normalize(i.n);
if (dot(n, v) < 0.0) { n = -n; }
if u.product_lights_meta.x>0.0 {
var radiance=vec3<f32>(0.0);
for (var k=0u;k<min(u32(u.product_lights_meta.y),21u);k+=1u) {
if u.product_lights[k*4u].w == 4.0 {radiance+=to_linear(base.rgb)*u.product_lights[k*4u+1u].xyz/3.141592653589793;continue;}
let sample=product_light_sample(k,i.wp);
radiance+=to_linear(base.rgb)*sample[1]*(max(dot(n,sample[0]),0.0)/3.141592653589793);
}
let mapped=radiance/(vec3<f32>(1.0)+radiance);
let encoded=select(mapped*12.92,1.055*pow(mapped,vec3<f32>(1.0/2.4))-vec3<f32>(0.055),mapped>vec3<f32>(0.0031308));
return out_color(vec4<f32>(encoded,base.a));
}
let key = normalize(v * 0.8 + vec3<f32>(-0.3, 0.2, 0.6));
let diff = max(dot(n, key), 0.0);
let fill = max(dot(n, v), 0.0);
@@ -650,3 +696,39 @@ fn fs_img(i: IOut) -> @location(0) vec4<f32> {
fn fs_line_all(i: LOut) -> @location(0) vec4<f32> {
return out_color(i.c);
}
// Point clouds: each point is a round dot a few pixels across, facing the screen, depth-tested
// and written like the model's faces, so the model hides the points behind it and they hide it.
// The colour's alpha carries the dot's size (eighths of a pixel).
struct POut {
@builtin(position) pos: vec4<f32>,
@location(0) c: vec4<f32>,
@location(1) wp: vec3<f32>,
@location(2) at: vec2<f32>,
};
@vertex
fn vs_point(@builtin(vertex_index) vi: u32, @location(0) p: vec3<f32>, @location(1) c: vec4<f32>) -> POut {
var xs = array<f32, 6>(-1.0, 1.0, 1.0, -1.0, 1.0, -1.0);
var ys = array<f32, 6>(-1.0, -1.0, 1.0, -1.0, 1.0, 1.0);
let at = vec2<f32>(xs[vi], ys[vi]);
let size = max(c.a * 255.0 / 8.0, 1.0);
let centre = u.vp * vec4<f32>(p, 1.0);
let half = u.screen.xy * 0.5;
var o: POut;
o.pos = vec4<f32>(centre.xy + at * size * 0.5 / half * centre.w, centre.z, centre.w);
// Behind the eye: nothing.
if (near_z(centre) < 0.0) { o.pos = vec4<f32>(0.0, 0.0, 0.0, -1.0); }
o.c = vec4<f32>(c.rgb, 1.0);
o.wp = p;
// Dots under two pixels are squares: a disc that small loses its centre pixel.
o.at = at * select(1.0, 0.0, size < 2.0);
return o;
}
@fragment
fn fs_point(i: POut) -> @location(0) vec4<f32> {
if (clipped(i.wp) || dot(i.at, i.at) > 1.0) { discard; }
return out_color(i.c);
}
+16 -4
View File
@@ -193,8 +193,12 @@ impl Tokens {
sketch_halo: Color32::from_rgb(240, 243, 247),
sketch_point: Color32::BLACK,
sketch_locked: Color32::from_rgb(0, 140, 60),
dimension: Color32::from_rgb(36, 40, 48),
dimension_driven: Color32::from_rgb(120, 126, 138),
// The error red (`error`).
sketch_conflict: Color32::from_rgb(214, 54, 54),
// Sketch dimensions in a plum of their own, apart from the blue and black curves for
// every colour vision mode (#7861).
dimension: Color32::from_rgb(130, 42, 110),
dimension_driven: Color32::from_rgb(160, 90, 120),
sketch_point_free: Color32::from_rgb(30, 90, 200),
section_cap: Color32::from_rgb(214, 128, 72),
dialog_bg: Color32::from_rgb(250, 251, 252),
@@ -341,6 +345,7 @@ impl Tokens {
t.selected_bg = Color32::from_rgb(42, 70, 110);
t.construction_geom = icon_primary(crate::icon_svg::CONSTRUCTION_PRIMARY[1]);
t.error = Color32::from_rgb(240, 100, 96);
t.sketch_conflict = t.error;
t.warning = Color32::from_rgb(236, 170, 70);
t.icon = Color32::from_rgb(208, 214, 226);
t.timeline = Color32::from_rgb(33, 37, 45);
@@ -358,8 +363,9 @@ impl Tokens {
t.sketch_halo = Color32::from_rgb(28, 32, 40);
t.sketch_point = Color32::from_rgb(232, 234, 238);
t.sketch_locked = Color32::from_rgb(96, 204, 116);
t.dimension = Color32::from_rgb(224, 228, 234);
t.dimension_driven = Color32::from_rgb(142, 150, 164);
// Sketch dimensions: Light's plum, lightened for the dark view (#7861).
t.dimension = Color32::from_rgb(224, 176, 224);
t.dimension_driven = Color32::from_rgb(176, 104, 144);
t.sketch_point_free = Color32::from_rgb(92, 150, 255);
t.profile_fill = Color32::from_rgba_unmultiplied_const(120, 150, 200, 40);
t.dialog_bg = Color32::from_rgb(39, 43, 52);
@@ -410,6 +416,9 @@ impl Tokens {
}
t.construction_geom = icon_primary(crate::icon_svg::CONSTRUCTION_RED_GREEN[dark as usize]);
t.glyph_text = icon_primary(crate::icon_svg::CONSTRAINT_RED_GREEN[dark as usize]);
// A deeper red keeps conflicting sketch geometry (#7244) apart from the orange
// construction and centerline lines and the crimson lost links for a red-green loss.
t.sketch_conflict = Color32::from_rgb(195, 35, 30);
t.surface_accent = if dark { Color32::from_rgb(200, 135, 55) } else { Color32::from_rgb(164, 80, 0) };
t.mesh_accent = if dark { Color32::from_rgb(190, 175, 70) } else { Color32::from_rgb(130, 103, 0) };
t.form_accent = if dark { Color32::from_rgb(165, 135, 220) } else { Color32::from_rgb(115, 73, 205) };
@@ -440,6 +449,9 @@ impl Tokens {
t.preview_sketch = Color32::from_rgb(125, 180, 105);
t.glyph_text = icon_primary(crate::icon_svg::CONSTRAINT_BLUE_YELLOW[dark as usize]);
t.construction_geom = icon_primary(crate::icon_svg::CONSTRUCTION_BLUE_YELLOW[dark as usize]);
// Conflicting sketch geometry (#7244) apart from the lost links and the construction and
// centerline lines for a blue-yellow loss: darker on the light view, lighter on the dark.
t.sketch_conflict = if dark { Color32::from_rgb(255, 170, 160) } else { Color32::from_rgb(165, 15, 30) };
if dark {
t.sketch_locked = Color32::from_rgb(140, 225, 110);
t.box_crossing = Color32::from_rgb(240, 110, 170);
+3
View File
@@ -1,4 +1,6 @@
{
"order": 76,
"provenance": "Original black-and-white palette",
"base": "dark",
"colors": {
"accent": "#ffffff",
@@ -142,6 +144,7 @@
"sketch": "#ffffff",
"sketch_accent": "#ffffff",
"sketch_centerline": "#ffffff",
"sketch_conflict": "#ffffff",
"sketch_construction": "#ffffff",
"sketch_fixed": "#ffffff",
"sketch_halo": "#000000",
+3
View File
@@ -1,4 +1,6 @@
{
"order": 75,
"provenance": "Original black-and-white palette",
"base": "light",
"colors": {
"accent": "#000000",
@@ -142,6 +144,7 @@
"sketch": "#000000",
"sketch_accent": "#000000",
"sketch_centerline": "#000000",
"sketch_conflict": "#000000",
"sketch_construction": "#000000",
"sketch_fixed": "#000000",
"sketch_halo": "#ffffff",
+4 -2
View File
@@ -1,4 +1,6 @@
{
"order": 220,
"provenance": "Maps the Okibi design system",
"base": "light",
"colors": {
"accent": "#c63a00",
@@ -50,8 +52,8 @@
"dialog_footer": "#efece8",
"dialog_label": "#403d38",
"dialog_rule": "#d9d5d0",
"dimension": "#403d38",
"dimension_driven": "#6c6863",
"dimension": "#006a70",
"dimension_driven": "#4d8a8c",
"error": "#c71529",
"field": "#fffdfa",
"file_rail": "#e1ddd8",
+4 -2
View File
@@ -1,4 +1,6 @@
{
"order": 110,
"provenance": "Original SolidCraft theme",
"base": "dark",
"colors": {
"accent": "#ffbc48",
@@ -50,8 +52,8 @@
"dialog_footer": "#100b04",
"dialog_label": "#f3ca87",
"dialog_rule": "#3b280d",
"dimension": "#ffe2a5",
"dimension_driven": "#9b6922",
"dimension": "#f5e050",
"dimension_driven": "#a8962e",
"error": "#fff0ca",
"field": "#100b04",
"form_accent": "#ca892b",
+4 -2
View File
@@ -1,4 +1,6 @@
{
"order": 120,
"provenance": "Original SolidCraft theme",
"base": "dark",
"colors": {
"accent": "#66ddff",
@@ -50,8 +52,8 @@
"dialog_footer": "#0c223c",
"dialog_label": "#e3f0fa",
"dialog_rule": "#254764",
"dimension": "#e3f0fa",
"dimension_driven": "#94b2cb",
"dimension": "#ffd970",
"dimension_driven": "#c9a95a",
"error": "#ff968d",
"field": "#071d35",
"form_accent": "#baade8",
@@ -1,4 +1,6 @@
{
"order": 200,
"provenance": "Original SolidCraft mapping of Catppuccin (MIT palette)",
"base": "dark",
"colors": {
"accent": "#cba6f7",
@@ -50,8 +52,8 @@
"dialog_footer": "#1e1e2e",
"dialog_label": "#cdd6f4",
"dialog_rule": "#45475a",
"dimension": "#cdd6f4",
"dimension_driven": "#a6adc8",
"dimension": "#94e2d5",
"dimension_driven": "#6c9d95",
"error": "#f38ba8",
"field": "#181825",
"form_accent": "#a9b2f2",
+5 -2
View File
@@ -1,4 +1,6 @@
{
"order": 250,
"provenance": "Original mapping of the IBM Color Graphics Adapter palette",
"base": "dark",
"colors": {
"accent": "#55ffff",
@@ -45,8 +47,8 @@
"dialog_footer": "#000000",
"dialog_label": "#55ffff",
"dialog_rule": "#00aaaa",
"dimension": "#ffffff",
"dimension_driven": "#aaaaaa",
"dimension": "#ffff55",
"dimension_driven": "#aa5500",
"error": "#ff5555",
"field": "#000000",
"file_background": "#000000",
@@ -142,6 +144,7 @@
"sketch": "#55ffff",
"sketch_accent": "#ff55ff",
"sketch_centerline": "#ffff55",
"sketch_conflict": "#ff5555",
"sketch_construction": "#aaaaaa",
"sketch_fixed": "#ffffff",
"sketch_halo": "#000000",
+4 -2
View File
@@ -1,4 +1,6 @@
{
"order": 240,
"provenance": "Original warm paper and charcoal palette",
"base": "dark",
"colors": {
"accent": "#e8845e",
@@ -50,8 +52,8 @@
"dialog_footer": "#29241f",
"dialog_label": "#c3bdb5",
"dialog_rule": "#403930",
"dimension": "#c3bdb5",
"dimension_driven": "#9d9285",
"dimension": "#5fc4c0",
"dimension_driven": "#4f9895",
"error": "#f85052",
"field": "#3c352d",
"form_accent": "#a6a2fc",
+4 -2
View File
@@ -1,4 +1,6 @@
{
"order": 230,
"provenance": "Original warm paper and charcoal palette",
"base": "light",
"colors": {
"accent": "#bf5634",
@@ -50,8 +52,8 @@
"dialog_footer": "#f4eee3",
"dialog_label": "#423e36",
"dialog_rule": "#e0d8c9",
"dimension": "#423e36",
"dimension_driven": "#726a5d",
"dimension": "#006a70",
"dimension_driven": "#4a8688",
"error": "#c71529",
"field": "#fdfaf3",
"form_accent": "#5844bc",
+4 -2
View File
@@ -1,4 +1,6 @@
{
"order": 90,
"provenance": "Original SolidCraft theme",
"base": "light",
"colors": {
"accent": "#007aff",
@@ -50,8 +52,8 @@
"dialog_footer": "#ececec",
"dialog_label": "#262628",
"dialog_rule": "#c6c6c8",
"dimension": "#262628",
"dimension_driven": "#6e6e73",
"dimension": "#00646b",
"dimension_driven": "#3d8087",
"error": "#d53137",
"field": "#ffffff",
"form_accent": "#8b62bf",
+4 -2
View File
@@ -1,4 +1,6 @@
{
"order": 180,
"provenance": "Original SolidCraft theme",
"base": "dark",
"colors": {
"accent": "#4aa3d8",
@@ -50,8 +52,8 @@
"dialog_footer": "#25272b",
"dialog_label": "#e0e2e5",
"dialog_rule": "#484d55",
"dimension": "#e0e2e5",
"dimension_driven": "#a0a6b0",
"dimension": "#5fd0c8",
"dimension_driven": "#4a9a95",
"error": "#ed7777",
"field": "#1c1e21",
"form_accent": "#a08fc4",
+4 -2
View File
@@ -1,4 +1,6 @@
{
"order": 100,
"provenance": "Original SolidCraft theme",
"base": "dark",
"colors": {
"accent": "#4dff79",
@@ -50,8 +52,8 @@
"dialog_footer": "#061009",
"dialog_label": "#8def9f",
"dialog_rule": "#173b22",
"dimension": "#8def9f",
"dimension_driven": "#5b9b6a",
"dimension": "#c8ff4d",
"dimension_driven": "#7fa040",
"error": "#d8ffdf",
"field": "#061009",
"form_accent": "#1faa4e",
@@ -1,4 +1,6 @@
{
"order": 20,
"provenance": "Original SolidCraft mapping of Gruvbox (MIT palette)",
"format": "solidcraft-theme/1",
"name": "Gruvbox Dark Hard",
"base": "dark",
@@ -55,8 +57,8 @@
"sketch_fixed": "#ebdbb2",
"sketch_halo": "#141617",
"sketch_locked": "#b8bb26",
"dimension": "#ebdbb2",
"dimension_driven": "#928374",
"dimension": "#fabd2f",
"dimension_driven": "#b57614",
"sketch_point": "#ebdbb2",
"sketch_point_free": "#83a598",
"dialog_bg": "#3c3836",
@@ -1,4 +1,6 @@
{
"order": 30,
"provenance": "Original SolidCraft mapping of Gruvbox (MIT palette)",
"format": "solidcraft-theme/1",
"name": "Gruvbox Dark Soft",
"base": "dark",
@@ -56,8 +58,8 @@
"sketch_fixed": "#ebdbb2",
"sketch_halo": "#1d2021",
"sketch_locked": "#b8bb26",
"dimension": "#ebdbb2",
"dimension_driven": "#928374",
"dimension": "#fabd2f",
"dimension_driven": "#b57614",
"sketch_point": "#ebdbb2",
"sketch_point_free": "#83a598",
"dialog_bg": "#3c3836",
+4 -2
View File
@@ -1,4 +1,6 @@
{
"order": 10,
"provenance": "Original SolidCraft mapping of Gruvbox (MIT palette)",
"format": "solidcraft-theme/1",
"name": "Gruvbox Dark",
"base": "dark",
@@ -56,8 +58,8 @@
"sketch_fixed": "#ebdbb2",
"sketch_halo": "#1d2021",
"sketch_locked": "#b8bb26",
"dimension": "#ebdbb2",
"dimension_driven": "#928374",
"dimension": "#fabd2f",
"dimension_driven": "#b57614",
"sketch_point": "#ebdbb2",
"sketch_point_free": "#83a598",
"dialog_bg": "#3c3836",
@@ -1,4 +1,6 @@
{
"order": 50,
"provenance": "Original SolidCraft mapping of Gruvbox (MIT palette)",
"format": "solidcraft-theme/1",
"name": "Gruvbox Light Hard",
"base": "light",
@@ -55,8 +57,8 @@
"sketch_fixed": "#282828",
"sketch_halo": "#fbf8e3",
"sketch_locked": "#79740e",
"dimension": "#3c3836",
"dimension_driven": "#7c6f64",
"dimension": "#1f5c40",
"dimension_driven": "#4f7d64",
"sketch_point": "#282828",
"sketch_point_free": "#076678",
"dialog_bg": "#f9f5d7",
@@ -1,4 +1,6 @@
{
"order": 60,
"provenance": "Original SolidCraft mapping of Gruvbox (MIT palette)",
"format": "solidcraft-theme/1",
"name": "Gruvbox Light Soft",
"base": "light",
@@ -55,8 +57,8 @@
"sketch_fixed": "#282828",
"sketch_halo": "#f6edd0",
"sketch_locked": "#79740e",
"dimension": "#3c3836",
"dimension_driven": "#7c6f64",
"dimension": "#1f5c40",
"dimension_driven": "#4f7d64",
"sketch_point": "#282828",
"sketch_point_free": "#076678",
"dialog_bg": "#f2e5bc",
+4 -2
View File
@@ -1,4 +1,6 @@
{
"order": 40,
"provenance": "Original SolidCraft mapping of Gruvbox (MIT palette)",
"format": "solidcraft-theme/1",
"name": "Gruvbox Light",
"base": "light",
@@ -55,8 +57,8 @@
"sketch_fixed": "#282828",
"sketch_halo": "#fcf5d8",
"sketch_locked": "#79740e",
"dimension": "#3c3836",
"dimension_driven": "#7c6f64",
"dimension": "#1f5c40",
"dimension_driven": "#4f7d64",
"sketch_point": "#282828",
"sketch_point_free": "#076678",
"dialog_bg": "#fbf1c7",
+4 -2
View File
@@ -1,4 +1,6 @@
{
"order": 170,
"provenance": "Original SolidCraft theme",
"base": "dark",
"colors": {
"accent": "#00bfff",
@@ -50,8 +52,8 @@
"dialog_footer": "#000000",
"dialog_label": "#ffffff",
"dialog_rule": "#a0a0a0",
"dimension": "#ffffff",
"dimension_driven": "#cccccc",
"dimension": "#ffff66",
"dimension_driven": "#c8c850",
"error": "#ff6666",
"field": "#000000",
"form_accent": "#d98af9",
+4 -2
View File
@@ -1,4 +1,6 @@
{
"order": 160,
"provenance": "Original SolidCraft mapping of Nord (MIT palette)",
"base": "dark",
"colors": {
"accent": "#88c0d0",
@@ -50,8 +52,8 @@
"dialog_footer": "#2e3440",
"dialog_label": "#eceff4",
"dialog_rule": "#4c566a",
"dimension": "#eceff4",
"dimension_driven": "#a4b0c2",
"dimension": "#d08770",
"dimension_driven": "#a87060",
"error": "#bf616a",
"field": "#242933",
"form_accent": "#cfa7c7",
+4 -2
View File
@@ -1,4 +1,6 @@
{
"order": 190,
"provenance": "Original SolidCraft mapping of Dracula (MIT palette)",
"base": "dark",
"colors": {
"accent": "#bd93f9",
@@ -50,8 +52,8 @@
"dialog_footer": "#282a36",
"dialog_label": "#f8f8f2",
"dialog_rule": "#50546d",
"dimension": "#f8f8f2",
"dimension_driven": "#a1a4c4",
"dimension": "#8be9fd",
"dimension_driven": "#62a3b1",
"error": "#ff5555",
"field": "#21222c",
"form_accent": "#c096e8",
+4 -2
View File
@@ -1,4 +1,6 @@
{
"order": 210,
"provenance": "Maps the Okibi design system",
"base": "dark",
"colors": {
"accent": "#ff7c50",
@@ -50,8 +52,8 @@
"dialog_footer": "#1b1e20",
"dialog_label": "#bbbec1",
"dialog_rule": "#2f3235",
"dimension": "#bbbec1",
"dimension_driven": "#8f9296",
"dimension": "#5fc4c0",
"dimension_driven": "#4f9895",
"error": "#f85052",
"field": "#2c2e31",
"file_rail": "#0f1113",
+4 -2
View File
@@ -1,4 +1,6 @@
{
"order": 130,
"provenance": "Original SolidCraft mapping of Atom One Dark (MIT palette)",
"base": "dark",
"colors": {
"accent": "#61afef",
@@ -50,8 +52,8 @@
"dialog_footer": "#2f343e",
"dialog_label": "#b4bbc8",
"dialog_rule": "#353b47",
"dimension": "#abb2bf",
"dimension_driven": "#7f848e",
"dimension": "#56b6c2",
"dimension_driven": "#3f8590",
"error": "#e06c75",
"field": "#1e2227",
"form_accent": "#d283e9",
+4 -2
View File
@@ -1,4 +1,6 @@
{
"order": 80,
"provenance": "Original SolidCraft theme",
"base": "light",
"colors": {
"accent": "#555599",
@@ -50,8 +52,8 @@
"dialog_footer": "#dddddd",
"dialog_label": "#222222",
"dialog_rule": "#aaaaaa",
"dimension": "#222222",
"dimension_driven": "#666666",
"dimension": "#00787f",
"dimension_driven": "#4d9196",
"error": "#bb3333",
"field": "#ffffff",
"form_accent": "#704e81",
+4 -2
View File
@@ -1,4 +1,6 @@
{
"order": 150,
"provenance": "Original SolidCraft mapping of Solarized (MIT palette)",
"base": "light",
"colors": {
"accent": "#268bd2",
@@ -50,8 +52,8 @@
"dialog_footer": "#eee8d5",
"dialog_label": "#586e75",
"dialog_rule": "#b6b3a1",
"dimension": "#586e75",
"dimension_driven": "#657b83",
"dimension": "#136059",
"dimension_driven": "#3d8f88",
"error": "#dc322f",
"field": "#fdf6e3",
"form_accent": "#6b70c3",
+4 -2
View File
@@ -1,4 +1,6 @@
{
"order": 70,
"provenance": "Original SolidCraft theme",
"base": "light",
"colors": {
"accent": "#000080",
@@ -50,8 +52,8 @@
"dialog_footer": "#c0c0c0",
"dialog_label": "#000000",
"dialog_rule": "#808080",
"dimension": "#000000",
"dimension_driven": "#808080",
"dimension": "#ffffff",
"dimension_driven": "#ffff00",
"error": "#ff0000",
"field": "#ffffff",
"form_accent": "#800080",
+4 -2
View File
@@ -1,4 +1,6 @@
{
"order": 140,
"provenance": "Original SolidCraft mapping of Tokyo Night (MIT palette)",
"base": "dark",
"colors": {
"accent": "#7aa2f7",
@@ -50,8 +52,8 @@
"dialog_footer": "#1a1b26",
"dialog_label": "#c0caf5",
"dialog_rule": "#414868",
"dimension": "#c0caf5",
"dimension_driven": "#9aa5ce",
"dimension": "#73daca",
"dimension_driven": "#4f9a90",
"error": "#f7768e",
"field": "#16161e",
"form_accent": "#b190ec",
+43 -17
View File
@@ -1,15 +1,40 @@
//! Lists the design-format changes in `src/format/`: one file per change, in `v<N>/` once a
//! release fixed its number, else in `pending/` (see `src/format/pending/README.md`). The list is
//! Lists the format changes of each family of files versioned by `src/format.rs`: designs in
//! `src/format/`, drawings in `src/drawing/format/` (#3890). One file per change, in `v<N>/` once a
//! release fixed its number, else in `pending/` (see `src/format/pending/README.md`). The lists are
//! generated so that a change adds a file and never edits a line another change also edits.
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
/// The last format whose upgrade steps are written out in `format.rs` (`JSON_STEPS`).
const HAND_WRITTEN: u32 = 22;
/// A family's change files: their folder, the last format before them, and the prefix of the
/// family's lists in `format.rs`.
struct Family {
folder: &'static str,
base: u32,
list_prefix: &'static str,
}
/// Every family, each read by `scripts/fold-format-pending.sh` too. A design's upgrade steps are
/// written out in `format.rs` up to 22 (`JSON_STEPS`); a drawing's first format is 1.
const FAMILIES: [Family; 2] =
[Family { folder: "src/format", base: 22, list_prefix: "" }, Family { folder: "src/drawing/format", base: 1, list_prefix: "DRAWING_" }];
fn main() {
let root = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()).join("src/format");
let manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap());
let mut out = String::new();
let mut mods = String::new();
for family in &FAMILIES {
lists(family, &manifest, &mut out, &mut mods);
}
out.push_str(&mods);
let dest = PathBuf::from(std::env::var("OUT_DIR").unwrap()).join("format_parts.rs");
std::fs::write(dest, out).unwrap();
}
/// A family's `FOLDED` and `PENDING` lists; its folded formats follow on from its base.
fn lists(family: &Family, manifest: &Path, out: &mut String, mods: &mut String) {
let Family { folder, base, list_prefix } = family;
let root = manifest.join(folder);
println!("cargo:rerun-if-changed={}", root.display());
let mut folded: Vec<(u32, PathBuf)> = std::fs::read_dir(&root)
.unwrap_or_else(|e| panic!("{}: {e}", root.display()))
@@ -21,23 +46,24 @@ fn main() {
.collect();
folded.sort();
for (i, (version, _)) in folded.iter().enumerate() {
let expected = HAND_WRITTEN + 1 + i as u32;
assert_eq!(*version, expected, "src/format/: expected v{expected}/ next, found v{version}/ (versions are contiguous)");
let expected = base + 1 + i as u32;
assert_eq!(*version, expected, "{folder}/: expected v{expected}/ next, found v{version}/ (versions are contiguous)");
}
let mut out = String::new();
let mut mods = String::new();
out.push_str("/// The changes of each format after the hand-written ones, oldest first.\nconst FOLDED: &[(u32, &[Part])] = &[\n");
// Module names start with the family's (`drawing_pending_…`), so two families' changes of one
// name never clash.
let module = list_prefix.to_ascii_lowercase();
let _ = writeln!(
out,
"/// The changes of each format after the base, oldest first (`{folder}/v<N>/`).\nconst {list_prefix}FOLDED: &[(u32, &[Part])] = &["
);
for (version, dir) in &folded {
let _ = writeln!(out, " ({version}, &[");
parts(dir, &format!("v{version}"), &mut out, &mut mods);
parts(dir, &format!("{module}v{version}"), out, mods);
out.push_str(" ]),\n");
}
out.push_str("];\n\n/// The changes no release has a number for yet (`src/format/pending/`).\nconst PENDING: &[Part] = &[\n");
parts(&root.join("pending"), "pending", &mut out, &mut mods);
out.push_str("];\n");
out.push_str(&mods);
let dest = PathBuf::from(std::env::var("OUT_DIR").unwrap()).join("format_parts.rs");
std::fs::write(dest, out).unwrap();
let _ = writeln!(out, "];\n\n/// The changes no release has a number for yet (`{folder}/pending/`).\nconst {list_prefix}PENDING: &[Part] = &[");
parts(&root.join("pending"), &format!("{module}pending"), out, mods);
out.push_str("];\n\n");
}
/// The `Part`s of the change files in `dir`, in file-name order, and a module for each.
+12 -8
View File
@@ -747,7 +747,7 @@ mod tests {
fn stable_ids_categories_and_original_licences_cover_the_requested_pack() {
let mut ids = BTreeSet::new();
let mut names = BTreeSet::new();
for preset in &STARTER_PACK {
for preset in STARTER_PACK {
assert!(preset.id.starts_with("starter.") && preset.id.len() <= 128);
assert!(preset.id.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'.' || b == b'-'));
assert!(ids.insert(preset.id));
@@ -767,14 +767,14 @@ mod tests {
(Category::Solar, 2),
(Category::Regolith, 4),
] {
assert_eq!(STARTER_PACK.iter().filter(|p| p.category == category).count(), count);
assert!(STARTER_PACK.iter().filter(|p| p.category == category).count() >= count);
}
}
#[test]
fn opaque_construction_and_owned_look_roundtrip_preserve_current_response() {
let mut admitted = 0;
for preset in &STARTER_PACK {
for preset in STARTER_PACK {
if let Recipe::Opaque(recipe) = preset.recipe {
let look = preset.opaque_look().unwrap();
assert_eq!(look.opacity, 1.0);
@@ -789,7 +789,7 @@ mod tests {
admitted += 1;
}
}
assert_eq!(admitted, 34);
assert!(admitted >= 34);
}
#[test]
@@ -815,12 +815,16 @@ mod tests {
bad.boundary = GlassBoundary::Thin { thickness_mm: 10001.0 };
assert!(bad.validate().is_err());
}
// The swatch shows the transmission as sRGB codes.
assert_eq!(find("starter.glass.smoky-neutral").unwrap().preview_color(), [160, 166, 173]);
}
#[test]
fn legacy_library_and_six_existing_finish_names_are_not_overridden_or_duplicated() {
assert_eq!(crate::appearance::LIBRARY.len(), 20);
for (name, color, opacity) in crate::appearance::LIBRARY {
assert!(!crate::appearance::LIBRARY.is_empty());
let mut names = BTreeSet::new();
for &(name, color, opacity) in crate::appearance::LIBRARY {
assert!(!name.is_empty() && names.insert(name.to_ascii_lowercase()));
let look = crate::appearance::library(name).unwrap();
assert_eq!(look.color, color);
assert_eq!(look.opacity, opacity);
@@ -842,7 +846,7 @@ mod tests {
#[test]
fn uniform_previews_and_planned_resource_scale_are_explicit_and_not_bindings() {
let mut recipes = 0;
for preset in &STARTER_PACK {
for preset in STARTER_PACK {
if let Some(recipe) = preset.future_texture_recipe {
assert!(preset.name.contains("Uniform Preview"));
assert!(preset.preview_note.contains("Uniform opaque preview only"));
@@ -853,7 +857,7 @@ mod tests {
recipes += 1;
}
}
assert_eq!(recipes, 14);
assert!(recipes >= 14);
}
}
}
+1 -4
View File
@@ -154,10 +154,7 @@ impl VisualPreset {
match self.recipe {
Recipe::Opaque(r) => r.color,
// A UI swatch of the transmission values, not rendered glass or absorption.
Recipe::Glass(r) => r.transmission_linear_rgb.map(|v| {
let encoded = if v <= 0.0031308 { 12.92 * v } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 };
(encoded.clamp(0.0, 1.0) * 255.0).round() as u8
}),
Recipe::Glass(r) => r.transmission_linear_rgb.map(solidcraft_geom::srgb::encode8),
}
}
+50
View File
@@ -124,6 +124,48 @@ fn identity() -> Mat {
IDENTITY
}
/// How a component counts in the bill of materials of an assembly it is placed in. It is a part
/// property (the `bom_type` key of the component's [`Metadata`](crate::Metadata)); a component
/// without one is [`BomType::Normal`].
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BomType {
/// A row of its own; every occurrence adds to its quantity.
#[default]
Normal,
/// No row of its own: what it holds counts as if placed directly in its parent.
Phantom,
/// Shown, but counted nowhere, and nothing inside it is counted either.
Reference,
/// Bought: one row, with nothing inside it counted or listed.
Purchased,
/// Made as one item that doesn't come apart (a welded frame): one row in the assembly it is
/// placed in. What it holds is listed in its own bill of materials, and its purchased parts
/// also in the parts-only list of the assembly it is placed in.
Inseparable,
}
impl BomType {
/// Every type, in the order commands and documentation list them.
pub const ALL: [BomType; 5] = [BomType::Normal, BomType::Phantom, BomType::Reference, BomType::Purchased, BomType::Inseparable];
/// The word the design file and commands use.
pub fn as_str(self) -> &'static str {
match self {
BomType::Normal => "normal",
BomType::Phantom => "phantom",
BomType::Reference => "reference",
BomType::Purchased => "purchased",
BomType::Inseparable => "inseparable",
}
}
/// The type a word names, if it names one.
pub fn parse(word: &str) -> Option<BomType> {
BomType::ALL.into_iter().find(|t| t.as_str() == word)
}
}
/// How deep and how many placements [`Document::placements`] walks.
pub const MAX_PLACEMENT_DEPTH: usize = 64;
pub const MAX_PLACEMENTS: usize = 100_000;
@@ -213,6 +255,14 @@ pub fn map_point_expr(m: &Mat, p: &mut crate::PointExpr) {
}
}
/// Map a displacement given as length expressions, retaining its parameter dependencies.
/// Unlike a point, a displacement is unaffected by the frame's translation.
pub fn map_vector_expr(m: &Mat, v: &mut crate::PointExpr) {
let mut rotation = *m;
rotation[3] = [0.0, 0.0, 0.0, 1.0];
map_point_expr(&rotation, v);
}
impl FeatureKind {
/// Map the feature's world geometry (points, directions, explicit planes) by `m`.
pub fn transform_geometry(&mut self, m: &Mat) {
+10 -4
View File
@@ -215,10 +215,16 @@ fn named_vertex(st: &ModelState, at: Vec3, names: &[String]) -> Option<Vec3> {
let ends: Vec<[Vec3; 2]> = names
.iter()
.map(|n| {
// The edge with that name itself, never the one nearest the old pick.
let on = bodies(st).find_map(|b| crate::naming::find_edge(b, n, at).ok())?.0;
let pts = edge_points(st, on, &None).ok()?;
Some([*pts.first()?, *pts.last()?])
// The edge with that name itself, never the one nearest the old pick, and its ends
// on the body that has it: picked again by a point on it, a corner found the first
// edge of any body through that corner, such as another body's edge running past
// it, so a vertex took that edge's end (#8650).
bodies(st).find_map(|b| {
let hit = crate::name_lookup::locate_edge(b, n, at).ok()?;
let m = b.mesh();
let pts = m.edges.get(hit.edge)?;
Some([*pts.first()?, *pts.last()?])
})
})
.collect::<Option<_>>()?;
let (first, rest) = ends.split_first()?;
+9 -3
View File
@@ -2727,6 +2727,9 @@ pub struct Document {
/// Reference images on planes (Insert Canvas).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub canvases: Vec<crate::canvas::Canvas>,
/// Scanned points kept as a reference (Insert Point Cloud).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub point_clouds: Vec<crate::point_cloud::PointCloud>,
/// Comments of feature-input parameters (by name).
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub param_comments: std::collections::BTreeMap<String, String>,
@@ -2932,6 +2935,7 @@ impl Document {
.map(|g| g.id)
.chain(self.timeline_groups.iter().map(|g| g.id))
.chain(self.canvases.iter().map(|c| c.id))
.chain(self.point_clouds.iter().map(|c| c.id))
.chain(a.joints.iter().map(|j| j.id))
.chain(a.contact_sets.iter().map(|c| c.id))
.chain(a.studies.iter().map(|s| s.id))
@@ -2979,6 +2983,7 @@ impl Document {
appearances: Default::default(),
favorites: Default::default(),
canvases: Vec::new(),
point_clouds: Vec::new(),
param_comments: Default::default(),
browser_groups: Vec::new(),
timeline_groups: Vec::new(),
@@ -3346,12 +3351,13 @@ impl Document {
Ok(id)
}
/// A component by id or name (0 / "root" is the design itself).
/// A component by id or name (0 / "root" is the design itself). A component that carries the
/// design's own name is found by that name: the design is still named by 0 / "root".
pub fn find_component(&self, key: &str) -> Option<u64> {
if key == "0" || key.eq_ignore_ascii_case("root") || key == self.name {
if key == "0" || key.eq_ignore_ascii_case("root") {
return Some(0);
}
self.components.iter().find(|c| c.id.to_string() == key || c.name == key).map(|c| c.id)
self.components.iter().find(|c| c.id.to_string() == key || c.name == key).map(|c| c.id).or_else(|| (key == self.name).then_some(0))
}
/// Read a design file's JSON (any format this build knows; older ones are upgraded).
+305
View File
@@ -0,0 +1,305 @@
//! A drawing (#3890): sheets of paper that draw designs saved in their own files. A drawing is a
//! file of its own, `"format": "solidcraft-drawing/N"`, versioned by the same code as designs
//! ([`DRAWING`]); format 1 is this record.
//!
//! - **Sources** are the designs it draws: an id and the design file's path, relative to the
//! drawing's folder once the drawing is saved, else as given, with `/` between folders (a linked
//! design's [`DesignLink`](crate::DesignLink) rule). What a design was when the drawing last saw
//! it is not saved here.
//! - **Sheets**: the paper, a copy of its drawing border, the resolution its pictures are rendered
//! at, the component it documents, and whether it is left out of the sheet count or of printing.
//! The border is a copy of the profile it was made with, never a name, so a later built-in never
//! changes a saved drawing.
//! - **Units** follow each view's design or are one length unit for the whole drawing. The
//! **language** is the content's, a BCP 47 tag chosen when the drawing is made, whatever the
//! interface's is. **Properties** follow [`Metadata`]'s rules.
//!
//! The rules, which every later kind of record (views, annotations, tables) keeps too:
//! - Every object has an id of [`crate::ids`]: random, so objects added side by side never take
//! the same one, and written as 13 characters of base32. No two objects share an id, and a run
//! never gives a removed object's id again ([`Drawing::allocate_id`]).
//! - Every reference between records is an id and must resolve: a file with one that doesn't is
//! damaged and refused, never patched.
//! - 1 to [`MAX_SHEETS`] sheets and at most [`MAX_SOURCES`] sources; names of 1 to
//! [`MAX_NAME_BYTES`] bytes; paths of 1 to [`MAX_PATH_BYTES`] bytes, without NUL.
//! - Nothing derived is saved, and a drawing always saves as the same bytes: fields in their
//! order, maps sorted.
//! - A later change to the record is one file in `src/drawing/format/pending/` (its README).
//!
//! [`Drawing::from_json`] reads a file in this order: at most [`MAX_DRAWING_BYTES`] of text, the
//! family's format check, every number in range, the upgrade, the typed record, then
//! [`Drawing::validate`].
use std::collections::BTreeSet;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use solidcraft_geom::drawing_sheet::{self, DrawingBorderProfile};
use crate::format::DRAWING;
use crate::ids::{self, IdSource};
use crate::{DocError, Metadata, Result, expr};
/// The most sheets a drawing may have.
pub const MAX_SHEETS: usize = 1000;
/// The most designs a drawing may draw.
pub const MAX_SOURCES: usize = 64;
/// The longest name of a drawing or a sheet, in bytes.
pub const MAX_NAME_BYTES: usize = 256;
/// The longest path to a design, in bytes.
pub const MAX_PATH_BYTES: usize = 4096;
/// The longest language tag: what RFC 5646 asks every reader to take (§4.4.1).
pub const MAX_LANGUAGE_CHARS: usize = 35;
/// The largest drawing read, as JSON text.
pub const MAX_DRAWING_BYTES: usize = 16 << 20;
/// The resolution a sheet's pictures (its shaded views) are rendered at unless it says, in dots
/// per inch.
pub const DEFAULT_DPI: f64 = 300.0;
/// The lowest resolution a sheet may ask for.
pub const MIN_DPI: f64 = 25.0;
/// The highest resolution a sheet may ask for.
pub const MAX_DPI: f64 = 2400.0;
/// A drawing: its sheets and the designs they draw.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Drawing {
/// `solidcraft-drawing/N`: the format this build writes, whatever the file said.
pub format: String,
/// The changes of `format` this drawing has while they are pending
/// ([`Family::pending_names`](crate::format::Family::pending_names)). Empty, and not saved,
/// otherwise.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub format_pending: Vec<String>,
pub name: String,
/// The designs the drawing draws.
pub sources: Vec<Source>,
/// The drawing's properties and other tools' data, kept as they are.
#[serde(default, skip_serializing_if = "Metadata::is_empty")]
pub properties: Metadata,
#[serde(default)]
pub units: Units,
/// The language of the drawing's content, a BCP 47 tag (`en-GB`).
pub language: String,
/// In order, each behind its own `Arc`, so an undo step shares every sheet it didn't change.
pub sheets: Vec<Arc<Sheet>>,
}
/// A design the drawing draws.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Source {
#[serde(with = "crate::ids")]
pub id: u64,
/// The design file: relative to the drawing's folder once the drawing is saved, else as given
/// (`/` separates folders).
pub path: String,
}
/// The length unit a drawing gives its measurements in.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum Units {
/// Each view's design's own units.
#[default]
FollowModel,
/// One length unit for the whole drawing, by its name in [`expr::UNITS`] (`mm`, `in`).
Fixed { unit: String },
}
/// One sheet of a drawing.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Sheet {
#[serde(with = "crate::ids")]
pub id: u64,
pub name: String,
/// The paper: its size and the way it lies.
pub sheet: drawing_sheet::Sheet,
/// A copy of the drawing border's profile (`None`: no drawing border).
pub border: Option<DrawingBorderProfile>,
/// The resolution its pictures (its shaded views) are rendered at, in dots per inch.
#[serde(default = "default_dpi")]
pub dpi: f64,
/// The component the sheet documents, which its title block reads.
pub documents: Option<SheetComponent>,
#[serde(default)]
pub excluded: Excluded,
}
/// A component of one of the drawing's designs.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SheetComponent {
/// The [`Source`]'s id.
#[serde(with = "crate::ids")]
pub source: u64,
/// The component's id in that design, as the design writes it (0: the design itself).
pub component: u64,
}
/// What a sheet is left out of.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Excluded {
/// The sheet count ("sheet 2 of 3" on the other sheets).
pub count: bool,
/// Printing and exporting every sheet.
pub print: bool,
}
fn default_dpi() -> f64 {
DEFAULT_DPI
}
impl Drawing {
/// A drawing of no designs yet, named `name` and written in `language`, with one sheet named
/// `sheet`: the paper `paper` with a copy of `border`, its id from `id_source`.
pub fn new(
name: &str,
language: &str,
sheet: &str,
paper: drawing_sheet::Sheet,
border: Option<DrawingBorderProfile>,
id_source: &mut IdSource,
) -> Result<Drawing> {
let id = id_source.next(&BTreeSet::new())?;
let first = Sheet { id, name: sheet.into(), sheet: paper, border, dpi: DEFAULT_DPI, documents: None, excluded: Excluded::default() };
let drawing = Drawing {
format: DRAWING.format_string(DRAWING.current()),
format_pending: DRAWING.pending_names(),
name: name.into(),
sources: Vec::new(),
properties: Metadata::default(),
units: Units::default(),
language: language.into(),
sheets: vec![Arc::new(first)],
};
drawing.validate()?;
Ok(drawing)
}
/// Read a drawing file's JSON (any format this build knows; older ones are upgraded). A file
/// that breaks a rule of the record is damaged and refused, never patched.
pub fn from_json(s: &str) -> Result<Drawing> {
if s.len() > MAX_DRAWING_BYTES {
return Err(DocError::Invalid(format!("drawing too large (at most {} MiB)", MAX_DRAWING_BYTES >> 20)));
}
let mut v: Value = serde_json::from_str(s).map_err(|e| DocError::Invalid(format!("not a SolidCraft drawing: {e}")))?;
let (version, applied) = DRAWING.take_version(&mut v)?;
DRAWING.check_numbers(&v, "properties")?;
DRAWING.upgrade(&mut v, version, applied.as_deref())?;
let d: Drawing = serde_json::from_value(v).map_err(damaged)?;
d.validate().map_err(damaged)?;
Ok(Drawing { format: DRAWING.format_string(DRAWING.current()), format_pending: DRAWING.pending_names(), ..d })
}
/// The drawing's JSON: the same bytes for the same drawing. JSON longer than a read takes
/// ([`MAX_DRAWING_BYTES`]) is refused, so every drawing saved opens again: other tools'
/// properties are kept as they came, and indenting them can make a drawing that read in
/// under the limit save over it.
pub fn to_json(&self) -> Result<String> {
let text = serde_json::to_string_pretty(self).map_err(|e| DocError::Invalid(format!("drawing not saved: {e}")))?;
require(text.len() <= MAX_DRAWING_BYTES, || format!("drawing too large to save (at most {} MiB)", MAX_DRAWING_BYTES >> 20))?;
Ok(text)
}
/// An id for a new object: one no object of the drawing has, from `id_source`, which never
/// gives one twice.
pub fn allocate_id(&self, id_source: &mut IdSource) -> Result<u64> {
id_source.next(&self.ids().collect())
}
/// Every object's id, of every kind: what a new id must differ from. Each later kind adds its
/// own here.
fn ids(&self) -> impl Iterator<Item = u64> {
self.sources.iter().map(|s| s.id).chain(self.sheets.iter().map(|s| s.id))
}
/// The record's rules (the module's notes): what reading a file checks, and what every edit
/// keeps.
pub fn validate(&self) -> Result<()> {
require((1..=MAX_SHEETS).contains(&self.sheets.len()), || format!("a drawing has 1 to {MAX_SHEETS} sheets"))?;
require(self.sources.len() <= MAX_SOURCES, || format!("a drawing draws at most {MAX_SOURCES} designs"))?;
check_name(&self.name, "the drawing's")?;
require(is_language_tag(&self.language), || {
format!("the language is not a BCP 47 tag of 2 to {MAX_LANGUAGE_CHARS} characters (such as `en` or `en-GB`)")
})?;
if let Units::Fixed { unit } = &self.units {
// A length unit of the expression unit table, written as the table writes it.
let length = unit.trim() == unit.as_str() && expr::unit_info(unit).is_some_and(|(kind, _)| kind == expr::Kind::Length);
require(length, || "the drawing's unit is not a length unit (such as `mm` or `in`)".into())?;
}
let mut seen = BTreeSet::new();
for id in self.ids() {
require(id != 0, || "an object has the id 0, which is never one".into())?;
require(seen.insert(id), || format!("two objects have the id {}", ids::encode(id)))?;
}
for source in &self.sources {
let path = &source.path;
require(!path.is_empty() && path.len() <= MAX_PATH_BYTES && !path.contains('\0'), || {
format!("source {}: a design's path is 1 to {MAX_PATH_BYTES} bytes, without NUL", ids::encode(source.id))
})?;
}
for sheet in &self.sheets {
self.check_sheet(sheet).map_err(|e| DocError::Invalid(format!("sheet {}: {e}", ids::encode(sheet.id))))?;
}
Ok(())
}
/// A sheet's own rules, and that the source it documents is one of the drawing's.
fn check_sheet(&self, sheet: &Sheet) -> Result<()> {
check_name(&sheet.name, "its")?;
sheet.sheet.size_mm().map_err(from_sheet)?;
if let Some(border) = &sheet.border {
border.validate().map_err(from_sheet)?;
}
require((MIN_DPI..=MAX_DPI).contains(&sheet.dpi), || format!("its pictures are {MIN_DPI} to {MAX_DPI} dpi"))?;
match sheet.documents {
Some(SheetComponent { source, .. }) => {
let named = || format!("it documents source {}, which the drawing doesn't have", ids::encode(source));
require(self.sources.iter().any(|s| s.id == source), named)
}
None => Ok(()),
}
}
}
/// `Ok` when `ok`, else the rule `broken` names.
fn require(ok: bool, broken: impl FnOnce() -> String) -> Result<()> {
if ok { Ok(()) } else { Err(DocError::Invalid(broken())) }
}
/// A name: 1 to [`MAX_NAME_BYTES`] bytes, not only spaces, without control characters.
fn check_name(name: &str, whose: &str) -> Result<()> {
require(!name.trim().is_empty() && name.len() <= MAX_NAME_BYTES && !name.chars().any(char::is_control), || {
format!("{whose} name is 1 to {MAX_NAME_BYTES} bytes, not only spaces, without control characters")
})
}
/// A well-formed language tag, as far as a drawing needs (RFC 5646 §2.1): 2 to
/// [`MAX_LANGUAGE_CHARS`] characters, subtags of 1 to 8 ASCII letters and digits joined by `-`,
/// the first of 2 to 8 letters (`en`, `en-GB`, `zh-Hans-CN`). Whether the subtags are registered
/// isn't checked, and a private-use tag alone (`x-…`) isn't taken.
fn is_language_tag(tag: &str) -> bool {
let mut subtags = tag.split('-');
let first = subtags.next().unwrap_or_default();
(2..=MAX_LANGUAGE_CHARS).contains(&tag.len())
&& (2..=8).contains(&first.len())
&& first.bytes().all(|b| b.is_ascii_alphabetic())
&& subtags.all(|s| (1..=8).contains(&s.len()) && s.bytes().all(|b| b.is_ascii_alphanumeric()))
}
/// A file that breaks a rule of the record.
fn damaged(e: impl std::fmt::Display) -> DocError {
DocError::Invalid(format!("damaged drawing: {e}"))
}
fn from_sheet(e: drawing_sheet::Invalid) -> DocError {
DocError::Invalid(e.to_string())
}
#[cfg(test)]
#[path = "drawing_tests.rs"]
mod tests;
@@ -0,0 +1,30 @@
# Pending drawing-format changes
One file per change to what a drawing file saves that no release has shipped yet. Drawings
(`"format": "solidcraft-drawing/N"`, #3890) are versioned by the same code as designs
(`crates/doc/src/format.rs`, `Family`), from their own first format, 1. Adding a file here is the
whole format bump: never edit the `DRAWING` family in `format.rs`, another change's file or a
`../v<N>/` folder.
- Name it after the change, lowercase with dashes: `sheet-views.rs`.
- Start it with a `//!` note: what is saved, what an older drawing gets without it, and what
the last release does with a drawing that has it (it refuses the newer format number).
- If older drawings must be rewritten as they open, add the step to the same file:
```rust
//! …the note…
use serde_json::Value;
pub fn step(v: &mut Value) -> crate::Result<()> {
// rewrite the drawing's JSON here
Ok(())
}
```
Steps run in file-name order, after the steps of every older format.
While this folder has a change in it, the build writes the next drawing format number (the last
folded one + 1, which no release reads), and lists the changes a drawing has in its
`format_pending` field, so a later build applies only the steps that drawing is missing. At
release time `scripts/fold-format-pending.sh` moves these files into `../v<N>/`, as it does the
design format's (`crates/doc/src/format/pending/README.md`).
+553
View File
@@ -0,0 +1,553 @@
use serde_json::json;
use solidcraft_geom::drawing_sheet::{Margins, Orientation, PaperSize, SheetSize};
use super::*;
/// The seed every test drawing's ids come from.
const SEED: u64 = 3890;
/// A profile of its own: ISO 5457's filing margin and trimming marks, which the built-in leaves off.
fn own_border() -> DrawingBorderProfile {
DrawingBorderProfile { filing_margin: true, trimming_mark_mm: Some([10.0, 5.0]), ..DrawingBorderProfile::iso() }
}
/// Three designs on two sheets: the first sheet (A3, the ISO border) documents component 7 of the
/// first design; the second (A4 portrait, a border of its own, 600 dpi, left out of printing)
/// documents the second design. The third design is on no sheet yet.
fn plate() -> Drawing {
let mut id_source = IdSource::seeded(SEED);
let a3 = drawing_sheet::Sheet::standard(PaperSize::A3, Orientation::Landscape);
let mut d = Drawing::new("Plate assembly", "en-GB", "Assembly", a3, Some(DrawingBorderProfile::iso()), &mut id_source).unwrap();
for path in ["plate.sc", "../parts/bolt.sc", "bracket.sc"] {
let id = d.allocate_id(&mut id_source).unwrap();
d.sources.push(Source { id, path: path.into() });
}
let (plate_id, bolt_id) = (d.sources[0].id, d.sources[1].id);
let id = d.allocate_id(&mut id_source).unwrap();
d.sheets.push(Arc::new(Sheet {
id,
name: "Bolt".into(),
sheet: drawing_sheet::Sheet::standard(PaperSize::A4, Orientation::Portrait),
border: Some(own_border()),
dpi: 600.0,
documents: Some(SheetComponent { source: bolt_id, component: 0 }),
excluded: Excluded { count: false, print: true },
}));
Arc::make_mut(&mut d.sheets[0]).documents = Some(SheetComponent { source: plate_id, component: 7 });
d.properties.set(crate::metadata::Owner::Design, "author", json!("Jane Doe")).unwrap();
d.units = Units::Fixed { unit: "in".into() };
d.validate().unwrap();
d
}
/// One A4 sheet that documents nothing, no sources: the base the caps grow from.
fn bare() -> Drawing {
let a4 = drawing_sheet::Sheet::standard(PaperSize::A4, Orientation::Landscape);
Drawing::new("Bare", "en", "Sheet", a4, None, &mut IdSource::seeded(SEED)).unwrap()
}
fn file(d: &Drawing) -> Value {
serde_json::from_str(&d.to_json().unwrap()).unwrap()
}
fn read(file: &Value) -> Result<Drawing> {
Drawing::from_json(&file.to_string())
}
/// Why `file` is refused, or "read".
fn refusal(file: &Value) -> String {
read(file).map_or_else(|e| e.to_string(), |_| "read".into())
}
/// Why `d`'s file, changed by `edit`, is refused, or "read".
fn refused(d: &Drawing, edit: impl FnOnce(&mut Value)) -> String {
let mut v = file(d);
edit(&mut v);
refusal(&v)
}
/// A drawing reads back as it was saved, and save, open and save again writes the same bytes, as
/// does another run from the same seed. The file holds each field under the record's name for it,
/// ids as their 13 characters, and no counter.
#[test]
fn a_drawing_reads_back_as_saved_and_saves_the_same_bytes_again() {
let d = plate();
let saved = d.to_json().unwrap();
let reopened = Drawing::from_json(&saved).unwrap();
assert_eq!(reopened, d);
assert_eq!(reopened.to_json().unwrap(), saved);
assert_eq!(plate().to_json().unwrap(), saved, "a seeded run makes the same drawing");
let v = file(&d);
let [plate_id, bolt_id, bracket_id] = [0, 1, 2].map(|i| ids::encode(d.sources[i].id));
let [assembly_id, bolt_sheet_id] = [0, 1].map(|i| ids::encode(d.sheets[i].id));
assert_eq!(assembly_id, "1ge30wkvq38r0", "the seed's first id, as splitmix64 gives it");
assert_eq!(v["format"], DRAWING.format_string(DRAWING.current()));
assert_eq!(v.get("format_pending").is_some(), !DRAWING.pending_names().is_empty());
assert_eq!(v.get("next_id"), None);
assert_eq!((&v["name"], &v["language"]), (&json!("Plate assembly"), &json!("en-GB")));
assert_eq!(
v["sources"],
json!([{"id": plate_id, "path": "plate.sc"}, {"id": bolt_id, "path": "../parts/bolt.sc"}, {"id": bracket_id, "path": "bracket.sc"}])
);
assert_eq!(v["properties"], json!({"author": "Jane Doe"}));
assert_eq!(v["units"], json!({"fixed": {"unit": "in"}}));
assert_eq!((&v["sheets"][0]["id"], &v["sheets"][0]["dpi"]), (&json!(assembly_id), &json!(300.0)));
assert_eq!(v["sheets"][0]["sheet"], json!({"size": {"standard": "a3"}, "orientation": "landscape"}));
assert_eq!(v["sheets"][0]["documents"], json!({"source": plate_id, "component": 7}));
assert_eq!(v["sheets"][0]["excluded"], json!({"count": false, "print": false}));
assert_eq!(
v["sheets"][1],
json!({
"id": bolt_sheet_id, "name": "Bolt", "sheet": {"size": {"standard": "a4"}, "orientation": "portrait"},
"border": serde_json::to_value(own_border()).unwrap(), "dpi": 600.0,
"documents": {"source": bolt_id, "component": 0}, "excluded": {"count": false, "print": true}
})
);
}
/// A hand-written file may leave out what has a default, and reads as a new drawing has it; the
/// rest is needed.
#[test]
fn a_file_may_leave_out_what_has_a_default() {
let minimal = json!({
"format": DRAWING.format_string(1),
"name": "Bracket",
"sources": [],
"language": "de",
"sheets": [{"id": "0000000000001", "name": "Blatt 1", "sheet": {"size": {"standard": "a3"}}}]
});
let d = read(&minimal).unwrap();
assert_eq!((d.sheets[0].id, &d.units, d.properties.is_empty()), (1, &Units::FollowModel, true));
let sheet = &d.sheets[0];
assert_eq!(sheet.sheet, drawing_sheet::Sheet::standard(PaperSize::A3, Orientation::Landscape));
assert_eq!((sheet.border, sheet.dpi, sheet.documents, sheet.excluded), (None, DEFAULT_DPI, None, Excluded::default()));
// The format as this build writes it, however the file spelt it.
let mut spelt = minimal.clone();
spelt["format"] = json!("solidcraft-drawing/+1");
assert_eq!(read(&spelt).unwrap().format, DRAWING.format_string(DRAWING.current()));
for key in ["name", "sources", "language", "sheets"] {
let mut v = minimal.clone();
v.as_object_mut().unwrap().remove(key);
let why = refusal(&v);
assert!(why.starts_with("damaged drawing: ") && why.contains(&format!("missing field `{key}`")), "{why}");
}
}
/// Every cap on both sides: sheets, sources, the bytes of paths and names (two-byte characters too).
#[test]
fn every_cap_holds_on_both_sides() {
let base = file(&bare());
let with = |key: &str, value: Value| {
let mut v = base.clone();
v[key] = value;
v
};
let sheet = base["sheets"][0].clone();
let sheets = |n: u64| {
let one = |id: u64| {
let mut s = sheet.clone();
s["id"] = json!(ids::encode(id));
s
};
Value::Array((1..=n).map(one).collect())
};
assert_eq!(read(&with("sheets", sheets(1000))).unwrap().sheets.len(), 1000);
for n in [0, 1001] {
assert_eq!(refusal(&with("sheets", sheets(n))), "damaged drawing: a drawing has 1 to 1000 sheets", "{n} sheets");
}
// Sources from id 1: the sheet's id is the seed's, far from them.
let sources = |n: u64| Value::Array((1..=n).map(|id| json!({"id": ids::encode(id), "path": format!("part{id}.sc")})).collect());
assert_eq!(read(&with("sources", sources(64))).unwrap().sources.len(), 64);
assert_eq!(refusal(&with("sources", sources(65))), "damaged drawing: a drawing draws at most 64 designs");
let path = |p: &str| refusal(&with("sources", json!([{"id": ids::encode(1), "path": p}])));
for fits in ["a".repeat(4096), "é".repeat(2048), "/".into()] {
assert_eq!(path(fits.as_str()), "read", "{} bytes", fits.len());
}
let rule = "damaged drawing: source 0000000000001: a design's path is 1 to 4096 bytes, without NUL";
for broken in ["a".repeat(4097), "é".repeat(2048) + "a", String::new(), "plate\0.sc".into()] {
assert_eq!(path(broken.as_str()), rule, "{} bytes", broken.len());
}
let names = |name: &str| {
let mut sheet_named = base.clone();
sheet_named["sheets"][0]["name"] = json!(name);
(refusal(&with("name", json!(name))), refusal(&sheet_named))
};
for fits in ["x".repeat(256), "é".repeat(128), "A".into(), "Blatt 1 – Übersicht".into()] {
assert_eq!(names(fits.as_str()), ("read".to_string(), "read".to_string()), "{} bytes", fits.len());
}
let rule = "name is 1 to 256 bytes, not only spaces, without control characters";
let sheet_id = ids::encode(bare().sheets[0].id);
for broken in ["x".repeat(257), "é".repeat(128) + "a", String::new(), " ".into(), "two\nlines".into(), "bell\u{7}".into(), "nul\0".into()] {
let expected = (format!("damaged drawing: the drawing's {rule}"), format!("damaged drawing: sheet {sheet_id}: its {rule}"));
assert_eq!(names(broken.as_str()), expected, "{broken:?}");
}
}
/// The content language is a well-formed BCP 47 tag of 2 to 35 characters.
#[test]
fn the_language_is_a_bcp_47_tag_of_2_to_35_characters() {
let base = file(&bare());
let why = |tag: &str| {
let mut v = base.clone();
v["language"] = json!(tag);
refusal(&v)
};
let longest = "en-abcdefgh-abcdefgh-abcdefgh-abcde";
assert_eq!(longest.len(), 35);
for tag in ["en", "de", "ast", "en-GB", "EN-gb", "zh-Hans-CN", "sr-Latn-RS", "es-419", longest] {
assert_eq!(why(tag), "read", "{tag}");
}
let rule = "damaged drawing: the language is not a BCP 47 tag of 2 to 35 characters (such as `en` or `en-GB`)";
let too_long = format!("{longest}f");
let broken =
["e", "", too_long.as_str(), "en_GB", "en-", "-en", "en--GB", "e1", "x-private", "en-abcdefghi", "abcdefghi", "日本", "en GB", "en\n"];
for tag in broken {
assert_eq!(why(tag), rule, "{tag:?}");
}
}
/// A fixed unit is a length unit of the expression unit table, written as the table writes it.
#[test]
fn a_fixed_unit_is_a_length_unit_of_the_expression_table() {
let base = file(&bare());
let units = |units: Value| {
let mut v = base.clone();
v["units"] = units;
read(&v).map(|d| d.units)
};
for (unit, kind, _) in expr::UNITS {
assert_eq!(units(json!({"fixed": {"unit": unit}})).is_ok(), *kind == expr::Kind::Length, "{unit}");
}
assert_eq!(units(json!({"fixed": {"unit": "in"}})).unwrap(), Units::Fixed { unit: "in".into() });
assert_eq!(units(json!("follow_model")).unwrap(), Units::FollowModel);
let rule = "damaged drawing: the drawing's unit is not a length unit (such as `mm` or `in`)";
for unit in ["furlong", "deg", "°", "none", "", " in", "in ", "IN"] {
assert_eq!(units(json!({"fixed": {"unit": unit}})).unwrap_err().to_string(), rule, "{unit:?}");
}
for broken in [json!("follow_design"), json!({"fixed": "in"}), json!({"fixed": {}}), json!(null)] {
assert!(units(broken.clone()).is_err(), "{broken}");
}
}
/// A sheet's border is saved as a copy of its profile, field by field, and read back as saved, so
/// a later built-in never changes it; a profile's name in its place is refused.
#[test]
fn a_border_is_saved_as_a_copy_of_its_profile() {
let v = file(&plate());
assert_eq!(v["sheets"][0]["border"], serde_json::to_value(DrawingBorderProfile::iso()).unwrap());
assert_eq!(v["sheets"][1]["border"], serde_json::to_value(own_border()).unwrap());
assert_eq!(read(&v).unwrap().sheets[1].border, Some(own_border()));
let mut edited = v.clone();
edited["sheets"][0]["border"]["margins"]["left_mm"] = json!(15.0);
let iso = DrawingBorderProfile::iso();
let wider = DrawingBorderProfile { margins: Margins { left_mm: 15.0, ..iso.margins }, ..iso };
assert_eq!(read(&edited).unwrap().sheets[0].border, Some(wider));
edited["sheets"][0]["border"] = json!("iso");
assert!(refusal(&edited).starts_with("damaged drawing: "));
edited["sheets"][0]["border"] = Value::Null;
assert_eq!(read(&edited).unwrap().sheets[0].border, None, "no drawing border");
}
/// What reading refuses: each damage the rules name, refused rather than patched, and files this
/// build can't read.
#[test]
fn damaged_files_are_refused_and_never_patched() {
let d = plate();
let plate_id = ids::encode(d.sources[0].id);
let [assembly_id, bolt_sheet_id] = [0, 1].map(|i| ids::encode(d.sheets[i].id));
// Two objects with one id, of one kind and of two kinds.
assert_eq!(refused(&d, |v| v["sources"][1]["id"] = json!(plate_id)), format!("damaged drawing: two objects have the id {plate_id}"));
assert_eq!(refused(&d, |v| v["sheets"][1]["id"] = json!(assembly_id)), format!("damaged drawing: two objects have the id {assembly_id}"));
assert_eq!(refused(&d, |v| v["sources"][2]["id"] = json!(bolt_sheet_id)), format!("damaged drawing: two objects have the id {bolt_sheet_id}"));
// An id that isn't one, where an object has it and where a reference names it; no number form,
// and no counter.
let not_an_id = "damaged drawing: an id is 13 characters of lowercase Crockford base32, not all 0";
for text in ["0000000000000", "FZZZZZZZZZZZZ", "000000000001", "0000000000001 ", "000000000000o", "g000000000000"] {
assert_eq!(refused(&d, |v| v["sources"][2]["id"] = json!(text)), not_an_id, "{text:?}");
assert_eq!(refused(&d, |v| v["sheets"][1]["documents"]["source"] = json!(text)), not_an_id, "{text:?}");
}
assert!(refused(&d, |v| v["sources"][2]["id"] = json!(5)).starts_with("damaged drawing: invalid type"));
assert!(refused(&d, |v| v["next_id"] = json!(6)).contains("unknown field `next_id`"));
let mut in_memory = d.clone();
in_memory.sources[2].id = 0;
assert_eq!(in_memory.validate().unwrap_err().to_string(), "an object has the id 0, which is never one");
// A reference to nothing, or to an object of another kind; a source a sheet documents removed.
for missing in [ids::encode(99), assembly_id.clone()] {
let expected = format!("damaged drawing: sheet {bolt_sheet_id}: it documents source {missing}, which the drawing doesn't have");
assert_eq!(refused(&d, |v| v["sheets"][1]["documents"]["source"] = json!(missing)), expected);
}
let without_plate = refused(&d, |v| {
v["sources"].as_array_mut().unwrap().remove(0);
});
assert_eq!(without_plate, format!("damaged drawing: sheet {assembly_id}: it documents source {plate_id}, which the drawing doesn't have"));
// The paper, its border and its resolution, through the sheet's own rules.
let custom = |sides: Value| refused(&d, |v| v["sheets"][0]["sheet"]["size"] = json!({"custom": {"sides_mm": sides}}));
assert_eq!(custom(json!([5000.0, 0.5])), "read");
for sides in [json!([5000.1, 100.0]), json!([0.0, 100.0]), json!([-1.0, 100.0])] {
let expected = format!("damaged drawing: sheet {assembly_id}: drawing sheet: a custom side is finite, above 0 and at most 5000 mm");
assert_eq!(custom(sides), expected);
}
let why = custom(json!([1e10, 100.0]));
assert!(why.starts_with("damaged drawing: the number ") && why.ends_with(" is out of range"), "checked before the record: {why}");
let nan = d.to_json().unwrap().replacen("600.0", "NaN", 1);
assert!(Drawing::from_json(&nan).unwrap_err().to_string().starts_with("not a SolidCraft drawing: "), "NaN isn't JSON");
assert_eq!(
refused(&d, |v| v["sheets"][1]["border"]["margins"]["top_mm"] = json!(-1.0)),
format!("damaged drawing: sheet {bolt_sheet_id}: drawing sheet: a margin is finite and 0 to 5000 mm")
);
for dpi in [25.0, 2400.0] {
assert_eq!(refused(&d, |v| v["sheets"][1]["dpi"] = json!(dpi)), "read", "{dpi}");
}
for dpi in [24.9, 2400.1, 0.0, -300.0] {
let expected = format!("damaged drawing: sheet {bolt_sheet_id}: its pictures are 25 to 2400 dpi");
assert_eq!(refused(&d, |v| v["sheets"][1]["dpi"] = json!(dpi)), expected, "{dpi}");
}
// In memory, where NaN can be, the same rules.
let mut in_memory = plate();
Arc::make_mut(&mut in_memory.sheets[1]).dpi = f64::NAN;
assert_eq!(in_memory.validate().unwrap_err().to_string(), format!("sheet {bolt_sheet_id}: its pictures are 25 to 2400 dpi"));
let sheet = Arc::make_mut(&mut in_memory.sheets[1]);
sheet.dpi = DEFAULT_DPI;
sheet.sheet.size = SheetSize::Custom { sides_mm: [f64::NAN, 100.0] };
assert!(in_memory.validate().unwrap_err().to_string().starts_with(&format!("sheet {bolt_sheet_id}: drawing sheet: a custom side")));
// An unknown field anywhere.
let unknown: [fn(&mut Value); 8] = [
|v| v["views"] = json!([]),
|v| v["sources"][0]["name"] = json!("Plate"),
|v| v["sheets"][0]["scale"] = json!(1),
|v| v["sheets"][0]["sheet"]["colour"] = json!("white"),
|v| v["sheets"][0]["border"]["style"] = json!("iso"),
|v| v["sheets"][0]["documents"]["view"] = json!(1),
|v| v["sheets"][0]["excluded"]["export"] = json!(true),
|v| v["units"]["fixed"]["precision"] = json!(2),
];
for (i, edit) in unknown.into_iter().enumerate() {
let why = refused(&d, edit);
assert!(why.starts_with("damaged drawing: ") && why.contains("unknown field"), "{i}: {why}");
}
// A newer build's file is refused as one before anything in it is read: a field this build
// doesn't know, or a number out of its range.
let (current, newer) = (DRAWING.current(), DRAWING.current() + 1);
let why = refused(&d, |v| {
v["format"] = json!(DRAWING.format_string(newer));
v["views"] = json!([]);
v["sheets"][0]["dpi"] = json!(1e10);
});
assert_eq!(
why,
format!(
"this drawing was saved by a newer SolidCraft (file format {newer}; this version reads formats up to {current}): update SolidCraft to open it"
)
);
// A list of pending changes means nothing beside a format made of none; beside the pending
// number, a change this build doesn't know is a newer build's.
let why = refused(&d, |v| v["format_pending"] = json!(["a-later-change"]));
if DRAWING.pending_names().is_empty() {
assert_eq!(why, "read");
} else {
assert!(why.contains("newer SolidCraft"), "{why}");
}
// A design is never read as a drawing, nor a drawing as a design.
let design = crate::Document::new("Plate").to_json();
let design_format = crate::format::format_string(crate::format::FORMAT);
assert_eq!(Drawing::from_json(&design).unwrap_err().to_string(), format!("not a SolidCraft drawing (format `{design_format}`)"));
let drawing_format = DRAWING.format_string(current);
assert_eq!(
crate::Document::from_json(&d.to_json().unwrap()).unwrap_err().to_string(),
format!("not a SolidCraft design (format `{drawing_format}`)")
);
// Not an object, not JSON, nested too deeply, too large.
assert_eq!(Drawing::from_json("[]").unwrap_err().to_string(), "not a SolidCraft drawing (expected a JSON object)");
assert!(Drawing::from_json("").unwrap_err().to_string().starts_with("not a SolidCraft drawing: "));
let deep: Value = serde_json::from_str(&format!("{}1{}", "[".repeat(100), "]".repeat(100))).unwrap();
assert_eq!(refused(&d, |v| v["views"] = deep), "damaged drawing: nested too deeply");
let deeper = format!("{{\"format\": \"{drawing_format}\", \"views\": {}1{}}}", "[".repeat(200), "]".repeat(200));
assert!(Drawing::from_json(&deeper).unwrap_err().to_string().starts_with("not a SolidCraft drawing: "));
let text = d.to_json().unwrap();
let at_cap = text.clone() + &" ".repeat(MAX_DRAWING_BYTES - text.len());
assert_eq!(Drawing::from_json(&at_cap).unwrap(), d);
assert_eq!(Drawing::from_json(&(at_cap + " ")).unwrap_err().to_string(), "drawing too large (at most 16 MiB)");
}
/// A run never gives an id twice: not one in use, and not a removed object's. Only another run from
/// the same seed, which tests and replays start from the same file, can give a removed id again.
#[test]
fn a_run_never_gives_an_id_twice() {
let mut id_source = IdSource::seeded(SEED);
let a4 = drawing_sheet::Sheet::standard(PaperSize::A4, Orientation::Landscape);
let mut d = Drawing::new("Ids", "en", "Sheet", a4, None, &mut id_source).unwrap();
let mut given = BTreeSet::from([d.sheets[0].id]);
for _ in 0..MAX_SOURCES {
let id = d.allocate_id(&mut id_source).unwrap();
d.sources.push(Source { id, path: "part.sc".into() });
assert!(given.insert(id), "{id:#x} given twice");
}
let removed = d.sources.pop().unwrap().id;
for _ in 0..10_000 {
let id = d.allocate_id(&mut id_source).unwrap();
assert!(id != removed && given.insert(id), "{id:#x} given twice");
}
// The same seed, from the start, over a file it made: every draw up to the removed id is in use.
let reopened = Drawing::from_json(&d.to_json().unwrap()).unwrap();
let again = reopened.allocate_id(&mut IdSource::seeded(SEED)).unwrap();
assert!(!reopened.ids().any(|id| id == again));
assert_eq!(again, removed);
}
/// Properties follow the design metadata's rules: other tools' data and a well-known key of another
/// kind are kept as they are, never read as numbers, while the same number elsewhere is refused.
#[test]
fn properties_are_kept_as_they_are() {
let mut v = file(&plate());
v["properties"]["com.example.pdm"] = json!({"huge": 1e300, "ids": [1, 2, 3]});
v["properties"]["revision"] = json!(3);
let d = read(&v).unwrap();
assert_eq!(d.properties.get("com.example.pdm"), Some(&json!({"huge": 1e300, "ids": [1, 2, 3]})));
assert_eq!((d.properties.revision(), d.properties.get("revision")), (None, Some(&json!(3))));
assert_eq!(read(&file(&d)).unwrap(), d);
v["sheets"][0]["dpi"] = json!(1e300);
let why = refusal(&v);
assert!(why.starts_with("damaged drawing: the number ") && why.ends_with(" is out of range"), "{why}");
}
/// A drawing whose JSON would be over the read limit isn't saved. Other tools' properties are kept
/// as they came, and indenting them can grow a file read in under the limit past it, so the save
/// refuses rather than write a file that won't open: every drawing saved opens again.
#[test]
fn a_drawing_too_large_to_reopen_is_not_saved() {
let file = |zeros: usize| {
let items = vec!["0"; zeros].join(",");
let sheet = r#"{"id":"0000000000001","name":"S","sheet":{"size":{"standard":"a4"}}}"#;
let format = DRAWING.format_string(1);
format!(
r#"{{"format":"{format}","name":"A","sources":[],"language":"en","sheets":[{sheet}],"properties":{{"com.example":{{"items":[{items}]}}}}}}"#
)
};
// The case found in review (#3891): 2,000,000 zeros read in at about 4 MB and indent past 16 MiB.
let big = Drawing::from_json(&file(2_000_000)).unwrap();
assert_eq!(big.to_json().unwrap_err().to_string(), "drawing too large to save (at most 16 MiB)");
let small = Drawing::from_json(&file(100_000)).unwrap();
assert_eq!(Drawing::from_json(&small.to_json().unwrap()).unwrap(), small);
}
/// A new drawing has one sheet, its id the source's first, follows its designs' units and renders
/// pictures at 300 dpi; a name, language or border that breaks the rules is refused.
#[test]
fn a_new_drawing_has_one_sheet_and_keeps_the_rules() {
let d = bare();
let first = IdSource::seeded(SEED).next(&BTreeSet::new()).unwrap();
assert_eq!((d.sheets.len(), d.sheets[0].id, d.sheets[0].dpi, &d.units), (1, first, DEFAULT_DPI, &Units::FollowModel));
assert_eq!((d.format, d.format_pending), (DRAWING.format_string(DRAWING.current()), DRAWING.pending_names()));
let a4 = drawing_sheet::Sheet::standard(PaperSize::A4, Orientation::Landscape);
let thin = DrawingBorderProfile { drawing_border_line_mm: 0.0, ..DrawingBorderProfile::iso() };
let broken = [("", "en", "Sheet", None), ("Bare", "en_GB", "Sheet", None), ("Bare", "en", "\n", None), ("Bare", "en", "Sheet", Some(thin))];
for (name, language, sheet, border) in broken {
let made = Drawing::new(name, language, sheet, a4, border, &mut IdSource::seeded(SEED));
assert!(made.is_err(), "{name:?} {language:?} {sheet:?} {border:?}");
}
}
/// Hostile input never panics: a valid file's text cut, overwritten and given lies, and its JSON
/// values replaced, repeated and dropped. Each read gives an error, or a drawing that keeps the
/// rules and reads back as it saves.
#[test]
fn mutated_files_give_an_error_or_a_valid_drawing_never_a_panic() {
let good = plate().to_json().unwrap();
let tree: Value = serde_json::from_str(&good).unwrap();
let nodes = count(&tree) as u64;
let lies = [
json!(null),
json!(0),
json!(-1),
json!(1),
json!(1e10),
json!(u64::MAX),
json!(""),
json!("x".repeat(300)),
json!("0000000000000"),
json!("0000000000001"),
json!("fzzzzzzzzzzzz"),
json!("FZZZZZZZZZZZZ"),
json!([]),
json!({}),
json!(true),
json!("follow_model"),
json!({"fixed": {"unit": "deg"}}),
];
let words = ["NaN", "1e400", "-0", "18446744073709551616", "\"", "{", "]", ",", "null", "\u{0}", "é", "o", "I"];
let mut seed: u64 = 0x9e37_79b9_7f4a_7c15;
let mut rnd = move || {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
seed
};
let mut read_back = 0;
for i in 0..10_000 {
let text = if i % 2 == 0 {
let mut v = tree.clone();
let lie = &lies[(rnd() % lies.len() as u64) as usize];
mutate(&mut v, &mut ((rnd() % nodes) as usize), lie, rnd() % 3);
v.to_string()
} else {
let mut s = good.clone().into_bytes();
for _ in 0..1 + rnd() % 3 {
// A run of up to 15 bytes taken out, overwritten by one byte or replaced by a lie.
let at = (rnd() % (s.len() as u64 + 1)) as usize;
let cut = ((rnd() % 16) as usize).min(s.len() - at);
let byte = [(rnd() >> 56) as u8];
let put: &[u8] = match rnd() % 3 {
0 => &[],
1 => &byte,
_ => words[(rnd() % words.len() as u64) as usize].as_bytes(),
};
let next = [&s[..at], put, &s[at + cut..]].concat();
s = next;
if rnd() % 8 == 0 {
s.truncate(at);
}
}
String::from_utf8_lossy(&s).into_owned()
};
if let Ok(d) = Drawing::from_json(&text) {
assert!(d.validate().is_ok() && d.ids().all(|id| id != 0), "{text}");
assert_eq!(Drawing::from_json(&d.to_json().unwrap()).unwrap(), d, "{text}");
read_back += 1;
}
}
assert!(read_back > 50, "only {read_back} mutated files read: the mutations hardly reach the record's rules");
}
/// The number of values in `v`, itself included.
fn count(v: &Value) -> usize {
1 + match v {
Value::Array(a) => a.iter().map(count).sum::<usize>(),
Value::Object(o) => o.values().map(count).sum(),
_ => 0,
}
}
/// Change the `pick`th value of `v`, depth first: `how` 1 repeats a list's first element (two
/// objects with one id), 2 drops an object's first field, and otherwise `lie` takes its place.
fn mutate(v: &mut Value, pick: &mut usize, lie: &Value, how: u64) -> bool {
if *pick == 0 {
match (v, how) {
(Value::Array(a), 1) if !a.is_empty() => {
let first = a[0].clone();
a.push(first);
}
(Value::Object(o), 2) if !o.is_empty() => {
let key = o.keys().next().cloned().unwrap_or_default();
o.remove(&key);
}
(v, _) => *v = lie.clone(),
}
return true;
}
*pick -= 1;
match v {
Value::Array(a) => a.iter_mut().any(|x| mutate(x, pick, lie, how)),
Value::Object(o) => o.values_mut().any(|x| mutate(x, pick, lie, how)),
_ => false,
}
}
+24 -12
View File
@@ -1865,6 +1865,14 @@ fn placement_digest(doc: &Document, f: &Feature, st: Option<&ModelState>) -> u64
reads.placed_body(st, tool);
}
}
// A pattern of bodies copies them along its directions or about its axis in its own
// component's frame, so it follows where each body it copies is placed relative to that
// frame: copies of a root block, patterned in a component, move with the component.
FeatureKind::Pattern { bodies, .. } if !bodies.is_empty() => {
for b in bodies {
reads.placed_body(st, &b.0);
}
}
// Joins, cuts and intersects meet their targets where those are placed (#3383).
kind if drawn_tools_change_bodies(kind) => reads.drawn_targets(st, f),
FeatureKind::Sketch { plane, sketch } => {
@@ -2721,10 +2729,22 @@ fn note_join_apart(before: &ModelState, after: &ModelState, f: &Feature, warning
if operation != Operation::Join {
return;
}
for (name, n) in join_pieces_added(before, after) {
append_warning(
warning,
format!("the joined piece doesn't touch `{name}`, so `{name}` is now in {n} separate pieces (New Body makes it a body of its own)"),
);
}
}
/// The bodies a Join from `before` to `after` left in more separate pieces than it found, with
/// their piece counts now: empty when the joined material made one body with what it touched
/// (or bridged pieces). A lid closing a cavity adds a void, not a piece.
pub fn join_pieces_added(before: &ModelState, after: &ModelState) -> Vec<(String, usize)> {
let pieces = |b: &Body| b.lumps().map_or(1, |l| l.len());
// Pieces of the bodies the join changed or absorbed before it, and of those it kept after.
let (mut had, mut have) = (0usize, 0usize);
let mut grown: Vec<(&str, usize)> = Vec::new();
let mut grown: Vec<(String, usize)> = Vec::new();
for was in &before.bodies {
match after.body(&was.name) {
Some(now) if now.body.same_shape(&was.body) => {}
@@ -2733,21 +2753,13 @@ fn note_join_apart(before: &ModelState, after: &ModelState, f: &Feature, warning
had += old;
have += new;
if new > old {
grown.push((now.name.as_str(), new));
grown.push((now.name.clone(), new));
}
}
None => had += pieces(&was.body),
}
}
if have <= had {
return;
}
for (name, n) in grown {
append_warning(
warning,
format!("the joined piece doesn't touch `{name}`, so `{name}` is now in {n} separate pieces (New Body makes it a body of its own)"),
);
}
if have <= had { Vec::new() } else { grown }
}
/// Add tool bodies to the model according to the operation.
@@ -6240,7 +6252,7 @@ pub fn pattern_matrices(doc: &Document, st: &ModelState, p: &crate::PatternKind)
fn pattern_transforms(doc: &Document, vals: &BTreeMap<String, Value>, st: &ModelState, component: u64, p: &crate::PatternKind) -> Result<Vec<Mat>> {
let count = |e: &str| -> Result<usize> {
let n = val(vals, e, Kind::Unitless)?.round();
let n = expr::eval_count(e, vals)?;
if !(1.0..=MAX_INSTANCES as f64).contains(&n) {
return Err(DocError::Invalid(format!("pattern count must be 1…{MAX_INSTANCES}")));
}

Some files were not shown because too many files have changed in this diff Show More