Compare commits

..

73 Commits

Author SHA1 Message Date
aiapienthusiast d92923a964 Agent chat: a Cheaper Inference preset for OpenAI-compatible endpoints (#11174) 2026-11-05 02:58:56 +00:00
froglemonade2 71598c0354 Agent control: ui.menu refuses items the design doesn't have, reports the History bar's right-click menu, and gives Edit Joint Limits its own id (#10745) (#10746)
* claim: #10745

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

* Control channel: ui.menu refuses missing targets, reports the History item menu, and tells the joint menu items apart (#10745)

- ui.menu looks a target up and refuses one the design doesn't have ("no body `NoSuchBody`").
- The History bar's feature menu is the shared context menu (Target::Timeline), so ui.menu
  reports it, ui.menuPick runs its items and {"type":"timeline","feature":id} opens it.
  Its Show All toggle moves to TreeState so the menu can list Delete after the marker.
- Edit Joint Limits is ui.editJointLimits and opens Edit Joint with Limits expanded.

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

* Icon audit: open the section and canvas menus on a real section and canvas; a hidden section has a menu too

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

* ui.menu: check a group's or folder's scope and folder name; Delete after the marker gets its own id

A group or folder target is refused unless its scope is a row of its component (`c<id>` or a
placement of it) and the folder is bodies, sketches or construction. The History item menu's
delete of everything after the marker is ui.deleteAfterMarker, so ui.menuPick can tell it from
Delete (review on #10746).

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

* ui.menu: a component's own c<id> scope is the design root's only; test that Delete after the marker deletes

The browser draws a part's folders and groups under its placements (o:<path>), so a c<id> scope
for any component but the root names a row that isn't there (review on #10746). The History test
now picks ui.deleteAfterMarker and checks the features are gone.

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

* ui.menu: a placement scope must be spelt as the browser keys it

o:012 parses to placement 12 but isn't the row's key (o:12), so Show/Hide would store a key no
row reads (review on #10746).

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

* History item menu: no Delete after the marker when nothing shown is after it

A timeline target for a feature the bar's component filter leaves out listed no features, so the
item did nothing (review on #10746).

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

* ui.menu: a plane target must be construction geometry

The plane menu's Show/Hide keys the feature's name as an origin item, so any other feature got a
menu whose actions don't fit it (review on #10746).

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

* ui.menu: accept the view navigator's and selection filter's menus, which main added

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:35:55 +00:00
Homero Thompson 06a8a9b7d2 AGENTS.md and the contributing skill: say how to work as an agent (#8059)
* AGENTS.md and the contributing skill: say how to work as an agent

Two rules that the loop's own agents follow but the repository never
wrote down, so every agent that reads it had to rediscover them:

* The code, the comments, the commit messages and the docs are in English,
  always. A comment in another language is a defect: the people who
  maintain the code cannot review it. A user-visible string is not inline
  in the code either; it goes to the message catalogs, and the UI text
  skill says how to name and translate it.
* Parallel work runs in parallel, and dependent work stays sequential.
  One subagent per independent part, with a self-contained brief, and no
  two subagents writing the same file. A fix and the test that proves it,
  a refactor and the callers it breaks: those stay in order.

AGENTS.md is the router, so it gets the two rules in one line each, and
skills/solidcraft-contributing/SKILL.md gets the "Working as an agent"
section with the detail.

* AGENTS.md: fit the two agent rules under the router cap

`cargo xtask skills` reads the router as a router: one line per rule,
with the detail in the skill the line points at. The two new lines
broke that contract twice over. They carried their own detail (a
comment in another language is a defect; one subagent per part, no two
writing the same file) instead of pointing at the "Working as an
agent" section that already holds it, and the file grew to 5361
characters, over the 5000 a router may have.

So the two lines shrink to what a router line is, and five clauses
that repeated a rule the pointed-at skill already states in full give
up their place:

* "Act when the code, docs and rules settle a question" is in the
  contributing skill, in more detail.
* The unsafe allowlist is the crash-safety skill's own list.
* "No second PR for an issue a lower-numbered one closes" and "never
  force-push main, no pushes just to catch up with main" are the
  contributing skill's claim and push rules.
* "Do not switch branches while the gate runs" is in the contributing
  skill too.

No rule is lost: each one still has exactly one place that states it,
and that place is a skill. The router is 4970 characters.
2026-11-05 02:25:46 +00:00
Colin Faulkingham 4d2691e636 Assistant: opt-in HTTP authentication for private endpoints (#5341) (#10674)
* claim: #5341

* Allow per-endpoint HTTP authentication with explicit consent

* fix: discard model listings after HTTP consent revocation
2026-11-05 02:12:56 +00:00
Nathalie Schönwetter 6e17874cb9 A Move that copies can be patterned or mirrored (#5931) (#6018)
Co-authored-by: Nathalie Schönwetter <nathalie@cfw-ag.ch>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 02:04:01 +00:00
NAX LAB bf22dad8e8 Fix: flake build fails on git dependencies in Cargo.lock (#12146)
* Set `allowBuiltinFetchGit = true` in `cargoLock`:

* Remove comment
2026-11-05 01:26:31 +00:00
semmelrinde 731029cd87 Report a bug: put the whole report in the GitHub link whenever GitHub keeps it (#7103) (#7104)
Open GitHub issue form filled the issue through the link only up to
2,000 characters, with every space and line break encoded as three, so
most reports opened GitHub with the title alone and their text on the
clipboard, and many arrived empty (#10556, #10654).

The link now encodes only what a query value can't carry (a space is
`+`) and is limited by what GitHub keeps: 6,000 characters, and 7,000
once its sign-in page encodes it again. A report too long for that still
goes through the clipboard, whole.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 01:09:37 +00:00
Raymond Thurman 6b5c5dc6e8 Sketch on a thickened surface's face (#7641) (#7703)
* claim: #7641

* Sketch on a thickened surface follows the face (#7641)

Thicken dropped the kernel's face history, so Create Sketch refused
every face of the solid. Carry that history through, the same way
Shell and Trim do.

The regression covers the offset face and an end wall, and checks
that a boss on the offset face follows a thickness change and reopens.
2026-11-05 01:09:18 +00:00
github-actions[bot] e7ce6c282a Regenerate generated files and fold pending files [skip ci] 2026-11-05 02:47:03 +00:00
rugbymauri 67c58cbd28 Sheet metal rules: a design parameter named Thickness no longer overrides it (#9623) (#9633)
* claim: #9623

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

* Sheet metal and plastic rules: Thickness in a rule's expressions is the rule's thickness

A rule's expressions read `Thickness` as the rule's thickness only while the
design had no parameter of that name; with one, the default gap, reliefs and
a `Thickness` bend radius took the parameter instead (a 2 mm rule beside
Thickness = 10 mm closed corners to a 10 mm gap). Now `Thickness` is always
the rule's, as documented; the rule's own thickness still reads the design's
parameters, so `thickness: Thickness` keeps naming the design parameter.

Renaming a parameter called Thickness, its users and what it drives follow
the same reading: only a rule's thickness field refers to it. Renaming a
parameter to Thickness is refused while another rule field uses it, since
that field would then read the rule's thickness instead.

Fixes #9623

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

---------

Signed-off-by: Maurizio Monticelli <maurizio@whatwedo.ch>
2026-11-05 01:07:30 +00:00
austin62jake 7c89f7445f Assembly: moving a component without capturing its position marks the design unsaved (#9736) (#9851)
* Claim #9736

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

* Assembly: a move not captured yet is an unsaved change (#9736)

A component moved without capturing its position left the design reading
as saved: file.save wrote the file without the move and said nothing, and
closing the tab didn't ask. Now:

- Session::is_dirty is true while a move is pending, so closing a tab or
  quitting asks "save changes?".
- file.save and file.save_as refuse while a move is pending, naming the
  component and the capture and revert commands.
- In the app, Save As (and Save As JSON, Save a copy, Download design)
  asks the Capture Position question first, as Save already did; a tab
  closing after "Save" waits for that answer, and Cancel keeps it open.

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

* Close: the save-changes card waits behind the Capture Position question (#9736)

Saving a tab that closes asks the Capture Position question first; the
"save changes?" card stayed drawn over it and took a second answer. It
now hides and ignores answers until the question is answered. The design
container doc says a save stops for a move not captured.

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

* Assembly: an exploded view shown is not a move to save (#9736)

explode.show displays a view through the pending moves, as an uncaptured
move does. Those placements last only while the view is shown, so they
don't make the design unsaved and don't stop a save
(Session::moved_uncaptured). A move on top of a shown view still does.

The relink test saved with a move pending; it captures it first now.

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

* Assembly: the pending-move save test reads the world state as main now gives it

world_state() returns a Result since the kernel merge; the test unwraps it.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-05 01:06:46 +00:00
ShawnMarx 26adcfa997 Docs: add BoxInsertDesigner to the showcase (#10095)
* Docs: add BoxInsertDesigner to the showcase

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

* Docs: attribution rows for the BoxInsertDesigner showcase recording

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 01:06:36 +00:00
github-actions[bot] 1e41dddae2 Regenerate generated files and fold pending files [skip ci] 2026-11-05 02:11:35 +00:00
PapaEcho a9b219bc9f joint: let the origin be the design origin or a sketch point (#10290) (#10584)
The joint origin input only accepted faces, edges and vertices, so the world origin and sketch points could not be picked to place a joint. Extend the origin input's snap mask with ORIGIN and POINTS and resolve a sketch point (and the empty-body world-origin vertex) to a world snap in snap_of, so a joint can be placed on the design origin or a hole centre like Fusion.
2026-11-05 01:06:11 +00:00
semmelrinde 32dc8750df Sketch Project: faces and edges of a part's second copy now project (#9957) (#10721)
* Sketch Project: a face or edge of a part's second placement projects where that placement is (#9957)

Projecting the face of `Block (Part:2)` failed with "face of Block: the
face no longer exists". The pick's body name was turned into the
definition's own name, and the model a sketch reads holds only each
component's first placement, so the face was looked for on the first
placement, somewhere else.

Sketch picks now keep the name the browser shows, and the further
placements a sketch's links name are placed into the model it reads
(`with_placed_copies`), when the sketch is evaluated and when a link is
added or refreshed. Only the named ones are placed. A link to a body
the model lacks also makes the sketch follow every placement, so moving
the copy moves what was projected from it.

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

* Sketch Project: a projection from a second placement follows renames of its part and body (#9957)

The link keeps the copy by the name the browser shows (`Block (Part:2)`),
which a rename of the component or of the body changes. component.rename
and body.rename now rename it in the sketches' links as well.

The copies a sketch reads are found through their component's bodies,
not by going through every body for every placement.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 01:05:40 +00:00
semmelrinde ceeff58a7f Scale: X, Y and Z factors from the dialog (#10782) (#10983)
The Scale dialog gets a Type row, Uniform or Per axis. Per axis shows
X, Y and Z rows and sends factors, which solid.scale already takes. A
Scale made with factors opens per axis with its three values; before,
they rode along as an extra and the dialog's factor did nothing.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 01:05:10 +00:00
github-actions[bot] a55286953a Regenerate generated files and fold pending files [skip ci] 2026-11-05 01:37:26 +00:00
github-actions[bot] 9a0846ef68 Regenerate generated files and fold pending files [skip ci] 2026-11-05 01:28:10 +00:00
raykholo 06749ac464 Add the Fair Surface and Match Surface dialogs (#4644) (#4653)
* claim: #4644

* Add the Fair Surface and Match Surface dialogs (#4644)

Neither command had a dialog, so the toolbar and command search ran them
with no parameters and they failed with "`body` is required".

Fair Surface picks the surface or some of its faces and sets the
strength, the iterations, whether the edges are kept and an optional
tolerance (blank: 0.001 of the body's size). Match Surface picks the
surface or face to rebuild, the target and an edge chain of the target,
and the continuity (G1, G2 or G3, default G2). Both preview, and Edit
Feature restores the saved picks and settings. New labels go through
the catalogs with English, Japanese, Chinese, German and Korean text.

* Keep surface-dialog strings pending with complete translations (#4644)

* Fair tolerance is a length; Fair and Match picks reopen on their saved bodies

The Fair dialog's tolerance now goes through the same design-unit length
conversion as the other surface lengths, on OK and when an edit is refilled.
Editing a Fair or Match feature looks its face and edge picks up on the body
they were saved on, so a coincident face or edge of another body cannot take
their place.

* Surface dialogs: strings stay in the pending catalog only (#4644)

* Surface dialogs: edge picks use the session world, as main's edge_sel now does (#4644)

* Surface dialogs: refuse to refill when a saved pick no longer resolves, and pass body names by reference (#4644)

* Surface dialogs: Fair and Match now open dialogs, so the refused-command tab test no longer lists them (#4644)
2026-11-05 00:05:53 +00:00
semmelrinde a6e7126bf1 Closed stderr pipes no longer crash the app, CLI or MCP server (#10111) (#10126)
* Stderr: a closed stderr no longer stops the app, the CLI or the MCP server (#10111)

`eprintln!` panics with "failed printing to stderr" when stderr is a
pipe its reader has closed. A process another program starts writes
into such a pipe: `solidcraft mcp` under an agent, and the app that
one starts, which inherits it. #10111 is the app stopping that way
while an agent (Codex) connected. `solidcraft_geom::note!` (re-exported
as `solidcraft_engine::note!`) writes the same line and ignores a
failed write, as the CLI's logger already does; the app, the CLI, the
MCP server, the crash hook and the crates they run use it instead of
`eprintln!` outside tests.

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

* Stderr: test that a closed stderr leaves the CLI's exit code as it is (#10111)

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

* Stderr: the old name's alias and the fuzz command don't stop on a closed stderr either (#10111)

`solvecraft-cli` writes its deprecation notice before it starts
`solidcraft-cli`, so an agent still configured with the old name got the
panic before the MCP server started (found in review). It and the fuzz
command now write through `note!` too, and the test covers the alias.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 23:29:07 +00:00
Christian Hammacher 4a906699ee Browser: the component menu keeps its everyday actions and groups the rest in flyouts (#8769) (#8770)
* Browser: the component menu keeps its everyday actions and groups the rest in flyouts

A component's or placement's right-click menu had grown to about thirty entries in seven
blocks. Activate, Edit in Place, Open Linked Design, Move/Copy, Show/Hide, Isolate, the
clipboard, Delete, Rename, Appearance, Physical Material and Properties stay on the menu;
Ground, Capture and Revert Position, Unlock Contents and Turn Up Axis move to a Position
flyout, Relink Source or Extract to File and the exports to File, New Component, New Group
and Paste New to New, and Show All Bodies, Cycle Component Color, Expand All and Collapse
All to Display. context_menu::entries lists a menu with its flyouts, as ui.menuPick reads it.

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

* Browser: the component menu nests its flyouts by topic, and flyouts no longer scroll

Position (Ground, Capture and Revert Position, Unlock Contents, Turn Up Axis), Component
(New Component, Rename, Appearance, Physical Material, Cycle Component Color, Relink Source
or Extract to File, Properties; Design on the design's row), Export (Export, Save As Mesh,
Send to Slicer) and Browser (New Group, Expand All, Collapse All) each open a flyout; Paste
New stays with the clipboard and Show All Bodies with Show/Hide. Flyouts open inside flyouts,
so Appearance keeps its quick choices inside Component; the keys walk in and out of them.

A flyout no longer scrolls or sits away from its row: every flyout has its own area, measured
whole, where a short one used to squeeze every later one into its height. A component's only
body is no longer offered as a component of its own on a folded Bodies folder's entries.

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

* Browser: the component menu starts with what a double-click does, and keeps the old order of its sections

A linked component's menu starts with Edit in Place and Open Linked Design when a double-click
edits it in place (double_click_components), and with Activate otherwise. Position, Component
and Export follow Move/Copy, then the clipboard, then Show/Hide and Isolate, with Browser last,
as the sections came before the flyouts.

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

* Browser: Show All Bodies is on a component's menu only while a body is hidden

It is listed while a body is hidden itself, in a hidden placement, or in a hidden Bodies folder or
group of bodies, anywhere in the design, since that is what it shows.

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

* Browser: Relink Source and Extract to File sit right below Rename in the Component flyout

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

* Browser: Placement and Organize flyouts, Turn Up Axis back as a flyout, and flyout fixes

Placement (Ground, Turn Up Axis, Capture and Revert Position, Unlock Contents) follows
Move/Copy above the first separator, Turn Up Axis opens its two turns in a flyout again, and
the Browser flyout is called Organize. The Component flyout has three blocks: New Component
with Relink Source or Extract to File, then Appearance, Physical Material and Cycle Component
Color, then Rename and Properties.

A flyout of a flyout that opened to the left opens to the left too instead of covering the
menu, Left back to the menu chooses the row that opened the flyout, ui.menuPick refuses a row
that only opens a flyout, and the icon audit checks every level. The guides and the control
protocol describe the new menu paths.

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

* Browser: ui.menu gives a flyout's rows their placeholder flag too

ui.menu set placeholder only on the top-level rows, so a client reading
a flyout row's placeholder got nothing. The icon audit now reads the flag
of the flyout rows instead of resolving their icons itself.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 23:05:26 +00:00
github-actions[bot] a2a7102859 Regenerate generated files and fold pending files [skip ci] 2026-11-05 00:53:06 +00:00
cronos a137f2d459 Sketch Trim: keep the driving distance when trimming a line offset first (#11442) 2026-11-04 22:23:12 +00:00
thocsmith f87dd73661 Selection filter: from command search it offers Faces, Bodies, Components (#11505) (#11553)
* claim: #11505

* Selection filter: started from command search, it offers Faces, Bodies, Components (#11505)

Command search ran select.filter with no parameters, which could only fail
with "missing field `mode`". Started from the UI (command search, a
shortcut, ui.start), it now opens its three modes as a menu with the
current one marked, as View > Visual style and Navigation tool do (#8868).
Programmatic calls keep their contract and open nothing.

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

---------

Co-authored-by: thocsmith <193096300+thocsmith@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 22:23:02 +00:00
PaddleStroke af5644aee6 Ribbon: switch panel menus on hover after the first click (#11447) (#11713)
* claim: #11447

* Add an option to switch ribbon panel menus on hover

* Update profile membership count for ribbon hover setting
2026-11-04 22:22:38 +00:00
SixSolid abeb232f17 Sketch Mirror: use an origin axis that is not in the sketch plane (#8201) (#12091)
* claim: #8201

* Sketch Mirror: use an origin axis that is not in the sketch plane (#8201)

A sketch on a part face does not hold the world X, Y or Z axis, so Mirror refused the pick. It now mirrors about the axis's projection into the sketch plane and keeps that line linked as fixed reference geometry. An axis perpendicular to the plane stays refused because it has no line in the plane. The sketch's own X and Y axes keep working as before.
2026-11-04 22:22:10 +00:00
raykholo 1a27c8399d Linux 3D mouse: push/lift and tilt/twist axes are no longer swapped (#3523) (#4833)
* claim: #3523

* Linux 3D mouse: decode spacenavd's axes in its documented frame (#3523)

spacenavd reports x to the right, y up and z away from the user, with the
rotations about those axes. The decoder treated y as away and z as up, so
with a stock spacenavd push and lift traded places, as did sideways tilt
and twist. Map the daemon's axes to the raw HID frame as translation
[x, -z, -y] and rotation [rx, -rz, -ry].

The decode still runs before a device's calibration and the per-axis
motion choice, so both apply unchanged on top of the corrected reading.
The tests now move each daemon axis on its own, in both directions, so a
swap of two axes can no longer pass.
2026-11-04 22:13:57 +00:00
Jackson Oursland 5ab59e7c1b Assistant: DeepSeek and Qwen endpoint presets (#5016) (#5063)
Adds DeepSeek and Qwen presets to both endpoint protocols: OpenAI-compatible (api.deepseek.com, dashscope-intl compatible-mode/v1) and Anthropic-compatible (api.deepseek.com/anthropic, dashscope-intl apps/anthropic).

Co-authored-by: Claude Code <noreply@anthropic.com>
2026-11-04 22:13:47 +00:00
SiroArt 9a274f91df Planned bodies: smaller GPU chunks, and a chunk keeps its colours while they are unchanged (#5490)
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 22:13:38 +00:00
PaddleStroke 08fa042039 Dialogs: drag a value's label sideways to change it (#2217) (#9685)
* claim: #2217

* Dialogs: drag a value's label sideways to change it (#2217)

A value row's label (expr_row, hint_row) now scrubs a literal value: about a
hundredth of its size per point of drag, on that step's grid, Shift ten times
finer, Ctrl/Cmd ten times coarser. The unit and decimal mark stay as typed, a
size never turns negative, and expressions and parameters are left alone. The
text changes as if typed, so previews follow and OK still makes one command.

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

* Label drag: measure from the press point, cap values, keep scrub kit-private

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 22:10:04 +00:00
P'tite Tête 09bb8aaaf5 Revolve: an axis origin written as an expression is no longer read as zero (#10252)
* fix: Revolve axis origin takes expressions instead of silently zero

- AxisRef::Line origin is a PointExpr evaluated with parameter values in the design unit (format note in pending)
- An unreadable origin or dir is a BadParams error instead of (0, 0, 0)
- Edit Feature keeps the saved expressions of an untouched origin or dir, for solid and surface revolves, in moved components too
- Face role names stay stable with a parameter-driven axis

* chore: Re-run PR checks after the description fix

* fix: Edit Feature of a revolve maps nothing where nothing is placed

- unplaced designs skip world_state and the pick table, as before the merge
- the untouched-edit test checks that OK applied the edit (CodeRabbit)

---------

Co-authored-by: P'tite Tête <asso@ptitetete.com>
2026-11-04 22:09:27 +00:00
Mateusz Sych 46edd8d749 Agent chat: Custom instructions in Preferences, added to the system note (#8220) (#11105)
* claim: #8220

* Agent chat: Custom instructions in Preferences, added to the system note (#8220)

Preferences > AI gets a multi-line Custom instructions field, saved with
the other AI preferences (agent_instructions, kept by page resets like
other user-written text). The chat adds the text after its own system
note when a session starts, by the routes #9811 set up: ACP session/new
(the Claude adapter's _meta key, the standard systemPrompt field, the
Antigravity key) and the system message or field of every API protocol.
Connect and New chat set it, so an edit applies from the next chat and
never changes the one running. Blank text adds nothing; at most 4000
characters are kept, in the field, over ui.agent_chat and from a hand-
edited settings file. ui.agent_chat takes and reports `instructions`.

* Custom instructions: typing in the field stays in the field

The text area did not report itself to Preferences search as a key sink, so with
the field focused, search took the typed characters and moved focus away. The
field now notes the sink while it has focus, like the other preference fields,
and the #8220 test types into it.

---------

Co-authored-by: MateuszSychXpl <151755914+MateuszSychXpl@users.noreply.github.com>
2026-11-04 22:07:30 +00:00
semmelrinde c5d3dabd1b Split Body: the tool face follows a parameter change (#11674) (#11699)
The tool face of Split Body is now a face pick: it is named when it is
picked and found again by that name, as other face picks are, so a split
by a rail's face follows the rail when a parameter moves it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 22:06:13 +00:00
Ben Rog-Wilhelm 92171bd79b Hole: a hole on a face that another part touches drills its own part, not the other one (#12066) (#12067)
* claim: #12066

* Hole: a hole on a face that another part touches drills its own part (#12066)

A hole placed by position with no direction drills against the outward
normal of the face at the point. That face was looked for on every body,
so where another part touched the face at the hole, the part made first
won. The hole then drilled away from the part `targets` named and cut only
the tool's run-in pocket into it, without an error.

A hole that names its bodies now goes by their faces wherever one of them
passes through its position, when it is made (hole_direction_at) and when
a design is evaluated (hole_dir_into_face). Saved holes whose direction
leaves their own body's face there are mirrored into it, as format 18 does
for a thin plate's far side; the pending format note records it. A hole
whose bodies have no face at its position (placed on another part's face
to drill the part behind it) goes by every body's faces, as before.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 22:05:30 +00:00
CamWight 2ce55baba1 Dimensions: a radius leader and an aligned dimension's line reach their text (#12093)
The value text is drawn level, in an upright box, and the line runs
through its middle. The layout stopped the line where the box's farthest
corner reaches along it (half.x*|u.x| + half.y*|u.y|), not where the line
leaves the box (min(half.x/|u.x|, half.y/|u.y|)). They agree only when the
line is level or upright: at 45 degrees a radius leader stopped about
12 px short of its text, and an aligned dimension's line broke too wide
around its value.

One helper, box_exit, gives where the line leaves the box, for the radial
leader, the linear break and the leader to a text pulled past the
extension lines. Whether the text fits between the arrows still goes by
the whole box. A text box that isn't finite counts as empty, and one
larger than a sketch can be is held to MAX_COORD.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 22:05:13 +00:00
github-actions[bot] 6870f9539c Regenerate generated files and fold pending files [skip ci] 2026-11-04 23:51:34 +00:00
rugbymauri 2998d412b6 Primitives: clicking a pick field no longer jumps to the Diameter box (#10428) (#10536)
* claim: #10428

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

* Primitives: a pick field waits for its pick and says what it is for

Clicking Face, Reference 1 or 2 in the Cylinder window, or Point in the
Box, Sphere and Torus windows, and then clicking something the field
does not take (empty space, the cylinder's own preview) gave the
keyboard to Diameter, so the field no longer looked active and nothing
said why the click picked nothing. A pick field clicked into now keeps
the keyboard until a click picks something, and a click it does not
take leaves a note under the form saying what the field is for.

These fields also get their own tooltips: Face is the face or vertex
the cylinder stands on, Reference 1 and 2 are straight edges of that
face the distances below are measured from, and Point is a vertex,
edge, face, sketch point or the origin whose point fills X, Y and Z.

Fixes #10428

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

---------

Signed-off-by: Maurizio Monticelli <maurizio@whatwedo.ch>
2026-11-04 22:04:36 +00:00
Daniel 2396b6be05 Hole: the drag handle looks like the Rest, Boss and Snap Fit handles (#6818) (#6819)
* claim: #6818

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

* Hole: the handle drawn with the shared marker (#6818)

A Single hole's drag handle (Move holes by clicking or dragging) is drawn with the placement
marker from place_gizmo/look.rs instead of its own ring and cross, so a hole and a Rest on the
same model show one language for the same action. place_gizmo::marker is the one crate-visible
door: it runs the marker's hover and drag motions under the handle's id and draws the crosshair,
never dimmed. Hole keeps its snapping, the click that takes a hole out, its handle names and its
hover text.

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

* Hole: hole_handle removed (#6818)

The shared marker draws the handle now; nothing else used it.

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

* Hole: the marker takes no colours it does not use (#6818)

place_gizmo::marker took a &Tokens only to hand it to crosshair, which reads its colours itself,
so the hole's show fetched tokens nothing read. The argument and that fetch go.

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

* Hole: a test sees the handle drawn as the shared marker (#6818)

The hole tests found the handle by its published position, which the look does not change. This
one reads what the frame paints round it: the white dot and four dashed arms at rest, no ring
round the centre, arrowheads when pointed at, solid shafts while it drags. It fails with the old
ring and cross back on the call line.

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

* Place handles: marker hover eases in 120 ms, drag in 220 ms (Refs #6818)

The marker answered the pointer in 220 ms and a drag in 360 ms, which felt slow on review. Both
stay in look.rs as the one place a later preference can read them.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-04 21:55:30 +00:00
austin62jake 3c1074cfc6 Timeline: moving a construction axis now updates the features that use it (#9544) (#9545)
* Evaluation: a construction axis moved past a feature makes it evaluate again (#9544)

The state digest in a feature's cache key left out the construction
geometry before it, and the context digest hashes construction features
without their order. A feature started from the same key with or without
the axis before it, so after a move it kept its old result, and a cached
state restored whole brought back the construction list it was made with.

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

* Evaluation: say plainly what an empty construction list hashes (#9544)

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-04 21:55:19 +00:00
PaddleStroke c4a63a1d43 GPU path tracer: don't rerun a setup that crashed in the driver (#9236) (#9666)
* claim: #9236

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

* GPU path tracer: don't rerun a setup that crashed in the driver (#9236)

A driver that takes the process down while it compiles the path tracer
(RADV on an RX 7600) is beyond error scopes and unwind guards. A running
setup now keeps gpu-path-setup.json (its adapter key) in the settings
folder, removed when the setup answers or the app closes normally. A
launch that finds it for the same adapter leaves the setup failed with an
explanation and a Try again button; CPU ray tracing stays available, and
a driver update changes the key and so tries again.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 21:54:45 +00:00
dudedagger42 2fdcc5ae49 Appearance: a picture of your own behind the model (#11353)
* Appearance: a picture of your own behind the model

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

* Background picture: a label that fits its row

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 21:53:10 +00:00
austin62jake 4678a04827 Agents and scripts: the contact commands describe their exact parameters and reply (#11949) (#11950)
* claim: #11949

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

* Contact sets: input and output schemas for the seven contact commands (#11949)

describe_command now gives the exact parameters and reply of
contact.enable_sets, enable_all, disable_all, create, edit, list and
check, and the warn and strict parameter checks use them. The handlers
read their parameters from the raw JSON, so the contracts in
schema/motion.rs read each key as its handler does, with its message:
`occurrences` (OccurrenceKeys) and `set` are required in the schemas
but left to the handlers' messages in the types, through a small
`require` transform. The replies are built from typed values instead of
json! literals, with the same JSON; the listed sets mirror the doc
crate's ContactSet.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-04 21:52:53 +00:00
kaltenbrunnermarkus 63ffadfebf Import: a curve that refers to itself is refused instead of overflowing the stack (#11893)
A 2D TRIMMED_CURVE in a pcurve read its basis straight through read_curve,
past the depth check that only the cached entry points (curve_in, curve_2d,
surface_in) made. A trimmed curve whose basis is itself, or a loop of two,
recursed until the stack overflowed and the app closed.

The depth check now sits in read_curve and read_surface, which every curve
and surface read passes through, so any chain of references, a cycle
included, ends at MAX_GEOM_DEPTH with an error that names the entity:
"curve #9: definitions nested more than 12 deep, or referring to
themselves". The other nested entities (surface, seam, intersection and
bounded surface curves, pcurves, 3D offset curves, offset, trimmed,
revolution and extrusion surfaces) already went through the checked entry
points; they now share the one check.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 21:45:25 +00:00
kaltenbrunnermarkus aee947f0f9 Sketch: hovering over a large sketch no longer slows down (#9614)
* claim: Sketch: keep profile outlines and trim/extend cuts between mouse moves

* Sketch: keep profile outlines and trim/extend cuts between mouse moves

Hovering a large sketch sampled every profile's loops into polylines for
each point asked, and Trim/Extend intersected the hovered curve with every
other curve on every mouse move.

- Loop2::contains rejects points clearly outside a box from the loop's end
  and control points before sampling; Region2::outline samples the loops
  once (RegionOutline). Profile keeps both in OnceLocks and answers
  Profile::contains from them (viewport pick, profile_at_point, profile
  selection by points). Answers are unchanged: the box margin covers
  rounding, and non-finite values or negative conic weights skip the box.
- sketch_modify splits the cuts on a curve (TrimHover) from choosing the
  piece around the cursor. The UI keeps one per sketch state and curve,
  and Extend's preview per sketch state, curve and growing end (afresh
  when the sketch has an infinite line).

Plate with 400 circles (801 profiles), release, per mouse move: UI frame
5.19 -> 1.11 ms, with Trim 9.45 -> 1.50 ms; 30 lines x 30 splines: Trim
piece 6.10 -> 0.0002 ms. Equivalence tests compare with the old answers.

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

* Sketch: keep the profile outline helpers in the sketch crate

The outer loop's box, the clearly-outside test and the outline sampled
once move from geom into crates/sketch/src/profiles.rs as private items
built from geom's public types, so the PR leaves crates/geom untouched.
Profile::contains answers exactly as before; the geom test moves to the
sketch profile tests.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 21:42:06 +00:00
Daniel ad56dd6a93 Box window: a picked point fills Corner and Center in the design's unit (#12051) (#12052)
* Box window: a picked point fills Corner and Center in the design's unit

A vertex, edge or face picked into the Box window's Point input wrote its X, Y and Z as millimetre text whatever the design's length unit was, so an inch design showed "50.8 mm" where the other fields read inches. The fields now take the point through length_input::model, as Move and the Hole references do, so they read "2 in" there and stay "50.8 mm" in a millimetre design. The same applies to the Sphere and Torus centre.

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

* Box window: a picked point reads to a millionth of the design's unit

length_input::model divides without rounding, so a vertex at 38.1 mm read "1.5000000000000002 in" in an inch design. length_input::shown rounds to a millionth of the design's unit, as the millimetre text did before, and the test pins that case.

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

* Box window: a picked point keeps the micrometre in every design unit

length_input::shown rounds to a millionth of the design's unit, and the picked point's text is what OK sends, so in a metre design a vertex could move by up to half a millimetre. Document::design_length keeps the micrometre in every unit and still writes 38.1 mm as "1.5 in". The test now covers a metre design with a point that is not round in mm, and the Sphere and Torus centre.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 21:40:17 +00:00
ABDULLAH cafe015ef9 Arabic: sketch tool hints and errors, view-mode name, face term and Fillet name in the hidden-edge selection preference (#12080)
* Arabic: view-mode name, face term and Fillet command name in the hidden-edge selection preference

* Arabic: translate the sketch tool hints and errors, clearer Fillet phrase in the hidden-edge selection help
2026-11-04 21:40:08 +00:00
Daniel 155387bac1 Sketch Trim: a cut on a shape's centre ends the piece there, with no second point (#12101) (#12103)
* Sketch Trim: a cut on a shape's centre ends the piece there, with no second point

Trimming half of a center rectangle's diagonal left two points at the centre. Trim dropped the diagonal's Midpoint constraint and ended the kept half on a new point held only on the other diagonal, so the old centre was free and the new end slid along the other diagonal. Dragging a corner pulled them apart.

A cut that lands on a point held at the middle of the trimmed line or of the cutter now ends the kept piece on that point. When the trimmed line held it, the Midpoint moves to the cutter if the point is the cutter's middle too (a rectangle's diagonals bisect each other), and otherwise the point is held on the cutter as a new end would be.

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

* Sketch Trim: weld the cut end before a parallel distance moves onto it

A distance between parallel lines that measured from the trimmed line's end moves to the kept piece's start. When that start is a cut welded to a centre point, the move named the new point, which the trim then removed along with the dimension. The weld now happens first.

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

* Sketch Trim: test that a parallel distance moves onto the welded centre

Trimming the start half of a center rectangle's diagonal moves a distance to a parallel line onto the kept half's start, which is the centre. The test fails if the weld runs after that move: the dimension then names the cut point and is removed with it.

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

* Sketch Trim: the centre comment names the case that reaches it

Only a line carries a Midpoint, so the comment's ellipse case could not reach this code.

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

* Sketch Trim: a crossing line whose middle is only near the cut gets no midpoint hold

Trimming a line at its held midpoint moved that hold onto the cutter whenever the cutter's middle sat at the cut, so a crossing line that merely happened to have its middle there was tied to the point. The hold now moves only when the sketch makes the two lines diagonals of a parallelogram (each pair of opposite sides held parallel, horizontal or vertical), as a center rectangle's are; otherwise the point stays on the cutter.

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

* Sketch Trim: ask the solver whether a cutter's middle is held at a cut midpoint

Moving a cut midpoint's hold onto the cutter only for a parallelogram of parallel, horizontal or vertical sides missed rectangles the sketch holds another way: with the bottom side held square to a side instead of horizontal, trimming a diagonal at the centre left the centre held only on the other diagonal, one degree of freedom freer than before. The hold now moves when the sketch already implies it, that is when adding it to the sketch as it was takes away no degree of freedom.

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

* Sketch Trim: solve for the implied midpoint only when a cut lands on one

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 21:39:58 +00:00
Daniel de1db6fb8c Parameters: reach the end of a long expression the same way on every OS in its test (#12104)
a_long_expression_opens_wide_while_edited_and_enter_applies_it moved the cursor to the end of the wrapped field with Ctrl+End. The control channel's cmd flag also sets ctrl, and on macOS egui treats Ctrl with End as an unhandled Emacs-style chord, so the cursor stayed where the click put it. The typed " * 2" did not land at the end, the parameter kept its old expression, and the test failed on macOS while it passed on Linux.

The test now uses Command+Down with ctrl off, which egui maps to the end of the text on every platform. What the test checks is unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 21:39:49 +00:00
github-actions[bot] c47e848f4a Regenerate generated files and fold pending files [skip ci] 2026-11-04 23:04:10 +00:00
github-actions[bot] 8b4fe10995 Regenerate generated files and fold pending files [skip ci] 2026-11-04 22:53:51 +00:00
Daniel 8fb046aaa8 Sketch: the whole sketch updates while you type a dimension's value (#11584) (#11585)
* claim: #11584

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

* Sketch: the sketch follows a dimension's value while it is typed (#11584)

While a new dimension's value was typed, only the dimension moved: the sketch's curves, points, constraint glyphs and other dimensions stayed at the old size until Enter. The typed value was already solved in a scratch session; it is now solved in the frame it is typed, and the open sketch is drawn from that solve, for every kind of curve, within the display cache's limits per sketch. Esc draws the design again. Picking and snapping of curves and points stay on the design, and no hover highlight of the design is painted while a value is typed. A value that does not solve keeps the last one that did.

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

* Dimension: the sketch scene follows each solve of a typed value, not its address (#11584)

The sketch scene was keyed on the preview's address, which a later solve can have once the drawn one is freed; the scene would then stay at the old value. Each solve now has a number, and the scene is keyed on it.

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

* Dimension: while a value is typed, no design profile is lit over the sketch, and its curves are sampled before its fills (#11584)

The profile under the pointer was lit where the design has it, over the sketch drawn at the typed value; it is no longer, and the highlight is built again when the value is let go. The typed sketch's profiles were sampled before its curves from the one budget, so a sketch whose fills used it up lost its curves; the curves now go first.

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

* Dimension: a selected profile is not lit over the sketch while a value is typed either (#11584)

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

* Dimension: the view goes to the typed preview only once a value is typed (#11584)

A picked dimension's value box opened with a draft that is the design as it is, and the view already treated it as the typed preview: the profile under the pointer, the selection and the hover went dark before anything was typed. The preview now starts with the first key, completion or reference.

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

* Dimension: a reference picked into the value is solved at once (#11584)

The overlays read the preview before the editor solves a changed value, so for the click's frame they drew the solve before the reference.

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

* Dimension: a selected sketch curve is not lit where the design has it while a value is typed (#11584)

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

* Dimension: the pointer's x, y readout is not drawn while a value is typed, as the hover is not (#11584)

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

* Dimension: curvature combs, the design's teeth, are not drawn while a value is typed (#11584)

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

* Dimension: while a value is typed only the open sketch loses its hover and profile highlights; other sketches keep theirs (#11584)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 21:34:23 +00:00
leValdemar19 7b31c0567b Sketch Transform: turn and scale about a picked point, such as a rectangle's corner (#11718) (#11797)
* claim: #11718

* Sketch Transform: turn and scale about a picked point, such as a rectangle's corner (#11718)

The Transform window turned and scaled the picked entities only about the
middle of their box. It now has a Center field, as Move does. A sketch point
picked there is the point Scale and Angle work about, and Position moves it.
The box, its corner handles and the knob in the view follow the same pivot.
With Center empty, Transform works as before.

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

* Sketch Transform: a click on a corner picks it as Center while Center waits for a pick (#11718)

The rectangle corner the issue wants to turn about sits under the box's
corner handle, so a click there started a scale. While the Center field
waits for a pick, the box's handles leave clicks to the sketch.

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

* Sketch Transform: the handles work again once Center has its point (#11718)

Center is the window's last field, so a pick there left it active, and the
box's handles stayed off until another field was clicked. They are now off
only while Center is empty and waiting for its pick.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 21:33:06 +00:00
Daniel 5efdd52854 Ribbon: a tool pinned into the Finish Sketch group shows pressed (#12049) (#12050)
* Ribbon: a tool pinned into the Finish Sketch group shows pressed

The Finish Sketch and Finish Editing groups never drew the pressed state for any of their buttons, so a drawing tool dragged into them (the group at the ribbon's right edge takes drops) looked like a one-shot action while it ran. Only Finish, Cancel and Finish Edit are now left out of the pressed state.

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

* Ribbon: test that Finish, Cancel and Finish Edit stay plain under a press

None of the three runs as a tool or leaves a dialog behind, so a press is the one way they could light up. The tests now press each one and fail if any of them is dropped from the exclusion. Finish Edit is pressed and released in the edit-in-place test, so the release is also the click that finishes the edit.

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

* Ribbon: Finish with AutoConstrain stays plain under a press like Finish

Finish with AutoConstrain belongs to the Finish Sketch group and ends the sketch like Finish, but it was not in the list of buttons left out of the pressed state, so once pinned as a button it lit while the pointer was held on it.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 21:32:12 +00:00
takuto-NA 61ada52984 Reuse unchanged imported meshes during joint motion (#5476) (#5480)
* perf(mesh): reuse imported contents and display triangles

* fix(viewport): preserve reference mesh detail and glass fallback

* fix(viewport): preserve small faded mesh ordering without OIT
2026-11-04 21:30:58 +00:00
SS-EN 6578d591ac control::execute: check the undo depth before placing the world for the first-body fit (#5940)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 21:30:48 +00:00
JK3D d9421bf2ca Browser: an option to start folders closed and open rows only on request (#8768) (#8771)
* claim: #8768

* feat: Browser folders start closed and find a body only when asked, behind a preference

Preferences › Layout › Browser rows start closed (`browser_rows_start_closed`, off by
default) starts the Browser's folders closed, and placements too whatever "Components start
open" (#9274) says. Rows then open with their arrow, Expand All or Find in Browser, which opens
just the rows down to its body and closes again the ones the last find opened. A body picked in
the 3D view leaves the Browser as it is, and only the body Find in Browser found has the rows
above it marked, so a large assembly's placements are not named again after each edit.

With the preference off, folders start open, placements start as "Components start open"
says, and a pick in the 3D view opens the Browser down to its body, as on main. Find in Browser
looks the body's placement up once and opens its rows before drawing, instead of each row
testing the revealed path while it is drawn.

* Browser tests: read the row order from the Browser's content frame

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-11-04 21:29:23 +00:00
PaddleStroke 774c45a512 Dynamics: rigid-body solver core with joints, gravity and springs (#9719) (#9721)
* claim: #9719

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

* Dynamics: rigid-body solver core with joints, gravity and springs (#9719)

A new L1 crate, solidcraft-dynamics (geom only): rigid bodies in maximal coordinates, joint constraint equations with analytic Jacobians for rigid, revolute, slider, cylindrical, planar and ball joints, gravity, linear spring-dampers and constant loads; the index-1 equations of motion solved through a rank-revealing Schur complement (repeated joint equations are solved, not refused); RK4 and Dormand-Prince 5(4) with position and velocity projection; joint values, rates, reactions and energies at every output sample; caps on bodies, joints, rows, steps and samples; Result errors only.

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

* Dynamics: take Vec3 from the record crate after the layer split, refresh the lock file

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-11-04 21:27:17 +00:00
P'tite Tête 315d8cfeb7 fix: sketch.intersect keeps a small round between short walls (#9962)
- section() kept the loose fit whenever it had fewer curves, even when it had merged 2 mm walls and an R2 round into one R2.85 arc that misses the walls, leaving the outline open
- The tight fit is now also kept when the loose one strays from the exact crossings (on planes and rulings) and the tight one does not; capped work, other curves count as near
- Test: a box with R2 corner rounds and four tapered grooves stopping 2 mm short, cut at Y=20

Co-authored-by: P'tite Tête <asso@ptitetete.com>
2026-11-04 21:27:05 +00:00
TaeHeon Kim 4ab1640dc8 OCLP detection: run /usr/sbin/nvram by its full path (#10109)
The probe ran `nvram` through PATH, which a Finder launch may not list
and which a look-alike earlier in it could change. The doc comment that
said exec would need unsafe is replaced by a description of what the
code does. Behaviour is otherwise unchanged.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 21:26:51 +00:00
raykholo 3aee35fe01 Web app: keep unsaved work in the browser instead of the leave-page box (#10220) (#10221)
* claim: #10220

* Web app: keep unsaved work in the browser instead of the leave-page box (#10220)

A new preference, off by default, on Preferences > General > Saving (web
app only): "Keep unsaved work in this browser instead of asking when the
tab closes". With it on, each open design with unsaved changes keeps a
copy in the browser's private storage (keep-<page>-<design>.sc/.json),
written once the design has been still for 0.8 s (at most 4 s after the
first change it lacks) and again on pagehide or when the page is hidden.
While every copy is current, beforeunload doesn't ask. A copy goes when
its design is saved, closed, or the preference is turned off.

The next visit offers the copies newer than their design's last save in
SolidCraft's own window, "Restore unsaved design: <name>, changed
<time>?", one window for several, with Restore, Open saved version, and
Discard. A page holds a Web Lock while it runs, so another open tab's
copies are never offered or removed.

With the preference off, the single autosave and the browser's prompt
work exactly as before.

* Web app: the restore window comes from the UI kit's recovery module (#10220)

The kit's text and divider helpers are private to the UI crate, so the
"Restore unsaved design?" window moves to recovery_ui
(browser_copies_window), beside the desktop recovery window, and the web
shell only maps its answer. Also keeps the window handle for the page
listeners instead of using the one moved into the app.

* Web app: restore window buttons sit in one row (#10220)

* Web app: keep unsaved work and still ask, unless told not to (#10220)

Keeping a copy in the browser no longer replaces the browser's leave-page
box by itself. A second option, shown once work is kept and on by default,
leaves the box in place; off, the box appears only while a copy isn't current.

* Web app: kept-copy review fixes (#10220)

A failed write of a kept copy waits 30 s before the next try, a recovery
copy is dropped only after the remembered saved file has opened, the
preference is documented under its current label, and the ui.prefs list
is sorted again.
2026-11-04 21:26:23 +00:00
raykholo 5562d9c6c8 Quick-access bar: pin ribbon commands to the application bar (#10223) (#10224)
* claim: #10223

* Quick-access bar: pin ribbon commands to the application bar (#10223)

A command's menu (a ribbon button, a section drop-down row or a command
search result) has Add to Quick Access: the command's own icon joins the
application bar after Save, Undo and Redo. A click starts it as its
ribbon button does, a drag along the bar reorders the buttons, and the
button's menu has Remove from Quick Access. Empty, and so not drawn, by
default; it holds up to 24 commands.

The list is kept with the toolbar's customisations (`quick_access` in
the toolbar's saved document), so it is saved, exported with the
keyboard shortcuts and carried by settings profiles.

* Quick access: a pinned family variant no longer replaces the ribbon choice

A quick-access click now starts the pinned command with start_pinned, so it
starts exactly that command and leaves the sketch family remembered choice
(the main ribbon button) unchanged.
2026-11-04 21:26:13 +00:00
Colin Faulkingham d16496e057 Navigation: honor reverse zoom for mouse drags (#10354) (#10365)
* claim: #10354

* fix(ui): honor reverse zoom for mouse drags
2026-11-04 21:26:01 +00:00
Arjan Duijs 0690ee5875 Web app: run as a modelling editor inside another page that keeps the design files (#8517) (#11273)
* claim: #8517

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

* web: editing embed for host pages (?embed=editor, #8517)

A host page that keeps the files itself and frames SolidCraft for modelling needs
the modelling UI without the application chrome. `?embed=editor` runs the web app
as such an embed:

- no application bar (logo, File menu, document tabs, update, help, report a bug)
  and no start page; the layout is docked;
- the ribbon, browser, timeline and properties stay; Ctrl+Z and Ctrl+Y still undo
  and redo;
- Ctrl+N, Ctrl+O, Ctrl+S, Ctrl+W and Ctrl+, are left to the host;
- nothing is kept in the browser (no stored designs, autosave, recovery prompt,
  preferences or leave-page prompt), as with the existing viewer embed.

The existing `?embed&open=` viewer embed is unchanged. SolveApp gets one flag,
`embed_editor`, for desktop and test hosts too.

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

* control: document.export / document.import / document.saved for a host (#8517)

An editing embed's host keeps the design itself. Three control methods
(window.solidcraft.request in the browser; the desktop control channel too; not
the agent chat's, whose method list is unchanged):

- `document.export` -> {name, revision, dirty, format: "json", text}: the active
  design as a `.json` design file holds it. `revision` is the one
  `document.revision` answers, so a host that follows the design compares one number.
- `document.import {text, name?}`: replaces the active design with that text,
  timeline and parameters as saved, opened saved with a new `document_id`. It runs
  as a design opened in a tab of its own, with the old tab closed without asking,
  so whatever was open in the old design leaves with it. A text that does not read
  leaves the design as it was. Waits behind a file loading or a design change, as
  commands do, and evaluates on the window thread.
- `document.saved {revision}`: the host saved that revision; refused when the
  design has changed since.

They are control methods, not commands: they act on the app's open design. The
browser API test gets a round trip through all three.

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

---------

Co-authored-by: aduijs <7501203+aduijs@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 21:23:25 +00:00
Colin Faulkingham 1dd921fc52 Components: stop Capture All Positions from doubling moves (#11259) (#11364)
* Model cache: a body made by rounds keeps them, so a later round blends them the same after opening

A design gave another body when opened from its model cache: the rounds a body was made of
lived only in memory, so a later round at their mitre was built sequentially (or refused, as
too large) after opening and blended with them when evaluated anew. The saved model now
records, per kernel body made by rounds, the body they were made on (a place in the bodies,
stored once though no state holds it) and the edges with their radii; reading it attaches
them back down each chain, and a thinned model keeps those bodies with the bodies made from
them. Model cache format 7.

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

* Tests: a corner patch of rounds 2/3/4 takes no more than the strips leave in its block

three_rounds_at_a_corner bounded the patch's extra cut at 3 mm³ beyond the strips, a figure
with no derivation; SMLib's all-at-once body at radii 2, 3, 4 takes 3.099 (V 7878.579574
against strips 7881.678). That body is the strips' outside the block its patch lies in: its
rounds are the strips' cylinders ending at a's section y = rc, b's x = 20 − rc and c's rails
where they meet a's and b's (the patch's four corners, now asserted), so the patch takes at
most what the strips leave in that block (11.86 at 2/2/3, 32.69 at 2/3/4). The lower bound is
that block; the upper one, at most the strips' volume, stays.

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

* Tests: the io crate's hand-built saved models list their rounds, as format 7 records do

Model cache format 7 (f3cf0c4f3) records a `rounds` list in every saved model, and reading one
without it is refused ("saved model: rounds"), as is a model without any of its other lists:
the states, the preview and the thinned model all came back empty. The io tests' saved_model
and timeline_model wrote format-6 records, so six tests (the round trip, the repeated part
stored once, and the four too-big-for-the-file cases) read nothing. Their bodies are none made
by rounds: the list is empty, and every expectation stays as it was.

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

* SMLib: pin eaa998a (tangency-labs/smlib#68, #69, #70)

Batch 4: drafts and loops, mitre corners, rim chamfers, rulings, tight box, stitch, point
classification, eased corners. Batch 5: a face's tight box divides its cells at the surface's
knots (thread boxes exact); a cylinder, cone, sphere or torus is extended along its own circles.
Batch 6: a revolution's extension closes its turn; a dropped point's foot is the converged one.
The C API is ABI 5 (smlib_blend_dropping and smlib_brep_classify_point are new; nothing existing
changed), so the binding expects 5.

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

* Tests: a strip's tangent and curvature run-offs are the distance long across the surface

SMLib measures an extension's distance across the surface (smlib_extend_sheet corrects
CreateExtendedSurface until the parameter line through the side's middle is that long), so each
run-off of the cylinder strip, swept 20 along z, adds 5 x 20: 10 pi/2 20 + 100 = 414.159 for
both. The parabola-length formula (418.18) was truck's.

Ruling d2 (2026-10-30).

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

* kernel: one smooth angle, and a round too small to change the part is refused

The blend's tangent angle is the shaded view's half degree (smooth_edges::ANGLE), one value
for smooth. A constant round lies r (sec(d/2) - 1) inside the crease it replaces (about
r d^2/8); below the edge's tolerance it changes nothing and is refused before SMLib (which
answered SM_ERR_INVALID_INPUT), naming the smallest radius that rounds,
tolerance / (sec(d/2) - 1). d is the least turn along the edge
(tangent_edge::least_angle_along, at tangent_all_along's stations).

Ruling d3 (2026-10-30). The block's tolerance is 2e-5 mm, not 0.1 um: at 1 deg the smallest
radius is 0.526 mm, so R2 and R20 reach SMLib, which still calls an edge within 2 deg smooth
(SmEdge::GetBlendEdgeuse); that test is ignored until SMLib uses 0.5 deg.

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

* Fillet rig: the knife-edge chamfers name SMLib's cause, a cusp's ball radius of 0

smlib_chamfer turns the distance into a ball radius d sqrt((1 - c)/(1 + c)), c the faces'
binormals' dot (capi chamfer_radius); at #851's cusp the binormals agree, the radius is 0 and
the call is SM_ERR_INVALID_INPUT before the fillet executive runs (gdb: chamfer_edges sees
radius 0; the dump's why is empty). SolidCraft hands every chamfer to SMLib, so the
ball-contact chord (the ball of radius d: 3.822 wide at d = 2, 159.2365 removed) is SMLib's to
build.

Ruling d4 (2026-10-30).

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

* kernel tests: a corner round wider than its faces overflows and takes the corner's other edges

R21 on a 20 mm cube's edge builds (6107.529370, closed form, to 2e-12). With R5 on the
corner's other edges the R21 takes the faces they lie on, so the body is R21's alone; SMLib
fails that blend (status 1001, fillet error 4, Unable to Trim Rail Curve) and the test is
ignored until it builds. It replaces the refusal test that expected TooLarge.

Ruling d8 (2026-10-30).

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

* Tests: a 1 degree crease rounds, and the knife edge's chamfers build, on SMLib smlib-batch-4-rulings

With SMLib's smooth limit at half a degree (422e6f1) the 1 degree crease rounds R2 and R20
(the test is no longer ignored), and with a cusp's chamfer the ball of radius d (d231547) the
The 0.5 one stays, for its own cause (the chamfer face left apart from the body). Two corner
cases the same SMLib (replay/1.0.1 and those commits) builds come off too.

Needs SMLIB_REV at that SMLib once it is pushed.

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

* kernel, doc: an overflowing round goes first; an edge it removes is gone, and the fillet says so

SMLib (smlib-batch-4-rulings 0e83cca, SMLIB_ABI_VERSION 5) rounds the edges whose rounds overflow
their faces first and then what is left of the others; smlib_blend_dropping says which picks'
edges the overflow removed whole. The smlib crate binds it (Brep::blend_dropping), the kernel
returns those picks (fillet_removing), and a fillet notes them as its warning: "the edge at
(0, 0, 10) was removed by the larger round, so it is not rounded" (a 50 x 50 x 20 block, R21 on
its bottom front edge with the front left upright: the block is R21's alone, in closed form).

Ruling d8: [21, 5, 5] on a 20 cube builds as R21 alone (6107.529370); the test is no longer
ignored. R25 with R2 at a corner (blend_corner) builds the same way, R25's alone in closed form,
where it was refused. The rig's 1080 notched block outline at R8 builds now and comes off the
known-failing list.

Needs SMLIB_REV at that SMLib once it is pushed.

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

* Fillet rig: the knife edge's 0.5 chamfer builds

SMLib's trim took a fillet meeting its face at more than 78 degrees for one with no side
(smlib-batch-4-rulings 971e567); the chord of the knife edge's 0.5 chamfer meets the flat face at
81 degrees. The case comes off the known-failing list.

Needs SMLIB_REV at that SMLib once it is pushed.

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

* Tests: a 15° tapered text builds, every letter grown, exact where its outline gives it

Owner ruling d6: the letter t of "tres long" was drafted inward by SMLib (its
loop's normal, summed from the cross products of its chords, pointed down;
fixed in SMLib). With the fix every letter grows, and the join builds at 3°
and 15° by the Round rule and by the default rule (tangent for spline
outlines), where before 15° was expected to be refused.

Exact where the outline derives it: a letter's section at height z is its
outline offset by e = z tan a, by the Round rule of area A + P e + C e² while
the offset trims nothing (Steiner; C half each loop's turning less
tan(t/2) - t/2 at each reflex corner), so the letter is
A h + P k h²/2 + C k² h³/3. Each extruded alone, the t at 3° and s, l and o
at 3° and 15° are checked to 1e-6 (SMLib measures them within 1.3e-9; its
mass properties integrate to 1e-8). The concave joins of r, n, g and e are
tighter than e already at 3° (their swallowtails trimmed: r 1.1e-3 under the
formula), the t's by 15° (9.9e-5 over), and on the block the letters grow
into each other, so the whole word is checked for what holds exactly: one
closed body with the block, and half-way up every point of each straight
letter's outline inside the solid (the t drafted inward failed that).

With SMLib's fix (smlib-batch-4-text-loops 82133b2).

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

* Kernel rig: a sloped basin's mitred rim rounds are checked tangent along their contact lines

1080-fillet-sloped-basin-1-mm-rim-r0-5-4-edges asked G1 at every blend seam. Its front wall
leans 19 over 20 and the others 10 over 20, so at each rim corner the two mitred rounds meet the
sloped corner edge at different heights (0.155625 and 0.276393 below the top): the deeper round
runs on past the corner edge and is cut by the plane of the other's wall, a crease by
construction, the gap below the shallower round filled on that plane. The check now measures
the rounds' contact seams, on the top and on each wall, where the rounds are tangent. The
volume is unchanged.

With SMLib smlib-batch-4-mitre (1c15364): the case passes (V 144530.427946 against
144530.427909, 15 faces, sound); before it, only the four corner creases failed the check.

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

* smlib: Brep::classify_point says exactly where a point lies against a body

Binds the fork's smlib_brep_classify_point (SmBrep::Point3DClassify at the body's own zone
tolerance): inside, outside, or on the boundary within that tolerance of a face, an edge or a
vertex. A box and a cylinder just off its curved wall in measure's tests.

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

* kernel: Body::classify_point decides inside, outside or on the boundary exactly

SMLib's point classification at the body's own tolerance, on the exact faces and edges, in
place of a mesh at a chord of 2e-3 of the body's size (about 0.4 mm on a 200 mm part). A point
within the tolerance of a face, edge or vertex is OnBoundary, never decided by a ray. A mesh
body's triangles are its exact shape: on them within the record's tolerance, else by ray
crossings. The kernel's own probes move to it; Body::contains goes once its last callers have.

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

* doc: a hole decides whether it exits at its drill point's shoulder exactly

Material past the depth needs a body with the point inside, air before it needs every body to
have it outside; a point within a body's tolerance of the exit face is neither.

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

* engine tests: probes ask for the exact point class

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

* ui, cli tests: probes ask for the exact point class

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

* kernel: Body::contains, the mesh test, is gone

Every caller asks Body::classify_point now.

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

* kernel tests: the rim chamfers running off their rims build, on SMLib 737c392

chamfer_wider_than_sloped_basin_rim_builds and a_rim_loop_chamfer_running_off_its_rim_builds
waited for SMLib to build the cliff overflow (CloseRemovalRegion refused it,
SM_FILERR_NO_ROOM). The pinned SMLib builds it (eddf0ede4, bba9914b6): both pass on aarch64
Linux and are no longer ignored.

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

* Fillet rig: the cases SMLib 737c392 builds come off the known-failing list

The gate on aarch64 Linux reports 16 fillet cases FIXED with the pinned SMLib: the sloped and
equal-slope basin rim chamfers and rounds past their rims (1080-*, 5835-*), the pocket rim
chamfer past the floor, and the three pocket rim rounds now mitred at the pocket's corners
(every-pocket-*). No other area's gate reports a case fixed.

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

* kernel: the re-blend's removed picks formatted as rustfmt lays them out

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

* kernel: blend_smlib laid out by the 2024 edition's rustfmt

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

* Skills: the tests skill's unit-test example stays under the 3600-character limit

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

* Fillet: the radius-too-large choice in plain nouns

"If too large: Stop and say so / Largest that fits / Narrow where needed"
becomes "Face limits: Exact radius / Largest radius / Narrowed radius", in
every language of the catalog, with the error and the tip that name them.

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

* kernel: a round that would cut through its wall into a hollow is refused

The owner's rule of 2026-10-30: a round running past a rim of its faces
into open air builds; one whose section would remove material through to
a hollow behind its edge (a slit into the hollow, a cut through the part)
is refused as too large, so the fillet names the largest radius that fits.

Decided exactly in the section of a straight convex edge between planar
faces: the round's centre is at depth r below both planes, and it opens
where its arc uncovers a boundary point strictly inside the wedge with no
material beyond it toward the centre (the body's exact point
classification). Looked at: straight edges parallel to the round's (a
hollow's corner), cylinders about parallel axes (a rounded corner), and a
picked concave parallel edge's own round, which fills the corner.

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

* Fillet rig: 1668's R7 past the inside corner is refused at 4 + 2 sqrt2

The round's centre (32 - r, 42 - r) leaves the hollow's corner (30, 40)
outside its circle, opening the wall, where sqrt2 (r - 2) = r: the case
expects the refusal naming 6.828. 2852's R30, refused naming 25.24, now
passes too: both come off the known-failing list.

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

* xtask: known-failing grades a nextest junit report against tests/KNOWN_FAILING_TESTS

`cargo xtask known-failing --junit PATH` reads nextest's junit (a file, or every .xml under a
directory, so all of a run's junit artifacts at once) and fails on a test that failed and the
list does not name, and on a listed test that passed (FIXED: take its line off, as the kernel
rig's gate does). A listed test that did not run is neither, since a pull request tests only the
packages it touches. The list has the rig's KNOWN_FAILING format (`<test> <cause>`, sorted, the
rig's own parser), with `<test>` nextest's `<binary id>::<test name>`; `cargo xtask lists`
checks it is sorted. The list starts empty.

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

* CI: the test jobs grade their junit against tests/KNOWN_FAILING_TESTS

Native Linux tests and the GPU path test shards no longer fail on any failed test: nextest's
"tests failed" exit (100) goes to `cargo xtask known-failing --junit`, which fails on a failure
the list does not name and on a listed test that passes. Any other nextest exit (a build error,
a bad filter) still fails the step at once. The tests skill says to list a failing test with its
cause, in place of ignoring it.

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

* Tests: the known-failing list takes develop's 10 failing tests

Develop 38fd4a4f6 (CI run 41157648345, after the SMLib eaa998a pin) fails 10 cargo tests; each
is listed with the cause its failure message gives. Seven are SMLib refusals or results off
their derived volumes (fillet, chamfer and shell), the cli action matrix and naming corpus
among them; the two corner_g2 tests are the fitted G2 corner patch bulging past the box, which
the explicit G2 corner fixes.

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

* Reference tests: the eight cases that fail on SMLib eaa998a name their cause as known failures

Develop 38fd4a4f6's Reference tests fail 34, 35, 77, 78 and 81 (STEP export finds fillet edges
4.6e-5 to 6.1e-5 off their surfaces against 2e-5), 37 (the apex blend 0.193 off), 80 (the
taper-to-fit area) and 83 (thicken status 1001). Each expected.json names its cause in
known_failure, which the oracle already reads: a known failure that passes fails the job.

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

* Sweep path: a curve of several pieces is turned round whole

The path chain oriented its first piece, not its first curve: a first curve
of several exact pieces (a fit spline, an ellipse, a projected free-form
edge) was never turned round, so a path whose next curve meets it at its
start was refused as "not connected end to end". The chain now orients
curve by curve: the first runs toward the second where they meet at one
end only (else from the end nearest the profile), each next one starts
where the previous ended, with one joint tolerance for both questions.

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

* SMLib: pin 2fc375f (tangency-labs/smlib#71, #72, #73)

A sheet's side is extended exactly the distance asked, or the call says it cannot (#71);
NVIDIA's -Werror build passes again (#72); a body splits by a sheet of several faces as it
is, smlib_split_body_by_sheet (#73). The C API is ABI 6 (the new entry), so the binding
expects 6.

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

* kernel: Extend grows a surface of several faces face by face

A surface body of more than one face (a fit-point spline's spans extruded, #3830) was refused.
Each face with a picked edge is now extended alone by SMLib's extension of its surface, and the
faces are sewn back together, each the face it was. A side that lengthens onto a face meeting it
tangent takes that face along, past its edge continuing the picked one; across a crease the
neighbour grows only when its edge is picked too, otherwise the extension is refused. Faces whose
lengthened sides no longer meet are refused. Up to a target keeps the pieces every grown face
lies in.

3830's extensions now build; its split still fails (the tool point picks one span, and a face
tool cuts with that face's surface alone), so it stays on the list with that reason.

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

* kernel: Split Body by a face of a smooth sheet cuts with the whole sheet

A face tool cut with that face's surface alone, so a face of a surface body made of spans joined
tangent (a fit-point spline extruded, #3830) cut nothing where its span ends inside the body.
Split Body by a face of a surface body now takes the faces joined to it tangentially (the exact
check Extend uses, within the one angle for smooth) and slices with them as they are, through
SMLib's slice by a sheet (smlib_split_body_by_sheet, tangency-labs/smlib 7a01f1b), its sides those
of the picked face. A sheet that only nicks the body cuts nothing, as before; a lone face, or a
face of a solid, still cuts with its surface extended.

3830 comes off the booleans list. Its answer counted the two halves of the cup and not the surface
that cut it, which stays as a body of its own (4879 removes its surface for that reason): three
bodies, the volume unchanged.

Needs SMLib 7a01f1b pinned (SMLIB_REV, prebuilt; its SMLIB_ABI_VERSION 6, which crates/smlib now expects) to link.

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

* Tests: a cylinder strip's run-offs are 100 pi + 100 to 1e-6, SMLib's exact area

The tangent and curvature run-offs of the quarter cylinder r 10 strip, 5 across the surface,
are its section swept 20 along z: exactly 10 pi/2 20 + 5 x 20 = 414.159265. measure is SMLib's
integration on the exact surface (1e-8), not a mesh, so the run-off check is 1e-6 relative and
the 'measured on meshes' notes go. Red until SMLib's extension is solved to its distance
(smlib-extend-converges 590d8a1): eaa998a's curvature run-off is 4.986 long, area 413.882.

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

* kernel: an extension SMLib cannot make exact says so, naming the length asked

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

* Sketch: a control spline can carry its own knots and weights

A sketch spline over control points was always the clamped uniform B-spline
over them, so a free-form edge whose knots are not evenly spaced (or whose
weights are not 1) could only come into a sketch as one curve per Bezier
span. A control spline now may save `nurbs`: its clamped knots and its
weights. Its points, polyline, exact pieces (one a knot span; conics for a
rational quadratic), the solver's distance and curvature, links, parallel
re-projection and inspect.sketch all read them; a loaded design's are
checked. One pending format file (spline-knots).

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

* Sketch projection: a free-form edge comes in as the one exact spline it is

A projected spline edge of several Bezier spans came into the sketch as one
curve per span (#1508), so a G2 round's bottom edge was 16 curves and a path
naming the corner named one span of it (#3604). The kernel now reads such an
edge (a spline, or an elliptical arc of several spans) as the one clamped
B-spline it is over the edge's interval, its knots and weights its own, and
the sketch takes it as one control spline whose ends sit on the edge's
vertices. Planar regions still read edges span by span. The 3604 case builds
with its original path and leaves KNOWN_FAILING.

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

* Fillet rig: blends past an outer rim of their faces build, on SMLib smlib-blends-past-rims

The owner's ruling: a round or chamfer that runs past an outer edge of its face into open air
builds, tangent where it has room and cut by the face beyond where it runs out. SMLib's branch
smlib-blends-past-rims builds them by the rig's analytic answers. Off the known-failing list:
the disc's rim past its 2 mm wall (190), the hole's top rim R12 past the plate's bottom, the
chamfer past the cylinder's bottom (2893), each closing on the wall continued as itself, and
926's chamfer ending on the boss's wall, which now builds to its answer too.

The sloped basins' R4 rim rounds (1080, mitred rounds built together where one overflows) build
to their analytic volumes (to 6e-9), and stay listed with that reason: their continuity check
(g1 at every blend seam) takes in the mitres' creases, G0 by the mitre ruling.
2893-chamfer-two-distances-elliptical-rim is graded as a solid (the owner: it builds) and stays
listed, waiting on the ruling on unequal chamfers along a rim whose angle varies.
5158-fillet-round-just-past-a-10mm-face stays listed with its cause found: the round passes
5e-6 under the box's top back edge, inside the body's tolerance.

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

* Naming: test that a round of a round's end has no seam with that round's faces

The r1 round of a block's top edge, chained onto the end curve of the r2
round of its arc at a cylinder (rig fillets/every-block-cylinder-crease-
top-edge-after-arc-round-r1), ends where it runs into the cylinder. That
edge is no seam of the r1 round: the cylinder is a face of the r2 round,
not of the r1. blend_seams takes it for one today (the r2 round's name
F3:blend:F1:+z|F2:side holds a | of its own), and the rig reads it as a
contact seam at 60 degrees.

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

* Naming: a blend's seams split its edge's name at the | between its two faces

A blend face's name holds the edge it replaced, <face>|<face>, and a side
of that edge can be a blend face whose own name holds a |. Whether a face
is a contact of the blend was read as the edge's name starting or ending
with the face's name next to a |, so the r2 round's own face F2:side read
as a contact of the r1 round F4:blend:F1:+y|F3:blend:F1:+z|F2:side. The
edge's name is now split into its two faces, a blend side read whole as
the two faces of its own edge.

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

* Fillet rig: five refused blends build, off the known-failing list

926 (the chamfer ending on the boss's wall), the oblique cut's r0.1
round, the every-block r1 round (its seam with the cylinder is no
contact seam, after the blend_seams fix), the up-down squares' concave
chamfers and the shelled box's lip chamfer build with SMLib branch
smlib-blend-refusals (f8650b8 and later). 1207 r0.5 passes SMLib's check
on its rails now and stays listed for the next cause: its end rib on the
boss, which starts where the round and the boss are tangent.

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

* Kernel rig: the trough and the pocket whose floors fall to a drain build

SMLib now offsets a vertex where four faces meet (two walls and two facets at the floor's
corners, four facets at the drain) to two vertices joined by a new edge (tangency-labs/smlib
branch smlib-shells). Both volumes match their closed forms. The pocket's lining, hanging from
the top's rim round the pocket, stands apart from the slab's outer walls with the cavity rising
between them to the open top: the case says two shells in the one body.

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

* Kernel rig: the plate with a hole through its arc side shells open at its far end

SMLib's merge now intersects the offsets of an edge's faces where they could not be mated along
it (the hole's offset poking out through the half-round side's; tangency-labs/solidcore#1795):
one closed valid solid with the one face open, as the owner ruled.

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

* Kernel rig: the ring cut frame and the up-down join shell

SMLib's inset now keeps only the regions that are cavity, not the one the offsets close where
they overlap (between the two faces of #545's 0.5 floor, round the up-down join's two empty
octants). The frame's core under the recess is hollowed to a sealed void, so the case says two
shells and its closed form, 21044 mm³.

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

* Kernel rig: #616's lid shells open at a rib's side, with the 0.15 lip

SMLib keeps the skirt top's offset where its loops wind the other way under the opening, the
cavity's floor (the owner's ruling of 2026-10-31: build it; a lip thinner than the wall is no
break-through).

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

* Known failing tests: the action matrix's shells and chamfers as they fail with SMLib batch 7

The ring cut frame's and the up-down join's shells build now, and so do the up-down join's and
modern-plastic's chamfers (SMLib smlib-blend-refusals). #616's lid in the corpus still fails
SMLib's loop check, the shelled box shelled again at an inner wall is unchanged, and the
oblique cut of a cylinder's fillet still fails SmSurfOfRevolution's Test #7: 3 actions of 7.

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

* SMLib: pin 26cc451 (batch 7: rounds past a rim, blend refusals that build, shells)

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

* Known failing tests: the shell after a cut whose end touches an arc side builds on SMLib batch 7

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

* Surface fair test: the energy falls strictly, the face moves, and the edges are read after the fair

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

* Sketch scaling test: the first dimension sets l1 to 40 and scales both joined rectangles about the origin

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

* Frames tests: a pick on a moved component or an instance removes the exact round and bore volumes

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

* Issue 1080 tests: the rib's rounds and the mitred corner chamfers and rounds have their exact volumes

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

* Engine tests: lip_after_boss checks each lip's and groove's exact band, hole_as_deep_as_the_plate each hole's exact bore

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

* Robustness tests: lip_after_boss and hole_as_deep_as_the_plate live once, in the engine's tests.rs with exact volumes

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

* Engine tests: loft_cut goes; loft_spacing's hollow elbow covers its section counts and more

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

* Kernel loft test: the square-to-circle loft has the oracle's exact ruled volume, without printing

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

* Tessellation lens test: the lens's triangles fill exactly the rhombus through its arcs' midpoints

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

* Pattern test: six chamfered holes take exactly five more holes' bore and chamfer rings

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

* Kernel pocket floor fillet test: the corner rounds and the floor loop round add their exact volumes, by Pappus at the corners

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

* Shell regression: the saddle-corner shell opened on the top box's side has its exact volume

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

* SMLib history test: every operand's order is the identity on its record's faces and edges

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

* Boolean history tests: the SMLib module's box-minus-sphere, box-minus-cylinder and box-union-box copies go; the top-level ones check more

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

* Modify history tests: shell, offset and thicken are one test each through the public API, with the volumes, refusals and history the SMLib copies checked

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

* Chamfer history test: the chamfer after each box cut is exactly one face on the painted block too

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

* Grid test: a pole side's face is the five triangles fanned from its one grid node, none degenerate

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

* Kernel tests: the twelve ignored print-only debug tests and surveys go; coplanar_curved_booleans checks the flush-capped cylinder cuts and unions they printed

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

* Kernel: the #883 board fixture generator is an example, not an ignored test

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

* STEP import test: a seam sphere whose meridian stops short of the pole imports as the whole 2 mm ball

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

* Material tests: the nine copies in material_properties go; the persistence file checks the library copy's card and formats 20 and 21 in one loop

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

* Sample test: one loop checks every sample builds cleanly and without a panic; the robustness copy goes

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

* Known failing: the non-antipodal seam sphere import, refused for a loop of no (u, v) area

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

* Tests: rustfmt the board fixture example and the coplanar asserts

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

* Kernel rig: the two 3871 cone cases were byte-for-byte the 258 cases; the 258 cases carry their sources

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

* Kernel rig: a volume or area can be graded as a hand-derived range where analysis only bounds it

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

* Kernel rig: fillets keeps one case per regime; 17 replications of a case in the same regime go

The sloped-basin chamfers and rounds at 0.5 mm rims repeat the 1 mm rim's regimes; two of the
three knife-edge chamfer tips, the r1 and box r3 curvature corners, the r1 tangent corner, the
1 mm pocket-rim and hole-rim chamfers short of the floor and the r1 together-round each repeat a kept case's regime
and grades. #854 moves to 851-chamfer-arc-top-edge-9-5, and the cases that cited a dropped one
cite the kept one or state its formula.

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

* Kernel rig: five cases outside fillets repeat a kept case's regime and go

A square cut is the slanted plane's ordinary crossing; the cam's half turn is the quarter turn's
contact at a symmetric angle; the r15 slab stands inside the same coincidence tolerance as the r10;
the 90 degree unfold and the 40 degree flange repeat the 72 degree and 20 degree cases' formulas.

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

* Kernel rig: 3243 rim and tooth join graded on its volume (rim plus the trimmed tooth less their 0.01 mm overlap)

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

* Kernel rig: 3782 emboss on a cylinder graded on its volume, area, faces and box (the wrapped 4 x 5 badge raised 1 mm)

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

* Kernel rig: 3604 G2 swept-tool cut graded on a volume range (filleted box minus the swept corner section)

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

* Kernel rig: 4505 coil joined through a cylinder wall graded on its volume, area and box (cylinder plus the wire's screw-swept segment outside r 76)

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

* Kernel rig: 4879 window cut through a thickened spline revolve graded on a volume range and its faces

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

* Kernel rig: 4623 join of crossing pleated loft skins graded on its volume and box (both skins less their overlap)

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

* Kernel rig: 831 tapered glyph extrude graded on a volume range and box (the font's S drafted in by 10 deg)

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

* Kernel rig: 2357 ring of 90 cylinder segments graded on its volume and box (the segments less their 90 joint overlaps)

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

* Kernel rig: 4542 fluted wall joined on a revolved base graded on its volume and box (the base by Pappus plus the fit-spline ring)

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

* Kernel rig: 1985 and 2243 STEP-export enclosures graded on their volume and box (the outline less the cavity, plus gusset and standoffs)

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

* Kernel rig: both 4092 loft, ribs and pad unions graded on their volume and box

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

* Kernel rig: 1525 graded on its volume (the convex body's exact 2 mm inward offset), faces and box

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

* Kernel rig: 1494 graded on its volume (box and cylinder less the 1 mm-offset cavity, sharp to rounded concave edges) and box

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

* Kernel rig: 4492 graded on its box and a volume range (the face's 19 mm sweep added, within the block's sides)

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

* Kernel rig: 745 graded on its volume (the end moved 1 mm plus the spline boss's exact area), faces and box

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

* Kernel rig: 4567 graded on its volume (the revolved spline less its revolved 2 mm offset), faces and box

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

* Kernel rig: the up-down squares shell graded on its volume (the columns less the 0.346 mm-offset cavity, sharp to rounded concave edges) and box

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

* Kernel rig: 545 graded on its volume (the frame less its 0.5 mm-offset cavity, sharp to rounded concave edges), its sealed pillar void and box

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

* Kernel rig: the shell-again case graded on its volume (the shelled box less the 0.5 mm-offset cavity in its walls) and box

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

* Kernel rig: the arc-sided plate shell graded on its volume (sharp to rounded at the hole's bottom), faces and box

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

* Kernel rig: 616 graded on its volume (the lid less its 0.5 mm-offset cavity, sharp to rounded concave edges) and box

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

* Kernel rig: 391 graded on its volume (the shelled housing, sharp to rounded at the flange, less the letters' 0.6 mm deboss) and box

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

* Kernel rig: shell/3576 was the ladder's drafted box with rounds shelled, recipe for recipe; the ladder case grades it and carries #3576 and #3573

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

* Kernel rig: the two 851 chamfers of 2 graded on their volume (the cone's sweep, and its union with the front edge's prism)

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

* Kernel rig: the chamfered corner of the lipped shell graded on its volume (the chamfer's triangle through the wall)

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

* Kernel rig: the concave round into the peg's cylinder graded on a volume range (the torus it must add, the band it can't leave)

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

* Kernel rig: the two folded 72 degree trays graded on their volume (flat area x thickness) and the walls' reach

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

* Kernel rig: the flanged rhombus graded on its volume (flat area x thickness, all four reliefs cut) and its walls' ends

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

* Kernel rig: the base with two adjacent flanges and a hole graded on its volume (base, bends and panels folded)

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

* Kernel rig: the flange beside the ledge graded on a volume range (the flange's two readings of its length)

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

* Kernel rig: the two G0 window patches graded on the box the part they close keeps

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

* Kernel rig: the hub's six swept grooves graded on its volume (the revolved hub less each tube's part inside it)

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

* Kernel rig: the cut mirrored loft graded on the box its sections pin

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

* Kernel rig: the three modelled-thread joins and the fine M48 graded on their volume (the basic profile's groove, Pappus)

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

* Kernel rig: the oblique-cut round graded by its seams (tangent, not curvature-continuous); the flanged rhombus says what it tests (its reliefs stay apart at 60 mm)

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

* Kernel rig: 3841 is 168's rim touching its coin along a circle, and 4092's reduced recipe is its report less a flange clear of the failing join; the kept cases carry their sources

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

* Kernel rig: the oblique-cut round keeps its old grade until the new blend algorithm lands

Its seams grade needs the continuity check to skip the body the round is not on. Fillet cases
wait for the new algorithm.

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

* Kernel rig: 1985's enclosure is 78 x 77 (74 and 73 inside, 2 mm walls); its volume 38063.827 by hand

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

* Kernel rig: the three spline walls' face counts wait for the one-wall decision; their volumes grade them

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

* STEP import: a face bounded by seams alone runs its loop the other way, never turns over

A loop that uses each of its edges once each way (a whole torus within its tube and ring seams)
is the same set of uses run backwards, so it says nothing of the face's side. One written
clockwise in (u, v) turned the face over, against its same_sense: the torus came in inside out
with a warning. Such a loop is now reversed and the face keeps its side.

Test: seam_only_torus_imports_as_a_whole_ring, both orders of the two seams, volume 2π²Rr².

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

* STEP import: a face bounded by a slit alone is the whole of its closed surface

A loop that runs out along a chain of edges and straight back (a meridian from a sphere's pole
stopping short of the other, out and back) encloses no (u, v) area, yet on a closed surface it
bounds all of it less a cut of none. The reader only read such a face when the slit already ran
pole to pole (and moved the sphere's frame onto it only then), so the 2 mm ball whose meridian
stops at 2.5 rad was refused for its empty loop.

The slit is now made the face's seams, so its loop bounds the surface's whole domain: on a
sphere, a revolution whose profile ends on its axis, or a spline closed round u that collapses
at both ends, the slit's meridian is run on to both poles, out and back (a sphere's frame put
on the slit first, whichever two points it joins); on a torus, the circle the slit lies along is
closed and the other circle through its start is the second seam. The seams on a sphere are its
own meridians (angle 0 on the equator), so their pcurves are exact lines on its form.

Tests: non_antipodal_seam_sphere_imports_as_a_whole_ball passes (its known-failing line goes);
seam_only_torus_imports_as_a_whole_ring adds a torus bounded by a slit along its tube.

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

* Rig: the test-peg's concave arc and front top edge rounded together stay within the body's box

A new fillet case, report-fillet-concave-arc-and-front-top-edge-together-solver-residues:
the reported part's bar (with the sketch solver's residues) whose sides run tangent into the
cylinder, its concave arc and front top edge rounded r2 together. The plane of the front face
bounds the whole body, so every blend lies within the unblended body's exact box (SMLib's tight
box); graded to 4e-5, the corner patch's own tolerance. On SMLib 2fc375f the corner patch
stands 0.0374 out of the front face (box y min 0.0205): listed in KNOWN_FAILING.

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

* Rig: the test-peg corner at r5 and r7

Two more fillet cases, the reported part's concave arc and front top edge rounded together at
r5 and r7, with the same exact-box containment check (graded to 4e-5, the corner patch's
tolerance). On SMLib 2fc375f the corner patch stands 0.128 (r5) and 0.230 (r7) out of the
bar's front face (box y min -0.0699 and -0.1723): listed in KNOWN_FAILING.

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

* Rig: blends that end where their face runs tangent into another

Fifteen fillet cases on the test-peg's shape with clean numbers: a bar 40 x 16 x 10 whose far
end is a half disc R8, a disc R8 standing 10 on its top, joined, so the front face runs tangent
into the cylinder along x = 40 and the front top edge (convex) meets the concave arc of the top
tangentially there. Each blend ends on a face tangent to the one it rolls on.

- Rounds: both edges together at r2 and r5; one after the other in either order at r2, and at
  r1.5 and r3; the front edge alone as a curvature round, whose end on the cylinder is a
  semicubical cusp. The containment invariant (the unblended body's exact box, to 4e-5) and
  G1 seams, or the analytic volume where the blend has a closed section.
- Chamfers: the front edge alone and the concave arc alone (analytic volumes: the section along
  x up to the wall, the cone's spandrel ring clipped by the sides), both together, and one after
  the other (1 then 2).
- STEP: the peg written to STEP and read back, its front edge rounded (analytic) and both edges
  rounded together.
- A cone: the peg drafted 5 deg, whose front face runs tangent into a cone along a generator,
  its front edge alone and both edges together.

The quadratures are tanh-sinh, converged to 1e-13; SMLib's own volumes of the passing analytic
cases agree to 4e-9. A sphere is left out: a plane meets a sphere tangentially at a point, not
along a seam, so it is not this ending.

On SMLib 2fc375f eight fail, listed in KNOWN_FAILING: every chamfer that ends tangent (status
1001, SmFilletNx1Corner), the two rounds together (the corner patch 0.0374 at r2 and 0.128 at r5
out of the front face, and the rounds meeting with normals 180 degrees apart where their
contact lines on the top cross), the same on the STEP-read peg (status 1001) and on the drafted
peg (its corner refused as unsupported).

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

* Rig: fillet corners graded curvature-continuous, the default to be

Six fillet cases that pick their edges with no type, so they take the fillet's default, which the
owner has ruled is to be curvature-continuous (G2) at every corner, with circular rounds kept as
an option. Each grades every seam of the rounds and of the corner patch G2, on one valid body:

- an oblique convex corner (a block drafted 15 deg);
- a four-edge and a five-edge apex corner (pyramids lofted to a point);
- a pocket's concave corner;
- a mixed corner (a step: one concave edge, two convex);
- a corner beside a cylinder (the top corner of a half cylinder).

All six fail on SMLib 2fc375f, listed in KNOWN_FAILING: today's default is the circular round,
whose seams jump in curvature by 1/r. On the five-edge apex the corner patch also meets two
rounds at 8.45 degrees (G0).

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

* Rig: the test-peg's front chamfer builds

With the SMLib fork's smlib-tangent-ends (a rail's end on its side edge is the rail/edge
crossing), report-peg-front-chamfer-d2 passes. Its line leaves KNOWN_FAILING, for the pin that
carries that fix.

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

* Rig: the curvature-continuous corner cases pick the curvature fillet

The six corner cases added in 3a854104a took the fillet's default type, on the premise that the
default would become curvature-continuous. The default stays the circular round, so each case
now asks for the curvature fillet itself (type curvature: curvature-continuous sections and
corner patches) and keeps its grading: every seam of the rounds and of the corner patch G2, one
valid body. They are renamed from cov-fillet-g2-default-* to cov-fillet-g2-*.

The oblique three-edge corner (a block drafted 15 deg) and the pocket corner pass. Four fail, as
KNOWN_FAILING now says:

- the corner beside a cylinder: the curvature fillet builds its sections only between planar
  faces;
- the four- and five-edge apex corners: refused because the default setback is shorter than
  where the rounds' contact lines meet (3.90 and 3.51 mm from the apex);
- the mixed corner: SMLib sets back a corner only where its edges are all convex or all
  concave.

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

* Rig: the test-peg's concave chamfer, alone and after the front chamfer, builds

With SMLib's tangent end as an event of the fillet's trace (blend-end-event 6d73e4f), the
concave chamfer's rail on the cylinder ends where it crosses the seam along which the front
face runs tangent into the cylinder, continued past the vertex, and its end rib on the front
face is built from there. report-peg-concave-chamfer-d2 and
report-peg-front-chamfer-d1-then-concave-chamfer-d2 pass and leave KNOWN_FAILING. The two
chamfers together still fail, now at the mixed-convexity corner where they meet: its line says
so. Rig against SMLib 0cc48bd: no area regresses (963/993 pass, 961 on a87c502), ladder 168/168,
kernel lib only its listed failures.

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

* Kernel rig: constant rounds of the exact classes, and a tee's round

A plane cutting a cylinder or a cone at an angle, a sphere or a cone coaxial with the other face, a
cone and a cylinder touching one ball, two pipes touching at a point: their rounds' centre lines
are rational, so the rounds are rational surfaces of degrees fixed by the construction ((4, 2),
(2, 2) revolved, (6, 2), (12, 2)). The tee's centre line is not rational; its round is graded on
its volume. Volumes from the divergence theorem over the exact pieces, at rel 1e-6. The six that
SMLib 0cc48bd traces are listed in KNOWN_FAILING with what they come out as.

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

* Kernel rig: the exact-class rounds pass on SMLib's classified exact rounds

The rounds now come as the rational surfaces their classes give, closed ones as two faces split
at their seams, the touching pipes' as four: one splines entry a face, each of the class's degrees.
Their KNOWN_FAILING lines go.

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

* SMLib: pin 7ac037e (the new blend's tangent-end event, zero-speed G2 ends, and its exact-class rounds)

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

* Kernel rig: the fillets known-failing list is the 22 cases that fail on SMLib 7ac037e

Twelve listed cases build on the new blend's end event and exact classes, or were already
built on develop: they come off the list.

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

* Kernel rig: #316's bottom rounds as large as the wall, or larger, shell with a step wall at the opening, not refused

Where the round meets the open bottom the cavity's wall steps down along the bottom's normal, so the
round meets it at 90 degrees and the opening is exactly the picked face. The cases are renamed and
graded on the exact volumes, and come off the shell known-failing list.

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

* Merge fixes: every crate and test target compiles on the merged tree, but for crates/geom's hlr

world_state, view::bounds, home_camera and scene return Result on develop; new
main code takes that with ? or session_world. New fixtures carry the spline's
nurbs. ForeignCopy::of returns Result. occurrence_placement_with walks the path
with its moves, since the placement index holds pending_moves only.

The hlr port is separate and still to come: this commit does not compile as it
stands in crates/geom/src/hlr.

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

* Drawings hidden lines: read develop's B-rep records

Edges come from the record's edge list, cut to their interval (C3::of_edge), and
faces from its face list; a cylinder, cone, sphere or torus chart takes its bounds
from where its NURBS form lies on its label (S3::of_face). A rigidly placed
revolution has main's revolution chart; other placements stay refused. Moving a body
to its centre goes through Brep::moved. Loops are read against the natural normal of
the face's parameters where a chart wraps.

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

* Hidden-line fixtures: build develop's records from the fixtures' data

Fixtures keep their bounded curves and surfaces and their own edge numbers; a private
builder makes the canonical record, and the local prepare puts the edges back in the
fixture's numbering. Assertions are unchanged.

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

* Hidden lines: tests of the bounded edges, face charts and placed revolutions

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

* drawing-hlr doc: what is drawn and refused on develop's records

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

* hlr tests: a placed revolution is refused when its placement is not rigid

The new model has one record for main's Revolution, which was drawn, and its
PlacedRevolution, which was refused. An identity placement is now the former, so
this test's fixture takes a non-rigid one. The refusal it asserts is unchanged.

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

* Hidden lines: a traced intersection is drawn only where its spline lies on both surfaces

A leader off either surface is refused like a curve with no spline to follow. Tests: a traced rim
drawn on the circle and plane within tolerance, the same leader 1 mm off refused, and kernel bodies
with arcs through the production prepare against closed forms.

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

* Hidden lines: kernel bodies through the production prepare against closed forms

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

* Tests from main: the world state is a Result here

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

* A blend's pick measures close edges on their curves, not their polylines

A polyline can be a chord off its edge, more than two edges are apart near a
tangent point. A pick exactly on a ball's rim took the straight edge the rim is
tangent to, and solidcore built a chamfer of that edge with a clean check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
(cherry picked from commit c786db6bf4)

* Merge fixes from the first SMLib test pass

Model cache key covers the crates develop's kernel adds under doc (profile, smlib, step).
A piece taking a name also takes the feature that made it: SMLib lists a cut's pieces in another
order, and the swap left the name on a piece of the wrong feature.
A replaced analysis keeps its status slot. A command with no world picks does not place the model
first, so assembly.mass_properties answers for a flattening placement. FeatureCommon loses the
touching key the merge added; only the contracts that read it take it.
Two of main's tests listed as failing on SMLib, with why.

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

* Merge fixes from the ui test pass and the gates

Main's thread.direction keys go with develop's thread hand, which replaced them. Vietnamese for the
messages develop added while main made vi a complete catalog. CGA and 1-bit themes set develop's
comb and section hatch colours. A ribbon button with a persistent effect is pressed only while its
tool runs, so persistent_ribbon_pressed alone decides how its effect is shown; other analyses stay
pressed while on. Two of main's ui tests listed as failing on SMLib, with why.
Gates: fixed thread tables become slices, one collapsed if, rustfmt.

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

* A silhouette takes its facing from the surface's normals

A flat triangle beside the outline can tip the other way from the surface it approximates, and its
edges joined the outline. On SMLib's sphere that made 192 segments instead of the equator's 64, 100
curves and 95 profiles from the tight fit of #7989. Each corner's normal is used where it is close
to the triangle's own; a mesh without such normals (none, zero, not numbers, or averaged across
creases) keeps the triangle's normal, as before.

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

* Clippy and rustfmt on the merged tree

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

* Stepwise open checks every feature under the session's mode

OpenSteps::start evaluated under the default check mode, and finish kept those results under the session's mode: an each_step or off session opened in steps recomputed nothing. start now takes the mode (the app passes its session's), and a mode that changed before finish evaluates the design again under the new one.

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

* Clippy without SMLib: the planar-sheet edge reader is built only with it

Its one caller is in the smlib-only sheets module, so without the feature it was dead code and
clippy -D warnings refused the kernel.

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

* SMLib: pin d01d7b0 (the new blend's one Gⁿ section generator: the chord, circle and curvature sections, and the variable rounds as the union of their balls)

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

* Kernel rig: the section orders, chord, circle and curvature section, on planes and against a cylinder

Eight fillet cases for the blend's cross-section by its order of contact with the faces, each
graded on its analytic volume at rel 1e-7 and its exact continuity (the order, and not the next):

- a box's top back edge chamfered 3 (G0), rounded r3 (G1) and curvature-rounded 3 (G2): the
  areas cut off the corner s^2/2, (1 - pi/4) s^2 and 113/756 s^2, the last by the default
  thumbweight's definition;
- a pipe R10 dipping into a plate, its concave edge chamfered 2 (the chord between the contacts of
  the ball SMLib rolls for that setback, radius 1), rounded r2 and curvature-rounded 2, each
  section's added area by Green's theorem along the plate, the section and the pipe's arc; the
  order-2 section is built from the faces' feet of the point at distance d from both, which on
  the pipe are not linear in d;
- a boss root curvature-rounded 2, the square corner's section revolved (Pappus);
- a variable round r1 to r5 along a box edge (rel 1e-5): the balls' envelope is a circular cone,
  and the volume is the cone's sections integrated along the edge.

Three fail today and are listed: the two curvature rounds whose faces are not a square corner of
planes (SMLib's quintic blend curve, not the order-2 section: rel 6.5e-7 and 1.2e-5) and the
variable round (great circles, not the envelope's: rel 4.8e-5). The other five pass on SMLib
7ac037e.

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

* Kernel rig: the curvature fillets' expected bodies from the order-2 section at thumbweight 0

SMLIB_BLEND_CURVATURE's section is now the order-2 G^n section at thumbweight 0. Between two
planes square to each other it cuts A = 0.154192655384275 s^2 off the corner, its first moment
about either face 0.0291753295725360 s^3 (Green's theorem on the section): the curvature cases
built on that corner (862, 2708, every-box-edge, every-hole-top-rim, every-l-bracket,
every-block-cylinder-crease, the three-edges-at-bar-corner reports, 3604's fillet) take their
volumes from it by their own methods, the peg's front blend 23.3237146876 (its section along x
up to the wall). Between planes the section's t^3 term vanishes (their feet are linear in the
distance): the box edge case is exactly G3. The boss root is a certified fit, G3 to the plate and
G2 to the wall at Continuity Analysis's tolerances: at least G2. The boss root and the pipe on
the plate pass on SMLib's section and leave the known-failing list.

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

* Curvature fillets: the section's default thumbweight is equal area, so the round's volumes are the circle's

On the round's rails the order-2 section takes off what the ball's circle does: (1 - pi/4) s^2 at a
square corner, first moment 0.0474559669443 s^3 (w = -0.5252123889). The kernel and engine tests
and the curvature rig cases take their volumes from it by their own methods; the pipe on the plate
from its own wedge (70.52878 degrees, w = -0.8544103493). The G2 corner patch's seam test is listed
until the corner step.

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

* Kernel rig: four G2 corners wait for the corner step: their patch meets the new section's rounds G1 at one face seam

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

* Kernel rig: the turn at a corner of two rounds, Mitre (the default) and Swept, at a pocket's floor and a boss's base, by exact volumes

A round's spandrel A = (1 - pi/4) r^2 and its first moment about its wall M = r^3 (5/6 - pi/4):
Mitre adds L A -+ 2 M per edge (cut at, or run on to, the corners' bisector planes), Swept turns
the section about a convex corner's edge, (pi/2) M a corner, and refuses a concave corner rounded
less than the ball. The pocket's tangent chain round its R3 corners is a torus each. Graded to
1e-7, where Mitre and Swept differ by 6.6e-6. The Swept cases are listed until solid.fillet
takes a turn.

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

* Kernel rig: a junction family: oblique wedges 30 to 150 degrees rounded and chamfered (exact), bosses upright and tilted, pipe tees and a Y junction

Four are listed with their causes: the equal-pipe tee (its exact conic-pair rounds meet at the
ellipses' crossings in a corner the old corner code cannot join), the tilted bosses (1001, before the
blend rework too) and the Y junction (refused for room at its triple points).

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

* Variable rounds: graded by the union of their balls (their envelope), not the balls' great circles

The smlib crate's laws and ladder 005, 006, 007 and 012 take the volumes the balls' envelope
removes; the law with a corner is its own test, listed as failing until the corner step.

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

* Kernel tests: variable rounds graded by the union of their balls, the removed volume to 1e-4 of itself

removed_by_balls integrates, in each plane square to a right-angled edge, the corner the union
of the balls leaves (by rays from the edge, the least entry over the balls), cut to the body's
section. The law with a corner through a middle point is its own test, listed as failing.

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

* Kernel tests: a spline radius whose balls fold over one another at its peak is refused

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

* Kernel rig: the variable round on its cone passes (the round is the balls' envelope)

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

* Tests: three rounds of different radii at a corner listed: the corner's transition kinks with the balls' touch circles

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

* Ladder: the variable rounds graded to 1e-6 by the union of their balls; 012 by its law going on past the edge

At the default 1e-3 the great circles' volumes (4e-6 off) passed too. SMLib builds each within
1.6e-7. The golden takes the new volumes and areas; counts unchanged. The G2 corner test listed
as failing passes on SMLib's step 3 and leaves the list.

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

* Reference tests: the variable rounds 77, 78 and 81 pass on SMLib d01d7b0; their known-failure marks come off

The new section builds their edges on the surfaces, so STEP export no longer finds them 4.6e-5
to 6.1e-5 off. develop's Reference tests (60393a718) report all three as passing while marked.

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

* STEP import: a ball's halves whose seams are off each other's quarter lines each form within one turn

A sphere split into two halves along a circle about its axis, each half covering a pole with no
seam, gets a seam per half from the first vertex of its loop to the pole. The circles between the
halves group their frames so they turn together, and when the two seams are not a whole number of
quarters apart no one turn fits both: the group kept its frames, a half's form ran past one turn,
SMLib's trim (SMLib and solidcore alike) across the form's end crossed the face, and the mesh
folded over the ball (a rotor's ball showed torn; its half had 196 times its area in triangles).

When no one turn fits the group, each spilling face is now turned on its own to start on a quarter
line. On the rotor file this also clears three other faces that were filled leniently and a
0.076 mm edge gap; regression test in step_in/tests.rs with the area and volume of each half.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgEu4Lr3moCCisdZShKz
(cherry picked from commit b3188e005d427043b918267449c2336a1777e54f)

* SMLib: pin 5e8c9c1 (the new blend's corners: Ball and End/Join; exact fold and room checks with fit sizes; overflow in place; the round as its definition)

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

* Kernel tests: a law's dip shows its corner ball's sphere, a peak's two rounds cross (the reasons had them swapped)

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

* Reference tests: the variable rounds 77, 78, 80 and 81 expect the union of their balls

A variable round is the envelope of the balls rolled along the edge, which takes less than a
quarter round in each section. The expectations are now that union, the law continued past the
edge's ends along its end slopes, integrated by Gauss-Legendre panels in x and t; 2, 4 and 8
panels agree to 1e-9 of the body's values, and 77's removed volume matches a brute-force union of
balls to 2.5e-10. The nearest-ball search now scans the balls the ray meets around ball x
(|x - s| < r(s) sqrt(sin 2t)) on their own, which near the faces and where 78's radius closes to 0
are narrower than the global scan's step. Regenerating keeps each case's known_failure. 79, a
constant radius, is unchanged.

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

* Known failing: the law corners' kernel and crate tests and rig 1207 pass on SMLib 48ea616

SMLib builds a variable round's law corners as its balls' union (the corner
ball's zone at a dip, the sides' crossing at a peak): the kernel's corner
through a middle point (39.5410) and the crate's 4 -> 2 -> 4 law (39.5421) pass.
Rig 1207 (the bar's top edge rounded after the boss's arc, r0.5) builds and
grades on the same SMLib; it failed on c820346.

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

* Kernel rig: every fillet and chamfer case rounds its tangent chain

The owner's ruling of 2026-11-01: the rig and the ladder round with tangent_chain true, how a
user's default pick behaves. The 38 fillets-area recipes and the ladder's 07-fillets/016 and 018
that passed false now pass true. The shell recipes keep theirs: a shell's tangent_chain is its
open-face chain, a different feature.

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

* Kernel rig: the partial-selection cases kept as -partial-chain copies, tangent chain off

Where a case's point is one edge of a tangent chain rounded alone, a copy keeps tangent_chain
false as its own case (SMLib rounds exactly the picked edges, ending at the smooth vertex); their
expectations stay as they were, to be re-derived:
- fillets/1207-fillet-box-end-edge-into-unblended-round-partial-chain;
- fillets/every-fillet-after-fillet-curvature-front-then-side-r3-partial-chain;
- fillets/1207-fillet-bar-top-edge-after-boss-arc-round-partial-chain, renamed from
  ...-no-tangent-chain: with the chain on it is exactly 1207-fillet-bar-top-edge-after-boss-arc-round,
  which the rig already has;
- ladder/07-fillets/020 and 021, the r5/r3/r7 and r5/r3/r2 corners (016 and 018) with the r3 on
  its edge alone: with the chain on, the r3 runs on along the r5 round's end arc and down the
  vertical edge the third round was to take.

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

* Kernel rig: the concave arc between the block's tangent sides kept with tangent chain off

fillets/every-block-cylinder-tangent-concave-arc-r2-partial-chain: the pick takes one piece of the
concave half circle, which SMLib splits; with the chain off its other pieces are left out, and the
blend is refused at the piece's end (the base gate's outcome). With the chain on the case rounds
the whole half circle. The copy keeps its expectation, to be re-derived.

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

* Kernel rig: two chains that now build graded by their exact volumes

With the tangent chain on, two cases round more than the edge they pick, and their old
expectations were the single edge's:
- 1207-fillet-box-end-edge-into-unblended-round: the r1 chain is the end face's top edge, the r3
  round's end arc and the back upright; 34 A + (pi/2) A (3 - c) by Pappus on the arc:
  V = 7953.139216 (it built 7953.139216, the old 7957.505 passed only inside 1e-3), 10 faces.
- 851-fillet-top-front-then-front-left-upright: the upright's chain runs up the first round's
  end arc on x = 0 and along the left top edge, so the corner becomes the R2 ball's eighth:
  V0 less the first round's spandrel to the wall (35.344727) less 27 (4 - pi) + 2 pi/3:
  V = 9530.777979 (built 9530.777987), 10 faces.

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

* Kernel rig: a curvature chain round a round's end refused for its size; the concave half circle builds

- every-fillet-after-fillet-curvature-front-then-side-r3: the side edge's chain runs round the
  first curvature round's end curve, whose radius of curvature has a weighted mean of 3 (its
  setback) and is unbounded at the ends, so somewhere below 3: the size-3 blend's contact line
  folds back on itself. Refused, "larger than the faces beside its edges allow", as ladder
  07-fillets/016's r7 on the r5 arc. (It was solid or refused for running on tangentially.)
- every-block-cylinder-tangent-concave-arc-r2-no-tangent-chain: the chain takes every piece of
  the half circle and ends at the convex edges it runs on into; it builds the analytic 19091.9620,
  so the outcome is solid (the refusal alternative was the chain-off one's).

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

* Known failing: two 851 chains whose corner piece is a ball of tube-centre radius 0

851-fillet-arc-then-top-front and 851-fillet-top-front-then-upright: with the tangent chain on,
each R2 chain runs along the end curve of an R2 round on a curved face (the arc round's end on the
front face; the first round's end on the wall), where the rolling ball sits on that round's own
centre line, so the piece is the ball about (39.2538, -19, 8): the three-edge rolling-ball corner.
SMLib fails with status 1001; the same piece on a plane end arc builds (front-left upright).
Expectations unchanged.

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

* Ladder: 016 and 018 refused, their r3 chain takes the vertical edge; 020 and 021 recorded

With the tangent chain on, 016's and 018's r3 runs on along the r5 round's end arc and down the
vertical edge, so the third pick (60, 40, 8) is 3 from both of that round's contact lines and
names no edge: refused, asking for a point along one edge. The corners they were made for are
020 and 021 (the r3 alone), whose golden entries are 016's and 018's (021 measured on f12a101).

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

* Kernel tests: three rounds at a box corner end on one rational patch on the ball's sphere, not an analytic sphere

SMLib's Ball corner is always the rational patch exactly on the sphere (owner ruling, blend
design §23), so the corner counts as an 'other' face beside the three cylinders and six planes.
The removed volume still checks the corner against r³ − πr³/6.

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

* Kernel rig: the curvature round on a pipe on a plate is listed: a seam grades G3 where exactly G2 is wanted

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

* Kernel tests: the box-and-fillet wire fixture records the round's cylinder frame as SMLib now keeps it

The round's cylinder keeps the frame it was made on when it is turned (SMLib's SwapUV no longer
re-derives it): its origin sits at x = −20 on the same axis line, (x, 2, 8) along x, radius 2.
Same cylinder; one number in the record moves.

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

* Known failing: two kernel tests' reasons as they fail on SMLib 5e8c9c1 (a partial selection running into an unblended tangent edge)

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

* Kernel rig: two curvature rounds get 600 s, past the 180 s default, while G2 certification is slow (tangency-labs/smlib#96)

On the CI runners the boss root and the hole rim, both size-2/size-1 curvature rounds, build
correctly but take more than 180 s: the order-2 section's span certificate and the round's
definition are evaluated station by station. The per-case limit is the one the thread cases use.

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

* Lid ring sketch test: find the ring under the pick, not by main's kernel's name

On SMLib the lip join's exact history names the lid underside's ring a piece
of the lid's own start (F14:start#[...]), as the naming rule says for a face
that came from the feature's input; main's kernel named it after the case's
top it rests on (F2:top). The test looked the ring up by that name and so
stopped before the attachment it guards. It now takes the underside piece
under the pick point, which the cap certificate attaches on either kernel.

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

* Viewport culling test: each box's triangles from its mesh, not 12

SMLib meshes a 10 mm box in 108 triangles, so the hard-coded 12 a box stopped
the test before it reached the culling. The expected counts are now each
body's span in the scene.

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

* Timeline chip test: a fillet that really reduces

The R20 fillet with fit reduce on one top edge of a 10 mm box builds on SMLib
without a reduced-radius warning, so the fixture had no warned feature. It
now rounds the four vertical edges at R6, reduced to the full round (5), as
the copy-warning test beside it does, and checks the warning says so.

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

* Oracle: 34-chamfer-then-fillet and 35-fillet-curved-curved pass on SMLib 5e8c9c1 (their STEP edges are on their surfaces)

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

* Known-failing: the shell of a fully rounded box opened at one face fails on SMLib 5e8c9c1 (tangency-labs/smlib#102)

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

* Known-failing: the action matrix's oblique cylinder cut fillet passes on SMLib 5e8c9c1; two shells still fail

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

* Kernel rig: the boss root's curvature round gets the rig's 1800 s cap: on the CI runners, 8 cases at a time, its G2 certification passed 600 s (tangency-labs/smlib#96)

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

* Kernel rig: a case's continuity can ask for an order per seam (seams: [{order, edges, exact}])

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

* Kernel rig: the pipe on a plate's curvature round meets the plate G2 and the pipe G3, 40307.8856312 (option B's order-2 jets, tangency-labs/solidcraft#10845)

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

* Edge names: a seam names its one face once; the curve-id test reads every edge kernel-neutrally

A seam's two sides are the same face, and edge names list each face an edge
bounds once, so SMLib's extruded circle names its side's seam F2:side:c1, with
no separator. The name grammar now says one face also means a seam. The #5858
test no longer counts one '|' an edge: every part of every edge name is a
whole face name of the edge, one per face it bounds, two for the rims and one
only for the side's seam (checked to run bottom to top on the circle).

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

* rustfmt on the merged tree

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

* Known failing on SMLib: two of main's new tests assume truck's box mesh and fillet torus

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

* SMLib: pin f916a8d (a round on a definition face is its definition; the G1 setback fill certified convex, setbacks too short refused naming the least; the G2 certificate halves a span no level can certify at once)

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

* Fillet: a setback too short for its corner is refused naming the least that builds (smlib_blend_fit_size)

SMLib's setback corner takes a G1 fill only once certified convex, and refuses a setback too
short for that as SMLIB_ERR_NO_ROOM with SMLIB_FIT_SETBACK_AT_LEAST, the smallest setback that
builds. The kernel reads it (solidcraft-smlib binds smlib_blend_fit_size) and names it; the
Fillet dialog says it in plain words. Other room refusals keep their words. The r5 box corner
set back 5.5 is refused naming 5.962, which builds.

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

* Kernel tests: three rounds of different radii at a corner meet in SMLib's setback corner

Each round runs whole to its section square to its edge at its setback (the owner's rule, at
least 1.5 r, scaled to the least at which the G1 fill is certified convex), and the hole is the
setback split: one patch per round and the triangle in three quadrilaterals, 15 faces, G1.
Outside the box the sections span the body is the three strips', so its volume is theirs within
that box's. A later r3 round at the mitred corner makes the body all three at once make
(7928.142563938, 15 faces), as the engine and the model-cache tests have it.

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

* Kernel tests: SMLib's own corner between curvature rounds is curvature continuous, as the G2 patch is

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

* Kernel rig: the boss root's and the hole rim's curvature rounds take the default timeout again (37 s and 34 s on the box under the rig's parallel load, tangency-labs/smlib#96)

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

* Agents: model decisions use exact geometry; a mesh is for display, export, previews and mesh bodies

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

* xtask: mesh-decisions, model decisions use exact geometry; today's 176 mesh decision sites are listed and the list only shrinks

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

* Sheet metal: cuts and holes pass over sheets and bends by their exact boxes, never a mesh's

A primitive cut skips the sheets kernel::apart (exact bounds grown by the record tolerance)
finds clear of its tool; the bend check leaves the clear case to kernel::boolean, which answers
it from the same exact boxes with no intersection work. The mesh-box helpers are gone, and their
four lines leave xtask/mesh-decisions.txt (172 sites left).

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

* Agents: AGENTS.md loses a stray merge-conflict marker line at its end

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

* Locales: Vietnamese for the setback-too-short refusal, now that vi is a complete catalog

Main made vi a complete catalog; develop's new message (ui.fillet_error_setback_short) had no vi
entry, so every_complete_catalog_carries_every_english_message failed on the merged tree.

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

* Kernel rig: exact volumes for 1213's bent-tube round and 4782's bar round, rel 1e-6

1213: the loft is a cubic skin through the five sections (ellipses between
them), not a torus; its volume 131601.597487935 by quadrature less the exact
rolling-ball removal 233.662461919 gives 131367.935026 (was a Pappus estimate
on a torus, 131360.148, at rel 5e-4).

4782: the section's area in closed form, 25 pi/3 - 5 sqrt(75) + 25, so
V = 630.2934873 (was a grid estimate, 630.293125).

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

* Kernel rig: the boss root's and the hole rim's curvature rounds take 600 s again: under CI's parallel load they pass the 180 s default (develop 820b7b6)

They take 37 s and 34 s alone on the box, but CI runs 8 cases at once on slower runners. Their
G2 certificate cost is the open item (tangency-labs/smlib#106, moved to solidcore#2344).

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

* After the merge: clippy's auto-deref, and the mesh-decisions list for the merged tree

Develop's list was taken on develop alone. Main's code (tap-marking reach, cut and hole box
checks, join pieces, print faces, placed measuring, selection items, imported mesh payloads) and
the branch's own exact_edges and named_vertex use meshes too; three listed functions were renamed
by main (face_outline and plate_recesses into FaceSlice, paste into build).

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

* After the merge: main's new tests on the SMLib kernel

- Cut To the far side of a curved wall (#8634): the tool stops past the first crossing of the
  face ahead of the profile; SMLib's whole bore is one face that a line crosses twice, so the
  single-crossing rule found none and the cut took nothing.
- Clearing view analyses goes through refresh_display, as main's #10687 made the others, so an
  open feature editor keeps OK and Cancel after Zebra.
- vi, a complete catalog on main, gets the setback-too-short message.
- fillets/KNOWN_FAILING: 4782's cause is now its continuity check (SMLib f916a8d builds the
  expected volume).
- Known failing, as main's tests assume truck: the spline wall's spans as one mesh patch, a
  hole touching an edge at a point leaving one body, and a copy of a copy's triangles matching
  a fresh mesh.

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

* Twisted sweep: circles about a straight path are exact cylinders (solidcore#2184)

A twist about a straight path maps a profile circle centred on the path's
line onto itself, so its swept wall is exactly the cylinder. The skin
through the stations stayed within 3.9e-9 of it (6.6e-9 on the outer wall),
and a boolean with another body on that cylinder failed with status 1001
(solidcore#2184; the kernel's handling of a B-spline face lying on a
cylinder stays open there).

sweep_controlled now builds those walls exactly when the path is lines
along the profile's normal and there is no end scale or rails: a profile
whose every loop is such a circle is swept untwisted, and such holes in
another outer loop are cut as exact cylinders from the skinned sweep of
the rest. Other loops, and an axis-centred arc inside a loop of other
curves, are skinned as before; a failed cut falls back to skinning all.

Regression test in smlib_sweeps_tests.rs: #2184's tubes joined at 10, 16,
18, 20 and 30 degrees give one closed body of 11.5625 pi with cylinder
walls; a twisted hexagon with the bore keeps a cylinder bore.

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

* After the merge: a hole that misses is found by exact boxes; a face is a one-face patch; main's mesh-box helpers gone

- `hole_misses` (main's #10701, through brett's branch) decided from the targets' mesh boxes. It now uses their exact boxes (`Body::exact_bounds`). An unknown box still leaves the hole not known to miss.
- With #10303's two shortcuts and `hole_misses` off the mesh, `body_box`, `mesh_box` and `padded_box` have no caller left, and are deleted.
- `project`'s intersection link passes its one face to `meeting_edges` as a one-element patch. This branch picks one face, not #9009's mesh patch.
- xtask/mesh-decisions.txt drops the six sites that no longer use a mesh here: `apply_traced_with`, `cut_end_past_face`, `beyond_cylinder_reach`, `hole_misses`, `body_box`/`mesh_box`/`padded_box`.

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

* After the merge: split_move's Result reaches its callers; to_local's duplicated move check goes

`frames::split_move` returns a Result on this branch: an unplaceable model is an error, not a silent no-split.
- Move/Copy of a component (main's #9914) propagates it, as its other calls do.
- `to_local` had the solid.move check twice after the merge (brett's Option one and ours). Ours stays, after the no-picks/not-placed return.

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

* After the merge: main's two new tests take world_state's Result, as the rest of their files do

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

* Native B-rep: a ball patch is its own surface kind, read from and written to SMLib's flat kind 6

SMLib's flat arrays now carry the corner of three equal rounds as `SURFACE_BALL_PATCH` (kind 6): its net as a NURBS face's block, with its sphere's centre and radius as its definition (ids 36 and 166). The record reads it as `ball_patch {center, radius}`, a label like the analytic kinds, with the net as its `nurbs`. A plain sphere with a form won't do: the net is not a piece of SMLib's sphere NURBS, so SMLib's `IsNurbSurfaceSphere` would refuse it.

Written back, a ball patch is kind 6 with the same definition, which SMLib takes once its net is certified on that sphere. Elsewhere:
- Offsets classify it as its sphere, so its offsets stay exact and collapse at the centre.
- Face kinds count it as a sphere: fillet_three_edges_at_a_corner's corner is now `sphere`, no longer `other`.
- STEP, which has no ball-patch entity, gets the net as the face's B-spline surface, pcurves included.
- Iso curves and SSI of the label are refused; the face's form is what evaluates.

Test: a_rounded_cubes_ball_patches_go_round_with_their_spheres. The cube's eight corners are ball patches about the corners pulled in by r. Points of each patch lie on its sphere to 1e-12. The patches are kind 6 again after import, with the same definitions bit for bit, and the closed-form volume holds.

Format: pending/ball-patch-faces.rs. Docs: docs/native-brep.md and the rig README's `surface_types` (`ball_patch`).

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

* SMLib: pin v1.1.0-alpha (a18bbe5): the generic blend's step-4 corners, the Ball's sphere patch through the flat arrays, all-target CI

Since f916a8d:
- #120: every target again, plus the warnings fix;
- #121: the Ball's patch as flat kind 6 with its sphere, read by the previous commit;
- #122: tests gated on counts;
- #123: the Mixed corner;
- #124: the End at a smooth vertex;
- #125: four or more rounds, and open setbacks;
- #126: the equal-area thumbweight guard;
- #127: the trunk merge.

The SMLIB_PREBUILT checksums are from the release prebuilt-a18bbe5…, written by `scripts/fetch-smlib.sh --pin`.

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

* smlib export tests: rustfmt's line for the rounded cube's edge midpoints

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

* Kernel rig: the fillet and ladder results on SMLib v1.1.0-alpha, the new failures recorded with their reasons

Fixed (off the known-failing list): the Mixed corner's peg cases and the concave arc with the front top edge (smlib #123), and the 40° boss junction (smlib #124). Eight cases in all.

New failures, recorded as they are, each with its reason. They passed on f916a8d.
- **Six cases grade a blend seam G0 where G1 (or G2) is wanted,** at the end of the round's contact seam, after smlib #124's End at a smooth vertex. Two of them also shift in volume.
- **Four cases come back unsupported** after smlib #125, which leaves setbacks to the Setback's corners only.
- **Ladder 07/012** makes 9 faces, 20 edges and 13 vertices, where its golden had 8, 18 and 12. Its golden entry is the new result, with the reason added.

Whether each change is by design or a defect is being established. A defect is fixed in solidcore, where the End and the Setback are being brought over (#2364, #2365), not in the frozen SMLib.

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

* Kernel ladder: 07/021 and 06/018 recorded as they are on SMLib v1.1.0-alpha, each with its reason

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

* Kernel ladder: a golden case marked unstable is gated on its status, and 06/018 is marked

On SMLib v1.1.0-alpha, 06/018 makes 597 or 594 edges from the same input:
the union's post-process keeps pointers to edges it has deleted. A golden
entry's `unstable` reason makes the gate compare that case's status only
and name it in its output; recording keeps the reason, and only an edit of
the golden removes it. v1.1.0-alpha.1 fixes the cause and removes the mark.

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

* After the merge with main: its new code on develop's exact bounds, types and thread fields

- The sheet cut's cull (#10301) and the tapped wall's (#10227) use each
  body's exact box (`exact_box`), not its display mesh.
- model.threads replies with develop's thread fields: form, hand (right,
  left or both) and starts.
- surface.boundary's schema checks a network curve's continuity as the
  handler does, and words a list entry's error as it does.
- Main's new tests take develop's Hole fields and world_state's Result.
- mesh-decisions: contact_sweep's triangle check is now `depth` (main
  split `hits`).

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

* Known failing: the 14 cargo tests SMLib v1.1.0-alpha fails, with what each sees; two cases that pass now

Develop's CI after the a18bbe5 pin (run 41522001173) fails 14 tests that
passed on f916a8d, and passes two listed ones. Each new line says what the
test sees on v1.1.0-alpha; v1.1.0-alpha.1 fixes the setback hole's cause.
shell_tangent_chain_that_opens_every_face_explains_how_to_keep_walls and
oracle 37-pyramid-apex-blend pass and leave their lists.

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

* Cargo.lock: develop's profile, smlib and step crates at main's 0.30.1

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

* Kernel rig: main's six new sketch-only commands are exempt from a kernel case

sketch.construction_line (and its horizontal and vertical forms) and
sketch.spline.insert_point, remove_point and reverse change only the 2D
sketch.

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

* Cuts leave the bodies their tools can't reach, by exact boxes (#10225 on exact geometry)

- The per-target cut loop skips a body whose exact box lies clear of
  every tool's, as main's version did with mesh boxes. The sheet culls
  (#10226, #10301) and this one now leave the far sheets and bodies alone:
  a_cut_leaves_bodies_it_cannot_reach_as_they_were passes and leaves the
  known-failing list.
- a_cut_still_runs_where_its_tool_touches_grazes_or_meets_a_curved_face:
  a tool 0.5 µm clear of the block is now left out (exact boxes; 0.5 µm is
  far past the bodies' tolerances); touching and 0.1 mm in still cut.
- Known failing, with reasons: main's edge lookup by a stored point
  (#11286, on the display mesh) is not taken; SMLib refuses the
  wrong-way chamfer for its tangent chain first.
- EXEMPT: main's eight new sketch-only commands.

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

* Kernel rig: main's new view, rendering, appearance and settings commands are exempt from a case

62 of the commands main added change no geometry: the view and render
panes, rendering lights and results, appearance tools, settings profiles,
point clouds (drawn as points, never a body), the selection, two sketch
edits, component links and History bar visibility, edit.repeat,
timeline.play, inspect.compare_picture (a picture against the outline),
and component.paste_from_design, which pastes the app's cross-window
clipboard that no command fills. Each joins EXEMPT's first section under
its family's reason.

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

* Kernel rig: construct.plane.axis_point gets construction cases

The plane through a box edge and its far corner, graded against its
closed-form normal (0,-2,3)/sqrt(13) through the origin, and the refusal
of a point on the axis.

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

* Kernel rig: assembly cases for mass properties, align, find limits and moving items between parts

- assembly.mass_properties: two boxes (2 g/cm3) in a rigid group: mass
  0.006 kg, centre (18.333,5,5), inertia diag(0.1, 1.0333, 1.0333) kg mm2
  by the parallel-axis theorem.
- occurrence.align: a block's end face onto a base's top face: a quarter
  turn about y, centre (70,20,20), box (65,15,10)-(75,25,30).
- joint.find_limits: a slider between two stops: min -20, max 10 mm.
- component.move_features, browser.reparent and
  component.move_sketches.to_parts: items moved into a turned and moved
  part keep their world place (volumes, centres and boxes by hand).

The mass case assigns a material it creates, so material.create and
material.assign leave EXEMPT. PATHS names the new command modules.

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

* Kernel rig: export and print face cases in the import area

- file.export_step, export_stl, export_3mf, export_obj: a 30x20x10 box
  written and read back (the meshes converted to a solid): V 6000,
  A 2200, 6 faces, box (5,-10,0)-(35,10,10).
- file.export_urdf: a hinged arm on a grounded base: 3 links, 2 joints,
  and the base's mesh, written in metres, reads back as the
  1 x 0.5 x 0.2 box (V 0.1, A 1.6).
- body.print_face: a cylinder's flat end is accepted, its side refused.

PATHS adds the URDF writer and the print face modules.

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

* claim: #11259

* fix: preserve transferred profile frames when capturing positions

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: brett19 <brett19@gmail.com>
Co-authored-by: jyviko <jyviko@gmail.com>
2026-11-04 21:22:49 +00:00
Homero Thompson 1d387d3f21 Measure: the area of a curved face on the exact surface, not the display mesh (#9086) (#11486)
* Measure: the area of a curved face on the exact surface, not the display mesh (#9086)

* Measure: a non-straight border edge leaves the band to the mesh; fix the Vec3 imports (#9086)

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-11-04 21:22:24 +00:00
github-actions[bot] 51d237829a Regenerate generated files and fold pending files [skip ci] 2026-11-04 21:50:55 +00:00
bherbruck df4f9d3529 Inspect menu: Component Colors is a normal on/off item with a check mark (#11854)
* Inspect menu: Component Colors is a normal on/off item

The Component Colors entry in Inspect > Check was labelled "Enable Component Colors" and
swapped to "Disable Component Colors" with a different icon while on. It is now labelled
"Component Colors" in both states, keeps one icon, and shows a check mark when on, like Draft
Analysis and the other toggles beside it. The command id and parameters are unchanged.

The now-unused "disable" strings and the colored_components_off icon are removed. Other
languages' old "Enable ..." wording is dropped so they fall back to English until retranslated.

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

* Keep the Component Colors label in the fully translated catalogs

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

* Checkable menu rows tell screen readers whether they are on

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

* Georgian: Component Colors loses its Enable/Disable wording

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 20:12:36 +00:00
takuto-NA ef2f485e15 Extend viewport AO to the existing virtual ground (#3413) (#11979)
* claim: #3413

* feat(gpu): extend viewport AO to the virtual ground

* test(gpu): retain measured AO render attachments

* perf: initialize ground AO pipeline with concurrent jobs

* fix(gpu): size ground AO receiver for the current frame radius

* fix(viewport): retain orbit caches when ground AO visibility changes
2026-11-04 19:42:01 +00:00
github-actions[bot] a43cd99e55 Regenerate generated files and fold pending files [skip ci] 2026-11-04 21:11:47 +00:00
github-actions[bot] 8f26457487 Regenerate generated files and fold pending files [skip ci] 2026-11-04 21:01:46 +00:00
sascha2101 07ef94e102 Hole: drill only the shown bodies when no target is picked (#7520) (#7562)
* claim: #7520

* Hole: drill only the shown bodies when no target is picked (#7520)

A Hole that names no bodies drilled every body in its path, hidden ones
too, and its dialog preview replaced the hidden bodies with drilled ones.
Like a cut (#3961), it now takes the shown bodies of the active component
as its participants while some are hidden, stored so showing the others
later changes nothing. With nothing hidden, or everything, it still drills
every body.

Its face points are named on the bodies it drills first, so a hidden
body's face in the same plane no longer takes the picked face's name.

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

* Hole: drill the picked body though it is hidden (#7520)

Review on #7562: with the picked face's body hidden (picked through Select
Other, or hidden while the dialog is open), the shown-bodies default left it
out, so the hole drilled only shown bodies further along, or nothing, without
a message. The body whose face holds the hole's position now joins the shown
ones, unless a shown body has a face there too (a face shared by a shown and
a hidden body is the shown one's, as a pick in the view takes it).

The test also covers editing a hole made with nothing hidden while a body it
drills is hidden: timeline.redefine stores participants only when the edit
passes them, so the hole keeps its empty scope and the hidden body its bore.

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

* Hole: test that a hole made with a body hidden replays the same (#7520)

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

* Hole: follow the face-name lookup now returning a Result (#7520)

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

* Hole: tests follow main's Vec3 and world_state (#7520)

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

* Hole: format (#7520)

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

* Hole: with every body hidden, drill them all as before (#7520)

Co-Authored-By: Claude Sonnet 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>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-11-04 19:19:18 +00:00
semmelrinde 02f276e296 Report a bug: limit answers by characters and show the room left (#7191) (#7196)
* claim: #7191

* Report a bug: limit answers by characters and show the room left (#7191)

The answers were cut to 2,000 bytes, silently: 666 characters of
Chinese or Japanese. They now keep 2,000 characters, as the text field's
own limit counts them, in the form, in the report and in a crash draft.
Under 200 characters left, an answer says how many there are. The
report's budget grows so three full answers in any script still fit
beside the technical details.

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

* Report a bug: leave the crash draft's description as it was

It is the app's own English text, well under the limit in bytes as in
characters; leaving the line alone keeps it apart from #7189's change.

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

* Locales: Arabic and Hebrew for the new strings

ar and he are complete catalogs on main now (#3458): every new message
needs them. The pending-messages test compares right-to-left messages in
display order once #7532 lands (#7527).

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

* Locales: Vietnamese for the new strings

Vietnamese became a complete catalog on main (#5823), so every new English
message needs it too.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 19:15:23 +00:00
austin62jake 9602587b0d Navigation: a middle or right drag keeps the mode it started with (#9557) (#9592)
* Claim #9557

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

* Navigation: a middle or right drag keeps the mode it started with (#9557)

The mode of a middle or right drag was worked out again from the keys
held on every frame, so letting go of Shift partway through a Fusion
Shift+middle orbit turned the rest of the drag into a pan. It is now taken
when the drag starts and kept until its button is released, as a left
drag with the mapping's keys already was. Preferences > Navigation > "A
drag keeps the navigation it started with" (on) turns it off.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-04 19:14:40 +00:00
274 changed files with 13560 additions and 2841 deletions
+7 -5
View File
@@ -3,13 +3,13 @@
SolidCraft is a clean-room, pure-Rust parametric 3D CAD app (sketch solver, feature timeline, B-rep kernel, an egui UI that agents drive). This file is a router: the hard rules, then the skill to read for your task. Humans: CONTRIBUTING.md.
## Start here
Work: ROADMAP.md, "Where help is wanted most" in CONTRIBUTING.md, issues labelled `help wanted`, `good first issue`, `needs-triage`; architecture in `docs/architecture.md`. Act when the code, docs and rules settle a question; ask only when it changes what users see. Local `plan/` notes are optional.
Work: ROADMAP.md, "Where help is wanted most" in CONTRIBUTING.md, issues labelled `help wanted`, `good first issue`, `needs-triage`; architecture in `docs/architecture.md`. Local `plan/` notes are optional.
## Hard rules (what enforces each)
- Independent: never copy another product's code, icons, text or templates; never drive or take data from commercial CAD. `skills/solidcraft-ground-rules/SKILL.md`.
- No GPL/LGPL/AGPL code, not even reading it (OpenCASCADE, FreeCAD, SolveSpace, CadQuery, LibreCAD, QCAD): `cargo xtask licences --check`.
- Every asset is original or openly licensed with a row in ATTRIBUTION.md; read `assets/AGENTS.md` before icon work: `cargo xtask assets`, `cargo xtask icons`.
- Never crash: no unwrap/expect/panic/unsafe in non-test code (unsafe allowlist in crash-safety), errors are `Result`, input is hostile: clippy `-D warnings`, `hostile_params_never_panic`.
- Never crash: no unwrap/expect/panic/unsafe in non-test code, errors are `Result`, input is hostile: clippy `-D warnings`, `hostile_params_never_panic`.
- Model decisions (what an operation does, which bodies it touches, which face or edge a point or name means, depths, nearness) use exact geometry; a mesh is for display, export, previews and mesh bodies (`xtask/mesh-decisions.txt` only shrinks): `cargo xtask mesh-decisions`.
- New kernel cases go to solidcore: `cargo xtask lists`.
- Everything is a command and programmatic calls never open dialogs: `skills/solidcraft-commands/SKILL.md`.
@@ -21,12 +21,14 @@ Work: ROADMAP.md, "Where help is wanted most" in CONTRIBUTING.md, issues labelle
- A design-format change is ONE new file in `crates/doc/src/format/pending/`: `scripts/test-format-pending.sh`.
- A command has input and output schemas (will become required; `schema/unconverted.txt` only shrinks): `skills/solidcraft-commands/SKILL.md`.
- New UI strings go in `assets/locales/pending/<slug>.json`, ATTRIBUTION rows in `attribution/pending/<slug>.md`: `cargo xtask lists`.
- A changed default keeps the old behaviour behind a preference and fills the PR's `Changes a default:` line, and no second PR for an issue a lower-numbered one closes: `pr-checks.yml`.
- A changed default keeps the old behaviour behind a preference and fills the PR's `Changes a default:` line: `pr-checks.yml`.
- A regression test goes in the area's existing test file, never a per-bug file (#3226).
- Look at open PRs first, claim with a draft PR (lowest number wins), one topic per PR, never force-push `main`, no pushes just to catch up with `main`: `skills/solidcraft-contributing/SKILL.md`.
- Look at open PRs first, claim with a draft PR (lowest number wins), one topic per PR: `skills/solidcraft-contributing/SKILL.md`.
- A PR's title and first sentence make sense to someone who never saw your chat or the issue: `skills/solidcraft-contributing/SKILL.md`.
- Code, comments, commit messages and docs are in English, always: `skills/solidcraft-contributing/SKILL.md` ("Working as an agent").
- Independent work runs in parallel, dependent work stays sequential: `skills/solidcraft-contributing/SKILL.md` ("Working as an agent").
- Show observable changes with real screenshots or a recording, captured yourself.
- Run `cargo xtask ci` before every commit, without switching branches while it runs. `cargo xtask skills` keeps this file and `skills/` honest.
- Run `cargo xtask ci` before every commit. `cargo xtask skills` keeps this file and `skills/` honest.
## Working on X: read the skill first
| Working on | Read |
+4 -1
View File
@@ -317,7 +317,6 @@ Editable drawing code, native projects and shipped exports are collected under `
| assets/icons/ui/design/svg/model/circle_ttt.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/coil.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/colored_components.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/colored_components_off.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/combine.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/component.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
@@ -799,6 +798,10 @@ Original SolidCraft drawings, Open for owner review; no runtime assignments.
| docs/book/src/media/ui-tour/window.png | SolidCraft contributors | screenshot of SolidCraft 0.29.0 itself (`solidcraft --sample`, default dark theme, software renderer, 1280x800) with original numbered callouts (digits set in DejaVu Sans Bold), captured with examples/demo/doc_shots.py | MIT OR Apache-2.0 |
| docs/book/src/media/ui-tour/navigate.mp4 | SolidCraft contributors | original screen recording of the actual SolidCraft 0.29.0 English desktop UI (`--sample`, default dark theme, software renderer) orbiting, panning and zooming, with the original drawn pointer of examples/demo/overlay.py and labels set in DejaVu Sans Bold, captured with examples/demo/doc_shots.py | MIT OR Apache-2.0 |
| assets/locales/pending/sketch-tool-hints.json | carlymx | Original sketch tool hints and engine error translations | MIT OR Apache-2.0 |
| assets/locales/pending/primitive-pick-field-tips.json | SolidCraft contributors | Original primitive pick-field tooltip translations | MIT OR Apache-2.0 |
| assets/locales/pending/ribbon-panel-hover.json | SolidCraft contributors | Original ribbon panel menu preference labels and translations | MIT OR Apache-2.0 |
| docs/screenshots/box-insert-designer.gif | Shawn Marx | original recording made with BoxInsertDesigner's own Motion export (its three.js viewer) of its example game `examples/ccg-example` (https://github.com/ShawnMarx/BoxInsertDesigner): a card game box insert whose trays were built by SolidCraft through `solidcraft-cli`; the published card, board and box art in it is pixelated beyond recognition, and no logos or game names are shown (2.5× playback, 800 px wide) | MIT OR Apache-2.0 |
| docs/screenshots/box-insert-designer.mp4 | Shawn Marx | original recording made with BoxInsertDesigner's own Motion export (its three.js viewer) of its example game `examples/ccg-example` (https://github.com/ShawnMarx/BoxInsertDesigner): a card game box insert whose trays were built by SolidCraft through `solidcraft-cli`; the published card, board and box art in it is pixelated beyond recognition, and no logos or game names are shown (1280×720) | MIT OR Apache-2.0 |
## Code adapted from sibling projects
+3 -1
View File
@@ -88,6 +88,8 @@ The [architecture page](docs/architecture.md) has the layer table. The short ver
- `crates/geom`: vectors, planes, meshes, measures.
- `crates/sketch`: our own constraint solver, plus profile finding.
- `crates/dynamics`: multibody dynamics: rigid bodies, joint constraints, gravity and springs,
integrated in time (RFC #9719). It depends only on `geom`.
- `crates/kernel`: the B-rep kernel boundary. It wraps [truck](https://github.com/ricosjp/truck),
and it's the only crate allowed to touch truck. Fillets, chamfers, shell/offset, Delete Face,
Split Face, STEP/IGES read and write, and face provenance are our own code here. Patched
@@ -277,7 +279,7 @@ mean something else, it needs a format change. **Add one file, and edit nothing
change's file, or any `v<N>/` folder: those are fixed once released.
That's all. The build counts the files: while any are pending it writes the next format number,
which no release reads, so a release warns about a design that has your change and keeps what it can't read of it.
which no release reads, so a release refuses a design that has your change instead of losing it.
A file also lists the pending changes it has (`format_pending`), so a later development build
runs only the steps that file is missing. Two pull requests that each add a change merge without
a conflict. `crates/doc/src/format/pending/README.md` has an example.
Generated
+11
View File
@@ -4153,6 +4153,17 @@ dependencies = [
"web-time",
]
[[package]]
name = "solidcraft-dynamics"
version = "0.40.0-rc.2"
dependencies = [
"serde",
"serde_json",
"solidcore-record",
"solidcraft-geom",
"thiserror 2.0.21",
]
[[package]]
name = "solidcraft-engine"
version = "0.40.0-rc.2"
+1
View File
@@ -22,6 +22,7 @@ solidcraft-kernel = { path = "crates/kernel" }
solidcraft-smlib = { path = "crates/smlib" }
solidcraft-step = { path = "crates/step" }
solidcraft-render = { path = "crates/render" }
solidcraft-dynamics = { path = "crates/dynamics" }
solidcraft-gpu = { path = "crates/gpu" }
solidcraft-doc = { path = "crates/doc" }
solidcraft-io = { path = "crates/io" }
+2 -2
View File
@@ -11,7 +11,7 @@ use std::process::{Command, ExitCode};
const TARGET: &str = "solidcraft-cli";
fn main() -> ExitCode {
eprintln!("solvecraft-cli is deprecated and will be removed in a later release: use solidcraft-cli");
solidcraft_engine::note!("solvecraft-cli is deprecated and will be removed in a later release: use solidcraft-cli");
let sibling = std::env::current_exe()
.ok()
.and_then(|exe| exe.parent().map(|dir| dir.join(format!("{TARGET}{}", std::env::consts::EXE_SUFFIX))))
@@ -23,7 +23,7 @@ fn main() -> ExitCode {
None => ExitCode::FAILURE,
},
Err(e) => {
eprintln!("solvecraft-cli: could not run {}: {e}", program.to_string_lossy());
solidcraft_engine::note!("solvecraft-cli: could not run {}: {e}", program.to_string_lossy());
ExitCode::FAILURE
}
}
+9 -6
View File
@@ -33,8 +33,8 @@ pub fn one(args: &[String]) -> Result<(), String> {
std::panic::set_hook(Box::new(|_| {
PANICS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
}));
eprintln!("design {seed}");
let run = fuzz::run_with(seed, steps, &mut |st| eprintln!("step {}", serde_json::to_string(st).unwrap_or_default()));
solidcraft_engine::note!("design {seed}");
let run = fuzz::run_with(seed, steps, &mut |st| solidcraft_engine::note!("step {}", serde_json::to_string(st).unwrap_or_default()));
let mut design = json!({
"outcome": run.outcome,
"steps": run.steps.iter().map(|s| s.kind).collect::<Vec<_>>(),
@@ -45,13 +45,13 @@ pub fn one(args: &[String]) -> Result<(), String> {
if run.outcome.is_bug() {
design["script"] = run.script();
if minimise {
eprintln!("minimise");
solidcraft_engine::note!("minimise");
let m = fuzz::minimise(&run);
design["minimal"] = json!({"outcome": m.outcome, "steps": m.steps.iter().map(|s| s.kind).collect::<Vec<_>>(), "script": m.script()});
}
}
let (a, b) = solidcraft_engine::fuzz::boolean_shapes(seed);
eprintln!("boolean {seed} {} {}", serde_json::to_string(&a).unwrap_or_default(), serde_json::to_string(&b).unwrap_or_default());
solidcraft_engine::note!("boolean {seed} {} {}", serde_json::to_string(&a).unwrap_or_default(), serde_json::to_string(&b).unwrap_or_default());
let b = boolean_case(seed);
let bp = PANICS.swap(0, std::sync::atomic::Ordering::SeqCst);
println!("{}", json!({"seed": seed, "design": design, "boolean": b, "boolean_panics": bp}));
@@ -162,11 +162,14 @@ fn record(v: &Value, out_dir: Option<&str>) {
let d = &v["design"];
let bug = matches!(d["outcome"]["kind"].as_str(), Some("failed" | "panic" | "invalid" | "volume"));
if bug || v.get("timeout").is_some() || v.get("crash").is_some() {
eprintln!("seed {seed}: {}", if bug { format!("{} {}", d["outcome"]["kind"], d["outcome"]["detail"]) } else { format!("{v}") });
solidcraft_engine::note!(
"seed {seed}: {}",
if bug { format!("{} {}", d["outcome"]["kind"], d["outcome"]["detail"]) } else { format!("{v}") }
);
}
let bk = v["boolean"]["outcome"].as_str().unwrap_or("ok");
if bk != "ok" {
eprintln!("seed {seed}: boolean {bk} {}", v["boolean"]["detail"]);
solidcraft_engine::note!("seed {seed}: boolean {bk} {}", v["boolean"]["detail"]);
}
if let Some(dir) = out_dir {
use std::io::Write;
+12 -29
View File
@@ -154,7 +154,7 @@ fn main() -> ExitCode {
}
};
if let Err(e) = max_file_size(&mut args) {
eprintln!("error: {e}");
solidcraft_engine::note!("error: {e}");
return ExitCode::FAILURE;
}
let r = match args.first().map(String::as_str) {
@@ -235,7 +235,7 @@ fn main() -> ExitCode {
match r {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e}");
solidcraft_engine::note!("error: {e}");
ExitCode::FAILURE
}
}
@@ -318,19 +318,6 @@ fn load_checked(path: &str, check: solidcraft_engine::schema::ParamCheck) -> Res
/// `load_checked`, the features computed under `validation` (`diagnostics.validation`), which
/// the session keeps.
fn load_with(path: &str, check: solidcraft_engine::schema::ParamCheck, validation: solidcraft_engine::kernel::Validation) -> Result<Session, String> {
let s = read_session(path, check, validation)?;
// A newer SolidCraft's file opens all the same; stderr says so, stdout stays the caller's.
for warning in s.doc.newer_file.iter().flat_map(|file| file.warnings()) {
eprintln!("{path}: warning: {warning}");
}
Ok(s)
}
fn read_session(
path: &str,
check: solidcraft_engine::schema::ParamCheck,
validation: solidcraft_engine::kernel::Validation,
) -> Result<Session, String> {
let mut s = Session::default();
s.param_check = check;
s.set_validation(validation);
@@ -364,7 +351,7 @@ fn run_script_warning(s: &mut Session, path: &str, script: &Value) -> Result<Vec
/// Step `step`'s `param_warnings` on stderr, one per line, named after the script at `path`.
fn print_param_warnings(path: &str, step: usize, r: &Value) {
for w in r.get("param_warnings").and_then(Value::as_array).into_iter().flatten() {
eprintln!("{path}: step {}: {}", step + 1, w.as_str().unwrap_or_default());
solidcraft_engine::note!("{path}: step {}: {}", step + 1, w.as_str().unwrap_or_default());
}
}
@@ -592,7 +579,7 @@ fn cmd_eval(args: &[String]) -> Result<(), String> {
let mut s = open(flag(args, "--in"), positional(args), solidcraft_engine::schema::ParamCheck::Off)?;
let m = s.execute("inspect.measure", &json!({})).map_err(|e| e.to_string())?;
let d = s.execute("document.inspect", &json!({})).map_err(|e| e.to_string())?;
let mut out = json!({
let out = json!({
"body_count": m["body_count"],
"bodies": m["bodies"],
"total": m["total"],
@@ -600,10 +587,6 @@ fn cmd_eval(args: &[String]) -> Result<(), String> {
"params": d["params"],
"sketches": d["sketches"],
});
if let Some(file) = &s.doc.newer_file {
out["newer_file"] = file.report();
out["warnings"] = json!(file.warnings());
}
print_json(&out, json_out)
}
@@ -615,10 +598,10 @@ fn cmd_script(args: &[String]) -> Result<(), String> {
let out = flag(args, "--out");
let r = s.execute("timeline.export_script", &json!({"path": out})).map_err(|e| e.to_string())?;
for what in r["omitted"].as_array().into_iter().flatten().filter_map(Value::as_str) {
eprintln!("note: the script leaves out {what}");
solidcraft_engine::note!("note: the script leaves out {what}");
}
match out {
Some(out) => eprintln!("{out}: {} steps", r["steps"]),
Some(out) => solidcraft_engine::note!("{out}: {} steps", r["steps"]),
None => println!("{}", pretty(&r["script"])),
}
Ok(())
@@ -649,7 +632,7 @@ fn cmd_gpu(args: &[String]) -> Result<(), String> {
);
match s.lands_on {
Some(solidcraft_gpu::select::Landing::Cpu) => {
eprintln!("warning: {}", solidcraft_engine::capture::CPU_WARNING);
solidcraft_engine::note!("warning: {}", solidcraft_engine::capture::CPU_WARNING);
Ok(())
}
Some(_) => Ok(()),
@@ -726,7 +709,7 @@ fn cmd_snapshot(args: &[String]) -> Result<(), String> {
std::fs::write(out, &png.bytes).map_err(|e| format!("{out}: {e}"))?;
let mut reply = json!({"path": out, "width": w, "height": h, "bytes": png.bytes.len(), "renderer": png.renderer});
if let Some(w) = solidcraft_engine::capture::warning(png.renderer) {
eprintln!("warning: {w}");
solidcraft_engine::note!("warning: {w}");
reply["warning"] = json!(w);
}
println!("{}", pretty(&reply));
@@ -813,7 +796,7 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> {
.then(|| solidcraft_mcp::agent_scope(flags(args, "--allow-path").into_iter().map(std::path::PathBuf::from)));
if let Some(s) = &scope {
let roots: Vec<String> = s.roots().iter().map(|r| r.display().to_string()).collect();
eprintln!(
solidcraft_engine::note!(
"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(", ")
);
@@ -821,7 +804,7 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> {
.and_then(|p| std::path::Path::new(p).parent())
.map(|d| if d.as_os_str().is_empty() { std::path::Path::new(".") } else { d });
if let Some(why) = design_dir.and_then(solidcraft_engine::io::vfs::design_folder_refusal) {
eprintln!("solidcraft mcp: the design's folder is left out because {why}; add it with --allow-path if you mean it");
solidcraft_engine::note!("solidcraft mcp: the design's folder is left out because {why}; add it with --allow-path if you mean it");
}
}
// How calls that break a command's parameter contract are treated, headless or bridged.
@@ -835,7 +818,7 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> {
// Serve without the app too: the tools connect once it runs, and say it isn't until then.
let mut remote = solidcraft_mcp::Remote::new(addr, flag(args, "--token")).with_param_check(param_check);
if let Err(e) = remote.try_connect() {
eprintln!(
solidcraft_engine::note!(
"solidcraft mcp: no SolidCraft app at {addr} yet ({e}); start it with `solidcraft --control {port}` \
(or SOLIDCRAFT_CONTROL_PORT={port}), tool calls connect once it runs"
);
@@ -852,7 +835,7 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> {
(None, None) => {
let auto = !args.iter().any(|a| a == "--headless");
if auto && let Some(port) = discover_app() {
eprintln!("solidcraft mcp: bridged to the running app at 127.0.0.1:{port} (--headless starts a session of its own)");
solidcraft_engine::note!("solidcraft mcp: bridged to the running app at 127.0.0.1:{port} (--headless starts a session of its own)");
let remote = solidcraft_mcp::Remote::new(&format!("127.0.0.1:{port}"), flag(args, "--token")).with_param_check(param_check);
return serve_mcp(Box::new(remote), scope);
}
+23
View File
@@ -188,3 +188,26 @@ fn max_file_size_raises_the_limit_and_refuses_bad_sizes() {
assert!(!output.status.success() && !stderr.contains("too large"), "{stderr}");
let _ = std::fs::remove_dir_all(&dir);
}
/// #10111: a line for a stderr whose reader has gone (the pipe closed before it, as an agent may
/// leave the MCP server's, and the app that one starts) is dropped, and the command ends as it
/// would with stderr open: here a script that isn't there fails with 1, where `eprintln!`
/// panicked with "failed printing to stderr" (101).
#[test]
fn a_closed_stderr_does_not_stop_the_cli() {
for _ in 0..5 {
let mut child = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
.args(["run", "no-such-script.json"])
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.unwrap();
drop(child.stderr.take());
assert_eq!(child.wait().unwrap().code(), Some(1));
}
// The old name's alias writes its deprecation notice first: an agent still configured with
// `solvecraft-cli mcp` must get the server, not that panic.
let mut child = Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).arg("--help").stdout(Stdio::null()).stderr(Stdio::piped()).spawn().unwrap();
drop(child.stderr.take());
assert_eq!(child.wait().unwrap().code(), Some(0));
}
+19 -51
View File
@@ -159,7 +159,7 @@ fn cli_loads_both_design_encodings_by_contents() {
}
/// #1369: a pending concept that did not land in the numbered release must not be silently
/// dropped on open/resave, in either encoding: it opens with a warning, and saving keeps it.
/// dropped on open/resave, in either encoding or before replacing an existing destination.
#[test]
fn unknown_pending_concepts_preserve_the_open_design_and_output_files() {
use solidcraft_engine::{Session, doc, io};
@@ -173,29 +173,34 @@ fn unknown_pending_concepts_preserve_the_open_design_and_output_files() {
let source = dir.0.join(format!("experimental-{version}.{extension}"));
std::fs::write(&source, &bytes).unwrap();
let mut session = Session::default();
// It opens, and the reply says what it is.
let reply = session.execute("doc.open", &json!({"path": source})).unwrap();
let notice = reply["newer_file"]["notice"].as_str().unwrap().to_string();
assert!(notice.contains("experimental-card") && notice.starts_with("Saved by a newer SolidCraft"), "{notice}");
assert_eq!(reply["warnings"][0], notice.as_str());
assert_eq!(session.doc.name, "Experimental");
session.execute("solid.box", &json!({"length": 2, "width": 3, "height": 4})).unwrap();
let original = session.doc.clone();
let original_path = session.path.clone();
let undo = session.undo.len();
let dirty = session.is_dirty();
let err = session.execute("doc.open", &json!({"path": source})).unwrap_err().to_string();
assert!(err.contains("experimental-card") && err.contains("update SolidCraft"), "{err}");
assert_eq!(session.doc, original);
assert_eq!(session.path, original_path);
assert_eq!(session.undo.len(), undo);
assert_eq!(session.is_dirty(), dirty);
for existing in [false, true] {
let destination = dir.0.join(format!("output-{version}-{extension}-{existing}.{extension}"));
let backup = io::backup_path(&destination);
if existing {
std::fs::write(&destination, b"existing design").unwrap();
std::fs::write(&backup, b"existing backup").unwrap();
}
let output = cli().args(["run", "--in"]).arg(&source).arg("--save").arg(&destination).output().unwrap();
assert_eq!(output.status.code(), Some(1));
let error = String::from_utf8_lossy(&output.stderr);
assert_eq!(output.status.code(), Some(0), "{error}");
assert!(error.contains("warning: Saved by a newer SolidCraft") && error.contains("experimental-card"), "{error}");
// Saved, it still says it has the change, and opens the same way.
let saved = io::read_design(&std::fs::read(&destination).unwrap()).unwrap();
assert!(saved.format_pending.iter().any(|name| name == "experimental-card"));
assert!(saved.newer_file.is_some());
assert!(error.contains("experimental-card") && error.contains("update SolidCraft"), "{error}");
if existing {
assert_eq!(std::fs::read(&backup).unwrap(), b"existing design");
assert_eq!(std::fs::read(&destination).unwrap(), b"existing design");
assert_eq!(std::fs::read(&backup).unwrap(), b"existing backup");
} else {
assert!(!destination.exists() && !backup.exists());
}
assert_eq!(std::fs::read(&source).unwrap(), bytes);
}
@@ -203,43 +208,6 @@ fn unknown_pending_concepts_preserve_the_open_design_and_output_files() {
}
}
/// `eval` and `run` on a design one format newer, with a feature kind this version doesn't have:
/// the rest builds, the reply names it, and the saved file still holds it, under the file's number.
#[test]
fn the_cli_opens_a_newer_design_and_saving_keeps_what_it_cant_read() {
use solidcraft_engine::{Session, doc, io};
let dir = Scratch::new();
let mut session = Session::default();
session.execute("solid.box", &json!({"length": 2, "width": 3, "height": 4})).unwrap();
let mut file: Value = serde_json::from_slice(&io::write_design(&session.doc)).unwrap();
let newer = doc::format::format_string(doc::format::FORMAT + 1);
file["format"] = json!(newer);
let future = json!({"id": 900, "name": "Lattice1", "type": "gyroid_lattice", "cell": 2.5});
file["features"].as_array_mut().unwrap().push(future.clone());
let source = dir.0.join("newer.json");
std::fs::write(&source, file.to_string()).unwrap();
let output = cli().args(["eval"]).arg(&source).output().unwrap();
let error = String::from_utf8_lossy(&output.stderr);
assert_eq!(output.status.code(), Some(0), "{error}");
assert!(error.contains("warning: Saved by a newer SolidCraft") && error.contains("Lattice1"), "{error}");
let reply: Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(reply["body_count"], 1);
assert!(reply["newer_file"]["not_understood"][0].as_str().unwrap().contains("gyroid_lattice"), "{reply}");
assert!(reply["warnings"][0].as_str().unwrap().starts_with("Saved by a newer SolidCraft"), "{reply}");
for extension in ["json", "sc"] {
let destination = dir.0.join(format!("saved.{extension}"));
let output = cli().args(["run", "--in"]).arg(&source).arg("--save").arg(&destination).output().unwrap();
assert_eq!(output.status.code(), Some(0), "{}", String::from_utf8_lossy(&output.stderr));
let saved = io::read_design(&std::fs::read(&destination).unwrap()).unwrap();
assert_eq!(saved.format, newer, "never a lower number");
let again: Value = serde_json::from_slice(&io::write_design(&saved)).unwrap();
assert_eq!(again["features"].as_array().unwrap().last().unwrap(), &future);
}
}
#[test]
fn known_pending_concepts_and_released_files_round_trip_with_inert_metadata() {
use solidcraft_engine::{doc, io};
+1 -1
View File
@@ -25,6 +25,7 @@ solidcraft-engine = { workspace = true }
solidcraft-ui-egui = { workspace = true }
egui = { workspace = true }
log = { workspace = true }
serde_json = { workspace = true }
url = "2"
percent-encoding = "2"
@@ -33,7 +34,6 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
serde_json = { workspace = true }
web-sys = { version = "0.3", features = [
"Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "Navigator", "StorageManager",
"FileSystemHandle", "FileSystemDirectoryHandle", "FileSystemFileHandle", "FileSystemGetFileOptions", "FileSystemGetDirectoryOptions", "FileSystemWritableFileStream",
+4 -1
View File
@@ -553,7 +553,7 @@ mod web {
/// Open a design remembered from an earlier session (a recent one, after a reload): ask once
/// for access to the file (call in the click), read it into the engine's files and queue it
/// to open. A file that is gone is forgotten and dropped from the recent designs.
pub fn restore(files: &Rc<Files>, path: &str) {
pub fn restore(files: &Rc<Files>, path: &str, on_opened: Option<Box<dyn FnOnce()>>) {
let Some((id, name)) = parse(path) else { return };
let (files, path, id, name) = (files.clone(), path.to_string(), id.to_string(), name.to_string());
spawn_local(async move {
@@ -568,6 +568,9 @@ mod web {
files.opened.borrow_mut().push(path);
files.wake();
touch(&id).await;
if let Some(done) = on_opened {
done();
}
}
Err(e) => {
let gone = e.contains("NotFoundError") || e.contains("could not be found") || e.contains("no longer remembers");
+175 -5
View File
@@ -5,7 +5,8 @@
//! and go out as downloads (exports, and "Download design"). Where the File System Access API
//! exists a design also lives on the computer's disk, saved and opened through the browser's file
//! dialogs (`disk.rs`). An autosave of unsaved work is kept in OPFS and offered again after a
//! reload or a crash.
//! reload or a crash. With "keep unsaved work in this browser" on, each open design's unsaved
//! changes are kept there instead, one copy per design (`keep.rs`, #10220).
use std::cell::{Cell, RefCell};
use std::rc::Rc;
@@ -13,7 +14,7 @@ use std::rc::Rc;
use js_sys::{Array, Function, Object, Reflect, Uint8Array};
use solidcraft_engine::io::vfs;
use crate::disk;
use crate::{disk, keep};
use wasm_bindgen::{JsCast as _, JsValue, closure::Closure};
use wasm_bindgen_futures::{JsFuture, spawn_local};
use web_sys::{
@@ -79,6 +80,16 @@ pub struct Files {
pub recovery: RefCell<Option<(String, Option<String>)>>,
/// The app's context, to wake it when a browser call finishes.
pub ctx: RefCell<Option<egui::Context>>,
/// This page's id: the copies it keeps are named after it (`keep::entry_id`).
pub keep_page: RefCell<String>,
/// Kept copies stored by this page, and the design revision each holds once its write ended.
pub kept: RefCell<std::collections::HashMap<String, Option<u64>>>,
/// Kept copies being written or removed now (one browser call at a time per copy).
pub keeping: RefCell<std::collections::HashSet<String>>,
/// When (ms) the last write of a copy failed, to wait before trying again (`keep::may_write`).
pub kept_failed: RefCell<std::collections::HashMap<String, f64>>,
/// Copies earlier pages left, newer than their design's last save: offered at start.
pub keep_offered: RefCell<Vec<keep::Entry>>,
}
impl Files {
@@ -288,6 +299,9 @@ pub fn start(files: &Rc<Files>) {
let _ = JsFuture::from(p).await;
});
}
let page = keep::page_id(js_sys::Date::now(), js_sys::Math::random());
hold_page_lock(&page);
*files.keep_page.borrow_mut() = page;
let files = files.clone();
spawn_local(async move {
match disk::known_ids().await {
@@ -300,6 +314,7 @@ pub fn start(files: &Rc<Files>) {
files.say("This page can't use the browser's file storage (it needs https or localhost): designs are kept in local storage, a few MB at most.", false);
}
let mut entries = Vec::new();
let kept = kept_copies(&all);
let has_recovery = all.iter().any(|(n, _, _)| n == RECOVERY);
let legacy = (!has_recovery).then(|| LEGACY_RECOVERY.into_iter().find(|l| all.iter().any(|(n, _, _)| n == l))).flatten();
for (name, bytes, modified) in all {
@@ -315,7 +330,7 @@ pub fn start(files: &Rc<Files>) {
*files.recovery.borrow_mut() = Some((n, meta["path"].as_str().map(str::to_string)));
continue;
}
if name != RECOVERY {
if name != RECOVERY && keep::parse_name(&name).is_none() {
entries.push(Entry { name: name.clone(), size: bytes.len() as u64, modified });
}
vfs::insert(&format!("{OPFS}{name}"), bytes);
@@ -326,6 +341,7 @@ pub fn start(files: &Rc<Files>) {
if !vfs::exists(&format!("{OPFS}{RECOVERY}")) {
*files.recovery.borrow_mut() = None;
}
sort_kept(&files, kept, &live_pages().await);
}
Err(e) => files.say(format!("browser storage: {e}"), true),
}
@@ -343,7 +359,7 @@ pub fn flush(files: &Rc<Files>) {
{
let mut e = files.entries.borrow_mut();
e.retain(|x| x.name != name);
if name != RECOVERY && name != RECOVERY_META {
if name != RECOVERY && name != RECOVERY_META && keep::parse_name(&name).is_none() {
e.push(Entry { name: name.clone(), size: bytes.len() as u64, modified: js_sys::Date::now() });
e.sort_by_key(|a| a.name.to_lowercase());
}
@@ -473,7 +489,15 @@ pub fn recovery_path() -> String {
/// A recovery file lives in OPFS, but its relative links belong to the original design's
/// folder (which can be on disk). Resolve them before Open sees the temporary file location.
pub fn prepare_recovery(original_path: Option<&str>) -> Result<String, String> {
let path = recovery_path();
prepare_copy(recovery_path(), original_path)
}
/// The same for a kept copy (#10220).
pub fn prepare_kept(id: &str, original_path: Option<&str>) -> Result<String, String> {
prepare_copy(format!("{OPFS}{}", keep::design_file(id)), original_path)
}
fn prepare_copy(path: String, original_path: Option<&str>) -> Result<String, String> {
let bytes = vfs::read(&path, solidcraft_engine::io::MAX_DESIGN_BYTES).map_err(|e| e.to_string())?;
let mut doc = solidcraft_engine::io::read_design(&bytes).map_err(|e| e.to_string())?;
solidcraft_engine::resolve_design_links(&mut doc, original_path);
@@ -646,3 +670,149 @@ pub fn take_drops(files: &Rc<Files>, raw: &mut egui::RawInput, theme: bool) {
});
}
}
/// Hold this page's browser lock while it runs, so other tabs know its kept copies are in use
/// (`keep.rs`). Browsers without the Web Locks API hold none; their copies are then offered by
/// any later page.
fn hold_page_lock(page: &str) {
let Some(locks) = web_locks() else { return };
let Ok(request) = Reflect::get(&locks, &"request".into()).and_then(|f| f.dyn_into::<Function>().map_err(JsValue::from)) else { return };
// A promise that never settles: the lock is let go only when the page goes.
let hold = Closure::<dyn FnMut(JsValue) -> js_sys::Promise>::new(|_lock: JsValue| js_sys::Promise::new(&mut |_, _| {}));
let name = format!("{}{page}", keep::LOCK_PREFIX);
if let Err(e) = request.call2(&locks, &name.into(), hold.as_ref().unchecked_ref()) {
log::info!("keep: no page lock: {}", err(e));
}
// The callback lives as long as the page.
hold.forget();
}
fn web_locks() -> Option<JsValue> {
let nav = web_sys::window()?.navigator();
Reflect::get(&nav, &"locks".into()).ok().filter(|l| l.is_object())
}
/// The pages still open (in this browser profile) that hold their lock.
async fn live_pages() -> Vec<String> {
let query = async {
let locks = web_locks().ok_or("no Web Locks")?;
let f: Function = Reflect::get(&locks, &"query".into()).map_err(err)?.dyn_into().map_err(|_| "no query".to_string())?;
let p: js_sys::Promise = f.call0(&locks).map_err(err)?.dyn_into().map_err(|_| "no promise".to_string())?;
let state = JsFuture::from(p).await.map_err(err)?;
let held: Array = Reflect::get(&state, &"held".into()).map_err(err)?.dyn_into().map_err(|_| "no list".to_string())?;
Ok::<_, String>(
held.iter()
.take(10_000)
.filter_map(|l| Reflect::get(&l, &"name".into()).ok()?.as_string())
.filter_map(|n| n.strip_prefix(keep::LOCK_PREFIX).map(str::to_string))
.collect(),
)
};
query.await.unwrap_or_else(|e| {
log::info!("keep: open pages unknown ({e})");
Vec::new()
})
}
/// The kept copies among the stored files: (copy, when its design's file was last written), and
/// the ids of designs stored without their metadata.
struct Found {
copies: Vec<(keep::Entry, Option<f64>)>,
orphans: Vec<String>,
}
fn kept_copies(all: &[(String, Vec<u8>, f64)]) -> Found {
let modified = |path: &str| path.strip_prefix(OPFS).and_then(|n| all.iter().find(|(name, _, _)| name == n)).map(|(_, _, m)| *m);
let mut copies = Vec::new();
let mut designs = Vec::new();
for (name, bytes, _) in all {
match keep::parse_name(name) {
Some((id, keep::Part::Meta)) => {
if let Some(e) = keep::Entry::parse(id, bytes) {
let saved = e.path.as_deref().and_then(modified);
copies.push((e, saved));
}
}
Some((id, keep::Part::Design)) => designs.push(id.to_string()),
None => {}
}
}
// A copy is whole only with its design.
copies.retain(|(e, _)| designs.contains(&e.id));
let orphans = designs.into_iter().filter(|id| !copies.iter().any(|(e, _)| &e.id == id)).collect();
Found { copies, orphans }
}
/// Offer the copies earlier pages left that hold unsaved work, and remove those their design's
/// later save made stale (and parts without the rest), leaving open pages' copies alone.
fn sort_kept(files: &Rc<Files>, found: Found, live: &[String]) {
let mut offered = Vec::new();
let mut gone = Vec::new();
for (e, saved) in found.copies {
match keep::fate(&e, live, saved) {
keep::Fate::Offer => offered.push(e),
keep::Fate::Drop => gone.push(e.id),
keep::Fate::Leave => {}
}
}
gone.extend(found.orphans.into_iter().filter(|id| !keep::page_of(id).is_some_and(|p| live.iter().any(|l| l == p))));
keep::order_offered(&mut offered);
*files.keep_offered.borrow_mut() = offered;
for id in gone {
remove_kept(files, &id);
}
}
/// The path (in the engine's files) a kept copy opens from.
pub fn kept_path(id: &str) -> String {
format!("{OPFS}{}", keep::design_file(id))
}
/// Write the copy of a design with unsaved changes (`bytes`, described by `entry`) that holds
/// its revision `revision`. Its metadata goes last, so a copy is never offered half written.
pub fn write_kept(files: &Rc<Files>, entry: &keep::Entry, bytes: Vec<u8>, revision: u64) {
let id = entry.id.clone();
if !files.keeping.borrow_mut().insert(id.clone()) {
return;
}
files.kept.borrow_mut().entry(id.clone()).or_insert(None);
let meta = entry.to_json();
let files = files.clone();
spawn_local(async move {
let r = async {
write_file(&keep::design_file(&id), &bytes).await?;
write_file(&keep::meta_file(&id), meta.as_bytes()).await
};
match r.await {
Ok(()) => {
files.kept.borrow_mut().insert(id.clone(), Some(revision));
files.kept_failed.borrow_mut().remove(&id);
}
Err(e) => {
files.kept_failed.borrow_mut().insert(id.clone(), js_sys::Date::now());
files.say(format!("keeping unsaved work in this browser: {e}"), true)
}
}
files.keeping.borrow_mut().remove(&id);
files.wake();
});
}
/// Remove kept copy `id` (its design was saved, closed, discarded or restored).
pub fn remove_kept(files: &Rc<Files>, id: &str) {
let id = id.to_string();
if !files.keeping.borrow_mut().insert(id.clone()) {
return;
}
let _ = vfs::remove(&kept_path(&id));
files.kept_failed.borrow_mut().remove(&id);
let files = files.clone();
spawn_local(async move {
// The metadata first: what's left without it is never offered.
let _ = remove_file(&keep::meta_file(&id)).await;
let _ = remove_file(&keep::design_file(&id)).await;
files.kept.borrow_mut().remove(&id);
files.keeping.borrow_mut().remove(&id);
files.wake();
});
}
+360
View File
@@ -0,0 +1,360 @@
//! Keep unsaved work in this browser (#10220): the decisions, in plain Rust (tested natively).
//!
//! With Preferences › General › "Keep unsaved work in this browser instead of asking when the tab
//! closes" on, each open design with unsaved changes has a copy in the browser's private storage:
//! `keep-<id>.sc` (the design) and `keep-<id>.json` (its name, its file and when it last
//! changed). The copy is written shortly after each change ([`due`]) and again when the page is
//! hidden, so it is already current when the tab closes; the page then doesn't ask through the
//! browser's "leave site?" box ([`asks_before_unload`]). A design that is saved, closed or
//! discarded has its copy removed. The next visit offers the copies newer than their design's
//! last save ([`fate`]) in SolidCraft's own window, answered with [`plan`].
//!
//! An id is `<page>-<n>`: the page that wrote it (each page load has its own) and the design's
//! number in that page. A page holds a browser lock named after it while it runs, so another tab
//! never offers, or removes, the copies of a page that is still open.
#![cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
use serde_json::{Value, json};
/// The start of every kept file's name.
pub const PREFIX: &str = "keep-";
/// The browser lock a page holds while it runs: this, then its page id.
pub const LOCK_PREFIX: &str = "solidcraft.keep.";
/// A copy is written once the design has been still this long (ms)…
pub const QUIET_MS: f64 = 800.0;
/// …or at the latest this long after the first change it doesn't hold yet (ms).
pub const MAX_WAIT_MS: f64 = 4000.0;
/// The most copies offered at once (the rest wait for the next visit).
pub const MAX_OFFERED: usize = 32;
const MAX_ID: usize = 64;
const MAX_NAME: usize = 256;
const MAX_PATH: usize = 4096;
/// Copies' metadata is small: a damaged or hostile one is not read.
pub const MAX_META_BYTES: usize = 64 * 1024;
/// Ids are short and safe in a file name: ASCII letters, digits and dashes, with a page part.
pub fn valid_id(id: &str) -> bool {
!id.is_empty() && id.len() <= MAX_ID && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') && page_of(id).is_some()
}
/// The page that wrote copy `id`.
pub fn page_of(id: &str) -> Option<&str> {
id.rsplit_once('-').map(|(page, _)| page).filter(|p| !p.is_empty())
}
/// This page's id, from the time it started (ms since 1970) and a random number in 0..1:
/// letters and digits only.
pub fn page_id(now_ms: f64, random: f64) -> String {
let now = if now_ms.is_finite() && now_ms > 0.0 { now_ms.min(1e15) as u64 } else { 0 };
let random = if random.is_finite() { (random.clamp(0.0, 1.0) * f64::from(0xff_ffff)) as u64 } else { 0 };
format!("{now:x}{random:06x}")
}
/// The copy of design `uid` (`Session::uid`) open in page `page`.
pub fn entry_id(page: &str, uid: u64) -> String {
format!("{page}-{uid}")
}
/// The stored file names of copy `id`: its design and its metadata.
pub fn design_file(id: &str) -> String {
format!("{PREFIX}{id}.{}", solidcraft_engine::io::DESIGN_EXT)
}
pub fn meta_file(id: &str) -> String {
format!("{PREFIX}{id}.json")
}
/// Which part of a copy a stored file is.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Part {
Design,
Meta,
}
/// A stored file's copy id and part; `None` for every other file.
pub fn parse_name(name: &str) -> Option<(&str, Part)> {
let rest = name.strip_prefix(PREFIX)?;
let (id, part) = if let Some(id) = rest.strip_suffix(".json") {
(id, Part::Meta)
} else {
(rest.strip_suffix(solidcraft_engine::io::DESIGN_EXT)?.strip_suffix('.')?, Part::Design)
};
valid_id(id).then_some((id, part))
}
/// A kept copy, as its metadata describes it.
#[derive(Clone, Debug, PartialEq)]
pub struct Entry {
pub id: String,
/// The design's name.
pub name: String,
/// Its file (`None`: never saved).
pub path: Option<String>,
/// When the design last changed (ms since 1970).
pub changed_at: f64,
}
impl Entry {
pub fn to_json(&self) -> String {
json!({"name": self.name, "path": self.path, "changed_at": self.changed_at}).to_string()
}
/// Read copy `id`'s metadata. Anything malformed, oversized or not finite is no copy.
pub fn parse(id: &str, bytes: &[u8]) -> Option<Entry> {
if !valid_id(id) || bytes.len() > MAX_META_BYTES {
return None;
}
let v: Value = serde_json::from_slice(bytes).ok()?;
let name = v.get("name")?.as_str()?.chars().take(MAX_NAME).collect::<String>();
let path = match v.get("path") {
None | Some(Value::Null) => None,
Some(p) => Some(p.as_str().filter(|p| !p.is_empty() && p.len() <= MAX_PATH)?.to_string()),
};
let changed_at = v.get("changed_at")?.as_f64().filter(|t| t.is_finite() && *t >= 0.0)?;
Some(Entry { id: id.to_string(), name, path, changed_at })
}
}
/// Whether a copy that last changed at `changed_at` holds work its design's file doesn't:
/// the file was last written at `saved_at` (ms since 1970; `None` or unknown: never saved, or
/// the time can't be read, so the copy is kept rather than lost).
pub fn newer_than_save(changed_at: f64, saved_at: Option<f64>) -> bool {
match saved_at {
Some(saved) if saved.is_finite() && saved > 0.0 => changed_at > saved,
_ => true,
}
}
/// What the next visit does with a copy it finds.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Fate {
/// Offer it: "Restore unsaved design…?".
Offer,
/// Its design was saved after it: remove it without asking.
Drop,
/// Its page is still open (another tab): leave it alone.
Leave,
}
/// The copy `entry`, whose design's file was last written at `saved_at`, while the pages
/// `live` are open.
pub fn fate(entry: &Entry, live: &[String], saved_at: Option<f64>) -> Fate {
if page_of(&entry.id).is_some_and(|p| live.iter().any(|l| l == p)) {
Fate::Leave
} else if newer_than_save(entry.changed_at, saved_at) {
Fate::Offer
} else {
Fate::Drop
}
}
/// Copies to offer first: the most recently changed, at most [`MAX_OFFERED`].
pub fn order_offered(entries: &mut Vec<Entry>) {
entries.sort_by(|a, b| b.changed_at.total_cmp(&a.changed_at).then_with(|| a.id.cmp(&b.id)));
entries.truncate(MAX_OFFERED);
}
/// Whether a design's copy is due to be written at `now`: the design last changed at
/// `last_change`, and the copy has lacked a change since `first_unwritten` (all ms).
pub fn due(now: f64, first_unwritten: f64, last_change: f64) -> bool {
now - last_change >= QUIET_MS || now - first_unwritten >= MAX_WAIT_MS
}
/// How long after a failed write of a design's copy it isn't tried again (ms).
pub const RETRY_MS: f64 = 30_000.0;
/// Whether a copy whose write last failed at `failed_at` (ms; `None`: it hasn't) may be written
/// at `now`: not before [`RETRY_MS`] has passed, so a full or denied store isn't hit every tick.
pub fn may_write(now: f64, failed_at: Option<f64>) -> bool {
failed_at.is_none_or(|t| !(now >= t) || now - t >= RETRY_MS)
}
/// Whether `wanted` is the design now open, given the open design's path.
pub fn is_open(open: Option<&str>, wanted: &str) -> bool {
open == Some(wanted)
}
/// Whether leaving the page asks through the browser's "leave site?" box: there is unsaved work,
/// and either the user still wants to be asked or it isn't all kept in the browser yet (with the
/// keep preference off it never is, so there the box always stays).
pub fn asks_before_unload(persists: bool, keep: bool, also_ask: bool, any_unsaved: bool, all_kept: bool) -> bool {
persists && any_unsaved && (also_ask || !(keep && all_kept))
}
/// An answer in the "Restore unsaved design?" window.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Answer {
/// Open the copy, with its unsaved changes, as the design it was (saving goes to its file).
Restore,
/// Open the design's file as last saved; the copy goes.
OpenSaved,
/// The copy goes.
Discard,
}
/// What an answer does.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Plan {
/// Open the copy (and, once it is open, give it back its name and file).
pub open_copy: bool,
/// Open this saved file.
pub open_saved: Option<String>,
/// Remove the copy.
pub drop_copy: bool,
}
/// What `answer` does to copy `entry`; `saved_exists`: its file can still be opened. `None`: the
/// answer isn't offered for it (Open saved version of a design never saved, or gone).
pub fn plan(answer: Answer, entry: &Entry, saved_exists: bool) -> Option<Plan> {
match answer {
Answer::Restore => Some(Plan { open_copy: true, open_saved: None, drop_copy: true }),
Answer::OpenSaved => {
let path = entry.path.clone().filter(|_| saved_exists)?;
Some(Plan { open_copy: false, open_saved: Some(path), drop_copy: true })
}
Answer::Discard => Some(Plan { open_copy: false, open_saved: None, drop_copy: true }),
}
}
/// The answers the window offers for `entry`, in order.
pub fn answers(entry: &Entry, saved_exists: bool) -> Vec<Answer> {
[Answer::Restore, Answer::OpenSaved, Answer::Discard].into_iter().filter(|a| plan(*a, entry, saved_exists).is_some()).collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(id: &str, changed_at: f64, path: Option<&str>) -> Entry {
Entry { id: id.into(), name: "Bracket".into(), path: path.map(str::to_string), changed_at }
}
#[test]
fn a_failed_write_waits_before_the_next_try() {
assert!(may_write(1000.0, None));
assert!(!may_write(1000.0 + RETRY_MS - 1.0, Some(1000.0)));
assert!(may_write(1000.0 + RETRY_MS, Some(1000.0)));
// A clock that went back doesn't block for good.
assert!(may_write(10.0, Some(1000.0)));
}
#[test]
fn a_design_counts_as_opened_only_at_its_own_path() {
assert!(is_open(Some("a.sc"), "a.sc"));
assert!(!is_open(Some("b.sc"), "a.sc"));
assert!(!is_open(None, "a.sc"));
}
#[test]
fn copies_are_named_by_page_and_design_and_nothing_else_reads_as_one() {
let page = page_id(1_700_000_000_000.0, 0.5);
assert!(page.chars().all(|c| c.is_ascii_hexdigit()));
let id = entry_id(&page, 7);
assert!(valid_id(&id));
assert_eq!(page_of(&id), Some(page.as_str()));
assert_eq!(parse_name(&design_file(&id)), Some((id.as_str(), Part::Design)));
assert_eq!(parse_name(&meta_file(&id)), Some((id.as_str(), Part::Meta)));
for other in ["part.sc", "recovery.sc", "recovery.json", "keep-.sc", "keep-x.sc", "keep-a/b-1.sc", "keep-a-1.step", "keep-a-1sc"] {
assert_eq!(parse_name(other), None, "{other}");
}
// Hostile clocks still give a usable id.
for (now, random) in [(f64::NAN, f64::NAN), (f64::INFINITY, -1.0), (-5.0, 9.0)] {
assert!(valid_id(&entry_id(&page_id(now, random), 1)));
}
}
#[test]
fn metadata_round_trips_and_hostile_metadata_is_no_copy() {
let e = entry("a1-3", 1234.0, Some("/opfs/Bracket.sc"));
assert_eq!(Entry::parse("a1-3", e.to_json().as_bytes()), Some(e.clone()));
let never_saved = entry("a1-4", 99.0, None);
assert_eq!(Entry::parse("a1-4", never_saved.to_json().as_bytes()), Some(never_saved));
for bad in [
"",
"[]",
r#"{"name":"x","changed_at":"soon"}"#,
r#"{"name":"x","changed_at":-1}"#,
r#"{"name":"x","changed_at":1e999}"#,
r#"{"name":"x","path":7,"changed_at":1}"#,
r#"{"name":"x","path":"","changed_at":1}"#,
r#"{"changed_at":1}"#,
] {
assert_eq!(Entry::parse("a1-3", bad.as_bytes()), None, "{bad}");
}
assert_eq!(Entry::parse("../x", e.to_json().as_bytes()), None);
assert_eq!(Entry::parse("a1-3", &vec![b' '; MAX_META_BYTES + 1]), None);
let long = json!({"name": "n".repeat(10_000), "changed_at": 1}).to_string();
assert_eq!(Entry::parse("a1-3", long.as_bytes()).map(|e| e.name.len()), Some(MAX_NAME));
}
#[test]
fn a_copy_newer_than_the_last_save_is_offered_an_older_one_dropped_an_open_pages_left() {
let e = entry("p1-1", 2000.0, Some("/opfs/Bracket.sc"));
// Changed after the file was last written: offered.
assert_eq!(fate(&e, &[], Some(1000.0)), Fate::Offer);
// Saved since (in this tab or another): nothing to offer.
assert_eq!(fate(&e, &[], Some(2000.0)), Fate::Drop);
assert_eq!(fate(&e, &[], Some(3000.0)), Fate::Drop);
// Never saved, or the save time can't be read: kept rather than lost.
for saved in [None, Some(0.0), Some(f64::NAN), Some(f64::INFINITY), Some(-4.0)] {
assert_eq!(fate(&e, &[], saved), Fate::Offer, "{saved:?}");
}
// Its page is still open in another tab: neither offered nor removed.
assert_eq!(fate(&e, &["p1".into()], Some(1000.0)), Fate::Leave);
assert_eq!(fate(&e, &["p1".into()], Some(3000.0)), Fate::Leave);
assert_eq!(fate(&e, &["p2".into()], Some(1000.0)), Fate::Offer);
}
#[test]
fn the_newest_copies_are_offered_first_and_at_most_a_windowful() {
let mut list: Vec<Entry> = (0..40).map(|i| entry(&format!("p-{i}"), f64::from(i), None)).collect();
order_offered(&mut list);
assert_eq!(list.len(), MAX_OFFERED);
assert_eq!(list.first().map(|e| e.id.as_str()), Some("p-39"));
}
#[test]
fn a_copy_is_written_once_the_design_is_still_or_after_the_longest_wait() {
// Still changing, not waited long: not yet.
assert!(!due(1000.0, 900.0, 900.0));
// Still for QUIET_MS: due.
assert!(due(900.0 + QUIET_MS, 900.0, 900.0));
// Changing all the time: due after MAX_WAIT_MS anyway.
assert!(!due(5000.0, 2000.0, 4900.0));
assert!(due(2000.0 + MAX_WAIT_MS, 2000.0, 2000.0 + MAX_WAIT_MS - 1.0));
}
#[test]
fn the_browser_asks_before_unload_unless_told_not_to_and_every_copy_is_current() {
// Keeping off: exactly as before, unsaved work asks, whatever the second option says.
assert!(asks_before_unload(true, false, true, true, true));
assert!(asks_before_unload(true, false, true, true, false));
assert!(asks_before_unload(true, false, false, true, true));
// Keeping on, asking on (the default): both, the box and the copy.
assert!(asks_before_unload(true, true, true, true, true));
// Keeping on, asking off, every unsaved design kept: no browser box.
assert!(!asks_before_unload(true, true, false, true, true));
// Keeping on, asking off, a copy still being written (or failed): the browser still asks.
assert!(asks_before_unload(true, true, false, true, false));
// Nothing unsaved, or an embedded page that keeps nothing: never.
assert!(!asks_before_unload(true, true, true, false, false));
assert!(!asks_before_unload(true, false, true, false, false));
assert!(!asks_before_unload(false, false, true, true, false));
}
#[test]
fn restore_open_saved_and_discard_each_do_one_thing_and_always_remove_the_copy() {
let saved = entry("p-1", 5.0, Some("/opfs/Bracket.sc"));
assert_eq!(plan(Answer::Restore, &saved, true), Some(Plan { open_copy: true, open_saved: None, drop_copy: true }));
assert_eq!(
plan(Answer::OpenSaved, &saved, true),
Some(Plan { open_copy: false, open_saved: Some("/opfs/Bracket.sc".into()), drop_copy: true })
);
assert_eq!(plan(Answer::Discard, &saved, true), Some(Plan { open_copy: false, open_saved: None, drop_copy: true }));
assert_eq!(answers(&saved, true), [Answer::Restore, Answer::OpenSaved, Answer::Discard]);
// A design never saved, or whose file is gone, has no saved version to open.
let never = entry("p-2", 5.0, None);
assert_eq!(plan(Answer::OpenSaved, &never, true), None);
assert_eq!(plan(Answer::OpenSaved, &saved, false), None);
assert_eq!(answers(&never, false), [Answer::Restore, Answer::Discard]);
}
}
+277 -10
View File
@@ -3,7 +3,8 @@
//! Runs the same [`solidcraft_ui_egui::SolveApp`] as the desktop app through eframe's web runner
//! (wgpu: WebGPU where available, WebGL2 otherwise). Build with `trunk build --release` from this
//! directory (output in `dist/web`). URL flags: `?webgl` forces WebGL2 (remembered until
//! `?webgpu`), `?sample` opens the sample part.
//! `?webgpu`), `?sample` opens the sample part, `?embed=editor` runs it as an editing embed in a
//! host page (no application bar or start page; nothing kept in the browser; #8517).
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(target_arch = "wasm32")]
@@ -18,6 +19,8 @@ mod easyprint;
mod files;
#[cfg(target_arch = "wasm32")]
mod install;
#[cfg(any(target_arch = "wasm32", test))]
mod keep;
#[cfg(target_arch = "wasm32")]
mod linked;
#[cfg(any(target_arch = "wasm32", test))]
@@ -42,6 +45,8 @@ fn query_flag(query: &str, name: &str) -> bool {
struct Launch {
open: Option<String>,
embed: bool,
/// `?embed=editor` (#8517): an editing embed in a host page that keeps the files itself.
editor: bool,
}
#[cfg(any(target_arch = "wasm32", test))]
@@ -52,16 +57,19 @@ impl Launch {
for (key, value) in url::form_urlencoded::parse(query.trim_start_matches('?').as_bytes()) {
match key.as_ref() {
"open" if launch.open.is_none() => launch.open = Some(value.into_owned()),
"embed" if value == "editor" => launch.editor = true,
"embed" => embed = true,
_ => {}
}
}
launch.embed = embed && launch.open.is_some();
launch.embed = embed && launch.open.is_some() && !launch.editor;
launch
}
/// The browser's own storage (designs, autosave, recovery, preferences) is used: not when
/// embedded, where the host keeps what is kept.
fn persists(&self) -> bool {
!self.embed
!self.embed && !self.editor
}
fn full_app_url(&self, mut url: url::Url) -> String {
@@ -178,6 +186,7 @@ mod web {
use crate::api::Api;
use crate::disk::{self, SaveTarget};
use crate::files::{self, Files, Intent};
use crate::keep;
use crate::space_mouse::SpaceMouse;
const CANVAS_ID: &str = "solidcraft_canvas";
@@ -237,7 +246,9 @@ mod web {
}
/// The browser's "leave site? changes you made may not be saved" prompt while a design has
/// unsaved changes (the page can't show its own dialog when it is being closed).
/// unsaved changes (the page can't show its own dialog when it is being closed). With "keep
/// unsaved work in this browser" on and "still ask" off, there is nothing to ask once every
/// unsaved design's copy is current (`keep::asks_before_unload`, #10220).
fn ask_before_unload(window: &web_sys::Window) {
let on_unload = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::BeforeUnloadEvent)>::new(|e: web_sys::BeforeUnloadEvent| {
if UNSAVED.with(std::cell::Cell::get) {
@@ -250,6 +261,50 @@ mod web {
on_unload.forget();
}
/// With "keep unsaved work in this browser" on, the page brings every unsaved design's copy up
/// to date when it is hidden or goes (`pagehide`, `visibilitychange`): the browser may not
/// wait for that write, so the copies are written shortly after each change as well
/// (`Shell::keep_tick`, #10220).
fn keep_when_hidden(window: &web_sys::Window, runner: &eframe::WebRunner) {
let keep_now = {
let runner = runner.clone();
move || {
if let Some(mut shell) = runner.app_mut::<Shell>() {
shell.keep_tick(None, true);
}
}
};
let on_hide = {
let keep_now = keep_now.clone();
wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::Event)>::new(move |_: web_sys::Event| keep_now())
};
let on_visibility = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::Event)>::new(move |_: web_sys::Event| {
let hidden = web_sys::window()
.and_then(|w| w.document())
.and_then(|d| js_sys::Reflect::get(&d, &"visibilityState".into()).ok())
.and_then(|v| v.as_string())
.is_some_and(|v| v == "hidden");
if hidden {
keep_now();
}
});
let _ = window.add_event_listener_with_callback("pagehide", on_hide.as_ref().unchecked_ref());
if let Some(document) = window.document() {
let _ = document.add_event_listener_with_callback("visibilitychange", on_visibility.as_ref().unchecked_ref());
}
// The listeners live as long as the page.
on_hide.forget();
on_visibility.forget();
}
/// One open design followed for its kept copy: its revision, when that last changed, and since
/// when its copy has lacked a change (`None`: current, or being written).
struct Track {
revision: u64,
last_change: f64,
first_unwritten: Option<f64>,
}
/// eframe cancels the browser's action for every Tab it gets, so the browser couldn't switch
/// tabs while the app has the keyboard. Its tab-switching keys stop here, on the way down to
/// the canvas, before eframe sees them; the browser then acts on them as usual.
@@ -322,6 +377,10 @@ mod web {
/// The settings this tab wrote or loaded last, and those another tab wrote since (#8644).
prefs_writer: crate::PrefsWriter,
other_tab_prefs: PrefsInbox,
/// The open designs followed for their kept copy, by `Session::uid` (#10220).
keep_track: std::collections::HashMap<u64, Track>,
/// Every unsaved design's kept copy is current (and the preference is on).
all_kept: bool,
}
impl Shell {
@@ -436,8 +495,14 @@ mod web {
self.sync_recents();
}
let now = ctx.input(|i| i.time);
// With "keep unsaved work in this browser" on, each design's own copy takes over from
// the single autosave (#10220).
if self.app.preferences.keep_unsaved_in_browser && self.autosaved.is_some() {
files::autosave(&self.files, None);
self.autosaved = None;
}
// Unsaved work is kept (at most every AUTOSAVE_S); saved work needs no autosave.
if self.files.ready.get() && self.files.recovery.borrow().is_none() {
if self.files.ready.get() && self.files.recovery.borrow().is_none() && !self.app.preferences.keep_unsaved_in_browser {
let s = &self.app.session;
let changed = s.is_dirty() && self.autosaved != Some(s.revision);
let wait = AUTOSAVE_S - (now - self.autosaved_at);
@@ -456,6 +521,175 @@ mod web {
if !self.files.ready.get() || !self.files.incoming.borrow().is_empty() {
ctx.request_repaint_after(std::time::Duration::from_millis(100));
}
self.all_kept = self.keep_tick(Some(ctx), false);
}
/// Keep each open design's unsaved changes in the browser, when the preference asks for
/// it: a design's copy is written once it has been still a moment (`keep::due`), or at
/// once with `now` (the page is being hidden); a copy whose design is saved or closed, or
/// with the preference off, goes. Answers whether every unsaved design's copy is current.
fn keep_tick(&mut self, ctx: Option<&egui::Context>, now: bool) -> bool {
if !self.launch.persists() || !self.files.ready.get() {
return false;
}
let Shell { app, files, keep_track, .. } = self;
let on = app.preferences.keep_unsaved_in_browser;
let time = js_sys::Date::now();
let page = files.keep_page.borrow().clone();
let mut wanted = std::collections::HashSet::new();
let mut all_kept = true;
let mut wake: Option<f64> = None;
let mut error = None;
let mut soon = |ms: f64| wake = Some(wake.map_or(ms, |w: f64| w.min(ms)));
if on {
for s in solidcraft_ui_egui::documents::open_sessions(app).into_iter().filter(|s| s.is_dirty()) {
let id = keep::entry_id(&page, s.uid);
wanted.insert(s.uid);
let t = keep_track.entry(s.uid).or_insert(Track { revision: s.revision, last_change: time, first_unwritten: None });
if t.revision != s.revision {
t.revision = s.revision;
t.last_change = time;
}
if files.kept.borrow().get(&id).copied().flatten() == Some(s.revision) {
t.first_unwritten = None;
continue;
}
all_kept = false;
if files.keeping.borrow().contains(&id) {
soon(100.0);
continue;
}
let first = *t.first_unwritten.get_or_insert(time);
let failed = files.kept_failed.borrow().get(&id).copied();
if !keep::may_write(time, failed) {
// The last write failed: try again after a while.
soon(keep::RETRY_MS);
continue;
}
if now || keep::due(time, first, t.last_change) {
match solidcraft_engine::io::write_design_container(&s.design()) {
Ok(bytes) => {
let entry = keep::Entry { id, name: s.doc.name.clone(), path: s.path.clone(), changed_at: t.last_change };
files::write_kept(files, &entry, bytes, s.revision);
t.first_unwritten = None;
}
Err(e) => {
files.kept_failed.borrow_mut().insert(id, time);
error = Some(format!("keeping unsaved work in this browser: {e}"));
}
}
soon(100.0);
} else {
soon((keep::QUIET_MS - (time - t.last_change)).min(keep::MAX_WAIT_MS - (time - first)).max(16.0));
}
}
}
// This page's copies no design needs any more: saved, closed, or the preference off.
let stale: Vec<String> = files
.kept
.borrow()
.keys()
.filter(|id| keep::page_of(id) == Some(page.as_str()))
.filter(|id| !wanted.iter().any(|uid| keep::entry_id(&page, *uid) == **id))
.cloned()
.collect();
for id in stale {
files::remove_kept(files, &id);
soon(100.0);
}
keep_track.retain(|uid, _| wanted.contains(uid));
if let Some(e) = error {
app.set_status(e, true);
}
if let (Some(ctx), Some(ms)) = (ctx, wake) {
ctx.request_repaint_after(std::time::Duration::from_secs_f64(ms / 1000.0));
}
on && all_kept
}
/// Offer the copies an earlier visit kept (#10220): "Restore unsaved design: <name>,
/// changed <time>?", one window for all of them, each with Restore, Open saved version
/// (when it has a file) and Discard.
fn keep_prompt(&mut self, ctx: &egui::Context) {
let offered = self.files.keep_offered.borrow().clone();
if offered.is_empty() || !self.files.ready.get() {
return;
}
let now = (js_sys::Date::now() / 1000.0) as u64;
let rows: Vec<_> = offered
.iter()
.map(|e| solidcraft_ui_egui::recovery_ui::BrowserCopy {
name: &e.name,
when: solidcraft_ui_egui::recovery_ui::ago_between(now, (e.changed_at / 1000.0) as u64),
open_saved: keep::answers(e, self.saved_exists(e)).contains(&keep::Answer::OpenSaved),
})
.collect();
let act = solidcraft_ui_egui::recovery_ui::browser_copies_window(ctx, &self.app.preferences.language, &rows);
let Some((index, answer)) = act else { return };
let Some(entry) = offered.get(index) else { return };
let answer = match answer {
solidcraft_ui_egui::recovery_ui::CopyAnswer::Restore => keep::Answer::Restore,
solidcraft_ui_egui::recovery_ui::CopyAnswer::OpenSaved => keep::Answer::OpenSaved,
solidcraft_ui_egui::recovery_ui::CopyAnswer::Discard => keep::Answer::Discard,
};
self.keep_answer(entry, answer);
}
/// Whether a kept copy's design file can still be opened: in the browser's storage, or a
/// file on the computer this browser remembers.
fn saved_exists(&self, e: &keep::Entry) -> bool {
e.path.as_deref().is_some_and(|p| vfs::exists(p) || disk::parse(p).is_some_and(|(id, _)| self.files.disk_known.borrow().contains(id)))
}
/// Act on an answer in the "Restore unsaved design?" window (`keep::plan`).
fn keep_answer(&mut self, entry: &keep::Entry, answer: keep::Answer) {
let Some(plan) = keep::plan(answer, entry, self.saved_exists(entry)) else { return };
if plan.open_copy {
let copy = match files::prepare_kept(&entry.id, entry.path.as_deref()) {
Ok(copy) => copy,
Err(error) => {
self.app.set_status(error, true);
return;
}
};
self.app.open_path(&copy);
if self.app.session.path.as_deref() != Some(copy.as_str()) {
// Not opened (the status says why): it stays offered.
return;
}
// It is its own design again (saving goes to its file), with its changes unsaved.
self.app.session.path = entry.path.clone();
self.app.session.doc_mut().name = entry.name.clone();
self.app.session.mark_unsaved();
self.app.home.open = false;
}
if let Some(path) = &plan.open_saved {
if vfs::exists(path) {
self.app.open_path(path);
if !keep::is_open(self.app.session.path.as_deref(), path) {
// Not opened (the status says why): the copy stays offered.
return;
}
} else {
// A file on the computer: the browser asks for access again, in this click.
// The copy goes once the file has opened, not before: if access is refused,
// the picker is dismissed or the file is gone, it stays offered.
let (files, id) = (self.files.clone(), entry.id.clone());
disk::restore(
&self.files,
path,
Some(Box::new(move || {
files.keep_offered.borrow_mut().retain(|e| e.id != id);
files::remove_kept(&files, &id);
})),
);
return;
}
}
if plan.drop_copy {
self.files.keep_offered.borrow_mut().retain(|e| e.id != entry.id);
files::remove_kept(&self.files, &entry.id);
}
}
/// The start page's recent designs are the stored ones: the remembered order first (those
@@ -692,6 +926,13 @@ mod web {
files::autosave(&self.files, None);
}
/// Whether leaving the page asks through the browser's own box (`keep::asks_before_unload`).
fn asks_before_unload(&self) -> bool {
let any = solidcraft_ui_egui::documents::any_dirty(&self.app);
let prefs = &self.app.preferences;
keep::asks_before_unload(self.launch.persists(), prefs.keep_unsaved_in_browser, prefs.keep_unsaved_also_asks, any, self.all_kept)
}
/// Whether the shell already has the on-demand face the live language needs.
fn script_font_ready(&self) -> bool {
match solidcraft_ui_egui::fonts::web_script_font_kind(solidcraft_ui_egui::i18n::effective_language(&self.app.preferences.language)) {
@@ -803,7 +1044,7 @@ mod web {
self.api.poll();
self.space_mouse.logic(&mut self.app, ctx);
self.files_tick(ctx);
UNSAVED.with(|u| u.set(solidcraft_ui_egui::documents::any_dirty(&self.app)));
UNSAVED.with(|u| u.set(self.asks_before_unload()));
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) {
@@ -823,7 +1064,7 @@ mod web {
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.linked.tick(&mut self.app, ui.ctx());
UNSAVED.with(|u| u.set(self.launch.persists() && solidcraft_ui_egui::documents::any_dirty(&self.app)));
UNSAVED.with(|u| u.set(self.asks_before_unload()));
if self.launch.embed {
ui_kit::Container::new(ui.ctx(), ui_kit::Surface::Chrome).edge("embed_header", ui_kit::Edge::Top).show(ui, |ui| {
if ui_kit::button(ui, solidcraft_ui_egui::i18n::tr("web.open_full"), ui_kit::Button::Normal).clicked()
@@ -838,10 +1079,15 @@ mod web {
if self.launch.persists() {
self.file_panel(&ctx);
self.recovery_prompt(&ctx);
self.keep_prompt(&ctx);
}
self.watch_text_for_font(&ctx);
self.store_prefs(&ctx);
}
#[cfg(target_arch = "wasm32")]
fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
Some(&mut *self)
}
fn persist_egui_memory(&self) -> bool {
// eframe keeps this under one fixed key: only stable uses it, so a pre-release never rewrites it.
self.launch.persists() && crate::channel::current().is_empty()
@@ -916,7 +1162,8 @@ mod web {
{
create.instance_descriptor.backends = eframe::wgpu::Backends::GL;
}
let result = eframe::WebRunner::new()
let runner = eframe::WebRunner::new();
let result = runner
.start(
canvas,
options,
@@ -928,7 +1175,7 @@ mod web {
}
let (panel, opener, saver, shown) = (files.clone(), files.clone(), files.clone(), files.clone());
let (restorable, restorer) = (files.clone(), files.clone());
let services = if launch.embed { Services::default() } else { Services {
let services = if !launch.persists() { Services::default() } else { Services {
// Open shows the browser's open dialog for a design where it has one
// (the file keeps its handle, so Save writes back to it), else the
// browser's files window. A command that reads another kind of file
@@ -960,7 +1207,7 @@ mod web {
can_restore: Some(Box::new(move |path: &str| {
disk::parse(path).is_some_and(|(id, _)| restorable.disk_known.borrow().contains(id))
})),
restore_file: Some(Box::new(move |path: &str| disk::restore(&restorer, path))),
restore_file: Some(Box::new(move |path: &str| disk::restore(&restorer, path, None))),
download_design: Some(Box::new(files::download)),
easyprint_upload: Some(std::sync::Arc::new(crate::easyprint::upload)),
browser_files: Some(Box::new(move || shown.panel.set(true))),
@@ -972,6 +1219,7 @@ mod web {
let (sender, receiver) = std::sync::mpsc::channel();
let mut app = SolveApp::new(Session::default(), services).with_control(receiver);
app.embedded = launch.embed;
app.embed_editor = launch.editor;
// Listening before reading: settings another tab writes from now on are
// newer than those read here.
let other_tab_prefs = PrefsInbox::default();
@@ -1026,10 +1274,18 @@ mod web {
wheel_gestures: solidcraft_ui_egui::WebWheelGestures::default(),
prefs_writer: crate::PrefsWriter::default(),
other_tab_prefs,
keep_track: Default::default(),
all_kept: false,
}))
}),
)
.await;
if result.is_ok()
&& crate::Launch::parse(&query()).persists()
&& let Some(window) = web_sys::window()
{
keep_when_hidden(&window, &runner);
}
if let Some(el) = document.get_element_by_id(LOADING_ID) {
match result {
Ok(()) => dismiss_loading(el),
@@ -1082,6 +1338,17 @@ mod tests {
assert!(full.fragment().is_none());
}
#[test]
fn embed_editor_needs_no_design_link_and_keeps_nothing_in_the_browser() {
let editor = Launch::parse("?embed=editor");
assert!(editor.editor && !editor.embed && !editor.persists());
let linked = Launch::parse("?embed=editor&open=https://example.org/a.sc");
assert!(linked.editor && !linked.embed, "an editing embed may open a linked design, still editable");
let viewer = Launch::parse("?embed=viewer&open=https://example.org/a.sc");
assert!(viewer.embed && !viewer.editor, "any other value is the viewer embed, as before");
assert!(!Launch::parse("?other=editor").editor);
}
#[test]
fn design_links_refuse_hostile_schemes_credentials_and_sizes() {
assert!(design_url("https://example.org/arm.solidcraft?download=1", "http://solidcraft.io").is_ok());
+13 -9
View File
@@ -68,7 +68,9 @@ pub fn start(listen_on: control_auth::Listen, ctx: egui::Context) -> Receiver<Co
Ok(l) => {
if let Some(port) = listen_on_listener(l, token.clone(), tx, ctx, false) {
if matches!(listen_on, control_auth::Listen::Default) {
eprintln!("solidcraft: control channel on the default port 127.0.0.1:{port} (Preferences > Assistant turns it off)");
solidcraft_engine::note!(
"solidcraft: control channel on the default port 127.0.0.1:{port} (Preferences > Assistant turns it off)"
);
}
// The discovery file: where `solidcraft-cli mcp` finds this app with no arguments.
match control_auth::write_current_port(port) {
@@ -77,14 +79,14 @@ pub fn start(listen_on: control_auth::Listen, ctx: egui::Context) -> Receiver<Co
*p = Some((port, token));
}
}
Err(e) => eprintln!("solidcraft: could not write the control discovery file ({e})"),
Err(e) => solidcraft_engine::note!("solidcraft: could not write the control discovery file ({e})"),
}
}
}
Err(e) => eprintln!("solidcraft: control server failed to bind 127.0.0.1: {e}"),
Err(e) => solidcraft_engine::note!("solidcraft: control server failed to bind 127.0.0.1: {e}"),
}
}
None => eprintln!("solidcraft: control server not started: no random token available"),
None => solidcraft_engine::note!("solidcraft: control server not started: no random token available"),
}
rx
}
@@ -95,13 +97,13 @@ pub fn start(listen_on: control_auth::Listen, ctx: egui::Context) -> Receiver<Co
/// requests go to `tx`, where the chat's gate reads them; one may wait long for the user's Allow.
pub fn listen_agent(tx: Sender<ControlRequest>, ctx: egui::Context) -> Option<u16> {
let Some(token) = new_token() else {
eprintln!("solidcraft: agent chat endpoint not started: no random token available");
solidcraft_engine::note!("solidcraft: agent chat endpoint not started: no random token available");
return None;
};
let listener = match TcpListener::bind(("127.0.0.1", 0)) {
Ok(l) => l,
Err(e) => {
eprintln!("solidcraft: control server failed to bind 127.0.0.1: {e}");
solidcraft_engine::note!("solidcraft: control server failed to bind 127.0.0.1: {e}");
return None;
}
};
@@ -115,19 +117,21 @@ fn listen_on_listener(listener: TcpListener, token: String, tx: Sender<ControlRe
match control_auth::token_path(port) {
Some(path) => match control_auth::write_token_file(&path, &token) {
Ok(()) => {
eprintln!("solidcraft: control server listening on 127.0.0.1:{port} (token in {})", path.display());
solidcraft_engine::note!("solidcraft: control server listening on 127.0.0.1:{port} (token in {})", path.display());
if let Ok(mut files) = TOKEN_FILES.lock() {
files.push((path, token.clone()));
}
}
Err(e) => eprintln!(
Err(e) => solidcraft_engine::note!(
"solidcraft: control server listening on 127.0.0.1:{port}, but its token file {} could not be written ({e}); \
clients need SOLIDCRAFT_CONTROL_TOKEN",
path.display()
),
},
None => {
eprintln!("solidcraft: control server listening on 127.0.0.1:{port}; no data folder for its token, clients need SOLIDCRAFT_CONTROL_TOKEN")
solidcraft_engine::note!(
"solidcraft: control server listening on 127.0.0.1:{port}; no data folder for its token, clients need SOLIDCRAFT_CONTROL_TOKEN"
)
}
}
let token = std::sync::Arc::new(token);
+1 -1
View File
@@ -112,7 +112,7 @@ pub fn restart(software: bool, lost_at: u64, reason: &str) {
pub fn end() -> ! {
let watchdog = std::thread::Builder::new().name("solidcraft-exit".into()).spawn(|| {
std::thread::sleep(END_GRACE);
eprintln!("SolidCraft graphics: still closing after the device was lost; aborting");
solidcraft_engine::note!("SolidCraft graphics: still closing after the device was lost; aborting");
std::process::abort();
});
if let Err(e) = watchdog {
+15 -12
View File
@@ -79,7 +79,7 @@ fn mark(step: &str) {
static START: std::sync::OnceLock<(std::time::Instant, bool)> = std::sync::OnceLock::new();
let (start, on) = START.get_or_init(|| (std::time::Instant::now(), solidcraft_engine::geom::env::var_os("PROFILE_STARTUP").is_some()));
if *on {
eprintln!("startup {:>6.0} ms {step}", start.elapsed().as_secs_f64() * 1000.0);
solidcraft_engine::note!("startup {:>6.0} ms {step}", start.elapsed().as_secs_f64() * 1000.0);
}
}
@@ -303,6 +303,8 @@ impl eframe::App for App {
log::warn!("a picture was still being drawn as the app closed");
}
solidcraft_ui_egui::recovery_ui::close(&mut self.solve);
// A GPU path tracer setup still running did not stop the app (#9236).
solidcraft_ui_egui::render_workspace::close(&mut self.solve);
solidcraft_ui_egui::open_elsewhere::close(&mut self.solve);
// Before a restart (`Drop for App`) starts the next instance, which would otherwise get
// nothing from the 3D mouse driver (`solidcraft_3dx_client::stop`).
@@ -366,7 +368,7 @@ impl App {
let Some(gpu) = self.gpu.as_mut().filter(|g| g.lost_at.is_none() && g.lost.is_lost()) else { return };
let reason = gpu.lost.reason().unwrap_or_default();
log::error!("graphics: the device was lost ({reason}); closing to start again");
eprintln!("SolidCraft graphics: the device was lost ({reason}); starting again");
solidcraft_engine::note!("SolidCraft graphics: the device was lost ({reason}); starting again");
solidcraft_engine::crash::phase(&format!("graphics device lost: {reason}"));
gpu.lost_at = Some(device_lost::now_secs());
gpu.reason = reason;
@@ -991,7 +993,7 @@ fn main() -> eframe::Result {
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}");
solidcraft_engine::note!("SolidCraft: {error}");
return Err(eframe::Error::AppCreation(std::io::Error::other(error).into()));
}
}
@@ -1023,7 +1025,7 @@ fn main() -> eframe::Result {
let fallback_note = std::env::var(startup::FALLBACK_NOTE_ENV).ok().map(|s| s.chars().take(4096).collect::<String>());
if let Some(note) = &fallback_note {
log::warn!("{note}");
eprintln!("SolidCraft: {note}");
solidcraft_engine::note!("SolidCraft: {note}");
notes.push(note.clone());
}
let saved_samples = if safe_mode { None } else { msaa() };
@@ -1136,13 +1138,13 @@ fn main() -> eframe::Result {
// Windows: the taskbar button shows the mark even when slow first frames keep the
// taskbar waiting for the window's icon.
if !native_frame::set_class_icon(cc) {
eprintln!("SolidCraft window: no class icon; the taskbar may show a generic one");
solidcraft_engine::note!("SolidCraft window: no class icon; the taskbar may show a generic one");
}
// The OS hit test offers the resize border, at the window's DPI, from the first
// message (#1381).
app.native_resize_border = custom_titlebar && native_frame::install(cc);
if custom_titlebar && !app.native_resize_border {
eprintln!("SolidCraft window: no native resize border; egui edge zones instead");
solidcraft_engine::note!("SolidCraft window: no native resize border; egui edge zones instead");
}
}
app.integrated_titlebar = cfg!(target_os = "macos");
@@ -1173,9 +1175,10 @@ fn main() -> eframe::Result {
.collect();
let info = rs.adapter.get_info();
if !app.preferences.graphics_adapter.is_empty() && !info.name.eq_ignore_ascii_case(app.preferences.graphics_adapter.as_str()) {
eprintln!(
solidcraft_engine::note!(
"SolidCraft graphics: preferred adapter {:?} was not used; using {:?} instead",
app.preferences.graphics_adapter, info.name
app.preferences.graphics_adapter,
info.name
);
}
let samples = scene_msaa_request(saved_samples);
@@ -1183,7 +1186,7 @@ fn main() -> eframe::Result {
eframe::egui_wgpu::supported_msaa_samples(&rs.adapter, &rs.device, rs.target_format, depth)
});
let scene_samples = scene_samples(info.backend, u32::from(samples), &supported);
eprintln!("SolidCraft graphics: {:?}, MSAA {scene_samples}x (requested {}x)", rs.adapter.get_info().backend, samples);
solidcraft_engine::note!("SolidCraft graphics: {:?}, MSAA {scene_samples}x (requested {}x)", rs.adapter.get_info().backend, samples);
solidcraft_engine::crash::phase("viewport pipelines (shader compile)");
let scene_samples = app.set_wgpu_split(rs, depth_bits, STENCIL_BITS, scene_samples);
app.msaa_supported = supported;
@@ -1344,7 +1347,7 @@ fn wgpu_options(cpu: bool, preference: Option<&str>) -> eframe::egui_wgpu::WgpuC
let retry = std::env::var(startup::RETRY_BACKEND_ENV).ok();
let preference = if cpu { preference } else { retry.as_deref().or(preference) };
if let Some(backends) = startup::choose_backends(cfg!(target_os = "windows"), env_set, cpu, preference, startup::has_hardware_adapter) {
eprintln!("SolidCraft graphics: starting {backends:?}");
solidcraft_engine::note!("SolidCraft graphics: starting {backends:?}");
setup.instance_descriptor.backends = backends;
}
let intel_mac = cfg!(all(target_os = "macos", target_arch = "x86_64"));
@@ -1370,7 +1373,7 @@ fn wgpu_options(cpu: bool, preference: Option<&str>) -> eframe::egui_wgpu::WgpuC
/// event loop or a failed device; each retry advances through the APIs, then software once.
fn no_window(error: eframe::Error, tried_cpu: bool, failed: eframe::wgpu::Backends, settings: Option<&std::path::Path>) -> eframe::Result {
log::error!("the window could not open: {error}");
eprintln!("SolidCraft: the window could not open: {error}");
solidcraft_engine::note!("SolidCraft: the window could not open: {error}");
// This failure was handled, so neither the marker nor the journal should call it a crash.
if let Some(dir) = settings {
startup::started(dir);
@@ -1383,7 +1386,7 @@ fn no_window(error: eframe::Error, tried_cpu: bool, failed: eframe::wgpu::Backen
let previous = std::env::var(startup::FALLBACK_NOTE_ENV).unwrap_or_default();
let note = startup::fallback_note(&previous, failed, next, &error.to_string());
log::warn!("{note}");
eprintln!("SolidCraft: {note}");
solidcraft_engine::note!("SolidCraft: {note}");
let mut command = std::process::Command::new(exe);
command.args(std::env::args_os().skip(1)).env(startup::FALLBACK_NOTE_ENV, note);
if next == "cpu" {
+23 -17
View File
@@ -40,10 +40,10 @@ fn connect_to(path: &Path) -> Option<UnixStream> {
/// The axes of one record, or `None` for a record that is not motion.
///
/// The daemon's frame is right-handed with x to the right, y away from the user and z up; ours is
/// the device's raw one, x right, y towards the user and z down, so y and z (and the rotations
/// about them) change sign. With nothing set in `spnavcfg` the two give the same motion; what
/// the user swaps or inverts there then carries over.
/// The daemon reports x to the right, y up and z away from the user; our raw HID frame is x
/// right, y towards the user and z down. Swap and negate y/z and ry/rz to match that frame.
/// With nothing set in `spnavcfg` the two readers give the same motion; what the user swaps
/// or inverts there then carries over.
fn decode(record: &[u8; RECORD_BYTES]) -> Option<Axes> {
let mut data = [0_i32; 8];
for (slot, bytes) in data.iter_mut().zip(record.as_chunks::<4>().0) {
@@ -51,8 +51,8 @@ fn decode(record: &[u8; RECORD_BYTES]) -> Option<Axes> {
}
let [kind, x, y, z, rx, ry, rz, _period] = data;
(kind == 0).then(|| Axes {
translation: [unit(x), unit(-i64::from(y)), unit(-i64::from(z))],
rotation: [unit(rx), unit(-i64::from(ry)), unit(-i64::from(rz))],
translation: [unit(x), unit(-i64::from(z)), unit(-i64::from(y))],
rotation: [unit(rx), unit(-i64::from(rz)), unit(-i64::from(ry))],
})
}
@@ -149,22 +149,28 @@ mod tests {
#[test]
fn motion_records_become_axes_in_the_devices_raw_frame() {
// Full deflection of the daemon's x, y and z is the device's full scale, and y and z flip.
assert_eq!(decode(&record([0, 500, 500, 500, 0, 0, 0, 8])), Some(Axes { translation: [350, -350, -350], rotation: [0; 3] }));
// Rotations follow translations: right-handed in both frames, so ry and rz flip as y and z do.
assert_eq!(decode(&record([0, 0, 0, 0, 250, 250, 250, 8])), Some(Axes { translation: [0; 3], rotation: [175, -175, -175] }));
// Each daemon axis reaches only its matching raw HID axis, in both directions.
for (daemon_axis, raw_axis, sign) in [(0, 0, 1), (1, 2, -1), (2, 1, -1), (3, 3, 1), (4, 5, -1), (5, 4, -1)] {
for value in [-500, 500] {
let mut data = [0; 8];
data[daemon_axis + 1] = value;
let mut expected = [0; 6];
expected[raw_axis] = sign * (value * 350 / 500) as i16;
assert_eq!(decode(&record(data)), Some(Axes::from(expected)), "daemon axis {daemon_axis}, value {value}");
}
}
// Rest is a motion record of zeros.
assert_eq!(decode(&record([0, 0, 0, 0, 0, 0, 0, 1200])), Some(Axes::default()));
}
#[test]
fn what_spnavcfg_swaps_reaches_the_view() {
// The daemon sends a push away as y. With Y and Z swapped in spnavcfg it sends the same push
// as z, and the view gets a lift (raw z) instead of the zoom (raw y).
let before = decode(&record([0, 0, 500, 0, 0, 0, 0, 8])).map(|axes| axes.translation);
let after = decode(&record([0, 0, 0, 500, 0, 0, 0, 8])).map(|axes| axes.translation);
assert_eq!(before, Some([0, -350, 0]));
assert_eq!(after, Some([0, 0, -350]));
// A push away is daemon z (raw y); swapping Y/Z makes it a lift (raw z).
// The same swap changes a sideways tilt (daemon rz, raw ry) into a twist (raw rz).
let before = decode(&record([0, 0, 0, 500, 0, 0, 250, 8]));
let after = decode(&record([0, 0, 500, 0, 0, 250, 0, 8]));
assert_eq!(before, Some(Axes { translation: [0, -350, 0], rotation: [0, -175, 0] }));
assert_eq!(after, Some(Axes { translation: [0, 0, -350], rotation: [0, 0, -175] }));
}
#[test]
@@ -179,7 +185,7 @@ mod tests {
// Nothing overflows on the extremes of `i32`.
let extreme = decode(&record([0, i32::MIN, i32::MIN, i32::MAX, i32::MAX, i32::MIN, i32::MIN, -1]));
assert_eq!(extreme.map(|axes| axes.translation[0]), Some(i16::MIN));
assert_eq!(extreme.map(|axes| axes.translation[1]), Some(i16::MAX));
assert_eq!(extreme.map(|axes| axes.translation), Some([i16::MIN, i16::MIN, i16::MAX]));
assert_eq!(extreme.map(|axes| axes.rotation[0]), Some(i16::MAX));
}
+11 -6
View File
@@ -311,13 +311,18 @@ pub const SYSTEM_VERSION_COMPAT_ENV: &str = "SYSTEM_VERSION_COMPAT";
#[cfg(target_os = "macos")]
pub const OCLP_NVRAM_KEY: &str = "4D1FDA02-38C7-4A6A-9CC6-4BCCA8B30102:OCLP-Version";
/// Where macOS keeps its `nvram` tool.
#[cfg(target_os = "macos")]
const NVRAM_TOOL: &str = "/usr/sbin/nvram";
/// `true` if the OCLP NVRAM key is present on this system.
///
/// Runs `nvram <key>` and treats exit 0 as present. Any error (missing binary,
/// permission denied, key absent) returns `false` — this is a best-effort probe.
/// Runs `/usr/sbin/nvram <key>` and treats exit 0 as present. The full path keeps a `PATH` that
/// lacks `/usr/sbin`, or lists a look-alike first, from changing the answer. Any error (missing
/// binary, permission denied, key absent) returns `false`: this is a best-effort probe.
#[cfg(target_os = "macos")]
pub fn oclp_nvram_present() -> bool {
std::process::Command::new("nvram").arg(OCLP_NVRAM_KEY).output().map(|o| o.status.success()).unwrap_or(false)
std::process::Command::new(NVRAM_TOOL).arg(OCLP_NVRAM_KEY).output().map(|o| o.status.success()).unwrap_or(false)
}
/// Whether to re-launch with `SYSTEM_VERSION_COMPAT=1`: it is not set yet and OCLP shows through
@@ -334,8 +339,8 @@ fn compat_wanted(compat_set: bool, nvram: impl FnOnce() -> bool) -> bool {
///
/// `SYSTEM_VERSION_COMPAT=1` must be present **before** the process starts: setting it
/// with `std::env::set_var` inside `main` is too late because the macOS frameworks have
/// already queried the OS version by then. Re-launching is the only safe approach that
/// works without `unsafe` (a true `execve` replacement would require it).
/// already queried the OS version by then, so the program starts itself again with the variable
/// set and waits for that child.
///
/// Returns `Ok(false)` immediately when:
/// - `SYSTEM_VERSION_COMPAT` is already set (re-launched process, no loop), or
@@ -352,7 +357,7 @@ pub fn relaunch_with_version_compat() -> Result<bool, String> {
return Ok(false);
}
log::info!("startup: OCLP detected; re-launching with {}=1", SYSTEM_VERSION_COMPAT_ENV);
eprintln!("SolidCraft: OCLP detected; re-launching with {}=1", SYSTEM_VERSION_COMPAT_ENV);
solidcraft_engine::note!("SolidCraft: OCLP detected; re-launching with {}=1", SYSTEM_VERSION_COMPAT_ENV);
let exe = std::env::current_exe().map_err(|e| format!("could not find own executable: {e}"))?;
let status = std::process::Command::new(&exe)
.args(std::env::args_os().skip(1))
@@ -60,7 +60,6 @@ circle_ttt false
close false
coil false
colored_components false
colored_components_off false
combine false
compare false
component false
@@ -1,21 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Disable component colors</title>
<path d="M9.235 17.706L15.059 20.882L15.059 48.941L9.235 44.926Z" fill="#328bd3"/>
<path d="M35.176 9.235L15.059 20.882L15.059 48.941L36.765 35.139Z" fill="#236ead"/>
<path d="M29.046 5L35.176 8.176L15.196 19.718L9.066 16.541Z" fill="#b9ddf8"/>
<path d="M29.046 5.529L35.176 8.706L35.176 36.235L15.059 49.471L9.235 45.765L9.066 17.071Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.066 17.6L15.059 20.882L34.647 9.235" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M15.059 20.882L15.059 47.067" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M32.222 18.595L44.949 25.944L44.949 51.122L32.222 43.774Z" fill="#328bd3"/>
<path d="M54.934 20.173L44.949 25.944L44.949 51.122L54.934 45.352Z" fill="#236ead"/>
<path d="M42.059 12.412L54.934 20.173L44.949 25.944L32 17.706Z" fill="#b9ddf8"/>
<path d="M41.159 11.766L54.934 20.173L54.934 45.352L44.949 51.122L31.164 42.715L31.164 17.536Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M32.222 18.595L44.949 25.944L54.934 20.173" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M44.949 25.944L44.949 51.122" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M18.299 39.062L31.015 46.411L31.015 59L18.299 51.652Z" fill="#328bd3"/>
<path d="M41.921 40.121L31.015 46.411L31.015 59L41.921 52.711Z" fill="#236ead"/>
<path d="M29.194 32.773L41.921 40.121L31.015 46.411L18.299 39.062Z" fill="#b9ddf8"/>
<path d="M29.194 32.773L41.921 40.121L41.921 52.711L31.015 59L18.299 51.652L18.299 39.062Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M18.299 39.062L31.015 46.411L41.921 40.121" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M31.015 46.411L31.015 59" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

-11
View File
@@ -79,17 +79,6 @@ pub(crate) fn snap_hint(painter: &egui::Painter, c: Pos2, kind: SnapKind, col: C
painter.circle_filled(c, 1.3, col);
}
/// A placed hole's drag handle: a ring round the hole's centre with a cross through it, filled
/// while it is hovered or dragged.
pub(crate) fn hole_handle(painter: &egui::Painter, c: Pos2, col: Color32, hot: bool) {
let ic = Tokens::get().icons;
let r = 6.0;
painter.circle_filled(c, r + 3.5, ic.marker_halo);
painter.circle(c, r, if hot { col } else { col.gamma_multiply(0.35) }, Stroke::new(1.6, col));
let ink = Stroke::new(1.3, if hot { ic.highlight } else { col });
painter.line_segment([c - vec2(r + 3.0, 0.0), c + vec2(r + 3.0, 0.0)], ink);
painter.line_segment([c - vec2(0.0, r + 3.0), c + vec2(0.0, r + 3.0)], ink);
}
pub(crate) fn glyph_icon(name: &str) -> Option<&'static str> {
Some(match name {
"Coincident" => "c_coincident",
+73 -30
View File
@@ -629,6 +629,8 @@
"Suppress Selected Folders": "تعطيل المجلدات المحددة",
"Suppressed": "مُعطّل",
"Surface": "السطح",
"Surface or face": "سطح أو وجه",
"Surface or faces": "سطح أو أوجه",
"Surface, or faces to turn over": "السطح، أو الأوجه المراد قلبها",
"Surfaces": "الأسطح",
"Swap Ends": "تبديل الطرفين",
@@ -642,6 +644,7 @@
"Target": "الهدف",
"Target Body": "الجسم الهدف",
"Target Face (optional)": "الوجه الهدف (اختياري)",
"Target edges": "الحواف المستهدفة",
"Tear": "تمزيق",
"Technical details": "التفاصيل التقنية",
"The dashed sketch is a saved plane guide. Select a new face to repair the attachment.": "الرسم المتقطع دليل مستوى محفوظ. حدد وجهًا جديدًا لإصلاح الارتباط.",
@@ -649,6 +652,7 @@
"The direction must be an axis or a straight sketch curve that lies in the sketch plane": "يجب أن يكون الاتجاه محورًا أو منحنى رسم مستقيمًا يقع في مستوى الرسم",
"The mirror line cannot also be mirrored": "لا يمكن عكس خط المرآة نفسه",
"The mirror line must be a straight curve of this sketch, not an origin axis": "يجب أن يكون خط المرآة منحنى مستقيمًا من هذا الرسم، وليس أحد محاور نقطة الأصل",
"The origin axis is perpendicular to the sketch plane, so it has no line in the plane": "محور الأصل عمودي على مستوى الرسم، لذا ليس له خط في المستوى",
"The saved author occurrence is missing. Select a valid author placement before rebinding.": "نسخة المؤلف المحفوظة مفقودة. حدد موضع مؤلف صالحًا قبل إعادة الربط.",
"Then": "ثم",
"These features will be deleted:": "ستُحذف هذه الميزات:",
@@ -751,6 +755,8 @@
"ai.prefs.follow_camera": "تتبّع الكاميرا",
"ai.prefs.follow_camera_off": "تتبّع الكاميرا التلقائي متوقف.",
"ai.prefs.follow_camera_on": "تتجه الكاميرا نحو الرسومات التي ينشئها الوكيل.",
"ai.prefs.instructions": "تعليمات مخصصة",
"ai.prefs.instructions_note": "تُرسَل إلى الوكيل في بداية كل محادثة، بعد تعليمات SolidCraft الخاصة: طريقتك المفضلة في العمل والقواعد التي يجب الالتزام بها. يسري أي تغيير من المحادثة التالية (اتصال أو محادثة جديدة).",
"ai.prefs.intro": "وكلاء الذكاء الاصطناعي في SolidCraft.",
"ai.prefs.make_default": "تعيين افتراضيًا",
"ai.prefs.no_agents": "لا يوجد وكلاء بعد. أضف وكيلًا أو حدّث القائمة للعثور على الوكلاء المثبّتين.",
@@ -964,8 +970,7 @@
"cmd.inspect_analysis": "تحليل العرض",
"cmd.inspect_center_of_mass": "مركز الكتلة",
"cmd.inspect_clearance": "الخلوص",
"cmd.inspect_colored_components": "تفعيل ألوان المكوّنات",
"cmd.inspect_colored_components_disable": "تعطيل ألوان المكوّنات",
"cmd.inspect_colored_components": "ألوان المكوّنات",
"cmd.inspect_compare": "مقارنة الأجسام",
"cmd.inspect_compare_picture": "مقارنة بصورة مرجعية",
"cmd.inspect_continuity": "تحليل الاستمرارية",
@@ -1396,6 +1401,7 @@
"dialog.computing": "جارٍ الحساب… {seconds} ث",
"dialog.could_not_be_checked": "تعذّر فحصه",
"dialog.custom_axis": "↖ محور مخصص",
"dialog.cylinder_face_tip": "وجه النموذج أو رأسه الذي تقوم عليه الأسطوانة: انقر هنا ثم انقر عليه في العرض. إذا تُرك فارغًا، تحدد X وY وZ موضعها.",
"dialog.dimension_insert_hint": "انقر على بُعد قائد آخر لإدراج اسمه",
"dialog.dimension_name": "البُعد: {name}",
"dialog.dimension_step_down": "إنقاص بمقدار {step}",
@@ -1404,6 +1410,8 @@
"dialog.edit_title": "تعديل {title}",
"dialog.no_shown_body": "لا يوجد جسم ظاهر لتغييره: أظهر جسمًا، أو اختر «جسم جديد»",
"dialog.pattern_sketch_not_object": "اختر أجسامًا أو ميزات: الرسم ليس أيًّا منهما. كرّر منحنياته بأداة النمط أو المرآة داخل الرسم، أو ابثقه أولًا.",
"dialog.primitive_point_tip": "نقطة تملأ X وY وZ: انقر هنا ثم على رأس أو حافة أو وجه من النموذج، أو على نقطة من رسم، أو على نقطة الأصل.",
"dialog.reference_edge_tip": "حافة مستقيمة من الوجه المختار تُقاس منها المسافة أدناه: انقر هنا ثم على الحافة.",
"dialog.select": "↖ تحديد",
"dialog.selected_count": "↖ {count} محدد",
"dialog.selection_constraint": "قيد {id}",
@@ -2040,6 +2048,8 @@
"nav.blender.help": "السحب بالزر الأوسط يدوّر العرض، وShift مع الأوسط يُزيحه، وCtrl مع الأوسط يكبّره؛ والسحب بالأيمن لا يحرك الكاميرا",
"nav.creo.help": "السحب بالزر الأوسط يدوّر العرض، وShift مع الأوسط يُزيحه، وCtrl مع السحب العمودي بالأوسط يكبّره أو يصغّره (السحب لأسفل للتكبير)؛ وتدوير العجلة يكبّره أو يصغّره",
"nav.creo.label": "Creo",
"nav.drag_keeps_mode.help": "يستمر السحب بالزر الأوسط أو الأيمن في التدوير أو التحريك أو التكبير كما بدأ حتى تحرير الزر، مهما ضُغطت المفاتيح أو حُرّرت في أثناء ذلك. عند الإيقاف: تقرر المفاتيح المضغوطة في كل لحظة من السحب.",
"nav.drag_keeps_mode.label": "يحتفظ السحب بطريقة التنقل التي بدأ بها",
"nav.free_orbit.help": "الفأرة والفأرة ثلاثية الأبعاد تدوّران العرض عبر القطبين وتحافظان على اللف. يُعطَّل التقاط المحاور بـ Shift.",
"nav.free_orbit.label": "تدوير حر",
"nav.free_orbit.snap_help": "الفأرة والفأرة ثلاثية الأبعاد تدوّران العرض عبر القطبين وتحافظان على اللف. اضغط مطولًا على Shift أثناء سحب التدوير لالتقاط اتجاه العرض واللف.",
@@ -2276,6 +2286,9 @@
"prefs.auto_operation_note": "ضم أو جسم جديد أو قص، حسب ما تلامسه المادة.",
"prefs.axes": "المحاور",
"prefs.axis": "المحور",
"prefs.background_choose": "اختيار صورة…",
"prefs.background_image": "الصورة",
"prefs.background_unreadable": "تعذرت قراءة صورة الخلفية",
"prefs.blur_cost": "تكلفة العرض النسبية. زيادة الأشرطة تعني عادةً مزيدًا من العمل.",
"prefs.blur_device": "غير متاح على جهاز الرسوميات أو مسار العرض هذا",
"prefs.blur_guard": "خُفّضت جودة التمويه للحفاظ على استجابة الإطارات. لم يتغير نصف القطر.",
@@ -2297,6 +2310,8 @@
"prefs.browser_eye_left": "أيقونة الإظهار بجانب سهم الطي",
"prefs.browser_eye_left_note": "تعرض صفوف المستعرض أيقونة الإظهار/الإخفاء بجانب سهم الطي كما كانت من قبل، بدلًا من عمود عند نهاية الصفوف.",
"prefs.browser_reveal_on_hover": "إظهار عند مرور المؤشر",
"prefs.browser_rows_start_closed": "صفوف المستعرض تبدأ مغلقة",
"prefs.browser_rows_start_closed_note": "تبدأ المواضع والمجلدات في المستعرض مغلقة، فيعرض التصميم الذي يضم مكونات كثيرة صفوفه العليا. يُفتح كل صف بسهمه أو بـ«توسيع الكل» أو بـ«البحث في المستعرض» الذي يفتح الصفوف المؤدية إلى الجسم فقط، ويغلق من جديد الصفوف التي فتحها البحث السابق. اختيار جسم في العرض ثلاثي الأبعاد يترك المستعرض كما هو. عند إيقافه تبدأ المجلدات مفتوحة، وتبدأ المواضع كما يحدّد خيار «المكوّنات مفتوحة من البداية»، ويفتح الاختيار في العرض ثلاثي الأبعاد المستعرضَ حتى صف الجسم، كما كان من قبل.",
"prefs.browser_show_note": "«ظاهر» يُبقي المستعرض على الشاشة. «إظهار عند مرور المؤشر» يُخفيه حتى يمر المؤشر فوق مكانه أو فوق الشريط الرفيع عند حافته اليسرى، ويبقى مفتوحًا ما دام المؤشر داخله أو كانت قائمة منه مفتوحة.",
"prefs.browser_shown": "ظاهر",
"prefs.built_in": "مدمج",
@@ -2428,6 +2443,10 @@
"prefs.invert_curved_roll_arrows_note": "سهما التدوير المنحنيان عند زاوية المكعب يدوران العرض داخل مستوى الشاشة. عند التشغيل: سهم التدوير مع عقارب الساعة يدور عكسها، وسهم العكس يدور معها. عند الإيقاف: يدور كل سهم بالعرض حسب انحنائه.",
"prefs.invert_navigator_arrows": "عكس أسهم تدوير مكعب العرض",
"prefs.invert_navigator_arrows_note": "تدوّر الأسهم المحيطة بالمكعب الكاميرا في الاتجاه الذي تشير إليه، كما في Fusion وInventor: السهم الموجود على اليسار يشير إلى اليمين ويدوّر الكاميرا إلى اليمين. عند الإيقاف: يدوّر السهم المكعب نحو الجهة التي يقع فيها، فيدوّر السهم الأيسر الكاميرا إلى اليسار. لا يتأثر سهما الدوران المنحنيان.",
"prefs.keep_unsaved_also_asks": "السؤال أيضًا عند إغلاق علامة التبويب",
"prefs.keep_unsaved_also_asks_tip": "عند إيقاف هذا الخيار، تُغلق علامة التبويب دون مربع المتصفح بعد تحديث نسخة كل تصميم غير محفوظ.",
"prefs.keep_unsaved_in_browser": "الاحتفاظ بالعمل غير المحفوظ في هذا المتصفح",
"prefs.keep_unsaved_in_browser_tip": "تُحدَّث التغييرات غير المحفوظة لكل تصميم مفتوح باستمرار في التخزين الخاص بهذا المتصفح. وتعرض الزيارة التالية استعادتها.",
"prefs.keep_work_on_tab_switch": "إبقاء مربعات الحوار والأدوات مفتوحة عند التبديل بين التصاميم",
"prefs.keep_work_on_tab_switch_note": "تحتفظ كل علامة تبويب تصميم بمربع الحوار أو الأداة المفتوحة، بما حددته وكتبته فيها، وبعلامة تبويب شريط الأدوات. وتجدها كما هي عند العودة إلى التصميم. عند الإيقاف: يؤدي التبديل بين التصاميم إلى إلغائها وعرض علامة التبويب «النموذج».",
"prefs.labels": "التسميات",
@@ -2439,6 +2458,8 @@
"prefs.layout_page.card": "بطاقة",
"prefs.layout_page.column": "عمود",
"prefs.layout_page.hover": "عند مرور المؤشر",
"prefs.layout_page.hover_panel_menus": "تبديل قوائم اللوحات عند مرور المؤشر",
"prefs.layout_page.hover_panel_menus_help": "انقر على اسم لوحة في الشريط لفتح قائمتها. مرّر المؤشر فوق اسم لوحة أخرى للتبديل. حرّك المؤشر خارج الشريط والقائمة المفتوحة لإغلاقها.",
"prefs.layout_page.icons_labels": "الأيقونات + التسميات",
"prefs.layout_page.ok_cancel": "موافق وإلغاء",
"prefs.layout_page.on_hover": "عند مرور المؤشر",
@@ -2559,8 +2580,8 @@
"prefs.section_hatch_by_part": "حسب الجزء",
"prefs.section_hatch_one": "نمط واحد",
"prefs.select": "تحديد",
"prefs.select_hidden_edges": "تحديد الحواف المخفية في العرض المظلل مع الحواف المخفية",
"prefs.select_hidden_edges_help": "يتيح تحديد الحواف عبر الأسطح عند تفعيل العرض المظلل مع الحواف المخفية، بما في ذلك استخدام أداة Fillet. يحافظ إيقافه على سلوك التحديد في v0.30.1. تظل الأجسام المخفية غير قابلة للتحديد.",
"prefs.select_hidden_edges": "تحديد الحواف المخفية في نمط «مظلل مع الحواف المخفية»",
"prefs.select_hidden_edges_help": "يتيح تحديد الحواف عبر الأوجه عند تفعيل نمط «مظلل مع الحواف المخفية»، بما في ذلك عند استخدام التقويس. عند الإيقاف يبقى سلوك التحديد كما في v0.30.1. تظل الأجسام المخفية غير قابلة للتحديد.",
"prefs.select_one_item": "تحديد عنصر واحد",
"prefs.select_other_hidden": "تحديد العناصر الهندسية المخفية باستخدام «تحديد عنصر آخر»",
"prefs.select_other_hidden_help": "عند التفعيل: تضمين الأجسام والمخططات المخفية والحواف المحجوبة قرب المؤشر. عند التعطيل: استخدام القائمة الأصلية للعناصر الهندسية المرئية أسفل المؤشر.",
@@ -2756,6 +2777,12 @@
"recovery.just_now": "الآن",
"recovery.min_ago": "قبل {count} دقيقة",
"recovery.never_saved": "لم يُحفظ قط",
"recovery.open_saved": "فتح النسخة المحفوظة",
"recovery.restore": "استعادة",
"recovery.restore_intro": "التغييرات التي لم تُحفظ عند آخر إغلاق لهذه الصفحة محفوظة في هذا المتصفح.",
"recovery.restore_row": "{name}، تغيّر {when}",
"recovery.restore_title": "استعادة التصاميم غير المحفوظة؟",
"recovery.restore_title_one": "استعادة التصميم غير المحفوظ: {name}، تغيّر {when}؟",
"refused.change": "تعذّر تغيير {body}.",
"refused.cut": "تعذّر قطع {body}.",
"refused.face_curved": "أحد أوجهه المنحنية لا يمكن استخدامه في النمذجة.",
@@ -3277,9 +3304,11 @@
"render.frame_wide": "16:9 · عريض",
"render.gpu_depth": "أقصى عمق للمسار",
"render.gpu_options_help": "تُحفظ العيّنات والعمق والبذرة والتعريض مع هذا الإعداد. تُحفظ طريقة الصورة النشطة فقط.",
"render.gpu_path_crashed": "أُغلق SolidCraft أثناء إعداده على محوّل الرسوميات هذا في المرة السابقة، على الأرجح داخل برنامج تشغيل الرسوميات، لذا لن يُعاد إعداده تلقائيًا. استخدم تتبع الأشعة على المعالج المركزي بدلًا منه، أو حاول مرة أخرى (يعيد تحديث برنامج التشغيل المحاولة أيضًا).",
"render.gpu_path_failed": "متتبع المسارات على GPU غير متاح",
"render.gpu_path_first_setup": "قد يستغرق الإعداد الأول على وحدة GPU هذه عدة دقائق؛ ويبقى SolidCraft قابلًا للاستخدام أثناءها. تعيد المرات اللاحقة استخدام المظللات المترجمة حيث يحتفظ بها برنامج التشغيل.",
"render.gpu_path_preparing": "متتبع المسارات على GPU: ترجمة المظللات…",
"render.gpu_path_retry": "حاول مرة أخرى",
"render.gpu_seed": "البذرة العشوائية",
"render.gpu_unavailable": "تتبع المسارات على GPU غير جاهز على هذا الجهاز.",
"render.ground_apply": "ملاءمة الأرضية وحفظها",
@@ -4087,6 +4116,8 @@
"sample.plate.name": "لوح نموذجي",
"sample.vents.about": "شقوق في نمط مستطيلي: الصفوف والأعمدة والخطوة معاملات",
"sample.vents.name": "لوح تهوية",
"scale.per_axis": "لكل محور",
"scale.uniform": "موحّد",
"search.command_keys": "Enter تشغيل Shift+Enter تشغيل مع الإبقاء مفتوحًا Esc إغلاق",
"search.commands": "الأوامر",
"search.design": "في هذا التصميم",
@@ -4177,32 +4208,32 @@
"sketch.dof_left": "تبقّت {count} درجات حرية",
"sketch.dof_left_one": "تبقّت {count} درجة حرية",
"sketch.draw_curves": "الرسم كـ {linetype}",
"sketch.error.closed_curve_two_crossings": "a closed curve needs two crossings to trim",
"sketch.error.nothing_to_break": "nothing to break here",
"sketch.error.offset_fillet_chamfer": "`{id}` is an offset, and its corners follow its source curves: round or cut the source corners before offsetting, or break the offset association first",
"sketch.error.offset_trim": "`{id}` is an offset, and its ends follow its source curves: edit the source curves instead, or break the offset association first",
"sketch.hint.arc.center_point": "Arc: click center and start, go round either way and click the end, or type a signed sweep",
"sketch.hint.arc.three_point": "Arc: click start, end, then a point on the arc",
"sketch.hint.arc3d": "3D Arc: click start, end, then a point on the arc (points may leave the sketch plane)",
"sketch.hint.break": "Break: click a curve where it should split",
"sketch.hint.circle.center": "Circle: click the center, then a point on the circle",
"sketch.hint.constraint.generic": "Constraint: pick the sketch entities",
"sketch.hint.constraint.horizontal_vertical": "Horizontal/Vertical: pick a line, two points, or a point and a line; hold Shift to pick a line midpoint",
"sketch.hint.constraint.midpoint": "MidPoint: pick a point and a line, or two lines to center the second on the first",
"sketch.hint.dimension": "Dimension: pick one or two entities and type the value (or click to place it first); click a dimension to edit it; Enter applies",
"sketch.hint.extend": "Extend: click a curve near the end to extend (Shift: click two curves to extend both to where they meet)",
"sketch.hint.fallback": "Click in the sketch",
"sketch.hint.line": "Line: click points; right-click or Esc to finish",
"sketch.hint.line3d": "3D Line: click world points; right-click or Esc to finish (points may leave the sketch plane)",
"sketch.hint.open_done": "Open done",
"sketch.hint.point3d": "3D Point: click where a 3D point goes",
"sketch.hint.rectangle.center": "Center rectangle: click the center, then a corner",
"sketch.hint.rectangle.rounded": "Rounded rectangle: click two opposite corners (Tab to the radius box to type the corner radius)",
"sketch.hint.rectangle.three_point": "3-point rectangle: click two points of one edge, then the width",
"sketch.hint.rectangle.two_point": "Rectangle: click two opposite corners",
"sketch.hint.slot": "Slot: click both ends, then the width",
"sketch.hint.spline3d": "3D Spline: click fit points in 3D; right-click or Esc to finish",
"sketch.hint.trim": "Trim: click the piece of a curve to remove, or drag across the pieces to remove",
"sketch.error.closed_curve_two_crossings": "يحتاج المنحنى المغلق إلى نقطتي تقاطع لقصّه",
"sketch.error.nothing_to_break": "لا يوجد هنا ما يمكن كسره",
"sketch.error.offset_fillet_chamfer": "`{id}` منحنى إزاحة، وزواياه تتبع منحنياته المصدر: قوّس زوايا المنحنيات المصدر أو اشطفها قبل الإزاحة، أو فك ارتباط الإزاحة أولًا",
"sketch.error.offset_trim": "`{id}` منحنى إزاحة، وأطرافه تتبع منحنياته المصدر: عدّل المنحنيات المصدر بدلًا من ذلك، أو فك ارتباط الإزاحة أولًا",
"sketch.hint.arc.center_point": "قوس: انقر المركز ثم البداية، ودُر في أي اتجاه وانقر النهاية، أو اكتب زاوية القوس بإشارة موجبة أو سالبة",
"sketch.hint.arc.three_point": "قوس: انقر البداية ثم النهاية، ثم نقطة على القوس",
"sketch.hint.arc3d": "قوس ثلاثي الأبعاد: انقر البداية ثم النهاية، ثم نقطة على القوس (يمكن أن تقع النقاط خارج مستوى الرسم)",
"sketch.hint.break": "كسر: انقر على منحنى في الموضع الذي يُقسم عنده",
"sketch.hint.circle.center": "دائرة: انقر المركز، ثم نقطة على الدائرة",
"sketch.hint.constraint.generic": "قيد: اختر عناصر الرسم",
"sketch.hint.constraint.horizontal_vertical": "أفقي/رأسي: اختر خطًا، أو نقطتين، أو نقطة وخطًا؛ اضغط Shift مع الاستمرار لاختيار منتصف خط",
"sketch.hint.constraint.midpoint": "نقطة المنتصف: اختر نقطة وخطًا، أو خطين لتوسيط الثاني على الأول",
"sketch.hint.dimension": "البُعد: اختر عنصرًا أو عنصرين واكتب القيمة (أو انقر لوضعه أولًا)؛ انقر على بُعد لتعديله؛ اضغط Enter للتطبيق",
"sketch.hint.extend": "تمديد: انقر على منحنى قرب الطرف المراد تمديده (Shift: انقر منحنيين لتمديدهما معًا حتى نقطة التقائهما)",
"sketch.hint.fallback": "انقر في الرسم",
"sketch.hint.line": "خط: انقر النقاط؛ انقر بالزر الأيمن أو اضغط Esc للإنهاء",
"sketch.hint.line3d": "خط ثلاثي الأبعاد: انقر نقاطًا في الفضاء؛ انقر بالزر الأيمن أو اضغط Esc للإنهاء (يمكن أن تقع النقاط خارج مستوى الرسم)",
"sketch.hint.open_done": "تم الفتح",
"sketch.hint.point3d": "نقطة ثلاثية الأبعاد: انقر حيث توضع النقطة ثلاثية الأبعاد",
"sketch.hint.rectangle.center": "مستطيل من المركز: انقر المركز، ثم زاوية",
"sketch.hint.rectangle.rounded": "مستطيل مستدير الزوايا: انقر زاويتين متقابلتين (اضغط Tab للانتقال إلى خانة نصف القطر وكتابة نصف قطر الزوايا)",
"sketch.hint.rectangle.three_point": "مستطيل بثلاث نقاط: انقر نقطتين على أحد الأضلاع، ثم العرض",
"sketch.hint.rectangle.two_point": "مستطيل: انقر زاويتين متقابلتين",
"sketch.hint.slot": "شق: انقر الطرفين، ثم العرض",
"sketch.hint.spline3d": "منحنى شريحي ثلاثي الأبعاد: انقر نقاط الملاءمة في الفضاء ثلاثي الأبعاد؛ انقر بالزر الأيمن أو اضغط Esc للإنهاء",
"sketch.hint.trim": "قص: انقر على الجزء المراد إزالته من المنحنى، أو اسحب عبر الأجزاء المراد إزالتها",
"sketch.make_selection": "تحويل التحديد إلى {linetype}",
"sketch.open_ends": "مفتوحة {count}",
"sketch.status_conflict": "تعارض القيود",
@@ -4424,6 +4455,7 @@
"ui.3d_sketch": "رسم ثلاثي الأبعاد",
"ui.activate": "تنشيط",
"ui.active_component": "المكوّن النشط",
"ui.addToQuickAccess": "إضافة إلى الوصول السريع",
"ui.add_configuration": "إضافة تكوين",
"ui.add_parameter": "إضافة معامل",
"ui.agent_chat": "محادثة الوكيل",
@@ -4975,6 +5007,11 @@
"ui.factor": "المعامل",
"ui.fade": "تلاشٍ",
"ui.fade_length": "طول التلاشي",
"ui.fair_iterations": "عدد التكرارات",
"ui.fair_keep_edges": "الحفاظ على الحواف",
"ui.fair_strength": "شدة التنعيم",
"ui.fair_surface_title": "تنعيم السطح",
"ui.fair_tolerance_default": "0.001 من حجم الجسم",
"ui.feature_history": "سجل الميزات",
"ui.feature_options": "خيارات الميزة",
"ui.features": "الميزات",
@@ -5188,6 +5225,7 @@
"ui.manipulator_free_alt": "السحب الحر للمُعالج أيضًا مع Alt/Option",
"ui.manipulator_free_alt_tip": "عند التفعيل: Alt/Option أو Ctrl/Cmd على أسهم البثق / أداة التحويل يعطّل التقاط القيم. عند الإيقاف: Ctrl/Cmd فقط.",
"ui.manufacture": "التصنيع",
"ui.match_surface_title": "مطابقة السطح",
"ui.material_review.axes": "محاور محلية للمادة؛ لم يُحدد اتجاه للجسم.",
"ui.material_review.claims": "المصادر والتراخيص ادعاءات غير موثّقة. القيم المفقودة تبقى مجهولة.",
"ui.material_review.compressive_strength": "مقاومة الانضغاط",
@@ -5251,6 +5289,7 @@
"ui.options": "الخيارات",
"ui.orbit": "تدوير العرض",
"ui.orbit_around": "التدوير حول",
"ui.organize": "تنظيم",
"ui.orientation": "الاتجاه",
"ui.origin": "نقطة الأصل",
"ui.palette_construction_mode": "وضع الخطوط المساعدة (X)",
@@ -5339,6 +5378,7 @@
"ui.quality": "الجودة",
"ui.quantity": "الكمية",
"ui.quantity_2": "الكمية 2",
"ui.quick_access_full": "يتسع شريط الوصول السريع لـ {count} أمرًا؛ أزل أحدها لإضافة آخر",
"ui.quit": "خروج",
"ui.radial_context_menu": "قائمة سياقية دائرية",
"ui.radius": "نصف القطر",
@@ -5356,6 +5396,7 @@
"ui.relink_not_linked": "هذا المكوّن غير مرتبط بتصميم مصدر.",
"ui.relink_select_one": "حدد مكوّنًا مرتبطًا واحدًا أو إحدى نسخه أولًا.",
"ui.relink_source_all": "إعادة ربط المصدر (كل النسخ)…",
"ui.removeFromQuickAccess": "إزالة من الوصول السريع",
"ui.removeFromToolbar": "إزالة من شريط الأدوات",
"ui.removeSection": "إزالة المقطع",
"ui.render": "التصيير",
@@ -5364,6 +5405,8 @@
"ui.repeat_nothing": "لا يوجد ما يُكرَّر بعد: ابدأ أداة أو مربع حوار أولًا.",
"ui.replace_with_link_done": "أصبحت النسخ الآن مواضع للتصميم المرتبط.",
"ui.report_a_bug": "الإبلاغ عن خطأ",
"ui.report_characters_left_one": "الأحرف المتبقية: {count}",
"ui.report_characters_left_other": "الأحرف المتبقية: {count}",
"ui.reset": "إعادة تعيين",
"ui.reset_all": "إعادة تعيين الكل",
"ui.reset_the_settings_on_this_page": "إعادة تعيين إعدادات هذه الصفحة",
-2
View File
@@ -957,8 +957,6 @@
"cmd.inspect_accessibility": "Analiza dostupnosti",
"cmd.inspect_center_of_mass": "Centar mase",
"cmd.inspect_clearance": "Provjera zazora",
"cmd.inspect_colored_components": "Omogući boje komponenti",
"cmd.inspect_colored_components_disable": "Onemogući boje komponenti",
"cmd.inspect_compare": "Uporedi tijela",
"cmd.inspect_compare_picture": "Uporedi sliku",
"cmd.inspect_continuity": "Analiza kontinuiteta",
+2 -2
View File
@@ -957,8 +957,6 @@
"cmd.inspect_accessibility": "Analýza přístupnosti",
"cmd.inspect_center_of_mass": "Těžiště",
"cmd.inspect_clearance": "Vůle",
"cmd.inspect_colored_components": "Zapnout barvy komponent",
"cmd.inspect_colored_components_disable": "Vypnout barvy komponent",
"cmd.inspect_compare": "Porovnat tělesa",
"cmd.inspect_compare_picture": "Porovnat obrázek",
"cmd.inspect_continuity": "Analýza spojitosti",
@@ -3809,6 +3807,8 @@
"sample.plate.name": "Ukázková deska",
"sample.vents.about": "Drážky v obdélníkovém poli: řádky, sloupce a rozteč jsou parametry",
"sample.vents.name": "Větrací deska",
"scale.per_axis": "Podle os",
"scale.uniform": "Rovnoměrně",
"search.command_keys": "Enter spustit Shift+Enter spustit a ponechat otevřené Esc zavřít",
"search.commands": "Příkazy",
"search.design": "V tomto návrhu",
+2 -2
View File
@@ -957,8 +957,6 @@
"cmd.inspect_accessibility": "Tilgængelighedsanalyse",
"cmd.inspect_center_of_mass": "Massecentrum",
"cmd.inspect_clearance": "Frigangskontrol",
"cmd.inspect_colored_components": "Aktivér komponentfarver",
"cmd.inspect_colored_components_disable": "Deaktivér komponentfarver",
"cmd.inspect_compare": "Sammenlign emner",
"cmd.inspect_compare_picture": "Sammenlign billede",
"cmd.inspect_continuity": "Kontinuitetsanalyse",
@@ -3809,6 +3807,8 @@
"sample.plate.name": "Eksempelplade",
"sample.vents.about": "Slidser i et rektangulært mønster: Rækker, kolonner og deling er parametre",
"sample.vents.name": "Ventilationsplade",
"scale.per_axis": "Per akse",
"scale.uniform": "Ensartet",
"search.command_keys": "Enter kør Shift+Enter kør og hold åben Esc luk",
"search.commands": "Kommandoer",
"search.design": "I dette design",
+26 -2
View File
@@ -628,6 +628,8 @@
"Suppress Selected Folders": "Ausgewählte Ordner unterdrücken",
"Suppressed": "Unterdrückt",
"Surface": "Flächenkörper",
"Surface or face": "Flächenkörper oder Fläche",
"Surface or faces": "Flächenkörper oder Flächen",
"Surface, or faces to turn over": "Fläche oder umzudrehende Flächen",
"Surfaces": "Flächen",
"Swap Ends": "Enden vertauschen",
@@ -641,6 +643,7 @@
"Target": "Ziel",
"Target Body": "Zielkörper",
"Target Face (optional)": "Zielfläche (optional)",
"Target edges": "Zielkanten",
"Tear": "Reißen",
"Technical details": "Technische Details",
"The dashed sketch is a saved plane guide. Select a new face to repair the attachment.": "Die gestrichelte Skizze ist eine gespeicherte Ebenenführung. Wählen Sie eine neue Fläche, um die Anbindung zu reparieren.",
@@ -648,6 +651,7 @@
"The direction must be an axis or a straight sketch curve that lies in the sketch plane": "Die Richtung muss eine Achse oder eine gerade Skizzenkurve in der Skizzenebene sein",
"The mirror line cannot also be mirrored": "Die Spiegellinie kann nicht gleichzeitig gespiegelt werden",
"The mirror line must be a straight curve of this sketch, not an origin axis": "Die Spiegellinie muss eine gerade Kurve dieser Skizze sein, keine Ursprungsachse",
"The origin axis is perpendicular to the sketch plane, so it has no line in the plane": "Die Ursprungsachse steht senkrecht auf der Skizzenebene und hat daher keine Linie in ihr",
"The saved author occurrence is missing. Select a valid author placement before rebinding.": "Das gespeicherte Autorvorkommen fehlt. Wählen Sie vor dem erneuten Anbinden eine gültige Autorplatzierung.",
"Then": "Then",
"These features will be deleted:": "Diese Merkmale werden gelöscht:",
@@ -960,8 +964,6 @@
"cmd.inspect_accessibility": "Zugänglichkeitsanalyse",
"cmd.inspect_center_of_mass": "Massenschwerpunkt",
"cmd.inspect_clearance": "Freiraum",
"cmd.inspect_colored_components": "Komponentenfarben einschalten",
"cmd.inspect_colored_components_disable": "Komponentenfarben ausschalten",
"cmd.inspect_compare": "Körper vergleichen",
"cmd.inspect_continuity": "Kontinuitätsprüfung",
"cmd.inspect_curvature_comb": "Krümmungskamm-Analyse",
@@ -1390,6 +1392,7 @@
"dialog.computing": "Wird berechnet… {seconds} s",
"dialog.could_not_be_checked": "nicht prüfbar",
"dialog.custom_axis": "↖ Eigene Achse",
"dialog.cylinder_face_tip": "Die Fläche oder der Eckpunkt des Modells, auf dem der Zylinder steht: hier klicken, dann in der Ansicht darauf klicken. Leer gelassen platzieren ihn X, Y und Z.",
"dialog.dimension_insert_hint": "Doppelklicken Sie auf eine andere steuernde Bemaßung, um ihren Namen einzufügen",
"dialog.dimension_name": "Bemaßung: {name}",
"dialog.dimension_step_down": "Um {step} verringern",
@@ -1398,6 +1401,8 @@
"dialog.edit_title": "{title} bearbeiten",
"dialog.no_shown_body": "Kein sichtbarer Körper zum Ändern vorhanden. Blenden Sie einen Körper ein oder wählen Sie „Neuer Körper“.",
"dialog.pattern_sketch_not_object": "Körper oder Funktionen wählen: Eine Skizze ist keines von beiden. Ihre Kurven mit Muster oder Spiegeln in der Skizze wiederholen oder sie zuerst extrudieren.",
"dialog.primitive_point_tip": "Ein Punkt, der X, Y und Z füllt: hier klicken, dann einen Eckpunkt, eine Kante oder Fläche des Modells, einen Punkt einer Skizze oder den Ursprung anklicken.",
"dialog.reference_edge_tip": "Eine gerade Kante der gewählten Fläche, von der aus der Abstand darunter gemessen wird: hier klicken, dann die Kante anklicken.",
"dialog.select": "↖ Auswählen",
"dialog.selected_count": "↖ {count} ausgewählt",
"dialog.selection_constraint": "Bedingung {id}",
@@ -2021,6 +2026,8 @@
"nav.blender.help": "Mittlere Maustaste dreht, Umschalt+Mitte verschiebt, Strg+Mitte zoomt; rechtes Ziehen lässt die Kamera unverändert",
"nav.creo.help": "Ziehen mit der mittleren Maustaste dreht, Umschalt+Mitte verschiebt, Strg+Mitte zoomt beim vertikalen Ziehen (nach unten zum Vergrößern); das Mausrad zoomt",
"nav.creo.label": "Creo",
"nav.drag_keeps_mode.help": "Ein Ziehen mit der mittleren oder rechten Taste dreht, verschiebt oder zoomt weiter wie begonnen, bis die Taste losgelassen wird, egal welche Tasten dabei gedrückt oder losgelassen werden. Aus: Die gehaltenen Tasten entscheiden in jedem Moment des Ziehens.",
"nav.drag_keeps_mode.label": "Ziehen behält die Navigation bei, mit der es begann",
"nav.free_orbit.help": "Maus und 3D-Maus drehen über beide Pole hinweg und behalten die Rollneigung bei. Das Einrasten mit Umschalt ist deaktiviert.",
"nav.free_orbit.label": "Freie Drehung",
"nav.free_orbit.snap_help": "Maus und 3D-Maus drehen über beide Pole hinweg und behalten die Rollausrichtung bei. Halten Sie beim Ziehen zum Drehen die Umschalttaste gedrückt, um Blickrichtung und Rollausrichtung einrasten zu lassen.",
@@ -2278,6 +2285,8 @@
"prefs.browser_eye_left": "Auge neben dem Aufklapppfeil",
"prefs.browser_eye_left_note": "Browserzeilen zeigen ihr Auge zum Ein- und Ausblenden wie bisher neben dem Aufklapppfeil statt in einer Spalte am rechten Zeilenende.",
"prefs.browser_reveal_on_hover": "Beim Überfahren einblenden",
"prefs.browser_rows_start_closed": "Browser-Zeilen beginnen geschlossen",
"prefs.browser_rows_start_closed_note": "Platzierungen und Ordner im Browser beginnen geschlossen, sodass ein Entwurf mit vielen Komponenten seine obersten Zeilen zeigt. Jede öffnet sich mit ihrem Pfeil, „Alle ausklappen“ oder „Im Browser suchen“, das nur die Zeilen bis zu seinem Körper öffnet und die von der letzten Suche geöffneten wieder schließt. Ein in der 3D-Ansicht gewählter Körper lässt den Browser, wie er ist. Ist es aus, beginnen die Ordner geöffnet, Platzierungen so, wie „Komponenten zu Beginn aufgeklappt“ es festlegt, und eine Auswahl in der 3D-Ansicht öffnet den Browser bis zu ihrem Körper, wie bisher.",
"prefs.browser_show_note": "Angezeigt lässt den Browser auf dem Bildschirm. Beim Überfahren einblenden blendet ihn aus, bis der Zeiger über seinen Platz oder die schmale Lasche an seinem linken Rand kommt; er bleibt offen, solange der Zeiger darin ist oder ein Menü daraus offen ist.",
"prefs.browser_shown": "Angezeigt",
"prefs.built_in": "Integriert",
@@ -2418,6 +2427,8 @@
"prefs.layout_page.card": "Karte",
"prefs.layout_page.column": "Spalte",
"prefs.layout_page.hover": "Bei Mauszeiger",
"prefs.layout_page.hover_panel_menus": "Bereichsmenüs beim Darüberfahren wechseln",
"prefs.layout_page.hover_panel_menus_help": "Klicken Sie auf einen Bereichsnamen, um das Menü zu öffnen. Bewegen Sie den Zeiger auf einen anderen Bereichsnamen, um das Menü zu wechseln. Außerhalb des Menübands und des geöffneten Menüs schließt es sich.",
"prefs.layout_page.icons_labels": "Symbole + Beschriftungen",
"prefs.layout_page.ok_cancel": "OK und Abbrechen",
"prefs.layout_page.on_hover": "Bei Mauszeiger",
@@ -3253,9 +3264,11 @@
"render.frame_wide": "16:9 · Breitbild",
"render.gpu_depth": "Maximale Pfadtiefe",
"render.gpu_options_help": "Samples, Tiefe, Startwert und Belichtung werden mit diesem Setup gespeichert. Es wird nur die aktive Bildmethode gespeichert.",
"render.gpu_path_crashed": "SolidCraft wurde beim letzten Einrichten auf diesem Grafikadapter beendet, wahrscheinlich im Grafiktreiber, und richtet ihn deshalb nicht von selbst erneut ein. Verwenden Sie stattdessen Raytracing auf der CPU oder versuchen Sie es erneut (nach einem Treiberupdate wird es ebenfalls erneut versucht).",
"render.gpu_path_failed": "GPU-Pathtracer nicht verfügbar",
"render.gpu_path_first_setup": "Das erste Setup auf dieser GPU kann mehrere Minuten dauern; SolidCraft bleibt währenddessen bedienbar. Spätere Starts verwenden die kompilierten Shader erneut, sofern der Treiber sie behält.",
"render.gpu_path_preparing": "GPU-Pathtracer: Shader werden kompiliert…",
"render.gpu_path_retry": "Erneut versuchen",
"render.gpu_seed": "Zufallsstartwert",
"render.gpu_unavailable": "GPU-Pathtracing ist auf diesem Gerät nicht bereit.",
"render.ground_apply": "Boden anpassen und speichern",
@@ -4063,6 +4076,8 @@
"sample.plate.name": "Beispielplatte",
"sample.vents.about": "Langlöcher in einem Rechteckmuster: Zeilen, Spalten und Abstand sind Parameter",
"sample.vents.name": "Lüftungsplatte",
"scale.per_axis": "Je Achse",
"scale.uniform": "Gleichmäßig",
"search.command_keys": "Eingabe ausführen Umschalt+Eingabe ausführen und offen lassen Esc schließen",
"search.commands": "Befehle",
"search.design": "In diesem Design",
@@ -4920,6 +4935,11 @@
"ui.factor": "Faktor",
"ui.fade": "Auslauf",
"ui.fade_length": "Auslauflänge",
"ui.fair_iterations": "Iterationen",
"ui.fair_keep_edges": "Kanten behalten",
"ui.fair_strength": "Stärke",
"ui.fair_surface_title": "FLÄCHENBEGRADIGUNG",
"ui.fair_tolerance_default": "0,001 der Körpergröße",
"ui.feature_history": "Feature-Verlauf",
"ui.feature_options": "Funktionsoptionen",
"ui.features": "Funktionen",
@@ -5129,6 +5149,7 @@
"ui.manipulator_free_alt": "Freies Ziehen des Manipulators auch mit Alt/Option",
"ui.manipulator_free_alt_tip": "An: Alt/Option oder Strg/Cmd auf Extrusions-/Gizmo-Pfeilen deaktiviert das Einrasten von Werten. Aus: nur Strg/Cmd.",
"ui.manufacture": "Fertigung",
"ui.match_surface_title": "FLÄCHENANPASSUNG",
"ui.material_review.axes": "Materiallokale Achsen; keine Körperausrichtung zugewiesen.",
"ui.material_review.claims": "Quellen und Lizenzen sind ungeprüfte Angaben. Fehlende Werte bleiben unbekannt.",
"ui.material_review.compressive_strength": "Druckfestigkeit",
@@ -5192,6 +5213,7 @@
"ui.options": "Optionen",
"ui.orbit": "Drehen",
"ui.orbit_around": "Umkreisen um",
"ui.organize": "Organisieren",
"ui.orientation": "Ausrichtung",
"ui.origin": "Ursprung",
"ui.palette_construction_mode": "Konstruktionsmodus (X)",
@@ -5305,6 +5327,8 @@
"ui.repeat_nothing": "Noch nichts zu wiederholen: Starten Sie zuerst ein Werkzeug oder einen Dialog.",
"ui.replace_with_link_done": "Die Kopien sind jetzt Platzierungen des verknüpften Designs.",
"ui.report_a_bug": "Fehler melden",
"ui.report_characters_left_one": "{count} Zeichen übrig",
"ui.report_characters_left_other": "{count} Zeichen übrig",
"ui.reset": "Zurücksetzen",
"ui.reset_all": "Alle zurücksetzen",
"ui.reset_the_settings_on_this_page": "Einstellungen auf dieser Seite zurücksetzen",
+2 -2
View File
@@ -22,8 +22,7 @@
"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.inspect_colored_components": "Component Colours",
"cmd.parameters_favorite": "Favourite Parameter",
"cmd.render_aging_describe": "Layered Surface Aging",
"cmd.render_effects_describe": "Lens and Creative Effects",
@@ -267,6 +266,7 @@
"ui.layout_compact": "Compact",
"ui.layout_floating": "Floating",
"ui.material_review.claims": "Sources and licences are unverified claims. Missing values remain unknown.",
"ui.organize": "Organise",
"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",
+45 -2
View File
@@ -629,6 +629,8 @@
"Suppress Selected Folders": "Suppress Selected Folders",
"Suppressed": "Suppressed",
"Surface": "Surface",
"Surface or face": "Surface or face",
"Surface or faces": "Surface or faces",
"Surface, or faces to turn over": "Surface, or faces to turn over",
"Surfaces": "Surfaces",
"Swap Ends": "Swap Ends",
@@ -642,6 +644,7 @@
"Target": "Target",
"Target Body": "Target Body",
"Target Face (optional)": "Target Face (optional)",
"Target edges": "Target edges",
"Tear": "Tear",
"Technical details": "Technical details",
"The dashed sketch is a saved plane guide. Select a new face to repair the attachment.": "The dashed sketch is a saved plane guide. Select a new face to repair the attachment.",
@@ -649,6 +652,7 @@
"The direction must be an axis or a straight sketch curve that lies in the sketch plane": "The direction must be an axis or a straight sketch curve that lies in the sketch plane",
"The mirror line cannot also be mirrored": "The mirror line cannot also be mirrored",
"The mirror line must be a straight curve of this sketch, not an origin axis": "The mirror line must be a straight curve of this sketch, not an origin axis",
"The origin axis is perpendicular to the sketch plane, so it has no line in the plane": "The origin axis is perpendicular to the sketch plane, so it has no line in the plane",
"The saved author occurrence is missing. Select a valid author placement before rebinding.": "The saved author occurrence is missing. Select a valid author placement before rebinding.",
"Then": "Then",
"These features will be deleted:": "These features will be deleted:",
@@ -751,6 +755,8 @@
"ai.prefs.follow_camera": "Follow the camera",
"ai.prefs.follow_camera_off": "Automatic camera following is off.",
"ai.prefs.follow_camera_on": "The camera turns to face sketches the agent creates.",
"ai.prefs.instructions": "Custom instructions",
"ai.prefs.instructions_note": "Sent to the agent at the start of every chat, after SolidCraft's own instructions: how you like to work, rules to keep to. A change applies from the next chat (Connect or New chat).",
"ai.prefs.intro": "AI agents in SolidCraft.",
"ai.prefs.make_default": "Make default",
"ai.prefs.no_agents": "No agents yet. Add an agent or refresh to find installed agents.",
@@ -964,8 +970,7 @@
"cmd.inspect_analysis": "View Analysis",
"cmd.inspect_center_of_mass": "Center of Mass",
"cmd.inspect_clearance": "Clearance",
"cmd.inspect_colored_components": "Enable Component Colors",
"cmd.inspect_colored_components_disable": "Disable Component Colors",
"cmd.inspect_colored_components": "Component Colors",
"cmd.inspect_compare": "Compare Bodies",
"cmd.inspect_compare_picture": "Compare Picture",
"cmd.inspect_continuity": "Continuity Analysis",
@@ -1396,6 +1401,7 @@
"dialog.computing": "Computing… {seconds} s",
"dialog.could_not_be_checked": "could not be checked",
"dialog.custom_axis": "↖ Custom Axis",
"dialog.cylinder_face_tip": "The face or vertex of the model the cylinder stands on: click here, then click it in the view. Left empty, X, Y and Z place it.",
"dialog.dimension_insert_hint": "Click another driving dimension to insert its name",
"dialog.dimension_name": "Dimension: {name}",
"dialog.dimension_step_down": "Down by {step}",
@@ -1404,6 +1410,8 @@
"dialog.edit_title": "EDIT {title}",
"dialog.no_shown_body": "No shown body to change: show one, or choose New Body",
"dialog.pattern_sketch_not_object": "Pick bodies or features: a sketch is neither. Repeat its curves with the sketch's own Pattern or Mirror, or extrude it first.",
"dialog.primitive_point_tip": "A point that fills X, Y and Z: click here, then click a vertex, edge or face of the model, a point of a sketch, or the origin.",
"dialog.reference_edge_tip": "A straight edge of the picked face to measure the distance below from: click here, then click the edge.",
"dialog.select": "↖ Select",
"dialog.selected_count": "↖ {count} selected",
"dialog.selection_constraint": "Constraint {id}",
@@ -2040,6 +2048,8 @@
"nav.blender.help": "Middle drag orbits, Shift+middle pans, Ctrl+middle zooms; right drag leaves the camera alone",
"nav.creo.help": "Middle drag orbits, Shift+middle pans, Ctrl+middle drag vertically zooms (down to zoom in); scroll the wheel to zoom",
"nav.creo.label": "Creo",
"nav.drag_keeps_mode.help": "A middle or right drag goes on orbiting, panning or zooming as it began until the button is released, whatever keys are pressed or let go meanwhile. Off: the keys held decide at every moment of the drag.",
"nav.drag_keeps_mode.label": "A drag keeps the navigation it started with",
"nav.free_orbit.help": "Mouse and 3D mouse rotate through both poles and preserve roll. Shift axis snapping is disabled.",
"nav.free_orbit.label": "Free orbit",
"nav.free_orbit.snap_help": "Mouse and 3D mouse rotate through both poles and preserve roll. Hold Shift during an orbit drag to snap the view direction and roll.",
@@ -2276,6 +2286,9 @@
"prefs.auto_operation_note": "Join, New Body or Cut, from what the material touches.",
"prefs.axes": "Axes",
"prefs.axis": "Axis",
"prefs.background_choose": "Choose picture…",
"prefs.background_image": "Picture",
"prefs.background_unreadable": "Could not read the background picture",
"prefs.blur_cost": "Relative rendering cost. More bars usually mean more work.",
"prefs.blur_device": "Unavailable on this graphics device or rendering path",
"prefs.blur_guard": "Blur quality was reduced to keep frames responsive. The radius is unchanged.",
@@ -2297,6 +2310,8 @@
"prefs.browser_eye_left": "Eye beside the fold arrow",
"prefs.browser_eye_left_note": "Browser rows show their show/hide eye next to the fold arrow, as before, instead of in a column at the right end of the rows.",
"prefs.browser_reveal_on_hover": "Reveal on hover",
"prefs.browser_rows_start_closed": "Browser rows start closed",
"prefs.browser_rows_start_closed_note": "Placements and folders in the Browser start closed, so a design with many components shows its top-level rows. Each opens with its arrow, Expand All or Find in Browser, which opens just the rows down to its body and closes again the ones the last find opened. A body picked in the 3D view leaves the Browser as it is. When off, folders start open, placements start as Components start open says, and a pick in the 3D view opens the Browser down to its body, as before.",
"prefs.browser_show_note": "Shown keeps the browser on screen. Reveal on hover keeps it out of sight until the pointer comes over its place or the thin tab at its left edge, and it stays open while the pointer is inside it or a menu from it is open.",
"prefs.browser_shown": "Shown",
"prefs.built_in": "Built in",
@@ -2428,6 +2443,10 @@
"prefs.invert_curved_roll_arrows_note": "The two curved arrows at the cube's corner roll the view within the screen plane. On: the clockwise arrow rolls counterclockwise and the counterclockwise arrow rolls clockwise. Off: each arrow rolls the view the way it curls.",
"prefs.invert_navigator_arrows": "Invert the view cube rotation arrows",
"prefs.invert_navigator_arrows_note": "The arrows around the cube orbit the camera the way they point, as in Fusion and Inventor: the arrow on the left points right and swings the camera right. Off: an arrow rotates the cube toward the side it sits on, so the left arrow swings the camera left. The curved roll arrows are not affected.",
"prefs.keep_unsaved_also_asks": "Still ask when the tab closes",
"prefs.keep_unsaved_also_asks_tip": "With this off, the tab closes without the browser's box once every unsaved design's copy is current.",
"prefs.keep_unsaved_in_browser": "Keep unsaved work in this browser",
"prefs.keep_unsaved_in_browser_tip": "Each open design's unsaved changes are kept up to date in this browser's private storage. The next visit offers to restore them.",
"prefs.keep_work_on_tab_switch": "Keep dialogs and tools open when switching designs",
"prefs.keep_work_on_tab_switch_note": "Each design tab keeps its open dialog or tool, with what you picked and typed in it, and its toolbar tab. They are there again when you come back to the design. Off: switching designs cancels them and shows the Model tab.",
"prefs.labels": "Labels",
@@ -2439,6 +2458,8 @@
"prefs.layout_page.card": "Card",
"prefs.layout_page.column": "Column",
"prefs.layout_page.hover": "Hover",
"prefs.layout_page.hover_panel_menus": "Switch panel menus on hover",
"prefs.layout_page.hover_panel_menus_help": "Click a ribbon panel name to open its menu. Hover another panel name to switch. Move outside the ribbon and open menu to close it.",
"prefs.layout_page.icons_labels": "Icons + labels",
"prefs.layout_page.ok_cancel": "OK and Cancel",
"prefs.layout_page.on_hover": "On hover",
@@ -2756,6 +2777,12 @@
"recovery.just_now": "just now",
"recovery.min_ago": "{count} min ago",
"recovery.never_saved": "never saved",
"recovery.open_saved": "Open saved version",
"recovery.restore": "Restore",
"recovery.restore_intro": "Changes that weren't saved when this page last closed are kept in this browser.",
"recovery.restore_row": "{name}, changed {when}",
"recovery.restore_title": "Restore unsaved designs?",
"recovery.restore_title_one": "Restore unsaved design: {name}, changed {when}?",
"refused.change": "Couldn't change {body}.",
"refused.cut": "Couldn't cut {body}.",
"refused.face_curved": "One of its curved faces can't be used for modelling.",
@@ -3277,9 +3304,11 @@
"render.frame_wide": "16:9 · Widescreen",
"render.gpu_depth": "Maximum path depth",
"render.gpu_options_help": "Samples, depth, seed and exposure are saved with this setup. Only the active image method is saved.",
"render.gpu_path_crashed": "SolidCraft closed while setting it up on this graphics adapter last time, most likely inside the graphics driver, so it is not set up again by itself. Choose CPU ray tracing instead, or try again (a driver update also tries again).",
"render.gpu_path_failed": "GPU path tracer unavailable",
"render.gpu_path_first_setup": "The first setup on this GPU can take several minutes; SolidCraft stays usable meanwhile. Later starts reuse the compiled shaders where the driver keeps them.",
"render.gpu_path_preparing": "GPU path tracer: compiling shaders…",
"render.gpu_path_retry": "Try again",
"render.gpu_seed": "Random seed",
"render.gpu_unavailable": "GPU path tracing is not ready on this device.",
"render.ground_apply": "Fit and save ground",
@@ -4087,6 +4116,8 @@
"sample.plate.name": "Sample Plate",
"sample.vents.about": "Slots in a rectangular pattern: rows, columns and pitch are parameters",
"sample.vents.name": "Vent Plate",
"scale.per_axis": "Per axis",
"scale.uniform": "Uniform",
"search.command_keys": "Enter run Shift+Enter run and keep open Esc close",
"search.commands": "Commands",
"search.design": "In this design",
@@ -4424,6 +4455,7 @@
"ui.3d_sketch": "3D Sketch",
"ui.activate": "Activate",
"ui.active_component": "Active component",
"ui.addToQuickAccess": "Add to Quick Access",
"ui.add_configuration": "Add configuration",
"ui.add_parameter": "Add parameter",
"ui.agent_chat": "Agent chat",
@@ -4975,6 +5007,11 @@
"ui.factor": "Factor",
"ui.fade": "Fade",
"ui.fade_length": "Fade Length",
"ui.fair_iterations": "Iterations",
"ui.fair_keep_edges": "Keep edges",
"ui.fair_strength": "Strength",
"ui.fair_surface_title": "FAIR SURFACE",
"ui.fair_tolerance_default": "0.001 of body size",
"ui.feature_history": "Feature history",
"ui.feature_options": "Feature Options",
"ui.features": "Features",
@@ -5188,6 +5225,7 @@
"ui.manipulator_free_alt": "Manipulator free drag also on Alt/Option",
"ui.manipulator_free_alt_tip": "On: Alt/Option or Ctrl/Cmd on extrude / gizmo arrows disables value snapping. Off: Ctrl/Cmd only.",
"ui.manufacture": "Manufacture",
"ui.match_surface_title": "MATCH SURFACE",
"ui.material_review.axes": "Material-local axes; no body orientation is assigned.",
"ui.material_review.claims": "Sources and licenses are unverified claims. Missing values remain unknown.",
"ui.material_review.compressive_strength": "Compressive strength",
@@ -5251,6 +5289,7 @@
"ui.options": "Options",
"ui.orbit": "Orbit",
"ui.orbit_around": "Orbit around",
"ui.organize": "Organize",
"ui.orientation": "Orientation",
"ui.origin": "Origin",
"ui.palette_construction_mode": "Construction mode (X)",
@@ -5339,6 +5378,7 @@
"ui.quality": "Quality",
"ui.quantity": "Quantity",
"ui.quantity_2": "Quantity 2",
"ui.quick_access_full": "The quick-access bar holds {count} commands; remove one to add another",
"ui.quit": "Quit",
"ui.radial_context_menu": "Radial context menu",
"ui.radius": "Radius",
@@ -5356,6 +5396,7 @@
"ui.relink_not_linked": "This component is not linked to a source design.",
"ui.relink_select_one": "Select one linked component or one of its instances first.",
"ui.relink_source_all": "Relink Source (All Instances)…",
"ui.removeFromQuickAccess": "Remove from Quick Access",
"ui.removeFromToolbar": "Remove from Toolbar",
"ui.removeSection": "Remove Section",
"ui.render": "Render",
@@ -5364,6 +5405,8 @@
"ui.repeat_nothing": "Nothing to repeat yet: start a tool or dialog first.",
"ui.replace_with_link_done": "The copies are now placements of the linked design.",
"ui.report_a_bug": "Report a bug",
"ui.report_characters_left_one": "{count} character left",
"ui.report_characters_left_other": "{count} characters left",
"ui.reset": "Reset",
"ui.reset_all": "Reset All",
"ui.reset_the_settings_on_this_page": "Reset the settings on this page",
+5 -2
View File
@@ -963,8 +963,6 @@
"cmd.inspect_analysis": "Análisis de vista",
"cmd.inspect_center_of_mass": "Centro de masas",
"cmd.inspect_clearance": "Holgura",
"cmd.inspect_colored_components": "Activar colores de componente",
"cmd.inspect_colored_components_disable": "Desactivar colores de componente",
"cmd.inspect_compare": "Comparar cuerpos",
"cmd.inspect_compare_picture": "Comparar imagen",
"cmd.inspect_continuity": "Análisis de continuidad",
@@ -1394,6 +1392,7 @@
"dialog.computing": "Calculando… {seconds} s",
"dialog.could_not_be_checked": "no se pudo comprobar",
"dialog.custom_axis": "↖ Eje personalizado",
"dialog.cylinder_face_tip": "La cara o el vértice del modelo sobre el que se apoya el cilindro: haz clic aquí y después haz clic en él en la vista. Si se deja vacío, lo colocan X, Y y Z.",
"dialog.dimension_insert_hint": "Haga doble clic en otra cota de control para insertar su nombre",
"dialog.dimension_name": "Cota: {name}",
"dialog.dimension_step_down": "Bajar {step}",
@@ -1402,6 +1401,8 @@
"dialog.edit_title": "EDITAR {title}",
"dialog.no_shown_body": "No hay cuerpo mostrado para cambiar: muestra uno, o elige Nuevo cuerpo",
"dialog.pattern_sketch_not_object": "Selecciona cuerpos o operaciones: un boceto no es ninguno. Repite sus curvas con el Patrón o Espejo del propio boceto, o extrúdelas primero.",
"dialog.primitive_point_tip": "Un punto que rellena X, Y y Z: haz clic aquí y después en un vértice, una arista o una cara del modelo, en un punto de un boceto o en el origen.",
"dialog.reference_edge_tip": "Una arista recta de la cara elegida desde la que se mide la distancia de abajo: haz clic aquí y después en la arista.",
"dialog.select": "↖ Seleccionar",
"dialog.selected_count": "↖ {count} seleccionados",
"dialog.selection_constraint": "Restricción {id}",
@@ -4078,6 +4079,8 @@
"sample.plate.name": "Placa de ejemplo",
"sample.vents.about": "Ranuras en un patrón rectangular: las filas, las columnas y el paso son parámetros",
"sample.vents.name": "Placa de ventilación",
"scale.per_axis": "Por eje",
"scale.uniform": "Uniforme",
"search.command_keys": "Intro ejecutar Mayús+Intro ejecutar y mantener abierto Esc cerrar",
"search.commands": "Comandos",
"search.design": "En este diseño",
+2 -2
View File
@@ -964,8 +964,6 @@
"cmd.inspect_analysis": "Näytä analyysi",
"cmd.inspect_center_of_mass": "Massakeskipiste",
"cmd.inspect_clearance": "Välys",
"cmd.inspect_colored_components": "Ota komponenttivärit käyttöön",
"cmd.inspect_colored_components_disable": "Poista komponenttivärit käytöstä",
"cmd.inspect_compare": "Vertaa kappaleita",
"cmd.inspect_compare_picture": "Vertaa kuvaa",
"cmd.inspect_continuity": "Jatkuvuusanalyysi",
@@ -4079,6 +4077,8 @@
"sample.plate.name": "Esimerkkilevy",
"sample.vents.about": "Urat suorakulmaisessa kuviossa: rivit, sarakkeet ja jako ovat parametreja",
"sample.vents.name": "Tuuletuslevy",
"scale.per_axis": "Akseleittain",
"scale.uniform": "Tasainen",
"search.command_keys": "Enter suorita Shift+Enter suorita ja pidä auki Esc sulje",
"search.commands": "Komennot",
"search.design": "Tässä mallissa",
+7 -2
View File
@@ -956,8 +956,6 @@
"cmd.inspect_accessibility": "Analyse d'accessibilité",
"cmd.inspect_center_of_mass": "Centre de gravité",
"cmd.inspect_clearance": "Jeu",
"cmd.inspect_colored_components": "Activer les couleurs des composants",
"cmd.inspect_colored_components_disable": "Désactiver les couleurs des composants",
"cmd.inspect_compare": "Comparer les corps",
"cmd.inspect_compare_picture": "Comparer les images",
"cmd.inspect_continuity": "Analyse de continuité",
@@ -1365,11 +1363,14 @@
"dialog.computing": "Calcul… {seconds} s",
"dialog.could_not_be_checked": "non vérifiable",
"dialog.custom_axis": "↖ Axe personnalisé",
"dialog.cylinder_face_tip": "La face ou le sommet du modèle sur lequel le cylindre est posé : cliquer ici, puis dessus dans la vue. Laissé vide, X, Y et Z le placent.",
"dialog.dimension_insert_hint": "Double-cliquez sur une autre cote pilotante pour insérer son nom",
"dialog.dimension_name": "Cote : {name}",
"dialog.edit_title": "MODIFIER {title}",
"dialog.no_shown_body": "Aucun corps affiché à modifier : affichez-en un ou choisissez Nouveau corps",
"dialog.pattern_sketch_not_object": "Choisissez des corps ou des fonctions : une esquisse n'est ni l'un ni l'autre. Répétez ses courbes avec la Répétition ou la Symétrie de l'esquisse, ou extrudez-la d'abord.",
"dialog.primitive_point_tip": "Un point qui remplit X, Y et Z : cliquer ici, puis sur un sommet, une arête ou une face du modèle, sur un point d'une esquisse ou sur l'origine.",
"dialog.reference_edge_tip": "Une arête droite de la face choisie, depuis laquelle la distance ci-dessous est mesurée : cliquer ici, puis sur l'arête.",
"dialog.select": "↖ Sélectionner",
"dialog.selected_count": "↖ {count} sélectionné(s)",
"dialog.selection_constraint": "Contrainte {id}",
@@ -3001,9 +3002,11 @@
"render.frame_wide": "16:9 · Écran large",
"render.gpu_depth": "Profondeur maximale des chemins",
"render.gpu_options_help": "Les échantillons, la profondeur, la graine et l'exposition sont enregistrés avec ce préréglage. Seule la méthode d'image active est enregistrée.",
"render.gpu_path_crashed": "SolidCraft s’est fermé pendant sa dernière préparation sur cet adaptateur graphique, très probablement dans le pilote graphique ; il n’est donc plus préparé de lui-même. Utilisez le lancer de rayons sur le CPU, ou réessayez (une mise à jour du pilote réessaie aussi).",
"render.gpu_path_failed": "Traceur de chemins GPU indisponible",
"render.gpu_path_first_setup": "La première initialisation sur ce GPU peut prendre plusieurs minutes ; SolidCraft reste utilisable pendant ce temps. Les démarrages suivants réutilisent les shaders compilés lorsque le pilote les conserve.",
"render.gpu_path_preparing": "Traceur de chemins GPU : compilation des shaders…",
"render.gpu_path_retry": "Réessayer",
"render.gpu_seed": "Graine aléatoire",
"render.gpu_unavailable": "Le tracé de chemins GPU n'est pas prêt sur cet appareil.",
"render.ground_apply": "Ajuster et enregistrer le sol",
@@ -3811,6 +3814,8 @@
"sample.plate.name": "Plaque d'exemple",
"sample.vents.about": "Rainures en répétition rectangulaire : lignes, colonnes et pas sont des paramètres",
"sample.vents.name": "Plaque d'aération",
"scale.per_axis": "Par axe",
"scale.uniform": "Uniforme",
"search.command_keys": "Entrée exécuter Maj+Entrée exécuter et garder ouvert Échap fermer",
"search.commands": "Commandes",
"search.design": "Dans cette conception",
+2 -2
View File
@@ -957,8 +957,6 @@
"cmd.inspect_accessibility": "Análise de accesibilidade",
"cmd.inspect_center_of_mass": "Centro de masas",
"cmd.inspect_clearance": "Folga",
"cmd.inspect_colored_components": "Activar as cores dos compoñentes",
"cmd.inspect_colored_components_disable": "Desactivar as cores dos compoñentes",
"cmd.inspect_compare": "Comparar corpos",
"cmd.inspect_compare_picture": "Comparar a imaxe",
"cmd.inspect_continuity": "Análise de continuidade",
@@ -3809,6 +3807,8 @@
"sample.plate.name": "Placa de exemplo",
"sample.vents.about": "Ranuras nun patrón rectangular: filas, columnas e paso son parámetros",
"sample.vents.name": "Placa de ventilación",
"scale.per_axis": "Por eixo",
"scale.uniform": "Uniforme",
"search.command_keys": "Intro executar Maiús+Intro executar e manter aberto Esc pechar",
"search.commands": "Ordes",
"search.design": "Neste deseño",
+45 -2
View File
@@ -629,6 +629,8 @@
"Suppress Selected Folders": "השבתת התיקיות שנבחרו",
"Suppressed": "מושבת",
"Surface": "משטח",
"Surface or face": "משטח או פאה",
"Surface or faces": "משטח או פאות",
"Surface, or faces to turn over": "משטח, או פאות להיפוך",
"Surfaces": "משטחים",
"Swap Ends": "החלפת קצוות",
@@ -642,6 +644,7 @@
"Target": "יעד",
"Target Body": "גוף יעד",
"Target Face (optional)": "פאת יעד (אופציונלי)",
"Target edges": "מקצועות יעד",
"Tear": "קריעה",
"Technical details": "פרטים טכניים",
"The dashed sketch is a saved plane guide. Select a new face to repair the attachment.": "הסקיצה המקווקוות מסמנת מישור שמור. יש לבחור פאה חדשה כדי לתקן את ההצמדה.",
@@ -649,6 +652,7 @@
"The direction must be an axis or a straight sketch curve that lies in the sketch plane": "הכיוון חייב להיות ציר או עקומת סקיצה ישרה הנמצאת במישור הסקיצה",
"The mirror line cannot also be mirrored": "אי אפשר לשקף גם את קו השיקוף עצמו",
"The mirror line must be a straight curve of this sketch, not an origin axis": "קו השיקוף חייב להיות עקומה ישרה של הסקיצה הזו, ולא אחד מצירי הראשית",
"The origin axis is perpendicular to the sketch plane, so it has no line in the plane": "ציר הראשית ניצב למישור הסקיצה, ולכן אין לו קו במישור",
"The saved author occurrence is missing. Select a valid author placement before rebinding.": "מופע היצירה השמור חסר. יש לבחור מיקום יצירה תקין לפני הקישור מחדש.",
"Then": "אז",
"These features will be deleted:": "הפיצ'רים הבאים יימחקו:",
@@ -751,6 +755,8 @@
"ai.prefs.follow_camera": "לעקוב אחר המצלמה",
"ai.prefs.follow_camera_off": "מעקב המצלמה האוטומטי כבוי.",
"ai.prefs.follow_camera_on": "המצלמה פונה אל הסקיצות שהסוכן יוצר.",
"ai.prefs.instructions": "הוראות מותאמות אישית",
"ai.prefs.instructions_note": "נשלחות לסוכן בתחילת כל צ'אט, אחרי ההוראות של SolidCraft עצמה: דרך העבודה המועדפת עליך וכללים שיש להקפיד עליהם. שינוי חל מהצ'אט הבא (התחברות או צ'אט חדש).",
"ai.prefs.intro": "סוכני AI ב־SolidCraft.",
"ai.prefs.make_default": "לקבוע כברירת מחדל",
"ai.prefs.no_agents": "אין עדיין סוכנים. אפשר להוסיף סוכן או לרענן כדי למצוא סוכנים מותקנים.",
@@ -964,8 +970,7 @@
"cmd.inspect_analysis": "ניתוח תצוגה",
"cmd.inspect_center_of_mass": "מרכז מסה",
"cmd.inspect_clearance": "מרווח",
"cmd.inspect_colored_components": "הפעלת צבעי רכיבים",
"cmd.inspect_colored_components_disable": "ביטול צבעי רכיבים",
"cmd.inspect_colored_components": "צבעי רכיבים",
"cmd.inspect_compare": "השוואת גופים",
"cmd.inspect_compare_picture": "השוואה לתמונת ייחוס",
"cmd.inspect_continuity": "ניתוח רציפות",
@@ -1396,6 +1401,7 @@
"dialog.computing": "מחשב… {seconds} שנ'",
"dialog.could_not_be_checked": "לא ניתן היה לבדוק",
"dialog.custom_axis": "↖ ציר מותאם",
"dialog.cylinder_face_tip": "הפאה או הקודקוד של המודל שעליהם הגליל ניצב: יש ללחוץ כאן ואז עליהם בתצוגה. אם נשאר ריק, X, Y ו-Z ממקמים אותו.",
"dialog.dimension_insert_hint": "יש ללחוץ על מידה מניעה אחרת כדי להוסיף את שמה",
"dialog.dimension_name": "מידה: {name}",
"dialog.dimension_step_down": "הפחתה ב־{step}",
@@ -1404,6 +1410,8 @@
"dialog.edit_title": "עריכת {title}",
"dialog.no_shown_body": "אין גוף מוצג לשינוי: הציגו גוף, או בחרו „גוף חדש”",
"dialog.pattern_sketch_not_object": "בחרו גופים או פיצ'רים: סקיצה אינה אף אחד מהם. חזרו על העקומות שלה בעזרת המערך או השיקוף של הסקיצה עצמה, או בצעו לה שליפה קודם.",
"dialog.primitive_point_tip": "נקודה שממלאת את X, Y ו-Z: יש ללחוץ כאן ואז על קודקוד, מקצוע או פאה של המודל, על נקודה של סקיצה, או על הראשית.",
"dialog.reference_edge_tip": "מקצוע ישר של הפאה שנבחרה שממנו נמדד המרחק שלמטה: יש ללחוץ כאן ואז על המקצוע.",
"dialog.select": "↖ בחירה",
"dialog.selected_count": "↖ {count} נבחרו",
"dialog.selection_constraint": "אילוץ {id}",
@@ -2040,6 +2048,8 @@
"nav.blender.help": "גרירה אמצעית מסובבת, Shift וגרירה אמצעית מזיזים, Ctrl וגרירה אמצעית מבצעים זום; גרירה ימנית לא מזיזה את המצלמה",
"nav.creo.help": "גרירה בלחצן האמצעי מסובבת את התצוגה, Shift והלחצן האמצעי מזיזים אותה, Ctrl וגרירה אנכית בלחצן האמצעי משנים את הזום (גרירה מטה להגדלה); גלילת הגלגלת משנה גם היא את הזום",
"nav.creo.label": "Creo",
"nav.drag_keeps_mode.help": "גרירה בכפתור האמצעי או הימני ממשיכה לסובב, להזיז או לשנות זום כפי שהתחילה עד שמשחררים את הכפתור, בלי קשר למקשים שנלחצים או משתחררים בינתיים. כבוי: המקשים הלחוצים קובעים בכל רגע של הגרירה.",
"nav.drag_keeps_mode.label": "גרירה שומרת על הניווט שבו התחילה",
"nav.free_orbit.help": "העכבר והעכבר התלת־ממדי מסובבים דרך שני הקטבים ושומרים על הגלגול. הצמדת הצירים עם Shift מושבתת.",
"nav.free_orbit.label": "סיבוב חופשי",
"nav.free_orbit.snap_help": "העכבר והעכבר התלת־ממדי מסובבים דרך שני הקטבים ושומרים על הגלגול. יש להחזיק את Shift בזמן גרירת סיבוב כדי להצמיד את כיוון התצוגה ואת הגלגול.",
@@ -2276,6 +2286,9 @@
"prefs.auto_operation_note": "איחוד, גוף חדש או חיתוך, לפי מה שהחומר נוגע בו.",
"prefs.axes": "צירים",
"prefs.axis": "ציר",
"prefs.background_choose": "בחירת תמונה…",
"prefs.background_image": "תמונה",
"prefs.background_unreadable": "לא ניתן לקרוא את תמונת הרקע",
"prefs.blur_cost": "עלות עיבוד יחסית. יותר פסים מציינים בדרך כלל יותר עבודה.",
"prefs.blur_device": "לא זמין בהתקן הגרפי או בנתיב העיבוד הזה",
"prefs.blur_guard": "איכות הטשטוש הופחתה כדי לשמור על תגובתיות הפריימים. הרדיוס לא השתנה.",
@@ -2297,6 +2310,8 @@
"prefs.browser_eye_left": "סמל העין ליד חץ הקיפול",
"prefs.browser_eye_left_note": "שורות עץ התכן מציגות את סמל העין ליד חץ הקיפול, כמו קודם, במקום בעמודה בקצה הימני של השורות.",
"prefs.browser_reveal_on_hover": "הצגה בריחוף",
"prefs.browser_rows_start_closed": "שורות עץ התכן מוצגות סגורות",
"prefs.browser_rows_start_closed_note": "מיקומים ותיקיות בעץ התכן מוצגים סגורים, כך שתכן עם רכיבים רבים מציג את השורות העליונות שלו. כל שורה נפתחת בחץ שלה, ב„הרחבת הכול” או ב„איתור בעץ התכן”, שפותח רק את השורות שמובילות לגוף שלו וסוגר שוב את אלה שהאיתור הקודם פתח. גוף שנבחר בתצוגה התלת־ממדית משאיר את עץ התכן כפי שהוא. כשהאפשרות כבויה התיקיות מוצגות פתוחות, המיקומים מוצגים כפי שקובעת האפשרות „רכיבים פתוחים מההתחלה”, ובחירה בתצוגה התלת־ממדית פותחת את עץ התכן עד לגוף שלה, כמו קודם.",
"prefs.browser_show_note": "״מוצג״ משאיר את הדפדפן על המסך. ״הצגה בריחוף״ מסתיר אותו עד שהמצביע מגיע למקומו או ללשונית הדקה בקצהו השמאלי, והוא נשאר פתוח כל עוד המצביע בתוכו או שתפריט ממנו פתוח.",
"prefs.browser_shown": "מוצג",
"prefs.built_in": "מובנה",
@@ -2428,6 +2443,10 @@
"prefs.invert_curved_roll_arrows_note": "שני החצים המעוגלים בפינת הקובייה מסובבים את התצוגה במישור המסך. פועל: החץ עם כיוון השעון מסובב נגד כיוון השעון, והחץ נגד כיוון השעון מסובב עם כיוון השעון. כבוי: כל חץ מסובב את התצוגה לפי העקום שלו.",
"prefs.invert_navigator_arrows": "היפוך חצי סיבוב קוביית התצוגה",
"prefs.invert_navigator_arrows_note": "החצים סביב הקובייה מחוויים את המצלמה לכיוון שהחץ מצביע, כמו ב-Fusion ו-Inventor: החץ שמשמאל מצביע ימימה ומניע את המצלמה ימימה. כבוי: החץ מסובב את הקובייה אל הצד שבו הוא יושב, כך שחץ שמאל מניע את המצלמה שמאלה. חצי הגלילה המעוגלים אינם מושפעים.",
"prefs.keep_unsaved_also_asks": "לשאול גם כשהכרטיסייה נסגרת",
"prefs.keep_unsaved_also_asks_tip": "כשאפשרות זו כבויה, הכרטיסייה נסגרת ללא חלון הדפדפן לאחר שהעותק של כל תכן שלא נשמר עודכן.",
"prefs.keep_unsaved_in_browser": "לשמור עבודה שלא נשמרה בדפדפן זה",
"prefs.keep_unsaved_in_browser_tip": "השינויים שלא נשמרו בכל תכן פתוח נשמרים מעודכנים באחסון הפרטי של דפדפן זה. בביקור הבא תוצע שחזורם.",
"prefs.keep_work_on_tab_switch": "השארת תיבות דו־שיח וכלים פתוחים במעבר בין עיצובים",
"prefs.keep_work_on_tab_switch_note": "כל לשונית עיצוב שומרת את תיבת הדו־שיח או הכלי הפתוחים בה, עם מה שבחרת והקלדת בהם, ואת לשונית סרגל הכלים שלה. הם מחכים לך כשתחזור לעיצוב. כבוי: מעבר בין עיצובים מבטל אותם ומציג את הלשונית מודל.",
"prefs.labels": "תוויות",
@@ -2439,6 +2458,8 @@
"prefs.layout_page.card": "כרטיס",
"prefs.layout_page.column": "עמודה",
"prefs.layout_page.hover": "מעבר עכבר",
"prefs.layout_page.hover_panel_menus": "מעבר בין תפריטי לוחות בריחוף",
"prefs.layout_page.hover_panel_menus_help": "לחצו על שם לוח ברצועת הכלים כדי לפתוח את התפריט שלו. העבירו את הסמן מעל שם של לוח אחר כדי לעבור לתפריט שלו. הזיזו את הסמן אל מחוץ לרצועת הכלים ולתפריט הפתוח כדי לסגור אותו.",
"prefs.layout_page.icons_labels": "סמלים + תוויות",
"prefs.layout_page.ok_cancel": "אישור וביטול",
"prefs.layout_page.on_hover": "בעת מעבר עכבר",
@@ -2756,6 +2777,12 @@
"recovery.just_now": "זה עתה",
"recovery.min_ago": "לפני {count} דק'",
"recovery.never_saved": "לא נשמר מעולם",
"recovery.open_saved": "פתיחת הגרסה השמורה",
"recovery.restore": "שחזור",
"recovery.restore_intro": "שינויים שלא נשמרו כשדף זה נסגר לאחרונה שמורים בדפדפן זה.",
"recovery.restore_row": "{name}, שונה {when}",
"recovery.restore_title": "לשחזר תכנים שלא נשמרו?",
"recovery.restore_title_one": "לשחזר תכן שלא נשמר: {name}, שונה {when}?",
"refused.change": "לא ניתן היה לשנות את {body}.",
"refused.cut": "לא ניתן היה לחתוך את {body}.",
"refused.face_curved": "אחת הפאות המעוקלות שלו אינה ניתנת לשימוש במידול.",
@@ -3277,9 +3304,11 @@
"render.frame_wide": "16:9 · רחב",
"render.gpu_depth": "עומק מסלול מרבי",
"render.gpu_options_help": "הדגימות, העומק, הזרע והחשיפה נשמרים עם ההגדרה הזאת. נשמרת רק שיטת התמונה הפעילה.",
"render.gpu_path_crashed": "SolidCraft נסגר בזמן ההכנה על מתאם גרפי זה בפעם הקודמת, ככל הנראה בתוך מנהל ההתקן הגרפי, ולכן ההכנה לא תתבצע שוב מעצמה. יש להשתמש במעקב קרניים על המעבד במקום זאת, או לנסות שוב (עדכון מנהל ההתקן גם מנסה שוב).",
"render.gpu_path_failed": "מעקב המסלולים ב־GPU אינו זמין",
"render.gpu_path_first_setup": "ההגדרה הראשונה ב־GPU הזה עשויה להימשך כמה דקות; SolidCraft נשאר זמין לשימוש בינתיים. הפעלות מאוחרות יותר משתמשות שוב בשיידרים שהודרו, כשמנהל ההתקן שומר אותן.",
"render.gpu_path_preparing": "מעקב מסלולים ב־GPU: הידור שיידרים…",
"render.gpu_path_retry": "לנסות שוב",
"render.gpu_seed": "זרע אקראי",
"render.gpu_unavailable": "מעקב מסלולים ב־GPU אינו מוכן במכשיר הזה.",
"render.ground_apply": "התאמת הקרקע ושמירתה",
@@ -4087,6 +4116,8 @@
"sample.plate.name": "לוח לדוגמה",
"sample.vents.about": "חריצים במערך מלבני: מספר השורות, מספר העמודות והפסיעה הם פרמטרים",
"sample.vents.name": "לוח אוורור",
"scale.per_axis": "לפי ציר",
"scale.uniform": "אחיד",
"search.command_keys": "Enter הרצה Shift+Enter הרצה והשארה פתוח Esc סגירה",
"search.commands": "פקודות",
"search.design": "בתכן זה",
@@ -4424,6 +4455,7 @@
"ui.3d_sketch": "סקיצה תלת־ממדית",
"ui.activate": "הפעלה",
"ui.active_component": "רכיב פעיל",
"ui.addToQuickAccess": "הוספה לגישה מהירה",
"ui.add_configuration": "הוספת תצורה",
"ui.add_parameter": "הוספת פרמטר",
"ui.agent_chat": "צ'אט עם סוכן",
@@ -4975,6 +5007,11 @@
"ui.factor": "מקדם",
"ui.fade": "דעיכה",
"ui.fade_length": "אורך דעיכה",
"ui.fair_iterations": "מספר איטרציות",
"ui.fair_keep_edges": "שמירה על המקצועות",
"ui.fair_strength": "עוצמה",
"ui.fair_surface_title": "החלקת משטח",
"ui.fair_tolerance_default": "0.001 מגודל הגוף",
"ui.feature_history": "ציר הזמן",
"ui.feature_options": "אפשרויות פיצ'ר",
"ui.features": "פיצ'רים",
@@ -5188,6 +5225,7 @@
"ui.manipulator_free_alt": "גרירה חופשית של הידית גם עם Alt/Option",
"ui.manipulator_free_alt_tip": "פעיל: Alt/Option או Ctrl/Cmd על חיצי השליפה / הידית מבטלים הצמדת ערכים. כבוי: Ctrl/Cmd בלבד.",
"ui.manufacture": "ייצור",
"ui.match_surface_title": "התאמת משטח",
"ui.material_review.axes": "צירים מקומיים של החומר; לא הוגדרה אוריינטציה לגוף.",
"ui.material_review.claims": "המקורות והרישיונות הם טענות שלא אומתו. ערכים חסרים נשארים לא ידועים.",
"ui.material_review.compressive_strength": "חוזק לחיצה",
@@ -5251,6 +5289,7 @@
"ui.options": "אפשרויות",
"ui.orbit": "סיבוב מבט",
"ui.orbit_around": "סיבוב מבט סביב",
"ui.organize": "ארגון",
"ui.orientation": "אוריינטציה",
"ui.origin": "ראשית",
"ui.palette_construction_mode": "מצב עזר (X)",
@@ -5339,6 +5378,7 @@
"ui.quality": "איכות",
"ui.quantity": "כמות",
"ui.quantity_2": "כמות 2",
"ui.quick_access_full": "סרגל הגישה המהירה מכיל {count} פקודות; יש להסיר אחת כדי להוסיף אחרת",
"ui.quit": "יציאה",
"ui.radial_context_menu": "תפריט הקשר מעגלי",
"ui.radius": "רדיוס",
@@ -5356,6 +5396,7 @@
"ui.relink_not_linked": "רכיב זה אינו מקושר לתכן מקור.",
"ui.relink_select_one": "יש לבחור תחילה רכיב מקושר אחד או אחד המופעים שלו.",
"ui.relink_source_all": "קישור מחדש למקור (כל המופעים)…",
"ui.removeFromQuickAccess": "הסרה מהגישה המהירה",
"ui.removeFromToolbar": "הסרה מסרגל הכלים",
"ui.removeSection": "הסרת מקטע",
"ui.render": "הדמיה",
@@ -5364,6 +5405,8 @@
"ui.repeat_nothing": "אין עדיין מה לחזור עליו: התחל קודם כלי או תיבת דו־שיח.",
"ui.replace_with_link_done": "העותקים הם עכשיו מיקומים של התכן המקושר.",
"ui.report_a_bug": "דיווח על באג",
"ui.report_characters_left_one": "תווים שנותרו: {count}",
"ui.report_characters_left_other": "תווים שנותרו: {count}",
"ui.reset": "איפוס",
"ui.reset_all": "איפוס הכול",
"ui.reset_the_settings_on_this_page": "איפוס ההגדרות בדף זה",
-2
View File
@@ -957,8 +957,6 @@
"cmd.inspect_accessibility": "Analiza dostupnosti",
"cmd.inspect_center_of_mass": "Središte mase",
"cmd.inspect_clearance": "Provjera zazora",
"cmd.inspect_colored_components": "Omogući boje komponenti",
"cmd.inspect_colored_components_disable": "Onemogući boje komponenti",
"cmd.inspect_compare": "Usporedi tijela",
"cmd.inspect_compare_picture": "Usporedi sliku",
"cmd.inspect_continuity": "Analiza kontinuiteta",
+2 -2
View File
@@ -957,8 +957,6 @@
"cmd.inspect_accessibility": "Hozzáférhetőség-elemzés",
"cmd.inspect_center_of_mass": "Tömegközéppont",
"cmd.inspect_clearance": "Hézag",
"cmd.inspect_colored_components": "Komponensszínek bekapcsolása",
"cmd.inspect_colored_components_disable": "Komponensszínek kikapcsolása",
"cmd.inspect_compare": "Testek összehasonlítása",
"cmd.inspect_compare_picture": "Kép összehasonlítása",
"cmd.inspect_continuity": "Folytonosságelemzés",
@@ -3809,6 +3807,8 @@
"sample.plate.name": "Mintalemez",
"sample.vents.about": "Hornyok téglalap mintában: a sorok, oszlopok és osztás paraméterek",
"sample.vents.name": "Szellőzőlemez",
"scale.per_axis": "Tengelyenként",
"scale.uniform": "Egyenletes",
"search.command_keys": "Enter futtatás Shift+Enter futtatás és nyitva tartás Esc bezárás",
"search.commands": "Parancsok",
"search.design": "Ebben a tervben",
+2 -2
View File
@@ -957,8 +957,6 @@
"cmd.inspect_accessibility": "Analisis Aksesibilitas",
"cmd.inspect_center_of_mass": "Pusat Massa",
"cmd.inspect_clearance": "Kelonggaran",
"cmd.inspect_colored_components": "Aktifkan Warna Komponen",
"cmd.inspect_colored_components_disable": "Nonaktifkan Warna Komponen",
"cmd.inspect_compare": "Bandingkan Bodi",
"cmd.inspect_compare_picture": "Bandingkan dengan Gambar",
"cmd.inspect_continuity": "Analisis Kontinuitas",
@@ -3809,6 +3807,8 @@
"sample.plate.name": "Pelat Contoh",
"sample.vents.about": "Slot dalam pola persegi panjang: baris, kolom, dan pitch adalah parameter",
"sample.vents.name": "Pelat Ventilasi",
"scale.per_axis": "Per sumbu",
"scale.uniform": "Seragam",
"search.command_keys": "Enter jalankan Shift+Enter jalankan dan tetap buka Esc tutup",
"search.commands": "Perintah",
"search.design": "Di desain ini",
+5 -2
View File
@@ -957,8 +957,6 @@
"cmd.inspect_accessibility": "Analisi di accessibilità",
"cmd.inspect_center_of_mass": "Centro di massa",
"cmd.inspect_clearance": "Distanza libera",
"cmd.inspect_colored_components": "Attiva colori dei componenti",
"cmd.inspect_colored_components_disable": "Disattiva colori dei componenti",
"cmd.inspect_compare": "Confronta corpi",
"cmd.inspect_compare_picture": "Confronta immagine",
"cmd.inspect_continuity": "Analisi di continuità",
@@ -1366,11 +1364,14 @@
"dialog.computing": "Calcolo in corso… {seconds} s",
"dialog.could_not_be_checked": "impossibile verificare",
"dialog.custom_axis": "↖ Asse personalizzato",
"dialog.cylinder_face_tip": "La faccia o il vertice del modello su cui poggia il cilindro: fai clic qui, poi su di esso nella vista. Se resta vuoto, lo posizionano X, Y e Z.",
"dialog.dimension_insert_hint": "Fai doppio clic su un’altra quota di controllo per inserirne il nome",
"dialog.dimension_name": "Quota: {name}",
"dialog.edit_title": "MODIFICA {title}",
"dialog.no_shown_body": "Nessun corpo visibile da modificare: mostrane uno o scegli Nuovo corpo",
"dialog.pattern_sketch_not_object": "Seleziona corpi o lavorazioni: uno schizzo non è nessuno dei due. Ripeti le sue curve con Ripetizione o Specchia dello schizzo stesso, oppure estrudilo prima.",
"dialog.primitive_point_tip": "Un punto che compila X, Y e Z: fai clic qui, poi su un vertice, uno spigolo o una faccia del modello, su un punto di uno schizzo oppure sull'origine.",
"dialog.reference_edge_tip": "Uno spigolo rettilineo della faccia scelta da cui si misura la distanza sotto: fai clic qui, poi sullo spigolo.",
"dialog.select": "↖ Seleziona",
"dialog.selected_count": "↖ Selezionati: {count}",
"dialog.selection_constraint": "Vincolo {id}",
@@ -3812,6 +3813,8 @@
"sample.plate.name": "Piastra di esempio",
"sample.vents.about": "Asole in ripetizione rettangolare: righe, colonne e passo sono parametri",
"sample.vents.name": "Piastra di ventilazione",
"scale.per_axis": "Per asse",
"scale.uniform": "Uniforme",
"search.command_keys": "Invio esegui Maiusc+Invio esegui e tieni aperto Esc chiudi",
"search.commands": "Comandi",
"search.design": "In questo progetto",
+45 -2
View File
@@ -629,6 +629,8 @@
"Suppress Selected Folders": "選択したフォルダーを抑制",
"Suppressed": "抑制",
"Surface": "サーフェス",
"Surface or face": "サーフェスまたは面",
"Surface or faces": "サーフェスまたは面",
"Surface, or faces to turn over": "サーフェス、または裏返す面",
"Surfaces": "サーフェス",
"Swap Ends": "端点を入れ替え",
@@ -642,6 +644,7 @@
"Target": "ターゲット",
"Target Body": "ターゲットボディ",
"Target Face (optional)": "対象面 (オプション)",
"Target edges": "ターゲットのエッジ",
"Tear": "引き裂き",
"Technical details": "技術情報",
"The dashed sketch is a saved plane guide. Select a new face to repair the attachment.": "破線のスケッチは保存された平面ガイドです。新しい面を選択してアタッチメントを修復してください。",
@@ -649,6 +652,7 @@
"The direction must be an axis or a straight sketch curve that lies in the sketch plane": "方向は、軸、またはスケッチ平面上にある直線のスケッチ曲線にしてください",
"The mirror line cannot also be mirrored": "ミラー線自体をミラーすることはできません",
"The mirror line must be a straight curve of this sketch, not an origin axis": "ミラー線は、原点の軸ではなく、このスケッチの直線にしてください",
"The origin axis is perpendicular to the sketch plane, so it has no line in the plane": "原点軸はスケッチ平面に垂直なため、平面内に線がありません",
"The saved author occurrence is missing. Select a valid author placement before rebinding.": "保存された作成元のオカレンスが見つかりません。再バインドする前に有効な作成元配置を選択してください。",
"Then": "次を実行 (Then)",
"These features will be deleted:": "以下のフィーチャーが削除されます:",
@@ -751,6 +755,8 @@
"ai.prefs.follow_camera": "カメラを追従",
"ai.prefs.follow_camera_off": "カメラの自動追従はオフです。",
"ai.prefs.follow_camera_on": "エージェントが作成するスケッチにカメラを向けます。",
"ai.prefs.instructions": "カスタム指示",
"ai.prefs.instructions_note": "各チャットの開始時に、SolidCraft 自身の指示の後に続けてエージェントへ送られます。作業の進め方や守ってほしいルールを書いてください。変更は次のチャット(接続または新しいチャット)から適用されます。",
"ai.prefs.intro": "SolidCraftのAIエージェント。",
"ai.prefs.make_default": "既定にする",
"ai.prefs.no_agents": "エージェントはまだありません。追加するか、更新してインストール済みのエージェントを検索してください。",
@@ -964,8 +970,7 @@
"cmd.inspect_analysis": "表示解析",
"cmd.inspect_center_of_mass": "重心",
"cmd.inspect_clearance": "クリアランス",
"cmd.inspect_colored_components": "コンポーネントの色分けを有効化",
"cmd.inspect_colored_components_disable": "コンポーネントの色分けを無効化",
"cmd.inspect_colored_components": "コンポーネントの色分け",
"cmd.inspect_compare": "ボディを比較",
"cmd.inspect_compare_picture": "参考画像と比較",
"cmd.inspect_continuity": "連続性解析",
@@ -1396,6 +1401,7 @@
"dialog.computing": "計算中… {seconds} 秒",
"dialog.could_not_be_checked": "チェックできません",
"dialog.custom_axis": "↖ カスタム軸",
"dialog.cylinder_face_tip": "円柱が立つモデルの面または頂点: ここをクリックしてから、ビューでそれをクリック。空のままなら X、Y、Z で配置します。",
"dialog.dimension_insert_hint": "別の駆動寸法をクリックして名前を挿入",
"dialog.dimension_name": "寸法: {name}",
"dialog.dimension_step_down": "{step} 減らす",
@@ -1404,6 +1410,8 @@
"dialog.edit_title": "{title}を編集",
"dialog.no_shown_body": "変更できる表示中のボディがありません。ボディを表示するか、新規ボディを選択してください",
"dialog.pattern_sketch_not_object": "ボディかフィーチャーを選んでください。スケッチはどちらでもありません。曲線はスケッチ内のパターンかミラーで繰り返すか、先に押し出してください。",
"dialog.primitive_point_tip": "X、Y、Z を埋める点: ここをクリックしてから、モデルの頂点、エッジ、面、スケッチの点、または原点をクリック。",
"dialog.reference_edge_tip": "下の距離を測る基準となる、選んだ面の直線エッジ: ここをクリックしてからエッジをクリック。",
"dialog.select": "↖ 選択",
"dialog.selected_count": "↖ {count} 個選択中",
"dialog.selection_constraint": "拘束 {id}",
@@ -2040,6 +2048,8 @@
"nav.blender.help": "中ドラッグで回転、Shift+中ドラッグでパン、Ctrl+中ドラッグでズーム。右ドラッグではカメラは動きません。",
"nav.creo.help": "マウスの中ボタンドラッグで回転、Shift+中ボタンで平行移動、Ctrl+中ボタンの上下ドラッグでズーム(下方向で拡大)。ホイールの回転でもズーム",
"nav.creo.label": "Creo",
"nav.drag_keeps_mode.help": "中ボタンまたは右ボタンのドラッグは、途中でキーを押したり離したりしても、ボタンを離すまで開始時の回転・パン・ズームを続けます。オフにすると、ドラッグ中の各時点で押しているキーによって決まります。",
"nav.drag_keeps_mode.label": "ドラッグは開始時のナビゲーションを維持",
"nav.free_orbit.help": "マウスと3Dマウスで、極点を越えて回転し、ロールを保ちます。\nShiftによる軸スナップは無効になります。",
"nav.free_orbit.label": "自由回転",
"nav.free_orbit.snap_help": "マウスと3Dマウスで両極を越えて回転でき、ロールも保持されます。オービットのドラッグ中にShiftを押すと、視線方向とロールがスナップします。",
@@ -2276,6 +2286,9 @@
"prefs.auto_operation_note": "材料が接触する対象に応じて結合、新規ボディ、切り取りを選びます。",
"prefs.axes": "軸",
"prefs.axis": "軸",
"prefs.background_choose": "画像を選択…",
"prefs.background_image": "画像",
"prefs.background_unreadable": "背景画像を読み込めませんでした",
"prefs.blur_cost": "相対的な描画負荷です。バーが多いほど、通常は処理量が増えます。",
"prefs.blur_device": "このグラフィックスデバイスまたは描画経路では利用できません",
"prefs.blur_guard": "画面の応答性を保つため、ぼかしの品質を下げました。半径は変更されていません。",
@@ -2297,6 +2310,8 @@
"prefs.browser_eye_left": "折りたたみ矢印の隣の目のアイコン",
"prefs.browser_eye_left_note": "ブラウザーの各行の表示切り替えを、行の右端の列ではなく、以前のように折りたたみ矢印の隣に表示します。",
"prefs.browser_reveal_on_hover": "ホバーで表示",
"prefs.browser_rows_start_closed": "ブラウザーの行を閉じた状態で始める",
"prefs.browser_rows_start_closed_note": "ブラウザーの配置とフォルダーを閉じた状態で表示するため、多数のコンポーネントを含むデザインでは最上位の行だけが表示されます。各行は矢印、「すべて展開」または「ブラウザで検索」で開きます。「ブラウザで検索」はボディまでの行だけを開き、前回の検索で開いた行を再び閉じます。3Dビューでボディを選んでもブラウザーはそのままです。オフのときはフォルダーが開いた状態で始まり、配置は「コンポーネントを最初から展開」の設定に従い、3Dビューで選ぶとブラウザーがそのボディまで開きます(以前と同じ)。",
"prefs.browser_show_note": "「表示」はブラウザーを常に画面に表示します。「ホバーで表示」はポインターがその位置か左端の細いタブに来るまで隠し、ポインターが中にある間やそのメニューが開いている間は表示したままにします。",
"prefs.browser_shown": "表示",
"prefs.built_in": "組み込み",
@@ -2428,6 +2443,10 @@
"prefs.invert_curved_roll_arrows_note": "キューブの隅にある2つのカーブした矢印は、画面内でビューをロールします。オン: 時計回りの矢印が反時計回りに、反時計回りの矢印が時計回りにロールします。オフ: 各矢印は、そのカーブの向きにビューをロールします。",
"prefs.invert_navigator_arrows": "ビューキューブの回転矢印を反転",
"prefs.invert_navigator_arrows_note": "キューブの周りの矢印が、矢印の指す方向にカメラを回します(Fusion や Inventor と同じ動き):左の矢印は右を指し、カメラを右に振ります。オフ: 矢印は、そのある面の方へキューブを回すため、左の矢印はカメラを左に振ります。カーブしたロール矢印は影響を受けません。",
"prefs.keep_unsaved_also_asks": "タブを閉じるときにも確認する",
"prefs.keep_unsaved_also_asks_tip": "オフにすると、未保存の各デザインのコピーが最新であれば、ブラウザーの確認なしでタブが閉じます。",
"prefs.keep_unsaved_in_browser": "未保存の作業をこのブラウザーに保持する",
"prefs.keep_unsaved_in_browser_tip": "開いている各デザインの未保存の変更を、このブラウザーのプライベートストレージに常に最新の状態で保持します。次回のアクセス時に復元を提案します。",
"prefs.keep_work_on_tab_switch": "デザインを切り替えてもダイアログとツールを開いたままにする",
"prefs.keep_work_on_tab_switch_note": "各デザインのタブが、開いているダイアログまたはツール(選択や入力した内容を含む)とツールバーのタブを保持します。デザインに戻ると元のまま表示されます。オフ: デザインを切り替えるとそれらはキャンセルされ、モデル タブが表示されます。",
"prefs.labels": "ラベル",
@@ -2439,6 +2458,8 @@
"prefs.layout_page.card": "カード",
"prefs.layout_page.column": "列",
"prefs.layout_page.hover": "ホバー時",
"prefs.layout_page.hover_panel_menus": "ポイントしてパネルメニューを切り替える",
"prefs.layout_page.hover_panel_menus_help": "リボンのパネル名をクリックしてメニューを開きます。別のパネル名にポインターを合わせると切り替わります。リボンと開いているメニューの外に移動すると閉じます。",
"prefs.layout_page.icons_labels": "アイコン+ラベル",
"prefs.layout_page.ok_cancel": "OK とキャンセル",
"prefs.layout_page.on_hover": "ホバー時",
@@ -2756,6 +2777,12 @@
"recovery.just_now": "たった今",
"recovery.min_ago": "{count}分前",
"recovery.never_saved": "未保存",
"recovery.open_saved": "保存済みのバージョンを開く",
"recovery.restore": "復元",
"recovery.restore_intro": "このページを前回閉じたときに保存されていなかった変更は、このブラウザーに保持されています。",
"recovery.restore_row": "{name}、{when}に変更",
"recovery.restore_title": "未保存のデザインを復元しますか?",
"recovery.restore_title_one": "未保存のデザイン {name}({when}に変更)を復元しますか?",
"refused.change": "{body} を変更できませんでした。",
"refused.cut": "{body} をカットできませんでした。",
"refused.face_curved": "曲面の 1 つがモデリングに使えません。",
@@ -3277,9 +3304,11 @@
"render.frame_wide": "16:9 · ワイドスクリーン",
"render.gpu_depth": "最大パス深度",
"render.gpu_options_help": "サンプル数、深度、シード、露出をこの設定に保存します。保存するのは選択中の画像方式だけです。",
"render.gpu_path_crashed": "前回このグラフィックアダプターでの準備中に SolidCraft が終了しました。グラフィックドライバー内で終了した可能性が高いため、自動では再度準備しません。代わりに CPU レイトレーシングを使うか、もう一度試してください(ドライバーを更新した場合も再試行されます)。",
"render.gpu_path_failed": "GPUパストレーサーを利用できません",
"render.gpu_path_first_setup": "このGPUでの初回セットアップには数分かかることがあります。その間もSolidCraftは使用できます。以降の起動では、ドライバーが保持していればコンパイル済みのシェーダーを再利用します。",
"render.gpu_path_preparing": "GPUパストレーサー:シェーダーをコンパイル中…",
"render.gpu_path_retry": "もう一度試す",
"render.gpu_seed": "乱数シード",
"render.gpu_unavailable": "このデバイスでは GPU パストレーシングの準備ができていません。",
"render.ground_apply": "床をフィットして保存",
@@ -4087,6 +4116,8 @@
"sample.plate.name": "サンプル プレート",
"sample.vents.about": "矩形パターンに並んだスロット: 行数、列数、ピッチはパラメータ",
"sample.vents.name": "通気プレート",
"scale.per_axis": "軸ごと",
"scale.uniform": "均等",
"search.command_keys": "Enter 実行 Shift+Enter 実行して開いたままにする Esc 閉じる",
"search.commands": "コマンド",
"search.design": "このデザイン内",
@@ -4424,6 +4455,7 @@
"ui.3d_sketch": "3D スケッチ",
"ui.activate": "アクティブ化",
"ui.active_component": "アクティブなコンポーネント",
"ui.addToQuickAccess": "クイックアクセスに追加",
"ui.add_configuration": "構成を追加",
"ui.add_parameter": "パラメーターを追加",
"ui.agent_chat": "エージェントチャット",
@@ -4975,6 +5007,11 @@
"ui.factor": "倍率",
"ui.fade": "切り上げ",
"ui.fade_length": "切り上げ長さ",
"ui.fair_iterations": "反復回数",
"ui.fair_keep_edges": "エッジを保持",
"ui.fair_strength": "強度",
"ui.fair_surface_title": "サーフェスフェアリング",
"ui.fair_tolerance_default": "ボディサイズの 0.001 倍",
"ui.feature_history": "フィーチャー履歴",
"ui.feature_options": "フィーチャーオプション",
"ui.features": "フィーチャー",
@@ -5188,6 +5225,7 @@
"ui.manipulator_free_alt": "マニピュレータのフリードラッグを Alt/Option でも有効",
"ui.manipulator_free_alt_tip": "オン: 押し出し/ギズモ矢印で Alt/Option または Ctrl/Cmd が値スナップを無効にします。オフ: Ctrl/Cmd のみ。",
"ui.manufacture": "製造",
"ui.match_surface_title": "サーフェス一致",
"ui.material_review.axes": "材料ローカル軸。ボディの方向は割り当てません。",
"ui.material_review.claims": "出典とライセンスの申告は未確認です。欠落値は不明のままです。",
"ui.material_review.compressive_strength": "圧縮強度",
@@ -5251,6 +5289,7 @@
"ui.options": "オプション",
"ui.orbit": "回転",
"ui.orbit_around": "回転中心",
"ui.organize": "整理",
"ui.orientation": "向き",
"ui.origin": "原点",
"ui.palette_construction_mode": "補助線モード (X)",
@@ -5339,6 +5378,7 @@
"ui.quality": "品質",
"ui.quantity": "数量",
"ui.quantity_2": "数量 2",
"ui.quick_access_full": "クイックアクセスバーに置けるコマンドは {count} 個までです。追加するには 1 つ削除してください",
"ui.quit": "終了",
"ui.radial_context_menu": "パイ メニュー",
"ui.radius": "半径",
@@ -5356,6 +5396,7 @@
"ui.relink_not_linked": "このコンポーネントは参照元の設計にリンクされていません。",
"ui.relink_select_one": "リンクされたコンポーネントまたはそのインスタンスを1つ選択してください。",
"ui.relink_source_all": "参照元を再リンク(すべてのインスタンス)…",
"ui.removeFromQuickAccess": "クイックアクセスから削除",
"ui.removeFromToolbar": "ツールバーから削除",
"ui.removeSection": "セクションを解除",
"ui.render": "レンダリング",
@@ -5364,6 +5405,8 @@
"ui.repeat_nothing": "まだ繰り返すものがありません。先にツールかダイアログを開始してください。",
"ui.replace_with_link_done": "コピーはリンクされたデザインの配置になりました。",
"ui.report_a_bug": "不具合を報告",
"ui.report_characters_left_one": "残り {count} 文字",
"ui.report_characters_left_other": "残り {count} 文字",
"ui.reset": "リセット",
"ui.reset_all": "すべてリセット",
"ui.reset_the_settings_on_this_page": "このページの設定をリセット",
+1 -2
View File
@@ -963,8 +963,7 @@
"cmd.inspect_analysis": "ანალიზის ნახვა",
"cmd.inspect_center_of_mass": "მასის ცენტრი",
"cmd.inspect_clearance": "ღრეჩო",
"cmd.inspect_colored_components": "კომპონენტების ფერების ჩართვა",
"cmd.inspect_colored_components_disable": "კომპონენტების ფერების გამორთვა",
"cmd.inspect_colored_components": "კომპონენტების ფერები",
"cmd.inspect_compare": "სხეულების შედარება",
"cmd.inspect_compare_picture": "სურათთან შედარება",
"cmd.inspect_continuity": "უწყვეტობის ანალიზი",
+21
View File
@@ -544,6 +544,8 @@
"Suppress": "비활성화",
"Suppressed": "억제됨",
"Surface": "곡면",
"Surface or face": "곡면 또는 면",
"Surface or faces": "곡면 또는 면",
"Surface, or faces to turn over": "곡면 또는 뒤집을 면",
"Surfaces": "곡면",
"Swap Ends": "양 끝 바꾸기",
@@ -555,6 +557,7 @@
"Target": "대상",
"Target Body": "대상 바디",
"Target Face (optional)": "대상 면 (선택 사항)",
"Target edges": "대상 모서리",
"Tear": "찢김",
"Technical details": "기술 세부 정보",
"The dashed sketch is a saved plane guide. Select a new face to repair the attachment.": "점선 스케치는 저장된 평면 가이드입니다. 새 면을 선택하여 부착을 복구하세요.",
@@ -1206,6 +1209,7 @@
"dialog.computing": "계산 중… {seconds}초",
"dialog.could_not_be_checked": "확인할 수 없음",
"dialog.custom_axis": "↖ 사용자 정의 축",
"dialog.cylinder_face_tip": "원기둥이 놓일 모델의 면 또는 꼭짓점: 여기를 클릭한 다음 뷰에서 그것을 클릭. 비워 두면 X, Y, Z로 배치합니다.",
"dialog.dimension_insert_hint": "다른 구동 치수를 두 번 클릭하여 이름 삽입",
"dialog.dimension_name": "치수: {name}",
"dialog.dimension_step_down": "{step} 줄이기",
@@ -1213,6 +1217,8 @@
"dialog.dimension_step_up": "{step} 늘리기",
"dialog.edit_title": "{title} 편집",
"dialog.pattern_sketch_not_object": "바디나 피처를 선택하세요. 스케치는 둘 다 아닙니다. 곡선은 스케치 안의 패턴이나 대칭 복사로 반복하거나, 먼저 돌출하세요.",
"dialog.primitive_point_tip": "X, Y, Z를 채우는 점: 여기를 클릭한 다음 모델의 꼭짓점, 모서리, 면, 스케치의 점 또는 원점을 클릭.",
"dialog.reference_edge_tip": "아래 거리를 재는 기준이 되는, 선택한 면의 직선 모서리: 여기를 클릭한 다음 모서리를 클릭.",
"dialog.select": "↖ 선택",
"dialog.selected_count": "↖ {count}개 선택됨",
"dialog.selection_constraint": "구속 조건 {id}",
@@ -1465,6 +1471,8 @@
"nav.blender.help": "가운데 드래그는 회전, Shift+가운데 드래그는 이동, Ctrl+가운데 드래그는 확대/축소입니다. 오른쪽 드래그는 카메라에 영향을 주지 않습니다",
"nav.creo.help": "가운데 버튼을 끌면 회전, Shift+가운데 버튼은 이동, Ctrl+가운데 버튼을 위아래로 끌면 확대/축소합니다(아래로 끌면 확대). 휠을 굴려도 확대/축소합니다",
"nav.creo.label": "Creo",
"nav.drag_keeps_mode.help": "가운데 또는 오른쪽 버튼 드래그는 도중에 키를 누르거나 떼어도 버튼을 놓을 때까지 시작할 때의 회전, 이동 또는 확대/축소를 계속합니다. 끄면 드래그하는 동안 누르고 있는 키가 매 순간 결정합니다.",
"nav.drag_keeps_mode.label": "드래그는 시작할 때의 탐색 방식을 유지",
"nav.free_orbit.help": "마우스와 3D 마우스로 롤을 유지하며 양 극점을 넘어 회전합니다. Shift 축 스냅은 비활성화됩니다.",
"nav.free_orbit.label": "자유 회전",
"nav.free_orbit.snap_help": "마우스와 3D 마우스로 양쪽 극점을 지나 회전할 수 있으며 롤 각도가 유지됩니다. 궤도 회전 드래그 중 Shift를 누르면 보기 방향과 롤 각도가 스냅됩니다.",
@@ -1685,6 +1693,8 @@
"prefs.browser_components_open_note": "브라우저의 모든 구성요소가 이전처럼 펼쳐진 상태로 시작합니다. 끄면 직접 펼칠 때까지 접혀 있습니다.",
"prefs.browser_eye_left": "접기 화살표 옆의 눈 아이콘",
"prefs.browser_eye_left_note": "브라우저 행의 표시 아이콘을 행 오른쪽 끝의 열이 아니라 이전처럼 접기 화살표 옆에 표시합니다.",
"prefs.browser_rows_start_closed": "브라우저 행을 접힌 상태로 시작",
"prefs.browser_rows_start_closed_note": "브라우저의 배치와 폴더가 접힌 상태로 시작하므로, 컴포넌트가 많은 디자인은 최상위 행만 보여 줍니다. 각 행은 화살표, '모두 펼치기' 또는 '브라우저에서 찾기'로 엽니다. '브라우저에서 찾기'는 바디까지의 행만 열고, 지난번 찾기로 연 행은 다시 접습니다. 3D 뷰에서 바디를 골라도 브라우저는 그대로입니다. 끄면 폴더가 펼친 상태로 시작하고, 배치는 '구성요소를 펼친 상태로 시작' 설정을 따르며, 3D 뷰에서 고르면 브라우저가 그 바디까지 열립니다(이전과 같음).",
"prefs.built_in": "기본 제공",
"prefs.button_left": "왼쪽 드래그",
"prefs.button_middle": "가운데 드래그",
@@ -1808,6 +1818,8 @@
"prefs.layout_page.card": "카드",
"prefs.layout_page.column": "열",
"prefs.layout_page.hover": "마우스를 올릴 때",
"prefs.layout_page.hover_panel_menus": "포인터를 올려 패널 메뉴 전환",
"prefs.layout_page.hover_panel_menus_help": "리본의 패널 이름을 클릭하여 메뉴를 엽니다. 다른 패널 이름에 포인터를 올리면 메뉴가 전환됩니다. 리본과 열린 메뉴 밖으로 포인터를 이동하면 메뉴가 닫힙니다.",
"prefs.layout_page.icons_labels": "아이콘 + 레이블",
"prefs.layout_page.ok_cancel": "확인과 취소",
"prefs.layout_page.on_hover": "마우스를 올릴 때",
@@ -2952,6 +2964,8 @@
"sample.plate.name": "샘플 플레이트",
"sample.vents.about": "직사각형 패턴의 슬롯: 행, 열, 피치가 매개변수",
"sample.vents.name": "통풍 플레이트",
"scale.per_axis": "축별",
"scale.uniform": "균일",
"search.command_keys": "Enter 실행 Shift+Enter 실행 후 열어 두기 Esc 닫기",
"search.commands": "명령",
"search.design": "이 디자인에서",
@@ -3717,6 +3731,11 @@
"ui.factor": "배율",
"ui.fade": "페이드",
"ui.fade_length": "페이드 길이",
"ui.fair_iterations": "반복 횟수",
"ui.fair_keep_edges": "모서리 유지",
"ui.fair_strength": "강도",
"ui.fair_surface_title": "서피스 페어링",
"ui.fair_tolerance_default": "바디 크기의 0.001배",
"ui.feature_history": "피처 기록",
"ui.feature_options": "피처 옵션",
"ui.features": "피처",
@@ -3916,6 +3935,7 @@
"ui.manipulator_free_alt": "Alt/Option으로도 매니퓰레이터 자유 드래그",
"ui.manipulator_free_alt_tip": "켬: 돌출 / 기즈모 화살표에서 Alt/Option 또는 Ctrl/Cmd를 누르면 값 스냅이 해제됩니다. 끔: Ctrl/Cmd만 해당.",
"ui.manufacture": "제조",
"ui.match_surface_title": "서피스 일치",
"ui.material_review.axes": "재료 로컬 축입니다. 바디 방향을 지정하지 않습니다.",
"ui.material_review.claims": "출처와 라이선스는 검증되지 않은 주장입니다. 누락된 값은 알 수 없는 상태로 유지됩니다.",
"ui.material_review.compressive_strength": "압축 강도",
@@ -3975,6 +3995,7 @@
"ui.options": "옵션",
"ui.orbit": "회전",
"ui.orbit_around": "궤도 회전 기준",
"ui.organize": "정리",
"ui.orientation": "배향",
"ui.origin": "원점",
"ui.palette_construction_mode": "보조선 모드 (X)",
+2 -2
View File
@@ -957,8 +957,6 @@
"cmd.inspect_accessibility": "Analisis Kebolehcapaian",
"cmd.inspect_center_of_mass": "Pusat Jisim",
"cmd.inspect_clearance": "Kelegaan",
"cmd.inspect_colored_components": "Aktifkan Warna Komponen",
"cmd.inspect_colored_components_disable": "Nyahaktifkan Warna Komponen",
"cmd.inspect_compare": "Bandingkan Jasad",
"cmd.inspect_compare_picture": "Banding dengan Imej",
"cmd.inspect_continuity": "Analisis Keselanjaran",
@@ -3809,6 +3807,8 @@
"sample.plate.name": "Plat Contoh",
"sample.vents.about": "Corak slot segi empat tepat: baris, lajur dan pic sebagai parameter",
"sample.vents.name": "Plat Pengudaraan",
"scale.per_axis": "Mengikut paksi",
"scale.uniform": "Seragam",
"search.command_keys": "Enter jalankan Shift+Enter jalankan dan kekalkan terbuka Esc tutup",
"search.commands": "Arahan",
"search.design": "Dalam reka bentuk ini",
+2 -2
View File
@@ -957,8 +957,6 @@
"cmd.inspect_accessibility": "Tilgjengelighetsanalyse",
"cmd.inspect_center_of_mass": "Massesenter",
"cmd.inspect_clearance": "Klaring",
"cmd.inspect_colored_components": "Aktiver komponentfarger",
"cmd.inspect_colored_components_disable": "Deaktiver komponentfarger",
"cmd.inspect_compare": "Sammenlign kropper",
"cmd.inspect_compare_picture": "Sammenlign bilde",
"cmd.inspect_continuity": "Kontinuitetsanalyse",
@@ -3809,6 +3807,8 @@
"sample.plate.name": "Eksempelplate",
"sample.vents.about": "Spor i et rektangulært mønster: rader, kolonner og stigning er parametere",
"sample.vents.name": "Ventilasjonsplate",
"scale.per_axis": "Per akse",
"scale.uniform": "Jevn",
"search.command_keys": "Enter kjør Shift+Enter kjør og behold åpen Esc lukk",
"search.commands": "Kommandoer",
"search.design": "I dette designet",
+2 -2
View File
@@ -957,8 +957,6 @@
"cmd.inspect_accessibility": "Toegankelijkheidsanalyse",
"cmd.inspect_center_of_mass": "Zwaartepunt",
"cmd.inspect_clearance": "Speling",
"cmd.inspect_colored_components": "Componentkleuren inschakelen",
"cmd.inspect_colored_components_disable": "Componentkleuren uitschakelen",
"cmd.inspect_compare": "Lichamen vergelijken",
"cmd.inspect_compare_picture": "Afbeelding vergelijken",
"cmd.inspect_continuity": "Continuïteitsanalyse",
@@ -3820,6 +3818,8 @@
"sample.plate.name": "Voorbeeldplaat",
"sample.vents.about": "Sleuven in een rechthoekig patroon: rijen, kolommen en spoed zijn parameters",
"sample.vents.name": "Ventilatieplaat",
"scale.per_axis": "Per as",
"scale.uniform": "Uniform",
"search.command_keys": "Enter uitvoeren Shift+Enter uitvoeren en open houden Esc sluiten",
"search.commands": "Opdrachten",
"search.design": "In dit ontwerp",
@@ -0,0 +1,42 @@
{
"en": {
"ai.http.allow": "Allow insecure HTTP",
"ai.http.confirm": "Allow for this endpoint",
"ai.http.warning": "The API key and conversation will be sent unencrypted. Anyone with access to the network may read or change them. Use this only on a network you trust."
},
"ar": {
"ai.http.allow": "السماح باتصال HTTP غير آمن",
"ai.http.confirm": "السماح لهذه النقطة الطرفية",
"ai.http.warning": "سيُرسَل مفتاح API والمحادثة دون تشفير. يمكن لأي شخص لديه وصول إلى الشبكة قراءتهما أو تغييرهما. استخدم هذا الخيار فقط على شبكة تثق بها."
},
"de": {
"ai.http.allow": "Unsicheres HTTP erlauben",
"ai.http.confirm": "Für diesen Endpunkt erlauben",
"ai.http.warning": "Der API-Schlüssel und die Unterhaltung werden unverschlüsselt gesendet. Jeder mit Zugriff auf das Netzwerk kann sie lesen oder verändern. Verwenden Sie dies nur in einem vertrauenswürdigen Netzwerk."
},
"he": {
"ai.http.allow": "אפשר HTTP לא מאובטח",
"ai.http.confirm": "אפשר עבור נקודת קצה זו",
"ai.http.warning": "מפתח ה־API והשיחה יישלחו ללא הצפנה. כל מי שיש לו גישה לרשת יכול לקרוא או לשנות אותם. יש להשתמש באפשרות זו רק ברשת מהימנה."
},
"ja": {
"ai.http.allow": "安全でない HTTP を許可",
"ai.http.confirm": "このエンドポイントで許可",
"ai.http.warning": "API キーと会話は暗号化されずに送信されます。ネットワークにアクセスできる人は、内容を読み取ったり改ざんしたりする可能性があります。信頼できるネットワークでのみ使用してください。"
},
"ko": {
"ai.http.allow": "보안되지 않은 HTTP 허용",
"ai.http.confirm": "이 엔드포인트에 허용",
"ai.http.warning": "API 키와 대화가 암호화되지 않은 상태로 전송됩니다. 네트워크에 접근할 수 있는 누구나 이를 읽거나 변경할 수 있습니다. 신뢰하는 네트워크에서만 사용하세요."
},
"vi": {
"ai.http.allow": "Cho phép HTTP không bảo mật",
"ai.http.confirm": "Cho phép với điểm cuối này",
"ai.http.warning": "Khóa API và cuộc trò chuyện sẽ được gửi mà không mã hóa. Bất kỳ ai có quyền truy cập mạng đều có thể đọc hoặc thay đổi chúng. Chỉ sử dụng trên mạng mà bạn tin cậy."
},
"zh": {
"ai.http.allow": "允许不安全的 HTTP",
"ai.http.confirm": "允许此端点",
"ai.http.warning": "API 密钥和对话将以未加密的方式发送。任何能够访问该网络的人都可能读取或篡改它们。请仅在可信网络上使用。"
}
}
@@ -1,7 +0,0 @@
{
"en": {
"document.newer_file": "Saved by a newer SolidCraft (format {format}, this version reads up to {reads}). If something's missing, update.",
"document.newer_file_change": "Saved by a newer SolidCraft (format {format} with `{change}`, which this version doesn't know). If something's missing, update.",
"document.newer_file_not_read": "Not read: {what}. Saving keeps it."
}
}
-2
View File
@@ -934,8 +934,6 @@
"cmd.inspect_accessibility": "Analiza dostępności",
"cmd.inspect_center_of_mass": "Środek masy",
"cmd.inspect_clearance": "Prześwit",
"cmd.inspect_colored_components": "Włącz kolory komponentów",
"cmd.inspect_colored_components_disable": "Wyłącz kolory komponentów",
"cmd.inspect_compare": "Porównaj bryły",
"cmd.inspect_continuity": "Analiza ciągłości",
"cmd.inspect_curvature_comb": "Analiza grzebienia krzywizny",
+2
View File
@@ -1408,6 +1408,8 @@
"sample.plate.name": "Placa de exemplo",
"sample.vents.about": "Rasgos em padrão retangular: linhas, colunas e passo são parâmetros",
"sample.vents.name": "Placa de ventilação",
"scale.per_axis": "Por eixo",
"scale.uniform": "Uniforme",
"search.command_keys": "Enter executar Shift+Enter executar e manter aberto Esc fechar",
"search.commands": "Comandos",
"search.design": "Neste projeto",
+2 -2
View File
@@ -957,8 +957,6 @@
"cmd.inspect_accessibility": "Analiza accesibilității",
"cmd.inspect_center_of_mass": "Centru de masă",
"cmd.inspect_clearance": "Joc",
"cmd.inspect_colored_components": "Activează culorile componentelor",
"cmd.inspect_colored_components_disable": "Dezactivează culorile componentelor",
"cmd.inspect_compare": "Compară corpurile",
"cmd.inspect_compare_picture": "Compară imaginea",
"cmd.inspect_continuity": "Analiza continuității",
@@ -3809,6 +3807,8 @@
"sample.plate.name": "Placă exemplu",
"sample.vents.about": "Canale într-o matrice dreptunghiulară: rândurile, coloanele și pasul sunt parametri",
"sample.vents.name": "Placă de ventilație",
"scale.per_axis": "Pe axe",
"scale.uniform": "Uniform",
"search.command_keys": "Enter execută Shift+Enter execută și păstrează deschis Esc închide",
"search.commands": "Comenzi",
"search.design": "În acest proiect",
+3 -2
View File
@@ -647,6 +647,7 @@
"The direction must be an axis or a straight sketch curve that lies in the sketch plane": "Направление должно быть осью или прямолинейной кривой эскиза, лежащей в плоскости эскиза",
"The mirror line cannot also be mirrored": "Линию зеркала нельзя одновременно отражать",
"The mirror line must be a straight curve of this sketch, not an origin axis": "Линия зеркала должна быть прямолинейной кривой этого эскиза, а не осью начала координат",
"The origin axis is perpendicular to the sketch plane, so it has no line in the plane": "Ось начала координат перпендикулярна плоскости эскиза, поэтому линии в этой плоскости нет",
"The saved author occurrence is missing. Select a valid author placement before rebinding.": "Сохранённое исходное вхождение отсутствует. Выберите корректное исходное положение перед перепривязкой.",
"Then": "Затем",
"These features will be deleted:": "Эти операции будут удалены:",
@@ -956,8 +957,6 @@
"cmd.inspect_accessibility": "Анализ доступности",
"cmd.inspect_center_of_mass": "Центр масс",
"cmd.inspect_clearance": "Зазор",
"cmd.inspect_colored_components": "Включить цвета компонентов",
"cmd.inspect_colored_components_disable": "Отключить цвета компонентов",
"cmd.inspect_compare": "Сравнить тела",
"cmd.inspect_compare_picture": "Сравнить с изображением",
"cmd.inspect_continuity": "Анализ непрерывности",
@@ -3752,6 +3751,8 @@
"sample.plate.name": "Образец пластины",
"sample.vents.about": "Пазы в прямоугольном массиве: число рядов, столбцов и шаг заданы параметрами",
"sample.vents.name": "Вентиляционная пластина",
"scale.per_axis": "По осям",
"scale.uniform": "Равномерно",
"search.command_keys": "Enter выполнить Shift+Enter выполнить и оставить открытым Esc закрыть",
"search.commands": "Команды",
"search.design": "В этом дизайне",
-2
View File
@@ -957,8 +957,6 @@
"cmd.inspect_accessibility": "Анализа доступности",
"cmd.inspect_center_of_mass": "Центар масе",
"cmd.inspect_clearance": "Провера зазора",
"cmd.inspect_colored_components": "Омогући боје компоненти",
"cmd.inspect_colored_components_disable": "Онемогући боје компоненти",
"cmd.inspect_compare": "Упореди тела",
"cmd.inspect_compare_picture": "Упореди слику",
"cmd.inspect_continuity": "Анализа континуитета",
+2 -2
View File
@@ -957,8 +957,6 @@
"cmd.inspect_accessibility": "Tillgänglighetsanalys",
"cmd.inspect_center_of_mass": "Masscentrum",
"cmd.inspect_clearance": "Frigång",
"cmd.inspect_colored_components": "Aktivera komponentfärger",
"cmd.inspect_colored_components_disable": "Inaktivera komponentfärger",
"cmd.inspect_compare": "Jämför kroppar",
"cmd.inspect_compare_picture": "Jämför bild",
"cmd.inspect_continuity": "Kontinuitetsanalys",
@@ -3810,6 +3808,8 @@
"sample.plate.name": "Exempelplatta",
"sample.vents.about": "Spår i ett rektangulärt mönster: rader, kolumner och delning är parametrar",
"sample.vents.name": "Ventilationsplatta",
"scale.per_axis": "Per axel",
"scale.uniform": "Likformig",
"search.command_keys": "Enter kör Shift+Enter kör och behåll öppen Esc stäng",
"search.commands": "Kommandon",
"search.design": "I denna konstruktion",
+2 -2
View File
@@ -957,8 +957,6 @@
"cmd.inspect_accessibility": "Erişilebilirlik analizi",
"cmd.inspect_center_of_mass": "Kütle merkezi",
"cmd.inspect_clearance": "Açıklık",
"cmd.inspect_colored_components": "Bileşen Renklerini Etkinleştir",
"cmd.inspect_colored_components_disable": "Bileşen Renklerini Devre Dışı Bırak",
"cmd.inspect_compare": "Gövdeleri karşılaştır",
"cmd.inspect_compare_picture": "Görüntüyle karşılaştır",
"cmd.inspect_continuity": "Süreklilik kontrolü",
@@ -3809,6 +3807,8 @@
"sample.plate.name": "Örnek plaka",
"sample.vents.about": "Dikdörtgen desende yuvalar: satırlar, sütunlar ve aralık parametredir",
"sample.vents.name": "Havalandırma plakası",
"scale.per_axis": "Eksen başına",
"scale.uniform": "Tekdüze",
"search.command_keys": "Enter çalıştır Shift+Enter çalıştır ve açık tut Esc kapat",
"search.commands": "Komutlar",
"search.design": "Bu tasarımda",
+2 -2
View File
@@ -963,8 +963,6 @@
"cmd.inspect_analysis": "Перегляд аналізу",
"cmd.inspect_center_of_mass": "Центр мас",
"cmd.inspect_clearance": "Зазор",
"cmd.inspect_colored_components": "Увімкнути кольори компонентів",
"cmd.inspect_colored_components_disable": "Вимкнути кольори компонентів",
"cmd.inspect_compare": "Порівняти тіла",
"cmd.inspect_compare_picture": "Порівняти зображення",
"cmd.inspect_continuity": "Аналіз неперервності",
@@ -4076,6 +4074,8 @@
"sample.plate.name": "Зразкова пластина",
"sample.vents.about": "Пази в прямокутному масиві: рядки, стовпці та крок є параметрами",
"sample.vents.name": "Вентиляційна пластина",
"scale.per_axis": "По осях",
"scale.uniform": "Рівномірно",
"search.command_keys": "Enter виконати Shift+Enter виконати й залишити відкритим Esc закрити",
"search.commands": "Команди",
"search.design": "У цьому проєкті",
+45 -2
View File
@@ -629,6 +629,8 @@
"Suppress Selected Folders": "Bỏ kích hoạt các thư mục đã chọn",
"Suppressed": "Bị tạm nén",
"Surface": "Bề mặt",
"Surface or face": "Bề mặt hoặc mặt",
"Surface or faces": "Bề mặt hoặc các mặt",
"Surface, or faces to turn over": "Mặt cong, hoặc các mặt cần lật hướng",
"Surfaces": "Các bề mặt",
"Swap Ends": "Hoán đổi hai đầu",
@@ -642,6 +644,7 @@
"Target": "Mục tiêu",
"Target Body": "Khối mục tiêu",
"Target Face (optional)": "Mặt đích (tùy chọn)",
"Target edges": "Các cạnh đích",
"Tear": "Xé rách mép chấn",
"Technical details": "Chi tiết kỹ thuật",
"The dashed sketch is a saved plane guide. Select a new face to repair the attachment.": "Phác thảo nét đứt là đường dẫn mặt phẳng đã lưu. Hãy chọn một bề mặt mới để sửa lại liên kết.",
@@ -649,6 +652,7 @@
"The direction must be an axis or a straight sketch curve that lies in the sketch plane": "Hướng phải là một trục hoặc một đường cong phác thảo thẳng nằm trong mặt phẳng phác thảo",
"The mirror line cannot also be mirrored": "Đường đối xứng không thể đồng thời được lấy đối xứng",
"The mirror line must be a straight curve of this sketch, not an origin axis": "Đường đối xứng phải là một đường cong thẳng của phác thảo này, không phải trục gốc",
"The origin axis is perpendicular to the sketch plane, so it has no line in the plane": "Trục gốc vuông góc với mặt phẳng phác thảo nên không có đường thẳng trong mặt phẳng này",
"The saved author occurrence is missing. Select a valid author placement before rebinding.": "Bản thể tác giả đã lưu bị thiếu. Hãy chọn một vị trí tác giả hợp lệ trước khi gán lại.",
"Then": "Thì",
"These features will be deleted:": "Các tính năng sau sẽ bị xóa:",
@@ -751,6 +755,8 @@
"ai.prefs.follow_camera": "Theo camera",
"ai.prefs.follow_camera_off": "Tự động xoay camera theo phác thảo đang tắt.",
"ai.prefs.follow_camera_on": "Camera sẽ tự động xoay vuông góc với phác thảo do agent tạo.",
"ai.prefs.instructions": "Hướng dẫn tùy chỉnh",
"ai.prefs.instructions_note": "Được gửi cho agent khi bắt đầu mỗi cuộc trò chuyện, sau hướng dẫn riêng của SolidCraft: cách bạn muốn làm việc, các quy tắc cần tuân theo. Thay đổi có hiệu lực từ cuộc trò chuyện tiếp theo (Kết nối hoặc Cuộc trò chuyện mới).",
"ai.prefs.intro": "Tác nhân AI trong SolidCraft.",
"ai.prefs.make_default": "Đặt làm mặc định",
"ai.prefs.no_agents": "Chưa có agent nào. Thêm một agent hoặc làm mới để tìm các agent đã cài đặt.",
@@ -964,8 +970,7 @@
"cmd.inspect_analysis": "Phân tích khung nhìn",
"cmd.inspect_center_of_mass": "Trọng tâm",
"cmd.inspect_clearance": "Khoảng cách an toàn",
"cmd.inspect_colored_components": "Bật màu cụm chi tiết",
"cmd.inspect_colored_components_disable": "Tắt màu cụm chi tiết",
"cmd.inspect_colored_components": "Màu cụm chi tiết",
"cmd.inspect_compare": "So sánh các thân khối",
"cmd.inspect_compare_picture": "So sánh với ảnh tham chiếu",
"cmd.inspect_continuity": "Phân tích độ liên tục mặt",
@@ -1396,6 +1401,7 @@
"dialog.computing": "Đang tính toán… {seconds} s",
"dialog.could_not_be_checked": "không thể kiểm tra",
"dialog.custom_axis": "↖ Trục tùy chỉnh",
"dialog.cylinder_face_tip": "Bề mặt hoặc đỉnh của mô hình mà khối trụ đứng trên đó: nhấp vào đây, sau đó nhấp vào nó trong khung nhìn. Để trống thì X, Y và Z đặt vị trí.",
"dialog.dimension_insert_hint": "Nhấp vào một kích thước điều khiển khác để chèn tên của nó",
"dialog.dimension_name": "Kích thước: {name}",
"dialog.dimension_step_down": "Giảm {step}",
@@ -1404,6 +1410,8 @@
"dialog.edit_title": "CHỈNH SỬA {title}",
"dialog.no_shown_body": "Không có thân khối đang hiển thị để thay đổi: hãy hiển thị một thân khối, hoặc chọn “Tạo mới Thân khối”",
"dialog.pattern_sketch_not_object": "Hãy chọn thân khối hoặc tính năng: bản phác thảo không phải là cả hai. Lặp lại các đường cong của nó bằng Mảng hoặc Lấy đối xứng trong chính bản phác thảo, hoặc đùn khối nó trước.",
"dialog.primitive_point_tip": "Một điểm điền vào X, Y và Z: nhấp vào đây, sau đó nhấp vào một đỉnh, cạnh hoặc bề mặt của mô hình, một điểm của bản phác thảo, hoặc gốc tọa độ.",
"dialog.reference_edge_tip": "Một cạnh thẳng của bề mặt đã chọn, từ đó đo khoảng cách bên dưới: nhấp vào đây, sau đó nhấp vào cạnh.",
"dialog.select": "↖ Chọn",
"dialog.selected_count": "↖ {count} đã chọn",
"dialog.selection_constraint": "Ràng buộc {id}",
@@ -2040,6 +2048,8 @@
"nav.blender.help": "Kéo chuột giữa để xoay, Shift+chuột giữa để trượt, Ctrl+chuột giữa để thu phóng; kéo chuột phải giữ nguyên camera",
"nav.creo.help": "Kéo nút giữa để xoay, Shift+nút giữa để di chuyển, Ctrl+kéo nút giữa theo chiều dọc để thu phóng (kéo xuống để phóng to); lăn con lăn để thu phóng",
"nav.creo.label": "Creo",
"nav.drag_keeps_mode.help": "Kéo bằng nút giữa hoặc nút phải sẽ tiếp tục xoay, di chuyển hoặc thu phóng như lúc bắt đầu cho đến khi nhả nút, dù trong lúc đó có nhấn hay nhả phím nào. Tắt: các phím đang giữ quyết định ở mọi thời điểm của thao tác kéo.",
"nav.drag_keeps_mode.label": "Thao tác kéo giữ nguyên kiểu điều hướng lúc bắt đầu",
"nav.free_orbit.help": "Chuột và chuột 3D xoay tự do qua cả hai cực và giữ nguyên độ lăn nghiêng (Roll). Bắt dính trục bằng phím Shift sẽ bị tắt.",
"nav.free_orbit.label": "Xoay góc nhìn tự do",
"nav.free_orbit.snap_help": "Chuột và chuột 3D xoay qua cả hai cực và giữ nguyên góc nghiêng cuộn. Giữ Shift trong khi kéo xoay quỹ đạo để bắt dính hướng nhìn và góc nghiêng cuộn.",
@@ -2276,6 +2286,9 @@
"prefs.auto_operation_note": "Nối, Vật thể mới hoặc Cắt, tùy vào phần vật liệu tiếp xúc.",
"prefs.axes": "Các trục tọa độ",
"prefs.axis": "Trục tọa độ",
"prefs.background_choose": "Chọn ảnh…",
"prefs.background_image": "Ảnh",
"prefs.background_unreadable": "Không đọc được ảnh nền",
"prefs.blur_cost": "Chi phí kết xuất tương đối. Nhiều thanh hơn thường có nghĩa là cần xử lý nhiều hơn.",
"prefs.blur_device": "Không khả dụng trên thiết bị đồ họa hoặc đường kết xuất này",
"prefs.blur_guard": "Chất lượng làm mờ đã giảm để duy trì độ phản hồi của khung hình. Bán kính không thay đổi.",
@@ -2297,6 +2310,8 @@
"prefs.browser_eye_left": "Biểu tượng mắt cạnh mũi tên thu gọn",
"prefs.browser_eye_left_note": "Các hàng trong cây thư mục hiển thị biểu tượng mắt bật/tắt cạnh mũi tên thu gọn như trước đây, thay vì trong một cột ở đầu bên phải của các hàng.",
"prefs.browser_reveal_on_hover": "Hiện khi di chuột",
"prefs.browser_rows_start_closed": "Các dòng trong cây thư mục đóng sẵn",
"prefs.browser_rows_start_closed_note": "Vị trí đặt và thư mục trong cây thư mục bắt đầu ở trạng thái đóng, nên thiết kế có nhiều thành phần chỉ hiện các dòng cấp trên cùng. Mỗi dòng mở bằng mũi tên của nó, “Mở rộng tất cả” hoặc “Tìm trong cây đối tượng”, lệnh này chỉ mở các dòng dẫn tới thân khối và đóng lại các dòng mà lần tìm trước đã mở. Chọn một thân khối trong khung nhìn 3D không làm thay đổi cây thư mục. Khi tắt, các thư mục mở sẵn, vị trí đặt theo tùy chọn “Thành phần mở sẵn”, và việc chọn trong khung nhìn 3D mở cây thư mục tới thân khối đó, như trước đây.",
"prefs.browser_show_note": "Hiển thị giữ trình duyệt trên màn hình. Hiện khi di chuột ẩn nó cho đến khi con trỏ đến vị trí của nó hoặc thẻ mảnh ở cạnh trái, và nó vẫn mở khi con trỏ ở bên trong hoặc một menu của nó đang mở.",
"prefs.browser_shown": "Hiển thị",
"prefs.built_in": "Mặc định tích hợp",
@@ -2428,6 +2443,10 @@
"prefs.invert_curved_roll_arrows_note": "Hai mũi tên cong ở góc khối cuộn khung nhìn trong mặt phẳng màn hình. Khi bật: mũi tên theo chiều kim đồng hồ cuộn ngược chiều kim đồng hồ và ngược lại. Khi tắt: mỗi mũi tên cuộn khung nhìn theo chiều cong của nó.",
"prefs.invert_navigator_arrows": "Đảo ngược mũi tên xoay của khối điều hướng",
"prefs.invert_navigator_arrows_note": "Các mũi tên quanh khối xoay camera theo hướng mũi tên chỉ, như trong Fusion và Inventor: mũi tên bên trái chỉ sang phải và đưa camera sang phải. Khi tắt: mũi tên xoay khối về phía cạnh chứa nó, nên mũi tên bên trái đưa camera sang trái. Hai mũi tên cuộn cong không bị ảnh hưởng.",
"prefs.keep_unsaved_also_asks": "Vẫn hỏi khi đóng thẻ",
"prefs.keep_unsaved_also_asks_tip": "Khi tắt, thẻ sẽ đóng mà không có hộp thoại của trình duyệt khi bản sao của mọi thiết kế chưa lưu đã được cập nhật.",
"prefs.keep_unsaved_in_browser": "Giữ công việc chưa lưu trong trình duyệt này",
"prefs.keep_unsaved_in_browser_tip": "Các thay đổi chưa lưu của mỗi thiết kế đang mở được cập nhật liên tục trong bộ nhớ riêng của trình duyệt này. Lần truy cập sau sẽ đề nghị khôi phục chúng.",
"prefs.keep_work_on_tab_switch": "Giữ hộp thoại và công cụ mở khi chuyển giữa các thiết kế",
"prefs.keep_work_on_tab_switch_note": "Mỗi thẻ thiết kế giữ hộp thoại hoặc công cụ đang mở, cùng những gì bạn đã chọn và nhập trong đó, và thẻ thanh công cụ của nó. Chúng vẫn còn đó khi bạn quay lại thiết kế. Tắt: chuyển thiết kế sẽ hủy chúng và hiển thị thẻ Mô hình.",
"prefs.labels": "Nhãn chữ",
@@ -2439,6 +2458,8 @@
"prefs.layout_page.card": "Thẻ",
"prefs.layout_page.column": "Cột",
"prefs.layout_page.hover": "Rê chuột",
"prefs.layout_page.hover_panel_menus": "Chuyển menu nhóm khi di chuột",
"prefs.layout_page.hover_panel_menus_help": "Nhấp vào tên nhóm trên dải công cụ để mở menu. Di chuột lên tên nhóm khác để chuyển menu. Di chuyển con trỏ ra ngoài dải công cụ và menu đang mở để đóng menu.",
"prefs.layout_page.icons_labels": "Biểu tượng + nhãn",
"prefs.layout_page.ok_cancel": "Đồng ý và Hủy bỏ",
"prefs.layout_page.on_hover": "Khi rê chuột",
@@ -2756,6 +2777,12 @@
"recovery.just_now": "vừa xong",
"recovery.min_ago": "{count} phút trước",
"recovery.never_saved": "chưa từng lưu",
"recovery.open_saved": "Mở phiên bản đã lưu",
"recovery.restore": "Khôi phục",
"recovery.restore_intro": "Các thay đổi chưa được lưu khi trang này đóng lần trước được giữ trong trình duyệt này.",
"recovery.restore_row": "{name}, thay đổi {when}",
"recovery.restore_title": "Khôi phục các thiết kế chưa lưu?",
"recovery.restore_title_one": "Khôi phục thiết kế chưa lưu: {name}, thay đổi {when}?",
"refused.change": "Không thể thay đổi {body}.",
"refused.cut": "Không thể cắt {body}.",
"refused.face_curved": "Một trong các mặt cong của nó không dùng được để mô hình hóa.",
@@ -3277,9 +3304,11 @@
"render.frame_wide": "16:9 · Màn ảnh rộng",
"render.gpu_depth": "Độ sâu đường truyền tia tối đa",
"render.gpu_options_help": "Số mẫu, độ sâu, hạt giống và độ phơi sáng được lưu cùng thiết lập này. Chỉ lưu phương thức tạo ảnh đang hoạt động.",
"render.gpu_path_crashed": "Lần trước SolidCraft đã đóng khi đang thiết lập nó trên bộ điều hợp đồ họa này, nhiều khả năng là trong trình điều khiển đồ họa, nên nó sẽ không tự thiết lập lại. Hãy dùng dò tia trên CPU, hoặc thử lại (cập nhật trình điều khiển cũng sẽ thử lại).",
"render.gpu_path_failed": "Bộ dò đường truyền tia GPU không khả dụng",
"render.gpu_path_first_setup": "Lần thiết lập đầu tiên trên GPU này có thể mất vài phút; SolidCraft vẫn sử dụng được bình thường. Các lần khởi động sau sẽ tái sử dụng mã đổ bóng đã biên dịch nếu trình điều khiển lưu giữ chúng.",
"render.gpu_path_preparing": "Bộ dò đường truyền tia GPU: đang biên dịch mã đổ bóng…",
"render.gpu_path_retry": "Thử lại",
"render.gpu_seed": "Hạt giống ngẫu nhiên",
"render.gpu_unavailable": "Dò đường truyền tia GPU chưa sẵn sàng trên thiết bị này.",
"render.ground_apply": "Khớp và lưu mặt sàn",
@@ -4087,6 +4116,8 @@
"sample.plate.name": "Tấm mẫu",
"sample.vents.about": "Các rãnh then trong mảng chữ nhật: số hàng, số cột và bước rãnh là các tham số",
"sample.vents.name": "Tấm khe thông gió",
"scale.per_axis": "Theo từng trục",
"scale.uniform": "Đồng đều",
"search.command_keys": "Enter chạy Shift+Enter chạy và giữ mở Esc đóng",
"search.commands": "Lệnh",
"search.design": "Trong thiết kế này",
@@ -4424,6 +4455,7 @@
"ui.3d_sketch": "3D Phác thảo",
"ui.activate": "Kích hoạt",
"ui.active_component": "Cụm chi tiết đang kích hoạt",
"ui.addToQuickAccess": "Thêm vào Truy cập nhanh",
"ui.add_configuration": "Thêm cấu hình",
"ui.add_parameter": "Thêm tham số",
"ui.agent_chat": "Trò chuyện với AI Agent",
@@ -4975,6 +5007,11 @@
"ui.factor": "Hệ số",
"ui.fade": "Thoát ren",
"ui.fade_length": "Chiều dài thoát ren",
"ui.fair_iterations": "Số lần lặp",
"ui.fair_keep_edges": "Giữ các cạnh",
"ui.fair_strength": "Cường độ",
"ui.fair_surface_title": "LÀM MƯỢT BỀ MẶT",
"ui.fair_tolerance_default": "0,001 lần kích thước thân khối",
"ui.feature_history": "Lịch sử tính năng",
"ui.feature_options": "Tùy chọn tính năng",
"ui.features": "Các tính năng",
@@ -5188,6 +5225,7 @@
"ui.manipulator_free_alt": "Kéo tự do bộ thao tác cũng bằng Alt/Option",
"ui.manipulator_free_alt_tip": "Bật: Alt/Option hoặc Ctrl/Cmd trên các mũi tên đùn / gizmo sẽ tắt bước nhảy giá trị. Tắt: chỉ Ctrl/Cmd.",
"ui.manufacture": "Gia công chế tạo",
"ui.match_surface_title": "KHỚP BỀ MẶT",
"ui.material_review.axes": "Các trục cục bộ của vật liệu; chưa gán hướng cho thân khối.",
"ui.material_review.claims": "Nguồn gốc và giấy phép là thông tin chưa được kiểm chứng. Các giá trị thiếu vẫn ở dạng chưa biết.",
"ui.material_review.compressive_strength": "Độ bền nén",
@@ -5251,6 +5289,7 @@
"ui.options": "Tùy chọn",
"ui.orbit": "Xoay quanh tâm (Orbit)",
"ui.orbit_around": "Xoay quanh",
"ui.organize": "Sắp xếp",
"ui.orientation": "Định hướng",
"ui.origin": "Gốc tọa độ",
"ui.palette_construction_mode": "Chế độ đường dựng (X)",
@@ -5339,6 +5378,7 @@
"ui.quality": "Chất lượng",
"ui.quantity": "Số lượng",
"ui.quantity_2": "Số lượng 2",
"ui.quick_access_full": "Thanh truy cập nhanh chứa tối đa {count} lệnh; hãy gỡ một lệnh để thêm lệnh khác",
"ui.quit": "Thoát ứng dụng",
"ui.radial_context_menu": "Menu ngữ cảnh dạng vòng (Radial)",
"ui.radius": "Bán kính",
@@ -5356,6 +5396,7 @@
"ui.relink_not_linked": "Chi tiết này chưa được liên kết với thiết kế nguồn nào.",
"ui.relink_select_one": "Hãy chọn trước một chi tiết được liên kết hoặc một thể hiện của nó.",
"ui.relink_source_all": "Liên kết lại nguồn (tất cả thể hiện)…",
"ui.removeFromQuickAccess": "Gỡ khỏi Truy cập nhanh",
"ui.removeFromToolbar": "Xóa khỏi thanh công cụ",
"ui.removeSection": "Xóa mặt cắt",
"ui.render": "Kết xuất hình ảnh",
@@ -5364,6 +5405,8 @@
"ui.repeat_nothing": "Chưa có gì để lặp lại: hãy bắt đầu một công cụ hoặc hộp thoại trước.",
"ui.replace_with_link_done": "Các bản sao hiện đã trở thành các vị trí đặt của thiết kế liên kết.",
"ui.report_a_bug": "Báo cáo lỗi",
"ui.report_characters_left_one": "Còn {count} ký tự",
"ui.report_characters_left_other": "Còn {count} ký tự",
"ui.reset": "Đặt lại",
"ui.reset_all": "Đặt lại tất cả",
"ui.reset_the_settings_on_this_page": "Đặt lại các cài đặt trên trang này",
+5 -2
View File
@@ -957,8 +957,6 @@
"cmd.inspect_accessibility": "可達性分析",
"cmd.inspect_center_of_mass": "質心",
"cmd.inspect_clearance": "間隙",
"cmd.inspect_colored_components": "啟用元件顏色",
"cmd.inspect_colored_components_disable": "停用元件顏色",
"cmd.inspect_compare": "比較實體",
"cmd.inspect_compare_picture": "與圖片比較",
"cmd.inspect_continuity": "連續性分析",
@@ -1366,11 +1364,14 @@
"dialog.computing": "正在計算… {seconds} 秒",
"dialog.could_not_be_checked": "無法檢查",
"dialog.custom_axis": "↖ 自訂軸",
"dialog.cylinder_face_tip": "圓柱體所立的模型面或頂點:先點擊此處,再在視圖中點擊它。留空時由 X、Y、Z 放置。",
"dialog.dimension_insert_hint": "點擊其他驅動尺寸以插入其名稱",
"dialog.dimension_name": "尺寸:{name}",
"dialog.edit_title": "編輯 {title}",
"dialog.no_shown_body": "沒有可變更的已顯示實體:請顯示一個,或選擇「新建實體」",
"dialog.pattern_sketch_not_object": "請選取實體或特徵:草圖兩者皆非。請用草圖自己的「陣列」或「鏡射」重複其曲線,或先將它擠出。",
"dialog.primitive_point_tip": "填入 X、Y、Z 的點:先點擊此處,再點擊模型的頂點、邊或面、草圖中的點,或原點。",
"dialog.reference_edge_tip": "所選面上的一條直邊,下方的距離從它量起:先點擊此處,再點擊該邊。",
"dialog.select": "↖ 選擇",
"dialog.selected_count": "↖ 已選擇 {count} 個",
"dialog.selection_constraint": "拘束 {id}",
@@ -3812,6 +3813,8 @@
"sample.plate.name": "範例板",
"sample.vents.about": "依矩形陣列排列的槽:列數、欄數和間距均為參數",
"sample.vents.name": "通風板",
"scale.per_axis": "依軸",
"scale.uniform": "等比",
"search.command_keys": "Enter 執行 Shift+Enter 執行並保持開啟 Esc 關閉",
"search.commands": "命令",
"search.design": "此設計中",
+45 -2
View File
@@ -629,6 +629,8 @@
"Suppress Selected Folders": "抑制所选文件夹",
"Suppressed": "已抑制",
"Surface": "曲面",
"Surface or face": "曲面或面",
"Surface or faces": "曲面或面",
"Surface, or faces to turn over": "曲面,或要翻转的面",
"Surfaces": "曲面",
"Swap Ends": "交换端点",
@@ -642,6 +644,7 @@
"Target": "目标",
"Target Body": "目标实体",
"Target Face (optional)": "目标面(可选)",
"Target edges": "目标边",
"Tear": "撕裂",
"Technical details": "技术详情",
"The dashed sketch is a saved plane guide. Select a new face to repair the attachment.": "虚线草图是已保存的平面引导。选择一个新面以修复附着。",
@@ -649,6 +652,7 @@
"The direction must be an axis or a straight sketch curve that lies in the sketch plane": "方向必须是轴,或位于草图平面内的直线草图曲线",
"The mirror line cannot also be mirrored": "镜像线本身不能再被镜像",
"The mirror line must be a straight curve of this sketch, not an origin axis": "镜像线必须是此草图中的直线曲线,不能是原点轴",
"The origin axis is perpendicular to the sketch plane, so it has no line in the plane": "原点轴垂直于草图平面,因此在该平面内没有直线",
"The saved author occurrence is missing. Select a valid author placement before rebinding.": "保存的作者实例缺失。重新绑定前请选择有效的作者放置。",
"Then": "操作",
"These features will be deleted:": "以下特征将被删除:",
@@ -751,6 +755,8 @@
"ai.prefs.follow_camera": "跟随相机",
"ai.prefs.follow_camera_off": "相机自动跟随已关闭。",
"ai.prefs.follow_camera_on": "相机将转向代理创建的草图。",
"ai.prefs.instructions": "自定义指令",
"ai.prefs.instructions_note": "在每次对话开始时发送给代理,位于 SolidCraft 自身的指令之后:你喜欢的工作方式、需要遵守的规则。更改从下一次对话(连接或新对话)起生效。",
"ai.prefs.intro": "SolidCraft中的AI代理。",
"ai.prefs.make_default": "设为默认",
"ai.prefs.no_agents": "尚无代理。添加代理或刷新以查找已安装的代理。",
@@ -964,8 +970,7 @@
"cmd.inspect_analysis": "视图分析",
"cmd.inspect_center_of_mass": "质心",
"cmd.inspect_clearance": "间隙",
"cmd.inspect_colored_components": "启用组件颜色",
"cmd.inspect_colored_components_disable": "禁用组件颜色",
"cmd.inspect_colored_components": "组件颜色",
"cmd.inspect_compare": "比较实体",
"cmd.inspect_compare_picture": "比较参考图",
"cmd.inspect_continuity": "连续性分析",
@@ -1396,6 +1401,7 @@
"dialog.computing": "正在计算… {seconds} 秒",
"dialog.could_not_be_checked": "无法检查",
"dialog.custom_axis": "↖ 自定义轴",
"dialog.cylinder_face_tip": "圆柱体所立的模型面或顶点:先单击此处,再在视图中单击它。留空时由 X、Y、Z 放置。",
"dialog.dimension_insert_hint": "单击其他驱动尺寸以插入其名称",
"dialog.dimension_name": "尺寸:{name}",
"dialog.dimension_step_down": "减少 {step}",
@@ -1404,6 +1410,8 @@
"dialog.edit_title": "编辑 {title}",
"dialog.no_shown_body": "没有可更改的显示实体:请显示一个实体,或选择新建实体",
"dialog.pattern_sketch_not_object": "请选择实体或特征:草图两者都不是。请用草图自身的阵列或镜像重复其曲线,或先将其拉伸。",
"dialog.primitive_point_tip": "填入 X、Y、Z 的点:先单击此处,再单击模型的顶点、边或面、草图中的点,或原点。",
"dialog.reference_edge_tip": "所选面上的一条直边,下方的距离从它量起:先单击此处,再单击该边。",
"dialog.select": "↖ 选择",
"dialog.selected_count": "↖ 已选择 {count} 个",
"dialog.selection_constraint": "约束 {id}",
@@ -2040,6 +2048,8 @@
"nav.blender.help": "中键拖动旋转,Shift+中键拖动平移,Ctrl+中键拖动缩放;右键拖动不会移动相机",
"nav.creo.help": "中键拖动旋转,Shift+中键平移,Ctrl+中键上下拖动缩放(向下放大);滚动滚轮也可缩放",
"nav.creo.label": "Creo",
"nav.drag_keeps_mode.help": "中键或右键拖动会保持开始时的旋转、平移或缩放,直到松开按键,期间按下或松开任何键都不会改变。关闭后:拖动的每一刻都由当时按住的键决定。",
"nav.drag_keeps_mode.label": "拖动保持开始时的导航方式",
"nav.free_orbit.help": "鼠标和3D鼠标可保持滚转并越过两个极点旋转。Shift轴吸附将被禁用。",
"nav.free_orbit.label": "自由旋转",
"nav.free_orbit.snap_help": "鼠标和3D鼠标可越过两极旋转,并保留画面旋转角度。在拖动环绕时按住Shift,可吸附视图方向和画面旋转角度。",
@@ -2276,6 +2286,9 @@
"prefs.auto_operation_note": "根据材料接触的对象选择合并、新建实体或切割。",
"prefs.axes": "坐标轴",
"prefs.axis": "轴",
"prefs.background_choose": "选择图片…",
"prefs.background_image": "图片",
"prefs.background_unreadable": "无法读取背景图片",
"prefs.blur_cost": "相对渲染开销。条形越多,通常意味着处理工作越多。",
"prefs.blur_device": "此图形设备或渲染路径不支持此功能",
"prefs.blur_guard": "已降低模糊质量,以保持画面响应。模糊半径未改变。",
@@ -2297,6 +2310,8 @@
"prefs.browser_eye_left": "折叠箭头旁的眼睛图标",
"prefs.browser_eye_left_note": "浏览器各行的显示切换像以前一样位于折叠箭头旁,而不是在行右端的一列中。",
"prefs.browser_reveal_on_hover": "悬停时显示",
"prefs.browser_rows_start_closed": "浏览器中的行默认折叠",
"prefs.browser_rows_start_closed_note": "浏览器中的放置和文件夹默认折叠,因此包含大量组件的设计只显示顶层行。每一行可通过其箭头、“全部展开”或“在浏览器中查找”打开;“在浏览器中查找”只展开通往该实体的行,并重新折叠上次查找展开的行。在 3D 视图中选取实体不会改变浏览器。关闭时文件夹默认展开,放置按“组件初始展开”的设置显示,在 3D 视图中选取会使浏览器展开到该实体,与以前相同。",
"prefs.browser_show_note": "“显示”让浏览器始终留在屏幕上。“悬停时显示”会将其隐藏,直到指针移到它的位置或其左边缘的细标签上;指针在其中或其菜单打开时,它保持打开。",
"prefs.browser_shown": "显示",
"prefs.built_in": "内置",
@@ -2428,6 +2443,10 @@
"prefs.invert_curved_roll_arrows_note": "立方体角落的两个弯曲箭头在屏幕平面内翻滚视图。开启:顺时针箭头逆时针翻滚,逆时针箭头顺时针翻滚。关闭:每个箭头按其弯曲方向翻滚视图。",
"prefs.invert_navigator_arrows": "反转视图立方体的旋转箭头",
"prefs.invert_navigator_arrows_note": "立方体周围的箭头按箭头所指的方向转动相机,与 Fusion 和 Inventor 相同:左侧箭头指向右,相机向右转动。关闭:箭头把立方体转向它所在的那一面,因此左侧箭头使相机向左转动。弯曲的翻滚箭头不受影响。",
"prefs.keep_unsaved_also_asks": "关闭标签页时仍然询问",
"prefs.keep_unsaved_also_asks_tip": "关闭此项后,只要每个未保存设计的副本都是最新的,标签页就会直接关闭,不显示浏览器的提示框。",
"prefs.keep_unsaved_in_browser": "在此浏览器中保留未保存的工作",
"prefs.keep_unsaved_in_browser_tip": "每个打开的设计的未保存更改都会在此浏览器的私有存储中保持最新。下次访问时会提示恢复。",
"prefs.keep_work_on_tab_switch": "切换设计时保持对话框和工具打开",
"prefs.keep_work_on_tab_switch_note": "每个设计标签页保留其打开的对话框或工具(包括其中已选择和输入的内容)以及工具栏选项卡。回到该设计时它们仍在原处。关闭时:切换设计会取消它们并显示“模型”选项卡。",
"prefs.labels": "标签",
@@ -2439,6 +2458,8 @@
"prefs.layout_page.card": "卡片",
"prefs.layout_page.column": "列",
"prefs.layout_page.hover": "悬停",
"prefs.layout_page.hover_panel_menus": "悬停切换面板菜单",
"prefs.layout_page.hover_panel_menus_help": "点击功能区的面板名称打开菜单。将指针悬停在另一面板名称上即可切换菜单。将指针移到功能区和已打开菜单之外即可关闭菜单。",
"prefs.layout_page.icons_labels": "图标+标签",
"prefs.layout_page.ok_cancel": "确定和取消",
"prefs.layout_page.on_hover": "悬停时",
@@ -2756,6 +2777,12 @@
"recovery.just_now": "刚刚",
"recovery.min_ago": "{count} 分钟前",
"recovery.never_saved": "从未保存",
"recovery.open_saved": "打开已保存的版本",
"recovery.restore": "恢复",
"recovery.restore_intro": "此页面上次关闭时未保存的更改保留在此浏览器中。",
"recovery.restore_row": "{name},更改于{when}",
"recovery.restore_title": "恢复未保存的设计?",
"recovery.restore_title_one": "恢复未保存的设计:{name},更改于{when}?",
"refused.change": "无法更改 {body}。",
"refused.cut": "无法切割 {body}。",
"refused.face_curved": "它的一个曲面无法用于建模。",
@@ -3277,9 +3304,11 @@
"render.frame_wide": "16:9 · 宽屏",
"render.gpu_depth": "最大路径深度",
"render.gpu_options_help": "采样数、深度、种子和曝光会随此配置保存。仅保存当前图像方法。",
"render.gpu_path_crashed": "上次在此图形适配器上设置时 SolidCraft 关闭了,很可能是在图形驱动程序内部,因此不会再自动设置。请改用 CPU 光线追踪,或重试(更新驱动程序后也会重试)。",
"render.gpu_path_failed": "GPU 路径追踪器不可用",
"render.gpu_path_first_setup": "首次在此 GPU 上设置可能需要几分钟,期间 SolidCraft 仍可正常使用。之后启动时,如果驱动程序保留了已编译的着色器,将直接复用。",
"render.gpu_path_preparing": "GPU 路径追踪器:正在编译着色器…",
"render.gpu_path_retry": "重试",
"render.gpu_seed": "随机种子",
"render.gpu_unavailable": "此设备上的 GPU 路径追踪尚未就绪。",
"render.ground_apply": "适配并保存地面",
@@ -4087,6 +4116,8 @@
"sample.plate.name": "示例板",
"sample.vents.about": "按矩形阵列排列的槽:行数、列数和间距均为参数",
"sample.vents.name": "通风板",
"scale.per_axis": "按轴",
"scale.uniform": "等比",
"search.command_keys": "Enter 运行 Shift+Enter 运行并保持打开 Esc 关闭",
"search.commands": "命令",
"search.design": "此设计中",
@@ -4424,6 +4455,7 @@
"ui.3d_sketch": "3D 草图",
"ui.activate": "激活",
"ui.active_component": "活动组件",
"ui.addToQuickAccess": "添加到快速访问",
"ui.add_configuration": "添加配置",
"ui.add_parameter": "添加参数",
"ui.agent_chat": "智能体聊天",
@@ -4975,6 +5007,11 @@
"ui.factor": "系数",
"ui.fade": "收尾",
"ui.fade_length": "收尾长度",
"ui.fair_iterations": "迭代次数",
"ui.fair_keep_edges": "保持边",
"ui.fair_strength": "强度",
"ui.fair_surface_title": "曲面光顺",
"ui.fair_tolerance_default": "实体尺寸的 0.001 倍",
"ui.feature_history": "特征历史",
"ui.feature_options": "特征选项",
"ui.features": "特征",
@@ -5188,6 +5225,7 @@
"ui.manipulator_free_alt": "操纵器自由拖动也适用于 Alt/Option",
"ui.manipulator_free_alt_tip": "开:在拉伸/小控件箭头上按 Alt/Option 或 Ctrl/Cmd 可关闭数值捕捉。关:仅 Ctrl/Cmd。",
"ui.manufacture": "制造",
"ui.match_surface_title": "曲面匹配",
"ui.material_review.axes": "材料局部坐标轴;不指定实体方向。",
"ui.material_review.claims": "来源和许可证声明未经核实。缺失值保持未知。",
"ui.material_review.compressive_strength": "抗压强度",
@@ -5251,6 +5289,7 @@
"ui.options": "选项",
"ui.orbit": "环绕",
"ui.orbit_around": "环绕",
"ui.organize": "整理",
"ui.orientation": "定向",
"ui.origin": "原点",
"ui.palette_construction_mode": "构造模式 (X)",
@@ -5339,6 +5378,7 @@
"ui.quality": "质量",
"ui.quantity": "数量",
"ui.quantity_2": "数量 2",
"ui.quick_access_full": "快速访问栏最多容纳 {count} 个命令;请先移除一个再添加",
"ui.quit": "退出",
"ui.radial_context_menu": "环形上下文菜单",
"ui.radius": "半径",
@@ -5356,6 +5396,7 @@
"ui.relink_not_linked": "此组件未链接到源设计。",
"ui.relink_select_one": "请先选择一个链接组件或其实例。",
"ui.relink_source_all": "重新链接源文件(所有实例)…",
"ui.removeFromQuickAccess": "从快速访问中移除",
"ui.removeFromToolbar": "从工具栏移除",
"ui.removeSection": "移除剖面",
"ui.render": "渲染",
@@ -5364,6 +5405,8 @@
"ui.repeat_nothing": "还没有可重复的操作:请先启动一个工具或对话框。",
"ui.replace_with_link_done": "这些副本现在是链接设计的放置。",
"ui.report_a_bug": "报告缺陷",
"ui.report_characters_left_one": "剩余 {count} 个字符",
"ui.report_characters_left_other": "剩余 {count} 个字符",
"ui.reset": "重置",
"ui.reset_all": "全部重置",
"ui.reset_the_settings_on_this_page": "重置此页面上的设置",
+7
View File
@@ -366,6 +366,13 @@ fn fs_ao_depth(i: TOut, @builtin(front_facing) front: bool) -> AoDepth {
return opaque_depth(i, front);
}
// The virtual ground preserves the viewport background; only contact occlusion is blended.
@fragment
fn fs_ground_ao(i: TOut) -> @location(0) vec4<f32> {
if (clipped(i.wp)) { discard; }
return vec4<f32>(0.0, 0.0, 0.0, 1.0 - occlusion(i));
}
/// The AO prepass's depth of an opaque surface's fragment.
fn opaque_depth(i: TOut, front: bool) -> AoDepth {
if (clipped(i.wp) || length(i.n) < 0.5) { discard; }
@@ -0,0 +1 @@
| assets/locales/pending/agent-http-consent.json | SolidCraft contributors | Original endpoint-specific HTTP consent messages and translations | MIT OR Apache-2.0 |
+40
View File
@@ -2,6 +2,46 @@
use crate::{DocError, Document, Mat, ModelBody, ModelState, Result};
use std::collections::BTreeSet;
/// A profile moved into the component receiving its own body is read through the recorded
/// transfer frame. Reading its live placement would move the source geometry when that
/// component moves, then move the transferred body a second time. All outputs of a constructor
/// must reach the sketch's component through the same frame; independent cross-component
/// references retain their ordinary live placement.
pub(crate) fn profile_frame(doc: &Document, feature: &crate::Feature, sketch: u64) -> Option<Mat> {
if !matches!(
feature.kind,
crate::FeatureKind::Extrude { operation: crate::Operation::NewBody, .. }
| crate::FeatureKind::Revolve { operation: crate::Operation::NewBody, .. }
) || feature.body_names.is_empty()
{
return None;
}
let owner = doc.feature(sketch)?.component;
if owner == feature.component {
return None;
}
let mut frames: std::collections::BTreeMap<&str, (u64, Mat)> =
feature.body_names.iter().map(|name| (name.as_str(), (feature.component, crate::IDENTITY))).collect();
let start = doc.feature_index(feature.id)?.saturating_add(1);
for step in doc.features.iter().take(doc.marker.unwrap_or(usize::MAX)).skip(start).filter(|step| !step.suppressed) {
let crate::FeatureKind::BodyTransfer { bodies, from, to, transform } = &step.kind else { continue };
for name in bodies {
if let Some((component, frame)) = frames.get_mut(name.as_ref()) {
if *component != *from || !valid_transfer_matrix(transform) {
return None;
}
*component = *to;
*frame = crate::mat_mul(transform, frame);
}
}
if frames.values().all(|(component, _)| *component == owner) {
let (_, first) = frames.values().next()?;
return frames.values().all(|(_, frame)| frame == first).then(|| crate::mat_inverse(first)).flatten();
}
}
None
}
pub fn valid_transfer_matrix(m: &Mat) -> bool {
crate::is_placement(m)
}
+1 -1
View File
@@ -185,7 +185,7 @@ fn shell_planar_format13_keeps_v12_documents_and_refuses_future() {
}
assert_eq!(legacy, current, "the migration preserves every prior field");
legacy["format"] = json!(crate::format::format_string(crate::format::FORMAT + 1));
assert!(crate::format::read(&legacy.to_string()).unwrap().newer_file.is_some());
assert!(crate::format::read(&legacy.to_string()).is_err());
}
#[test]
+11 -27
View File
@@ -452,8 +452,14 @@ pub enum AxisRef {
SketchAxis { axis: String },
/// World X / Y / Z axis through the origin.
World { axis: String },
/// Any line in the sketch plane, in world coordinates.
Line { origin: Position, dir: Dir },
/// Any line in the sketch plane, in world coordinates. The origin's coordinates are length
/// expressions, so the axis follows parameter changes.
Line {
#[serde(with = "point_expr")]
#[walk(placement)]
origin: PointExpr,
dir: Dir,
},
/// An edge or round face of the model (a straight or circular edge, a cylinder, cone or
/// torus face), found again on each evaluation: [`crate::construct::model_axis`].
Model { geo: GeoRef },
@@ -659,12 +665,6 @@ impl AlignFrame {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum FeatureKind {
/// A feature a newer SolidCraft saved that this build can't read ([`crate::newer_file`]): it
/// holds its place on the timeline, reports an error when evaluated, and goes back into the
/// file as it came.
NewerFileFeature {
raw: crate::newer_file::RawFeature,
},
/// A recorded timeline ownership boundary. Geometry is converted from its
/// current local frame into the destination component frame exactly once.
BodyTransfer {
@@ -1050,6 +1050,8 @@ pub enum FeatureKind {
Split {
body: BodyName,
plane: PlaneRef,
// The tool face is picked (and named), so the split follows it (#11674).
#[walk(picks_faces)]
#[serde(default, skip_serializing_if = "Option::is_none")]
tool: Option<FaceAt>,
/// A straight line of this sketch as the tool (#1552): the plane through it along the
@@ -2606,7 +2608,6 @@ impl FeatureKind {
/// Short type name (timeline tooltip, `inspect`).
pub fn type_name(&self) -> &'static str {
match self {
FeatureKind::NewerFileFeature { .. } => "NewerFileFeature",
FeatureKind::BodyTransfer { .. } => "BodyTransferFeature",
FeatureKind::Sketch { .. } => "Sketch",
FeatureKind::Extrude { .. } => "ExtrudeFeature",
@@ -2692,7 +2693,6 @@ impl FeatureKind {
/// Design-file variant key (e.g. `"extrude"`, `"box"`, `"sheet_flange"`).
pub fn kind_key(&self) -> &'static str {
match self {
FeatureKind::NewerFileFeature { .. } => "newer_file_feature",
FeatureKind::BodyTransfer { .. } => "body_transfer",
FeatureKind::Sketch { .. } => "sketch",
FeatureKind::Extrude { .. } => "extrude",
@@ -2855,7 +2855,6 @@ impl FeatureKind {
/// Default name prefix (`Extrude` → `Extrude1`).
pub fn base_name(&self) -> &'static str {
match self {
FeatureKind::NewerFileFeature { .. } => "Unknown",
FeatureKind::BodyTransfer { .. } => "BodyTransfer",
FeatureKind::Sketch { .. } => "Sketch",
FeatureKind::Extrude { .. } => "Extrude",
@@ -3128,10 +3127,6 @@ pub fn is_counted_body_name(name: &str) -> bool {
/// The document.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Document {
/// Set when a newer SolidCraft saved the file this came from: what to tell the user, and what
/// this build couldn't read and keeps for the next save ([`crate::newer_file`]).
#[serde(skip)]
pub newer_file: Option<std::sync::Arc<crate::newer_file::NewerFile>>,
/// Named image intent and immutable embedded HDR sources. Active jobs and device state are not saved.
#[serde(default, skip_serializing_if = "crate::render_settings::RenderSettings::is_empty")]
pub render: std::sync::Arc<crate::render_settings::RenderSettings>,
@@ -3493,7 +3488,6 @@ impl Document {
pub fn new(name: &str) -> Self {
Document {
newer_file: None,
render: Default::default(),
format: default_format(),
format_pending: crate::format::pending_names(),
@@ -3915,12 +3909,7 @@ impl Document {
/// Read a design file's JSON (any format this build knows; older ones are upgraded).
pub fn from_json(s: &str) -> Result<Document> {
// A newer build's resources may not read here; the section stays out then (`newer_file`).
if let Err(why) = crate::render_resources::check_json_budget(s)
&& !crate::format::says_newer(s)
{
return Err(why);
}
crate::render_resources::check_json_budget(s)?;
let mut d = crate::format::read(s)?;
if d.features.len() > MAX_FEATURES || d.params.len() > MAX_PARAMS {
return Err(DocError::Invalid("document too large".into()));
@@ -3932,11 +3921,6 @@ impl Document {
Ok(d)
}
pub fn to_json(&self) -> String {
if self.newer_file.is_some() {
let mut value = serde_json::to_value(self).unwrap_or_default();
crate::newer_file::restore(self, &mut value);
return serde_json::to_string_pretty(&value).unwrap_or_default();
}
serde_json::to_string_pretty(self).unwrap_or_default()
}
+3 -125
View File
@@ -84,65 +84,6 @@ pub struct Drawing {
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>>,
/// Set when a newer SolidCraft saved the file this came from.
#[serde(skip)]
pub newer_file: Option<Arc<NewerDrawing>>,
}
/// The fields a drawing's record has: anything else in a file is a newer build's.
const RECORD_FIELDS: [&str; 7] = ["format", "name", "sources", "properties", "units", "language", "sheets"];
/// Move the elements of the list `key` that don't read as `T` out of a newer build's drawing.
fn take_unread<T: serde::de::DeserializeOwned>(top: &mut serde_json::Map<String, Value>, key: &str, one: &str, file: &mut NewerDrawing) {
let Some(Value::Array(list)) = top.get_mut(key) else { return };
let mut readable = Vec::with_capacity(list.len());
for element in std::mem::take(list) {
match DRAWING.check_numbers(&element, "properties").and_then(|()| serde_json::from_value::<T>(element.clone()).map_err(damaged)) {
Ok(_) => readable.push(element),
Err(why) => {
let name = element.get("name").or(element.get("path")).and_then(Value::as_str).unwrap_or("unnamed");
file.not_understood.push(format!("{one} `{name}` ({why})"));
file.elements.push((key.to_string(), element));
}
}
}
*list = readable;
}
/// What a drawing file says and holds that this build doesn't read ([`Drawing::from_newer`]).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct NewerDrawing {
/// The number the file says.
pub format: u32,
/// The number this build reads up to.
pub reads_up_to: u32,
/// The changes the file names that this build doesn't have.
pub unknown_changes: Vec<String>,
/// What this build couldn't read, by name.
pub not_understood: Vec<String>,
fields: Vec<(String, Value)>,
elements: Vec<(String, Value)>,
}
impl NewerDrawing {
/// What to tell the user.
pub fn notice(&self) -> String {
format!(
"Saved by a newer SolidCraft (format {}, this version reads up to {}). If something's missing, update.",
self.format, self.reads_up_to
)
}
fn restore(&self, value: &mut Value) {
for (key, field) in &self.fields {
value[key] = field.clone();
}
for (key, element) in &self.elements {
if let Some(list) = value.get_mut(key).and_then(Value::as_array_mut) {
list.push(element.clone());
}
}
}
}
/// A design the drawing draws.
@@ -234,7 +175,6 @@ impl Drawing {
units: Units::default(),
language: language.into(),
sheets: vec![Arc::new(first)],
newer_file: None,
};
drawing.validate()?;
Ok(drawing)
@@ -248,65 +188,11 @@ impl Drawing {
}
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)?;
if let Some(newer) = DRAWING.newer(version, applied.as_deref()) {
return Self::from_newer(v, version, applied, newer);
}
let d = Self::decode(v, version, applied.as_deref())?;
Ok(Drawing { format: DRAWING.format_string(DRAWING.current()), format_pending: DRAWING.pending_names(), ..d })
}
fn decode(mut v: Value, version: u32, applied: Option<&[String]>) -> Result<Drawing> {
DRAWING.check_numbers(&v, "properties")?;
DRAWING.upgrade(&mut v, version, applied)?;
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(d)
}
/// A drawing a newer build saved: it opens with what this build can read of it. Everything in a
/// drawing refuses a field it doesn't know, so what it can't read shows as a top-level field,
/// a source or a sheet that doesn't read, which stays out and is named in
/// [`NewerDrawing::not_understood`]; [`Drawing::to_json`] writes it all back, with the file's
/// format. It takes at least one sheet this build can read to open.
fn from_newer(mut v: Value, version: u32, applied: Option<Vec<String>>, newer: crate::format::Newer) -> Result<Drawing> {
let list = applied.as_deref();
let can_read = |v: Value| Self::decode(v, version, list).map_err(|e| e.to_string());
let mut file = NewerDrawing {
format: newer.format,
reads_up_to: newer.reads_up_to,
unknown_changes: newer.unknown_changes.clone(),
..NewerDrawing::default()
};
let mut read = can_read(v.clone());
if read.is_err() {
let top = v.as_object_mut().ok_or_else(|| damaged("expected a JSON object"))?;
let keys: Vec<String> = top.keys().cloned().collect();
for key in keys {
if !RECORD_FIELDS.contains(&key.as_str())
&& let Some(value) = top.remove(&key)
{
file.not_understood.push(format!("field `{key}`"));
file.fields.push((key, value));
}
}
take_unread::<Sheet>(top, "sheets", "sheet", &mut file);
take_unread::<Source>(top, "sources", "source", &mut file);
read = can_read(v);
}
let d = read.map_err(|why| {
DocError::Invalid(format!(
"this drawing was saved by a newer SolidCraft (format {}, this version reads up to {}) and this version can't read it: {why}. Update SolidCraft to open it",
newer.format, newer.reads_up_to
))
})?;
let (format, format_pending) = if newer.format > newer.reads_up_to {
(DRAWING.format_string(newer.format), applied.unwrap_or_default())
} else {
let mut pending = DRAWING.pending_names();
pending.extend(newer.unknown_changes.iter().cloned());
(DRAWING.format_string(DRAWING.current()), pending)
};
Ok(Drawing { format, format_pending, newer_file: Some(Arc::new(file)), ..d })
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
@@ -314,15 +200,7 @@ impl Drawing {
/// 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 unsaved = |e: serde_json::Error| DocError::Invalid(format!("drawing not saved: {e}"));
let text = match &self.newer_file {
None => serde_json::to_string_pretty(self).map_err(unsaved)?,
Some(file) => {
let mut value = serde_json::to_value(self).map_err(unsaved)?;
file.restore(&mut value);
serde_json::to_string_pretty(&value).map_err(unsaved)?
}
};
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)
}
@@ -8,7 +8,7 @@ whole format bump: never edit the `DRAWING` family in `format.rs`, another chang
- 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 warns about the newer format number, opens what it can read and keeps the rest).
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
+13 -42
View File
@@ -325,28 +325,28 @@ fn damaged_files_are_refused_and_never_patched() {
let why = refused(&d, edit);
assert!(why.starts_with("damaged drawing: ") && why.contains("unknown field"), "{i}: {why}");
}
// A newer build's file isn't held to this build's rules: a field it doesn't know, or a sheet with
// a number out of range, stays out. With no sheet left to show, it says 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, "read");
let why = refused(&d, |v| {
v["format"] = json!(DRAWING.format_string(newer));
v["sheets"] = json!([{"id": "future", "a_future_field": true}]);
});
assert!(
why.starts_with(&format!(
"this drawing was saved by a newer SolidCraft (format {newer}, this version reads up to {current}) and this version can't read it: "
)),
"{why}"
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"]));
assert_eq!(why, "read");
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);
@@ -551,32 +551,3 @@ fn mutate(v: &mut Value, pick: &mut usize, lie: &Value, how: u64) -> bool {
_ => false,
}
}
/// A drawing a newer build saved opens, with what it can read, and saving keeps the rest.
#[test]
fn a_newer_builds_drawing_opens_and_keeps_what_it_cant_read() {
let d = plate();
let mut file: Value = file(&d);
assert!(file.as_object().unwrap().keys().all(|key| RECORD_FIELDS.contains(&key.as_str())), "a new field goes in RECORD_FIELDS");
let newer = DRAWING.format_string(DRAWING.current() + 1);
file["format"] = json!(newer);
// With nothing it can't read: the same drawing.
let opened = Drawing::from_json(&file.to_string()).unwrap();
assert_eq!(opened.sheets, d.sheets);
assert!(opened.newer_file.as_ref().unwrap().notice().starts_with("Saved by a newer SolidCraft"));
assert_eq!(serde_json::from_str::<Value>(&opened.to_json().unwrap()).unwrap()["format"], json!(newer));
// A field and a second sheet it can't read stay out, named, and go back.
file["views"] = json!([{"id": "a"}]);
let mut future_sheet = file["sheets"][0].clone();
future_sheet["name"] = json!("Future");
future_sheet["future_layers"] = json!([1]);
file["sheets"].as_array_mut().unwrap().push(future_sheet);
let opened = Drawing::from_json(&file.to_string()).unwrap();
assert_eq!(opened.sheets, d.sheets);
let said = &opened.newer_file.as_ref().unwrap().not_understood;
assert!(said.iter().any(|what| what.contains("views")) && said.iter().any(|what| what.contains("Future")), "{said:?}");
let again: Value = serde_json::from_str(&opened.to_json().unwrap()).unwrap();
assert_eq!(again["views"], file["views"]);
assert_eq!(again["sheets"].as_array().unwrap().len(), d.sheets.len() + 1);
assert_eq!(again["format"], json!(newer));
}
+277 -68
View File
@@ -659,7 +659,7 @@ impl ModelState {
}
/// A digest of everything later features can read: bodies (by content identity), sketches,
/// the body counter, threads and sheet bodies.
/// the body counter, threads, sheet bodies and construction geometry.
fn digest(&self) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
for b in &self.bodies {
@@ -701,12 +701,19 @@ impl ModelState {
}
serde_json::to_string(&self.threads).unwrap_or_default().hash(&mut h);
serde_json::to_string(&self.sheets).unwrap_or_default().hash(&mut h);
// Construction axes, planes and points so far: features find them here by name, and a
// result taken from the cache brings back the ones it was made with, so one moved past
// a feature must change the key (#9544). A state without any hashes as before.
if !self.construct.is_empty() {
serde_json::to_string(&self.construct).unwrap_or_default().hash(&mut h);
}
h.finish()
}
/// After a feature evaluated to this state from `before` with cache key `key`: bodies it
/// made or changed take that key, sketches it made a digest of their solved geometry (see
/// [`quantize`]); the rest keep theirs.
/// `f` must have gone through [`naming_feature`]: face naming reads no parameter values.
fn stamp(&mut self, doc: &Document, before: &Arc<ModelState>, f: &Feature, key: u64, mats: Vec<Mat>) {
let feature = f.id;
let depth = before.bodies.iter().filter_map(|b| b.names.depth()).max().unwrap_or(0) + 1;
@@ -1084,8 +1091,8 @@ pub struct Model {
pub no_cache: bool,
/// Results kept besides the cache ([`Model::keep`]).
kept: KeptSlot,
/// The imported texts' digests the last `evaluate` fingerprinted the features with.
step_digests: StepDigests,
/// Embedded imports' content digests from the last evaluation.
import_digests: ImportDigests,
/// The address of the shared document whose fingerprints the results were last given in
/// full, with [`fingerprints_digest`] of them then ([`Model::pinned_prefix`]). Only an
/// address: a reference to the document would make every in-place edit of it a copy.
@@ -1124,7 +1131,7 @@ fn param_names(f: &Feature) -> Vec<String> {
names
}
fn fingerprint(doc: &Document, prev: u64, f: &Feature, vals: &BTreeMap<String, Value>, rolled_back: bool, steps: &StepDigests) -> u64 {
fn fingerprint(doc: &Document, prev: u64, f: &Feature, vals: &BTreeMap<String, Value>, rolled_back: bool, steps: &ImportDigests) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
prev.hash(&mut h);
rolled_back.hash(&mut h);
@@ -1145,34 +1152,73 @@ fn fingerprint(doc: &Document, prev: u64, f: &Feature, vals: &BTreeMap<String, V
h.finish()
}
/// The digests of the Import features' STEP texts, by feature id, with the text each was taken
/// from. Every evaluation fingerprints every feature, and writing out a design's imported files
/// each time made every command as slow as the files are large: a text is shared (`Arc`), so
/// its digest is taken once and kept while the feature holds that same text.
/// Content digests for embedded imports. Shared, unchanged arrays/text are read once;
/// feature metadata is still fingerprinted on every evaluation. Rebuilding this map prunes
/// removed features and never trusts an address without retaining and checking its Arc.
#[derive(Clone, Debug, Default)]
struct StepDigests(std::collections::HashMap<u64, (Arc<str>, u64)>);
struct ImportDigests {
steps: std::collections::HashMap<u64, (Arc<str>, u64)>,
meshes: std::collections::HashMap<u64, Vec<MeshDigest>>,
}
impl StepDigests {
/// The digests of `doc`'s imported texts, reusing `known` for texts it has seen.
fn of(doc: &Document, known: &StepDigests) -> StepDigests {
let texts = doc.features.iter().filter_map(|f| match &f.kind {
FeatureKind::Import { step, .. } => Some((f.id, step)),
_ => None,
});
StepDigests(
texts
.map(|(id, step)| {
let digest = known.get(id, step).unwrap_or_else(|| step_text_digest(step));
(id, (step.clone(), digest))
})
.collect(),
)
#[derive(Clone, Debug)]
struct MeshDigest {
positions: Arc<[f64]>,
triangles: Arc<[u32]>,
digest: u64,
}
impl MeshDigest {
fn matches(&self, mesh: &crate::MeshData) -> bool {
Arc::ptr_eq(&self.positions, &mesh.positions) && Arc::ptr_eq(&self.triangles, &mesh.triangles)
}
}
impl ImportDigests {
fn of(doc: &Document, known: &ImportDigests) -> ImportDigests {
let mut out = ImportDigests::default();
for f in &doc.features {
match &f.kind {
FeatureKind::Import { step, .. } => {
let digest = known.get(f.id, step).unwrap_or_else(|| step_text_digest(step));
out.steps.insert(f.id, (step.clone(), digest));
}
FeatureKind::MeshImport { meshes, .. } => {
let digests = meshes
.iter()
.enumerate()
.map(|(index, mesh)| {
let digest = known.mesh(f.id, index, mesh).unwrap_or_else(|| mesh_content_digest(mesh));
MeshDigest { positions: mesh.positions.clone(), triangles: mesh.triangles.clone(), digest }
})
.collect();
out.meshes.insert(f.id, digests);
}
_ => {}
}
}
out
}
/// The digest of `step`, when it is the text feature `id` was last seen with.
fn get(&self, id: u64, step: &Arc<str>) -> Option<u64> {
self.0.get(&id).filter(|(text, _)| Arc::ptr_eq(text, step)).map(|(_, digest)| *digest)
self.steps.get(&id).filter(|(text, _)| Arc::ptr_eq(text, step)).map(|(_, digest)| *digest)
}
fn mesh(&self, id: u64, index: usize, mesh: &crate::MeshData) -> Option<u64> {
self.meshes.get(&id)?.get(index).filter(|cached| cached.matches(mesh)).map(|cached| cached.digest)
}
}
fn mesh_content_digest(mesh: &crate::MeshData) -> u64 {
#[cfg(test)]
cost_tests::count(|c| c.mesh_digested += 1);
let mut h = std::collections::hash_map::DefaultHasher::new();
mesh.positions.len().hash(&mut h);
for coordinate in mesh.positions.iter() {
coordinate.to_bits().hash(&mut h);
}
mesh.triangles.hash(&mut h);
h.finish()
}
fn step_text_digest(text: &str) -> u64 {
@@ -1208,12 +1254,12 @@ fn quantize(sk: &mut solidcraft_sketch::Sketch) {
}
/// A feature as fingerprinted: its definition, a sketch's stored solve quantized, an imported
/// file's text by its digest.
/// file's text or mesh payload by its content digest.
/// Hash [`feature_json`] of `f` into `h`. Every evaluation fingerprints every feature, and in a
/// design built command by command almost all of them are the same as last time: the text of a
/// feature is kept (by id) with the feature it was written from, and written again only when the
/// feature differs. An Import's text also depends on its STEP digest, so it is written each time
/// as before.
/// feature differs. Embedded imports also depend on their content digests, so their small
/// metadata-and-digest representation is written each time without comparing large arrays.
///
/// The texts are shared by every thread: a command evaluated ahead runs on a new worker thread
/// each time (the desktop's deferred commands and delete checks, the control channel's ahead
@@ -1221,10 +1267,10 @@ fn quantize(sk: &mut solidcraft_sketch::Sketch) {
/// feature again. The lock is held only to look up or store an entry, never while a feature is
/// compared or written; an entry is the same whichever thread wrote it, since the text is a
/// function of the feature alone.
fn hash_feature_json(f: &Feature, steps: &StepDigests, h: &mut impl Hasher) {
fn hash_feature_json(f: &Feature, steps: &ImportDigests, h: &mut impl Hasher) {
type Written = std::collections::HashMap<u64, Arc<(Feature, String)>>;
static WRITTEN: std::sync::LazyLock<std::sync::Mutex<Written>> = std::sync::LazyLock::new(Default::default);
if matches!(f.kind, FeatureKind::Import { .. }) {
if matches!(f.kind, FeatureKind::Import { .. } | FeatureKind::MeshImport { .. }) {
feature_json(f, steps).hash(h);
return;
}
@@ -1247,7 +1293,21 @@ fn hash_feature_json(f: &Feature, steps: &StepDigests, h: &mut impl Hasher) {
written.insert(f.id, entry);
}
fn feature_json(f: &Feature, steps: &StepDigests) -> String {
fn feature_json(f: &Feature, steps: &ImportDigests) -> String {
if matches!(&f.kind, FeatureKind::MeshImport { .. }) {
let mut g = f.clone();
let mut digests = Vec::new();
if let FeatureKind::MeshImport { meshes, .. } = &mut g.kind {
for (index, mesh) in meshes.iter_mut().enumerate() {
digests.push(steps.mesh(f.id, index, mesh).unwrap_or_else(|| mesh_content_digest(mesh)));
mesh.positions = Arc::from([]);
mesh.triangles = Arc::from([]);
}
}
// Keep the complete definition (including mesh order, names and colors) without
// writing the large numeric arrays out again. Equal content in fresh Arcs hashes alike.
return serde_json::to_string(&(g, digests)).unwrap_or_default();
}
if let FeatureKind::Import { step, .. } = &f.kind {
let mut g = f.clone();
if let FeatureKind::Import { step: text, .. } = &mut g.kind {
@@ -1322,6 +1382,12 @@ impl Document {
pub fn evaluation_values(&self) -> BTreeMap<String, Value> {
evaluation_values(self).0
}
/// A placement point read as the features read it: the parameters, and a bare number
/// beside them in the design's length unit (#9015).
pub fn eval_point(&self, p: &crate::PointExpr) -> Result<Vec3> {
point(&self.evaluation_values(), p)
}
}
/// The values features are evaluated with: the parameters, and the design's length unit.
@@ -1344,7 +1410,7 @@ fn chain_fingerprint(
f: &Feature,
prev: u64,
rolled_back: bool,
steps: &StepDigests,
steps: &ImportDigests,
st: Option<&ModelState>,
) -> u64 {
let mut fp = fingerprint(doc, prev, f, vals, rolled_back, steps) ^ placement_digest(doc, f, st);
@@ -1422,7 +1488,7 @@ fn context_digest(doc: &Document, vals: &BTreeMap<String, Value>) -> u64 {
for f in doc.features.iter().filter(|f| {
matches!(f.kind, FeatureKind::ConstructionPlane { .. } | FeatureKind::ConstructionAxis { .. } | FeatureKind::ConstructionPoint { .. })
}) {
fingerprint(doc, 0, f, vals, false, &StepDigests::default()).hash(&mut h);
fingerprint(doc, 0, f, vals, false, &ImportDigests::default()).hash(&mut h);
}
doc.units.hash(&mut h);
serde_json::to_string(&doc.sheet).unwrap_or_default().hash(&mut h);
@@ -1504,6 +1570,9 @@ fn cache_key(f: &Feature, chain_fp: u64, local: u64, ctx: u64, state: &ModelStat
#[derive(Default)]
struct PlacementReads {
all: bool,
/// A body named that `st` lacks: a further placement's (`Block (Part:2)`), found through
/// its occurrence (#9957), or gone.
unlisted: bool,
components: std::collections::BTreeSet<u64>,
paths: Vec<Vec<u64>>,
bodies: std::collections::BTreeSet<String>,
@@ -1542,14 +1611,17 @@ impl PlacementReads {
}
}
/// The component of body `name` in `st`; every component when the body is not there (a
/// projection then takes the body nearest its point, wherever that is).
/// The component of body `name` in `st`; every component and every placement when the body
/// is not there (a further placement's body, or the body nearest its point, wherever that is).
fn body(&mut self, doc: &Document, st: Option<&ModelState>, name: &str) {
match st.and_then(|st| st.body(name)) {
Some(b) => {
self.components.insert(doc.model_body_component(b));
}
None => self.all = true,
None => {
self.all = true;
self.unlisted = true;
}
}
}
@@ -1650,6 +1722,13 @@ fn placement_digest(doc: &Document, f: &Feature, st: Option<&ModelState>) -> u64
for (sketch, _) in f.kind.profile_sels() {
if doc.feature(sketch).is_some_and(|s| s.component != f.component) {
reads.sketch(doc, sketch);
// A later transfer can preserve this profile's historical frame. Changes to that
// boundary (including suppression and rollback) must invalidate the constructor.
if let Some(relative) = crate::body_transfer::profile_frame(doc, f, sketch) {
"transferred profile".hash(&mut h);
sketch.hash(&mut h);
hash_placement(&relative, &mut h);
}
}
}
let path = match &f.kind {
@@ -1778,6 +1857,9 @@ fn placement_digest(doc: &Document, f: &Feature, st: Option<&ModelState>) -> u64
}
}
hash_placed_bodies(doc, st, &reads.bodies, &inverse, &mut h);
if reads.unlisted {
placements_hash(doc).hash(&mut h);
}
// Legacy and component-wide reads still need the conservative offset dependency. Body
// reads above already include just their own offsets, in the frame that evaluates them.
if reads.all || !reads.components.is_empty() || !reads.paths.is_empty() {
@@ -1915,7 +1997,7 @@ impl Model {
}
// The fingerprints from the first stored sketch on.
let vals = evaluation_values(doc).0;
let steps = StepDigests::of(doc, &self.step_digests);
let steps = ImportDigests::of(doc, &self.import_digests);
let marker = doc.marker.unwrap_or(usize::MAX);
let mut prev_fp = first.checked_sub(1).and_then(|i| self.results.get(i)).map_or(0, |r| r.fingerprint);
for (i, f) in doc.features.iter().enumerate().skip(first) {
@@ -1965,7 +2047,7 @@ impl Model {
return false;
}
let vals = evaluation_values(doc).0;
let steps = StepDigests::of(doc, &self.step_digests);
let steps = ImportDigests::of(doc, &self.import_digests);
let marker = doc.marker.unwrap_or(usize::MAX);
let mut prev = 0;
// The model each feature starts from (which components the bodies it names are in).
@@ -2030,7 +2112,7 @@ impl Model {
let previous_context = self.context;
let mut rolled_results = std::mem::take(&mut self.rolled_results);
self.context = ctx;
self.step_digests = StepDigests::of(doc, &self.step_digests);
self.import_digests = ImportDigests::of(doc, &self.import_digests);
let primed = self.primed.as_ref().filter(|(context, _)| !self.no_cache && *context == ctx).map(|(_, results)| results.clone());
// Each feature's component, for `Document::legacy_body_component` on every body after
// every feature without searching the timeline for each (the first feature with an id).
@@ -2065,7 +2147,7 @@ impl Model {
};
// Opening was cancelled: the rest is skipped and the result discarded.
let cancelled = watched.step(i);
let fp = chain_fingerprint(doc, &vals, f, prev_fp, rolled_back, &self.step_digests, Some(&state));
let fp = chain_fingerprint(doc, &vals, f, prev_fp, rolled_back, &self.import_digests, Some(&state));
prev_fp = fp;
let current = |old: &&FeatureResult| old.id == f.id && old.fingerprint == fp && !old.stale;
// A state the saved model did not keep is evaluated again from the one before it; the
@@ -2141,7 +2223,7 @@ impl Model {
}
// The same feature on the same inputs: its earlier result.
let key =
cache_key(f, fp, fingerprint(doc, 0, f, &vals, false, &self.step_digests) ^ placement_digest(doc, f, Some(&state)), ctx, &state);
cache_key(f, fp, fingerprint(doc, 0, f, &vals, false, &self.import_digests) ^ placement_digest(doc, f, Some(&state)), ctx, &state);
let kept = self.kept.0.as_ref().filter(|_| !self.no_cache);
if !self.no_cache
&& let Some(c) = self
@@ -2276,7 +2358,9 @@ impl Model {
}
_ => mats,
};
next.stamp(doc, &state, f, key, mats);
// A revolve axis origin that follows parameters names its faces from its values.
let named = naming_feature(&vals, f);
next.stamp(doc, &state, &named, key, mats);
// Evaluated again only for what came before it (a mirror moved by a drag).
if let Some(old) = self.results.get(i).filter(|old| old.id == f.id) {
let made = old.state.bodies.iter().filter(|b| b.feature == f.id).map(|b| (b.name.as_str(), b));
@@ -2495,7 +2579,7 @@ impl Model {
pub(crate) fn refingerprint_from(&mut self, doc: &Document, start: usize) {
self.placement_inputs = None;
self.fingerprinted = None;
self.step_digests = StepDigests::of(doc, &self.step_digests);
self.import_digests = ImportDigests::of(doc, &self.import_digests);
let start = start.min(self.results.len()).min(doc.features.len());
if start == self.results.len().min(doc.features.len()) {
return;
@@ -2507,7 +2591,7 @@ impl Model {
// Each feature's fingerprint reads the model it starts from, as evaluating does.
let mut before = last.map_or_else(|| Arc::new(ModelState::default()), |r| r.state.clone());
for (i, (r, f)) in self.results.iter_mut().zip(&doc.features).enumerate().skip(start) {
prev = chain_fingerprint(doc, &vals, f, prev, i >= marker, &self.step_digests, Some(&before));
prev = chain_fingerprint(doc, &vals, f, prev, i >= marker, &self.import_digests, Some(&before));
r.fingerprint = prev;
before = r.state.clone();
}
@@ -3593,10 +3677,11 @@ pub fn hole_on_surface(st: &ModelState, p: Vec3, dir: Vec3, scope: &[String]) ->
/// The faces of the model near `p`: for each, how far `p` is from it, the outward normal there
/// (the surface's own normal for planes, cylinders and spheres, turned the way the face looks out
/// of its body; the nearest mesh facet's otherwise) and, for a plane, how far `p` is from the
/// plane itself. Only faces within `reach` of `p`, nearest first.
fn faces_through(st: &ModelState, p: Vec3, reach: f64) -> Vec<(f64, Vec3, Option<f64>)> {
/// plane itself. Only faces within `reach` of `p`, nearest first, of the bodies in `scope`
/// (every body when it is empty).
fn faces_through(st: &ModelState, p: Vec3, reach: f64, scope: &[String]) -> Vec<(f64, Vec3, Option<f64>)> {
let mut out: Vec<(f64, Vec3, Option<f64>)> = Vec::new();
for b in st.bodies.iter().filter(|b| !b.body.is_mesh()) {
for b in st.bodies.iter().filter(|b| !b.body.is_mesh() && in_hole_scope(scope, b)) {
let m = b.mesh();
// The nearest facet of each face of this body.
let mut near: BTreeMap<u32, (f64, Vec3)> = BTreeMap::new();
@@ -3642,13 +3727,29 @@ fn faces_through(st: &ModelState, p: Vec3, reach: f64) -> Vec<(f64, Vec3, Option
out
}
/// The faces near `p` a hole placed there goes by (as [`faces_through`]): those of the bodies in
/// `scope`, the hole's own, when one of them passes within `on` of `p`, else those of every body.
/// Where a plate's face touches another body both faces pass through `p`, and a hole that drills
/// the plate goes by the plate's face, never the other body's (#12066). A hole placed on another
/// body's face, to drill through it into its own body behind, still goes by that face.
fn hole_faces_through(st: &ModelState, p: Vec3, reach: f64, on: f64, scope: &[String]) -> Vec<(f64, Vec3, Option<f64>)> {
if !scope.is_empty() {
let own = faces_through(st, p, reach, scope);
if own.first().is_some_and(|(d, _, _)| *d <= on) {
return own;
}
}
faces_through(st, p, reach, &[])
}
/// The way a hole placed at `p` drills: into the material, against the outward normal of the
/// face `p` lies on. A point on no face (a planar face's centroid outside it, say) takes the
/// nearest planar face whose plane it lies in, else the face nearest it within its body's pick
/// tolerance. None when no face is near.
pub fn hole_direction_at(st: &ModelState, p: Vec3) -> Option<Vec3> {
/// tolerance. None when no face is near. Where the faces of several bodies pass through `p`, those
/// of the bodies in `scope` (the bodies the hole drills; empty for every body) decide.
pub fn hole_direction_at(st: &ModelState, p: Vec3, scope: &[String]) -> Option<Vec3> {
let size = st.bodies.iter().map(|b| b.body.size()).fold(1.0, f64::max);
let faces = faces_through(st, p, f64::INFINITY);
let faces = hole_faces_through(st, p, f64::INFINITY, size * 1e-4, scope);
if let Some((d, n, _)) = faces.first()
&& *d <= size * 1e-4
{
@@ -3665,10 +3766,13 @@ pub fn hole_direction_at(st: &ModelState, p: Vec3) -> Option<Vec3> {
/// starts on (as designs saved before format 18 can say: their direction was taken from any face
/// whose plane passed near the point, such as the other side of a thin plate or a face of another
/// body at the same height), it is mirrored in the face it starts on, so it goes in at the same
/// angle. A direction that enters a face there, or a spot on no face, is kept.
fn hole_dir_into_face(st: &ModelState, p: Vec3, dir: Vec3) -> Vec3 {
/// angle. A direction that enters a face there, or a spot on no face, is kept. Where the faces of
/// the hole's own bodies (`scope`, see [`hole_drill_scope`]) pass through the spot, only they
/// count: a hole that names the plate it drills, saved with the direction into a body touching
/// the plate there, drills the plate (#12066).
fn hole_dir_into_face(st: &ModelState, p: Vec3, dir: Vec3, scope: &[String]) -> Vec3 {
let size = st.bodies.iter().map(|b| b.body.size()).fold(1.0, f64::max);
let faces = faces_through(st, p, size * 1e-5);
let faces = hole_faces_through(st, p, size * 1e-5, size * 1e-5, scope);
if faces.is_empty() || faces.iter().any(|(_, n, _)| n.dot(dir) < -1e-9) {
return dir;
}
@@ -4140,10 +4244,26 @@ fn blend_body(
Ok((Traced::step(nb.with_preserved_planar_sources(&mb.body)?, &[&from]), used, tapered))
}
/// `f` as the naming of its faces reads it: a revolve's explicit axis origin written as the values
/// it has now (the naming has no parameter values to read an expression with).
fn naming_feature<'a>(vals: &BTreeMap<String, Value>, f: &'a Feature) -> std::borrow::Cow<'a, Feature> {
if let FeatureKind::Revolve { axis: AxisRef::Line { origin, .. }, .. } = &f.kind
&& let Ok(o) = point(vals, origin)
{
let mut g = f.clone();
if let FeatureKind::Revolve { axis: AxisRef::Line { origin, .. }, .. } = &mut g.kind {
*origin = [format!("{} mm", o.x).into(), format!("{} mm", o.y).into(), format!("{} mm", o.z).into()];
}
return std::borrow::Cow::Owned(g);
}
std::borrow::Cow::Borrowed(f)
}
/// The revolve axis in the sketch's plane coordinates (a point and a direction). `st` is the model
/// an axis on an edge or face of it is found in; `construct` gives a construction axis, by name, in
/// the sketch's 3D frame.
/// the sketch's 3D frame; `vals` are the parameter values an explicit axis's origin is read with.
pub(crate) fn revolve_axis(
vals: &BTreeMap<String, Value>,
ss: &SolvedSketch,
axis: &AxisRef,
st: &ModelState,
@@ -4178,7 +4298,7 @@ pub(crate) fn revolve_axis(
}
Ok((pl.to_local(Vec3::ZERO), Vec2::new(d.dot(pl.x), d.dot(pl.y))))
}
AxisRef::Line { origin, dir } => line_in_sketch_plane(ss, origin.0, dir.0),
AxisRef::Line { origin, dir } => line_in_sketch_plane(ss, point(vals, origin)?, dir.0),
AxisRef::Model { geo } => {
let (o, d) = crate::construct::model_axis(st, geo)?;
line_in_sketch_plane(ss, o, d)
@@ -4287,6 +4407,13 @@ fn hole_scope(f: &Feature) -> &[String] {
}
}
/// The bodies whose faces decide which way a hole placed at a position drills, where they pass
/// through it: the bodies it cuts when it names them, so a body touching the face there can't
/// turn it round (#12066). Empty (every body) otherwise, and for `["*"]`.
fn hole_drill_scope(f: &Feature) -> &[String] {
if f.participants.iter().any(|b| b == "*") { &[] } else { &f.participants }
}
fn in_hole_scope(scope: &[String], b: &ModelBody) -> bool {
scope.is_empty() || scope.contains(&b.name)
}
@@ -4312,7 +4439,7 @@ fn hole_spots(vals: &BTreeMap<String, Value>, st: &ModelState, f: &Feature) -> R
let dir = direction.normalized().ok_or_else(|| DocError::Invalid("hole direction".into()))?;
let at = hole_on_surface(st, single_hole_position(vals, st, position, at, points)?, dir, &[]);
let at = with_references(vals, st, f, at, dir, true)?;
let dir = hole_dir_into_face(st, at, dir);
let dir = hole_dir_into_face(st, at, dir, hole_drill_scope(f));
return Ok((vec![hole_on_surface(st, at, dir, &[])], dir));
}
};
@@ -4360,7 +4487,7 @@ fn hole_spots_raw(vals: &BTreeMap<String, Value>, st: &ModelState, f: &Feature)
let dir = direction.normalized().ok_or_else(|| DocError::Invalid("hole direction".into()))?;
let p = single_hole_position(vals, st, position, at, points)?;
let p = if hole_references(f).is_empty() { p } else { with_references(vals, st, f, hole_on_surface(st, p, dir, &[]), dir, false)? };
Ok((vec![p], hole_dir_into_face(st, hole_on_surface(st, p, dir, &[]), dir)))
Ok((vec![p], hole_dir_into_face(st, hole_on_surface(st, p, dir, &[]), dir, hole_drill_scope(f))))
}
}
}
@@ -5051,10 +5178,10 @@ fn feature_tools_with_constructor(
let unnamed = |bodies: Vec<Body>| -> Vec<Traced> { bodies.into_iter().map(Traced::unnamed).collect() };
match &f.kind {
FeatureKind::Extrude { sketch, profiles, extent, operation, targets, thin } => {
let ss = sketch_in_feature_frame(
let ss = constructor_profile_in_frame(
doc,
st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?,
f.component,
f,
)?;
let regions = match thin {
Some(thin) => thin_regions(vals, &ss, thin)?,
@@ -5278,14 +5405,15 @@ fn feature_tools_with_constructor(
Ok(tools)
}
FeatureKind::Revolve { sketch, profiles, axis, angle, angle2, symmetric, convention, to, .. } => {
let ss = sketch_in_feature_frame(
let ss = constructor_profile_in_frame(
doc,
st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?,
f.component,
f,
)?;
let regions = solidcraft_sketch::merge_regions(&select_profiles(&ss, profiles)?);
let (o, d) =
revolve_axis(&ss, axis, &*axis_state(doc, st, f.component, axis)?, &|name| construction_axis_in(Some(doc), st, name, f.component))?;
let (o, d) = revolve_axis(vals, &ss, axis, &*axis_state(doc, st, f.component, axis)?, &|name| {
construction_axis_in(Some(doc), st, name, f.component)
})?;
// To Object is in this feature's component frame: the model as seen from there.
let tst = if to.is_some() { state_in_frame(doc, st, f.component)? } else { std::borrow::Cow::Borrowed(st) };
let angles = if to.is_some() {
@@ -5686,16 +5814,27 @@ fn sketch_in_feature_frame(doc: &Document, sketch: &SolvedSketch, consumer: u64)
profile_in_frame(doc, sketch, consumer)
}
fn constructor_profile_in_frame(doc: &Document, sketch: &SolvedSketch, feature: &Feature) -> Result<SolvedSketch> {
match crate::body_transfer::profile_frame(doc, feature, sketch.feature) {
Some(relative) => profile_with_transform(sketch, &relative),
None => sketch_in_feature_frame(doc, sketch, feature.component),
}
}
fn profile_in_frame(doc: &Document, sketch: &SolvedSketch, consumer: u64) -> Result<SolvedSketch> {
let owner = doc.feature(sketch.feature).map(|feature| feature.component).unwrap_or(0);
let inverse =
crate::mat_inverse(&doc.component_transform(consumer)).ok_or_else(|| DocError::Invalid("profile consumer frame is invalid".into()))?;
let relative = crate::mat_mul(&inverse, &doc.component_transform(owner));
profile_with_transform(sketch, &relative)
}
fn profile_with_transform(sketch: &SolvedSketch, relative: &Mat) -> Result<SolvedSketch> {
let mut mapped = sketch.clone();
mapped.plane = Plane::new(
crate::apply_point(&relative, sketch.plane.origin),
crate::apply_vector(&relative, sketch.plane.x),
crate::apply_vector(&relative, sketch.plane.y),
crate::apply_point(relative, sketch.plane.origin),
crate::apply_vector(relative, sketch.plane.x),
crate::apply_vector(relative, sketch.plane.y),
)
.ok_or_else(|| DocError::Invalid("profile sketch frame is invalid".into()))?;
Ok(mapped)
@@ -6596,6 +6735,24 @@ fn replay(
}
continue;
}
// A moved copy is copied again as a body: each instance is the copy the Move made,
// placed by the pattern (#5931).
if let FeatureKind::Move { copy: true, .. } = &src.kind {
let made: Vec<ModelBody> = st.bodies.iter().filter(|b| b.feature == src.id).cloned().collect();
if made.is_empty() {
return Err(DocError::Invalid(format!("{} made no copy to repeat", src.name)));
}
let mut output = 0;
for (k, m) in mats.iter().enumerate() {
for mb in &made {
let moved = moved_copy(mb, kernel::transform_matrix(&mb.body, *m)?, first + k + 1);
let name = copy_body_name(st, f, output, &mb.name);
st.bodies.push(ModelBody::traced_new(name, moved, f.id));
output += 1;
}
}
continue;
}
let (op, targets) = feature_op(src);
// A primitive's or a hole's targets are its participants (#1108): the copies change
// those bodies only, like the feature itself (#2755).
@@ -7064,7 +7221,6 @@ fn eval_feature(
// Its tools meet bodies in other placements where those are shown (#3383).
let _frames = ToolFrames::install(doc, f, st);
match &f.kind {
FeatureKind::NewerFileFeature { raw } => Err(DocError::Invalid(raw.why())),
FeatureKind::BodyTransfer { bodies, from, to, transform } => {
crate::body_transfer::evaluate(doc, st, &crate::plain(bodies), *from, *to, *transform)
}
@@ -7114,6 +7270,14 @@ fn eval_feature(
None if !plane_reads_geometry(plane) && sketch.links.is_empty() => std::borrow::Cow::Borrowed(st),
None => state_in_frame(doc, st, f.component)?,
};
// A further placement a link reads is placed in its frame too (#9957).
let named: Vec<&str> = sketch.links.iter().flat_map(|l| l.source.bodies()).collect();
let fst = match anchor.map(|anchor| anchor.author_frame(doc, f.component)) {
_ if named.is_empty() => fst,
Some(Ok(frame)) => with_placed_copies(doc, st, fst, &frame, &named),
Some(Err(_)) => fst,
None => with_placed_copies(doc, st, fst, &doc.component_transform(f.component), &named),
};
let (plane, face_warning) = match (anchor, plane) {
(Some(_), plane) => doc.resolve_attached_plane_noted(vals, plane, f.component, st, &fst)?,
// A flat face of a sheet: the panel's exact plane, from its flat region and fold.
@@ -8351,6 +8515,51 @@ pub fn state_in_frame_at<'a>(doc: &Document, st: &'a ModelState, comp: u64, fram
Ok(Cow::Owned(out))
}
/// `framed` (the model `st` in the frame that `frame` places in the world) with the further
/// placements `names` names (`Block (Part:2)`), where they are in the world. A component's model
/// holds its first placement only, so a sketch projecting a copy's face found no body of that
/// name and took the first placement's (#9957). Only the named ones are placed: placing every
/// body of an assembly for each sketch made sketch edits as slow as the design is big.
pub fn with_placed_copies<'a>(
doc: &Document,
st: &ModelState,
framed: std::borrow::Cow<'a, ModelState>,
frame: &Mat,
names: &[&str],
) -> std::borrow::Cow<'a, ModelState> {
let missing: Vec<&str> = names.iter().copied().filter(|n| !n.is_empty() && framed.body(n).is_none()).collect();
if missing.is_empty() || doc.occurrences.is_empty() {
return framed;
}
let Some(inv) = crate::mat_inverse(frame) else { return framed };
let mut by_component: std::collections::HashMap<u64, Vec<&ModelBody>> = std::collections::HashMap::new();
for (b, owner) in st.bodies.iter().zip(doc.model_body_components(st)) {
by_component.entry(owner).or_default().push(b);
}
let world = crate::WorldNames::new(doc, st);
let mut placed: Vec<ModelBody> = Vec::new();
for (comp, path, m) in doc.placements() {
for b in by_component.get(&comp).into_iter().flatten() {
let name = world.name(&b.name, comp, &path);
if !missing.contains(&name.as_str()) || placed.iter().any(|p| p.name == name) {
continue;
}
let bm = doc.model_body_offset(b).map(|o| crate::mat_mul(&m, o)).unwrap_or(m);
if let Ok(mut copy) = st.body_in_frame(b, &crate::mat_mul(&inv, &bm)) {
copy.name = name;
copy.source_name = Some(b.definition_name().to_string());
placed.push(copy);
}
}
}
if placed.is_empty() {
return framed;
}
let mut out = framed.into_owned();
out.bodies.extend(placed);
std::borrow::Cow::Owned(out)
}
/// The model placed in the world: each component's bodies, sketches, threads and construction
/// geometry through every occurrence of it (bodies of the first placement keep their names;
/// further instances get the occurrence path in brackets). Without components this is the model
+123 -1
View File
@@ -16,7 +16,7 @@ use solidcraft_sketch::Sketch;
use crate::eval::{PlacementReads, body_world, hash_placement, placement_digest};
use crate::*;
/// Work counted on this thread: bodies moved, STEP texts digested, joint faces and bodies
/// Work counted on this thread: bodies moved, STEP/mesh contents digested, joint faces and bodies
/// measured, bodies searched for by name, features searched for by id, bodies a cut (or
/// intersect) ran its booleans on, bodies searched for the cylinder a tap marking lies on,
/// bodies whose faces a tapped hole read for its wall, intersects run to find a pattern's copies
@@ -26,6 +26,7 @@ use crate::*;
pub(super) struct Counts {
pub moved: usize,
pub digested: usize,
pub mesh_digested: usize,
pub joint_faces: usize,
pub measures: usize,
pub body_searches: usize,
@@ -42,6 +43,7 @@ thread_local! {
Cell::new(Counts {
moved: 0,
digested: 0,
mesh_digested: 0,
joint_faces: 0,
measures: 0,
body_searches: 0,
@@ -1637,3 +1639,123 @@ fn tools_are_apart_only_when_they_neither_meet_nor_nest() {
// A rod in the tube's bore, clear of its wall, is apart from it.
assert!(super::tools_clearly_apart(&[tube, rod(0.0, 0.0, 3.0)]));
}
fn imported_meshes() -> Document {
let mut doc = Document::new("reference meshes");
let comp = doc.add_component(Some("Part"), 0).unwrap();
let meshes = (0..2)
.map(|i| MeshData {
name: format!("Mesh{i}"),
positions: [0.0, 0.0, 0.0, 2.0, 0.0, 0.0, 0.0, 3.0, 0.0, 0.0, 0.0, 4.0].into(),
triangles: [0, 2, 1, 0, 1, 3, 0, 3, 2, 1, 2, 3].into(),
color: Some([0.2, 0.4, 0.6]),
})
.collect();
let id = doc.add_feature(FeatureKind::MeshImport { file: "tetrahedra.stl".into(), meshes }, Some("Meshes")).unwrap();
doc.feature_mut(id).unwrap().component = comp;
doc
}
fn meshes_mut(doc: &mut Document) -> &mut Vec<MeshData> {
let FeatureKind::MeshImport { meshes, .. } = &mut doc.features[0].kind else { panic!("mesh fixture") };
meshes
}
fn same_as_fresh(doc: &Document, model: &Model) {
let mut fresh = Model::new();
fresh.no_cache = true;
fresh.evaluate(doc);
assert_eq!(model.results.iter().map(|r| r.fingerprint).collect::<Vec<_>>(), fresh.results.iter().map(|r| r.fingerprint).collect::<Vec<_>>());
let (actual, expected) = (model.state(), fresh.state());
assert_eq!(actual.bodies.len(), expected.bodies.len());
for (a, b) in actual.bodies.iter().zip(&expected.bodies) {
assert_eq!(a.name, b.name);
assert_eq!(a.mesh().positions, b.mesh().positions);
assert_eq!(a.mesh().triangles, b.mesh().triangles);
assert_eq!(a.body.color(), b.body.color());
}
assert!(model.results.iter().all(|r| r.error.is_none()));
}
#[test]
fn unchanged_mesh_payloads_are_not_read_again_for_rigid_motion() {
let mut doc = imported_meshes();
let mut model = Model::new();
assert_eq!(counted(|| model.evaluate(&doc)).mesh_digested, 2);
for k in 1..5 {
doc = doc.clone(); // Undo/preview copies share the arrays, not the document itself.
doc.occurrences[0].transform = rigid(Vec3::new(f64::from(k), 2.0, 3.0), Vec3::ZERO, Vec3::Z, 0.1 * f64::from(k));
assert_eq!(
counted(|| {
model.evaluate(&doc);
assert!(model.is_current(&doc));
})
.mesh_digested,
0
);
assert_eq!(model.last_recomputed, 0);
same_as_fresh(&doc, &model);
}
let saved = serde_json::to_string(&doc).unwrap();
let reopened: Document = serde_json::from_str(&saved).unwrap();
assert_eq!(counted(|| model.evaluate(&reopened)).mesh_digested, 2, "new array identities must be checked");
assert_eq!(model.last_recomputed, 0, "equal content has equal fingerprints");
same_as_fresh(&reopened, &model);
}
#[test]
fn mesh_payload_edits_invalidate_the_cache_and_undo_restores_the_geometry() {
let mut doc = imported_meshes();
let original = doc.clone();
let mut model = Model::new();
model.evaluate(&doc);
let fingerprint = model.results[0].fingerprint;
Arc::make_mut(&mut meshes_mut(&mut doc)[0].positions)[3] = 5.0;
assert!(!model.is_current(&doc));
assert_eq!(counted(|| model.evaluate(&doc)).mesh_digested, 1);
assert_ne!(model.results[0].fingerprint, fingerprint);
same_as_fresh(&doc, &model);
let edited = doc.clone();
Arc::make_mut(&mut meshes_mut(&mut doc)[0].triangles).swap(0, 1);
assert_eq!(counted(|| model.evaluate(&doc)).mesh_digested, 1);
same_as_fresh(&doc, &model);
for restored in [&original, &edited, &original] {
model.evaluate(restored);
same_as_fresh(restored, &model);
}
assert_eq!(model.results[0].fingerprint, fingerprint);
}
#[test]
fn mesh_metadata_and_order_remain_part_of_the_fingerprint() {
let mut doc = imported_meshes();
let mut model = Model::new();
model.evaluate(&doc);
for edit in 0..7 {
let before = model.results[0].fingerprint;
match edit {
0 => meshes_mut(&mut doc)[0].name = "Renamed".into(),
1 => meshes_mut(&mut doc)[0].color = Some([0.8, 0.1, 0.3]),
2 => doc.features[0].name = "Renamed feature".into(),
3 => meshes_mut(&mut doc).swap(0, 1),
4 => {
meshes_mut(&mut doc).pop();
}
5 => {
let mesh = meshes_mut(&mut doc)[0].clone();
meshes_mut(&mut doc).push(MeshData { name: "Added".into(), ..mesh });
}
_ => {
let FeatureKind::MeshImport { file, .. } = &mut doc.features[0].kind else { panic!("mesh fixture") };
*file = "renamed.stl".into();
}
}
assert!(!model.is_current(&doc));
let work = counted(|| model.evaluate(&doc));
if edit < 3 || edit == 6 {
assert_eq!(work.mesh_digested, 0, "metadata alone: {edit}");
}
assert_ne!(model.results[0].fingerprint, before, "edit {edit}");
same_as_fresh(&doc, &model);
}
}
+7 -4
View File
@@ -63,13 +63,16 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
let t = val(vals, thickness, Kind::Length)?;
let mut tools = Vec::new();
for n in bodies {
let b = st.body(n).ok_or_else(|| DocError::Unknown(format!("body `{n}`")))?;
tools.push(kernel::thicken(&b.body, t, *symmetric)?);
let b = st.body(n).cloned().ok_or_else(|| DocError::Unknown(format!("body `{n}`")))?;
// Kept face, offset and edge walls. Carry that history so a sketch can attach
// to the solid's faces and follow the thickness (#7641).
let thickened = kernel::thicken(&b.body, t, *symmetric)?;
tools.push(Traced::step(thickened, &[&b.traced()]));
}
if tools.is_empty() {
return Err(DocError::Invalid("pick the surfaces to thicken".into()));
}
apply_op(doc, st, f, tools, *operation, &crate::plain(targets))
apply_traced(doc, st, f, tools, *operation, &crate::plain(targets))
}
FeatureKind::SurfaceTrim { body, plane, tool, keep, keep_more, curves, with, keep_with, stitch, extend } => {
let i = st.bodies.iter().position(|b| &b.name == body).ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
@@ -308,7 +311,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?,
f.component,
)?;
let (o, d) = revolve_axis(&ss, axis, &*super::axis_state(doc, st, f.component, axis)?, &|name| {
let (o, d) = revolve_axis(vals, &ss, axis, &*super::axis_state(doc, st, f.component, axis)?, &|name| {
construction_axis_in(Some(doc), st, name, f.component)
})?;
let a = val(vals, angle, Kind::Angle)?;
+3 -2
View File
@@ -55,7 +55,8 @@ impl FeatureKind {
/// The body a feature that works on one body's faces names (shell, draft, split, offset,
/// delete and replace face, surface offset, the placed plastic features, sheet metal
/// conversion, ruled surface, untrim): its face points pick faces of that body.
/// conversion, ruled surface, untrim, Split Body's tool face): its face points pick faces of
/// that body.
pub fn face_body(&self) -> Option<&str> {
match self {
FeatureKind::Shell { body, .. }
@@ -69,7 +70,7 @@ impl FeatureKind {
| FeatureKind::SnapFit { body, .. }
| FeatureKind::Rest { body, .. } => body.as_deref(),
FeatureKind::SheetConvert { body, .. } | FeatureKind::SurfaceRuled { body, .. } => Some(body.as_ref()),
FeatureKind::SurfaceUntrim { face, .. } => Some(face.body.as_ref()),
FeatureKind::SurfaceUntrim { face, .. } | FeatureKind::Split { tool: Some(face), .. } => Some(face.body.as_ref()),
_ => None,
}
}
@@ -29,7 +29,6 @@ pub(super) fn fixtures() -> Vec<(&'static str, Value)> {
let hole =
|h: Value| json!({"type": "hole", "position": o, "direction": [0, 0, 1], "diameter": "hole.diameter", "depth": "hole.depth", "hole": h});
vec![
("newer_file_feature", json!({"type": "newer_file_feature", "raw": {"type": "gyroid_lattice", "cell": 2}})),
(
"body_transfer",
json!({"type": "body_transfer", "bodies": ["B"], "from": 0, "to": 1, "transform": [[1,0,0,0],[0,1,0,0],[0,0,1,0],[0,0,0,1]]}),
@@ -1,4 +1,3 @@
newer_file_feature
body_transfer
sketch
inputs:
@@ -1,4 +1,3 @@
newer_file_feature
body_transfer
sketch
moves: plane.axis_dir[0], plane.axis_dir[1], plane.axis_origin[0], plane.axis_origin[1], plane.axis_origin[2]
@@ -13,6 +12,7 @@ revolve
moves: axis.dir[0], axis.dir[1], axis.dir[2], axis.origin[0], axis.origin[1], axis.origin[2], to[0], to[1], to[2]
face points: [3, 3.5, 3.25]
profiles: 1 all
placement: 9, 9.5, 9.25
fillet_constant
moves: edges[0][0], edges[0][1], edges[0][2]
fillet_chord
@@ -87,6 +87,7 @@ construction_plane
moves: plane.axis_dir[0], plane.axis_dir[1], plane.axis_origin[0], plane.axis_origin[1], plane.axis_origin[2]
split
moves: plane.axis_dir[0], plane.axis_dir[1], plane.axis_origin[0], plane.axis_origin[1], plane.axis_origin[2], tool.point[0], tool.point[1], tool.point[2]
face points: [0, 0, 0]
split_line
moves: plane.axis_dir[0], plane.axis_dir[1], plane.axis_origin[0], plane.axis_origin[1], plane.axis_origin[2]
construction_axis
@@ -167,6 +168,7 @@ surface_extrude
face points: [1, 1.5, 1.25], [2, 2.5, 2.25]
surface_revolve
moves: axis.dir[0], axis.dir[1], axis.dir[2], axis.origin[0], axis.origin[1], axis.origin[2]
placement: 9, 9.5, 9.25
surface_sweep
surface_sweep_options
surface_loft
@@ -1,4 +1,3 @@
newer_file_feature
body_transfer
remap_bodies: bodies[0]
sketch
+117 -139
View File
@@ -2,9 +2,8 @@
//!
//! A design file starts with `"format": "solidcraft/N"`. A file of an older format is read and
//! upgraded step by step (N → N+1 → … → current): JSON-level steps first, for changes in the
//! file's shape, then document-level steps. A file a newer build saved opens too, with a warning:
//! what this build reads is read, what it doesn't is reported by name and kept for the next save
//! (`crate::newer_file`).
//! file's shape, then document-level steps. A file of a newer format than this build knows is
//! refused with a message saying so, never guessed at.
//!
//! History:
//! - 1: every design up to 2026-10-08 (format string since M0). Fields added over time all
@@ -265,7 +264,7 @@ const FORMATS: Formats<'static> = Formats { hand: &JSON_STEPS, folded: FOLDED, p
/// A kind of file whose format is versioned here: designs, and drawings (#3890). Each family has
/// its own format tag, its own numbers and its own folder of change files (`build.rs` lists them);
/// reading a file's format, telling a file a newer build saved and the upgrade are the same code
/// reading a file's format, refusing a file a newer build saved and the upgrade are the same code
/// for every family.
pub struct Family<'a> {
/// What a file of the family is called in messages.
@@ -318,8 +317,8 @@ impl Family<'_> {
}
/// Read a file's format and take its `format_pending` out of its JSON, before anything else
/// in it is read: a file that isn't this family's is refused here. One a newer build saved is
/// not: [`Family::newer`] says so.
/// in it is read: a file that isn't this family's, or that a newer build saved, is refused
/// here, so nothing a newer build saved is judged by this build's rules.
pub fn take_version(&self, v: &mut Value) -> Result<(u32, Option<Vec<String>>)> {
let noun = self.noun;
let not_ours = |what: String| DocError::Invalid(format!("not a SolidCraft {noun} ({what})"));
@@ -330,19 +329,10 @@ impl Family<'_> {
Some(_) => return Err(not_ours("format is not a string".into())),
};
let applied = take_names(v, "format_pending", noun)?;
if version == 0 {
return Err(not_ours("format 0".into()));
}
self.formats.check(noun, version, applied.as_deref())?;
Ok((version, applied))
}
/// What says a file of format `version` with the changes `applied` came from a newer build:
/// a number past this build's, or a change this build doesn't have anywhere in its history.
/// `None` for a file this build fully knows.
pub fn newer(&self, version: u32, applied: Option<&[String]>) -> Option<Newer> {
self.formats.newer(version, applied)
}
/// Bring a file of format `version` up to this build's; `applied` is its `format_pending`
/// (both from [`Family::take_version`]).
pub fn upgrade(&self, v: &mut Value, version: u32, applied: Option<&[String]>) -> Result<()> {
@@ -360,17 +350,6 @@ impl Family<'_> {
}
}
/// How a file shows a newer build saved it ([`Family::newer`]).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Newer {
/// The number the file says.
pub format: u32,
/// The number this build reads up to.
pub reads_up_to: u32,
/// The changes the file names that this build doesn't have.
pub unknown_changes: Vec<String>,
}
/// The names of the pending changes this build writes into a design (`format_pending`).
pub fn pending_names() -> Vec<String> {
DESIGN.pending_names()
@@ -415,25 +394,35 @@ impl Formats<'_> {
}
}
/// [`Family::newer`]. Changes are recognized across the entire history: a branch's pending
/// change may have landed at a later number. A list beside a format that isn't made of changes
/// means nothing (a tool or a test set an older number on a newer file).
fn newer(&self, version: u32, applied: Option<&[String]>) -> Option<Newer> {
/// Refuse a `noun` of format `version` that this build can't read: format 0, a newer number,
/// or a change `applied` (its `format_pending`) names that this build doesn't have anywhere in
/// its history, which only a newer build's file can. Changes are recognized across the entire
/// history: a branch's pending change may have landed at a later number. A list beside a format
/// that isn't made of changes means nothing (a tool or a test set an older number on a newer
/// file).
fn check(&self, noun: &str, version: u32, applied: Option<&[String]>) -> Result<()> {
let newer = |what: String| DocError::Invalid(format!("this {noun} was saved by a newer SolidCraft ({what}): update SolidCraft to open it"));
let current = self.current();
let listed = applied.filter(|_| version > current || self.parts(version).is_some()).unwrap_or_default();
let unknown_changes: Vec<String> =
listed.iter().filter(|name| !self.knows_part_name(self.canonical_applied_name(version, name))).cloned().collect();
(version > current || !unknown_changes.is_empty()).then_some(Newer { format: version, reads_up_to: current, unknown_changes })
if version == 0 {
return Err(DocError::Invalid(format!("not a SolidCraft {noun} (format 0)")));
}
if version > current {
return Err(newer(format!("file format {version}; this version reads formats up to {current}")));
}
let applied = applied.filter(|_| self.parts(version).is_some()).unwrap_or_default();
match applied.iter().find(|name| !self.knows_part_name(self.canonical_applied_name(version, name))) {
Some(unknown) => Err(newer(format!("file format {version} with `{unknown}`, which this version doesn't know"))),
None => Ok(()),
}
}
/// Bring a `noun` of format `version` up to [`Formats::current`]. `applied` is the file's
/// `format_pending`: the changes of its format it already has, when it was written while they
/// were pending; the others' steps run first, and already-applied changes never run again at
/// their later number. A file past this build's number has no steps to run.
/// were pending; the others' steps run first. A file [`Formats::check`] refuses is refused,
/// whoever calls, before anything is changed; already-applied changes never run again at
/// their later number.
fn upgrade(&self, noun: &str, v: &mut Value, version: u32, applied: Option<&[String]>) -> Result<()> {
if version == 0 {
return Err(DocError::Invalid(format!("not a SolidCraft {noun} (format 0)")));
}
self.check(noun, version, applied)?;
// A list beside a format that isn't made of changes is ignored, like any field an upgrade
// drops.
let applied = applied.filter(|_| self.parts(version).is_some());
@@ -688,53 +677,18 @@ fn v2_to_v3(v: &mut Value) -> Result<()> {
pub fn read(s: &str) -> Result<Document> {
let mut v: Value = serde_json::from_str(s).map_err(|e| DocError::Invalid(format!("not a SolidCraft design: {e}")))?;
let (version, applied) = DESIGN.take_version(&mut v)?;
match DESIGN.newer(version, applied.as_deref()) {
Some(newer) => crate::newer_file::read(v, version, applied, newer),
None => decode(v, version, applied.as_deref()),
}
}
/// Whether a design's text says a newer build saved it, read before anything else in it is.
pub fn says_newer(text: &str) -> bool {
#[derive(serde::Deserialize)]
struct Says {
format: Option<String>,
format_pending: Option<Vec<String>>,
}
serde_json::from_str::<Says>(text)
.ok()
.and_then(|says| Some((DESIGN.version_of(&says.format?)?, says.format_pending)))
.is_some_and(|(version, applied)| DESIGN.newer(version, applied.as_deref()).is_some())
}
/// The checks of a design's JSON that need nothing outside the part of it they are given.
pub(crate) fn check_raw(v: &Value, version: u32) -> Result<Vec<(usize, usize)>> {
check_design_numbers(v)?;
crate::config::validate_rules_json(v)?;
crate::config::validate_styles_json(v)?;
crate::face_anchor::validate_face_anchors_json(v)?;
validate_versioned_geometry(v)?;
validate_emboss_targets(v)?;
validate_freeform_offsets(v)?;
validate_sketch_sizes(v)?;
crate::format_offsets::validate_raw(v)?;
validate_wires(v, version)
}
/// [`check_raw`], the upgrade and the typed record alone: what a piece of a design must pass by
/// itself ([`crate::newer_file`]), with nothing else in the file for it to refer to.
pub(crate) fn check_piece(mut v: Value, version: u32, applied: Option<&[String]>) -> Result<()> {
check_raw(&v, version)?;
DESIGN.upgrade(&mut v, version, applied)?;
let d: Document = serde_json::from_value(v).map_err(|e| DocError::Invalid(format!("design file: {e}")))?;
check_typed_numbers(&d)
}
/// A design's JSON, its format taken out, as the document it holds.
pub(crate) fn decode(mut v: Value, version: u32, applied: Option<&[String]>) -> Result<Document> {
let legacy_wires = check_raw(&v, version)?;
check_design_numbers(&v)?;
crate::config::validate_rules_json(&v)?;
crate::config::validate_styles_json(&v)?;
crate::face_anchor::validate_face_anchors_json(&v)?;
validate_versioned_geometry(&v)?;
validate_emboss_targets(&v)?;
validate_freeform_offsets(&v)?;
validate_sketch_sizes(&v)?;
crate::format_offsets::validate_raw(&v)?;
let legacy_wires = validate_wires(&v, version)?;
validate_body_transfers(&v)?;
DESIGN.upgrade(&mut v, version, applied)?;
DESIGN.upgrade(&mut v, version, applied.as_deref())?;
let mut d: Document = serde_json::from_value(v).map_err(|e| DocError::Invalid(format!("design file: {e}")))?;
check_typed_numbers(&d)?;
let mut legacy_by_feature = std::collections::BTreeMap::<usize, Vec<usize>>::new();
@@ -1373,9 +1327,9 @@ mod tests {
let mut v = serde_json::json!({});
formats.upgrade("design", &mut v, next, Some(&["a-first".into(), "b-second".into()])).unwrap();
assert_eq!(trace_of(&v), Vec::<&str>::new());
// A change this build doesn't know is a newer build's: said, and kept.
let said = formats.newer(next, Some(&["c-later".into()])).unwrap();
assert_eq!(said, Newer { format: next, reads_up_to: next, unknown_changes: vec!["c-later".into()] });
// A change this build doesn't know is a newer build's: refused, not guessed at.
let err = formats.upgrade("design", &mut serde_json::json!({}), next, Some(&["c-later".into()])).unwrap_err();
assert!(err.to_string().contains("newer SolidCraft"), "{err}");
// A list on a format that isn't made of changes means nothing.
let mut v = serde_json::json!({});
formats.upgrade("design", &mut v, HAND_WRITTEN, Some(&["a-first".into()])).unwrap();
@@ -1418,12 +1372,15 @@ mod tests {
}
#[test]
fn personal_card_alias_without_support_is_a_newer_builds() {
fn personal_card_alias_without_support_refuses_before_mutation() {
let hand: [JsonStep; 27] = [|_| Ok(()); 27];
let first = [traced("a-first")];
let formats = Formats { hand: &hand, folded: &[(29, &first)], pending: &[] };
let said = formats.newer(29, Some(&["physical-material-cards".into()])).unwrap();
assert_eq!(said.unknown_changes, ["physical-material-cards"]);
let mut file = serde_json::json!({"trace": [], "sentinel": 7});
let original = file.clone();
let error = formats.upgrade("design", &mut file, 29, Some(&["physical-material-cards".into()])).unwrap_err();
assert!(error.to_string().contains("physical-material-cards"), "{error}");
assert_eq!(file, original);
}
#[test]
@@ -1434,8 +1391,11 @@ mod tests {
let cards = [traced("material-engineering-cards")];
let formats = Formats { hand: &hand, folded: &[(28, &first), (29, &second), (30, &cards)], pending: &[] };
for version in [28, 30] {
let said = formats.newer(version, Some(&["physical-material-cards".into()])).unwrap();
assert_eq!(said.unknown_changes, ["physical-material-cards"]);
let mut file = serde_json::json!({"trace": []});
let original = file.clone();
let error = formats.upgrade("design", &mut file, version, Some(&["physical-material-cards".into()])).unwrap_err();
assert!(error.to_string().contains("physical-material-cards"), "{error}");
assert_eq!(file, original);
}
}
@@ -1448,7 +1408,11 @@ mod tests {
for names in
[["physical-material-cards".into(), "unknown-card-codec".into()], ["unknown-card-codec".into(), "physical-material-cards".into()]]
{
assert_eq!(formats.newer(29, Some(&names)).unwrap().unknown_changes, ["unknown-card-codec"]);
let mut file = serde_json::json!({"trace": ["b"]});
let original = file.clone();
let error = formats.upgrade("design", &mut file, 29, Some(&names)).unwrap_err();
assert!(error.to_string().contains("unknown-card-codec"), "{error}");
assert_eq!(file, original);
}
}
@@ -1482,14 +1446,18 @@ mod tests {
}
/// An experimental producer can use a number that a release later folds without its
/// change. Its unknown concept is still a newer build's.
/// change. Its unknown concept must still be refused before any migration runs.
#[test]
fn unknown_pending_concepts_are_said_after_folding() {
fn unknown_pending_concepts_are_refused_after_folding() {
let parts = [traced("a-first")];
let later = [traced("b-second")];
for pending in [&[][..], &later[..]] {
let formats = Formats { hand: &[], folded: &[(2, &parts)], pending };
assert_eq!(formats.newer(2, Some(&["experimental-card".into()])).unwrap().unknown_changes, ["experimental-card"]);
let mut file = serde_json::json!({"trace": []});
let original = file.clone();
let err = formats.upgrade("design", &mut file, 2, Some(&["experimental-card".into()])).unwrap_err();
assert!(err.to_string().contains("experimental-card") && err.to_string().contains("update SolidCraft"), "{err}");
assert_eq!(file, original, "refuse before applying even a known migration");
}
}
@@ -1511,20 +1479,23 @@ mod tests {
}
}
/// A design naming a pending change this build doesn't have is a newer build's: what it can't
/// read of it doesn't read as damage.
/// A design naming a pending change this build doesn't have is a newer build's, refused as
/// such before anything else in it is checked (as a newer number is), so a newer build's
/// fields never read as damage.
#[test]
fn a_newer_builds_pending_change_opens_and_its_fields_are_not_damage() {
fn a_newer_builds_pending_change_is_refused_before_the_design_is_checked() {
let mut file: Value = serde_json::from_str(&Document::new("A").to_json()).unwrap();
file["format_pending"] = serde_json::json!(["not-a-change-anyone-made"]);
file["features"] = serde_json::json!([{"type": "emboss", "face": {"body": "Body", "at": [0, 0, 0], "a_newer_key": true}}]);
let opened = read(&file.to_string()).unwrap();
let error = read(&file.to_string()).unwrap_err().to_string();
// With changes pending, FORMAT is made of them; once they are folded it is the folded
// format, which is made of changes too. Either way an unknown change is a newer build's.
let said = opened.newer_file.as_ref().unwrap();
assert_eq!(said.unknown_changes, ["not-a-change-anyone-made"]);
assert!(said.notice().contains(&format!("format {FORMAT} with `not-a-change-anyone-made`")), "{}", said.notice());
assert!(matches!(opened.features[0].kind, crate::FeatureKind::NewerFileFeature { .. }));
assert_eq!(
error,
format!(
"this design was saved by a newer SolidCraft (file format {FORMAT} with `not-a-change-anyone-made`, which this version doesn't know): update SolidCraft to open it"
)
);
}
/// Drawings follow the same rules from their own first format: folded formats follow on from 1
@@ -1604,17 +1575,17 @@ mod tests {
fn tagged(family: &Family<'_>, n: u32) -> Value {
serde_json::json!({"format": family.format_string(n)})
}
// A newer number isn't refused: it is said.
let says = |family: &Family<'_>, mut file: Value| {
let (version, applied) = family.take_version(&mut file).unwrap();
family.newer(version, applied.as_deref())
};
assert_eq!(says(&DESIGN, tagged(&DESIGN, FORMAT + 1)), Some(Newer { format: FORMAT + 1, reads_up_to: FORMAT, unknown_changes: vec![] }));
assert_eq!(read(&tagged(&DESIGN, FORMAT + 1).to_string()).unwrap().newer_file.unwrap().format, FORMAT + 1);
fn newer(noun: &str, n: u32, current: u32) -> String {
format!(
"this {noun} was saved by a newer SolidCraft (file format {n}; this version reads formats up to {current}): update SolidCraft to open it"
)
}
assert_eq!(refusal(&DESIGN, tagged(&DESIGN, FORMAT + 1)), newer("design", FORMAT + 1, FORMAT));
assert_eq!(read(&tagged(&DESIGN, FORMAT + 1).to_string()).unwrap_err().to_string(), newer("design", FORMAT + 1, FORMAT));
let current = DRAWING.current();
assert!(matches!(DRAWING.take_version(&mut tagged(&DRAWING, current)), Ok((v, None)) if v == current));
for n in [current + 1, u32::MAX] {
assert_eq!(says(&DRAWING, tagged(&DRAWING, n)), Some(Newer { format: n, reads_up_to: current, unknown_changes: vec![] }));
assert_eq!(refusal(&DRAWING, tagged(&DRAWING, n)), newer("drawing", n, current));
}
for (family, noun) in [(&DESIGN, "design"), (&DRAWING, "drawing")] {
assert_eq!(refusal(family, tagged(family, 0)), format!("not a SolidCraft {noun} (format 0)"));
@@ -1636,7 +1607,12 @@ mod tests {
let design = Family { formats: Formats { hand: &JSON_STEPS, folded: FOLDED, pending: &pending }, ..DESIGN };
let next = design.current();
let file = json!({"format": format_string(next), "format_pending": ["c-later"]});
assert_eq!(says(&design, file), Some(Newer { format: next, reads_up_to: next, unknown_changes: vec!["c-later".into()] }));
assert_eq!(
refusal(&design, file),
format!(
"this design was saved by a newer SolidCraft (file format {next} with `c-later`, which this version doesn't know): update SolidCraft to open it"
)
);
assert!(matches!(DESIGN.take_version(&mut json!({})), Ok((1, None))));
assert_eq!(refusal(&DRAWING, json!({})), "not a SolidCraft drawing (no format)");
}
@@ -1663,13 +1639,19 @@ mod tests {
assert_eq!(open(json!({"format": "solidcraft-drawing/2", "format_pending": ["a-first"]})).unwrap(), Vec::<String>::new());
// A list beside format 1 means nothing: the step still runs.
assert_eq!(open(json!({"format": "solidcraft-drawing/1", "format_pending": ["c-later"]})).unwrap(), ["a"]);
// Of a newer build: said, and what can run runs.
let list = ["a-first".to_string(), "c-later".to_string()];
assert_eq!(drawing.newer(2, Some(&list)).unwrap().unknown_changes, ["c-later"]);
assert_eq!(open(json!({"format": "solidcraft-drawing/2", "format_pending": list})).unwrap(), Vec::<String>::new());
assert_eq!(drawing.newer(3, None).unwrap(), Newer { format: 3, reads_up_to: 2, unknown_changes: vec![] });
assert_eq!(open(json!({"format": "solidcraft-drawing/3"})).unwrap(), Vec::<String>::new());
// An upgrade called without `take_version` still says format 0 isn't one.
let error = open(json!({"format": "solidcraft-drawing/2", "format_pending": ["a-first", "c-later"]})).unwrap_err().to_string();
assert_eq!(
error,
"this drawing was saved by a newer SolidCraft (file format 2 with `c-later`, which this version doesn't know): update SolidCraft to open it"
);
let error = open(json!({"format": "solidcraft-drawing/3"})).unwrap_err().to_string();
assert_eq!(
error,
"this drawing was saved by a newer SolidCraft (file format 3; this version reads formats up to 2): update SolidCraft to open it"
);
// An upgrade called without `take_version` refuses in the drawing's words too, format 0 included.
let error = drawing.upgrade(&mut json!({}), 3, None).unwrap_err().to_string();
assert!(error.starts_with("this drawing was saved by a newer SolidCraft (file format 3;"), "{error}");
assert_eq!(drawing.upgrade(&mut json!({}), 0, None).unwrap_err().to_string(), "not a SolidCraft drawing (format 0)");
}
@@ -1684,8 +1666,7 @@ mod tests {
assert!(read(&file.to_string()).is_err());
}
file["format_pending"] = serde_json::json!(["not-a-change-anyone-made"]);
let newer = read(&file.to_string()).unwrap();
assert_eq!(newer.newer_file.as_ref().unwrap().unknown_changes, ["not-a-change-anyone-made"], "even after folding");
assert!(read(&file.to_string()).is_err(), "unknown pending concepts are refused even after folding");
file["format_pending"] = Value::from(pending_names());
let opened = read(&file.to_string()).unwrap();
assert_eq!(opened.format_pending, pending_names());
@@ -2157,9 +2138,8 @@ mod tests {
value["features"] = serde_json::json!([{"type":"sketch", "sketch":{"freeform_offsets":records}}]);
let error = read(&value.to_string()).unwrap_err().to_string();
assert!(error.contains("free-form offset metadata"), "{error}");
// A newer build's file isn't held to this: the feature waits for that build.
value["format"] = Value::from(format_string(FORMAT + 1));
assert!(matches!(read(&value.to_string()).unwrap().features[0].kind, crate::FeatureKind::NewerFileFeature { .. }));
assert!(read(&value.to_string()).unwrap_err().to_string().contains("newer SolidCraft"));
}
}
@@ -2183,7 +2163,8 @@ mod tests {
let error = read(&value.to_string()).unwrap_err().to_string();
assert!(error.contains("emboss face"), "{error}");
value["format"] = Value::from(format_string(FORMAT + 1));
assert!(matches!(read(&value.to_string()).unwrap().features[0].kind, crate::FeatureKind::NewerFileFeature { .. }));
let error = read(&value.to_string()).unwrap_err().to_string();
assert!(error.contains("newer SolidCraft"), "{error}");
}
}
@@ -2226,7 +2207,7 @@ mod tests {
}
#[test]
fn version_seven_styles_upgrade_and_a_newer_versions_columns_are_kept() {
fn version_seven_styles_upgrade_and_newer_versions_are_refused_before_columns() {
use crate::config::{Column, ConfigRow};
let mut d = Document::new("configured styles");
d.configs.columns = vec![Column::Material("Body".into()), Column::Appearance("Body".into())];
@@ -2246,11 +2227,9 @@ mod tests {
value["format"] = Value::from(format_string(FORMAT + 1));
value["configs"]["columns"] = serde_json::json!([{"unknown_future_column":"Body"}]);
value["configs"]["rules"] = serde_json::json!({"unknown_future_rule": true});
let opened = read(&value.to_string()).unwrap();
let said = opened.newer_file.as_ref().unwrap();
assert!(said.not_understood.iter().any(|what| what.contains("configs")), "{:?}", said.not_understood);
let saved: Value = serde_json::from_str(&opened.to_json()).unwrap();
assert_eq!(saved["configs"], value["configs"], "kept for the build that reads them");
let error = read(&value.to_string()).unwrap_err().to_string();
assert!(error.contains("newer SolidCraft") && error.contains("update SolidCraft"), "{error}");
assert!(!error.contains("unknown variant"), "format refusal precedes decoding new variants");
}
#[test]
@@ -2305,10 +2284,8 @@ mod tests {
assert_eq!(read(&saved.to_string()).unwrap(), d);
saved["format"] = Value::from(format_string(FORMAT + 1));
saved["features"][0]["sketch"]["constraints"][0]["type"] = Value::from("future_dimension");
let opened = read(&saved.to_string()).unwrap();
assert!(matches!(opened.features[0].kind, crate::FeatureKind::NewerFileFeature { .. }));
let again: Value = serde_json::from_str(&opened.to_json()).unwrap();
assert_eq!(again["features"][0], saved["features"][0]);
let error = read(&saved.to_string()).unwrap_err().to_string();
assert!(error.contains("newer SolidCraft") && !error.contains("unknown variant"), "{error}");
}
#[test]
@@ -2415,14 +2392,15 @@ mod tests {
}
#[test]
fn older_upgraded_newer_opened_others_rejected() {
fn older_upgraded_newer_refused_others_rejected() {
let mut v: Value = serde_json::from_str(&Document::new("A").to_json()).unwrap();
v["format"] = Value::from("solidcraft/1");
assert_eq!(read(&v.to_string()).unwrap().format, format_string(FORMAT));
v.as_object_mut().unwrap().remove("format");
assert!(read(&v.to_string()).is_ok(), "a file without a format is format 1");
v["format"] = Value::from(format_string(FORMAT + 1));
assert_eq!(read(&v.to_string()).unwrap().newer_file.unwrap().format, FORMAT + 1);
let e = read(&v.to_string()).unwrap_err().to_string();
assert!(e.contains("newer SolidCraft"), "{e}");
for bad in ["solidcraft/0", "acad/3", "solidcraft/-1", "solidcraft/99999999999999999999"] {
v["format"] = Value::from(bad);
assert!(read(&v.to_string()).is_err(), "{bad}");
+1 -1
View File
@@ -5,7 +5,7 @@ here is the whole format bump: never edit `FORMAT`, `JSON_STEPS` or another chan
- Name it after the change, lowercase with dashes: `placed-plane-origins.rs`.
- Start it with a `//!` note: what is saved, what an older design gets without it, and what
the last release does with a design that has it (it warns about the newer format number, opens what it can read and keeps the rest).
the last release does with a design that has it (it refuses the newer format number).
- If older designs must be rewritten as they open, add the step to the same file:
```rust
@@ -0,0 +1,12 @@
//! Nothing new is saved for holes, but a hole placed on a face that names the bodies it drills
//! (`participants`: its `targets`, or the body of another component it was picked on) now finds
//! the face it starts on among those bodies' faces, where one of them passes through its position
//! (#12066). Where another body touched that face at the hole, older designs could save a
//! `direction` taken from the other body's face, pointing out of the drilled body; such a hole cut
//! only a shallow pocket. It is now mirrored into the drilled body's face when the design is
//! evaluated, as format 18 does for a direction taken from the far side of a thin plate, and
//! drills through. Holes whose direction enters one of their own bodies' faces there, holes whose
//! own bodies have no face at their position (placed on another body's face to drill through it),
//! holes that name no bodies, and holes at several sketch points (already limited to their bodies,
//! #2882) build as before. No rewrite is needed. The last release refuses a design with this
//! change (the newer format number) instead of building the pocket again.
@@ -0,0 +1,5 @@
//! A revolve's explicit axis `{origin, dir}` can save its origin's coordinates as length
//! expressions (`["W / 2", 0, "groove_floor"]`) so the axis follows the parameters; a plain number
//! stays a number. An older design has numbers only and reads as before. The last release refuses
//! a design with this change and asks for an update, instead of opening it and reading an
//! expression as the origin.
@@ -0,0 +1,6 @@
//! A new-body extrusion or revolve whose outputs transfer into its source sketch's component
//! reads the profile through the recorded transfer frame. Capturing that component's position
//! therefore moves the body once, as the pending preview does. Existing designs use their
//! saved transfer transforms without rewriting fields; independent cross-component references
//! keep their live placement. The last release refuses the newer design-format number rather
//! than reopening the design with the former double-placement behavior.
+1 -1
View File
@@ -399,7 +399,7 @@ mod tests {
damaged["features"][1]["profiles"]["associations"] = associations;
assert!(validate_raw(&damaged).is_err());
damaged["format"] = json!(crate::format::format_string(crate::format::FORMAT + 1));
assert!(crate::format::read(&damaged.to_string()).unwrap().newer_file.is_some());
assert!(crate::format::read(&damaged.to_string()).unwrap_err().to_string().contains("newer SolidCraft"));
}
// Absent metadata preserves the historical one-to-one chain representation.
let mut legacy = fixture();
+2 -3
View File
@@ -11,7 +11,6 @@ mod assembly;
mod body_transfer;
mod body_transfer_sheet;
mod browser_tree;
pub mod newer_file;
pub mod pbr_appearance;
pub use body_transfer::{valid_transfer_matrix, validate_body_transfer};
pub mod canvas;
@@ -93,8 +92,8 @@ pub use eval::{
blend_target, curve_pick_error, draft_corner_options, edge_names_for, edge_refs, face_point_names, face_point_state, hole_direction_at,
hole_on_surface, import_body_names, join_pieces_added, map_wire, metric_thread_sizes, object_height, parse_metric_thread, pattern_matrices,
revolve_extent, revolve_extent_for_handle, revolve_extent_with_convention, select_profiles, sketch_hole_direction, sketch_line_plane,
state_in_frame, state_in_frame_at, suggested_thread, suggested_thread_in, tangent_face_chain, through_all_distance, world_state,
world_state_reusing,
state_in_frame, state_in_frame_at, suggested_thread, suggested_thread_in, tangent_face_chain, through_all_distance, with_placed_copies,
world_state, world_state_reusing,
};
pub use eval::{EvalCache, KeptResults};
pub use expr::Expr;
+4 -4
View File
@@ -760,7 +760,7 @@ impl Model {
return Err(bad("saved model: features"));
}
let (vals, param_errors) = evaluation_values(doc);
let step_digests = StepDigests::of(doc, &StepDigests::default());
let import_digests = ImportDigests::of(doc, &ImportDigests::default());
let marker = doc.marker.unwrap_or(usize::MAX);
// As `evaluate` digests the context under the mode, so the next evaluation reuses these.
let ctx = context_digest(doc, &vals) ^ validation_digest(validation);
@@ -776,7 +776,7 @@ impl Model {
// A fingerprint reads the state before its feature (where the bodies it takes lie).
// After a state the file left out, the saved one is taken: `evaluate` works that state
// out, checks the fingerprints after it, and evaluates again from one that differs.
prev = if exact { chain_fingerprint(doc, &vals, f, prev, i >= marker, &step_digests, Some(&before)) } else { saved };
prev = if exact { chain_fingerprint(doc, &vals, f, prev, i >= marker, &import_digests, Some(&before)) } else { saved };
if r.get("id").and_then(Json::as_u64) != Some(f.id) || saved != prev {
return Err(bad("saved model: the document changed"));
}
@@ -826,7 +826,7 @@ impl Model {
}
results.push(result);
}
let mut model = Model { results, param_errors, step_digests, context: ctx, frame_bodies, validation, ..Model::default() };
let mut model = Model { results, param_errors, import_digests, context: ctx, frame_bodies, validation, ..Model::default() };
let unkept = model.results.iter().filter(|r| r.unkept).count();
if unkept > 0 {
// The features whose state the file did not keep, each evaluated again from the state
@@ -844,7 +844,7 @@ impl Model {
let mut before = Arc::new(ModelState::default());
if let Ok(mut cache) = model.cache.lock() {
for (f, r) in doc.features.iter().zip(&model.results).filter(|(_, r)| !r.skipped && r.error.is_none()) {
let local = fingerprint(doc, 0, f, &vals, false, &model.step_digests) ^ placement_digest(doc, f, Some(&before));
let local = fingerprint(doc, 0, f, &vals, false, &model.import_digests) ^ placement_digest(doc, f, Some(&before));
cache.put(
cache_key(f, r.fingerprint, local, ctx, &before),
CachedEval {
+5 -2
View File
@@ -835,7 +835,7 @@ pub(crate) fn face_centres(b: &ModelBody) -> Vec<Vec3> {
samples(&b.mesh(), face_count(b)).into_iter().map(|ps| ps.iter().fold(Vec3::ZERO, |a, p| a + *p) * (1.0 / ps.len().max(1) as f64)).collect()
}
/// The name of a face new to `f`.
/// The name of a face new to `f` (a feature that went through `eval::naming_feature`).
fn role_name(f: &Feature, before: &ModelState, ps: &[Vec3], ns: &[Vec3], fi: usize, fid: u64, curves: &std::cell::OnceCell<Curves>) -> String {
let c = ps.iter().fold(Vec3::ZERO, |a, p| a + *p) * (1.0 / ps.len().max(1) as f64);
match &f.kind {
@@ -858,7 +858,10 @@ fn role_name(f: &Feature, before: &ModelState, ps: &[Vec3], ns: &[Vec3], fi: usi
}
FeatureKind::Revolve { sketch, axis, .. } => {
let Some(ss) = before.sketch(sketch.0) else { return format!("F{fid}:face:{fi}") };
let Ok((o2, d2)) = crate::eval::revolve_axis(ss, axis, before, &|name| crate::eval::construction_axis_in(None, before, name, 0)) else {
// The origin was written as its values when the feature was evaluated (`naming_feature`).
let Ok((o2, d2)) =
crate::eval::revolve_axis(&Default::default(), ss, axis, before, &|name| crate::eval::construction_axis_in(None, before, name, 0))
else {
return format!("F{fid}:face:{fi}");
};
let (o, d) = (ss.plane.to_world(o2), (ss.plane.to_world(o2 + d2) - ss.plane.to_world(o2)).normalized().unwrap_or(Vec3::Z));
-530
View File
@@ -1,530 +0,0 @@
//! A design a newer SolidCraft saved: it opens, with a warning, and saving it loses nothing.
//!
//! A newer format number, or a change the file names that this build doesn't have, says a newer
//! build saved the file. It may use nothing this build lacks, so it is read as usual. When part of
//! it can't be read, that part stays out of the way and the rest opens:
//! - a feature this build can't read keeps its place on the timeline as
//! [`FeatureKind::NewerFileFeature`](crate::FeatureKind::NewerFileFeature), which reports an error when evaluated;
//! - a parameter it can't read is left out;
//! - any other top-level section it can't read is left out whole;
//! - a field it doesn't know, which typed reading would drop, is noticed by comparing the file
//! with what was read.
//!
//! All of these are named in [`NewerFile::not_understood`] and go back into the file when it is
//! saved ([`restore`]), as they came. Saving keeps the file's own format number and change list:
//! the file still holds what the newer build wrote, and a lower number would make that build
//! upgrade its own data as if it were old. What can't be put back because the design changed around
//! it ([`not_kept`]) is said in the reply to the save.
use std::collections::BTreeMap;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use crate::format::{self, DESIGN, Newer};
use crate::{DocError, Document, Result};
/// How a [`RawFeature`] is written while it is inside this build; [`restore`] unwraps it.
const FOREIGN_TAG: &str = "newer_file_feature";
/// Sections a design can't open without: filled in when the file's can't be read.
const CORE: [&str; 3] = ["name", "params", "features"];
/// The most sections left out one at a time to find the one a design can't be read with.
const MAX_SECTION_TRIES: usize = 64;
/// Fields a design container adds beside what a design holds; not unknown to this build.
const CONTAINER_ONLY: [&str; 5] = ["step_blob", "mesh_blob", "image_blob", "points_blob", "hdr_blob"];
/// A feature as a newer build wrote it.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct RawFeature(pub Box<Value>);
impl RawFeature {
/// The kind of feature it says it is.
pub fn kind(&self) -> &str {
self.0.get("type").and_then(Value::as_str).unwrap_or("unnamed")
}
/// Why it doesn't build here.
pub fn why(&self) -> String {
format!("saved by a newer SolidCraft: this version can't read a `{}` feature. Update SolidCraft to build it; saving keeps it", self.kind())
}
}
/// What the file says and holds that this build doesn't read.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct NewerFile {
/// The number the file says.
pub format: u32,
/// The number this build reads up to.
pub reads_up_to: u32,
/// The changes the file names that this build doesn't have.
pub unknown_changes: Vec<String>,
/// What this build couldn't read, by name.
pub not_understood: Vec<String>,
/// Fields known records lost in typed reading.
kept: Option<Kept>,
/// Top-level sections left out whole.
sections: Vec<Section>,
/// Elements left out of a top-level list: its key and the element.
elements: Vec<(String, Value)>,
/// The parts of a design container this build has no use for: name and bytes. A blob only one
/// of the kept pieces refers to is one of them.
pub parts: Vec<(String, Arc<[u8]>)>,
/// The file's `container.json`, when this build doesn't know what it says.
pub container: Option<Value>,
}
#[derive(Clone, Debug, PartialEq)]
struct Section {
key: String,
value: Value,
/// This build's record of the section, without the file's: while it is unchanged, the file's
/// goes back.
empty: Option<Value>,
}
/// The fields of a record that typed reading dropped.
#[derive(Clone, Debug, PartialEq)]
enum Kept {
Field(Value),
Fields(BTreeMap<String, Kept>),
Items(Vec<(Anchor, Kept)>),
}
/// Which element of a list some kept fields belong to.
#[derive(Clone, Debug, PartialEq)]
enum Anchor {
Id(u64),
Name(String),
/// Its place, good only while the list keeps its length.
Index {
at: usize,
of: usize,
},
}
impl NewerFile {
/// What to tell the user, whatever else is said.
pub fn notice(&self) -> String {
let reads = self.reads_up_to;
let format = self.format;
match self.unknown_changes.first() {
Some(change) if format <= reads => {
format!(
"Saved by a newer SolidCraft (format {format} with `{change}`, which this version doesn't know). If something's missing, update."
)
}
_ => format!("Saved by a newer SolidCraft (format {format}, this version reads up to {reads}). If something's missing, update."),
}
}
/// Every line to report: the notice, then what couldn't be read, by name.
pub fn warnings(&self) -> Vec<String> {
let unread = self.not_understood.iter().map(|what| format!("Not read: {what}. Saving keeps it."));
std::iter::once(self.notice()).chain(unread).collect()
}
/// What a reply says about it.
pub fn report(&self) -> Value {
json!({
"format": self.format,
"reads_up_to": self.reads_up_to,
"unknown_changes": self.unknown_changes,
"not_understood": self.not_understood,
"notice": self.notice(),
})
}
/// Whether the next save needs a container to keep what the file holds: plain JSON has no room
/// for parts beside the design.
pub fn needs_container(&self) -> bool {
!self.parts.is_empty()
}
/// Whether anything kept back names this text (a blob's name in a container).
pub fn mentions(&self, text: &str) -> bool {
fn value(v: &Value, text: &str) -> bool {
match v {
Value::String(s) => s == text,
Value::Array(a) => a.iter().any(|x| value(x, text)),
Value::Object(o) => o.values().any(|x| value(x, text)),
_ => false,
}
}
fn kept(k: &Kept, text: &str) -> bool {
match k {
Kept::Field(v) => value(v, text),
Kept::Fields(m) => m.values().any(|x| kept(x, text)),
Kept::Items(l) => l.iter().any(|(_, x)| kept(x, text)),
}
}
self.kept.as_ref().is_some_and(|k| kept(k, text))
|| self.sections.iter().any(|s| value(&s.value, text))
|| self.elements.iter().any(|(_, v)| value(v, text))
}
/// Say that a section the container reader couldn't finish reading is left out.
/// `empty` is what the design writes for the section while it holds nothing, if anything.
pub fn keep_section(&mut self, key: &str, value: Value, empty: Option<Value>, why: &str) {
self.not_understood.push(format!("section `{key}` ({why})"));
self.sections.push(Section { key: key.into(), value, empty });
}
}
/// Read a design a newer build saved, or say why it truly can't be read.
pub(crate) fn read(mut raw: Value, version: u32, applied: Option<Vec<String>>, newer: Newer) -> Result<Document> {
let applied_names = applied.as_deref();
let mut file =
NewerFile { format: newer.format, reads_up_to: newer.reads_up_to, unknown_changes: newer.unknown_changes.clone(), ..NewerFile::default() };
let (mut doc, read_as) = match format::decode(raw.clone(), version, applied_names) {
Ok(doc) => (doc, raw),
Err(_) => {
let mut foreign = BTreeMap::new();
let mut doc = salvage(&mut raw, version, applied_names, &mut file, &mut foreign).map_err(|why| {
DocError::Invalid(format!(
"this design was saved by a newer SolidCraft (format {}, this version reads up to {}) and this version can't read it: {why}. Update SolidCraft to open it",
newer.format, newer.reads_up_to
))
})?;
fill_foreign(&mut doc, foreign);
(doc, raw)
}
};
let mut upgraded = read_as;
// Compared with what was read, the file's own shape after the same upgrade.
DESIGN.upgrade(&mut upgraded, version, applied_names)?;
let known = serde_json::to_value(&doc).map_err(|e| DocError::Invalid(format!("design file: {e}")))?;
let mut paths = Vec::new();
file.kept = vanished(&upgraded, &known, "", &mut paths);
file.not_understood.extend(paths);
for section in &mut file.sections {
section.empty = known.get(&section.key).cloned();
}
if newer.format > newer.reads_up_to {
// The file's own number and list: it still holds what that build wrote.
doc.format = DESIGN.format_string(newer.format);
doc.format_pending = applied.unwrap_or_default();
} else {
// Upgraded to this build's format; the changes it doesn't know are still in it.
doc.format_pending.extend(newer.unknown_changes.iter().cloned());
}
doc.newer_file = Some(Arc::new(file));
Ok(doc)
}
/// Read the design around what can't be read, which moves to `file`. `raw` ends as what was read.
fn salvage(
raw: &mut Value,
version: u32,
applied: Option<&[String]>,
file: &mut NewerFile,
foreign: &mut BTreeMap<u64, Value>,
) -> std::result::Result<Document, String> {
let piece = |key: &str, value: &Value| {
let mut stub = json!({"name": "", "params": [], "features": []});
stub[key] = value.clone();
format::check_piece(stub, version, applied)
};
let Some(top) = raw.as_object_mut() else { return Err("expected a JSON object".into()) };
// A feature it can't read keeps its place; a parameter it can't read stays out.
if let Some(Value::Array(features)) = top.get_mut("features") {
let mut free = features.iter().filter_map(|f| f.get("id").and_then(Value::as_u64)).max().unwrap_or(0).saturating_add(1);
for feature in features.iter_mut() {
if let Err(why) = piece("features", &json!([feature])) {
file.not_understood.push(feature_note(feature, &why.to_string()));
*feature = wrapped(std::mem::take(feature), &mut free, foreign);
}
}
}
if let Some(Value::Array(params)) = top.get_mut("params") {
params.retain(|param| match piece("params", &json!([param])) {
Ok(()) => true,
Err(why) => {
let name = param.get("name").and_then(Value::as_str).unwrap_or("unnamed");
file.not_understood.push(format!("parameter `{name}` ({})", short(&why.to_string())));
file.elements.push(("params".into(), param.clone()));
false
}
});
}
// Any other section, by itself.
let keys: Vec<String> = top.keys().cloned().collect();
for key in &keys {
let value = top.get(key).cloned().unwrap_or_default();
if let Err(why) = piece(key, &value) {
move_out(top, key, &why.to_string(), file);
}
}
if !top.get("name").is_some_and(Value::is_string) {
top.insert("name".into(), json!("Untitled"));
}
for key in ["params", "features"] {
top.entry(key).or_insert_with(|| json!([]));
}
let attempt = |top: &Map<String, Value>| format::decode(Value::Object(top.clone()), version, applied);
let first = match attempt(top) {
Ok(doc) => return Ok(doc),
Err(why) => why,
};
// Sections that are fine by themselves but not together with the rest.
let keys: Vec<String> = top.keys().filter(|k| !CORE.contains(&k.as_str())).cloned().collect();
// Each try reads the whole design: a file of many sections goes straight to the last resort.
for key in keys.iter().filter(|_| keys.len() <= MAX_SECTION_TRIES) {
let mut without = top.clone();
without.remove(key);
if let Ok(doc) = attempt(&without) {
move_out(top, key, &first.to_string(), file);
return Ok(doc);
}
}
let mut bare = top.clone();
bare.retain(|key, _| CORE.contains(&key.as_str()));
match attempt(&bare) {
Ok(doc) => {
for key in &keys {
move_out(top, key, &first.to_string(), file);
}
Ok(doc)
}
Err(_) => Err(first.to_string()),
}
}
/// Leave a section out and keep it. A section the design needs is left empty.
fn move_out(top: &mut Map<String, Value>, key: &str, why: &str, file: &mut NewerFile) {
let Some(value) = top.remove(key) else { return };
file.not_understood.push(format!("section `{key}` ({})", short(why)));
file.sections.push(Section { key: key.into(), value, empty: None });
}
/// The first line of a reader's error, without the list of every kind it knows.
fn short(why: &str) -> &str {
let why = why.split(", expected").next().unwrap_or(why);
why.strip_prefix("design file: ").unwrap_or(why)
}
fn feature_note(feature: &Value, why: &str) -> String {
let name = feature.get("name").and_then(Value::as_str).unwrap_or("unnamed");
let kind = feature.get("type").and_then(Value::as_str).unwrap_or("unnamed");
let why = short(why);
if why.starts_with("unknown variant") {
format!("feature `{name}`, a `{kind}` feature this version doesn't know")
} else {
format!("feature `{name}`, a `{kind}` feature ({why})")
}
}
/// A feature this build can't read as this build reads it: the fields every feature has, which stay
/// editable. Its `raw` is filled in after reading ([`fill_foreign`]), so nothing in a feature this
/// build can't read is judged by this build's rules. `free` gives an id to one the file left
/// without.
fn wrapped(raw: Value, free: &mut u64, foreign: &mut BTreeMap<u64, Value>) -> Value {
let id = raw.get("id").and_then(Value::as_u64).unwrap_or_else(|| {
let id = *free;
*free = free.saturating_add(1);
id
});
let name = raw.get("name").and_then(Value::as_str).unwrap_or("Unknown feature").to_string();
let suppressed = raw.get("suppressed").and_then(Value::as_bool).unwrap_or(false);
let component = raw.get("component").and_then(Value::as_u64).unwrap_or(0);
foreign.insert(id, raw);
json!({"id": id, "name": name, "suppressed": suppressed, "component": component, "type": FOREIGN_TAG, "raw": null})
}
/// Give the features [`wrapped`] left without one what the file said.
fn fill_foreign(doc: &mut Document, mut foreign: BTreeMap<u64, Value>) {
for feature in &mut doc.features {
if let crate::FeatureKind::NewerFileFeature { raw } = &mut feature.kind
&& let Some(value) = foreign.remove(&feature.id)
{
raw.0 = Box::new(value);
}
}
}
/// A feature back as the file had it, with the changes this build can make to any feature.
fn unwrapped(feature: &mut Value) {
let Some(wrapper) = feature.as_object_mut() else { return };
let Some(Value::Object(mut raw)) = wrapper.remove("raw") else { return };
for key in ["id", "name", "suppressed", "component"] {
match wrapper.get(key) {
// Left out as a writer leaves it out, unless the file said it.
Some(v) if key != "id" && !raw.contains_key(key) && looks_default(v) => {}
Some(v) => {
raw.insert(key.into(), v.clone());
}
None => {
raw.remove(key);
}
}
}
*feature = Value::Object(raw);
}
/// A value a writer leaves out without losing anything: not worth telling anyone about.
fn looks_default(v: &Value) -> bool {
match v {
Value::Null => true,
Value::Bool(b) => !b,
Value::Number(n) => n.as_f64() == Some(0.0),
Value::String(s) => s.is_empty(),
Value::Array(a) => a.is_empty(),
Value::Object(o) => o.is_empty(),
}
}
/// What `raw` has that `known`, what was read of it, doesn't. `paths` gets those fields' names.
fn vanished(raw: &Value, known: &Value, path: &str, paths: &mut Vec<String>) -> Option<Kept> {
match (raw, known) {
(Value::Object(raw), Value::Object(known)) => {
if known.get("type").and_then(Value::as_str) == Some(FOREIGN_TAG) {
return None;
}
let mut kept = BTreeMap::new();
for (key, value) in raw {
if CONTAINER_ONLY.contains(&key.as_str()) || (path.is_empty() && matches!(key.as_str(), "format" | "format_pending")) {
continue;
}
let inner = if path.is_empty() { key.clone() } else { format!("{path}.{key}") };
match known.get(key) {
None => {
if !looks_default(value) {
paths.push(format!("field `{inner}`"));
}
kept.insert(key.clone(), Kept::Field(value.clone()));
}
Some(known) => {
if let Some(k) = vanished(value, known, &inner, paths) {
kept.insert(key.clone(), k);
}
}
}
}
(!kept.is_empty()).then_some(Kept::Fields(kept))
}
(Value::Array(raw), Value::Array(known)) => {
let mut items = Vec::new();
for (at, element) in raw.iter().enumerate() {
let anchor = anchor_of(element).unwrap_or(Anchor::Index { at, of: raw.len() });
let Some(partner) = find(known, &anchor) else { continue };
let label = match (element.get("name").and_then(Value::as_str), &anchor) {
(Some(name), _) => format!("{path}[{name}]"),
(None, Anchor::Id(id)) => format!("{path}[{id}]"),
_ => format!("{path}[{at}]"),
};
if let Some(k) = vanished(element, partner, &label, paths) {
items.push((anchor, k));
}
}
(!items.is_empty()).then_some(Kept::Items(items))
}
_ => None,
}
}
fn anchor_of(element: &Value) -> Option<Anchor> {
if let Some(id) = element.get("id").and_then(Value::as_u64) {
return Some(Anchor::Id(id));
}
element.get("name").and_then(Value::as_str).map(|name| Anchor::Name(name.to_string()))
}
fn find<'a>(list: &'a [Value], anchor: &Anchor) -> Option<&'a Value> {
match anchor {
Anchor::Id(id) => list.iter().find(|x| x.get("id").and_then(Value::as_u64) == Some(*id)),
Anchor::Name(name) => list.iter().find(|x| x.get("name").and_then(Value::as_str) == Some(name)),
Anchor::Index { at, of } => (list.len() == *of).then(|| list.get(*at)).flatten(),
}
}
fn find_mut<'a>(list: &'a mut [Value], anchor: &Anchor) -> Option<&'a mut Value> {
match anchor {
Anchor::Id(id) => list.iter_mut().find(|x| x.get("id").and_then(Value::as_u64) == Some(*id)),
Anchor::Name(name) => list.iter_mut().find(|x| x.get("name").and_then(Value::as_str) == Some(name)),
Anchor::Index { at, of } => (list.len() == *of).then(|| list.get_mut(*at)).flatten(),
}
}
/// Put the kept fields back. An element that is gone was deleted here, its fields with it; one
/// whose place can't be told any more, and a field whose record is gone, go into `lost`.
fn apply(kept: &Kept, value: &mut Value, path: &str, lost: &mut Vec<String>) {
match kept {
Kept::Field(_) => {}
Kept::Fields(fields) => {
let Some(object) = value.as_object_mut() else {
lost.push(format!("field `{path}`"));
return;
};
for (key, sub) in fields {
let inner = if path.is_empty() { key.clone() } else { format!("{path}.{key}") };
match (sub, object.get_mut(key)) {
(Kept::Field(v), None) => {
object.insert(key.clone(), v.clone());
}
(Kept::Field(_), Some(_)) => {}
(sub, Some(inner_value)) => apply(sub, inner_value, &inner, lost),
// The list it was in is empty and not written: its elements were deleted.
(Kept::Items(_), None) => {}
(_, None) => lost.push(format!("field `{inner}`")),
}
}
}
Kept::Items(items) => {
let Some(list) = value.as_array_mut() else {
lost.push(format!("field `{path}`"));
return;
};
for (anchor, sub) in items {
match (find_mut(list, anchor), anchor) {
(Some(element), _) => apply(sub, element, path, lost),
(None, Anchor::Index { at, .. }) => lost.push(format!("element {at} of `{path}`")),
(None, _) => {}
}
}
}
}
}
/// Write back what the design holds of the file a newer build saved, into `value`, the design's
/// JSON. Returns what couldn't go back ([`not_kept`]).
pub fn restore(doc: &Document, value: &mut Value) -> Vec<String> {
let mut lost = Vec::new();
let Some(file) = &doc.newer_file else { return lost };
if let Some(Value::Array(features)) = value.get_mut("features") {
for feature in features.iter_mut().filter(|f| f.get("type").and_then(Value::as_str) == Some(FOREIGN_TAG)) {
unwrapped(feature);
}
}
if let Some(kept) = &file.kept {
apply(kept, value, "", &mut lost);
}
if let Some(top) = value.as_object_mut() {
for section in &file.sections {
if top.get(&section.key) == section.empty.as_ref() {
top.insert(section.key.clone(), section.value.clone());
} else {
lost.push(format!("section `{}`, replaced by what was edited here", section.key));
}
}
for (key, element) in &file.elements {
let name = element.get("name");
match top.entry(key.clone()).or_insert_with(|| json!([])) {
Value::Array(list) if name.is_none() || !list.iter().any(|x| x.get("name") == name) => list.push(element.clone()),
_ => lost.push(format!("`{key}` element {}, replaced by what was made here", name.and_then(Value::as_str).unwrap_or("unnamed"))),
}
}
}
lost
}
/// What of the file a newer build saved the next save can't keep, because the design was changed
/// around it. Empty almost always.
pub fn not_kept(doc: &Document) -> Vec<String> {
let Ok(mut value) = serde_json::to_value(doc) else { return Vec::new() };
restore(doc, &mut value)
}
#[cfg(test)]
#[path = "newer_file_tests.rs"]
mod tests;
-236
View File
@@ -1,236 +0,0 @@
//! Designs a newer SolidCraft saved: they open, say so, and saving loses nothing.
use serde_json::{Value, json};
use crate::format::{DESIGN, FORMAT, format_string};
use crate::{Document, FeatureKind, Model, PlaneRef};
/// A real design: a sketched disc and its extrusion, and a parameter.
fn design() -> Document {
use solidcraft_sketch::Sketch;
let mut d = Document::new("Plate");
d.set_param("depth", "5 mm", Some("mm"), None).unwrap();
let mut sketch = Sketch::new();
sketch.add_circle(solidcore_record::Vec2::new(0.0, 0.0), 10.0, None, None).unwrap();
let base = d.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch }, Some("Base")).unwrap();
d.add_feature(extrude(base), Some("Pad")).unwrap();
d
}
fn extrude(sketch: u64) -> FeatureKind {
FeatureKind::Extrude {
thin: None,
sketch: crate::SketchId(sketch),
profiles: crate::ProfileSel::All,
extent: serde_json::from_value(json!({"distance": "depth"})).unwrap(),
operation: crate::Operation::NewBody,
targets: vec![],
}
}
fn saved(d: &Document) -> Value {
serde_json::from_str(&d.to_json()).unwrap()
}
/// `(body name, volume)` of everything the design builds, and the error of each feature.
fn built(d: &Document) -> (Vec<(String, f64)>, Vec<Option<String>>) {
let mut model = Model::default();
model.evaluate(d);
let bodies = model.state().bodies.iter().map(|b| (b.name.clone(), b.body.volume_at(0.01).unwrap())).collect();
(bodies, model.results.iter().map(|r| r.error.clone()).collect())
}
fn newer() -> String {
format_string(FORMAT + 1)
}
#[test]
fn one_format_newer_with_nothing_unknown_opens_with_the_same_bodies() {
let original = design();
let mut file = saved(&original);
file["format"] = json!(newer());
let opened = Document::from_json(&file.to_string()).unwrap();
let (bodies, errors) = built(&opened);
assert_eq!((bodies.clone(), errors), built(&original));
assert_eq!(bodies.len(), 1);
assert_eq!(opened.features, original.features);
let notice = opened.newer_file.as_ref().unwrap().notice();
assert_eq!(
notice,
format!("Saved by a newer SolidCraft (format {}, this version reads up to {FORMAT}). If something's missing, update.", FORMAT + 1)
);
assert!(opened.newer_file.as_ref().unwrap().not_understood.is_empty());
// Saved, it still says what it is; read again, the same.
let again = saved(&opened);
assert_eq!(again["format"], json!(newer()));
assert_eq!(Document::from_json(&opened.to_json()).unwrap(), opened);
}
#[test]
fn an_unknown_change_opens_with_a_warning_and_stays_on_the_file() {
let original = design();
let mut file = saved(&original);
file["format_pending"] = json!(["a-later-change"]);
let opened = Document::from_json(&file.to_string()).unwrap();
let report = opened.newer_file.as_ref().unwrap();
assert_eq!(report.unknown_changes, ["a-later-change"]);
assert!(report.notice().contains("a-later-change") && report.notice().starts_with("Saved by a newer SolidCraft"), "{}", report.notice());
assert_eq!(built(&opened), built(&original));
let again = saved(&opened);
assert_eq!(again["format"], json!(format_string(FORMAT)));
assert!(again["format_pending"].as_array().unwrap().contains(&json!("a-later-change")));
assert_eq!(Document::from_json(&opened.to_json()).unwrap(), opened);
}
/// The file, with a feature of a kind this build doesn't have between the sketch and the pad.
fn with_unknown_feature() -> Value {
let mut file = saved(&design());
file["format"] = json!(newer());
file["format_pending"] = json!(["a-later-change"]);
let future = json!({"id": 900, "name": "Lattice1", "type": "gyroid_lattice", "cell": 2.5, "nested": {"big": 1e300}, "suppressed": false});
file["features"].as_array_mut().unwrap().insert(1, future);
file
}
#[test]
fn an_unknown_feature_kind_opens_the_rest_and_reports_that_feature() {
let file = with_unknown_feature();
let opened = Document::from_json(&file.to_string()).unwrap();
let report = opened.newer_file.as_ref().unwrap();
assert!(report.not_understood.iter().any(|what| what.contains("Lattice1") && what.contains("gyroid_lattice")), "{:?}", report.not_understood);
assert_eq!(opened.features.len(), 3);
assert!(matches!(&opened.features[1].kind, FeatureKind::NewerFileFeature { raw } if raw.kind() == "gyroid_lattice"));
let (bodies, errors) = built(&opened);
assert_eq!(bodies.len(), 1, "the pad after it still builds");
assert!(errors[0].is_none() && errors[2].is_none(), "{errors:?}");
let why = errors[1].as_deref().unwrap();
assert!(why.contains("newer SolidCraft") && why.contains("gyroid_lattice"), "{why}");
}
#[test]
fn saving_keeps_an_unknown_feature_as_the_file_had_it_and_the_files_number() {
let file = with_unknown_feature();
let opened = Document::from_json(&file.to_string()).unwrap();
let again = saved(&opened);
assert_eq!(again["format"], file["format"], "never a lower number");
assert_eq!(again["format_pending"], file["format_pending"]);
assert_eq!(again["features"][1], file["features"][1], "the feature as the file had it");
assert_eq!(
again["features"].as_array().unwrap().iter().map(|f| f["type"].clone()).collect::<Vec<_>>(),
[json!("sketch"), json!("gyroid_lattice"), json!("extrude")]
);
assert_eq!(Document::from_json(&opened.to_json()).unwrap(), opened, "saved and read again, the same");
// A rename here goes with it; the rest stays.
let mut renamed = opened.clone();
renamed.features[1].name = "Infill".into();
let again = saved(&renamed);
assert_eq!(again["features"][1]["name"], "Infill");
assert_eq!(again["features"][1]["nested"], json!({"big": 1e300}));
}
#[test]
fn saving_keeps_fields_and_sections_this_version_doesnt_know() {
let mut file = saved(&design());
file["format"] = json!(newer());
file["future_section"] = json!({"anything": [1, 2, 3]});
file["features"][1]["draft_v2"] = json!({"angle": 3.0});
file["params"][0]["future_note"] = json!("keep me");
let opened = Document::from_json(&file.to_string()).unwrap();
assert_eq!(built(&opened).0.len(), 1);
let report = opened.newer_file.as_ref().unwrap();
for field in ["future_section", "draft_v2", "future_note"] {
assert!(report.not_understood.iter().any(|what| what.contains(field)), "{field}: {:?}", report.not_understood);
}
let again = saved(&opened);
assert_eq!(again["future_section"], file["future_section"]);
assert_eq!(again["features"][1]["draft_v2"], file["features"][1]["draft_v2"]);
assert_eq!(again["params"][0]["future_note"], "keep me");
// They follow their records when the design changes around them.
let mut changed = opened.clone();
let id = changed.features[1].id;
changed.features.insert(0, changed.features[0].clone());
changed.features[0].id = 777;
assert_eq!(changed.feature(id).unwrap().name, "Pad");
let again = saved(&changed);
assert_eq!(again["features"][2]["draft_v2"], file["features"][1]["draft_v2"]);
assert!(crate::newer_file::not_kept(&changed).is_empty());
}
#[test]
fn a_section_that_doesnt_read_is_left_out_named_and_kept() {
let mut file = saved(&design());
file["format"] = json!(newer());
file["configs"] = json!({"columns": [{"unknown_future_column": "Body"}], "rules": {"unknown_future_rule": true}});
let opened = Document::from_json(&file.to_string()).unwrap();
assert_eq!(built(&opened).0.len(), 1);
let report = opened.newer_file.as_ref().unwrap();
assert!(report.not_understood.iter().any(|what| what.contains("configs")), "{:?}", report.not_understood);
assert_eq!(saved(&opened)["configs"], file["configs"]);
assert!(crate::newer_file::not_kept(&opened).is_empty());
}
#[test]
fn a_parameter_that_doesnt_read_is_left_out_named_and_kept() {
let mut file = saved(&design());
file["format"] = json!(newer());
file["params"].as_array_mut().unwrap().push(json!({"name": "future", "expression": {"kind": "table"}}));
let opened = Document::from_json(&file.to_string()).unwrap();
assert_eq!(built(&opened).0.len(), 1);
assert!(opened.newer_file.as_ref().unwrap().not_understood.iter().any(|what| what.contains("`future`")));
let names: Vec<_> = saved(&opened)["params"].as_array().unwrap().iter().map(|p| p["name"].clone()).collect();
assert!(names.contains(&json!("future")) && names.contains(&json!("depth")), "{names:?}");
}
#[test]
fn what_cant_go_back_after_an_edit_is_said() {
let mut file = saved(&design());
file["format"] = json!(newer());
file["configs"] = json!({"columns": [{"unknown_future_column": "Body"}]});
let opened = Document::from_json(&file.to_string()).unwrap();
assert!(crate::newer_file::not_kept(&opened).is_empty());
let mut edited = opened.clone();
edited.favorites.insert("depth".into());
assert!(crate::newer_file::not_kept(&edited).is_empty(), "an edit elsewhere costs nothing");
let baseline: Value = saved(&edited);
assert_eq!(baseline["configs"], file["configs"]);
}
#[test]
fn a_feature_this_version_reads_wrongly_is_kept_whole() {
// A kind this build has, in a shape it doesn't: the feature can't read, and is not guessed at.
let mut file = saved(&design());
file["format"] = json!(newer());
file["features"][1]["operation"] = json!("weave");
let opened = Document::from_json(&file.to_string()).unwrap();
assert!(matches!(&opened.features[1].kind, FeatureKind::NewerFileFeature { raw } if raw.kind() == "extrude"));
assert!(built(&opened).1[1].as_deref().unwrap().contains("newer SolidCraft"));
assert_eq!(saved(&opened)["features"][1], file["features"][1]);
}
#[test]
fn what_truly_cant_be_read_says_why() {
for (text, says) in [
("not json", "HDR resource admission"),
("{\"format\":\"solidcraft/0\",\"name\":\"x\"}", "format 0"),
("{\"format\":\"someone-else/9\"}", "not a SolidCraft design (format `someone-else/9`)"),
] {
let why = Document::from_json(text).unwrap_err().to_string();
assert!(why.contains(says), "{text}: {why}");
}
// Two features of one id are damage whatever build saved them.
let mut file = saved(&design());
file["format"] = json!(newer());
let same = file["features"][0].clone();
file["features"].as_array_mut().unwrap().push(same);
let why = Document::from_json(&file.to_string()).unwrap_err().to_string();
assert!(why.contains(&format!("format {}", FORMAT + 1)) && why.contains("can't read it") && why.contains("damaged"), "{why}");
}
#[test]
fn a_file_this_build_fully_knows_is_not_flagged_and_damage_is_still_refused() {
assert!(Document::from_json(&design().to_json()).unwrap().newer_file.is_none());
assert_eq!(DESIGN.newer(FORMAT, None), None);
let mut file = saved(&design());
file["features"][1]["type"] = json!("gyroid_lattice");
assert!(Document::from_json(&file.to_string()).is_err(), "of this build's own format, that is damage");
}
+26 -8
View File
@@ -9,6 +9,7 @@ use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use crate::expr::{self, Expr, Kind, ParamDef, Value};
use crate::sheet::rule_references;
use crate::walk::Walk;
use crate::{DocError, Document, FeatureKind, MAX_PARAMS, Result};
@@ -797,13 +798,14 @@ impl Document {
}
}
let uses = |e: &str| expr::references(e).iter().any(|r| r == name);
let rule_uses = |field: &str, e: &str| rule_references(field, e).iter().any(|r| r == name);
for r in &self.sheet.rules {
for (field, _, _) in r.fields().into_iter().filter(|(_, e, _)| uses(e)) {
for (field, _, _) in r.fields().into_iter().filter(|(f, e, _)| rule_uses(f, e)) {
out.push(ParamUser::Rule { kind: "sheet metal".into(), name: r.name.clone(), field: field.into() });
}
}
for r in &self.plastic.rules {
for (field, _, _) in r.fields().into_iter().filter(|(_, e, _)| uses(e)) {
for (field, _, _) in r.fields().into_iter().filter(|(f, e, _)| rule_uses(f, e)) {
out.push(ParamUser::Rule { kind: "plastic".into(), name: r.name.clone(), field: field.into() });
}
}
@@ -855,6 +857,21 @@ impl Document {
if self.name_taken(new) {
return Err(DocError::Invalid(format!("there is already a parameter `{new}`")));
}
// Outside a rule's own thickness, `Thickness` is the rule's thickness (#9623): renaming to
// it would turn those references into the rule's thickness and change their values.
if new == "Thickness" {
let uses_old = |field: &str, e: &str| field != "thickness" && expr::references(e).iter().any(|r| r == old);
let sheet = self.sheet.rules.iter().flat_map(|r| r.fields().into_iter().map(move |(f, e, _)| ("sheet metal", &r.name, f, e)));
let plastic = self.plastic.rules.iter().flat_map(|r| r.fields().into_iter().map(move |(f, e, _)| ("plastic", &r.name, f, e)));
let list: Vec<String> =
sheet.chain(plastic).filter(|(_, _, f, e)| uses_old(f, e)).map(|(kind, name, f, _)| format!("{kind} rule {name} ({f})")).collect();
if !list.is_empty() {
return Err(DocError::Invalid(format!(
"`{old}` can't be renamed to `Thickness`: {} would read the rule's thickness instead",
list.join(", ")
)));
}
}
let mut n = 0;
let text = self.text_param(old).is_some();
for p in &mut self.text_params {
@@ -964,7 +981,8 @@ impl Document {
}
// Sheet metal and plastic rules and canvas planes hold expressions too (#4420).
let rules = self.sheet.rules.iter_mut().flat_map(|r| r.fields_mut()).chain(self.plastic.rules.iter_mut().flat_map(|r| r.fields_mut()));
for (_, e) in rules {
// `Thickness` outside a rule's own thickness is the rule's, not the parameter (#9623).
for (_, e) in rules.filter(|(field, _)| old != "Thickness" || *field == "thickness") {
let r = expr::rename_reference(e, old, new);
if r != *e {
*e = r;
@@ -1025,10 +1043,10 @@ impl Document {
}
// Sheet metal and plastic features rebuild when their rules' parameters change (#4420).
if f.kind.is_sheet() {
names.extend(self.sheet.rules.iter().flat_map(|r| r.fields()).flat_map(|(_, e, _)| expr::references(e)));
names.extend(self.sheet.rules.iter().flat_map(|r| r.fields()).flat_map(|(f, e, _)| rule_references(f, e)));
}
if f.kind.is_plastic() {
names.extend(self.plastic.rules.iter().flat_map(|r| r.fields()).flat_map(|(_, e, _)| expr::references(e)));
names.extend(self.plastic.rules.iter().flat_map(|r| r.fields()).flat_map(|(f, e, _)| rule_references(f, e)));
}
out.extend(names.into_iter().map(|n| (format!("Feature:{}", f.name), n)));
}
@@ -1052,9 +1070,9 @@ impl Document {
}
// A rule that uses it changes every sheet metal (or plastic) feature: the evaluation
// hashes the rules into their fingerprint.
let uses = |e: &String| expr::references(e).iter().any(|r| affected.contains(r));
let sheet = self.sheet.rules.iter().any(|r| r.fields().iter().any(|(_, e, _)| uses(e)));
let plastic = self.plastic.rules.iter().any(|r| r.fields().iter().any(|(_, e, _)| uses(e)));
let uses = |field: &str, e: &str| rule_references(field, e).iter().any(|r| affected.contains(r));
let sheet = self.sheet.rules.iter().any(|r| r.fields().iter().any(|(f, e, _)| uses(f, e)));
let plastic = self.plastic.rules.iter().any(|r| r.fields().iter().any(|(f, e, _)| uses(f, e)));
let mut out = Vec::new();
for f in &self.features {
let mut hit = f.param_names.iter().any(|n| affected.contains(n));
-1
View File
@@ -123,7 +123,6 @@ pub(super) fn local_geometry(f: &Feature, components: &std::collections::HashMap
/// Assembly records do not feed any of the feature kinds accepted by `local_geometry`.
fn same_geometry_inputs(a: &Document, b: &Document) -> bool {
let Document {
newer_file: _,
render,
format,
format_pending,
+1 -1
View File
@@ -167,7 +167,7 @@ impl Document {
return Err(DocError::Invalid("the plastic thickness must be positive".into()));
}
let with_t = |e: &str, k: Kind| -> Result<f64> {
let f = |n: &str| if n == "Thickness" && !vals.contains_key("Thickness") { Ok(Value::length(t)) } else { look(n) };
let f = |n: &str| if n == "Thickness" { Ok(Value::length(t)) } else { look(n) };
let v = expr::eval_with(e, &f)?.to_kind(k)?;
if !v.is_finite() || v < 0.0 {
return Err(DocError::Invalid(format!("plastic rule `{}`: `{e}` must not be negative", r.name)));
+1 -1
View File
@@ -278,7 +278,7 @@ fn selection_indices(ss: &SolvedSketch, selection: &ProfileSel, mut notes: Optio
.profiles
.iter()
.enumerate()
.filter(|(_, profile)| profile.region.contains(*point))
.filter(|(_, profile)| profile.contains(*point))
.min_by(|(_, a), (_, b)| a.area.total_cmp(&b.area))
.ok_or_else(|| DocError::Unknown(format!("profile at {:?} in {}", [point.x, point.y], ss.name)))?;
indices.push(index);
+30 -1
View File
@@ -1619,7 +1619,36 @@ pub(crate) fn section(plane: &Plane, m: &Mesh, face: Option<u32>) -> Vec<LinkGeo
// where they are) are kept when they take no more curves than the loose fit.
let close = refine(fit_segments(&segs, size, true));
let plain = |v: &[LinkGeom]| v.iter().all(|g| matches!(g, LinkGeom::Line(..) | LinkGeom::Arc { .. } | LinkGeom::Circle(..)));
if plain(&close) && close.len() <= lossy.len() { close } else { lossy }
// A loose fit with fewer curves can still be wrong: short faces beside a small round (2 mm
// walls each side of an R2 corner) fit within its tolerance as one larger arc. The exact
// crossings (on planes and rulings) show it: one of them off every curve of the fit.
// Any other curve counts as near, and past a work limit nothing is checked: the loose fit
// stands, as before.
let tight = size * 1e-4;
let checkable = exact.len().saturating_mul(close.len()) <= 4_000_000;
let strays = |v: &[LinkGeom]| exact.iter().any(|p| !v.iter().any(|g| plain_distance(g, *p).is_none_or(|d| d <= tight)));
if plain(&close) && (close.len() <= lossy.len() || (checkable && strays(&lossy) && !strays(&close))) { close } else { lossy }
}
/// How far `p` lies from a line, arc or circle; None for any other curve.
fn plain_distance(g: &LinkGeom, p: Vec2) -> Option<f64> {
match *g {
LinkGeom::Line(a, b) => {
let d = b - a;
let t = if d.len2() > 0.0 { ((p - a).dot(d) / d.len2()).clamp(0.0, 1.0) } else { 0.0 };
Some(p.dist(a.lerp(b, t)))
}
LinkGeom::Circle(c, r) => Some((p.dist(c) - r).abs()),
LinkGeom::Arc { c, a, b } => {
let (s0, sw) = ((a - c).angle(), ((b - c).angle() - (a - c).angle()).rem_euclid(std::f64::consts::TAU));
if ((p - c).angle() - s0).rem_euclid(std::f64::consts::TAU) <= sw + 1e-9 {
Some((p.dist(c) - c.dist(a)).abs())
} else {
Some(p.dist(a).min(p.dist(b)))
}
}
_ => None,
}
}
/// The segments where the plane crosses the mesh's triangles (in plane coordinates, each with
+13 -2
View File
@@ -284,6 +284,16 @@ impl SheetRule {
}
}
/// The design parameters a rule's `field` expression `e` reads: outside the rule's own
/// `thickness`, `Thickness` is the rule's thickness (sheet metal and plastic rules alike).
pub fn rule_references(field: &str, e: &str) -> Vec<String> {
let mut names = expr::references(e);
if field != "thickness" {
names.retain(|n| n != "Thickness");
}
names
}
fn rule_gap() -> String {
"Thickness".into()
}
@@ -464,7 +474,8 @@ impl Document {
.unwrap_or_else(SheetRule::steel)
}
/// Evaluate a rule: `Thickness` in its expressions is the rule's thickness.
/// Evaluate a rule: `Thickness` in its expressions is the rule's thickness, even where the
/// design has a parameter of that name (#9623); only the rule's `thickness` itself reads it.
pub fn rule_values(&self, vals: &std::collections::BTreeMap<String, Value>, r: &SheetRule) -> Result<RuleValues> {
self.rule_values_at(vals, r, None)
}
@@ -481,7 +492,7 @@ impl Document {
return Err(DocError::Invalid("the sheet thickness must be more than 0 and less than 10000 mm".into()));
}
let with_t = |e: &str, k: Kind| -> Result<f64> {
let f = |n: &str| if n == "Thickness" && !vals.contains_key("Thickness") { Ok(Value::length(t)) } else { look(n) };
let f = |n: &str| if n == "Thickness" { Ok(Value::length(t)) } else { look(n) };
expr::eval_with(e, &f)?.to_kind(k)
};
let v = RuleValues {
-1
View File
@@ -42,7 +42,6 @@ impl Walk for i8 {}
impl Walk for f64 {}
impl Walk for usize {}
impl Walk for u32 {}
impl Walk for crate::newer_file::RawFeature {}
impl Walk for ProfileSel {}
impl Walk for std::collections::BTreeMap<String, String> {}
impl Walk for crate::frame::Anchor {}
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "solidcraft-dynamics"
description = "SolidCraft multibody dynamics: rigid bodies, joint constraints, gravity and springs, integrated in time"
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
solidcore-record = { workspace = true }
solidcraft-geom = { workspace = true }
serde = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
+203
View File
@@ -0,0 +1,203 @@
//! Time stepping: classical Runge–Kutta 4 with a fixed step, or Dormand–Prince 5(4) with an
//! embedded error estimate, each followed by projection onto the joints (positions, then
//! velocities), landing on every output sample.
use crate::model::{Integrator, MAX_MAGNITUDE, MAX_STEPS, Model, Settings};
use crate::system::System;
use crate::{DynamicsError, Result, Sample, Stats, Trajectory};
/// Dormand–Prince 5(4) tableau (the system is autonomous, so the nodes are not needed): stage
/// weights, fifth-order weights and the difference between the fifth- and fourth-order weights
/// (the error estimate).
const DP_A: [[f64; 6]; 7] = [
[0.0; 6],
[1.0 / 5.0, 0.0, 0.0, 0.0, 0.0, 0.0],
[3.0 / 40.0, 9.0 / 40.0, 0.0, 0.0, 0.0, 0.0],
[44.0 / 45.0, -56.0 / 15.0, 32.0 / 9.0, 0.0, 0.0, 0.0],
[19372.0 / 6561.0, -25360.0 / 2187.0, 64448.0 / 6561.0, -212.0 / 729.0, 0.0, 0.0],
[9017.0 / 3168.0, -355.0 / 33.0, 46732.0 / 5247.0, 49.0 / 176.0, -5103.0 / 18656.0, 0.0],
[35.0 / 384.0, 0.0, 500.0 / 1113.0, 125.0 / 192.0, -2187.0 / 6784.0, 11.0 / 84.0],
];
const DP_B: [f64; 7] = [35.0 / 384.0, 0.0, 500.0 / 1113.0, 125.0 / 192.0, -2187.0 / 6784.0, 11.0 / 84.0, 0.0];
const DP_E: [f64; 7] = [
35.0 / 384.0 - 5179.0 / 57600.0,
0.0,
500.0 / 1113.0 - 7571.0 / 16695.0,
125.0 / 192.0 - 393.0 / 640.0,
-2187.0 / 6784.0 + 92097.0 / 339200.0,
11.0 / 84.0 - 187.0 / 2100.0,
-1.0 / 40.0,
];
/// `y + Σ k_i · w_i · h`.
fn combine(y: &[f64], ks: &[Vec<f64>], weights: &[f64], h: f64) -> Vec<f64> {
let mut out = y.to_vec();
for (k, w) in ks.iter().zip(weights) {
if *w == 0.0 {
continue;
}
for (o, d) in out.iter_mut().zip(k) {
*o += h * w * d;
}
}
out
}
fn at_time(e: DynamicsError, t: f64) -> DynamicsError {
match e {
DynamicsError::Singular { .. } => DynamicsError::Singular { time: t },
DynamicsError::Diverged { .. } => DynamicsError::Diverged { time: t },
other => other,
}
}
struct Run<'a> {
sys: &'a System,
settings: &'a Settings,
stats: Stats,
}
impl Run<'_> {
fn deriv(&self, y: &[f64]) -> Result<Vec<f64>> {
self.sys.accelerations(y).map(|a| a.ydot)
}
/// Normalise, project and check a new state.
fn finish_step(&mut self, y: &mut [f64], t: f64) -> Result<()> {
self.sys.normalize(y).map_err(|e| at_time(e, t))?;
if self.settings.projection {
self.sys.project_positions(y, self.settings.projection_tol).map_err(|e| at_time(e, t))?;
self.sys.project_velocities(y).map_err(|e| at_time(e, t))?;
}
if !y.iter().all(|x| x.is_finite() && x.abs() < MAX_MAGNITUDE) {
return Err(DynamicsError::Diverged { time: t });
}
Ok(())
}
fn count_step(&mut self, t: f64) -> Result<()> {
if self.stats.steps + self.stats.rejected >= MAX_STEPS {
return Err(DynamicsError::StepLimit { time: t });
}
Ok(())
}
fn rk4(&mut self, y: &[f64], h: f64) -> Result<Vec<f64>> {
let k1 = self.deriv(y)?;
let k2 = self.deriv(&combine(y, std::slice::from_ref(&k1), &[0.5], h))?;
let k3 = self.deriv(&combine(y, std::slice::from_ref(&k2), &[0.5], h))?;
let k4 = self.deriv(&combine(y, std::slice::from_ref(&k3), &[1.0], h))?;
Ok(combine(y, &[k1, k2, k3, k4], &[1.0 / 6.0, 1.0 / 3.0, 1.0 / 3.0, 1.0 / 6.0], h))
}
/// One Dormand–Prince step: the fifth-order state and the scaled error norm.
fn dopri(&mut self, y: &[f64], h: f64, rel_tol: f64, abs_tol: f64) -> Result<(Vec<f64>, f64)> {
let mut ks: Vec<Vec<f64>> = Vec::with_capacity(7);
for a in DP_A.iter().take(6) {
let stage = combine(y, &ks, a, h);
ks.push(self.deriv(&stage)?);
}
let y5 = combine(y, &ks, &DP_B, h);
ks.push(self.deriv(&y5)?);
let err = combine(&vec![0.0; y.len()], &ks, &DP_E, h);
let n = y.len().max(1) as f64;
let sum: f64 = err
.iter()
.zip(y.iter().zip(&y5))
.map(|(e, (a, b))| {
let scale = abs_tol + rel_tol * a.abs().max(b.abs());
(e / scale).powi(2)
})
.sum();
Ok((y5, (sum / n).sqrt()))
}
/// Advance `y` from `t` to `t_end` exactly.
fn advance(&mut self, y: &mut Vec<f64>, t: &mut f64, t_end: f64, h_next: &mut f64) -> Result<()> {
let span = t_end - *t;
let eps = 1e-12 * t_end.abs().max(1.0);
match self.settings.integrator {
Integrator::Rk4 { step } => {
// Equal steps no longer than `step` that land on t_end.
let n = (span / step).ceil().max(1.0);
let h = span / n;
for _ in 0..n as usize {
self.count_step(*t)?;
let mut next = self.rk4(y, h).map_err(|e| at_time(e, *t))?;
*t += h;
self.finish_step(&mut next, *t)?;
*y = next;
self.stats.steps += 1;
}
*t = t_end;
}
Integrator::Adaptive { rel_tol, abs_tol, min_step, max_step } => {
while t_end - *t > eps {
self.count_step(*t)?;
let remaining = t_end - *t;
let h = h_next.clamp(min_step, max_step).min(remaining);
let (mut next, err) = self.dopri(y, h, rel_tol, abs_tol).map_err(|e| at_time(e, *t))?;
// The usual controller: 0.9·err^(−1/5), within [0.2, 5].
let factor = if err > 0.0 { (0.9 * err.powf(-0.2)).clamp(0.2, 5.0) } else { 5.0 };
if err <= 1.0 {
let landed = h >= remaining;
*t = if landed { t_end } else { *t + h };
self.finish_step(&mut next, *t)?;
*y = next;
self.stats.steps += 1;
// Keep the step chosen before a shortened landing step.
if !landed || factor < 1.0 {
*h_next = h * factor;
}
} else {
self.stats.rejected += 1;
if h <= min_step * (1.0 + 1e-9) {
return Err(DynamicsError::StepTooSmall { time: *t });
}
*h_next = (h * factor).max(min_step);
}
}
*t = t_end;
}
}
Ok(())
}
fn sample(&mut self, y: &[f64], t: f64) -> Result<Sample> {
let acc = self.sys.accelerations(y).map_err(|e| at_time(e, t))?;
let constraint_error = self.sys.constraint_error(y);
self.stats.max_constraint_error = self.stats.max_constraint_error.max(constraint_error);
self.stats.redundant_equations = self.stats.redundant_equations.max(acc.row_count().saturating_sub(acc.rank));
Ok(Sample { time: t, bodies: self.sys.body_states(y), joints: self.sys.joint_results(y, &acc), energy: self.sys.energy(y), constraint_error })
}
}
/// Simulates `model` for `settings.duration`, returning the states at every output interval
/// (the first at t = 0, after the start has been projected onto the joints).
pub fn simulate(model: &Model, settings: &Settings) -> Result<Trajectory> {
settings.validate()?;
let sys = System::new(model, settings.baumgarte)?;
let mut y = System::initial_state(model)?;
// An inconsistent start (frames a little apart, velocities the joints don't allow) is
// brought onto the joints first, whether or not later steps project.
sys.project_positions(&mut y, settings.projection_tol).map_err(|e| at_time(e, 0.0))?;
sys.project_velocities(&mut y).map_err(|e| at_time(e, 0.0))?;
let mut run = Run { sys: &sys, settings, stats: Stats::default() };
let count = (settings.duration / settings.output_interval).ceil() as usize;
let mut samples = Vec::with_capacity(count + 1);
samples.push(run.sample(&y, 0.0)?);
let mut t = 0.0;
let mut h_next = match settings.integrator {
Integrator::Rk4 { step } => step,
Integrator::Adaptive { min_step, max_step, .. } => (settings.output_interval * 0.1).clamp(min_step, max_step),
};
for k in 1..=count {
let t_end = (k as f64 * settings.output_interval).min(settings.duration);
if t_end <= t {
continue;
}
run.advance(&mut y, &mut t, t_end, &mut h_next)?;
samples.push(run.sample(&y, t)?);
}
Ok(Trajectory { samples, stats: run.stats })
}
+136
View File
@@ -0,0 +1,136 @@
//! Multibody dynamics for SolidCraft (RFC #9719): rigid bodies with masses and inertias, held
//! together by joints, moved by gravity, springs, dampers and loads, integrated in time.
//!
//! The crate knows nothing of documents or occurrences: a [`Model`] lists bodies (mass,
//! inertia about the centre of mass, starting pose and velocities), joints between frames on
//! them ([`JointKind`]: rigid, revolute, slider, cylindrical, planar, ball) and force elements;
//! [`simulate`] returns a [`Trajectory`] of poses, joint values and rates, joint reactions and
//! energies at a fixed output interval. The assembly layer maps occurrences and joints to it.
//!
//! The formulation is the textbook one for constrained rigid bodies in maximal coordinates
//! (Lagrange multipliers, index-1 form, see `system.rs`), stepped by Runge–Kutta 4 or
//! Dormand–Prince 5(4) and projected back onto the joints after every step (`integrate.rs`).
//!
//! Units are the design's: mm, kg, s and radians, so forces are in mN (kg·mm/s²), torques in
//! mN·mm, stiffness in N/m (kg/s²) and gravity in mm/s² ([`STANDARD_GRAVITY`]).
//!
//! Every input is checked ([`Model::validate`], [`Settings::validate`]) and every size and loop
//! is capped ([`MAX_BODIES`], [`MAX_STEPS`], …): a bad model is an error, never a panic.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
mod integrate;
mod linalg;
pub mod math;
mod model;
mod system;
use serde::{Deserialize, Serialize};
use solidcore_record::Vec3;
pub use integrate::simulate;
pub use math::{Mat3, Quat};
pub use model::{
Anchor, Body, Force, Integrator, Joint, JointKind, MAX_BODIES, MAX_CONSTRAINT_ROWS, MAX_FORCES, MAX_JOINTS, MAX_MAGNITUDE,
MAX_PROJECTION_ITERATIONS, MAX_SAMPLES, MAX_STEPS, Marker, Model, STANDARD_GRAVITY, Settings,
};
/// Why a simulation could not run or stopped.
#[derive(Clone, Debug, PartialEq, thiserror::Error)]
pub enum DynamicsError {
#[error("invalid model: {0}")]
InvalidModel(String),
#[error("invalid settings: {0}")]
InvalidSettings(String),
#[error("too large: {0}")]
TooLarge(String),
#[error("the joint equations could not be solved at t = {time} s")]
Singular { time: f64 },
#[error("the motion diverged at t = {time} s")]
Diverged { time: f64 },
#[error("the step needed at t = {time} s is below the smallest allowed")]
StepTooSmall { time: f64 },
#[error("the run needs more than the step limit (stopped at t = {time} s)")]
StepLimit { time: f64 },
}
pub type Result<T> = std::result::Result<T, DynamicsError>;
/// A body's state at one time (world).
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct BodyState {
/// Centre of mass (mm).
pub position: Vec3,
/// The body's axes (body → world).
pub orientation: Quat,
/// Velocity of the centre of mass (mm/s).
pub velocity: Vec3,
/// Angular velocity (rad/s).
pub angular_velocity: Vec3,
}
/// A joint's state at one time.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct JointResult {
/// The joint's values ([`JointKind::values`]; radians and mm). A ball joint gives B's turn
/// from A as a rotation vector in A's frame.
pub values: Vec<f64>,
/// Their rates (rad/s, mm/s); a ball joint gives the relative angular velocity in A's frame.
pub rates: Vec<f64>,
/// The force the joint puts on `b`'s side (world, mN). Joints whose equations repeat
/// others' (a planar linkage of revolutes) share their reactions in one of many valid ways.
pub force: Vec3,
/// The torque the joint puts on `b`'s side about its frame's origin (world, mN·mm).
pub torque: Vec3,
}
/// Energies at one time (kg·mm²/s² = µJ).
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Energy {
pub kinetic: f64,
/// Potential energy of gravity, zero at the world origin.
pub gravity: f64,
/// Energy stored in springs (preload included).
pub spring: f64,
}
impl Energy {
pub fn total(&self) -> f64 {
self.kinetic + self.gravity + self.spring
}
}
/// The system at one output time.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Sample {
/// Time (s).
pub time: f64,
pub bodies: Vec<BodyState>,
pub joints: Vec<JointResult>,
pub energy: Energy,
/// The largest joint equation error (mm for points; directions are unitless).
pub constraint_error: f64,
}
/// What a run did.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Stats {
/// Accepted steps.
pub steps: usize,
/// Rejected steps (adaptive integration).
pub rejected: usize,
/// The largest joint equation error at an output sample.
pub max_constraint_error: f64,
/// Joint equations that repeat others (the most at any sample).
pub redundant_equations: usize,
}
/// A run's result: the samples at t = 0, one output interval, two, … up to the duration.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Trajectory {
pub samples: Vec<Sample>,
pub stats: Stats,
}
#[cfg(test)]
mod tests;
+107
View File
@@ -0,0 +1,107 @@
//! Dense symmetric positive semi-definite solves for the constraint equations.
//!
//! The matrix `J M⁻¹ Jᵀ` is singular when joints repeat each other's constraints (every
//! revolute of a planar linkage built in 3D also holds the linkage in its plane), but its
//! right-hand side is then consistent. A Cholesky factorisation with diagonal pivoting stops at
//! the numerical rank and sets the multipliers of the dependent rows to zero, which solves such
//! a system exactly.
/// A square row-major matrix.
#[derive(Clone, Debug)]
pub(crate) struct Sym {
n: usize,
data: Vec<f64>,
}
impl Sym {
pub(crate) fn zeros(n: usize) -> Sym {
Sym { n, data: vec![0.0; n.saturating_mul(n)] }
}
pub(crate) fn get(&self, r: usize, c: usize) -> f64 {
self.data.get(r * self.n + c).copied().unwrap_or(0.0)
}
pub(crate) fn set(&mut self, r: usize, c: usize, v: f64) {
if let Some(x) = self.data.get_mut(r * self.n + c) {
*x = v;
}
}
fn swap(&mut self, a: usize, b: usize) {
if a == b {
return;
}
let n = self.n;
for c in 0..n {
self.data.swap(a * n + c, b * n + c);
}
for r in 0..n {
self.data.swap(r * n + a, r * n + b);
}
}
}
/// Solve `A x = b` for a symmetric positive semi-definite `A`, dropping the directions whose
/// pivot falls below `rel_tol` times the largest diagonal. Returns the solution and the rank;
/// `None` when the input is not finite or the sizes disagree.
pub(crate) fn solve_psd(mut a: Sym, b: &[f64], rel_tol: f64) -> Option<(Vec<f64>, usize)> {
let n = a.n;
if b.len() != n || a.data.len() != n.checked_mul(n)? || !b.iter().chain(&a.data).all(|x| x.is_finite()) {
return None;
}
let mut perm: Vec<usize> = (0..n).collect();
let mut rhs = b.to_vec();
let max_diag = (0..n).map(|i| a.get(i, i)).fold(0.0_f64, f64::max);
let tol = (max_diag * rel_tol).max(f64::MIN_POSITIVE);
let mut rank = 0;
for k in 0..n {
// The largest remaining diagonal becomes the pivot.
let (best, best_val) = (k..n).map(|i| (i, a.get(i, i))).fold((k, f64::NEG_INFINITY), |acc, x| if x.1 > acc.1 { x } else { acc });
if !(best_val > tol) {
break;
}
a.swap(k, best);
perm.swap(k, best);
rhs.swap(k, best);
let l = best_val.sqrt();
a.set(k, k, l);
// Both triangles are kept, so a later pivot swap moves whole rows and columns.
for i in k + 1..n {
let v = a.get(i, k) / l;
a.set(i, k, v);
a.set(k, i, v);
}
for j in k + 1..n {
let ljk = a.get(j, k);
if ljk == 0.0 {
continue;
}
for i in j..n {
let v = a.get(i, j) - a.get(i, k) * ljk;
a.set(i, j, v);
a.set(j, i, v);
}
}
rank = k + 1;
}
// L y = rhs (first `rank` rows), then Lᵀ x = y; the dependent unknowns stay 0.
let mut y = vec![0.0; n];
for i in 0..rank {
let s = (0..i).fold(rhs.get(i).copied().unwrap_or(0.0), |s, j| s - a.get(i, j) * y.get(j).copied().unwrap_or(0.0));
if let Some(v) = y.get_mut(i) {
*v = s / a.get(i, i);
}
}
let mut x = vec![0.0; n];
for i in (0..rank).rev() {
let s = (i + 1..rank).fold(y.get(i).copied().unwrap_or(0.0), |s, j| s - a.get(j, i) * x.get(j).copied().unwrap_or(0.0));
if let Some(v) = x.get_mut(i) {
*v = s / a.get(i, i);
}
}
let mut out = vec![0.0; n];
for (k, p) in perm.iter().enumerate() {
if let (Some(o), Some(v)) = (out.get_mut(*p), x.get(k)) {
*o = *v;
}
}
out.iter().all(|v| v.is_finite()).then_some((out, rank))
}
+203
View File
@@ -0,0 +1,203 @@
//! Rotations for the solver: a 3×3 matrix and a unit quaternion.
use serde::{Deserialize, Serialize};
use solidcore_record::Vec3;
use solidcraft_geom::prelude::*;
/// A 3×3 matrix, row-major (`m[row][col]`); acts on column vectors.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Mat3(pub [[f64; 3]; 3]);
impl Mat3 {
pub const IDENTITY: Mat3 = Mat3([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);
pub const ZERO: Mat3 = Mat3([[0.0; 3]; 3]);
/// The matrix whose columns are `a`, `b` and `c`.
pub fn from_cols(a: Vec3, b: Vec3, c: Vec3) -> Mat3 {
Mat3([[a.x, b.x, c.x], [a.y, b.y, c.y], [a.z, b.z, c.z]])
}
pub fn diagonal(d: Vec3) -> Mat3 {
Mat3([[d.x, 0.0, 0.0], [0.0, d.y, 0.0], [0.0, 0.0, d.z]])
}
pub fn col(&self, i: usize) -> Vec3 {
let [r0, r1, r2] = self.0;
match i {
0 => Vec3::new(r0[0], r1[0], r2[0]),
1 => Vec3::new(r0[1], r1[1], r2[1]),
_ => Vec3::new(r0[2], r1[2], r2[2]),
}
}
pub fn row(&self, i: usize) -> Vec3 {
let [a, b, c] = match i {
0 => self.0[0],
1 => self.0[1],
_ => self.0[2],
};
Vec3::new(a, b, c)
}
pub fn mul_vec(&self, v: Vec3) -> Vec3 {
Vec3::new(self.row(0).dot(v), self.row(1).dot(v), self.row(2).dot(v))
}
pub fn mul(&self, o: &Mat3) -> Mat3 {
Mat3::from_cols(self.mul_vec(o.col(0)), self.mul_vec(o.col(1)), self.mul_vec(o.col(2)))
}
pub fn transpose(&self) -> Mat3 {
Mat3::from_cols(self.row(0), self.row(1), self.row(2))
}
pub fn is_finite(&self) -> bool {
self.0.iter().flatten().all(|x| x.is_finite())
}
/// The largest difference between `m[i][j]` and `m[j][i]`.
pub fn asymmetry(&self) -> f64 {
let m = &self.0;
(m[0][1] - m[1][0]).abs().max((m[0][2] - m[2][0]).abs()).max((m[1][2] - m[2][1]).abs())
}
pub fn determinant(&self) -> f64 {
self.col(0).dot(self.col(1).cross(self.col(2)))
}
/// The inverse of a symmetric positive definite matrix (an inertia tensor); `None` when it is
/// not one (by its leading principal minors, scaled to its size).
pub fn spd_inverse(&self) -> Option<Mat3> {
let m = &self.0;
let scale = m[0][0].abs().max(m[1][1].abs()).max(m[2][2].abs());
if !(scale > 0.0) || !self.is_finite() || self.asymmetry() > 1e-9 * scale {
return None;
}
let rel = 1e-12;
let m1 = m[0][0];
let m2 = m[0][0] * m[1][1] - m[0][1] * m[1][0];
let det = self.determinant();
if !(m1 > rel * scale) || !(m2 > rel * scale * scale) || !(det > rel * scale * scale * scale) {
return None;
}
// Adjugate over determinant: the cross products of the columns are the adjugate's rows.
let (a, b, c) = (self.col(0), self.col(1), self.col(2));
let inv = Mat3::from_cols(b.cross(c), c.cross(a), a.cross(b)).transpose();
let inv = Mat3(inv.0.map(|r| r.map(|x| x / det)));
inv.is_finite().then_some(inv)
}
}
/// `a × (b × c)`-style helper: the vector `a × (a × v)`.
pub fn cross2(a: Vec3, v: Vec3) -> Vec3 {
a.cross(a.cross(v))
}
/// A rotation as a unit quaternion `w + xi + yj + zk`.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Quat {
pub w: f64,
pub x: f64,
pub y: f64,
pub z: f64,
}
impl Default for Quat {
fn default() -> Self {
Quat::IDENTITY
}
}
impl Quat {
pub const IDENTITY: Quat = Quat { w: 1.0, x: 0.0, y: 0.0, z: 0.0 };
pub fn vec(self) -> Vec3 {
Vec3::new(self.x, self.y, self.z)
}
/// The turn by `angle` (radians) about `axis` (any length; the identity for a zero axis).
pub fn from_axis_angle(axis: Vec3, angle: f64) -> Quat {
match axis.normalized() {
Some(a) => {
let (s, c) = (angle * 0.5).sin_cos();
Quat { w: c, x: a.x * s, y: a.y * s, z: a.z * s }
}
None => Quat::IDENTITY,
}
}
/// The turn by the rotation vector `v` (axis times angle).
pub fn from_rotation_vector(v: Vec3) -> Quat {
// sin(θ/2)/θ by its series for small turns: a projection's corrections are far below
// the length a direction can be normalised from.
let angle = v.len();
let (s, c) = (angle * 0.5).sin_cos();
let k = if angle < 1e-4 { 0.5 - angle * angle / 48.0 } else { s / angle };
if !k.is_finite() || !v.is_finite() {
return Quat::IDENTITY;
}
Quat { w: c, x: v.x * k, y: v.y * k, z: v.z * k }
}
/// The rotation whose matrix has these orthonormal columns (a right-handed frame).
pub fn from_mat3(m: &Mat3) -> Option<Quat> {
let r = &m.0;
let tr = r[0][0] + r[1][1] + r[2][2];
let q = if tr > 0.0 {
let s = (tr + 1.0).sqrt() * 2.0;
Quat { w: 0.25 * s, x: (r[2][1] - r[1][2]) / s, y: (r[0][2] - r[2][0]) / s, z: (r[1][0] - r[0][1]) / s }
} else if r[0][0] > r[1][1] && r[0][0] > r[2][2] {
let s = (1.0 + r[0][0] - r[1][1] - r[2][2]).sqrt() * 2.0;
Quat { w: (r[2][1] - r[1][2]) / s, x: 0.25 * s, y: (r[0][1] + r[1][0]) / s, z: (r[0][2] + r[2][0]) / s }
} else if r[1][1] > r[2][2] {
let s = (1.0 + r[1][1] - r[0][0] - r[2][2]).sqrt() * 2.0;
Quat { w: (r[0][2] - r[2][0]) / s, x: (r[0][1] + r[1][0]) / s, y: 0.25 * s, z: (r[1][2] + r[2][1]) / s }
} else {
let s = (1.0 + r[2][2] - r[0][0] - r[1][1]).sqrt() * 2.0;
Quat { w: (r[1][0] - r[0][1]) / s, x: (r[0][2] + r[2][0]) / s, y: (r[1][2] + r[2][1]) / s, z: 0.25 * s }
};
q.normalized()
}
/// The Hamilton product `self ⊗ o` (turn by `o`, then by `self`).
pub fn compose(self, o: Quat) -> Quat {
Quat {
w: self.w * o.w - self.x * o.x - self.y * o.y - self.z * o.z,
x: self.w * o.x + self.x * o.w + self.y * o.z - self.z * o.y,
y: self.w * o.y - self.x * o.z + self.y * o.w + self.z * o.x,
z: self.w * o.z + self.x * o.y - self.y * o.x + self.z * o.w,
}
}
pub fn conjugate(self) -> Quat {
Quat { w: self.w, x: -self.x, y: -self.y, z: -self.z }
}
pub fn norm(self) -> f64 {
(self.w * self.w + self.x * self.x + self.y * self.y + self.z * self.z).sqrt()
}
pub fn is_finite(self) -> bool {
self.w.is_finite() && self.x.is_finite() && self.y.is_finite() && self.z.is_finite()
}
/// Scaled to length 1; `None` for a zero or non-finite quaternion.
pub fn normalized(self) -> Option<Quat> {
let n = self.norm();
(n > 1e-300 && n.is_finite()).then(|| Quat { w: self.w / n, x: self.x / n, y: self.y / n, z: self.z / n })
}
pub fn to_mat3(self) -> Mat3 {
let Quat { w, x, y, z } = self;
Mat3([
[1.0 - 2.0 * (y * y + z * z), 2.0 * (x * y - w * z), 2.0 * (x * z + w * y)],
[2.0 * (x * y + w * z), 1.0 - 2.0 * (x * x + z * z), 2.0 * (y * z - w * x)],
[2.0 * (x * z - w * y), 2.0 * (y * z + w * x), 1.0 - 2.0 * (x * x + y * y)],
])
}
pub fn rotate(self, v: Vec3) -> Vec3 {
// v + 2 q×(q×v + w v)
let q = self.vec();
v + q.cross(q.cross(v) + v * self.w) * 2.0
}
/// The rate of change of this orientation when the body turns at `omega` (world frame):
/// `q̇ = ½ (0, ω) ⊗ q`.
pub fn rate(self, omega: Vec3) -> Quat {
let o = Quat { w: 0.0, x: omega.x, y: omega.y, z: omega.z }.compose(self);
Quat { w: 0.5 * o.w, x: 0.5 * o.x, y: 0.5 * o.y, z: 0.5 * o.z }
}
/// `self + k·o`, component-wise (an integrator's stage; normalise afterwards).
pub fn add_scaled(self, o: Quat, k: f64) -> Quat {
Quat { w: self.w + k * o.w, x: self.x + k * o.x, y: self.y + k * o.y, z: self.z + k * o.z }
}
/// The rotation vector (axis times angle, angle in [0, π]).
pub fn rotation_vector(self) -> Vec3 {
let q = if self.w < 0.0 { Quat { w: -self.w, x: -self.x, y: -self.y, z: -self.z } } else { self };
let s = q.vec().len();
if s < 1e-12 {
return q.vec() * 2.0;
}
q.vec() * (2.0 * s.atan2(q.w) / s)
}
}
+385
View File
@@ -0,0 +1,385 @@
//! What a simulation is made of: rigid bodies, joints between frames on them, force elements
//! and the solver's settings. Everything is checked by [`Model::validate`] before a run.
use serde::{Deserialize, Serialize};
use solidcore_record::Vec3;
use solidcraft_geom::prelude::*;
use crate::math::{Mat3, Quat};
use crate::{DynamicsError, Result};
/// Bodies one simulation takes at most.
pub const MAX_BODIES: usize = 200;
/// Joints one simulation takes at most.
pub const MAX_JOINTS: usize = 400;
/// Constraint equations one simulation takes at most (the dense solve grows with their cube).
pub const MAX_CONSTRAINT_ROWS: usize = 1200;
/// Force elements one simulation takes at most.
pub const MAX_FORCES: usize = 2000;
/// Integration steps one run takes at most (accepted and rejected together).
pub const MAX_STEPS: usize = 2_000_000;
/// Output samples one run keeps at most.
pub const MAX_SAMPLES: usize = 100_000;
/// Projection iterations per step at most.
pub const MAX_PROJECTION_ITERATIONS: usize = 50;
/// Coordinates are kept within this size (mm, mm/s, rad/s); beyond it the run stops with
/// [`DynamicsError::Diverged`].
pub const MAX_MAGNITUDE: f64 = 1e12;
/// A rigid body. Lengths in mm, mass in kg, time in s, so forces are in kg·mm/s² (mN) and
/// torques in mN·mm.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Body {
/// Mass (kg), > 0.
pub mass: f64,
/// Inertia tensor about the centre of mass in the body's own axes (kg·mm²), symmetric
/// positive definite.
pub inertia: Mat3,
/// Centre of mass at the start (world, mm). The body's own frame has its origin here.
pub position: Vec3,
/// The body's axes at the start (body → world).
#[serde(default)]
pub orientation: Quat,
/// Velocity of the centre of mass at the start (world, mm/s).
#[serde(default)]
pub velocity: Vec3,
/// Angular velocity at the start (world, rad/s).
#[serde(default)]
pub angular_velocity: Vec3,
}
impl Body {
/// A body at rest at `position`, axes along the world's, with a diagonal inertia.
pub fn at_rest(mass: f64, principal_inertia: Vec3, position: Vec3) -> Body {
Body {
mass,
inertia: Mat3::diagonal(principal_inertia),
position,
orientation: Quat::IDENTITY,
velocity: Vec3::ZERO,
angular_velocity: Vec3::ZERO,
}
}
}
/// A frame on a body (in the body's own coordinates: origin at its centre of mass) or, with no
/// body, fixed in the world. `z` and `x` need not be unit or square: `x` is laid square to `z`.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Marker {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body: Option<usize>,
pub origin: Vec3,
pub z: Vec3,
pub x: Vec3,
}
impl Marker {
pub fn new(body: Option<usize>, origin: Vec3, z: Vec3, x: Vec3) -> Marker {
Marker { body, origin, z, x }
}
/// A frame fixed in the world.
pub fn ground(origin: Vec3, z: Vec3, x: Vec3) -> Marker {
Marker { body: None, origin, z, x }
}
}
/// A point on a body (body coordinates) or, with no body, in the world.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Anchor {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body: Option<usize>,
pub point: Vec3,
}
/// The joints the solver knows; each holds `b`'s frame to `a`'s as the matching assembly joint
/// does (z is the joint's axis), with the frames coinciding at zero joint values.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum JointKind {
/// No relative motion (6 equations).
Rigid,
/// Turns about z (5 equations).
Revolute,
/// Slides along z (5 equations).
Slider,
/// Turns about and slides along z (4 equations).
Cylindrical,
/// Slides in the xy plane and turns about z (3 equations).
Planar,
/// Turns any way about the common origin (3 equations).
Ball,
}
impl JointKind {
/// How many constraint equations the joint adds.
pub fn equations(self) -> usize {
match self {
JointKind::Rigid => 6,
JointKind::Revolute | JointKind::Slider => 5,
JointKind::Cylindrical => 4,
JointKind::Planar | JointKind::Ball => 3,
}
}
/// The joint's values, in the order [`crate::JointResult::values`] gives them.
pub fn values(self) -> &'static [&'static str] {
match self {
JointKind::Rigid => &[],
JointKind::Revolute => &["angle"],
JointKind::Slider => &["distance"],
JointKind::Cylindrical => &["angle", "distance"],
JointKind::Planar => &["x", "y", "angle"],
JointKind::Ball => &["rx", "ry", "rz"],
}
}
}
/// A joint: `b`'s frame held to `a`'s.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Joint {
pub kind: JointKind,
pub a: Marker,
pub b: Marker,
}
/// A force element.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Force {
/// A linear spring and damper between two points: the tension is
/// `stiffness·(length − free_length) + damping·(rate of length) + preload`.
Spring {
a: Anchor,
b: Anchor,
/// kg/s² (= N/m = mN/mm), ≥ 0.
stiffness: f64,
/// kg/s (= N·s/m), ≥ 0.
damping: f64,
/// mm, ≥ 0.
free_length: f64,
/// mN, tension at free length.
#[serde(default)]
preload: f64,
},
/// A constant force (world, mN) at a point of a body, and a constant torque (world, mN·mm).
Load {
body: usize,
#[serde(default)]
point: Vec3,
#[serde(default)]
force: Vec3,
#[serde(default)]
torque: Vec3,
},
}
/// How the equations are stepped in time.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Integrator {
/// Classical fourth-order Runge–Kutta with a fixed step (s).
Rk4 { step: f64 },
/// Dormand–Prince 5(4) with error control: steps between `min_step` and `max_step` (s)
/// keep the local error under `abs_tol + rel_tol·|y|`.
Adaptive { rel_tol: f64, abs_tol: f64, min_step: f64, max_step: f64 },
}
/// A run's settings.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Settings {
/// Simulated time (s), > 0.
pub duration: f64,
/// Time between output samples (s), > 0; the integrator lands on every sample.
pub output_interval: f64,
pub integrator: Integrator,
/// Project positions and velocities back onto the joints after every step (on by default).
#[serde(default = "yes")]
pub projection: bool,
/// The constraint error (mm, or radians times mm for directions) projection reaches.
#[serde(default = "default_projection_tol")]
pub projection_tol: f64,
/// Baumgarte stabilisation `(α, β)` (1/s): the constraint accelerations become
/// `Φ̈ + 2αΦ̇ + β²Φ = 0`. Off by default (projection keeps the joints).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub baumgarte: Option<(f64, f64)>,
}
fn yes() -> bool {
true
}
fn default_projection_tol() -> f64 {
1e-10
}
impl Settings {
/// Fixed-step RK4 settings.
pub fn rk4(duration: f64, step: f64, output_interval: f64) -> Settings {
Settings {
duration,
output_interval,
integrator: Integrator::Rk4 { step },
projection: true,
projection_tol: default_projection_tol(),
baumgarte: None,
}
}
/// Adaptive settings with the given relative and absolute tolerances.
pub fn adaptive(duration: f64, output_interval: f64, rel_tol: f64, abs_tol: f64) -> Settings {
Settings {
duration,
output_interval,
integrator: Integrator::Adaptive { rel_tol, abs_tol, min_step: 1e-9, max_step: output_interval },
projection: true,
projection_tol: default_projection_tol(),
baumgarte: None,
}
}
}
/// A multibody system.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Model {
pub bodies: Vec<Body>,
#[serde(default)]
pub joints: Vec<Joint>,
#[serde(default)]
pub forces: Vec<Force>,
/// Gravity (mm/s²), e.g. `(0, 0, −9806.65)`.
#[serde(default)]
pub gravity: Vec3,
}
/// Standard gravity (mm/s²).
pub const STANDARD_GRAVITY: f64 = 9806.65;
fn finite_vec(v: Vec3) -> bool {
v.is_finite() && v.x.abs() < MAX_MAGNITUDE && v.y.abs() < MAX_MAGNITUDE && v.z.abs() < MAX_MAGNITUDE
}
fn bad(what: String) -> DynamicsError {
DynamicsError::InvalidModel(what)
}
/// A marker's axes in its body's coordinates (columns x, y, z), from its `z` and `x`.
pub(crate) fn marker_axes(m: &Marker) -> Option<Mat3> {
let z = m.z.normalized()?;
let x = (m.x - z * m.x.dot(z)).normalized()?;
let y = z.cross(x).normalized()?;
Some(Mat3::from_cols(x, y, z))
}
impl Model {
/// Checks every number and reference, and the caps. Called by [`crate::simulate`].
pub fn validate(&self) -> Result<()> {
if self.bodies.is_empty() {
return Err(bad("no bodies".into()));
}
if self.bodies.len() > MAX_BODIES {
return Err(DynamicsError::TooLarge(format!("{} bodies (at most {MAX_BODIES})", self.bodies.len())));
}
if self.joints.len() > MAX_JOINTS {
return Err(DynamicsError::TooLarge(format!("{} joints (at most {MAX_JOINTS})", self.joints.len())));
}
if self.forces.len() > MAX_FORCES {
return Err(DynamicsError::TooLarge(format!("{} force elements (at most {MAX_FORCES})", self.forces.len())));
}
let rows: usize = self.joints.iter().map(|j| j.kind.equations()).sum();
if rows > MAX_CONSTRAINT_ROWS {
return Err(DynamicsError::TooLarge(format!("{rows} constraint equations (at most {MAX_CONSTRAINT_ROWS})")));
}
if !finite_vec(self.gravity) {
return Err(bad("gravity is not a finite vector".into()));
}
for (i, b) in self.bodies.iter().enumerate() {
if !(b.mass > 0.0) || !(b.mass < MAX_MAGNITUDE) {
return Err(bad(format!("body {i}: mass must be positive and finite")));
}
if b.inertia.spd_inverse().is_none() {
return Err(bad(format!("body {i}: inertia must be symmetric positive definite")));
}
if !finite_vec(b.position) || !finite_vec(b.velocity) || !finite_vec(b.angular_velocity) {
return Err(bad(format!("body {i}: position and velocities must be finite")));
}
if b.orientation.normalized().is_none() {
return Err(bad(format!("body {i}: orientation is not a rotation")));
}
}
let body_ok = |b: Option<usize>| b.is_none_or(|b| b < self.bodies.len());
for (i, j) in self.joints.iter().enumerate() {
for m in [&j.a, &j.b] {
if !body_ok(m.body) {
return Err(bad(format!("joint {i}: no body {}", m.body.unwrap_or(0))));
}
if !finite_vec(m.origin) || marker_axes(m).is_none() {
return Err(bad(format!("joint {i}: a frame's origin or axes are not usable")));
}
}
if j.a.body == j.b.body {
return Err(bad(format!("joint {i}: both frames are on the same body")));
}
}
for (i, f) in self.forces.iter().enumerate() {
match f {
Force::Spring { a, b, stiffness, damping, free_length, preload } => {
if !body_ok(a.body) || !body_ok(b.body) {
return Err(bad(format!("force {i}: no such body")));
}
if !finite_vec(a.point) || !finite_vec(b.point) {
return Err(bad(format!("force {i}: anchor points must be finite")));
}
let ok = |x: f64| (0.0..MAX_MAGNITUDE).contains(&x);
if !ok(*stiffness) || !ok(*damping) || !ok(*free_length) || !preload.is_finite() || preload.abs() >= MAX_MAGNITUDE {
return Err(bad(format!("force {i}: stiffness, damping and free length must be ≥ 0 and finite")));
}
}
Force::Load { body, point, force, torque } => {
if *body >= self.bodies.len() {
return Err(bad(format!("force {i}: no body {body}")));
}
if !finite_vec(*point) || !finite_vec(*force) || !finite_vec(*torque) {
return Err(bad(format!("force {i}: point, force and torque must be finite")));
}
}
}
}
Ok(())
}
}
impl Settings {
/// Checks the times, tolerances and that the run fits the step and sample caps.
pub fn validate(&self) -> Result<()> {
let pos = |x: f64| x > 0.0 && x < MAX_MAGNITUDE;
if !pos(self.duration) || !pos(self.output_interval) {
return Err(DynamicsError::InvalidSettings("duration and output interval must be positive and finite".into()));
}
let samples = (self.duration / self.output_interval).ceil();
if !(samples < MAX_SAMPLES as f64) {
return Err(DynamicsError::TooLarge(format!("{samples} output samples (at most {MAX_SAMPLES})")));
}
if !pos(self.projection_tol) {
return Err(DynamicsError::InvalidSettings("projection tolerance must be positive".into()));
}
if let Some((a, b)) = self.baumgarte
&& (!(0.0..MAX_MAGNITUDE).contains(&a) || !(0.0..MAX_MAGNITUDE).contains(&b))
{
return Err(DynamicsError::InvalidSettings("Baumgarte terms must be ≥ 0 and finite".into()));
}
match self.integrator {
Integrator::Rk4 { step } => {
if !pos(step) {
return Err(DynamicsError::InvalidSettings("step must be positive".into()));
}
let steps = (self.duration / step.min(self.output_interval)).ceil();
if !(steps < MAX_STEPS as f64) {
return Err(DynamicsError::TooLarge(format!("{steps} steps (at most {MAX_STEPS})")));
}
}
Integrator::Adaptive { rel_tol, abs_tol, min_step, max_step } => {
if !pos(rel_tol) || !pos(abs_tol) || !pos(min_step) || !pos(max_step) || min_step > max_step {
return Err(DynamicsError::InvalidSettings("tolerances and step bounds must be positive, min ≤ max".into()));
}
}
}
Ok(())
}
}
+594
View File
@@ -0,0 +1,594 @@
//! The equations of motion: body states, joint constraint equations with their Jacobians,
//! applied forces, the constrained accelerations and the projections back onto the joints.
//!
//! Bodies use maximal coordinates: centre-of-mass position `x`, unit quaternion `q`, velocity
//! `v` and angular velocity `ω` (both world). With `u = (v, ω)` per body, mass matrix
//! `M = diag(m·I₃, I_w)` (`I_w = R I_body Rᵀ`), joint equations `Φ(x, q) = 0`, their Jacobian
//! `J` (`Φ̇ = J u`) and `γ = −J̇ u`, the accelerations solve
//!
//! ```text
//! M u̇ + Jᵀ λ = F − ω × I_w ω, J u̇ = γ
//! ```
//!
//! through the Schur complement `(J M⁻¹ Jᵀ) λ = J M⁻¹ F − γ` (`linalg::solve_psd`, which takes
//! repeated joint equations), and `λ` gives the joints' reactions.
use solidcore_record::Vec3;
use solidcraft_geom::prelude::*;
use crate::linalg::{Sym, solve_psd};
use crate::math::{Mat3, Quat, cross2};
use crate::model::{Force, JointKind, MAX_MAGNITUDE, MAX_PROJECTION_ITERATIONS, Model, marker_axes};
use crate::{BodyState, DynamicsError, Energy, JointResult, Result};
/// Numbers per body in the flat state: x (3), q (4), v (3), ω (3).
pub(crate) const STRIDE: usize = 13;
/// Pivots under this fraction of the largest are taken as repeated equations.
const RANK_TOL: f64 = 1e-10;
fn v3(y: &[f64], at: usize) -> Vec3 {
let g = |i: usize| y.get(at + i).copied().unwrap_or(0.0);
Vec3::new(g(0), g(1), g(2))
}
fn put3(y: &mut [f64], at: usize, v: Vec3) {
for (i, c) in [v.x, v.y, v.z].into_iter().enumerate() {
if let Some(s) = y.get_mut(at + i) {
*s = c;
}
}
}
fn quat(y: &[f64], at: usize) -> Quat {
let g = |i: usize| y.get(at + i).copied().unwrap_or(0.0);
Quat { w: g(0), x: g(1), y: g(2), z: g(3) }
}
fn put_quat(y: &mut [f64], at: usize, q: Quat) {
for (i, c) in [q.w, q.x, q.y, q.z].into_iter().enumerate() {
if let Some(s) = y.get_mut(at + i) {
*s = c;
}
}
}
/// One body's precomputed constants.
struct BodyConst {
mass: f64,
inertia: Mat3,
inertia_inv: Mat3,
}
/// A joint frame: its body, origin and axes in the body's coordinates.
struct MarkerConst {
body: Option<usize>,
origin: Vec3,
axes: Mat3,
}
struct JointConst {
kind: JointKind,
a: MarkerConst,
b: MarkerConst,
}
/// A body's kinematics at one state.
#[derive(Clone, Copy)]
struct Kin {
x: Vec3,
rot: Mat3,
v: Vec3,
w: Vec3,
}
const GROUND: Kin = Kin { x: Vec3::ZERO, rot: Mat3::IDENTITY, v: Vec3::ZERO, w: Vec3::ZERO };
/// A joint frame at one state (world).
struct MarkerWorld {
body: Option<usize>,
/// Origin minus the body's centre of mass.
r: Vec3,
/// Origin.
p: Vec3,
/// Velocity of the origin.
pdot: Vec3,
axes: [Vec3; 3],
w: Vec3,
}
/// A body's part of one Jacobian row: (linear, angular).
type Block = (usize, Vec3, Vec3);
/// One constraint equation at a state.
pub(crate) struct Row {
a: Option<Block>,
b: Option<Block>,
pub(crate) phi: f64,
/// `J̇ u`: the equation's second derivative at zero accelerations.
jdot_u: f64,
}
impl Row {
fn blocks(&self) -> impl Iterator<Item = &Block> {
self.a.iter().chain(self.b.iter())
}
/// `J u`.
fn rate(&self, kin: &[Kin]) -> f64 {
self.blocks().map(|(i, l, a)| kin.get(*i).map_or(0.0, |k| l.dot(k.v) + a.dot(k.w))).sum()
}
}
/// A model ready to integrate.
pub(crate) struct System {
bodies: Vec<BodyConst>,
joints: Vec<JointConst>,
forces: Vec<Force>,
gravity: Vec3,
baumgarte: Option<(f64, f64)>,
}
/// Accelerations and multipliers at one state.
pub(crate) struct Accel {
/// The flat state's derivative.
pub(crate) ydot: Vec<f64>,
/// One multiplier per constraint row, in joint order.
lambda: Vec<f64>,
rows: Vec<Row>,
/// The rank of `J M⁻¹ Jᵀ`.
pub(crate) rank: usize,
}
impl Accel {
/// The number of joint equations.
pub(crate) fn row_count(&self) -> usize {
self.rows.len()
}
}
fn marker_const(m: &crate::Marker) -> Result<MarkerConst> {
let axes = marker_axes(m).ok_or_else(|| DynamicsError::InvalidModel("a joint frame's axes are not usable".into()))?;
Ok(MarkerConst { body: m.body, origin: m.origin, axes })
}
impl System {
pub(crate) fn new(model: &Model, baumgarte: Option<(f64, f64)>) -> Result<System> {
model.validate()?;
let bodies = model
.bodies
.iter()
.map(|b| {
let inertia_inv = b.inertia.spd_inverse().ok_or_else(|| DynamicsError::InvalidModel("inertia is not positive definite".into()))?;
Ok(BodyConst { mass: b.mass, inertia: b.inertia, inertia_inv })
})
.collect::<Result<Vec<_>>>()?;
let joints = model
.joints
.iter()
.map(|j| Ok(JointConst { kind: j.kind, a: marker_const(&j.a)?, b: marker_const(&j.b)? }))
.collect::<Result<Vec<_>>>()?;
Ok(System { bodies, joints, forces: model.forces.clone(), gravity: model.gravity, baumgarte })
}
/// The flat state of the model's starting conditions.
pub(crate) fn initial_state(model: &Model) -> Result<Vec<f64>> {
let mut y = vec![0.0; model.bodies.len() * STRIDE];
for (i, b) in model.bodies.iter().enumerate() {
let at = i * STRIDE;
let q = b.orientation.normalized().ok_or_else(|| DynamicsError::InvalidModel(format!("body {i}: orientation is not a rotation")))?;
put3(&mut y, at, b.position);
put_quat(&mut y, at + 3, q);
put3(&mut y, at + 7, b.velocity);
put3(&mut y, at + 10, b.angular_velocity);
}
Ok(y)
}
/// Unit quaternions again after a step.
pub(crate) fn normalize(&self, y: &mut [f64]) -> Result<()> {
for i in 0..self.bodies.len() {
let q = quat(y, i * STRIDE + 3).normalized().ok_or(DynamicsError::Diverged { time: f64::NAN })?;
put_quat(y, i * STRIDE + 3, q);
}
Ok(())
}
fn kinematics(&self, y: &[f64]) -> Vec<Kin> {
(0..self.bodies.len())
.map(|i| {
let at = i * STRIDE;
Kin { x: v3(y, at), rot: quat(y, at + 3).to_mat3(), v: v3(y, at + 7), w: v3(y, at + 10) }
})
.collect()
}
fn kin_of(kin: &[Kin], body: Option<usize>) -> Kin {
body.and_then(|b| kin.get(b).copied()).unwrap_or(GROUND)
}
fn marker_world(kin: &[Kin], m: &MarkerConst) -> MarkerWorld {
let k = Self::kin_of(kin, m.body);
let r = k.rot.mul_vec(m.origin);
let axes = [k.rot.mul_vec(m.axes.col(0)), k.rot.mul_vec(m.axes.col(1)), k.rot.mul_vec(m.axes.col(2))];
MarkerWorld { body: m.body, r, p: k.x + r, pdot: k.v + k.w.cross(r), axes, w: k.w }
}
/// The constraint equations of every joint at `kin`, in joint order.
fn rows(&self, kin: &[Kin]) -> Vec<Row> {
let mut rows = Vec::new();
for j in &self.joints {
let a = Self::marker_world(kin, &j.a);
let b = Self::marker_world(kin, &j.b);
joint_rows(j.kind, &a, &b, &mut rows);
}
rows
}
/// `M⁻¹` applied to one body's (force, torque).
fn minv(&self, kin: &[Kin], body: usize, f: Vec3, t: Vec3) -> (Vec3, Vec3) {
match (self.bodies.get(body), kin.get(body)) {
(Some(b), Some(k)) => {
let iw_inv = k.rot.mul(&b.inertia_inv).mul(&k.rot.transpose());
(f * (1.0 / b.mass), iw_inv.mul_vec(t))
}
_ => (Vec3::ZERO, Vec3::ZERO),
}
}
/// `J M⁻¹ Jᵀ`, and each row's `M⁻¹ Jᵀ` blocks.
fn schur(&self, kin: &[Kin], rows: &[Row]) -> (Sym, Vec<Vec<Block>>) {
let w: Vec<Vec<Block>> = rows
.iter()
.map(|r| {
r.blocks()
.map(|(i, l, a)| {
let (ml, ma) = self.minv(kin, *i, *l, *a);
(*i, ml, ma)
})
.collect()
})
.collect();
let n = rows.len();
let mut s = Sym::zeros(n);
for (r, row) in rows.iter().enumerate() {
for (c, wc) in w.iter().enumerate().skip(r) {
let mut sum = 0.0;
for (i, l, a) in row.blocks() {
for (k, ml, ma) in wc {
if i == k {
sum += l.dot(*ml) + a.dot(*ma);
}
}
}
s.set(r, c, sum);
s.set(c, r, sum);
}
}
(s, w)
}
/// Applied forces and torques (about each centre of mass, world), gyroscopic terms included.
fn applied(&self, kin: &[Kin]) -> Vec<(Vec3, Vec3)> {
let mut out: Vec<(Vec3, Vec3)> = self
.bodies
.iter()
.zip(kin)
.map(|(b, k)| {
let iw = k.rot.mul(&b.inertia).mul(&k.rot.transpose());
(self.gravity * b.mass, -k.w.cross(iw.mul_vec(k.w)))
})
.collect();
let mut add = |body: Option<usize>, r: Vec3, f: Vec3, t: Vec3| {
if let Some(slot) = body.and_then(|b| out.get_mut(b)) {
slot.0 += f;
slot.1 += r.cross(f) + t;
}
};
for f in &self.forces {
match *f {
Force::Spring { a, b, stiffness, damping, free_length, preload } => {
let (ka, kb) = (Self::kin_of(kin, a.body), Self::kin_of(kin, b.body));
let (ra, rb) = (ka.rot.mul_vec(a.point), kb.rot.mul_vec(b.point));
let d = (kb.x + rb) - (ka.x + ra);
let len = d.len();
let Some(n) = d.normalized().filter(|_| len > 1e-9) else { continue };
let rate = n.dot((kb.v + kb.w.cross(rb)) - (ka.v + ka.w.cross(ra)));
let tension = stiffness * (len - free_length) + damping * rate + preload;
add(a.body, ra, n * tension, Vec3::ZERO);
add(b.body, rb, n * -tension, Vec3::ZERO);
}
Force::Load { body, point, force, torque } => {
let r = kin.get(body).map_or(Vec3::ZERO, |k| k.rot.mul_vec(point));
add(Some(body), r, force, torque);
}
}
}
out
}
/// The state's derivative and the joints' multipliers.
pub(crate) fn accelerations(&self, y: &[f64]) -> Result<Accel> {
let kin = self.kinematics(y);
let applied = self.applied(&kin);
let rows = self.rows(&kin);
let (s, w) = self.schur(&kin, &rows);
// Unconstrained accelerations M⁻¹F.
let free: Vec<(Vec3, Vec3)> = applied.iter().enumerate().map(|(i, (f, t))| self.minv(&kin, i, *f, *t)).collect();
let rhs: Vec<f64> = rows
.iter()
.map(|r| {
let jmf: f64 = r.blocks().map(|(i, l, a)| free.get(*i).map_or(0.0, |(fl, fa)| l.dot(*fl) + a.dot(*fa))).sum();
let stab = self.baumgarte.map_or(0.0, |(alpha, beta)| 2.0 * alpha * r.rate(&kin) + beta * beta * r.phi);
jmf + r.jdot_u + stab
})
.collect();
let (lambda, rank) =
if rows.is_empty() { (Vec::new(), 0) } else { solve_psd(s, &rhs, RANK_TOL).ok_or(DynamicsError::Singular { time: f64::NAN })? };
let mut acc = free;
for (wr, l) in w.iter().zip(&lambda) {
for (i, ml, ma) in wr {
if let Some(a) = acc.get_mut(*i) {
a.0 -= *ml * *l;
a.1 -= *ma * *l;
}
}
}
let mut ydot = vec![0.0; y.len()];
for (i, (k, a)) in kin.iter().zip(&acc).enumerate() {
let at = i * STRIDE;
put3(&mut ydot, at, k.v);
put_quat(&mut ydot, at + 3, quat(y, at + 3).rate(k.w));
put3(&mut ydot, at + 7, a.0);
put3(&mut ydot, at + 10, a.1);
}
if !ydot.iter().all(|x| x.is_finite()) {
return Err(DynamicsError::Diverged { time: f64::NAN });
}
Ok(Accel { ydot, lambda, rows, rank })
}
/// The largest joint equation error at `y`.
pub(crate) fn constraint_error(&self, y: &[f64]) -> f64 {
self.rows(&self.kinematics(y)).iter().map(|r| r.phi.abs()).fold(0.0, f64::max)
}
/// Moves the bodies the least (mass-weighted) onto the joints: Gauss–Newton on `Φ = 0`.
/// Returns the error left.
pub(crate) fn project_positions(&self, y: &mut [f64], tol: f64) -> Result<f64> {
let mut err;
let mut iteration = 0;
loop {
let kin = self.kinematics(y);
let rows = self.rows(&kin);
err = rows.iter().map(|r| r.phi.abs()).fold(0.0, f64::max);
if !(err > tol) || iteration >= MAX_PROJECTION_ITERATIONS {
break;
}
iteration += 1;
let (s, w) = self.schur(&kin, &rows);
let phi: Vec<f64> = rows.iter().map(|r| r.phi).collect();
let (mu, _) = solve_psd(s, &phi, RANK_TOL).ok_or(DynamicsError::Singular { time: f64::NAN })?;
let mut delta = vec![(Vec3::ZERO, Vec3::ZERO); self.bodies.len()];
for (wr, m) in w.iter().zip(&mu) {
for (i, ml, ma) in wr {
if let Some(d) = delta.get_mut(*i) {
d.0 -= *ml * *m;
d.1 -= *ma * *m;
}
}
}
for (i, (dx, dth)) in delta.into_iter().enumerate() {
let at = i * STRIDE;
put3(y, at, v3(y, at) + dx);
let q = Quat::from_rotation_vector(dth).compose(quat(y, at + 3)).normalized().ok_or(DynamicsError::Diverged { time: f64::NAN })?;
put_quat(y, at + 3, q);
}
}
if !err.is_finite() || !y.iter().all(|x| x.is_finite() && x.abs() < MAX_MAGNITUDE) {
return Err(DynamicsError::Diverged { time: f64::NAN });
}
Ok(err)
}
/// Removes the velocities the joints don't allow (mass-weighted): `J u = 0`.
pub(crate) fn project_velocities(&self, y: &mut [f64]) -> Result<()> {
let kin = self.kinematics(y);
let rows = self.rows(&kin);
if rows.is_empty() {
return Ok(());
}
let (s, w) = self.schur(&kin, &rows);
let ju: Vec<f64> = rows.iter().map(|r| r.rate(&kin)).collect();
let (mu, _) = solve_psd(s, &ju, RANK_TOL).ok_or(DynamicsError::Singular { time: f64::NAN })?;
for (wr, m) in w.iter().zip(&mu) {
for (i, ml, ma) in wr {
let at = i * STRIDE;
put3(y, at + 7, v3(y, at + 7) - *ml * *m);
put3(y, at + 10, v3(y, at + 10) - *ma * *m);
}
}
Ok(())
}
/// The bodies' states.
pub(crate) fn body_states(&self, y: &[f64]) -> Vec<BodyState> {
(0..self.bodies.len())
.map(|i| {
let at = i * STRIDE;
BodyState { position: v3(y, at), orientation: quat(y, at + 3), velocity: v3(y, at + 7), angular_velocity: v3(y, at + 10) }
})
.collect()
}
/// Kinetic, gravity and spring energy.
pub(crate) fn energy(&self, y: &[f64]) -> Energy {
let kin = self.kinematics(y);
let mut e = Energy::default();
for (b, k) in self.bodies.iter().zip(&kin) {
let iw = k.rot.mul(&b.inertia).mul(&k.rot.transpose());
e.kinetic += 0.5 * b.mass * k.v.len2() + 0.5 * k.w.dot(iw.mul_vec(k.w));
e.gravity -= b.mass * self.gravity.dot(k.x);
}
for f in &self.forces {
if let Force::Spring { a, b, stiffness, free_length, preload, .. } = *f {
let (ka, kb) = (Self::kin_of(&kin, a.body), Self::kin_of(&kin, b.body));
let stretch = ((kb.x + kb.rot.mul_vec(b.point)) - (ka.x + ka.rot.mul_vec(a.point))).len() - free_length;
e.spring += 0.5 * stiffness * stretch * stretch + preload * stretch;
}
}
e
}
/// Each joint's values, rates and reaction (on `b`'s side, at its frame's origin, world),
/// from the multipliers `acc` found at `y`.
pub(crate) fn joint_results(&self, y: &[f64], acc: &Accel) -> Vec<JointResult> {
let kin = self.kinematics(y);
let mut at = 0;
let mut out = Vec::with_capacity(self.joints.len());
for j in &self.joints {
let a = Self::marker_world(&kin, &j.a);
let b = Self::marker_world(&kin, &j.b);
let (values, rates) = joint_values(j.kind, &a, &b);
let n = j.kind.equations();
// The wrench the joint's equations put on each body: −Jᵀλ.
let (mut fa, mut ta, mut fb, mut tb) = (Vec3::ZERO, Vec3::ZERO, Vec3::ZERO, Vec3::ZERO);
for k in at..at + n {
let (Some(row), Some(l)) = (acc.rows.get(k), acc.lambda.get(k)) else { continue };
if let Some((_, lin, ang)) = row.a {
fa -= lin * *l;
ta -= ang * *l;
}
if let Some((_, lin, ang)) = row.b {
fb -= lin * *l;
tb -= ang * *l;
}
}
at += n;
let (force, torque) = if b.body.is_some() {
(fb, tb - b.r.cross(fb))
} else {
// B is the ground: it takes what A takes, turned round, about B's origin.
let xa = Self::kin_of(&kin, a.body).x;
(-fa, -(ta + (xa - b.p).cross(fa)))
};
out.push(JointResult { values, rates, force, torque });
}
out
}
}
/// `(Φ, J, J̇u)` for one body point's world component along `e`: `e·(p_B − p_A)`.
fn point_row(a: &MarkerWorld, b: &MarkerWorld, e: Vec3) -> Row {
let acc = |m: &MarkerWorld| cross2(m.w, m.r);
Row { a: a.body.map(|i| (i, -e, -a.r.cross(e))), b: b.body.map(|i| (i, e, b.r.cross(e))), phi: e.dot(b.p - a.p), jdot_u: e.dot(acc(b) - acc(a)) }
}
/// `u·v = 0` for a direction `u` of A and `v` of B.
fn dot1_row(a: &MarkerWorld, u: Vec3, b: &MarkerWorld, v: Vec3) -> Row {
let (ud, vd) = (a.w.cross(u), b.w.cross(v));
let (udd, vdd) = (cross2(a.w, u), cross2(b.w, v));
Row {
a: a.body.map(|i| (i, Vec3::ZERO, u.cross(v))),
b: b.body.map(|i| (i, Vec3::ZERO, v.cross(u))),
phi: u.dot(v),
jdot_u: udd.dot(v) + 2.0 * ud.dot(vd) + u.dot(vdd),
}
}
/// `d·u = 0` for `d = p_B − p_A` and a direction `u` of A.
fn dot2_row(a: &MarkerWorld, u: Vec3, b: &MarkerWorld) -> Row {
let d = b.p - a.p;
let dd = b.pdot - a.pdot;
let ddd = cross2(b.w, b.r) - cross2(a.w, a.r);
let (ud, udd) = (a.w.cross(u), cross2(a.w, u));
Row {
a: a.body.map(|i| (i, -u, u.cross(d) - a.r.cross(u))),
b: b.body.map(|i| (i, u, b.r.cross(u))),
phi: d.dot(u),
jdot_u: ddd.dot(u) + 2.0 * dd.dot(ud) + d.dot(udd),
}
}
fn joint_rows(kind: JointKind, a: &MarkerWorld, b: &MarkerWorld, rows: &mut Vec<Row>) {
let [ax, ay, az] = a.axes;
let [bx, by, bz] = b.axes;
let point = |rows: &mut Vec<Row>| rows.extend([Vec3::X, Vec3::Y, Vec3::Z].map(|e| point_row(a, b, e)));
match kind {
JointKind::Rigid => {
point(rows);
rows.extend([dot1_row(a, ay, b, bz), dot1_row(a, az, b, bx), dot1_row(a, ax, b, by)]);
}
JointKind::Revolute => {
point(rows);
rows.extend([dot1_row(a, ax, b, bz), dot1_row(a, ay, b, bz)]);
}
JointKind::Ball => point(rows),
JointKind::Cylindrical => {
rows.extend([dot2_row(a, ax, b), dot2_row(a, ay, b), dot1_row(a, ax, b, bz), dot1_row(a, ay, b, bz)]);
}
JointKind::Slider => {
rows.extend([dot2_row(a, ax, b), dot2_row(a, ay, b), dot1_row(a, ax, b, bz), dot1_row(a, ay, b, bz), dot1_row(a, ax, b, by)]);
}
JointKind::Planar => {
rows.extend([dot2_row(a, az, b), dot1_row(a, az, b, bx), dot1_row(a, az, b, by)]);
}
}
}
/// A joint's values and their rates (see [`JointKind::values`]).
fn joint_values(kind: JointKind, a: &MarkerWorld, b: &MarkerWorld) -> (Vec<f64>, Vec<f64>) {
let [ax, ay, az] = a.axes;
let bx = b.axes[0];
let d = b.p - a.p;
let dd = b.pdot - a.pdot;
let angle = || bx.dot(ay).atan2(bx.dot(ax));
let spin = || (b.w - a.w).dot(az);
let along = |u: Vec3| (d.dot(u), dd.dot(u) + d.dot(a.w.cross(u)));
match kind {
JointKind::Rigid => (vec![], vec![]),
JointKind::Revolute => (vec![angle()], vec![spin()]),
JointKind::Slider => {
let (s, r) = along(az);
(vec![s], vec![r])
}
JointKind::Cylindrical => {
let (s, r) = along(az);
(vec![angle(), s], vec![spin(), r])
}
JointKind::Planar => {
let ((x, xr), (y, yr)) = (along(ax), along(ay));
(vec![x, y, angle()], vec![xr, yr, spin()])
}
JointKind::Ball => {
// B's turn from A as a rotation vector in A's frame, and the relative angular
// velocity in A's frame.
let am = Mat3::from_cols(ax, ay, az);
let rel = am.transpose().mul(&Mat3::from_cols(b.axes[0], b.axes[1], b.axes[2]));
let rv = Quat::from_mat3(&rel).map_or(Vec3::ZERO, Quat::rotation_vector);
let w = am.transpose().mul_vec(b.w - a.w);
(vec![rv.x, rv.y, rv.z], vec![w.x, w.y, w.z])
}
}
}
#[cfg(test)]
pub(crate) mod testing {
//! Finite-difference checks of the Jacobians and `J̇u` (tests only).
use super::*;
/// `(Φ, J u, J̇ u)` of every row at `y`.
pub(crate) fn rows_at(sys: &System, y: &[f64]) -> Vec<(f64, f64, f64)> {
let kin = sys.kinematics(y);
sys.rows(&kin).iter().map(|r| (r.phi, r.rate(&kin), r.jdot_u)).collect()
}
/// The state moved along its velocities for `h` (positions and orientations only).
pub(crate) fn drift(sys: &System, y: &[f64], h: f64) -> Vec<f64> {
let mut out = y.to_vec();
let kin = sys.kinematics(y);
for (i, k) in kin.iter().enumerate() {
let at = i * STRIDE;
put3(&mut out, at, k.x + k.v * h);
let q = Quat::from_rotation_vector(k.w * h).compose(quat(y, at + 3));
put_quat(&mut out, at + 3, q);
}
out
}
}
+540
View File
@@ -0,0 +1,540 @@
//! Analytic checks: free fall, pendulum periods, spring-mass oscillation, energy and momentum
//! conservation, slider-crank kinematics, joint reactions, Jacobians against finite
//! differences, and hostile input.
use std::f64::consts::{PI, TAU};
use solidcore_record::Vec3;
use solidcraft_geom::prelude::*;
use crate::system::{System, testing};
use crate::*;
const G: f64 = STANDARD_GRAVITY;
fn down() -> Vec3 {
Vec3::new(0.0, 0.0, -G)
}
fn ok<T>(r: Result<T>) -> T {
match r {
Ok(v) => v,
Err(e) => panic!("simulation failed: {e}"),
}
}
/// A body hanging `len` mm under a hinge at the origin turning about Y, at angle `theta` from
/// straight down (towards +X) and turning at `omega`.
fn pendulum(len: f64, theta: f64, omega: f64, inertia: f64) -> Model {
let dir = Vec3::new(theta.sin(), 0.0, -theta.cos());
// Turning about +Y takes −Z towards −X; the hinge's own Y axis is the world's.
let rot = Quat::from_axis_angle(Vec3::Y, -theta);
let mut body = Body::at_rest(1.0, Vec3::new(inertia, inertia, inertia), dir * len);
body.orientation = rot;
body.angular_velocity = Vec3::Y * -omega;
body.velocity = (Vec3::Y * -omega).cross(dir * len);
let joint = Joint {
kind: JointKind::Revolute,
a: Marker::ground(Vec3::ZERO, Vec3::Y, Vec3::X),
b: Marker::new(Some(0), Vec3::new(0.0, 0.0, len), Vec3::Y, Vec3::X),
};
Model { bodies: vec![body], joints: vec![joint], forces: vec![], gravity: down() }
}
/// Times where `f` crosses zero upwards, linearly interpolated between samples.
fn upward_crossings(traj: &Trajectory, f: impl Fn(&Sample) -> f64) -> Vec<f64> {
traj.samples
.windows(2)
.filter_map(|w| {
let (a, b) = (w.first()?, w.get(1)?);
let (fa, fb) = (f(a), f(b));
(fa < 0.0 && fb >= 0.0).then(|| a.time + (b.time - a.time) * fa / (fa - fb))
})
.collect()
}
fn mean_period(crossings: &[f64]) -> f64 {
assert!(crossings.len() >= 3, "too few crossings: {crossings:?}");
let (first, last) = (crossings.first().copied().unwrap_or(0.0), crossings.last().copied().unwrap_or(0.0));
(last - first) / (crossings.len() - 1) as f64
}
fn angle_of(s: &Sample) -> f64 {
s.joints.first().and_then(|j| j.values.first()).copied().unwrap_or(f64::NAN)
}
#[test]
fn free_fall_follows_the_parabola() {
let mut body = Body::at_rest(2.5, Vec3::new(10.0, 20.0, 30.0), Vec3::new(1.0, 2.0, 3.0));
body.velocity = Vec3::new(10.0, -5.0, 50.0);
body.angular_velocity = Vec3::new(0.0, 0.0, 1.0);
let model = Model { bodies: vec![body.clone()], joints: vec![], forces: vec![], gravity: down() };
let traj = ok(simulate(&model, &Settings::rk4(1.0, 0.01, 0.1)));
assert_eq!(traj.samples.len(), 11);
for s in &traj.samples {
let t = s.time;
let expect = body.position + body.velocity * t + down() * (0.5 * t * t);
let got = s.bodies.first().map(|b| b.position).unwrap_or(Vec3::ZERO);
assert!(got.dist(expect) < 1e-7, "t={t}: {got:?} vs {expect:?}");
// A symmetric body spinning about z keeps spinning.
let w = s.bodies.first().map(|b| b.angular_velocity).unwrap_or(Vec3::ZERO);
assert!(w.dist(Vec3::Z) < 1e-12);
// Energy is conserved exactly enough.
let e0 = traj.samples.first().map(|s| s.energy.total()).unwrap_or(0.0);
assert!((s.energy.total() - e0).abs() < 1e-6 * e0.abs().max(1.0));
}
}
#[test]
fn pendulum_small_angle_period() {
let (len, inertia) = (100.0, 40.0);
let i_pivot = inertia + len * len;
let period = TAU * (i_pivot / (G * len)).sqrt();
let amplitude = 0.01;
let model = pendulum(len, 0.0, amplitude * TAU / period, inertia);
let traj = ok(simulate(&model, &Settings::rk4(3.0, 1e-3, 1e-3)));
let measured = mean_period(&upward_crossings(&traj, angle_of));
// The finite amplitude lengthens the period by about θ²/16 (6e-6 here).
assert!((measured - period).abs() < 1e-4 * period, "period {measured} vs {period}");
assert!(traj.stats.max_constraint_error < 1e-9, "{:?}", traj.stats);
}
#[test]
fn adaptive_integration_matches_the_pendulum_period() {
let (len, inertia) = (100.0, 40.0);
let period = TAU * ((inertia + len * len) / (G * len)).sqrt();
let model = pendulum(len, 0.0, 0.01 * TAU / period, inertia);
let traj = ok(simulate(&model, &Settings::adaptive(3.0, 1e-3, 1e-9, 1e-9)));
let measured = mean_period(&upward_crossings(&traj, angle_of));
assert!((measured - period).abs() < 1e-4 * period, "period {measured} vs {period}");
// Larger tolerances take fewer steps than samples (the output still lands on each).
let coarse = Settings {
integrator: Integrator::Adaptive { rel_tol: 1e-6, abs_tol: 1e-6, min_step: 1e-9, max_step: 0.05 },
..Settings::rk4(3.0, 1.0, 0.05)
};
let traj = ok(simulate(&model, &coarse));
assert_eq!(traj.samples.len(), 61);
let measured = mean_period(&upward_crossings(&traj, angle_of));
assert!((measured - period).abs() < 2e-3 * period, "coarse period {measured} vs {period}");
}
#[test]
fn hanging_body_pin_carries_its_weight() {
let model = pendulum(100.0, 0.0, 0.0, 40.0);
let traj = ok(simulate(&model, &Settings::rk4(0.1, 1e-3, 0.05)));
for s in &traj.samples {
let j = s.joints.first().map(|j| (j.force, j.torque)).unwrap_or_default();
assert!(j.0.dist(Vec3::new(0.0, 0.0, G)) < 1e-6 * G, "force {:?}", j.0);
assert!(j.1.len() < 1e-6 * G, "torque {:?}", j.1);
assert!(angle_of(s).abs() < 1e-12);
}
}
#[test]
fn large_swing_conserves_energy() {
// Released from horizontal: a compound pendulum swinging through ±90°.
let model = pendulum(100.0, PI / 2.0, 0.0, 400.0);
let traj = ok(simulate(&model, &Settings::rk4(2.0, 1e-3, 1e-2)));
let e0 = traj.samples.first().map(|s| s.energy.total()).unwrap_or(0.0);
let scale = G * 100.0;
for s in &traj.samples {
assert!((s.energy.total() - e0).abs() < 1e-6 * scale, "t={} drift {}", s.time, s.energy.total() - e0);
}
}
/// Two links hinged about Y, released from horizontal.
fn double_pendulum() -> Model {
let link = |x: f64| Body::at_rest(1.0, Vec3::new(100.0, 900.0, 900.0), Vec3::new(x, 0.0, 0.0));
let hinge = |a: Option<usize>, pa: Vec3, b: usize, pb: Vec3| Joint {
kind: JointKind::Revolute,
a: Marker::new(a, pa, Vec3::Y, Vec3::X),
b: Marker::new(Some(b), pb, Vec3::Y, Vec3::X),
};
Model {
bodies: vec![link(50.0), link(150.0)],
joints: vec![
hinge(None, Vec3::ZERO, 0, Vec3::new(-50.0, 0.0, 0.0)),
hinge(Some(0), Vec3::new(50.0, 0.0, 0.0), 1, Vec3::new(-50.0, 0.0, 0.0)),
],
forces: vec![],
gravity: down(),
}
}
#[test]
fn double_pendulum_conserves_energy() {
let traj = ok(simulate(&double_pendulum(), &Settings::rk4(5.0, 5e-4, 1e-2)));
let e0 = traj.samples.first().map(|s| s.energy.total()).unwrap_or(0.0);
let scale = 2.0 * G * 150.0;
let worst = traj.samples.iter().map(|s| (s.energy.total() - e0).abs()).fold(0.0, f64::max);
assert!(worst < 1e-5 * scale, "energy drift {worst} of {scale}");
// It did swing hard: the lower link went well past vertical.
let lowest = traj.samples.iter().filter_map(|s| s.bodies.get(1)).map(|b| b.position.z).fold(0.0, f64::min);
assert!(lowest < -100.0, "{lowest}");
assert_eq!(traj.stats.redundant_equations, 0);
}
#[test]
fn constraint_drift_stays_bounded_over_long_runs() {
// Projection holds the joints to its tolerance (1e-10) however long the run.
let traj = ok(simulate(&double_pendulum(), &Settings::rk4(20.0, 1e-3, 0.1)));
assert!(traj.stats.max_constraint_error <= 1e-10, "{:?}", traj.stats);
// Without projection, Baumgarte terms keep the drift bounded too, if less tightly.
let settings = Settings { projection: false, baumgarte: Some((20.0, 20.0)), ..Settings::rk4(20.0, 1e-3, 0.1) };
let traj = ok(simulate(&double_pendulum(), &settings));
assert!(traj.stats.max_constraint_error < 1e-3, "{:?}", traj.stats);
}
#[test]
fn slider_crank_follows_its_closed_form() {
let (r, l) = (50.0, 150.0);
let theta0 = PI / 3.0;
let pin = Vec3::new(r * theta0.cos(), 0.0, -r * theta0.sin());
let s0 = r * theta0.cos() + (l * l - (r * theta0.sin()).powi(2)).sqrt();
let slide = Vec3::new(s0, 0.0, 0.0);
let u = (slide - pin) * (1.0 / l);
let mut crank = Body::at_rest(1.0, Vec3::new(100.0, 2000.0, 2000.0), pin * 0.5);
crank.orientation = Quat::from_axis_angle(Vec3::Y, theta0);
crank.angular_velocity = Vec3::Y * TAU;
crank.velocity = (Vec3::Y * TAU).cross(pin * 0.5);
let mut rod = Body::at_rest(0.5, Vec3::new(50.0, 1000.0, 1000.0), (pin + slide) * 0.5);
rod.orientation = Quat::from_axis_angle(Vec3::Y, (-u.z).atan2(u.x));
let slider = Body::at_rest(0.3, Vec3::new(50.0, 50.0, 50.0), slide);
let hinge = |a: Option<usize>, pa: Vec3, b: usize, pb: Vec3| Joint {
kind: JointKind::Revolute,
a: Marker::new(a, pa, Vec3::Y, Vec3::X),
b: Marker::new(Some(b), pb, Vec3::Y, Vec3::X),
};
let model = Model {
bodies: vec![crank, rod, slider],
joints: vec![
hinge(None, Vec3::ZERO, 0, Vec3::new(-r / 2.0, 0.0, 0.0)),
hinge(Some(0), Vec3::new(r / 2.0, 0.0, 0.0), 1, Vec3::new(-l / 2.0, 0.0, 0.0)),
hinge(Some(1), Vec3::new(l / 2.0, 0.0, 0.0), 2, Vec3::ZERO),
Joint { kind: JointKind::Slider, a: Marker::ground(Vec3::ZERO, Vec3::X, Vec3::Y), b: Marker::new(Some(2), Vec3::ZERO, Vec3::X, Vec3::Y) },
],
forces: vec![],
gravity: Vec3::ZERO,
};
let traj = ok(simulate(&model, &Settings::rk4(2.0, 1e-3, 1e-2)));
// A planar linkage built from 3D joints repeats three equations; they are solved, not refused.
assert_eq!(traj.stats.redundant_equations, 3);
let mut turned = 0.0_f64;
let mut last = theta0;
for s in &traj.samples {
let theta = angle_of(s);
let x = s.joints.get(3).and_then(|j| j.values.first()).copied().unwrap_or(f64::NAN);
let expect = r * theta.cos() + (l * l - (r * theta.sin()).powi(2)).sqrt();
assert!((x - expect).abs() < 1e-6, "t={}: slider {x} vs {expect}", s.time);
let step = (theta - last + PI).rem_euclid(TAU) - PI;
turned += step;
last = theta;
}
assert!(turned.abs() > TAU, "the crank turned {turned} rad");
assert!(traj.stats.max_constraint_error < 1e-9, "{:?}", traj.stats);
// No forces: kinetic energy is conserved.
let e0 = traj.samples.first().map(|s| s.energy.kinetic).unwrap_or(0.0);
assert!(traj.samples.iter().all(|s| (s.energy.kinetic - e0).abs() < 1e-6 * e0), "kinetic energy drifted");
}
/// A body on a slider along X, pulled toward the origin by a spring of free length 40 mm,
/// starting 10 mm stretched, with gravity across the slide.
fn spring_mass(damping: f64) -> Model {
Model {
bodies: vec![Body::at_rest(2.0, Vec3::new(500.0, 500.0, 500.0), Vec3::new(50.0, 0.0, 0.0))],
joints: vec![Joint {
kind: JointKind::Slider,
a: Marker::ground(Vec3::ZERO, Vec3::X, Vec3::Y),
b: Marker::new(Some(0), Vec3::ZERO, Vec3::X, Vec3::Y),
}],
forces: vec![Force::Spring {
a: Anchor { body: None, point: Vec3::ZERO },
b: Anchor { body: Some(0), point: Vec3::ZERO },
stiffness: 800.0,
damping,
free_length: 40.0,
preload: 0.0,
}],
gravity: down(),
}
}
#[test]
fn spring_mass_oscillates_at_its_natural_frequency() {
// ω = √(k/m) = 20 rad/s.
let traj = ok(simulate(&spring_mass(0.0), &Settings::rk4(1.0, 1e-4, 1e-3)));
for s in &traj.samples {
let x = s.bodies.first().map(|b| b.position.x).unwrap_or(f64::NAN);
let expect = 40.0 + 10.0 * (20.0 * s.time).cos();
assert!((x - expect).abs() < 1e-6, "t={}: {x} vs {expect}", s.time);
// The slide holds the body up against gravity.
let f = s.joints.first().map(|j| j.force).unwrap_or_default();
assert!((f.z - 2.0 * G).abs() < 1e-6 * G && f.x.abs() < 1e-6 * G, "reaction {f:?}");
}
let e0 = traj.samples.first().map(|s| s.energy.total()).unwrap_or(0.0);
assert!(traj.samples.iter().all(|s| (s.energy.total() - e0).abs() < 1e-8 * 800.0 * 100.0));
}
#[test]
fn spring_damper_decays_as_the_closed_form() {
// c = 8 kg/s: ζ = c / (2√(km)) = 0.1.
let (wn, zeta) = (20.0_f64, 0.1_f64);
let wd = wn * (1.0 - zeta * zeta).sqrt();
let traj = ok(simulate(&spring_mass(8.0), &Settings::rk4(1.0, 1e-4, 1e-3)));
for s in &traj.samples {
let t = s.time;
let x = s.bodies.first().map(|b| b.position.x).unwrap_or(f64::NAN);
let expect = 40.0 + (-zeta * wn * t).exp() * 10.0 * ((wd * t).cos() + zeta * wn / wd * (wd * t).sin());
assert!((x - expect).abs() < 1e-6, "t={t}: {x} vs {expect}");
}
}
#[test]
fn torque_free_body_keeps_energy_and_momentum() {
let mut body = Body::at_rest(1.0, Vec3::new(1000.0, 2000.0, 3000.0), Vec3::ZERO);
body.orientation = Quat::from_axis_angle(Vec3::new(1.0, 2.0, 3.0), 0.7);
body.angular_velocity = Vec3::new(1.0, 0.3, 5.0);
let model = Model { bodies: vec![body], joints: vec![], forces: vec![], gravity: Vec3::ZERO };
let traj = ok(simulate(&model, &Settings::rk4(2.0, 1e-4, 1e-2)));
let momentum = |s: &Sample| {
let b = s.bodies.first().copied();
b.map_or(Vec3::ZERO, |b| {
let r = b.orientation.to_mat3();
r.mul(&Mat3::diagonal(Vec3::new(1000.0, 2000.0, 3000.0))).mul(&r.transpose()).mul_vec(b.angular_velocity)
})
};
let (l0, e0) = traj.samples.first().map(|s| (momentum(s), s.energy.kinetic)).unwrap_or_default();
let mut tumbled = false;
for s in &traj.samples {
assert!(momentum(s).dist(l0) < 1e-7 * l0.len(), "t={} momentum {:?} vs {l0:?}", s.time, momentum(s));
assert!((s.energy.kinetic - e0).abs() < 1e-7 * e0);
let w = s.bodies.first().map(|b| b.angular_velocity).unwrap_or_default();
tumbled |= w.dist(Vec3::new(1.0, 0.3, 5.0)) > 0.1;
}
assert!(tumbled, "the angular velocity should precess");
}
#[test]
fn spherical_pendulum_keeps_energy_and_vertical_momentum() {
// A body on a ball joint, tilted and pushed sideways: it swings round the vertical.
let com = Vec3::new(60.0, 0.0, -80.0);
let mut body = Body::at_rest(1.5, Vec3::new(300.0, 500.0, 700.0), com);
body.velocity = Vec3::new(0.0, 400.0, 0.0);
let model = Model {
bodies: vec![body],
joints: vec![Joint {
kind: JointKind::Ball,
a: Marker::ground(Vec3::ZERO, Vec3::Z, Vec3::X),
b: Marker::new(Some(0), -com, Vec3::Z, Vec3::X),
}],
forces: vec![],
gravity: down(),
};
let traj = ok(simulate(&model, &Settings::rk4(3.0, 5e-4, 1e-2)));
let lz = |s: &Sample| {
s.bodies.first().map_or(0.0, |b| {
let r = b.orientation.to_mat3();
let iw = r.mul(&Mat3::diagonal(Vec3::new(300.0, 500.0, 700.0))).mul(&r.transpose());
(b.position.cross(b.velocity * 1.5) + iw.mul_vec(b.angular_velocity)).z
})
};
let (l0, e0) = traj.samples.first().map(|s| (lz(s), s.energy.total())).unwrap_or_default();
assert!(l0.abs() > 1e3);
for s in &traj.samples {
assert!((lz(s) - l0).abs() < 1e-6 * l0.abs(), "t={}: Lz {} vs {l0}", s.time, lz(s));
assert!((s.energy.total() - e0).abs() < 1e-6 * G * 100.0, "t={}: energy drift {}", s.time, s.energy.total() - e0);
// The joint holds the body's pivot at the origin.
let pivot = s.bodies.first().map(|b| b.position + b.orientation.rotate(-com)).unwrap_or_default();
assert!(pivot.len() < 1e-9, "{pivot:?}");
}
}
/// Every joint kind between two moving bodies and between the ground and a body: the
/// Jacobian and `J̇u` agree with finite differences of `Φ` along the motion.
#[test]
fn jacobians_match_finite_differences() {
let kinds = [JointKind::Rigid, JointKind::Revolute, JointKind::Slider, JointKind::Cylindrical, JointKind::Planar, JointKind::Ball];
let mut a = Body::at_rest(1.0, Vec3::new(1.0, 2.0, 3.0), Vec3::new(10.0, -20.0, 5.0));
a.orientation = Quat::from_axis_angle(Vec3::new(0.3, -1.0, 0.4), 0.9);
a.velocity = Vec3::new(3.0, -1.0, 2.0);
a.angular_velocity = Vec3::new(0.7, -0.4, 1.1);
let mut b = Body::at_rest(2.0, Vec3::new(3.0, 2.0, 1.0), Vec3::new(-5.0, 15.0, 30.0));
b.orientation = Quat::from_axis_angle(Vec3::new(1.0, 0.2, -0.5), -1.3);
b.velocity = Vec3::new(-2.0, 4.0, 1.0);
b.angular_velocity = Vec3::new(-0.5, 1.3, 0.2);
for kind in kinds {
for a_body in [Some(0), None] {
let joint = Joint {
kind,
a: Marker::new(a_body, Vec3::new(4.0, -3.0, 7.0), Vec3::new(0.2, 0.5, 1.0), Vec3::new(1.0, 0.0, 0.3)),
b: Marker::new(Some(1), Vec3::new(-6.0, 2.0, 1.0), Vec3::new(-0.4, 1.0, 0.1), Vec3::new(0.0, 0.3, 1.0)),
};
let model = Model { bodies: vec![a.clone(), b.clone()], joints: vec![joint], forces: vec![], gravity: Vec3::ZERO };
let sys = ok(System::new(&model, None));
let y = ok(System::initial_state(&model));
let h = 1e-4;
let (now, plus, minus) = (
testing::rows_at(&sys, &y),
testing::rows_at(&sys, &testing::drift(&sys, &y, h)),
testing::rows_at(&sys, &testing::drift(&sys, &y, -h)),
);
assert_eq!(now.len(), kind.equations());
for ((phi, ju, jdu), ((p, _, _), (m, _, _))) in now.iter().zip(plus.iter().zip(&minus)) {
let rate = (p - m) / (2.0 * h);
let accel = (p - 2.0 * phi + m) / (h * h);
assert!((rate - ju).abs() < 1e-5 * (1.0 + ju.abs()), "{kind:?} {a_body:?}: J u {ju} vs {rate}");
assert!((accel - jdu).abs() < 1e-3 * (1.0 + jdu.abs()), "{kind:?} {a_body:?}: J̇ u {jdu} vs {accel}");
}
}
}
}
#[test]
fn joint_values_read_the_relative_motion() {
// A body on each joint kind, displaced along its free directions, reads them back.
let place = |kind: JointKind, offset: Vec3, turn: f64| {
let mut body = Body::at_rest(1.0, Vec3::new(1.0, 1.0, 1.0), offset);
body.orientation = Quat::from_axis_angle(Vec3::Z, turn);
let model = Model {
bodies: vec![body],
joints: vec![Joint { kind, a: Marker::ground(Vec3::ZERO, Vec3::Z, Vec3::X), b: Marker::new(Some(0), Vec3::ZERO, Vec3::Z, Vec3::X) }],
forces: vec![],
gravity: Vec3::ZERO,
};
let traj = ok(simulate(&model, &Settings::rk4(0.01, 0.01, 0.01)));
traj.samples.first().and_then(|s| s.joints.first()).map(|j| j.values.clone()).unwrap_or_default()
};
let close = |a: &[f64], b: &[f64]| a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-9);
assert!(close(&place(JointKind::Revolute, Vec3::ZERO, 0.4), &[0.4]));
assert!(close(&place(JointKind::Slider, Vec3::new(0.0, 0.0, 12.0), 0.0), &[12.0]));
assert!(close(&place(JointKind::Cylindrical, Vec3::new(0.0, 0.0, -3.0), -1.0), &[-1.0, -3.0]));
assert!(close(&place(JointKind::Planar, Vec3::new(5.0, -7.0, 0.0), 2.0), &[5.0, -7.0, 2.0]));
assert!(close(&place(JointKind::Ball, Vec3::ZERO, 0.5), &[0.0, 0.0, 0.5]));
assert!(close(&place(JointKind::Rigid, Vec3::ZERO, 0.0), &[]));
}
#[test]
fn inconsistent_start_is_projected_onto_the_joints() {
// The pendulum's body starts 2 mm off its hinge and moving across it.
let mut model = pendulum(100.0, 0.3, 0.0, 40.0);
if let Some(b) = model.bodies.first_mut() {
b.position += Vec3::new(1.0, 2.0, -1.0);
b.velocity = Vec3::new(0.0, 50.0, 0.0);
}
let traj = ok(simulate(&model, &Settings::rk4(0.5, 1e-3, 0.05)));
assert!(traj.stats.max_constraint_error < 1e-9, "{:?}", traj.stats);
let y = traj.samples.first().and_then(|s| s.bodies.first()).map(|b| b.velocity.y).unwrap_or(f64::NAN);
assert!(y.abs() < 1e-9, "velocity along the hinge is removed: {y}");
}
#[test]
fn hostile_input_is_an_error_not_a_panic() {
let good = pendulum(100.0, 0.2, 0.0, 40.0);
let settings = Settings::rk4(0.1, 1e-3, 1e-2);
let mut cases: Vec<(Model, Settings)> = Vec::new();
let mut with = |f: &dyn Fn(&mut Model)| {
let mut m = good.clone();
f(&mut m);
cases.push((m, settings));
};
with(&|m| m.bodies.clear());
with(&|m| m.bodies.iter_mut().for_each(|b| b.mass = f64::NAN));
with(&|m| m.bodies.iter_mut().for_each(|b| b.mass = 0.0));
with(&|m| m.bodies.iter_mut().for_each(|b| b.mass = -1.0));
with(&|m| m.bodies.iter_mut().for_each(|b| b.inertia = Mat3::diagonal(Vec3::new(1.0, -1.0, 1.0))));
with(&|m| m.bodies.iter_mut().for_each(|b| b.inertia = Mat3([[1.0, 5.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]])));
with(&|m| m.bodies.iter_mut().for_each(|b| b.inertia = Mat3::ZERO));
with(&|m| m.bodies.iter_mut().for_each(|b| b.position.x = f64::INFINITY));
with(&|m| m.bodies.iter_mut().for_each(|b| b.orientation = Quat { w: 0.0, x: 0.0, y: 0.0, z: 0.0 }));
with(&|m| m.bodies.iter_mut().for_each(|b| b.velocity.z = 1e300));
with(&|m| m.gravity = Vec3::new(f64::NAN, 0.0, 0.0));
with(&|m| m.joints.iter_mut().for_each(|j| j.b.body = Some(7)));
with(&|m| m.joints.iter_mut().for_each(|j| j.a.body = j.b.body));
with(&|m| m.joints.iter_mut().for_each(|j| j.a.z = Vec3::ZERO));
with(&|m| m.joints.iter_mut().for_each(|j| j.b.x = j.b.z));
with(&|m| m.forces.push(Force::Load { body: 3, point: Vec3::ZERO, force: Vec3::X, torque: Vec3::ZERO }));
with(&|m| {
m.forces.push(Force::Spring {
a: Anchor { body: None, point: Vec3::ZERO },
b: Anchor { body: Some(0), point: Vec3::ZERO },
stiffness: -1.0,
damping: 0.0,
free_length: 0.0,
preload: 0.0,
})
});
with(&|m| {
m.forces.push(Force::Spring {
a: Anchor { body: Some(9), point: Vec3::ZERO },
b: Anchor { body: Some(0), point: Vec3::ZERO },
stiffness: 1.0,
damping: 0.0,
free_length: 0.0,
preload: 0.0,
})
});
with(&|m| m.bodies = vec![Body::at_rest(1.0, Vec3::new(1.0, 1.0, 1.0), Vec3::ZERO); MAX_BODIES + 1]);
with(&|m| {
m.joints = vec![
m.joints.first().copied().unwrap_or(Joint {
kind: JointKind::Ball,
a: Marker::ground(Vec3::ZERO, Vec3::Z, Vec3::X),
b: Marker::new(Some(0), Vec3::ZERO, Vec3::Z, Vec3::X)
});
MAX_JOINTS + 1
]
});
let bad_settings = [
Settings::rk4(0.0, 1e-3, 1e-2),
Settings::rk4(-1.0, 1e-3, 1e-2),
Settings::rk4(f64::NAN, 1e-3, 1e-2),
Settings::rk4(1.0, 0.0, 1e-2),
Settings::rk4(1.0, f64::INFINITY, 1e-2),
Settings::rk4(1.0, 1e-3, 0.0),
Settings::rk4(1e6, 1e-6, 1e-2),
Settings::rk4(1e3, 1e-3, 1e-6),
Settings { projection_tol: -1.0, ..settings },
Settings { baumgarte: Some((f64::NAN, 1.0)), ..settings },
Settings { integrator: Integrator::Adaptive { rel_tol: 1e-6, abs_tol: 1e-6, min_step: 1.0, max_step: 0.1 }, ..settings },
Settings { integrator: Integrator::Adaptive { rel_tol: 0.0, abs_tol: 1e-6, min_step: 1e-9, max_step: 0.1 }, ..settings },
];
cases.extend(bad_settings.into_iter().map(|s| (good.clone(), s)));
for (i, (m, s)) in cases.iter().enumerate() {
assert!(simulate(m, s).is_err(), "case {i} should be refused");
}
// Inputs that are valid but extreme stop with an error or finish; they never panic.
let stiff = Model {
forces: vec![Force::Spring {
a: Anchor { body: None, point: Vec3::ZERO },
b: Anchor { body: Some(0), point: Vec3::ZERO },
stiffness: 1e11,
damping: 0.0,
free_length: 0.0,
preload: 0.0,
}],
..good.clone()
};
let _ = simulate(&stiff, &Settings::rk4(0.1, 1e-2, 1e-2));
let tight = Settings { integrator: Integrator::Adaptive { rel_tol: 1e-14, abs_tol: 1e-14, min_step: 1e-3, max_step: 1e-3 }, ..settings };
let _ = simulate(&stiff, &tight);
}
#[test]
fn models_round_trip_through_json() {
let mut model = spring_mass(1.5);
model.forces.push(Force::Load { body: 0, point: Vec3::new(1.0, 0.0, 0.0), force: Vec3::new(0.0, 5.0, 0.0), torque: Vec3::ZERO });
let text = match serde_json::to_string(&model) {
Ok(t) => t,
Err(e) => panic!("{e}"),
};
let back: Model = match serde_json::from_str(&text) {
Ok(m) => m,
Err(e) => panic!("{e}"),
};
assert_eq!(back, model);
let settings: Settings = match serde_json::from_str(r#"{"duration":1.0,"output_interval":0.1,"integrator":{"type":"rk4","step":0.001}}"#) {
Ok(s) => s,
Err(e) => panic!("{e}"),
};
assert!(settings.projection && settings.validate().is_ok());
}

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