Compare commits

...

54 Commits

Author SHA1 Message Date
selune 492e2d1c17 docs(render): portable shutdown handoff and exact validation status 2026-10-31 13:09:55 +01:00
selune a09ecc2ec6 wip(render): preserve exact progressive-render source before handoff
Emergency draft source checkpoint from A077; existing authored history is retained through the real parent. No source repair or claimed authorship reassignment. Exact C28 source binding d38a: 28 PASS / 1 font-fixture FAIL / 954 UNRUN; three postchecks UNRUN. Native acceptance/full CI/readiness incomplete. Separate unadopted fixture/compatibility proposals remain separate; see preservation notes.
2026-10-31 13:04:20 +01:00
lillith a077906234 feat(render): add bounded CPU Interactive request lifecycle 2026-10-29 20:18:50 +01:00
lillith b2733d607a claim: #8936 2026-10-29 18:16:13 +01:00
github-actions[bot] 4aa2d6248e Regenerate generated files and fold pending files [skip ci] 2026-10-29 16:45:50 +00:00
github-actions[bot] bd4a234461 Regenerate generated files and fold pending files [skip ci] 2026-10-29 16:36:59 +00:00
Frank 162b3cd4ed Sketch Intersect: add Faces/Bodies and Projection Link options (#1110) (#7989)
* Sketch Intersect: add Faces/Bodies and Projection Link options (#1110)

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

---------

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

* Timeline: joints in feature folders (#8306)

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

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

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

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

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

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

---------

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

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

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

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

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

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

---------

Co-authored-by: Phanoob <phanoob@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 15:28:34 +00:00
raykholo 8a888ff0f3 Preferences: mouse-wheel zoom speed (#4176) (#4179)
* claim: #4176

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

---------

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

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

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

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

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

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

---------

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

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

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

Scroll keeps its usual meaning until a key is chosen in Navigation.

* Trackpad zoom modifier tests: Off is the default; tests that zoom choose Cmd (#8322)

* Trackpad zoom modifier: zoom once alongside the Ctrl+wheel pinch from #8539 (#8322)

A wheel the chosen key claims is no longer also read as a pinch, and a key that is not
chosen keeps main's pinch handling unchanged.
2026-10-29 14:49:19 +00:00
Colin Faulkingham 443e5cd59d Feature editor: allow visibility changes while editing (#8810) (#8827)
* claim: #8810

* fix(engine): preserve feature editors across visibility changes (#8810)
2026-10-29 14:26:24 +00:00
github-actions[bot] b4799575fd Regenerate generated files and fold pending files [skip ci] 2026-10-29 14:58:12 +00:00
Charlie 5a0abb8710 feat(align): add Components mode to Align for occurrence placement (#7224)
Align gets an Object Type row (Bodies | Components). In Components mode it runs the new
occurrence.align command, which moves only the chosen occurrence placements (their
transforms) instead of adding an Align feature to the shared component, so other instances
stay where they are.

- occurrence.align: from/to points, snaps, planar faces and planes in world coordinates,
  flip, capture/pending, grounded and rigid-group refusals as occurrence.move.
- A viewport or browser-body pick takes the placement directly under the active component.
- A placement inside a subassembly placed more than once still moves (as occurrence.move
  does) but the dialog warns, and the reply lists it under shared.
2026-10-29 14:19:05 +00:00
Andrzej Błasik cb05e1e0f7 GPU path tracer: run on Metal on macOS (#7183) (#7185)
* claim: #7183

* GPU path tracer runs on Metal on macOS, not only Vulkan (#3217)
2026-10-29 13:58:49 +00:00
jeromehzl 6fdd58dec6 Toasts: the corner stack keeps its full width just left of a pane at the right edge (#8804) (#8840)
corner_free looked only in a fixed column at the view's right edge and took
the largest free rect there by area. Next to a tall dialog docked at the
right edge, or the Assistant in the Floating layout, the narrow strip beside
the pane won: the toasts were squeezed into it, their text broke inside
words, and with a wider pane they were not shown at all.

It now tries that column first and then columns ending at each obstacle's
left edge, nearest the corner first, and takes the first free rect that is
TOAST_WIDTH wide and tall enough for a toast, as the frame-rate readout
moves beside a right pane (#6755); failing that, a rect tall enough beats a
wider one too short to show any toast. Without a pane the stack stays
exactly where it was.
2026-10-29 13:58:16 +00:00
phanoob 5b0a86a5b2 Graphics: a background colour of your choice for the 3D view (#7688) (#8815)
* claim: #7688

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

* Graphics: a background colour of your choice for the 3D view (#7688)

Preferences › Interface › Theme gets a Background row: the theme's
gradient (the default, unchanged) or one colour for the whole application,
filling the view in either theme. The window keeps its own colours, a
picture of the view (`ui.render`) takes the colour too, and `ui.prefs`
sets it as `viewport_background` ([r, g, b] or null).

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

---------

Co-authored-by: Phanoob <phanoob@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 13:58:13 +00:00
cronos 28e26209f3 Assembly Move: drag allowed joint freedoms (#6679) (#6904)
* feat: add joint-aware assembly Move handles and live motion readouts

* test: improve render job failure diagnostics

* fix: preserve assembly Move compatibility and viewport theme colors

* ci: skip default label sync for fork PRs

* fix: keep Windows CI checks portable after main rebase

* test: cover RTL assembly strings and stable refraction job

* i18n: cover assembly Move in Vietnamese

* i18n: cover assembly Move in French

* fix: complete CGA palette for assembly motion handles

* fix: complete Assembly Move palettes and Turkish strings

* fix: retain assembly move cache answers and toolbar help

* style: format merged assembly Move navigation guard

* fix: align Assembly Move preferences and tests with main

* Fix offline update fixture synchronization and clipped prism intersections

Wait for all v5 manifest requests before deploying v6. Intersect hole-free prism sections over their common height, retaining the established B-rep path for newly clipped perforated sections. Preserve the failing property seed and add closed-form and cap-boundary regressions.
2026-10-29 13:57:31 +00:00
github-actions[bot] 986de7ec9a Regenerate generated files and fold pending files [skip ci] 2026-10-29 14:25:59 +00:00
github-actions[bot] 7c7c0a57bd Regenerate generated files and fold pending files [skip ci] 2026-10-29 14:16:56 +00:00
tr1pp4 54b07d3e0b History in the browser: mark features that failed or built with a warning (#8592) (#8593)
* claim: #8592

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

* History: error and warning marks in the browser and on folded folders (#8592)

In the History "In browser" layout, a feature whose build failed gets the
error icon at the end of its row, one that built with a warning the
warning icon, with the message in the tooltip: the marks and text of the
History on the left. A folded History or Features folder carries the
worst mark of what it holds, and so does a folded folder in the History
on the left. Suppressed and rolled-back features are not built and get
none.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 13:51:20 +00:00
P'tite Tête 7dc341ae55 fix: Remove two per-frame quadratic lookups on large designs (#8843)
- active_body: take every body's owner in one pass (model_body_components) instead of searching the timeline body by body
- Timeline strip: find each chip's result in a table built once per frame (result_index) instead of a linear search per chip
- 4,880-body design: UI frame 31-32 ms -> 7 ms of CPU; tests pin the active body and the result lookup

Co-authored-by: P'tite Tête <asso@ptitetete.com>
2026-10-29 13:49:59 +00:00
Ryan 5e0e168ae9 Properties: moment of inertia, principal axes and first moments of a part or assembly (#8095) (#8103)
* claim: #8095

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

* Properties: moment of inertia, principal axes and first moments of a part or assembly (#8095)

component.properties and occurrence.properties report each closed body's
inertia tensor about its centre of mass, and in total the tensor about the
total centre of mass (world axes, g·mm²), its principal moments and axes,
the first moments, and with `about: [x, y, z]` the tensor about any point.
The tensor comes from each body's welded export mesh, tessellated once per
body, scaled to the measured mass and turned by each placement; bodies add
by parallel axes. The maths is a new crates/geom inertia module.

The Properties window shows the first moments, the tensor, the principal
moments and axes, and the view marks the centre of mass with the principal
axes while it is open. Inspect → Measure → Properties now opens that window
(it ran the command and showed nothing).

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

* Properties: Copy matrix, and the whole design's card titled just Properties (#8095)

Copy matrix, under the tensor, puts it on the clipboard as three lines of
tab-separated values at full precision, in the units shown. The card for
the whole design is titled Properties; one opened on a component,
placement or bodies keeps its name.

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

* Properties: measure beside the window, so a large assembly doesn't freeze it (#8095)

Properties ran on the window thread: 18 s on a 144-body design, with the
window answering nothing meanwhile (#5203). In the desktop app the card now
measures on a copy of the session on a worker, the way #4614 made agents'
measures work, says "Measuring…" until the reply is in, and Close drops it.
A quick measure still shows at once; headless, tests and the web measure in
place.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 13:47:44 +00:00
bherbruck be8bd43a18 CI: wasm compile in the merge queue, dependency advisories on dependency changes (#8652)
The wasm compile ran only by hand, so #8227 (a native-only DroppedFile::bytes() call in
crates/ui-egui) passed PR CI and the merge queue and broke the web build on main (#8534).
A merge group that touches the crates below the apps, the web app, the vendored crates, the
manifests, the toolchain or this workflow now runs `cargo xtask wasm`, and `ci` requires it.

Dependency advisories ran only nightly. A pull request or merge group that changes Cargo.lock
or a Cargo.toml now runs `cargo deny --locked check --warn unmaintained advisories`, and `ci`
requires it then. The nightly keeps the full advisories, bans and sources scan.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 13:39:48 +00:00
raykholo 884c47496b Render preview: geometry budget from the device, readable refusals (#4127) (#4154)
* claim: #4127

* Render preview: geometry budget from the device, readable refusals (#4127)

The Render preview refused every scene over a fixed 128 MiB of geometry. On
desktop the budget now follows the device's buffer limit (eight of its
largest buffers, at most 2 GiB; 2 GiB with the usual 256 MiB buffers). The
web keeps 128 MiB.

The product upload runs under its own GPU error scopes: an allocation the
device refuses is reported in the preview instead of reaching the device's
uncaptured-error handler, and the half-uploaded batches are dropped. Buffer
uploads map the buffer themselves so a refused buffer fails the upload
rather than panicking in create_buffer_init.

Refusals name the geometry size and the limit and say how to get under it,
on a theme panel card that stays readable on any preview background.

* Render preview: keep the previous geometry budget behind a preference (#4127)

* Render preview: adapt the device budget to main's GPU upload errors (#4127)
2026-10-29 13:33:39 +00:00
Karl Von Richter b2d727f4b2 Toolbar: choose visible tabs while preserving all defaults (#5717) (#8594)
* claim: #5717

* ui(feat): let users choose visible toolbar tabs

Keep Sheet Metal and Weldments visible by default while adding persisted tab visibility controls and restoring specialty tabs for designs that use them.

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

* ui(fix): respect hidden toolbar tabs in every design

Remove feature-based visibility overrides for Sheet Metal and Weldments. Keep all tabs visible by default and preserve the user choice across feature creation, opening designs, selection and undo.

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

---------

Signed-off-by: Karl Von Richter<karl@embeint.com>
2026-10-29 13:32:21 +00:00
bherbruck 4f5ed7bfa6 CI: open an issue when the nightly fails, close it when it passes (#8651)
The nightly failed every day from 2026-10-16 to 2026-10-28 without anyone noticing. A new
nightly-alert job (scheduled runs only, the only job with issues: write) opens or comments on
one issue, "Nightly CI is failing" (label ci), when a nightly job fails, and closes it when a
nightly passes.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 13:31:29 +00:00
raykholo f03d0a20bd Update now installs the newest release, checked when pressed (#7724) (#7725)
* claim: #7724

* Update now installs the newest release, checked when pressed (#7724)

* Update: close the unmanaged-install test after the rebase (#7724)

* Update: the refresh messages in Arabic, Hebrew and Vietnamese too (#7724)
2026-10-29 13:29:43 +00:00
SilentWolf75 c6c91e6f43 Parameters: show full names in the table and formula popup (#8533) (#8551)
* claim: #8533

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

* Parameters: show full names in the table and formula popup.

Hover tips carry the full identifier when Name cells or completion rows clip, and the completion list widens for long suggestions (#8533).

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-29 13:25:57 +00:00
github-actions[bot] 2f56d77e90 Regenerate generated files and fold pending files [skip ci] 2026-10-29 13:45:31 +00:00
Dylan M. Taylor 68b53a4c77 Release: Arch Linux packages for x86_64 and aarch64 (#8576)
A new arch job repackages each release tarball as
solidcraft-<version>-linux-<arch>.pkg.tar.zst with makepkg, through the Craft apps' shared
action (storytold/craft-common-actions/actions/arch-package, pinned by commit). The package
is io.tangencylabs.solidcraft, named after the tarball's .desktop file, provides solidcraft
and conflicts with solidcraft-bin/-git. Each package also gets a version-less copy, and
docs/downloads.md links them.
2026-10-29 13:17:34 +00:00
phanoob 2235389344 Navigation bar: can sit at the top of the view (#8183) (#8793)
* claim: #8183

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

* Navigation bar: can sit at the top of the view (#8183)

Preferences > Navigation bar gets an Edge choice, Bottom (default) or Top.
On top the bar sits under the ribbon, its menus open downwards and its
tooltips show below it; the OK/Cancel pair and the axis view move down
below it when they would overlap.

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

* Navigation bar: the edge's labels in the complete catalogs (#8183)

ar, he, ja, vi and zh carry every English message; the Edge row's three
labels were missing from them, so `xtask i18n --check` and
`every_complete_catalog_carries_every_english_message` failed.

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

---------

Co-authored-by: Phanoob <phanoob@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 13:16:51 +00:00
jeromehzl 9509348320 Viewport: editing a material's colour redraws the bodies that use it (#8764) (#8814)
body_appearance_key hashed only the name of a body's assigned material, so
after material.edit changed that material's colour (or an undo or redo
changed it back) the key stayed the same and the viewport kept the cached
old look. The key now also hashes the material's resolved look; with an
unchanged material the cache is reused as before.
2026-10-29 13:14:15 +00:00
Colin Faulkingham fd8ddc23b7 Pattern on Path: reopen saved patterns in the editor (#8784) (#8805)
* claim: #8784

* fix(ui): restore saved path pattern editor
2026-10-29 12:49:17 +00:00
Joseph 07d692ee0d Keyboard Shortcuts: several keys for one command (#6273) (#6288)
* Keyboard shortcuts: a command can have several keys

Click a key on the Keyboard Shortcuts page to replace it, or the + after it
to add another; every key runs the command and menus show the first. One
key is still saved as a string, several as a list. Taking a key over from
another command leaves that command its other keys. ui.shortcut takes a
slot and reports every key.

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

* Keyboard shortcuts: follow main's search layout; fix prefs test for key lists

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-29 12:32:19 +00:00
github-actions[bot] 00f9243c0d Regenerate generated files and fold pending files [skip ci] 2026-10-29 12:29:52 +00:00
ABDULLAH 1e1be6934f Arabic: use the Mouse mapping term in the Right drag orbits help (#8803)
nav.right_drag_orbits.help now names the setting as prefs.mouse_mapping does (تعيين أزرار الفأرة).
2026-10-29 11:58:59 +00:00
semmelrinde 6a183cce02 Sketch corners: a corner cut is refused where two lines lie in line, as rounding is (#8802)
Chamfer at a corner of two lines lying on each other cut both back and
added a line of no length between their ends; lines in line with each
other have no corner either. chamfer now refuses a corner angle of 0 or
180 degrees with Fillet's message, "the lines are parallel" (found by a
local trim fuzzer).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 11:49:31 +00:00
jeromehzl dd6a91533d Standard parts: Resize Part says what it works on, Parts opens Insert Part (#8735) (#8801)
* Resize Part: say what it works on instead of a parameter error; Parts opens the library (#8735)

Resize Part (parts.resize) started with no standard part selected, as the
ribbon starts it, ran the command with no parameters and showed
"`component` must be an id or a name". It now says to select an inserted
standard part, and that Scale resizes a body. Parts (parts.library) only
listed the library, so its button did nothing: it opens the Insert Part
dialog. The German and Dutch button labels said "scale" ("Teil skalieren",
"Onderdeel schalen"), like the Scale command: they now say change size.

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

* Resize Part: the German label matches the command, and the test checks that nothing ran (#8735)

The German ribbon label reads "Bauteilgröße ändern", the name the command,
its dialog and its messages already use. The hint for a missing standard
part is now one function shared by the ribbon start and the Browser's
Change Part Size. The regression test also checks that the design and its
history stay as they were and no message carries the command's own error,
that a scripted parts.library still only lists the library, and that with a
standard part picked Change Part Size opens on it.

---------

Co-authored-by: semmelrinde <124386289+semmelrinde@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 11:49:25 +00:00
Christian Hammacher 93d0298d25 Browser: Turn Up Axis on a placement row asks that placement whether it can move (#8786) (#8788)
A placement row's menu starts from its component's menu, which takes
Turn Up Axis's enabled state from the component's first placement.
Move/Copy was already set again for the placement right-clicked; Turn Up
Axis and its two turns now are too, so a free second placement can turn
while the first is grounded, and a grounded one can't.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 11:28:19 +00:00
semmelrinde cf86f18c11 Sketch: an end held on a removed curve where another curve ends stays tied (#8779)
A trimmed circle ended on a line exactly where an arc started on that
line, held there by a point-on-curve tie. Trimming the line away dropped
the tie and left the two ends on one spot, untied. remove_curves now ties
each point held on a removed curve at one of its ends to that end before
the curve goes (found by a local trim fuzzer, 15 -> 12 cases in 2000
seeds).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 11:07:28 +00:00
github-actions[bot] 63f053e4fe Regenerate generated files and fold pending files [skip ci] 2026-10-29 11:06:21 +00:00
Colin Faulkingham 81039a0e10 Pinch zoom: route Ctrl+wheel input to the camera (#8480) (#8539)
* claim: #8480

* Pinch to zoom on touchpad: native Ctrl+wheel pinch zooms the view (#8480)

The viewport's pinch path read only `egui::Event::Zoom`; `immediate_wheel` leaves
wheel events carrying the zoom modifier to egui. Native Linux and Windows
touchpads report a pinch as Ctrl+wheel in points, so the gesture reached neither
path and the view did not move. `pinch_factor` now reads those wheels too, in
every wheel unit and scaled as egui scales them, and `ui.scroll` can send the
same gesture (`ctrl: true`) so scripts and scenarios can replay a pinch.

* fix: finish Ctrl+wheel pinch handling and replay validation (#8480)
2026-10-29 10:40:10 +00:00
Lars Jansen 375dc01602 Navigation: Right drag orbits option; picking the SolidWorks preset turns it off (#6492) (#6497)
* claim: #6492

* feat(navigation): Right drag orbits option, off when picking the SolidWorks preset, which also fits on middle double-click (#6492)
2026-10-29 10:24:37 +00:00
PaddleStroke fe3f913d76 Dialogs: disable Apply until something changed (#8561) (#8728)
* claim: #8561

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

* Dialogs: disable Apply until something changed (#8561)

The dialog footer gets a disabled state for Apply (Footer::nothing_to_apply,
Window::apply_enabled): drawn with the theme's dim ink, not clickable, not
reached by Tab and reported disabled to accessibility. Preferences enables
Apply only while something differs from its snapshot (what Cancel would put
back), so it is disabled when the dialog opens and again after Apply.
ui.prefs replies with apply_enabled.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 10:14:45 +00:00
289 changed files with 212061 additions and 147107 deletions
+110 -6
View File
@@ -15,7 +15,9 @@
# nightly. The reference tests and the doc tests run in the merge queue, on main and nightly, not
# on pull requests (the fast lane, in Select checks); a break there is fixed forward on main. The
# Windows jobs (D3D12 shaders and app startup) run nightly, and in the merge queue only for a group
# that changes what they exercise. Only main saves the build caches; pull requests restore them.
# that changes what they exercise. The wasm compile check runs in the merge queue for a group that
# touches the crates below the apps, the web app or the manifests. Dependency advisories run nightly
# and on any pull request or merge group that changes Cargo.lock or a Cargo.toml. Only main saves the build caches; pull requests restore them.
# sccache is on a Blacksmith sticky disk that only main commits.
# A push to main runs only the repository checks when a passing merge-queue run tested that very
# commit, or when nothing a build reads changed since the last push to main whose run passed.
@@ -78,8 +80,13 @@ env:
jobs:
dependency-advisories:
# Nightly and by hand: advisories, bans and sources for everything. A pull request or merge
# group that changes Cargo.lock or a Cargo.toml (Select checks' `deps`) checks the advisories
# only, so a dependency with a known vulnerability is caught before it lands, not the next
# morning; other pull requests skip it. `ci` requires it whenever `deps` is true.
name: Dependency advisories
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
needs: changes
if: ${{ !cancelled() && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || needs.changes.outputs.deps == 'true') }}
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 20
steps:
@@ -88,7 +95,12 @@ jobs:
- name: Install advisory scanner
run: cargo install cargo-deny --version 0.20.2 --locked
- name: Check locked dependencies
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
run: cargo deny --locked check --warn unmaintained advisories bans sources
# deny.toml denies unmaintained crates; like the nightly, they are reported, not fatal.
- name: Check advisories for the changed dependencies
if: github.event_name != 'schedule' && github.event_name != 'workflow_dispatch'
run: cargo deny --locked check --warn unmaintained advisories
changes:
name: Select checks
@@ -115,6 +127,10 @@ jobs:
packages: ${{ steps.paths.outputs.packages }}
oracle: ${{ steps.paths.outputs.oracle }}
doctests: ${{ steps.paths.outputs.doctests }}
# Cargo.lock or a Cargo.toml changed (pull requests and merge groups): Dependency advisories.
deps: ${{ steps.paths.outputs.deps }}
# A merge group reaches the wasm build: Compile WebAssembly.
wasm: ${{ steps.paths.outputs.wasm }}
# The passing run a merge of main carries forward (its URL), when this push is only that.
carried: ${{ steps.paths.outputs.carried }}
steps:
@@ -143,6 +159,8 @@ jobs:
goldens=false
windows=false
gpu_paths=false
deps=false
wasm=false
full=true
packages=
# Whether the NUL-separated paths in $1 reach any code: scripts/ci/affected.py selects
@@ -183,6 +201,21 @@ jobs:
esac
done < "$1"
}
# Whether the NUL-separated paths in $1 change the dependencies (Cargo.lock or any
# Cargo.toml: deps), and whether they reach what `cargo xtask wasm` compiles (wasm): the
# crates below the apps, the web app, the vendored crates, the manifests and lockfile, the
# toolchain and cargo config, and this workflow. #8227 put a native-only call in
# crates/ui-egui and the merge queue passed it, because nothing built for wasm there.
deps_wasm_paths() {
while IFS= read -r -d '' path; do
case "$path" in
Cargo.lock|Cargo.toml|*/Cargo.toml) deps=true ;;
esac
case "$path" in
crates/*|apps/solidcraft-web/*|vendor/*|Cargo.lock|Cargo.toml|rust-toolchain*|.cargo/*|.github/workflows/ci.yml) wasm=true ;;
esac
done < "$1"
}
# Opt-in for a big render change: a pull request labelled render-tests, or a merge group
# holding one (its pull requests are the (#N) of its squash commits), runs them whatever
# the paths say.
@@ -228,14 +261,17 @@ jobs:
if git diff --no-renames --name-only -z "$GROUP_BASE_SHA...$GROUP_HEAD_SHA" > "$RUNNER_TEMP/ci-paths"; then
windows_paths "$RUNNER_TEMP/ci-paths" "$GROUP_BASE_SHA...$GROUP_HEAD_SHA"
gpu_paths "$RUNNER_TEMP/ci-paths"
deps_wasm_paths "$RUNNER_TEMP/ci-paths"
if [ "$gpu_paths" = false ] && group_has_render_label "$GROUP_BASE_SHA..$GROUP_HEAD_SHA"; then
gpu_paths=true
fi
else
windows=true
gpu_paths=true
deps=true
wasm=true
fi
echo "windows=$windows gpu_paths=$gpu_paths for $GROUP_BASE_SHA...$GROUP_HEAD_SHA"
echo "windows=$windows gpu_paths=$gpu_paths deps=$deps wasm=$wasm for $GROUP_BASE_SHA...$GROUP_HEAD_SHA"
elif [ "$EVENT_NAME" != pull_request ]; then
# The nightly run and Run workflow: everything, Windows included.
native=true
@@ -256,6 +292,10 @@ jobs:
done < "$RUNNER_TEMP/ci-paths"
# Computed as for a merge group, then turned off below (the fast lane).
windows_paths "$RUNNER_TEMP/ci-paths" "$BASE_SHA...$HEAD_SHA"
# The advisories run on the pull request itself (it builds nothing of ours: about two minutes, most of it installing cargo-deny);
# the wasm compile waits for the merge queue.
deps_wasm_paths "$RUNNER_TEMP/ci-paths"
wasm=false
# Pull requests build and test the touched packages and their dependents; nothing
# selected means no code changed.
select_packages "$RUNNER_TEMP/ci-paths"
@@ -327,6 +367,8 @@ jobs:
echo "windows=$windows"
echo "oracle=$oracle"
echo "doctests=$doctests"
echo "deps=$deps"
echo "wasm=$wasm"
echo "carried=$carried"
echo "full=$full"
echo "quick=$quick"
@@ -946,7 +988,7 @@ jobs:
# commit's real run instead: the merge queue reads `ci` from the newest run of a commit, so a
# run without a passing `ci` would block a pull request that passed (#1850, #3182, #3252).
name: ci
needs: [changes, checks, quick-compile, clippy, build-tests, tests, gpu-path-tests, doc-tests, oracle, windows-fxc, windows-startup]
needs: [changes, checks, quick-compile, clippy, build-tests, tests, gpu-path-tests, doc-tests, oracle, windows-fxc, windows-startup, dependency-advisories, wasm]
if: always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft)
runs-on: blacksmith-2vcpu-ubuntu-2404
permissions:
@@ -1000,6 +1042,10 @@ jobs:
ORACLE: ${{ needs.oracle.result }}
WINDOWS_FXC: ${{ needs.windows-fxc.result }}
WINDOWS_STARTUP: ${{ needs.windows-startup.result }}
DEPS_RUN: ${{ needs.changes.outputs.deps }}
ADVISORIES: ${{ needs.dependency-advisories.result }}
WASM_RUN: ${{ needs.changes.outputs.wasm }}
WASM: ${{ needs.wasm.result }}
run: |
test "$CHANGES" = success
test "$CHECKS" = success
@@ -1029,6 +1075,14 @@ jobs:
test "$WINDOWS_FXC" = success
test "$WINDOWS_STARTUP" = success
fi
# Cargo.lock or a Cargo.toml changed on a pull request or in a merge group.
if [ "$DEPS_RUN" = true ]; then
test "$ADVISORIES" = success
fi
# A merge group that reaches the wasm build.
if [ "$WASM_RUN" = true ]; then
test "$WASM" = success
fi
- name: Note a carried result
if: needs.changes.outputs.carried != ''
env:
@@ -1036,9 +1090,13 @@ jobs:
run: |
echo "This push only merges main into a change that already passed; the heavy checks were not repeated. Passing run: $CARRIED" >> "$GITHUB_STEP_SUMMARY"
# Every crate below the apps and the web app must build for the browser (`cargo xtask wasm`, a
# cargo check, no tests). In the merge queue for a group that reaches the wasm build (Select
# checks' `wasm`), where `ci` requires it; by hand with cross_compile.
wasm:
name: Compile WebAssembly (manual)
if: github.event_name == 'workflow_dispatch' && inputs.cross_compile
name: Compile WebAssembly
needs: changes
if: ${{ !cancelled() && ((github.event_name == 'workflow_dispatch' && inputs.cross_compile) || needs.changes.outputs.wasm == 'true') }}
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 30
steps:
@@ -1099,3 +1157,49 @@ jobs:
. "$HOME/.cargo/env"
export CRAFT_FONTS_DIR="$PWD/craft-fonts"
cargo check --all-targets --locked --target riscv64gc-unknown-linux-gnu
# Nobody watches the nightly run, and it failed every day from 2026-10-16 to 2026-10-28 before
# anyone noticed. When a nightly fails, open one issue, "Nightly CI is failing" (label `ci`), or
# comment on it if it is already open; when a nightly passes, close it. A nightly that neither
# failed nor fully passed (cancelled) leaves the issue as it is. The only job that may write issues.
nightly-alert:
name: Nightly alert
needs: [dependency-advisories, changes, checks, ci, build-tests, action-matrix, action-matrix-determinism, property-tests]
if: always() && github.event_name == 'schedule'
runs-on: blacksmith-2vcpu-ubuntu-2404
timeout-minutes: 5
permissions:
issues: write
steps:
- name: Open, update or close the nightly issue
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
RESULTS: ${{ toJSON(needs) }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
SHA: ${{ github.sha }}
run: |
title="Nightly CI is failing"
failed=$(jq -r 'to_entries[] | select(.value.result == "failure") | .key' <<<"$RESULTS")
unfinished=$(jq -r 'to_entries[] | select(.value.result != "success" and .value.result != "failure") | "\(.key) (\(.value.result))"' <<<"$RESULTS")
issue=$(gh issue list --state open --label ci --limit 100 --json number,title \
--jq "[.[] | select(.title == \"$title\")] | first | .number // empty")
if [ -n "$failed" ]; then
body="The nightly run on ${SHA:0:9} failed: $RUN_URL
Failed jobs: $(paste -sd , - <<<"$failed" | sed 's/,/, /g')."
if [ -n "$issue" ]; then
gh issue comment "$issue" --body "$body"
else
gh issue create --title "$title" --label ci --body "$body
This issue is opened and updated by the nightly run (the nightly-alert job in .github/workflows/ci.yml) and closed by the first nightly that passes."
fi
elif [ -z "$unfinished" ]; then
if [ -n "$issue" ]; then
gh issue close "$issue" --comment "The nightly run on ${SHA:0:9} passed: $RUN_URL"
fi
echo "The nightly passed."
else
echo "Nothing failed, but not every job finished: $unfinished. The issue is left as it is."
fi
+1
View File
@@ -52,6 +52,7 @@ jobs:
# effort: a fork PR just doesn't get the `changes-default` label from here.
label:
name: Label changes-default
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-24.04
timeout-minutes: 5
continue-on-error: true
+42 -2
View File
@@ -10,6 +10,7 @@
# (see the windows job). Nothing else is signed yet.
#
# Linux x86_64, aarch64 AppImage (+ .zsync), .deb, .rpm, .flatpak, .snap, .tar.gz packaging/linux
# Arch Linux x86_64, aarch64 .pkg.tar.zst, repackaged from the .tar.gz the arch job
# FreeBSD x86_64 .tar.gz (built in a FreeBSD VM) packaging/freebsd
# macOS universal .dmg, and the CLI as a .zip (both archs built in parallel) packaging/macos
# Windows x64, x86, arm64 .msi and a portable .zip; x64 also the plain .exe packaging/windows
@@ -206,6 +207,45 @@ jobs:
path: dist/release/*.flatpak
if-no-files-found: error
arch:
name: Arch Linux ${{ matrix.arch }}
needs: [version, linux]
if: needs.version.outputs.build == 'true'
# makepkg only repackages the tarball's prebuilt binaries, so both architectures build here.
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
arch: [x86_64, aarch64]
env:
VERSION: ${{ needs.version.outputs.version }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: release-linux-${{ matrix.arch }}
path: dist/linux
# The Craft apps' shared Arch packaging (storytold/craft-common-actions, MIT OR Apache-2.0):
# makepkg in an archlinux:base-devel container. The package is named after the tarball's
# .desktop file (io.tangencylabs.solidcraft) and written as
# dist/release/solidcraft-<version>-linux-<arch>.pkg.tar.zst.
- uses: storytold/craft-common-actions/actions/arch-package@fa5ef18780cf5e7d5d3b8de068de56cffc83cac5 # main
with:
tarball: dist/linux/solidcraft-${{ env.VERSION }}-linux-${{ matrix.arch }}.tar.gz
arch: ${{ matrix.arch }}
- name: Check the package
env:
PKG: dist/release/solidcraft-${{ env.VERSION }}-linux-${{ matrix.arch }}.pkg.tar.zst
run: |
tar --zstd -tf "$PKG" | grep -E '^usr/bin/solidcraft$|^usr/bin/solidcraft-cli$|^usr/share/applications/io\.tangencylabs\.solidcraft\.desktop$'
tar --zstd -xOf "$PKG" .PKGINFO | grep -E '^(pkgname|pkgver|arch) = '
tar --zstd -xOf "$PKG" .PKGINFO | grep -qx 'pkgname = io.tangencylabs.solidcraft'
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-arch-${{ matrix.arch }}
path: dist/release/*.pkg.tar.zst
if-no-files-found: error
snap:
@@ -571,7 +611,7 @@ jobs:
release:
name: GitHub Release or dry-run artifacts
needs: [version, linux, flatpak, snap, freebsd, macos, windows, web]
needs: [version, linux, flatpak, arch, snap, freebsd, macos, windows, web]
if: needs.version.outputs.build == 'true'
runs-on: ubuntu-latest
permissions:
@@ -597,7 +637,7 @@ jobs:
shopt -s nullglob
for f in "solidcraft-$VERSION-"*; do
case "$f" in
*.msi | *-portable.zip | *.dmg | *.AppImage | *.deb | *.rpm | *.flatpak | *.snap)
*.msi | *-portable.zip | *.dmg | *.AppImage | *.deb | *.rpm | *.flatpak | *.snap | *.pkg.tar.zst)
cp -- "$f" "solidcraft-${f#"solidcraft-$VERSION-"}" ;;
esac
done
+2 -1
View File
@@ -117,7 +117,8 @@ The [architecture page](docs/architecture.md) has the layer table. The short ver
not.) Permissive
dependencies are fine. Note which one you're adding and why in the PR. THIRD-PARTY-LICENSES.txt
lists it once the change lands: the Generated files workflow rewrites it on `main`.
Nightly/manual CI also scans advisories, bans and sources with cargo-deny; see
Nightly/manual CI also scans advisories, bans and sources with cargo-deny, and a PR that
changes Cargo.lock or a Cargo.toml checks the advisories; see
[the local command and coverage limits](docs/book/src/dev/testing.md#dependency-advisories).
3. **Every asset is original or openly licensed and has a row in
[ATTRIBUTION.md](ATTRIBUTION.md).** Icons are drawn in code (`crates/ui-egui/src/icons.rs`).
@@ -87,7 +87,7 @@ fn run() -> Result<(), String> {
let gpu_host_start = Instant::now();
let policy = solidcraft_gpu::select::Policy::from_env().map_err(|e| e.to_string())?;
let (adapter, report) =
solidcraft_gpu::select::pick_vulkan(policy, |_| true).ok_or("no Vulkan adapter satisfies the requested GPU policy")?;
solidcraft_gpu::select::pick_path(policy, |_| true).ok_or("no Vulkan or Metal adapter satisfies the requested GPU policy")?;
session.register_render_job_host(solidcraft_gpu::path::host_on_adapter(&adapter).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
(Some(report), Some(gpu_host_start.elapsed().as_secs_f64()))
} else {
+14 -13
View File
@@ -24,9 +24,9 @@ const MAX_BAD_LINES: usize = 8;
const EVENT_GAP: Duration = Duration::from_millis(100);
static OPEN: AtomicUsize = AtomicUsize::new(0);
/// The token files this launch wrote (its control port, the agent chat's endpoint), removed
/// when the app ends.
static TOKEN_FILES: std::sync::Mutex<Vec<std::path::PathBuf>> = std::sync::Mutex::new(Vec::new());
/// The token files this launch wrote (its control port, the agent chat's endpoint) and the
/// token in each, removed when the app ends unless another launch has written the file since.
static TOKEN_FILES: std::sync::Mutex<Vec<(std::path::PathBuf, String)>> = std::sync::Mutex::new(Vec::new());
/// A new random token: 32 bytes from the operating system, as hex.
fn new_token() -> Option<String> {
@@ -35,20 +35,21 @@ fn new_token() -> Option<String> {
Some(bytes.iter().map(|b| format!("{b:02x}")).collect())
}
/// Remove this launch's token file (at exit).
/// Remove this launch's token files and discovery file (at exit), leaving any that another
/// launch has written since (#8794).
pub fn remove_token_file() {
if let Ok(files) = TOKEN_FILES.lock() {
for p in files.iter() {
control_auth::remove_token_file(p);
for (path, token) in files.iter() {
control_auth::remove_token_file(path, token);
}
}
if let Some(port) = APP_PORT.lock().ok().and_then(|p| *p) {
control_auth::remove_current_port(port);
if let Some((port, token)) = APP_PORT.lock().ok().and_then(|p| p.clone()) {
control_auth::remove_current_port(port, &token);
}
}
/// The port the app's own channel listens on, for removing the discovery file at exit.
static APP_PORT: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
/// The port the app's own channel listens on and its token, for removing the discovery file at exit.
static APP_PORT: std::sync::Mutex<Option<(u16, String)>> = std::sync::Mutex::new(None);
pub fn start(listen_on: control_auth::Listen, ctx: egui::Context) -> Receiver<ControlRequest> {
let (tx, rx) = channel::<ControlRequest>();
@@ -61,7 +62,7 @@ pub fn start(listen_on: control_auth::Listen, ctx: egui::Context) -> Receiver<Co
};
match listener {
Ok(l) => {
if let Some(port) = listen_on_listener(l, token, tx, ctx, false) {
if let Some(port) = listen_on_listener(l, token.clone(), tx, ctx, false) {
if matches!(listen_on, control_auth::Listen::Default) {
eprintln!("solidcraft: control channel on the default port 127.0.0.1:{port} (Preferences > Assistant turns it off)");
}
@@ -69,7 +70,7 @@ pub fn start(listen_on: control_auth::Listen, ctx: egui::Context) -> Receiver<Co
match control_auth::write_current_port(port) {
Ok(_) => {
if let Ok(mut p) = APP_PORT.lock() {
*p = Some(port);
*p = Some((port, token));
}
}
Err(e) => eprintln!("solidcraft: could not write the control discovery file ({e})"),
@@ -112,7 +113,7 @@ fn listen_on_listener(listener: TcpListener, token: String, tx: Sender<ControlRe
Ok(()) => {
eprintln!("solidcraft: control server listening on 127.0.0.1:{port} (token in {})", path.display());
if let Ok(mut files) = TOKEN_FILES.lock() {
files.push(path);
files.push((path, token.clone()));
}
}
Err(e) => eprintln!(
@@ -224,6 +224,7 @@ settings_monitor false
settings_mouse false
settings_palette false
settings_plug false
sheet false
sheet_rules false
shell false
shield false
+4624 -4554
View File
File diff suppressed because it is too large Load Diff
+4310 -4309
View File
File diff suppressed because it is too large Load Diff
+4287 -4282
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -2202,6 +2202,7 @@
"ui.pan_zoom_orbit": "Panorér, zoom, rotér visning",
"ui.parallel": "Parallel",
"ui.parameters": "Parametre",
"ui.part_size_needs_a_part": "Vælg en indsat standarddel (en skrue, møtrik, skive eller stift) for at ændre dens størrelse. Brug Skalering til at gøre et emne større eller mindre.",
"ui.part_size_shared": "Genopbygger delen og erstatter alle tilføjede features. Alle forekomster af komponenten opdateres.",
"ui.paste": "Indsæt",
"ui.path": "Bane",
@@ -2354,6 +2355,10 @@
"ui.symmetric": "Symmetrisk",
"ui.symmetry": "Symmetri",
"ui.system": "System",
"ui.tab_assemble": "Samling",
"ui.tab_manage": "Administrér",
"ui.tab_sheet_metal": "Plade",
"ui.tab_view": "Visning",
"ui.tangent": "Tangent",
"ui.taper_angle": "Konusvinkel",
"ui.tapped": "Med gevind",
+3910 -3860
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -51,6 +51,7 @@
"prefs.theme_follow_off": "Off",
"prefs.theme_follow_stop": "Stop following",
"prefs.view": "View behaviour",
"prefs.viewport_background_colour": "Colour",
"render.aging_add": "Add aging layer",
"render.aging_airborne": "Airborne dust / participating medium",
"render.aging_airborne_help": "Airborne dust is a separate finite homogeneous scattering volume. It changes transport and raw EXR, not the surface-aging stack. Existing volume/glass/denoising restrictions remain explicit; no atmosphere is added automatically.",
@@ -168,6 +169,7 @@
"ui.point_cloud_file_colors": "File Colours",
"ui.point_cloud_no_file_colors": "The file has no colours",
"ui.point_cloud_single_color": "Single Colour",
"ui.properties.inertia_note": "About the centre of mass, in world axes (g·{unit}²)",
"ui.ribbon_labels": "Show text under ribbon icons",
"ui.workspace_layout": "Workspace layout",
"ui.zoom_to_cursor_tip": "The wheel zooms towards the point under the cursor (off: towards the view's centre)",
+4624 -4554
View File
File diff suppressed because it is too large Load Diff
+3658 -3642
View File
File diff suppressed because it is too large Load Diff
+4314 -4282
View File
File diff suppressed because it is too large Load Diff
+4287 -4282
View File
File diff suppressed because it is too large Load Diff
+4624 -4554
View File
File diff suppressed because it is too large Load Diff
+4310 -4309
View File
File diff suppressed because it is too large Load Diff
+4287 -4282
View File
File diff suppressed because it is too large Load Diff
+4287 -4282
View File
File diff suppressed because it is too large Load Diff
+4295 -4282
View File
File diff suppressed because it is too large Load Diff
+4624 -4554
View File
File diff suppressed because it is too large Load Diff
+3681 -3643
View File
File diff suppressed because it is too large Load Diff
+4287 -4282
View File
File diff suppressed because it is too large Load Diff
+4287 -4282
View File
File diff suppressed because it is too large Load Diff
+4291 -4286
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,56 @@
{
"en": {
"render.cpu_glass_continue": "Continue reflections",
"render.cpu_glass_help": "Standard uses bounded paths and may darken reflective glass. Continue reflections stops with an error if a supported optical limit is reached. Selected opaque reflections and unsupported glass shapes remain unavailable.",
"render.cpu_glass_label": "Glass rendering",
"render.cpu_glass_standard": "Standard"
},
"ar": {
"render.cpu_glass_continue": "متابعة الانعكاسات",
"render.cpu_glass_help": "يستخدم الوضع القياسي مسارات ضوء محدودة، وقد يجعل الزجاج العاكس أغمق. يوقف خيار «متابعة الانعكاسات» التصيير مع ظهور خطأ عند بلوغ أحد الحدود البصرية المدعومة. تظل الانعكاسات المحددة على الأجسام المعتمة وأشكال الزجاج غير المدعومة غير متاحة.",
"render.cpu_glass_label": "تصيير الزجاج",
"render.cpu_glass_standard": "قياسي"
},
"de": {
"render.cpu_glass_continue": "Reflexionen fortsetzen",
"render.cpu_glass_help": "Standard begrenzt die Lichtwege und kann reflektierendes Glas abdunkeln. Reflexionen fortsetzen stoppt das Rendering mit einem Fehler, wenn eine unterstützte optische Grenze erreicht wird. Ausgewählte opake Reflexionen und nicht unterstützte Glasformen bleiben nicht verfügbar.",
"render.cpu_glass_label": "Glasdarstellung",
"render.cpu_glass_standard": "Standard"
},
"es": {
"render.cpu_glass_continue": "Continuar reflejos",
"render.cpu_glass_help": "Estándar limita los recorridos y puede oscurecer el vidrio reflectante. Continuar reflejos detiene el render con un error si alcanza un límite óptico admitido. Los reflejos opacos seleccionados y las formas de vidrio no compatibles siguen sin estar disponibles.",
"render.cpu_glass_label": "Render de vidrio",
"render.cpu_glass_standard": "Estándar"
},
"he": {
"render.cpu_glass_continue": "המשך השתקפויות",
"render.cpu_glass_help": "המצב הרגיל מגביל את מסלולי האור ועלול להכהות זכוכית מחזירת אור. האפשרות «המשך השתקפויות» עוצרת את הרינדור עם שגיאה כאשר מגיעים לאחת המגבלות האופטיות הנתמכות. השתקפויות נבחרות על גופים אטומים וצורות זכוכית שאינן נתמכות נשארות לא זמינות.",
"render.cpu_glass_label": "רינדור זכוכית",
"render.cpu_glass_standard": "רגיל"
},
"ja": {
"render.cpu_glass_continue": "反射を継続",
"render.cpu_glass_help": "標準では光路を制限するため、反射するガラスが暗くなる場合があります。反射を継続する設定では、対応する光学的な上限に達するとエラーでレンダリングを停止します。選択された不透明面の反射や未対応のガラス形状は引き続き使用できません。",
"render.cpu_glass_label": "ガラスの描画",
"render.cpu_glass_standard": "標準"
},
"ko": {
"render.cpu_glass_continue": "반사 계속",
"render.cpu_glass_help": "표준은 광선 경로를 제한하므로 반사 유리가 어둡게 보일 수 있습니다. 반사 계속은 지원되는 광학 한계에 도달하면 오류와 함께 렌더링을 중지합니다. 선택한 불투명 표면 반사와 지원되지 않는 유리 형상은 계속 사용할 수 없습니다.",
"render.cpu_glass_label": "유리 렌더링",
"render.cpu_glass_standard": "표준"
},
"vi": {
"render.cpu_glass_continue": "Tiếp tục phản xạ",
"render.cpu_glass_help": "Chế độ tiêu chuẩn giới hạn đường truyền ánh sáng và có thể làm kính phản xạ trông tối hơn. Tùy chọn Tiếp tục phản xạ dừng kết xuất kèm lỗi khi đạt một giới hạn quang học được hỗ trợ. Các phản xạ đã chọn trên vật thể đục và các hình dạng kính không được hỗ trợ vẫn không khả dụng.",
"render.cpu_glass_label": "Kết xuất kính",
"render.cpu_glass_standard": "Tiêu chuẩn"
},
"zh": {
"render.cpu_glass_continue": "继续反射",
"render.cpu_glass_help": "标准模式限制光线路径,可能使反射玻璃变暗。继续反射模式在达到支持的光学限制时会报错并停止渲染。所选不透明表面反射和不支持的玻璃形状仍不可用。",
"render.cpu_glass_label": "玻璃渲染",
"render.cpu_glass_standard": "标准"
}
}
@@ -0,0 +1,47 @@
{
"en": {
"render.cpu_directional_shadow_help": "CPU optics are temporary and require certified sheets or axis-aligned boxes with opaque body and face appearances. Directional transmitted shadows require global shadows on and all active assignments to agree. Mixed choices, nesting, environment lighting and local lights are unsupported in that mode. This is straight RGB visibility, not refracted shadows or caustics. Subsurface, coats and maps remain unsupported; fixed and progressive sampling retain finite-depth limits.",
"render.cpu_shadow_conflict": "The global directional transmission option conflicts with an optical assignment whose transmitted shadows are off. No authored option was changed.",
"render.cpu_shadow_mixed": "CPU uses one transmitted-shadow switch for all active optical assignments. Mixed on/off choices cannot be represented and have been preserved."
},
"es": {
"render.cpu_directional_shadow_help": "La óptica CPU es temporal y requiere láminas o cajas alineadas con los ejes certificadas, con aspectos opacos de cuerpo y caras. Las sombras transmitidas direccionales requieren sombras globales activadas y que todas las asignaciones activas coincidan. Ese modo no admite mezclas, nesting, iluminación de entorno ni luces locales. Es visibilidad RGB recta, sin sombras refractadas ni cáusticas. La dispersión subsuperficial, las capas y los mapas siguen sin admitirse; el muestreo fijo y progresivo conserva los límites de profundidad.",
"render.cpu_shadow_conflict": "La opción global de transmisión direccional contradice una asignación óptica con sombras transmitidas desactivadas. No se ha cambiado ninguna opción definida.",
"render.cpu_shadow_mixed": "CPU usa un único control de sombras transmitidas para todas las asignaciones ópticas activas. No puede representar opciones activadas y desactivadas mezcladas; se han conservado."
},
"de": {
"render.cpu_directional_shadow_help": "CPU-Optik ist temporär und erfordert zertifizierte Scheiben oder achsenparallele Quader mit opaken Körper- und Flächenerscheinungen. Gerichtete durchgelassene Schatten erfordern globale Schatten und übereinstimmende aktive Zuweisungen. Gemischte Optionen, Verschachtelung, Umgebungslicht und lokale Lichter sind in diesem Modus nicht unterstützt. Dies ist geradlinige RGB-Sichtbarkeit, keine gebrochenen Schatten oder Kaustiken. Subsurface-Streuung, Beschichtungen und Maps bleiben nicht unterstützt; feste und progressive Abtastung behalten die endliche Tiefengrenze.",
"render.cpu_shadow_conflict": "Die globale gerichtete Transmission widerspricht einer optischen Zuweisung mit ausgeschalteten durchgelassenen Schatten. Keine festgelegte Option wurde geändert.",
"render.cpu_shadow_mixed": "CPU verwendet einen Schalter für durchgelassene Schatten aller aktiven optischen Zuweisungen. Gemischte Ein-/Aus-Werte sind nicht darstellbar und wurden beibehalten."
},
"ko": {
"render.cpu_directional_shadow_help": "CPU 광학은 임시 설정이며 불투명한 바디와 면 외관을 가진 인증된 판 또는 축 정렬 상자가 필요합니다. 방향성 투과 그림자는 전역 그림자가 켜져 있고 모든 활성 할당의 설정이 같아야 합니다. 이 모드는 혼합 설정, 중첩, 환경 조명 및 로컬 광원을 지원하지 않습니다. 이는 직선 RGB 가시성이며 굴절 그림자나 코스틱이 아닙니다. 표면하 산란, 코팅 및 맵은 계속 지원하지 않으며 고정 및 점진적 샘플링은 유한 깊이 제한을 유지합니다.",
"render.cpu_shadow_conflict": "전역 방향성 투과 옵션이 투과 그림자가 꺼진 광학 할당과 충돌합니다. 작성된 옵션은 변경되지 않았습니다.",
"render.cpu_shadow_mixed": "CPU는 모든 활성 광학 할당에 하나의 투과 그림자 스위치를 사용합니다. 켜짐과 꺼짐이 섞인 설정은 표현할 수 없으며 그대로 보존되었습니다."
},
"ar": {
"render.cpu_directional_shadow_help": "بصريات CPU مؤقتة وتتطلب صفائح أو صناديق محاذية للمحاور اجتازت التحقق، بمظاهر معتمة للجسم والأوجه. تتطلب الظلال النافذة الاتجاهية تفعيل الظلال العامة وتطابق كل التعيينات النشطة. لا يدعم هذا الوضع الخيارات المختلطة أو التداخل أو إضاءة البيئة أو الأضواء المحلية. هذه رؤية RGB مستقيمة وليست ظلالًا منكسرة أو كاوستيك. يبقى التشتت تحت السطحي والطلاءات والخرائط غير مدعوم، وتبقى حدود العمق المحدودة لأخذ العينات الثابت والتدريجي.",
"render.cpu_shadow_conflict": "يتعارض خيار النفاذ الاتجاهي العام مع تعيين بصري تكون ظلاله النافذة معطلة. لم يتغير أي خيار محدد.",
"render.cpu_shadow_mixed": "يستخدم CPU مفتاح ظلال نافذة واحدًا لجميع التعيينات البصرية النشطة. لا يمكن تمثيل خليط من التفعيل والتعطيل، وقد تم الاحتفاظ بالخيارات."
},
"he": {
"render.cpu_directional_shadow_help": "אופטיקת CPU היא זמנית ודורשת יריעות או תיבות מיושרות לצירים שעברו אימות, עם מראה אטום של הגוף והפאות. צללים מועברים כיווניים דורשים צללים כלליים פעילים ואחידות בכל ההקצאות הפעילות. מצב זה אינו תומך בערכים מעורבים, קינון, תאורת סביבה או אורות מקומיים. זו נראות RGB בקו ישר, ללא צללים נשברים או קאוסטיקה. פיזור תת-משטחי, ציפויים ומפות עדיין אינם נתמכים; דגימה קבועה והדרגתית שומרות על מגבלות העומק הסופי.",
"render.cpu_shadow_conflict": "אפשרות ההעברה הכיוונית הכללית סותרת הקצאה אופטית שהצללים המועברים שלה כבויים. שום אפשרות שנקבעה לא שונתה.",
"render.cpu_shadow_mixed": "CPU משתמש במתג צללים מועברים אחד לכל ההקצאות האופטיות הפעילות. לא ניתן לייצג ערכים מעורבים של פעיל וכבוי, והם נשמרו."
},
"ja": {
"render.cpu_directional_shadow_help": "CPU 光学設定は一時的で、不透明なボディと面の外観を持つ認証済みの薄板または軸に平行なボックスが必要です。平行光源の透過シャドウには全体のシャドウを有効にし、すべての有効な割り当ての設定を一致させる必要があります。このモードでは混在設定、入れ子、環境光、ローカルライトは非対応です。これは直線上の RGB 可視性であり、屈折シャドウやコースティクスではありません。表面下散乱、コート、マップは引き続き非対応で、固定およびプログレッシブサンプリングでも有限深度制限は残ります。",
"render.cpu_shadow_conflict": "全体の平行光透過オプションが、透過シャドウを無効にした光学割り当てと競合しています。設定したオプションは変更されていません。",
"render.cpu_shadow_mixed": "CPU はすべての有効な光学割り当てに単一の透過シャドウスイッチを使用します。有効と無効の混在は表現できないため、その設定を保持しています。"
},
"vi": {
"render.cpu_directional_shadow_help": "Quang học CPU là tạm thời và cần tấm hoặc hộp song song các trục đã được kiểm chứng, với diện mạo thân và mặt đục. Bóng truyền qua từ đèn định hướng cần bật bóng toàn cục và mọi phép gán đang bật phải cùng lựa chọn. Chế độ này không hỗ trợ lựa chọn lẫn bật/tắt, lồng nhau, ánh sáng môi trường hoặc đèn cục bộ. Đây là độ nhìn thấy RGB theo đường thẳng, không phải bóng khúc xạ hay caustic. Tán xạ dưới bề mặt, lớp phủ và bản đồ vẫn không được hỗ trợ; lấy mẫu cố định và lũy tiến vẫn có giới hạn độ sâu hữu hạn.",
"render.cpu_shadow_conflict": "Tùy chọn truyền sáng định hướng toàn cục xung đột với phép gán quang học có bóng truyền qua đang tắt. Không thay đổi tùy chọn đã soạn nào.",
"render.cpu_shadow_mixed": "CPU dùng một công tắc bóng truyền qua cho tất cả phép gán quang học đang bật. Không thể biểu diễn lựa chọn lẫn bật và tắt; các lựa chọn đã được giữ nguyên."
},
"zh": {
"render.cpu_directional_shadow_help": "CPU 光学设置是临时的,需要通过认证的薄片或轴对齐盒体,以及不透明的实体和面外观。方向光透射阴影要求开启全局阴影,并且所有启用的分配采用相同设置。此模式不支持混合开关、嵌套、环境照明或局部光源。这是直线 RGB 可见性,并非折射阴影或焦散。次表面散射、涂层和贴图仍不支持;固定和渐进采样仍保留有限深度限制。",
"render.cpu_shadow_conflict": "全局方向光透射选项与关闭透射阴影的光学分配冲突。未更改任何已设置的选项。",
"render.cpu_shadow_mixed": "CPU 对所有启用的光学分配使用一个透射阴影开关。无法表示开启和关闭混合的设置,已保留这些设置。"
}
}
@@ -0,0 +1,29 @@
{
"en": {
"render.cpu_optics_certification": "CPU Check and Start validate captured identity, opaque body and face appearances, and the requested sheet or axis-aligned box certificate. Choosing a boundary does not certify geometry. Start rechecks the capture; passing Check does not guarantee later admission."
},
"es": {
"render.cpu_optics_certification": "Comprobar e Iniciar en CPU validan la identidad capturada, los aspectos opacos del cuerpo y sus caras, y el certificado de lámina o caja alineada con los ejes solicitado. Elegir una frontera no certifica la geometría. Iniciar comprueba de nuevo la captura; superar Comprobar no garantiza una admisión posterior."
},
"de": {
"render.cpu_optics_certification": "Prüfen und Starten auf der CPU validieren die erfasste Identität, opake Körper- und Flächenerscheinungen sowie das angeforderte Zertifikat für Scheiben oder achsenparallele Quader. Die Wahl einer Grenze zertifiziert keine Geometrie. Starten prüft die Erfassung erneut; erfolgreiches Prüfen garantiert keine spätere Zulassung."
},
"ko": {
"render.cpu_optics_certification": "CPU의 확인과 시작은 캡처한 식별 정보, 불투명한 바디와 면 외관, 요청한 판 또는 축 정렬 상자의 인증을 검증합니다. 경계를 선택하는 것만으로 형상이 인증되지는 않습니다. 시작 시 캡처를 다시 확인하며, 확인에 성공해도 이후 승인이 보장되지는 않습니다."
},
"ar": {
"render.cpu_optics_certification": "يتحقق الفحص والبدء على CPU من الهوية الملتقطة ومظاهر الجسم والأوجه المعتمة وشهادة الصفيحة أو الصندوق المحاذي للمحاور المطلوبة. اختيار حد لا يثبت صلاحية الهندسة. يعيد البدء فحص اللقطة؛ نجاح الفحص لا يضمن القبول لاحقًا."
},
"he": {
"render.cpu_optics_certification": "בדיקה והתחלה ב-CPU מאמתות את הזהות שנלכדה, מראה אטום של הגוף והפאות ואת האישור המבוקש ליריעה או לתיבה מיושרת לצירים. בחירת גבול אינה מאשרת את הגאומטריה. התחלה בודקת שוב את הלכידה; הצלחה בבדיקה אינה מבטיחה אישור מאוחר יותר."
},
"ja": {
"render.cpu_optics_certification": "CPU の確認と開始では、取得した識別情報、不透明なボディと面の外観、要求された薄板または軸に平行なボックスの認証を検証します。境界の選択だけでは形状は認証されません。開始時に取得内容を再確認するため、確認の成功は後の受け入れを保証しません。"
},
"vi": {
"render.cpu_optics_certification": "Kiểm tra và Bắt đầu trên CPU xác thực danh tính đã chụp, diện mạo thân và mặt đục, cùng chứng nhận tấm hoặc hộp song song các trục được yêu cầu. Chọn biên không chứng nhận hình học. Bắt đầu kiểm tra lại bản chụp; vượt qua Kiểm tra không bảo đảm được chấp nhận về sau."
},
"zh": {
"render.cpu_optics_certification": "CPU 的检查和开始会验证捕获的身份、不透明的实体和面外观,以及请求的薄片或轴对齐盒体认证。选择边界本身并不能认证几何体。开始时会重新检查捕获内容;通过检查并不保证之后仍能获准执行。"
}
}
@@ -0,0 +1,110 @@
{
"en": {
"render.cpu_optics_boundary": "CPU requires an explicit Thin sheet or Box boundary. The captured geometry must pass certification.",
"render.cpu_optics_conflict": "CPU optical assignments cannot replace existing dielectric overrides.",
"render.cpu_optics_enable": "Use these optics on CPU",
"render.cpu_optics_explicit": "Review this assignment and explicitly enable its CPU optical route.",
"render.cpu_optics_fixed": "The installed CPU transport cannot admit this optical sampling request.",
"render.cpu_optics_help": "Temporary CPU optics: fixed or progressive sampling, opaque body and face appearances, certified sheets or axis-aligned boxes. Transmitted shadows, subsurface, coats and maps are unsupported. Box nesting needs explicit opt-in and certification. Accumulation retains the finite-depth transport limits.",
"render.cpu_optics_limit": "CPU supports at most eight optical body assignments.",
"render.cpu_optics_request": "CPU optics require the typed CPU image request and its existing options.",
"render.cpu_optics_saved": "Saved GPU optics cannot be mixed with temporary CPU optical assignments.",
"render.cpu_optics_unsupported": "This CPU route supports glass without transmitted shadows, subsurface, coats or maps. Review the authored options; they have been preserved."
},
"es": {
"render.cpu_optics_boundary": "CPU requiere elegir explícitamente Lámina fina o Caja. La geometría capturada debe superar la certificación.",
"render.cpu_optics_conflict": "Las asignaciones ópticas CPU no pueden sustituir las modificaciones dieléctricas existentes.",
"render.cpu_optics_enable": "Usar esta óptica en CPU",
"render.cpu_optics_explicit": "Revisa esta asignación y activa explícitamente su ruta óptica CPU.",
"render.cpu_optics_fixed": "El transporte CPU instalado no puede admitir esta solicitud de muestreo óptico.",
"render.cpu_optics_help": "Óptica CPU temporal: muestreo fijo o progresivo, aspectos opacos de cuerpo y caras, láminas o cajas alineadas con los ejes certificadas. No admite sombras transmitidas, dispersión subsuperficial, capas ni mapas. Las cajas anidadas requieren activación explícita y certificación. La acumulación conserva los límites de profundidad del transporte.",
"render.cpu_optics_limit": "CPU admite como máximo ocho asignaciones ópticas de cuerpo.",
"render.cpu_optics_request": "La óptica CPU requiere la solicitud de imagen CPU tipada y sus opciones existentes.",
"render.cpu_optics_saved": "La óptica GPU guardada no se puede mezclar con asignaciones ópticas CPU temporales.",
"render.cpu_optics_unsupported": "Esta ruta CPU admite vidrio sin sombras transmitidas, dispersión subsuperficial, capas ni mapas. Revisa las opciones definidas; se han conservado."
},
"de": {
"render.cpu_optics_boundary": "CPU erfordert die ausdrückliche Wahl von Dünner Scheibe oder Quader. Die erfasste Geometrie muss zertifiziert werden.",
"render.cpu_optics_conflict": "Optische CPU-Zuweisungen können vorhandene dielektrische Überschreibungen nicht ersetzen.",
"render.cpu_optics_enable": "Diese Optik auf der CPU verwenden",
"render.cpu_optics_explicit": "Prüfen Sie diese Zuweisung und aktivieren Sie ihren optischen CPU-Pfad ausdrücklich.",
"render.cpu_optics_fixed": "Der installierte CPU-Transport kann diese optische Abtastanforderung nicht zulassen.",
"render.cpu_optics_help": "Temporäre CPU-Optik: feste oder progressive Abtastung, opake Körper- und Flächenerscheinungen, zertifizierte Scheiben oder achsenparallele Quader. Durchgelassene Schatten, Subsurface-Streuung, Beschichtungen und Maps werden nicht unterstützt. Verschachtelte Quader erfordern ausdrückliche Aktivierung und Zertifizierung. Die Akkumulation behält die endliche Transporttiefe bei.",
"render.cpu_optics_limit": "CPU unterstützt höchstens acht optische Körperzuweisungen.",
"render.cpu_optics_request": "CPU-Optik erfordert die typisierte CPU-Bildanforderung und deren vorhandene Optionen.",
"render.cpu_optics_saved": "Gespeicherte GPU-Optik kann nicht mit temporären optischen CPU-Zuweisungen kombiniert werden.",
"render.cpu_optics_unsupported": "Dieser CPU-Pfad unterstützt Glas ohne durchgelassene Schatten, Subsurface-Streuung, Beschichtungen oder Maps. Prüfen Sie die unverändert erhaltenen Optionen."
},
"ko": {
"render.cpu_optics_boundary": "CPU에서는 얇은 판 또는 상자 경계를 명시적으로 선택해야 합니다. 캡처한 형상은 인증을 통과해야 합니다.",
"render.cpu_optics_conflict": "CPU 광학 할당은 기존 유전체 재정의를 대체할 수 없습니다.",
"render.cpu_optics_enable": "이 광학 설정을 CPU에서 사용",
"render.cpu_optics_explicit": "이 할당을 검토하고 CPU 광학 경로를 명시적으로 활성화하세요.",
"render.cpu_optics_fixed": "설치된 CPU 전송 모델은 이 광학 샘플링 요청을 허용할 수 없습니다.",
"render.cpu_optics_help": "임시 CPU 광학: 고정 또는 점진적 샘플링, 불투명한 바디와 면 외관, 인증된 판 또는 축 정렬 상자. 투과 그림자, 표면하 산란, 코팅 및 맵은 지원하지 않습니다. 중첩 상자는 명시적 활성화와 인증이 필요합니다. 누적해도 전송의 유한 깊이 제한은 유지됩니다.",
"render.cpu_optics_limit": "CPU는 최대 8개의 광학 바디 할당을 지원합니다.",
"render.cpu_optics_request": "CPU 광학에는 형식화된 CPU 이미지 요청과 기존 옵션이 필요합니다.",
"render.cpu_optics_saved": "저장된 GPU 광학을 임시 CPU 광학 할당과 혼합할 수 없습니다.",
"render.cpu_optics_unsupported": "이 CPU 경로는 투과 그림자, 표면하 산란, 코팅 및 맵이 없는 유리를 지원합니다. 작성된 옵션을 검토하세요. 옵션은 그대로 보존되었습니다."
},
"ar": {
"render.cpu_optics_boundary": "يتطلب CPU اختيار حد صفيحة رقيقة أو صندوق صراحةً. يجب أن تجتاز الهندسة الملتقطة التحقق.",
"render.cpu_optics_conflict": "لا يمكن لتعيينات البصريات على CPU استبدال تجاوزات العوازل الموجودة.",
"render.cpu_optics_enable": "استخدام هذه البصريات على CPU",
"render.cpu_optics_explicit": "راجع هذا التعيين وفعّل مساره البصري على CPU صراحةً.",
"render.cpu_optics_fixed": "لا يمكن لنموذج النقل المثبت على CPU قبول طلب أخذ العينات البصري هذا.",
"render.cpu_optics_help": "بصريات CPU مؤقتة: أخذ عينات ثابت أو تدريجي، ومظاهر معتمة للجسم والأوجه، وصفائح أو صناديق محاذية للمحاور اجتازت التحقق. الظلال النافذة والتشتت تحت السطحي والطلاءات والخرائط غير مدعومة. تتطلب الصناديق المتداخلة تفعيلًا صريحًا والتحقق. يبقى عمق النقل محدودًا حتى مع التراكم.",
"render.cpu_optics_limit": "يدعم CPU ثمانية تعيينات بصرية للأجسام كحد أقصى.",
"render.cpu_optics_request": "تتطلب بصريات CPU طلب صورة CPU محدد النوع وخياراته الموجودة.",
"render.cpu_optics_saved": "لا يمكن خلط بصريات GPU المحفوظة مع تعيينات بصريات CPU المؤقتة.",
"render.cpu_optics_unsupported": "يدعم مسار CPU هذا الزجاج دون ظلال نافذة أو تشتت تحت سطحي أو طلاءات أو خرائط. راجع الخيارات المحددة؛ فقد تم الاحتفاظ بها."
},
"he": {
"render.cpu_optics_boundary": "CPU דורש בחירה מפורשת בגבול יריעה דקה או תיבה. הגאומטריה שנלכדה חייבת לעבור אימות.",
"render.cpu_optics_conflict": "הקצאות אופטיות של CPU אינן יכולות להחליף דריסות דיאלקטריות קיימות.",
"render.cpu_optics_enable": "שימוש באופטיקה זו ב-CPU",
"render.cpu_optics_explicit": "יש לבדוק הקצאה זו ולהפעיל במפורש את הנתיב האופטי שלה ב-CPU.",
"render.cpu_optics_fixed": "מודל התעבורה המותקן ב-CPU אינו יכול לאשר בקשת דגימה אופטית זו.",
"render.cpu_optics_help": "אופטיקה זמנית ב-CPU: דגימה קבועה או הדרגתית, מראה אטום של הגוף והפאות, יריעות או תיבות מיושרות לצירים שעברו אימות. צללים מועברים, פיזור תת-משטחי, ציפויים ומפות אינם נתמכים. תיבות מקוננות דורשות הפעלה מפורשת ואימות. הצבירה שומרת על מגבלת עומק התעבורה הסופית.",
"render.cpu_optics_limit": "CPU תומך בעד שמונה הקצאות אופטיות לגופים.",
"render.cpu_optics_request": "אופטיקת CPU דורשת בקשת תמונת CPU מטיפוס מוגדר ואת האפשרויות הקיימות שלה.",
"render.cpu_optics_saved": "לא ניתן לערבב אופטיקת GPU שמורה עם הקצאות אופטיות זמניות של CPU.",
"render.cpu_optics_unsupported": "נתיב CPU זה תומך בזכוכית ללא צללים מועברים, פיזור תת-משטחי, ציפויים או מפות. יש לבדוק את האפשרויות שנקבעו; הן נשמרו ללא שינוי."
},
"ja": {
"render.cpu_optics_boundary": "CPU では薄板またはボックス境界を明示的に選択する必要があります。取得した形状は認証に合格する必要があります。",
"render.cpu_optics_conflict": "CPU の光学割り当てで既存の誘電体オーバーライドを置き換えることはできません。",
"render.cpu_optics_enable": "この光学設定を CPU で使用",
"render.cpu_optics_explicit": "この割り当てを確認し、CPU 光学経路を明示的に有効にしてください。",
"render.cpu_optics_fixed": "インストール済みの CPU 光輸送では、この光学サンプリング要求を受け付けられません。",
"render.cpu_optics_help": "一時的な CPU 光学設定:固定またはプログレッシブサンプリング、不透明なボディと面の外観、認証済みの薄板または軸に平行なボックス。透過シャドウ、表面下散乱、コート、マップは非対応です。入れ子のボックスには明示的な有効化と認証が必要です。累積しても光輸送の有限深度制限は残ります。",
"render.cpu_optics_limit": "CPU はボディへの光学割り当てを最大 8 個までサポートします。",
"render.cpu_optics_request": "CPU 光学設定には型付きの CPU 画像リクエストと既存のオプションが必要です。",
"render.cpu_optics_saved": "保存済み GPU 光学設定と一時的な CPU 光学割り当ては混在できません。",
"render.cpu_optics_unsupported": "この CPU 経路は透過シャドウ、表面下散乱、コート、マップのないガラスに対応します。設定したオプションを確認してください。内容は保持されています。"
},
"vi": {
"render.cpu_optics_boundary": "CPU yêu cầu chọn rõ ràng biên Tấm mỏng hoặc Hộp. Hình học đã chụp phải vượt qua kiểm chứng.",
"render.cpu_optics_conflict": "Các gán quang học CPU không thể thay thế các ghi đè điện môi hiện có.",
"render.cpu_optics_enable": "Dùng quang học này trên CPU",
"render.cpu_optics_explicit": "Hãy xem lại phép gán này và bật rõ ràng tuyến quang học CPU của nó.",
"render.cpu_optics_fixed": "Mô hình truyền ánh sáng CPU đã cài không thể chấp nhận yêu cầu lấy mẫu quang học này.",
"render.cpu_optics_help": "Quang học CPU tạm thời: lấy mẫu cố định hoặc lũy tiến, diện mạo thân và mặt đục, tấm hoặc hộp song song các trục đã được kiểm chứng. Không hỗ trợ bóng truyền qua, tán xạ dưới bề mặt, lớp phủ và bản đồ. Hộp lồng nhau cần được bật rõ ràng và kiểm chứng. Tích lũy vẫn giữ giới hạn độ sâu hữu hạn của truyền ánh sáng.",
"render.cpu_optics_limit": "CPU hỗ trợ tối đa tám phép gán quang học cho thân.",
"render.cpu_optics_request": "Quang học CPU yêu cầu yêu cầu ảnh CPU có kiểu cùng các tùy chọn hiện có.",
"render.cpu_optics_saved": "Không thể trộn quang học GPU đã lưu với các gán quang học CPU tạm thời.",
"render.cpu_optics_unsupported": "Tuyến CPU này hỗ trợ kính không có bóng truyền qua, tán xạ dưới bề mặt, lớp phủ hoặc bản đồ. Hãy xem lại các tùy chọn đã soạn; chúng đã được giữ nguyên."
},
"zh": {
"render.cpu_optics_boundary": "CPU 要求明确选择薄片或盒体边界。捕获的几何体必须通过认证。",
"render.cpu_optics_conflict": "CPU 光学分配不能替换现有的电介质覆盖设置。",
"render.cpu_optics_enable": "在 CPU 上使用此光学设置",
"render.cpu_optics_explicit": "请检查此分配并明确启用其 CPU 光学路径。",
"render.cpu_optics_fixed": "已安装的 CPU 光传输无法接受此光学采样请求。",
"render.cpu_optics_help": "临时 CPU 光学设置:固定或渐进采样、不透明的实体和面外观、通过认证的薄片或轴对齐盒体。不支持透射阴影、次表面散射、涂层或贴图。嵌套盒体需要明确启用并通过认证。累积仍保留光传输的有限深度限制。",
"render.cpu_optics_limit": "CPU 最多支持八个实体光学分配。",
"render.cpu_optics_request": "CPU 光学设置要求类型化的 CPU 图像请求及其现有选项。",
"render.cpu_optics_saved": "已保存的 GPU 光学设置不能与临时 CPU 光学分配混用。",
"render.cpu_optics_unsupported": "此 CPU 路径支持不含透射阴影、次表面散射、涂层或贴图的玻璃。请检查所编写的选项;这些选项已保留。"
}
}
@@ -0,0 +1,20 @@
{
"de": {
"render.cpu_pass_progress": "Vollständige Durchgänge: {current} / {target} · vorläufig"
},
"en": {
"render.cpu_pass_progress": "Complete passes: {current} / {target} · provisional"
},
"es": {
"render.cpu_pass_progress": "Pasadas completas: {current} / {target} · provisional"
},
"ja": {
"render.cpu_pass_progress": "完了したパス: {current} / {target} · 暫定"
},
"ko": {
"render.cpu_pass_progress": "완료된 패스: {current} / {target} · 임시"
},
"zh": {
"render.cpu_pass_progress": "完整遍数:{current} / {target} · 临时结果"
}
}
@@ -0,0 +1,29 @@
{
"ar": {
"render.product_exposure_invalid": "يجب أن يكون تعريض معاينة النموذج قيمة محدودة بين ‎−32 و32 EV."
},
"de": {
"render.product_exposure_invalid": "Die Belichtung der Modellvorschau muss endlich sein und zwischen −32 und 32 EV liegen."
},
"en": {
"render.product_exposure_invalid": "Model Preview exposure must be finite and between −32 and 32 EV."
},
"es": {
"render.product_exposure_invalid": "La exposición de la vista previa del modelo debe ser finita y estar entre −32 y 32 EV."
},
"he": {
"render.product_exposure_invalid": "חשיפת תצוגת המודל המקדימה חייבת להיות סופית ובטווח שבין ‎−32 ל־32 EV."
},
"ja": {
"render.product_exposure_invalid": "モデルプレビューの露出は −32~32 EV の有限値にしてください。"
},
"ko": {
"render.product_exposure_invalid": "모델 미리보기 노출은 −32~32 EV 범위의 유한한 값이어야 합니다."
},
"vi": {
"render.product_exposure_invalid": "Phơi sáng xem trước Mô hình phải là số hữu hạn trong khoảng −32 đến 32 EV."
},
"zh": {
"render.product_exposure_invalid": "模型预览曝光必须是 −32 到 32 EV 之间的有限值。"
}
}
@@ -0,0 +1,47 @@
{
"ar": {
"render.preview_environment_fixed": "تتطلب معاينة النموذج دوران بيئة بقيمة 0 وشدة بقيمة 1.",
"render.raster_exposure_help": "يضبط تعريض التصيير النقطي من ‎−32 إلى 32 EV بعد التظليل. يحافظ الصفر على الاستجابة الحالية، دون تغيير الأضواء أو المواد.",
"render.raster_exposure_invalid": "يجب أن يكون تعريض التصيير النقطي قيمة محدودة بين ‎−32 و32 EV."
},
"de": {
"render.preview_environment_fixed": "Die Modellvorschau benötigt eine Umgebungsdrehung von 0 und eine Intensität von 1.",
"render.raster_exposure_help": "Passt die Rasterbelichtung nach der Schattierung von −32 bis 32 EV an. Null erhält die bisherige Darstellung; Licht und Materialien bleiben unverändert.",
"render.raster_exposure_invalid": "Die Rasterbelichtung muss endlich sein und zwischen −32 und 32 EV liegen."
},
"en": {
"render.preview_environment_fixed": "Model Preview requires environment rotation 0 and intensity 1.",
"render.raster_exposure_help": "Adjusts Raster exposure from −32 to 32 EV after shading. Zero preserves the current response; lights and materials stay unchanged.",
"render.raster_exposure_invalid": "Raster exposure must be finite and between −32 and 32 EV."
},
"es": {
"render.preview_environment_fixed": "La vista previa del modelo requiere una rotación del entorno de 0 y una intensidad de 1.",
"render.raster_exposure_help": "Ajusta la exposición Raster de −32 a 32 EV después del sombreado. Cero conserva la respuesta actual; no cambia luces ni materiales.",
"render.raster_exposure_invalid": "La exposición Raster debe ser finita y estar entre −32 y 32 EV."
},
"he": {
"render.preview_environment_fixed": "תצוגת המודל המקדימה מחייבת סיבוב סביבה של 0 ועוצמה של 1.",
"render.raster_exposure_help": "התאמת חשיפת רסטר בין ‎−32 ל־32 EV לאחר ההצללה. אפס שומר על התוצאה הנוכחית; התאורה והחומרים אינם משתנים.",
"render.raster_exposure_invalid": "חשיפת הרסטר חייבת להיות סופית ובטווח שבין ‎−32 ל־32 EV."
},
"ja": {
"render.preview_environment_fixed": "モデルプレビューでは、環境の回転を 0、強度を 1 にしてください。",
"render.raster_exposure_help": "シェーディング後のラスター露出を −32~32 EV で調整します。0 は現在の表示を維持し、ライトやマテリアルは変更しません。",
"render.raster_exposure_invalid": "ラスター露出は −32~32 EV の有限値にしてください。"
},
"ko": {
"render.preview_environment_fixed": "모델 미리보기에서는 환경 회전이 0이고 강도가 1이어야 합니다.",
"render.raster_exposure_help": "셰이딩 후 래스터 노출을 −32~32 EV로 조정합니다. 0은 현재 표현을 유지하며 조명과 재질은 변경하지 않습니다.",
"render.raster_exposure_invalid": "래스터 노출은 −32~32 EV 범위의 유한한 값이어야 합니다."
},
"vi": {
"render.preview_environment_fixed": "Xem trước Mô hình yêu cầu góc xoay môi trường là 0 và cường độ là 1.",
"render.raster_exposure_help": "Điều chỉnh phơi sáng Raster từ −32 đến 32 EV sau khi tô bóng. Giá trị 0 giữ cách hiển thị hiện tại; ánh sáng và vật liệu không thay đổi.",
"render.raster_exposure_invalid": "Phơi sáng Raster phải là số hữu hạn trong khoảng −32 đến 32 EV."
},
"zh": {
"render.preview_environment_fixed": "模型预览要求环境旋转为 0,强度为 1。",
"render.raster_exposure_help": "在着色后将光栅曝光调整为 −32 到 32 EV。0 保留当前效果,不更改灯光和材质。",
"render.raster_exposure_invalid": "光栅曝光必须是 −32 到 32 EV 之间的有限值。"
}
}
@@ -0,0 +1,65 @@
{
"en": {
"render.glass_library_conflict": "This body already has optical settings. Edit or remove them before preparing a different recipe.",
"render.glass_library_edit": "Prepare physical glass",
"render.glass_library_help": "Prepares the recipe in the existing editor without assigning an appearance or rendering. CPU needs explicit optical permission and a Thin sheet or Box that passes actual certification. Closed-mesh and shadow settings stay unchanged.",
"render.glass_library_method": "Choose CPU or GPU rendering to prepare physical glass. Raster has no physical-glass route.",
"render.glass_library_prepared": "Physical glass recipe prepared for editing; review its boundary and optical settings before rendering."
},
"es": {
"render.glass_library_conflict": "Este cuerpo ya tiene ajustes ópticos. Edítalos o elimínalos antes de preparar otra receta.",
"render.glass_library_edit": "Preparar vidrio físico",
"render.glass_library_help": "Prepara la receta en el editor existente sin asignar un aspecto ni renderizar. CPU necesita permiso óptico explícito y una lámina o caja que supere la certificación real. No cambia los ajustes de malla cerrada ni de sombras.",
"render.glass_library_method": "Elige renderizado CPU o GPU para preparar vidrio físico. Raster no tiene una ruta de vidrio físico.",
"render.glass_library_prepared": "Receta de vidrio físico preparada para editar; revisa su frontera y sus ajustes ópticos antes de renderizar."
},
"de": {
"render.glass_library_conflict": "Dieser Körper hat bereits optische Einstellungen. Bearbeite oder entferne sie, bevor du ein anderes Rezept vorbereitest.",
"render.glass_library_edit": "Physikalisches Glas vorbereiten",
"render.glass_library_help": "Bereitet das Rezept im vorhandenen Editor vor, ohne ein Aussehen zuzuweisen oder zu rendern. CPU benötigt eine ausdrückliche Freigabe der Optik und eine dünne Scheibe oder einen Quader mit tatsächlicher Zertifizierung. Einstellungen für geschlossene Netze und Schatten bleiben unverändert.",
"render.glass_library_method": "Wähle CPU- oder GPU-Rendering, um physikalisches Glas vorzubereiten. Raster unterstützt kein physikalisches Glas.",
"render.glass_library_prepared": "Glasrezept zur Bearbeitung vorbereitet; prüfe vor dem Rendern seine Begrenzung und optischen Einstellungen."
},
"ko": {
"render.glass_library_conflict": "이 바디에는 이미 광학 설정이 있습니다. 다른 레시피를 준비하기 전에 기존 설정을 편집하거나 제거하세요.",
"render.glass_library_edit": "물리 기반 유리 준비",
"render.glass_library_help": "모양을 지정하거나 렌더링하지 않고 기존 편집기에서 레시피를 준비합니다. CPU에는 명시적인 광학 허용과 실제 검증을 통과하는 얇은 판 또는 상자가 필요합니다. 닫힌 메시와 그림자 설정은 변경하지 않습니다.",
"render.glass_library_method": "물리 기반 유리를 준비하려면 CPU 또는 GPU 렌더링을 선택하세요. Raster는 물리 기반 유리를 지원하지 않습니다.",
"render.glass_library_prepared": "편집할 유리 레시피를 준비했습니다. 렌더링 전에 경계와 광학 설정을 검토하세요."
},
"ar": {
"render.glass_library_conflict": "لهذا الجسم إعدادات بصرية بالفعل. عدّلها أو أزلها قبل تجهيز وصفة مختلفة.",
"render.glass_library_edit": "تجهيز زجاج فيزيائي",
"render.glass_library_help": "يجهّز الوصفة في المحرر الحالي دون تعيين مظهر أو تصيير. يتطلب CPU إذنًا بصريًا صريحًا وصفيحة رقيقة أو صندوقًا يجتاز التحقق الفعلي. تبقى إعدادات الشبكة المغلقة والظلال دون تغيير.",
"render.glass_library_method": "اختر التصيير عبر CPU أو GPU لتجهيز زجاج فيزيائي. لا يدعم Raster الزجاج الفيزيائي.",
"render.glass_library_prepared": "جُهّزت وصفة الزجاج للتحرير؛ راجع حدودها وإعداداتها البصرية قبل التصيير."
},
"he": {
"render.glass_library_conflict": "לגוף הזה כבר יש הגדרות אופטיות. יש לערוך או להסיר אותן לפני הכנת מתכון אחר.",
"render.glass_library_edit": "הכנת זכוכית פיזיקלית",
"render.glass_library_help": "מכין את המתכון בעורך הקיים בלי להקצות מראה או לרנדר. CPU דורש אישור אופטי מפורש ולוח דק או תיבה שעוברים אימות בפועל. הגדרות רשת סגורה וצללים אינן משתנות.",
"render.glass_library_method": "יש לבחור רינדור CPU או GPU כדי להכין זכוכית פיזיקלית. Raster אינו תומך בזכוכית פיזיקלית.",
"render.glass_library_prepared": "מתכון הזכוכית הוכן לעריכה; יש לבדוק את הגבול ואת ההגדרות האופטיות לפני הרינדור."
},
"ja": {
"render.glass_library_conflict": "このボディには既に光学設定があります。別のレシピを準備する前に、既存の設定を編集または削除してください。",
"render.glass_library_edit": "物理ガラスを準備",
"render.glass_library_help": "外観の割り当てやレンダリングを行わず、既存のエディターでレシピを準備します。CPU では光学処理の明示的な許可と、実際の認証に合格する薄板またはボックスが必要です。閉じたメッシュと影の設定は変更しません。",
"render.glass_library_method": "物理ガラスを準備するには CPU または GPU レンダリングを選択してください。Raster は物理ガラスに対応していません。",
"render.glass_library_prepared": "編集用のガラスレシピを準備しました。レンダリング前に境界と光学設定を確認してください。"
},
"vi": {
"render.glass_library_conflict": "Thân này đã có thiết lập quang học. Hãy sửa hoặc xóa chúng trước khi chuẩn bị công thức khác.",
"render.glass_library_edit": "Chuẩn bị kính vật lý",
"render.glass_library_help": "Chuẩn bị công thức trong trình chỉnh sửa hiện có mà không gán diện mạo hay kết xuất. CPU cần cho phép quang học rõ ràng và tấm mỏng hoặc hộp vượt qua kiểm chứng thực tế. Không đổi thiết lập lưới kín và bóng đổ.",
"render.glass_library_method": "Chọn kết xuất CPU hoặc GPU để chuẩn bị kính vật lý. Raster không hỗ trợ kính vật lý.",
"render.glass_library_prepared": "Đã chuẩn bị công thức kính để chỉnh sửa; hãy kiểm tra biên và thiết lập quang học trước khi kết xuất."
},
"zh": {
"render.glass_library_conflict": "此实体已有光学设置。准备其他配方前,请编辑或移除现有设置。",
"render.glass_library_edit": "准备物理玻璃",
"render.glass_library_help": "在现有编辑器中准备配方,不分配外观,也不进行渲染。CPU 需要明确启用光学处理,并使用通过实际认证的薄片或盒体。封闭网格和阴影设置保持不变。",
"render.glass_library_method": "请选择 CPU 或 GPU 渲染来准备物理玻璃。Raster 不支持物理玻璃。",
"render.glass_library_prepared": "玻璃配方已准备好供编辑;渲染前请检查边界和光学设置。"
}
}
@@ -0,0 +1,227 @@
{
"en": {
"cmd.render.interactive": "Interactive Render",
"help.render.interactive": "Explicitly enable a provisional CPU preview while editing. Final render settings and exportable images stay unchanged.",
"render.interactive_error": "Interactive preview error",
"render.interactive_feedback_milestones_complete": "No more preview updates are scheduled for this render.",
"render.interactive_feedback_policy": "Preview updates are limited per render; final image settings stay unchanged.",
"render.interactive_feedback_quota_exhausted": "Preview update limit reached for this render.",
"render.interactive_feedback_usage": "{started}/{limit} preview updates started for this render.",
"render.interactive_final_active": "Final render active",
"render.interactive_off": "Off",
"render.interactive_on": "On",
"render.interactive_partial_dimensions": "Display {display_width} × {display_height} px · Sampling {source_width} × {source_height} px · Output {final_width} × {final_height} px",
"render.interactive_partial_exhausted": "Not enough remaining work budget for another partial image.",
"render.interactive_partial_samples": "Samples/pixel {min}–{max} · {known}/{pixels} source pixels sampled",
"render.interactive_pause": "Pause",
"render.interactive_paused": "Paused",
"render.interactive_provisional": "Provisional preview",
"render.interactive_refining": "Refining preview",
"render.interactive_requires_cpu": "Interactive preview requires CPU ray tracing.",
"render.interactive_resume": "Resume",
"render.interactive_samples": "{done}/{total} SPP",
"render.interactive_stale": "Out of date",
"render.interactive_target": "Sample target reached",
"render.interactive_updating": "Updating"
},
"ar": {
"cmd.render.interactive": "تصيير تفاعلي",
"help.render.interactive": "فعّل معاينة مؤقتة باستخدام المعالج CPU أثناء التحرير. تبقى إعدادات التصيير النهائي والصور القابلة للتصدير دون تغيير.",
"render.interactive_error": "خطأ في المعاينة التفاعلية",
"render.interactive_feedback_milestones_complete": "لم تُجدول تحديثات معاينة أخرى لهذا التصيير.",
"render.interactive_feedback_policy": "تحديثات المعاينة محدودة لكل تصيير؛ تبقى إعدادات الصورة النهائية دون تغيير.",
"render.interactive_feedback_quota_exhausted": "تم بلوغ حد تحديثات المعاينة لهذا التصيير.",
"render.interactive_feedback_usage": "بدأ {started}/{limit} تحديثًا للمعاينة لهذا التصيير.",
"render.interactive_final_active": "التصيير النهائي قيد التنفيذ",
"render.interactive_off": "إيقاف",
"render.interactive_on": "تفعيل",
"render.interactive_partial_dimensions": "العرض {display_width} × {display_height} بكسل · أخذ العينات {source_width} × {source_height} بكسل · الإخراج {final_width} × {final_height} بكسل",
"render.interactive_partial_exhausted": "لا تكفي ميزانية المعالجة المتبقية لصورة جزئية أخرى.",
"render.interactive_partial_samples": "عينات لكل بكسل {min}–{max} · أُخذت عينات من {known}/{pixels} بكسل من المصدر",
"render.interactive_pause": "إيقاف مؤقت",
"render.interactive_paused": "متوقف مؤقتًا",
"render.interactive_provisional": "معاينة مؤقتة",
"render.interactive_refining": "جارٍ تحسين المعاينة",
"render.interactive_requires_cpu": "تتطلب المعاينة التفاعلية تتبّع الأشعة على المعالج CPU.",
"render.interactive_resume": "استئناف",
"render.interactive_samples": "{done}/{total} SPP",
"render.interactive_stale": "غير محدثة",
"render.interactive_target": "تم بلوغ عدد العينات المطلوب",
"render.interactive_updating": "جارٍ التحديث"
},
"de": {
"cmd.render.interactive": "Interaktives Rendering",
"help.render.interactive": "Aktivieren Sie die vorläufige CPU-Vorschau zum Bearbeiten manuell. Einstellungen für das finale Rendering und exportierbare Bilder bleiben unverändert.",
"render.interactive_error": "Fehler in der interaktiven Vorschau",
"render.interactive_feedback_milestones_complete": "Für dieses Rendering sind keine weiteren Vorschauaktualisierungen geplant.",
"render.interactive_feedback_policy": "Vorschauaktualisierungen sind pro Rendering begrenzt; die Einstellungen des finalen Bildes bleiben unverändert.",
"render.interactive_feedback_quota_exhausted": "Limit für Vorschauaktualisierungen dieses Renderings erreicht.",
"render.interactive_feedback_usage": "{started}/{limit} Vorschauaktualisierungen für dieses Rendering gestartet.",
"render.interactive_final_active": "Finales Rendering aktiv",
"render.interactive_off": "Aus",
"render.interactive_on": "Ein",
"render.interactive_partial_dimensions": "Anzeige {display_width} × {display_height} px · Abtastung {source_width} × {source_height} px · Ausgabe {final_width} × {final_height} px",
"render.interactive_partial_exhausted": "Das verbleibende Rechenbudget reicht nicht für ein weiteres Teilbild.",
"render.interactive_partial_samples": "Samples/Pixel {min}–{max} · {known}/{pixels} Quellpixel abgetastet",
"render.interactive_pause": "Pausieren",
"render.interactive_paused": "Pausiert",
"render.interactive_provisional": "Vorläufige Vorschau",
"render.interactive_refining": "Vorschau wird verfeinert",
"render.interactive_requires_cpu": "Die interaktive Vorschau erfordert CPU-Raytracing.",
"render.interactive_resume": "Fortsetzen",
"render.interactive_samples": "{done}/{total} SPP",
"render.interactive_stale": "Nicht aktuell",
"render.interactive_target": "Zielanzahl an Samples erreicht",
"render.interactive_updating": "Wird aktualisiert"
},
"es": {
"cmd.render.interactive": "Render interactivo",
"help.render.interactive": "Activa explícitamente una vista previa provisional por CPU mientras editas. Los ajustes del render final y las imágenes exportables se mantienen intactos.",
"render.interactive_error": "Error en la vista previa interactiva",
"render.interactive_feedback_milestones_complete": "No hay más actualizaciones de vista previa programadas para este renderizado.",
"render.interactive_feedback_policy": "Las actualizaciones de vista previa se limitan por renderizado; los ajustes de la imagen final no cambian.",
"render.interactive_feedback_quota_exhausted": "Se ha alcanzado el límite de actualizaciones de vista previa de este renderizado.",
"render.interactive_feedback_usage": "Se han iniciado {started}/{limit} actualizaciones de vista previa para este renderizado.",
"render.interactive_final_active": "Render final activo",
"render.interactive_off": "Desactivar",
"render.interactive_on": "Activar",
"render.interactive_partial_dimensions": "Visualización {display_width} × {display_height} px · Muestreo {source_width} × {source_height} px · Salida {final_width} × {final_height} px",
"render.interactive_partial_exhausted": "El presupuesto de cálculo restante no permite otra imagen parcial.",
"render.interactive_partial_samples": "Muestras/píxel {min}–{max} · {known}/{pixels} píxeles de origen muestreados",
"render.interactive_pause": "Pausar",
"render.interactive_paused": "En pausa",
"render.interactive_provisional": "Vista previa provisional",
"render.interactive_refining": "Refinando vista previa",
"render.interactive_requires_cpu": "La vista previa interactiva requiere trazado de rayos por CPU.",
"render.interactive_resume": "Reanudar",
"render.interactive_samples": "{done}/{total} SPP",
"render.interactive_stale": "Desactualizada",
"render.interactive_target": "Número objetivo de muestras alcanzado",
"render.interactive_updating": "Actualizando"
},
"he": {
"cmd.render.interactive": "רינדור אינטראקטיבי",
"help.render.interactive": "יש להפעיל במפורש תצוגה מקדימה זמנית במעבד CPU בזמן העריכה. הגדרות הרינדור הסופי והתמונות הזמינות לייצוא נשארות ללא שינוי.",
"render.interactive_error": "שגיאה בתצוגה המקדימה האינטראקטיבית",
"render.interactive_feedback_milestones_complete": "לא מתוכננים עדכוני תצוגה מקדימה נוספים לרינדור זה.",
"render.interactive_feedback_policy": "עדכוני התצוגה המקדימה מוגבלים לכל רינדור; הגדרות התמונה הסופית אינן משתנות.",
"render.interactive_feedback_quota_exhausted": "הושגה מגבלת עדכוני התצוגה המקדימה לרינדור זה.",
"render.interactive_feedback_usage": "החלו {started}/{limit} עדכוני תצוגה מקדימה לרינדור זה.",
"render.interactive_final_active": "הרינדור הסופי פעיל",
"render.interactive_off": "כיבוי",
"render.interactive_on": "הפעלה",
"render.interactive_partial_dimensions": "תצוגה {display_width} × {display_height} פיקסלים · דגימה {source_width} × {source_height} פיקסלים · פלט {final_width} × {final_height} פיקסלים",
"render.interactive_partial_exhausted": "תקציב החישוב שנותר אינו מספיק לתמונה חלקית נוספת.",
"render.interactive_partial_samples": "דגימות לפיקסל {min}–{max} · נדגמו {known}/{pixels} פיקסלים במקור",
"render.interactive_pause": "השהיה",
"render.interactive_paused": "מושהה",
"render.interactive_provisional": "תצוגה מקדימה זמנית",
"render.interactive_refining": "משפר את התצוגה המקדימה",
"render.interactive_requires_cpu": "תצוגה מקדימה אינטראקטיבית דורשת עקיבת קרניים במעבד CPU.",
"render.interactive_resume": "המשך",
"render.interactive_samples": "{done}/{total} SPP",
"render.interactive_stale": "לא עדכני",
"render.interactive_target": "הושג מספר הדגימות המבוקש",
"render.interactive_updating": "מעדכן"
},
"ja": {
"cmd.render.interactive": "インタラクティブレンダリング",
"help.render.interactive": "編集中にCPUによる暫定プレビューを明示的に有効にします。最終レンダリングの設定とエクスポート可能な画像は変更しません。",
"render.interactive_error": "インタラクティブプレビューのエラー",
"render.interactive_feedback_milestones_complete": "このレンダリングでは、これ以上のプレビュー更新は予定されていません。",
"render.interactive_feedback_policy": "プレビュー更新はレンダリングごとに制限されます。最終画像の設定は変わりません。",
"render.interactive_feedback_quota_exhausted": "このレンダリングのプレビュー更新上限に達しました。",
"render.interactive_feedback_usage": "このレンダリングで開始したプレビュー更新:{started}/{limit}。",
"render.interactive_final_active": "最終レンダリング中",
"render.interactive_off": "無効にする",
"render.interactive_on": "有効にする",
"render.interactive_partial_dimensions": "表示 {display_width} × {display_height} px · サンプリング {source_width} × {source_height} px · 出力 {final_width} × {final_height} px",
"render.interactive_partial_exhausted": "残りの計算予算では、次の部分画像を生成できません。",
"render.interactive_partial_samples": "ピクセルあたり {min}~{max} サンプル · 元画像の {known}/{pixels} ピクセルをサンプリング済み",
"render.interactive_pause": "一時停止",
"render.interactive_paused": "一時停止中",
"render.interactive_provisional": "暫定プレビュー",
"render.interactive_refining": "プレビューを改善中",
"render.interactive_requires_cpu": "インタラクティブプレビューにはCPUレイトレーシングが必要です。",
"render.interactive_resume": "再開",
"render.interactive_samples": "{done}/{total} SPP",
"render.interactive_stale": "最新ではありません",
"render.interactive_target": "目標サンプル数に到達",
"render.interactive_updating": "更新中"
},
"ko": {
"cmd.render.interactive": "대화형 렌더링",
"help.render.interactive": "편집하는 동안 임시 CPU 미리 보기를 직접 켭니다. 최종 렌더링 설정과 내보낼 수 있는 이미지는 변경하지 않습니다.",
"render.interactive_error": "대화형 미리 보기 오류",
"render.interactive_feedback_milestones_complete": "이 렌더링에는 추가 미리 보기 업데이트가 예정되어 있지 않습니다.",
"render.interactive_feedback_policy": "미리 보기 업데이트는 렌더링별로 제한되며 최종 이미지 설정은 바뀌지 않습니다.",
"render.interactive_feedback_quota_exhausted": "이 렌더링의 미리 보기 업데이트 한도에 도달했습니다.",
"render.interactive_feedback_usage": "이 렌더링에서 시작한 미리 보기 업데이트: {started}/{limit}.",
"render.interactive_final_active": "최종 렌더링 진행 중",
"render.interactive_off": "끄기",
"render.interactive_on": "켜기",
"render.interactive_partial_dimensions": "표시 {display_width} × {display_height} px · 샘플링 {source_width} × {source_height} px · 출력 {final_width} × {final_height} px",
"render.interactive_partial_exhausted": "남은 연산 예산으로는 부분 이미지를 더 만들 수 없습니다.",
"render.interactive_partial_samples": "픽셀당 샘플 {min}–{max} · 원본 픽셀 {known}/{pixels}개 샘플링됨",
"render.interactive_pause": "일시 중지",
"render.interactive_paused": "일시 중지됨",
"render.interactive_provisional": "임시 미리 보기",
"render.interactive_refining": "미리 보기 개선 중",
"render.interactive_requires_cpu": "대화형 미리 보기에는 CPU 레이 트레이싱이 필요합니다.",
"render.interactive_resume": "계속",
"render.interactive_samples": "{done}/{total} SPP",
"render.interactive_stale": "최신 상태가 아님",
"render.interactive_target": "목표 샘플 수 도달",
"render.interactive_updating": "업데이트 중"
},
"vi": {
"cmd.render.interactive": "Kết xuất tương tác",
"help.render.interactive": "Chủ động bật bản xem trước tạm thời bằng CPU khi chỉnh sửa. Thiết lập kết xuất cuối và các ảnh có thể xuất vẫn được giữ nguyên.",
"render.interactive_error": "Lỗi xem trước tương tác",
"render.interactive_feedback_milestones_complete": "Không còn cập nhật xem trước nào được lên lịch cho lần kết xuất này.",
"render.interactive_feedback_policy": "Số lần cập nhật xem trước được giới hạn theo từng lần kết xuất; cài đặt ảnh cuối cùng không thay đổi.",
"render.interactive_feedback_quota_exhausted": "Đã đạt giới hạn cập nhật xem trước cho lần kết xuất này.",
"render.interactive_feedback_usage": "Đã bắt đầu {started}/{limit} cập nhật xem trước cho lần kết xuất này.",
"render.interactive_final_active": "Đang kết xuất ảnh cuối",
"render.interactive_off": "Tắt",
"render.interactive_on": "Bật",
"render.interactive_partial_dimensions": "Hiển thị {display_width} × {display_height} px · Lấy mẫu {source_width} × {source_height} px · Đầu ra {final_width} × {final_height} px",
"render.interactive_partial_exhausted": "Ngân sách tính toán còn lại không đủ cho một ảnh từng phần nữa.",
"render.interactive_partial_samples": "Mẫu/điểm ảnh {min}–{max} · Đã lấy mẫu {known}/{pixels} điểm ảnh nguồn",
"render.interactive_pause": "Tạm dừng",
"render.interactive_paused": "Đã tạm dừng",
"render.interactive_provisional": "Bản xem trước tạm thời",
"render.interactive_refining": "Đang cải thiện bản xem trước",
"render.interactive_requires_cpu": "Xem trước tương tác yêu cầu dò tia bằng CPU.",
"render.interactive_resume": "Tiếp tục",
"render.interactive_samples": "{done}/{total} SPP",
"render.interactive_stale": "Chưa cập nhật",
"render.interactive_target": "Đã đạt số mẫu mục tiêu",
"render.interactive_updating": "Đang cập nhật"
},
"zh": {
"cmd.render.interactive": "交互式渲染",
"help.render.interactive": "编辑时手动启用临时 CPU 预览。最终渲染设置和可导出的图像保持不变。",
"render.interactive_error": "交互式预览错误",
"render.interactive_feedback_milestones_complete": "本次渲染不再计划新的预览更新。",
"render.interactive_feedback_policy": "每次渲染的预览更新次数有限;最终图像设置保持不变。",
"render.interactive_feedback_quota_exhausted": "本次渲染的预览更新已达到上限。",
"render.interactive_feedback_usage": "本次渲染已启动 {started}/{limit} 次预览更新。",
"render.interactive_final_active": "最终渲染进行中",
"render.interactive_off": "关闭",
"render.interactive_on": "启用",
"render.interactive_partial_dimensions": "显示 {display_width} × {display_height} px · 采样 {source_width} × {source_height} px · 输出 {final_width} × {final_height} px",
"render.interactive_partial_exhausted": "剩余计算预算不足以生成另一张部分图像。",
"render.interactive_partial_samples": "每像素样本数 {min}–{max} · 已采样 {known}/{pixels} 个源像素",
"render.interactive_pause": "暂停",
"render.interactive_paused": "已暂停",
"render.interactive_provisional": "临时预览",
"render.interactive_refining": "正在细化预览",
"render.interactive_requires_cpu": "交互式预览需要 CPU 光线追踪。",
"render.interactive_resume": "继续",
"render.interactive_samples": "{done}/{total} SPP",
"render.interactive_stale": "不是最新状态",
"render.interactive_target": "已达到目标采样数",
"render.interactive_updating": "正在更新"
}
}
@@ -0,0 +1,38 @@
{
"ar": {
"prefs.render_open_result_on_start": "فتح النتيجة عند بدء التصيير",
"prefs.render_open_result_on_start_help": "افتح النتيجة مرة واحدة بعد بدء التصيير بنجاح. عطّل هذا الخيار للاحتفاظ بالعرض المحدد. يمكنك العودة إلى النموذج أثناء التصيير."
},
"de": {
"prefs.render_open_result_on_start": "Ergebnis beim Renderstart öffnen",
"prefs.render_open_result_on_start_help": "Nach erfolgreichem Renderstart einmal das Ergebnis öffnen. Deaktivieren, um die gewählte Ansicht beizubehalten. Während des Renderns können Sie zum Modell zurückkehren."
},
"en": {
"prefs.render_open_result_on_start": "Open Result when rendering starts",
"prefs.render_open_result_on_start_help": "After starting a render successfully, open Result once. Turn off to keep your selected view. You can return to Model while rendering."
},
"es": {
"prefs.render_open_result_on_start": "Abrir Resultado al iniciar el renderizado",
"prefs.render_open_result_on_start_help": "Abre Resultado una vez tras iniciar correctamente el renderizado. Desactiva esta opción para conservar la vista seleccionada. Puedes volver a Modelo durante el renderizado."
},
"he": {
"prefs.render_open_result_on_start": "פתיחת התוצאה עם תחילת הרינדור",
"prefs.render_open_result_on_start_help": "פתיחת התוצאה פעם אחת לאחר התחלת רינדור בהצלחה. יש לבטל אפשרות זו כדי לשמור על התצוגה שנבחרה. אפשר לחזור למודל בזמן הרינדור."
},
"ja": {
"prefs.render_open_result_on_start": "レンダリング開始時に結果を開く",
"prefs.render_open_result_on_start_help": "レンダリングの開始に成功したら、結果を一度だけ開きます。無効にすると、選択中の表示を維持します。レンダリング中でもモデルに戻れます。"
},
"ko": {
"prefs.render_open_result_on_start": "렌더링 시작 시 결과 열기",
"prefs.render_open_result_on_start_help": "렌더링이 성공적으로 시작되면 결과를 한 번 엽니다. 끄면 선택한 보기가 유지됩니다. 렌더링 중에도 모델로 돌아갈 수 있습니다."
},
"vi": {
"prefs.render_open_result_on_start": "Mở Kết quả khi bắt đầu kết xuất",
"prefs.render_open_result_on_start_help": "Mở Kết quả một lần sau khi bắt đầu kết xuất thành công. Tắt để giữ chế độ xem đã chọn. Bạn có thể quay lại Mô hình trong khi kết xuất."
},
"zh": {
"prefs.render_open_result_on_start": "开始渲染时打开结果",
"prefs.render_open_result_on_start_help": "成功开始渲染后打开结果一次。关闭此选项可保留当前选中的视图。渲染期间也可以返回模型视图。"
}
}
+5
View File
@@ -2030,6 +2030,7 @@
"ui.pan_zoom_orbit": "Przesuwanie, powiększanie, obracanie",
"ui.parallel": "Równoległość",
"ui.parameters": "Parametry",
"ui.part_size_needs_a_part": "Zaznacz wstawioną część znormalizowaną (śrubę, nakrętkę, podkładkę lub kołek), aby zmienić jej rozmiar. Aby powiększyć lub pomniejszyć bryłę, użyj polecenia Skala.",
"ui.part_size_shared": "Przebudowuje część, zastępując wszystkie dodane elementy. Wszystkie wystąpienia tego komponentu zostaną zaktualizowane.",
"ui.paste": "Wklej",
"ui.path": "Ścieżka",
@@ -2174,6 +2175,10 @@
"ui.symmetric": "Symetrycznie",
"ui.symmetry": "Symetria",
"ui.system": "Systemowy",
"ui.tab_assemble": "Złożenie",
"ui.tab_manage": "Zarządzanie",
"ui.tab_sheet_metal": "Blacha",
"ui.tab_view": "Widok",
"ui.tangent": "Styczność",
"ui.taper_angle": "Kąt pochylenia",
"ui.tapped": "Gwintowany",
+5
View File
@@ -2161,6 +2161,7 @@
"ui.pan_zoom_orbit": "Deslocar, zoom, orbitar",
"ui.parallel": "Paralelo",
"ui.parameters": "Parâmetros",
"ui.part_size_needs_a_part": "Selecione uma peça padrão inserida (um parafuso, uma porca, uma arruela ou um pino) para alterar o tamanho. Para aumentar ou diminuir um corpo, use Escala.",
"ui.part_size_shared": "Reconstrói a peça, substituindo quaisquer recursos adicionados. Todas as ocorrências deste componente são atualizadas.",
"ui.paste": "Colar",
"ui.path": "Caminho",
@@ -2304,6 +2305,10 @@
"ui.symmetric": "Simétrico",
"ui.symmetry": "Simetria",
"ui.system": "Sistema",
"ui.tab_assemble": "Montar",
"ui.tab_manage": "Gerenciar",
"ui.tab_sheet_metal": "Chapa Metálica",
"ui.tab_view": "Vista",
"ui.tangent": "Tangente",
"ui.taper_angle": "Ângulo de inclinação",
"ui.tapped": "Roscado",
+4287 -4282
View File
File diff suppressed because it is too large Load Diff
+4022 -4017
View File
File diff suppressed because it is too large Load Diff
+4310 -4309
View File
File diff suppressed because it is too large Load Diff
+4287 -4282
View File
File diff suppressed because it is too large Load Diff
+4306 -4285
View File
File diff suppressed because it is too large Load Diff
+4287 -4282
View File
File diff suppressed because it is too large Load Diff
+4624 -4554
View File
File diff suppressed because it is too large Load Diff
+4287 -4282
View File
File diff suppressed because it is too large Load Diff
+4624 -4554
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+3
View File
@@ -175,6 +175,8 @@ impl Tokens {
body: Color32::from_rgb(176, 186, 198),
body_edge: Color32::from_rgb(40, 44, 52),
manipulator: Color32::from_rgb(40, 110, 220),
joint_manipulator: Color32::from_rgb(235, 157, 0),
joint_axis: Color32::from_rgb(0, 95, 110),
preview_add: Color32::from_rgb(112, 160, 226),
preview_new: Color32::from_rgb(240, 225, 105),
preview_sketch: Color32::from_rgb(32, 190, 96),
@@ -352,6 +354,7 @@ impl Tokens {
t.hover = Color32::from_rgb(54, 61, 74);
t.hover_edge_halo = Color32::from_rgb(203, 203, 203);
t.origin_point = Color32::from_rgb(214, 215, 218);
t.joint_axis = Color32::from_rgb(110, 240, 250);
t.viewport_top = Color32::from_rgb(46, 52, 64);
t.viewport_bottom = Color32::from_rgb(46, 52, 64);
t.grid = Color32::from_rgb(59, 65, 76);
+2
View File
@@ -93,6 +93,8 @@
"icons.highlight": "#000000",
"icons.marker_halo": "#000000",
"interference": "#ffffff",
"joint_axis": "#ffffff",
"joint_manipulator": "#ffffff",
"logo": [
"#ffffff",
"#ffffff",
+2
View File
@@ -93,6 +93,8 @@
"icons.highlight": "#ffffff",
"icons.marker_halo": "#ffffff",
"interference": "#000000",
"joint_axis": "#000000",
"joint_manipulator": "#000000",
"logo": [
"#000000",
"#000000",
+2
View File
@@ -93,6 +93,8 @@
"icons.highlight": "#ffffff",
"icons.marker_halo": "#000000cc",
"interference": "#ff5555",
"joint_axis": "#aaaaaa",
"joint_manipulator": "#ffff55",
"logo": [
"#ffffff",
"#55ffff",
+3
View File
@@ -81,6 +81,9 @@ impl Document {
.filter(|j| occurrences.contains(&j.a.occurrence) || occurrences.contains(&j.b.occurrence))
.map(|j| j.id)
.collect();
for group in &mut self.timeline_groups {
group.joints.retain(|id| !joints.contains(id));
}
let assembly = &mut self.assembly;
assembly.joints.retain(|j| !joints.contains(&j.id));
assembly.links.retain(|l| !joints.contains(&l.a) && !joints.contains(&l.b));
+4 -1
View File
@@ -2851,7 +2851,7 @@ pub struct BrowserGroup {
pub items: Vec<String>,
}
/// A saved feature-history folder. Folders may nest, and a feature can belong to at most one folder.
/// A saved history folder. Folders may nest; each feature or joint belongs to at most one folder.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct TimelineGroup {
pub id: u64,
@@ -2861,6 +2861,9 @@ pub struct TimelineGroup {
pub parent: Option<u64>,
#[serde(default)]
pub features: Vec<u64>,
/// Joints stored in this timeline folder.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub joints: Vec<u64>,
}
/// The first feature-history folder whose own and nested features do not form one
+8 -1
View File
@@ -712,7 +712,9 @@ pub fn read(s: &str) -> Result<Document> {
|| d.params.len() > crate::document::MAX_PARAMS
|| d.components.len() > crate::document::MAX_FEATURES
|| d.timeline_groups.len() > crate::document::MAX_FEATURES
|| d.timeline_groups.iter().any(|group| group.features.len() > crate::document::MAX_FEATURES)
|| d.timeline_groups
.iter()
.any(|group| group.features.len() > crate::document::MAX_FEATURES || group.joints.len() > crate::document::MAX_FEATURES)
{
return Err(DocError::Invalid("document too large".into()));
}
@@ -720,6 +722,8 @@ pub fn read(s: &str) -> Result<Document> {
let feature_ids: std::collections::BTreeSet<_> = d.features.iter().map(|feature| feature.id).collect();
let mut group_ids = std::collections::BTreeSet::new();
let mut grouped_features = std::collections::BTreeSet::new();
let joint_ids: std::collections::BTreeSet<_> = d.assembly.joints.iter().map(|joint| joint.id).collect();
let mut grouped_joints = std::collections::BTreeSet::new();
for group in &d.timeline_groups {
if group.name.trim().is_empty() || group.name.len() > 128 || !group_ids.insert(group.id) {
return Err(DocError::Invalid("damaged design: invalid timeline folder".into()));
@@ -727,6 +731,9 @@ pub fn read(s: &str) -> Result<Document> {
if group.parent.is_some_and(|parent| parent == group.id) {
return Err(DocError::Invalid("damaged design: timeline folder cannot contain itself".into()));
}
if group.joints.iter().any(|joint| !joint_ids.contains(joint) || !grouped_joints.insert(*joint)) {
return Err(DocError::Invalid("damaged design: invalid joint in a timeline folder".into()));
}
if group.features.iter().any(|feature| !feature_ids.contains(feature) || !grouped_features.insert(*feature)) {
return Err(DocError::Invalid("damaged design: invalid feature in a timeline folder".into()));
}
@@ -0,0 +1,6 @@
//! CPU render setups save their explicit dielectric transport choice. Missing or
//! older values retain LegacyBounded; its default is omitted. StrictTir preserves
//! deterministic internal reflection and refuses unresolved positive optical tails;
//! it does not make captured body bindings portable or add unsupported geometry.
//! No existing value needs migration or clamping. The last release refuses the
//! newer design format and asks for an update instead of losing the saved choice.
@@ -0,0 +1,6 @@
//! Saved CPU render setups accept finite exposure from -32 through32 EV, matching the
//! existing RenderSetup/UI range. Exposure is still saved only on RenderSetup, applied
//! once after linear accumulation, with default0 unchanged. Existing valid designs keep
//! their numerical intent; no migration/clamping or second exposure field is introduced.
//! The last release refuses this newer design format and requests an update instead of
//! accepting a CPU setup whose wider exposure it cannot execute.
@@ -0,0 +1,4 @@
//! Saves up to eight bounded linear-RGB directional lights in a Raster render job.
//! Older jobs without lights retain the exact implicit studio; an explicit empty
//! rig remains dark. No older data rewrite is needed. The last released reader
//! asks for an update instead of silently discarding this newer saved intent.
@@ -0,0 +1,4 @@
//! Raster product images now apply the existing saved exposure_ev control.
//! Older zero/omitted exposure retains the exact old path. Nonzero exposure
//! changes explicit rendering intent that was previously refused or ignored;
//! the last released reader asks for an update for this newer meaning.
@@ -0,0 +1,3 @@
//! Timeline folders save joint ids alongside their feature ids. Older designs default to no
//! joint membership; empty membership is omitted. The last release refuses the newer format
//! and asks for an update instead of losing folder membership.
+4
View File
@@ -23,6 +23,10 @@ use solidcraft_sketch::linalg;
use crate::{DocError, Document, IDENTITY, Mat, ModelState, Result, apply_point, apply_vector, mat_inverse, mat_mul};
#[path = "joints_handles.rs"]
mod handles;
pub use handles::{MotionAxis, motion_axes};
/// Joints the solver (and the URDF export) reads at most.
pub const MAX_JOINTS: usize = 10_000;
+124
View File
@@ -0,0 +1,124 @@
//! The instantaneous motion a joint value gives a selected occurrence. Probe the same
//! placement graph and loop solver as a drive, including reversed joints and motion links.
use std::collections::BTreeSet;
use super::{Analysis, Held, MAX_LOOP_VALUES, Solution, apply_links, build, joint_values, link_chains, solve_holding_with_analysis};
use crate::{Document, Mat, ModelState, apply_point, apply_vector, mat_inverse, mat_mul};
use solidcraft_geom::Vec3;
/// A driveable value, expressed as an axis of the selected occurrence in world coordinates.
#[derive(Clone, Debug)]
pub struct MotionAxis {
pub joint: u64,
pub index: usize,
pub origin: Vec3,
/// Increasing the value moves/turns the selected occurrence along/about this unit axis.
pub axis: Vec3,
pub angular: bool,
/// Selected motion (mm or radians) per native joint-value unit (mm or radians).
pub scale: f64,
}
/// Independent values on this occurrence's anchored chain, excluding fixed, suppressed,
/// invalid and zero-range motion. Closed loops must admit the probe before an axis is shown.
/// This is a bounded query for an explicit Move operation, not a per-hover rank analysis.
pub fn motion_axes(doc: &Document, st: &ModelState, occurrence: u64) -> Vec<MotionAxis> {
let asm = &doc.assembly;
let values = joint_values(asm);
let mut report = Solution::default();
let built = build(doc, st, &values, &mut report);
let tree = &built.tree;
if tree.fixed.contains_key(&occurrence) || !tree.parent.contains_key(&occurrence) || !report.errors.is_empty() {
return Vec::new();
}
let Some(base) = tree.world(occurrence, &values) else { return Vec::new() };
let Some(inverse) = mat_inverse(&base) else { return Vec::new() };
let path: BTreeSet<u64> = tree.path(occurrence).1.iter().map(|(id, _)| *id).collect();
let linked: BTreeSet<_> = asm.links.iter().map(|l| (l.b, l.ib)).collect();
let mut variables: Vec<_> = tree
.path(occurrence)
.1
.iter()
.filter_map(|(id, _)| tree.joints.get(id))
.flat_map(|j| (0..j.kind.dofs().len()).map(move |i| (j.id, i)))
.filter(|v| !linked.contains(v))
.collect();
variables.extend(link_chains(asm, tree, &path, &linked).1);
let mut seen = BTreeSet::new();
variables.retain(|v| seen.insert(*v));
let reference = tree
.path(occurrence)
.1
.first()
.and_then(|(id, from_a)| {
let (j, frames) = (tree.joints.get(id)?, tree.frames.get(id)?);
let (owner, frame) = if *from_a { (j.b.occurrence, &frames.b) } else { (j.a.occurrence, &frames.a) };
Some(apply_point(&mat_mul(&tree.world(owner, &values)?, frame), Vec3::ZERO))
})
.unwrap_or_else(|| apply_point(&base, Vec3::ZERO));
let mut out: Vec<MotionAxis> = Vec::new();
for (id, index) in variables.into_iter().take(MAX_LOOP_VALUES) {
let Some(joint) = tree.joints.get(&id) else { continue };
// A parameter expression constrains this value; moving it must not silently erase it.
if joint.exprs.value(index).is_some() {
continue;
}
let Some(q) = values.get(&id).and_then(|q| q.get(index)).copied() else { continue };
for requested in [1e-3, -1e-3] {
let mut probe = values.clone();
let Some(slot) = probe.get_mut(&id).and_then(|v| v.get_mut(index)) else { break };
*slot = q + requested;
if let Some((lo, hi)) = joint.limits.get(index).copied().flatten() {
*slot = (*slot).clamp(lo.min(hi), lo.max(hi));
}
let step = *slot - q;
if step.abs() < 1e-10 {
continue;
}
apply_links(asm, &mut probe);
let next = if built.closing.is_empty() {
tree.world(occurrence, &probe)
} else {
let mut changed = doc.clone();
for j in &mut changed.assembly.joints {
if let Some(q) = probe.get(&j.id) {
j.values.clone_from(q);
}
}
let held = Held::from([((id, index), q)]);
let solved = solve_holding_with_analysis(&changed, st, &held, Analysis::Placement);
if solved.conflicts.is_empty() && solved.errors.is_empty() { solved.world.get(&occurrence).copied() } else { None }
};
let Some(next) = next else { continue };
let delta = mat_mul(&next, &inverse);
if let Some(axis) = axis_of(delta, step, reference, id, index) {
// Coincident values describe the same visible motion; retain the nearest joint.
if !out.iter().any(|a| {
a.angular == axis.angular
&& a.axis.dot(axis.axis).abs() > 1.0 - 1e-6
&& (!a.angular || (a.origin - axis.origin).cross(a.axis).len() < 1e-4)
}) {
out.push(axis);
}
break;
}
}
}
out
}
fn axis_of(delta: Mat, step: f64, reference: Vec3, joint: u64, index: usize) -> Option<MotionAxis> {
let (x, y, z) = (apply_vector(&delta, Vec3::X), apply_vector(&delta, Vec3::Y), apply_vector(&delta, Vec3::Z));
let sine = Vec3::new(y.z - z.y, z.x - x.z, x.y - y.x);
let angle = sine.len().atan2(x.x + y.y + z.z - 1.0);
let translation = apply_point(&delta, Vec3::ZERO);
let (origin, axis, angular, scale) = if angle > 1e-8 {
let axis = sine.normalized()? * step.signum();
let transverse = translation - axis * translation.dot(axis);
let centre = (transverse + axis.cross(transverse) / (angle * step.signum() * 0.5).tan()) * 0.5;
(centre + axis * reference.dot(axis), axis, true, angle / step.abs())
} else {
(reference, (translation / step).normalized()?, false, translation.len() / step.abs())
};
(origin.is_finite() && axis.is_finite() && scale.is_finite() && scale > 1e-8).then_some(MotionAxis { joint, index, origin, axis, angular, scale })
}
+123 -19
View File
@@ -729,9 +729,14 @@ fn refresh_links_in(
// Words built from text parameters change shape when the parameters do: that
// rebuild is the text following them, not a reference that changed under it.
let words_follow = matches!(&l.source, LinkSource::Text { exprs, .. } if exprs.text.is_some());
let others = |sk: &Sketch| (sk.constraints.len(), sk.curves.len().saturating_sub(sk.link_curves(&l.id).len()));
let before = others(sk);
match sk.update_link(&l.id, &r.geom) {
Ok(true) if words_follow => {}
Ok(true) => warn.push(format!("projected {} changed shape and was rebuilt", l.source.describe())),
// A rebuilt projection is only worth a warning when it took something of
// the user's with it: a constraint, a dimension or a curve drawn on it.
Ok(true) if others(sk) == before => {}
Ok(true) => warn.push(format!("projected {} changed shape and was rebuilt; constraints on it were removed", l.source.describe())),
Ok(false) if r.moved => warn.push(format!("projected {} was re-found on the nearest geometry", l.source.describe())),
Ok(false) => {}
Err(e) => {
@@ -1079,7 +1084,7 @@ fn silhouette(plane: &Plane, m: &Mesh) -> Vec<LinkGeom> {
})
.map(|(a, b, _)| (plane.to_local(*a), plane.to_local(*b)))
.collect();
fit_segments(&segs, size)
fit_segments(&segs, size, true)
}
/// Section of a mesh (or one of its faces) with the sketch plane.
@@ -1088,7 +1093,13 @@ pub(crate) fn section(plane: &Plane, m: &Mesh, face: Option<u32>) -> Vec<LinkGeo
let (segs, exact) = section_segments(plane, m, face);
let segs: Vec<(Vec2, Vec2)> = segs.into_iter().map(|(a, b, _)| (a, b)).collect();
let loose = size * 2e-3;
fit_segments(&segs, size).into_iter().map(|g| refine_round(g, &exact, loose)).collect()
let refine = |v: Vec<LinkGeom>| -> Vec<LinkGeom> { v.into_iter().map(|g| refine_round(g, &exact, loose)).collect() };
let lossy = refine(fit_segments(&segs, size, false));
// Lines and arcs that follow the section closely (the corners of a rounded profile stay
// 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 }
}
/// The segments where the plane crosses the mesh's triangles (in plane coordinates, each with
@@ -1253,9 +1264,11 @@ fn refine_round(g: LinkGeom, exact: &[Vec2], tol: f64) -> LinkGeom {
}
/// Chain loose 2D segments into polylines (merging coincident ends) and fit each chain.
fn fit_segments(segs: &[(Vec2, Vec2)], size: f64) -> Vec<LinkGeom> {
fn fit_segments(segs: &[(Vec2, Vec2)], size: f64, preserve_arcs: bool) -> Vec<LinkGeom> {
// Mesh chords stray from the surface by up to the display tolerance (1/1000 of the size).
let loose = Some(size * 2e-3);
// A silhouette's vertices lie on the surface, so it is fitted tightly: a loose fit lets a line
// swallow the start of a tangent arc, or an arc run on into the straight edge after it.
let loose = Some(if preserve_arcs { size * 1e-5 } else { size * 2e-3 });
let q = size * 1e-7;
let key = |p: Vec2| ((p.x / q).round() as i64, (p.y / q).round() as i64);
let mut nodes: BTreeMap<(i64, i64), (Vec2, Vec<usize>)> = BTreeMap::new();
@@ -1303,19 +1316,96 @@ fn fit_segments(segs: &[(Vec2, Vec2)], size: f64) -> Vec<LinkGeom> {
for s in starts {
while nodes.get(&s).is_some_and(|n| n.1.iter().any(|e| !used.get(*e).copied().unwrap_or(true))) {
let c = walk(s, &mut used);
fit_chain(&c, loose, &mut out);
fit_chain_with_arcs(&c, loose, &mut out, preserve_arcs);
}
}
let keys: Vec<(i64, i64)> = nodes.keys().copied().collect();
for s in keys {
while nodes.get(&s).is_some_and(|n| n.1.iter().any(|e| !used.get(*e).copied().unwrap_or(true))) {
let c = walk(s, &mut used);
fit_chain(&c, loose, &mut out);
fit_chain_with_arcs(&c, loose, &mut out, preserve_arcs);
}
}
if preserve_arcs {
snap_tangent_arcs(&mut out, size * 1e-3);
}
out
}
/// A fitted arc is only a least-squares circle: make it exactly tangent to the straight edges
/// it runs into, ending on them where they touch it, with one radius.
fn snap_tangent_arcs(out: &mut [LinkGeom], tol: f64) {
for i in 0..out.len() {
let Some(LinkGeom::Arc { c, a, b }) = out.get(i).cloned() else { continue };
let r = (c.dist(a) + c.dist(b)) / 2.0;
// (arc end, line index, line end index, tangent point, unit normal towards the centre, centre distance)
let mut hits: Vec<(usize, usize, usize, Vec2, Vec2, f64)> = Vec::new();
for (end, e) in [a, b].into_iter().enumerate() {
for (j, g) in out.iter().enumerate() {
let LinkGeom::Line(p, q) = g else { continue };
let Some(u) = (*q - *p).normalized() else { continue };
let foot = *p + u * (c - *p).dot(u);
let d = c.dist(foot);
if (d - r).abs() > tol || foot.dist(e) > 0.5 * r {
continue;
}
let Some(n) = (c - foot).normalized() else { continue };
// The end of the line nearest the arc, and the tangent point must lie before it.
let (near, far) = if p.dist(e) <= q.dist(e) { (0, *q) } else { (1, *p) };
let near_pt = if near == 0 { *p } else { *q };
// The line must itself end next to the arc: not just lie on the same infinite line.
if (foot - far).dot(near_pt - far) <= 0.0 || near_pt.dist(foot) > 0.25 * r {
continue;
}
hits.push((end, j, near, foot, n, d));
}
}
let mut hits_by_end: [Option<(usize, usize, Vec2, Vec2, f64)>; 2] = [None, None];
for (end, j, near, foot, n, d) in hits {
if let Some(slot) = hits_by_end.get_mut(end)
&& slot.is_none()
{
*slot = Some((j, near, foot, n, d));
}
}
let (mut c, mut r) = (c, r);
if let [Some((_, _, _, n1, d1)), Some((_, _, _, n2, d2))] = hits_by_end {
// One circle tangent to both lines: equal distance to each.
let det = n1.cross(n2);
if det.abs() > 1e-6 {
let m = (d1 + d2) / 2.0;
let (r1, r2) = (m - d1, m - d2);
let delta = Vec2::new((r1 * n2.y - r2 * n1.y) / det, (n1.x * r2 - n2.x * r1) / det);
c += delta;
r = m;
}
} else if let [Some((_, _, _, _, d)), None] | [None, Some((_, _, _, _, d))] = hits_by_end {
r = d;
}
let on = |q: Vec2| c + (q - c).normalized().unwrap_or(Vec2::X) * r;
let (mut na, mut nb) = (on(a), on(b));
for (end, hit) in hits_by_end.into_iter().enumerate() {
let Some((j, near, _, n, _)) = hit else { continue };
let t = c - n * r;
if let Some(LinkGeom::Line(p, q)) = out.get_mut(j) {
if near == 0 {
*p = t;
} else {
*q = t;
}
}
if end == 0 {
na = t;
} else {
nb = t;
}
}
if let Some(LinkGeom::Arc { c: cc, a: aa, b: bb }) = out.get_mut(i) {
(*cc, *aa, *bb) = (c, na, nb);
}
}
}
// ---------------------------------------------------------------------------------------------
// Fitting
@@ -1392,9 +1482,9 @@ fn fits_line(p: &[Vec2], tol: f64) -> bool {
/// Circle through the chain (centre, radius, signed sweep from the first to the last point).
/// `exact`: the points lie on the curve (edge tessellation), else they are approximate
/// (mesh sections and silhouettes) and the circle is a least-squares fit.
fn fits_circle(p: &[Vec2], tol: f64, exact: bool) -> Option<(Vec2, f64, f64)> {
fn fits_circle(p: &[Vec2], tol: f64, exact: bool, preserve_arcs: bool) -> Option<(Vec2, f64, f64)> {
let n = p.len();
if n < 3 || (!exact && n < 5) {
if n < 3 || (!exact && !preserve_arcs && n < 5) {
return None;
}
let closed = p.first()?.dist(*p.last()?) <= tol;
@@ -1442,6 +1532,10 @@ fn arc_geom(p: &[Vec2], c: Vec2, r: f64, sweep: f64, tol: f64) -> Option<LinkGeo
/// else greedy maximal pieces. `loose`: the points are approximate (within `loose` mm), and
/// sharp corners always split the chain.
pub(crate) fn fit_chain(raw: &[Vec2], loose: Option<f64>, out: &mut Vec<LinkGeom>) {
fit_chain_with_arcs(raw, loose, out, false);
}
fn fit_chain_with_arcs(raw: &[Vec2], loose: Option<f64>, out: &mut Vec<LinkGeom>, preserve_arcs: bool) {
let mut p: Vec<Vec2> = Vec::with_capacity(raw.len());
let scale = raw.iter().fold(0.0_f64, |m, q| m.max(q.x.abs()).max(q.y.abs())).max(1.0);
let ext = match (
@@ -1491,10 +1585,12 @@ pub(crate) fn fit_chain(raw: &[Vec2], loose: Option<f64>, out: &mut Vec<LinkGeom
_ => 0.0,
}
};
let corners: Vec<usize> = (0..n).filter(|i| turn(*i) > 30f64.to_radians()).collect();
// A coarsely tessellated round turns up to ~35 degrees per chord; keep those together.
let corner_turn = if preserve_arcs { 50f64 } else { 30f64 }.to_radians();
let corners: Vec<usize> = (0..n).filter(|i| turn(*i) > corner_turn).collect();
let cut = |a: usize, b: usize, out: &mut Vec<LinkGeom>| {
if let Some(s) = p.get(a..=b) {
fit_piece(s, tol, exact, out);
fit_piece(s, tol, exact, preserve_arcs, out);
}
};
if closed && let Some(&c0) = corners.first() {
@@ -1508,7 +1604,7 @@ pub(crate) fn fit_chain(raw: &[Vec2], loose: Option<f64>, out: &mut Vec<LinkGeom
let mut start = 0;
for c in cs.into_iter().chain([m - 1]) {
if let Some(s) = q.get(start..=c) {
fit_piece(s, tol, exact, out);
fit_piece(s, tol, exact, preserve_arcs, out);
}
start = c;
}
@@ -1521,22 +1617,30 @@ pub(crate) fn fit_chain(raw: &[Vec2], loose: Option<f64>, out: &mut Vec<LinkGeom
}
return;
}
fit_piece(&p, tol, exact, out);
fit_piece(&p, tol, exact, preserve_arcs, out);
}
fn fit_piece(p: &[Vec2], tol: f64, exact: bool, out: &mut Vec<LinkGeom>) {
fn fit_piece(p: &[Vec2], tol: f64, exact: bool, preserve_arcs: bool, out: &mut Vec<LinkGeom>) {
if p.len() < 2 {
return;
}
// A near-straight run is a line, not an arc of huge radius.
let curved = |sw: f64| !preserve_arcs || exact || sw.abs() >= 20f64.to_radians();
let fitted_arc = || fits_circle(p, tol, exact, preserve_arcs).filter(|(_, _, sw)| curved(*sw)).and_then(|(c, r, sw)| arc_geom(p, c, r, sw, tol));
if preserve_arcs
&& (exact || p.len() >= 4)
&& let Some(g) = fitted_arc()
{
out.push(g);
return;
}
if fits_line(p, tol) {
if let (Some(a), Some(b)) = (p.first(), p.last()) {
out.push(LinkGeom::Line(*a, *b));
}
return;
}
if let Some((c, r, sw)) = fits_circle(p, tol, exact)
&& let Some(g) = arc_geom(p, c, r, sw, tol)
{
if !preserve_arcs && let Some(g) = fitted_arc() {
out.push(g);
return;
}
@@ -1553,7 +1657,7 @@ fn fit_piece(p: &[Vec2], tol: f64, exact: bool, out: &mut Vec<LinkGeom>) {
let mut jc = i + 1;
let mut arc = None;
while jc + 1 < n {
match p.get(i..=jc + 1).and_then(|s| fits_circle(s, tol, exact)) {
match p.get(i..=jc + 1).and_then(|s| fits_circle(s, tol, exact, preserve_arcs)) {
Some(f) => {
arc = Some(f);
jc += 1;
@@ -1563,7 +1667,7 @@ fn fit_piece(p: &[Vec2], tol: f64, exact: bool, out: &mut Vec<LinkGeom>) {
}
if jc >= jl + 2
&& jc - i >= 3
&& let Some((c, r, sw)) = arc
&& let Some((c, r, sw)) = arc.filter(|(_, _, sw)| curved(*sw))
&& let Some(g) = p.get(i..=jc).and_then(|s| arc_geom(s, c, r, sw, tol))
{
out.push(g);
+29
View File
@@ -206,6 +206,35 @@ mod tests {
assert!(family_edges(&mesh, &selected, &Plane::XY).is_err());
}
#[test]
fn coarsely_tessellated_round_silhouette_fits_an_arc() {
use solidcraft_geom::Vec2;
use solidcraft_sketch::LinkGeom;
let on = |deg: f64| Vec2::new(5. + 5. * deg.to_radians().cos(), 5. + 5. * deg.to_radians().sin());
let mut chain = vec![Vec2::new(0., -20.)];
chain.extend([180., 169.5, 135., 100.5, 90.].map(on));
chain.push(Vec2::new(45., 10.));
let mut out = Vec::new();
super::super::fit_chain_with_arcs(&chain, Some(0.05), &mut out, true);
assert!(out.iter().any(|g| matches!(g, LinkGeom::Arc { .. })), "{out:?}");
}
#[test]
fn tangent_snapping_leaves_unrelated_collinear_lines_alone() {
use solidcraft_geom::Vec2;
use solidcraft_sketch::LinkGeom;
let v = Vec2::new;
// A rounded corner of radius 2 meeting the top edge, and an unrelated collinear edge.
let mut out = vec![
LinkGeom::Line(v(21., 8.), v(25., 8.)),
LinkGeom::Arc { c: v(2.0008, 5.9992), a: v(2.2808, 8.0), b: v(0., 6.0) },
LinkGeom::Line(v(2.0668, 8.), v(48., 8.)),
];
super::super::snap_tangent_arcs(&mut out, 0.05);
assert_eq!(out.first(), Some(&LinkGeom::Line(v(21., 8.), v(25., 8.))));
let (Some(LinkGeom::Arc { a, .. }), Some(LinkGeom::Line(p, _))) = (out.get(1), out.get(2)) else { panic!("{out:?}") };
assert!(a.dist(*p) < 1e-9, "{out:?}");
}
#[test]
fn boundaries_touching_at_a_vertex_project() {
// Two squares of one face family meet only at the corner (1, 1): four boundary edges meet
+53 -4
View File
@@ -6,8 +6,8 @@ use serde::{Deserialize, Serialize};
use solidcraft_render::{
Camera,
job::{
CpuBudget, CpuProgressiveOptions, DirectionalLightOptions, JobOptions, LightingPreset, PunctualLightOptions, RaytraceEnvironmentOptions,
RaytraceOptions, RectangleLightOptions, StudioLighting, ThinLens, ToneMap,
CpuBudget, CpuDielectricTransport, CpuProgressiveOptions, DirectionalLightOptions, JobOptions, LightingPreset, PunctualLightOptions,
RaytraceEnvironmentOptions, RaytraceOptions, RectangleLightOptions, StudioLighting, ThinLens, ToneMap,
},
lighting::environment::SkyGradient,
};
@@ -25,6 +25,9 @@ pub enum RenderJob {
Raster {
#[serde(default)]
transparent_background: bool,
/// Omission retains the fixed studio; an explicit empty rig is dark.
#[serde(default, deserialize_with = "solidcraft_render::job::deserialize_raster_lights", skip_serializing_if = "Option::is_none")]
lights: Option<Vec<DirectionalLightOptions>>,
},
CpuRaytrace {
#[serde(default)]
@@ -42,8 +45,12 @@ pub enum RenderJob {
impl RenderJob {
pub fn validate(&self, camera: &Camera, exposure_ev: f64) -> Result<()> {
match self {
Self::Raster { .. } if exposure_ev != 0.0 => Err(DocError::Invalid("saved raster rendering requires exposure_ev: 0".into())),
Self::Raster { .. } => Ok(()),
Self::Raster { lights, .. } => {
if !exposure_ev.is_finite() || !(-32.0..=32.0).contains(&exposure_ev) {
return Err(DocError::Invalid("saved Raster exposure must be finite and within -32..32 EV".into()));
}
solidcraft_render::job::validate_raster_lights(lights).map_err(|e| DocError::Invalid(e.to_string()))
}
Self::CpuRaytrace { options } => options.to_runtime(exposure_ev).map(|_| ()),
Self::GpuPath { options, transparent_background } => {
if *transparent_background && options.backdrop.is_some() {
@@ -67,6 +74,8 @@ impl RenderJob {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct CpuJob {
#[serde(skip_serializing_if = "CpuDielectricTransport::is_legacy")]
pub dielectric_transport: CpuDielectricTransport,
#[serde(skip_serializing_if = "CpuBudget::is_compact")]
pub budget: CpuBudget,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -94,6 +103,7 @@ pub struct CpuJob {
impl Default for CpuJob {
fn default() -> Self {
Self {
dielectric_transport: CpuDielectricTransport::LegacyBounded,
budget: CpuBudget::Compact,
progressive: None,
primary_samples: 1,
@@ -129,6 +139,7 @@ impl CpuJob {
// a light family that is explicitly absent. This does not rewrite the file.
let active_area = self.area_lights.as_ref().is_some_and(|lights| !lights.is_empty());
let options = RaytraceOptions {
dielectric_transport: self.dielectric_transport,
budget: self.budget,
progressive: self.progressive,
primary_samples: self.primary_samples,
@@ -346,3 +357,41 @@ fn punctual_lights<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Resul
fn area_lights<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Option<Vec<RectangleLightOptions>>, D::Error> {
optional_lights::<D, RectangleLightOptions, 4>(d)
}
#[cfg(test)]
#[test]
fn cpu_dielectric_transport_saved_field_has_legacy_default_and_explicit_runtime_forward() {
let legacy: CpuJob = serde_json::from_str("{}").unwrap();
assert_eq!(legacy.dielectric_transport, CpuDielectricTransport::LegacyBounded);
assert!(serde_json::to_value(&legacy).unwrap().get("dielectric_transport").is_none());
for policy in [CpuDielectricTransport::LegacyBounded, CpuDielectricTransport::StrictTir] {
let saved = CpuJob { dielectric_transport: policy, ..Default::default() };
let reopened: CpuJob = serde_json::from_value(serde_json::to_value(&saved).unwrap()).unwrap();
assert_eq!(reopened, saved);
let runtime = reopened.to_runtime(0.0).unwrap();
assert_eq!(runtime.dielectric_transport, policy);
assert_eq!(runtime.exposure_ev, 0.0);
assert_eq!(runtime.budget, CpuBudget::Compact);
}
for value in [serde_json::json!(null), serde_json::json!(true), serde_json::json!(17), serde_json::json!("unlimited")] {
assert!(serde_json::from_value::<CpuJob>(serde_json::json!({"dielectric_transport":value})).is_err());
}
}
#[cfg(test)]
#[test]
fn cpu_dielectric_transport_document_roundtrip_keeps_choice_without_captured_bindings() {
for policy in [CpuDielectricTransport::LegacyBounded, CpuDielectricTransport::StrictTir] {
let setup = crate::render_settings::RenderSetup {
job: Some(RenderJob::CpuRaytrace { options: CpuJob { dielectric_transport: policy, ..Default::default() } }),
..Default::default()
};
let mut document = crate::Document::default();
std::sync::Arc::make_mut(&mut document.render).setups.insert("Policy".into(), setup.clone());
let reopened = crate::Document::from_json(&document.to_json()).unwrap();
assert_eq!(document, reopened);
assert_eq!(reopened.render.setups["Policy"], setup);
let Some(RenderJob::CpuRaytrace { options }) = &reopened.render.setups["Policy"].job else { panic!("saved CPU method changed") };
assert_eq!(options.to_runtime(0.0).unwrap().dielectric_transport, policy);
}
}
+124 -2
View File
@@ -238,6 +238,81 @@ mod tests {
use crate::Document;
use std::sync::Arc;
#[test]
fn raster_saved_exposure_uses_the_existing_field_and_roundtrips_its_real_bounds() {
for ev in [-32.0, -1.0, 0.0, 1.0, 2.0, 32.0] {
let setup =
RenderSetup { exposure_ev: ev, job: Some(RenderJob::Raster { transparent_background: false, lights: None }), ..Default::default() };
setup.validate().unwrap();
let mut document = Document::default();
Arc::make_mut(&mut document.render).setups.insert("EV".into(), setup.clone());
let loaded = Document::from_json(&document.to_json()).unwrap();
assert_eq!(loaded.render.setups["EV"], setup);
}
}
#[test]
fn raster_directional_jobs_roundtrip_original_intent_and_omit_legacy_none() {
use serde_json::json;
use solidcraft_render::job::DirectionalLightOptions;
let old = json!({"method":"raster","transparent_background":false});
let job: RenderJob = serde_json::from_value(old.clone()).unwrap();
assert_eq!(serde_json::to_value(&job).unwrap(), old);
let null: RenderJob = serde_json::from_value(json!({"method":"raster","lights":null})).unwrap();
assert_eq!(null, job);
let source = DirectionalLightOptions { direction: [3.0, -4.0, 0.0], color: [0.25, 0.5, 1.0], intensity: 2.0 };
for count in [0, 1, 8] {
let setup = RenderSetup {
job: Some(RenderJob::Raster { transparent_background: true, lights: Some(vec![source.clone(); count]) }),
width: 32,
height: 24,
background: [17, 29, 53],
..Default::default()
};
setup.validate().unwrap();
let mut document = Document::default();
let settings = Arc::make_mut(&mut document.render);
settings.setups.insert("Raster".into(), setup.clone());
settings
.setups
.insert("CPU".into(), RenderSetup { job: Some(RenderJob::CpuRaytrace { options: CpuJob::default() }), ..Default::default() });
settings.setups.insert(
"GPU".into(),
RenderSetup { job: Some(RenderJob::GpuPath { transparent_background: false, options: Box::default() }), ..Default::default() },
);
let loaded = Document::from_json(&document.to_json()).unwrap();
assert_eq!(loaded, document);
assert_eq!(loaded.render.setups["Raster"], setup);
let Some(RenderJob::Raster { lights: Some(lights), .. }) = &loaded.render.setups["Raster"].job else {
panic!("saved method or explicit rig lost")
};
assert_eq!(lights, &vec![source.clone(); count]);
}
}
#[test]
fn saved_raster_rigs_refuse_invalid_values_unknown_controls_and_excess_capacity() {
use serde_json::json;
let light = json!({"direction":[0,0,1],"color":[1,1,1],"intensity":1});
for job in [
json!({"method":"raster","lights":vec![light;9]}),
json!({"method":"raster","lights":[{"direction":[0,0,0],"color":[1,1,1],"intensity":0}]}),
json!({"method":"raster","lights":[{"direction":[0,0,1],"color":[2,1,1],"intensity":1}]}),
json!({"method":"raster","lights":[],"options":{"shadows":true}}),
] {
assert!(serde_json::from_value::<RenderJob>(job).is_err());
}
let setup =
RenderSetup { job: Some(RenderJob::Raster { transparent_background: false, lights: Some(Vec::with_capacity(9)) }), ..Default::default() };
assert!(setup.validate().is_err());
let exposure = RenderSetup {
exposure_ev: 33.0,
job: Some(RenderJob::Raster { transparent_background: false, lights: Some(Vec::new()) }),
..Default::default()
};
assert!(exposure.validate().is_err());
}
#[test]
fn saved_light_coordinates_are_admitted_only_when_the_document_can_reopen() {
use serde_json::json;
@@ -642,8 +717,9 @@ mod tests {
for setup in [
json!({"job":{}}),
json!({"job":{"method":"cpu_raytrace","options":{"primary_samples":2}}}),
json!({"exposure_ev":17,"job":{"method":"cpu_raytrace"}}),
json!({"exposure_ev":1,"job":{"method":"raster"}}),
// CPU17 is now valid within the shared saved range;33 remains invalid.
json!({"exposure_ev":33,"job":{"method":"cpu_raytrace"}}),
json!({"exposure_ev":33,"job":{"method":"raster"}}),
json!({"environment_rotation":0.5,"job":{"method":"gpu_path"}}),
json!({"environment_intensity":0,"job":{"method":"cpu_raytrace"}}),
json!({"job":{"method":"cpu_raytrace","options":{"seed":-1}}}),
@@ -739,7 +815,9 @@ mod tests {
#[cfg(test)]
mod cpu_budget_tests {
use super::*;
use crate::Document;
use solidcraft_render::job::{CpuBudget, CpuProgressiveOptions};
use std::sync::Arc;
#[test]
fn inactive_area_tuning_roundtrips_without_leaking_into_runtime_options() {
@@ -783,4 +861,48 @@ mod cpu_budget_tests {
let legacy_large = RenderSetup { width: 3840, height: 2160, ..Default::default() };
assert!(legacy_large.validate().is_err(), "large intent must name an explicit CPU job profile");
}
#[test]
fn cpu_exposure_expanded_range_roundtrips_offline_with_one_saved_authority() {
use serde_json::json;
assert_eq!(RenderSetup::default().exposure_ev, 0.0);
for ev in [-32.0, -17.0, 17.0, 32.0] {
for progressive in [false, true] {
let mut options = json!({"lights":[],"shadows":false});
if progressive {
options["progressive"] = json!({"samples_per_pixel":3,"seed":31});
}
let saved = json!({"setups":{"EV":{"width":2,"height":1,"exposure_ev":ev,
"job":{"method":"cpu_raytrace","options":options}}}});
let settings: RenderSettings = serde_json::from_value(saved).unwrap();
let document = Document { render: Arc::new(settings), ..Default::default() };
let text = document.to_json();
let wire: serde_json::Value = serde_json::from_str(&text).unwrap();
let pending = crate::format::pending_names();
if !pending.is_empty() {
assert_eq!(wire["format_pending"], json!(pending));
}
let reopened = Document::from_json(&text).unwrap();
assert_eq!(document, reopened);
let setup = &reopened.render.setups["EV"];
assert_eq!(setup.exposure_ev, ev);
let RenderJob::CpuRaytrace { options } = setup.job.as_ref().unwrap() else { panic!("CPU method changed") };
let runtime = options.to_runtime(setup.exposure_ev).unwrap();
assert_eq!(runtime.exposure_ev, ev);
assert_eq!(runtime.budget, solidcraft_render::job::CpuBudget::Compact);
assert_eq!(runtime.progressive.is_some(), progressive);
assert!(serde_json::to_value(options).unwrap().get("exposure_ev").is_none());
}
}
}
#[test]
fn cpu_exposure_expansion_keeps_nonfinite_and_outside_saved_values_invalid() {
for ev in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -33.0, 33.0, -32.01, 32.01] {
let options = CpuJob::default();
assert!(options.to_runtime(ev).is_err());
let setup = RenderSetup { exposure_ev: ev, job: Some(RenderJob::CpuRaytrace { options }), ..Default::default() };
assert!(setup.validate().is_err());
}
}
}
+16 -1
View File
@@ -566,7 +566,7 @@ fn feature_inserted_at_a_marker_inside_a_timeline_folder_joins_it() {
let first = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: Sketch::new() }, Some("First")).unwrap();
let second =
doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: Sketch::new() }, Some("Second")).unwrap();
doc.timeline_groups.push(TimelineGroup { id: 100, name: "Pair".into(), parent: None, features: vec![first, second] });
doc.timeline_groups.push(TimelineGroup { id: 100, name: "Pair".into(), parent: None, features: vec![first, second], joints: vec![] });
doc.marker = Some(1);
let inserted =
@@ -700,3 +700,18 @@ fn empty_boolean_targets_without_overlap_keep_plain_message() {
assert_eq!(model.result(cut).unwrap().error.as_deref(), Some("there is no body to cut or intersect"));
}
}
#[test]
fn timeline_group_legacy_json_omits_empty_joint_membership() {
let old = serde_json::json!({"id":100, "name":"Folder", "parent":null, "features":[]});
let group: TimelineGroup = serde_json::from_value(old.clone()).unwrap();
assert!(group.joints.is_empty());
assert_eq!(serde_json::to_value(&group).unwrap(), old);
let mut doc = Document::new("legacy folders");
doc.timeline_groups.push(group);
doc.next_id = 101;
let saved = doc.to_json();
let value: serde_json::Value = serde_json::from_str(&saved).unwrap();
assert!(value["timeline_groups"][0].get("joints").is_none());
assert_eq!(Document::from_json(&saved).unwrap().to_json(), saved);
}
+4 -4
View File
@@ -290,7 +290,7 @@ fn visibility(s: &mut Session, p: &Value) -> Result<Value> {
s.visibility.hidden_folders.push(k.clone());
}
}
s.revision += 1;
s.refresh_display();
return Ok(json!({"visible":visible,"changed":keys.len(),"visibility":s.visibility}));
}
if let Some(value) = p.get("paths") {
@@ -341,7 +341,7 @@ fn visibility(s: &mut Session, p: &Value) -> Result<Value> {
root_bodies.extend(ancestor_bodies);
s.visibility.hidden_occurrences = hidden;
s.visibility.hidden_bodies = root_bodies;
s.revision += 1;
s.refresh_display();
return Ok(json!({"visible":true,"isolated":paths,"visibility":s.visibility}));
}
for path in &paths {
@@ -350,7 +350,7 @@ fn visibility(s: &mut Session, p: &Value) -> Result<Value> {
s.visibility.hidden_occurrences.push(path.clone());
}
}
s.revision += 1;
s.refresh_display();
return Ok(json!({"visible":visible,"changed":paths.len(),"visibility":s.visibility}));
}
let items: Vec<Shown> = match (str_(p, "folder"), p.get("items")) {
@@ -462,7 +462,7 @@ fn visibility(s: &mut Session, p: &Value) -> Result<Value> {
}
}
}
s.revision += 1;
s.refresh_display();
Ok(json!({ "visible": visible, "changed": items.len(), "visibility": s.visibility }))
}
+22 -3
View File
@@ -11,13 +11,17 @@ use solidcraft_geom::Vec3;
use super::CommandSpec;
use crate::params::{bad, bool_, str_, string_list, vec3};
use crate::schema::component::{EditInPlaceParams, EditInPlaceResult, FinishEditParams, FinishEditResult};
use crate::schema::component::{
EditInPlaceParams, EditInPlaceResult, FinishEditParams, FinishEditResult, OccurrenceAlignParams, OccurrenceAlignResult,
};
use crate::schema::{TypedContract, output_schema_for};
use crate::{Result, Session};
const EDIT_IN_PLACE_CONTRACT: TypedContract<EditInPlaceParams> = TypedContract::new();
const FINISH_EDIT_CONTRACT: TypedContract<FinishEditParams> = TypedContract::new();
#[path = "component_align.rs"]
mod align;
#[path = "component_drag.rs"]
mod drag;
#[path = "component_matrix.rs"]
@@ -78,10 +82,10 @@ pub static COMMANDS: &[CommandSpec] = &[
),
CommandSpec::new("component.properties", "Component Physical Properties", properties::component)
.icon("measure").noundo()
.params("component?: id or name (default active); descendants?: bool (default true). Measures the first placement, or all placements for root, including pending moves; material-aware mass and center of mass in world coordinates; volume, density, mass and center of mass only for closed, consistently oriented bodies; total.open_bodies_skipped counts open bodies excluded from volume/mass totals"),
.params("component?: id or name (default active); descendants?: bool (default true); about?: [x, y, z] (mm). Measures the first placement, or all placements for root, including pending moves; material-aware mass and center of mass in world coordinates; volume, density, mass, center of mass and inertia_g_mm2 (the inertia tensor about the body's center of mass, world axes, from its tessellation) only for closed, consistently oriented bodies; total.open_bodies_skipped counts open bodies excluded from volume/mass totals; total.first_moment_g_mm (mass × center of mass), inertia_g_mm2 about the total center of mass, principal_moments_g_mm2 (smallest first) and principal_axes (unit, right-handed); with about, total.inertia_about {point, inertia_g_mm2}"),
CommandSpec::new("occurrence.properties", "Occurrence Physical Properties", properties::occurrence)
.icon("measure").noundo()
.params("occurrence: id or name; path?: [occurrence ids] (required if nested under multiple instances); descendants?: bool (default true). Physical properties of this placement in world coordinates, including pending moves; volume, density, mass and center of mass only for closed, consistently oriented bodies; total.open_bodies_skipped counts open bodies excluded from volume/mass totals"),
.params("occurrence: id or name; path?: [occurrence ids] (required if nested under multiple instances); descendants?: bool (default true); about?: [x, y, z] (mm). Physical properties of this placement in world coordinates, including pending moves; volume, density, mass, center of mass and inertia_g_mm2 only for closed, consistently oriented bodies; total.open_bodies_skipped counts open bodies excluded from volume/mass totals; total inertia as in component.properties"),
CommandSpec::new("component.rename", "Rename Component", rename).icon("rename").params("component, name"),
CommandSpec::new("component.delete", "Delete Component", delete).icon("delete").params("component: deletes it, its subcomponents, occurrences and features"),
CommandSpec::new("component.unlock", "Unlock Contents", unlock).icon("rigid_group").params(
@@ -110,6 +114,16 @@ pub static COMMANDS: &[CommandSpec] = &[
point - at (with any chain of joints it is in that is joined to nothing grounded) and captures its position; other pending \
moves stay pending. Shared definitions move in all placements. Grounded parts and parts joints hold to something fixed are refused (joint.reach moves those)",
),
CommandSpec::new("occurrence.align", "Align Occurrence", crate::checked!("occurrence.align", align::align))
.icon("align")
.contract(&ALIGN_CONTRACT)
.output(output_schema_for::<OccurrenceAlignResult>)
.params(
"occurrence?, occurrences?, path?, paths?: placements to move together; from | from_face | from_plane; to | to_face | to_plane; \
flip?: bool; capture?: bool (default true); move_grounded?: bool (default false). Moves only the chosen occurrence placements in \
world coordinates without changing shared component geometry; a placement inside a component placed more than once moves in \
every placement of it, and the reply's `shared` lists those paths",
),
CommandSpec::new("occurrence.ground", "Ground", ground).icon("ground").params("occurrence; grounded?: bool (default toggles)"),
CommandSpec::new("occurrence.reparent", "Move to Component", reparent::reparent).icon("move").params(
"occurrence; path?: exact placement; parent: component id or name (0 or \"root\" for the design); parent_path?: the parent's \
@@ -120,6 +134,8 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("component.revert_position", "Revert Position", revert).at("SOLID", "POSITION").icon("revert").noundo().params("occurrences?: [ids] (default all); drops selected pending occurrence moves"),
];
const ALIGN_CONTRACT: TypedContract<OccurrenceAlignParams> = TypedContract::new();
fn key(v: Option<&Value>) -> Option<String> {
match v {
Some(Value::Number(n)) => Some(n.to_string()),
@@ -366,6 +382,9 @@ fn unlock(s: &mut Session, p: &Value) -> Result<Value> {
}
let doc = s.doc_mut();
doc.assembly.joints.retain(|j| !gone.contains(&j.id));
for group in &mut doc.timeline_groups {
group.joints.retain(|id| !gone.contains(id));
}
doc.assembly.links.retain(|l| !gone.contains(&l.a) && !gone.contains(&l.b));
s.visibility.hidden_joints.retain(|h| !gone.contains(h));
Ok(json!({"component": id, "joints": gone}))
+244
View File
@@ -0,0 +1,244 @@
//! Aligning component occurrences (`occurrence.align`): moves only the chosen occurrence
//! placements in world coordinates using the same `from` / `to` / `flip` rules as `solid.align`,
//! without modifying shared component geometry across other instances.
use serde_json::{Value, json};
use solidcraft_doc::{IDENTITY, mat_inverse, mat_mul, rigid};
use solidcraft_geom::Vec3;
use super::{occurrence, occurrence_path, valid_position};
use crate::params::{bad, vec3};
use crate::{Result, Session};
const MAX_ALIGNED: usize = 64;
fn opt_bool(p: &Value, key: &str, default: bool, cmd: &str) -> Result<bool> {
match p.get(key) {
None => Ok(default),
Some(Value::Bool(b)) => Ok(*b),
_ => Err(bad(cmd, format!("`{key}` must be a boolean"))),
}
}
fn parse_path(v: &Value, key: &str, cmd: &str) -> Result<Vec<u64>> {
v.as_array()
.filter(|a| !a.is_empty() && a.len() <= 64)
.and_then(|a| a.iter().map(Value::as_u64).collect::<Option<Vec<_>>>())
.ok_or_else(|| bad(cmd, format!("`{key}` must list 1..64 occurrence ids")))
}
fn resolve_paths(s: &Session, p: &Value, cmd: &str) -> Result<Vec<Vec<u64>>> {
let raw: Vec<Vec<u64>> = if let Some(v) = p.get("paths") {
if ["occurrences", "occurrence", "path"].into_iter().any(|k| p.get(k).is_some()) {
return Err(bad(cmd, "give `paths`, `occurrences`, or `occurrence`/`path`, not more than one"));
}
let items = v
.as_array()
.filter(|a| !a.is_empty() && a.len() <= MAX_ALIGNED)
.ok_or_else(|| bad(cmd, format!("`paths` must list 1..{MAX_ALIGNED} occurrence paths")))?;
let mut out = Vec::with_capacity(items.len());
for item in items {
let path = parse_path(item, "paths", cmd)?;
s.occurrence_placement(&path).map_err(|e| bad(cmd, e.to_string()))?;
out.push(path);
}
out
} else if let Some(v) = p.get("occurrences") {
if p.get("occurrence").is_some() || p.get("path").is_some() {
return Err(bad(cmd, "give `occurrences` or `occurrence`/`path`, not both"));
}
let items: Vec<&Value> = match v {
Value::Number(_) | Value::String(_) => vec![v],
Value::Array(a) if !a.is_empty() && a.len() <= MAX_ALIGNED => a.iter().collect(),
_ => return Err(bad(cmd, format!("`occurrences` must be an occurrence id/name or a list of 1..{MAX_ALIGNED} ids/names"))),
};
let mut out = Vec::with_capacity(items.len());
for item in items {
let q = json!({ "occurrence": item });
let id = occurrence(s, &q, cmd)?;
out.push(occurrence_path(s, &json!({}), id, cmd)?);
}
out
} else if p.get("occurrence").is_some() {
let id = occurrence(s, p, cmd)?;
vec![occurrence_path(s, p, id, cmd)?]
} else if let Some(v) = p.get("path") {
let path = parse_path(v, "path", cmd)?;
s.occurrence_placement(&path).map_err(|e| bad(cmd, e.to_string()))?;
vec![path]
} else {
return Err(bad(cmd, "give `occurrence`, `occurrences`, `path` or `paths`"));
};
let mut deduped: Vec<Vec<u64>> = Vec::with_capacity(raw.len());
for path in raw {
if !deduped.contains(&path) {
deduped.push(path);
}
}
// If both an ancestor placement and a placement under it were selected, moving the ancestor
// already carries the descendant along; skip the descendant so it moves once. Only a path
// prefix means "under it": another placement of the same component does not carry it.
let all = deduped.clone();
deduped.retain(|cand| !all.iter().any(|other| other.len() < cand.len() && cand.starts_with(other)));
// Two distinct paths ending in the same nested occurrence id share one definition transform.
for (i, a) in deduped.iter().enumerate() {
if deduped[..i].iter().any(|b| b.last() == a.last()) {
return Err(bad(cmd, "cannot align two placements of the same shared child occurrence at once; align their parent occurrences instead"));
}
}
Ok(deduped)
}
fn world_pick(
s: &Session,
p: &Value,
pk: &str,
fk: &str,
plane: &Option<solidcraft_doc::PlaneRef>,
vals: &std::collections::BTreeMap<String, solidcraft_doc::expr::Value>,
cmd: &str,
) -> Result<(Vec3, Option<Vec3>)> {
if let Some(pl_ref) = plane {
if p.get(pk).is_some() || p.get(fk).is_some() {
return Err(bad(cmd, format!("give `{pk}_plane` or `{pk}`/`{fk}`, not both")));
}
let world = s.world_state();
let pl = s.placed_document().resolve_plane_in(vals, pl_ref, 0, Some(&*world)).map_err(|e| bad(cmd, e.to_string()))?;
return Ok((pl.origin, Some(pl.normal())));
}
if let Some(fv) = p.get(fk) {
if p.get(pk).is_some() {
return Err(bad(cmd, format!("give `{pk}` or `{fk}`, not both")));
}
let fp = vec3(fv).ok_or_else(|| bad(cmd, format!("`{fk}` must be [x, y, z]")))?;
let n = super::super::face::face_normal_world(s, fp).ok_or_else(|| bad(cmd, format!("`{fk}` must be on a planar face")))?;
return Ok((fp, Some(n)));
}
if p.get(pk).and_then(Value::as_str).is_some_and(|o| o.eq_ignore_ascii_case("origin")) {
return Ok((Vec3::ZERO, None));
}
if let Some(o) = p.get(pk).and_then(Value::as_object)
&& (o.contains_key("at") || o.contains_key("snap"))
{
let at = o.get("at").and_then(vec3).ok_or_else(|| bad(cmd, format!("`{pk}.at` must be [x, y, z]")))?;
let kind = match o.get("snap") {
None => "vertex",
Some(Value::String(k)) if matches!(k.as_str(), "vertex" | "edge_mid" | "face_center" | "circle_center") => k.as_str(),
_ => return Err(bad(cmd, format!("`{pk}.snap` must be vertex, edge_mid, face_center or circle_center"))),
};
return super::super::features_more::snap_point(s, kind, at)
.map(|q| (q, None))
.ok_or_else(|| bad(cmd, format!("no {kind} to snap to near `{pk}`")));
}
p.get(pk)
.and_then(vec3)
.map(|v| (v, None))
.ok_or_else(|| bad(cmd, format!("give `{pk}`, `{fk}` or `{pk}_plane` ([x, y, z], \"origin\", or a plane)")))
}
pub(super) fn align(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "occurrence.align";
let paths = resolve_paths(s, p, cmd)?;
let flip = opt_bool(p, "flip", false, cmd)?;
let capture = opt_bool(p, "capture", true, cmd)?;
let move_grounded = opt_bool(p, "move_grounded", false, cmd)?;
for path in &paths {
let Some(&id) = path.last() else {
return Err(bad(cmd, "occurrence path is empty"));
};
let o = s.doc.occurrences.iter().find(|o| o.id == id).ok_or_else(|| bad(cmd, "no occurrence"))?;
if o.grounded && !move_grounded {
return Err(bad(
cmd,
format!("{} is grounded: unground it first (occurrence.ground; a design's first part is grounded when it is made)", o.name),
));
}
if let Some(mut group) = s.held_by_rigid_group(id) {
for _ in 0..64 {
match s.doc.occurrence_of(group).and_then(|occ| s.held_by_rigid_group(occ.id)) {
Some(up) => group = up,
None => break,
}
}
let name = s.doc.components.iter().find(|c| c.id == group).map(|c| c.name.clone()).unwrap_or_default();
return Err(bad(cmd, format!("{} is held in {name} by a rigid group: move {name}, or Unlock Contents (component.unlock) first", o.name)));
}
}
let plane = |key: &str| -> Result<Option<solidcraft_doc::PlaneRef>> {
p.get(key).map(|v| super::super::features::plane_param(s, Some(v), cmd)).transpose()
};
let (from_plane, to_plane) = (plane("from_plane")?, plane("to_plane")?);
let (vals, _) = s.doc.param_values();
let (from, from_normal) = world_pick(s, p, "from", "from_face", &from_plane, &vals, cmd)?;
let (to, to_normal) = world_pick(s, p, "to", "to_face", &to_plane, &vals, cmd)?;
let (mut axis, mut ang) = (Vec3::Z, 0.0);
if let (Some(a), Some(b)) = (from_normal.and_then(Vec3::normalized), to_normal.and_then(Vec3::normalized)) {
let want = if flip { b } else { b * -1.0 };
let cross = a.cross(want);
let theta = cross.len().atan2(a.dot(want));
if theta.abs() > 1e-12 {
axis = cross.normalized().unwrap_or_else(|| a.any_perp());
ang = theta;
}
}
let d = match (&to_plane, to_normal.and_then(Vec3::normalized)) {
(Some(_), Some(n)) => n * (to - from).dot(n),
_ => to - from,
};
if !(d.is_finite() && from.is_finite() && axis.is_finite() && ang.is_finite()) {
return Err(bad(cmd, "non-finite alignment transform"));
}
let world_move = rigid(d, from, axis, ang);
// Compute every new local placement from the current placements before mutating anything.
let mut updates = Vec::with_capacity(paths.len());
for path in &paths {
let placement = s.occurrence_placement(path).map_err(|e| bad(cmd, e.to_string()))?;
let inverse = mat_inverse(&placement.parent_world).ok_or_else(|| bad(cmd, "parent frame is singular"))?;
let local_delta = mat_mul(&mat_mul(&inverse, &world_move), &placement.parent_world);
let next = mat_mul(&local_delta, &placement.local);
if !valid_position(&next) {
return Err(bad(cmd, "alignment transform exceeds supported numeric range"));
}
updates.push((placement.occurrence, next));
}
let moved: Vec<u64> = updates.iter().map(|(id, _)| *id).collect();
// Placements inside a component placed more than once move in every placement of it: the
// command still runs (as occurrence.move does), and says so for the dialog's warning.
let shared: Vec<&Vec<u64>> = paths.iter().filter(|path| s.occurrence_shared(path)).collect();
if capture {
if world_move != IDENTITY || updates.iter().any(|(id, _)| s.pending_moves.contains_key(id)) {
for (id, next) in updates {
s.pending_moves.remove(&id);
if let Some(x) = s.doc_mut().occurrences.iter_mut().find(|x| x.id == id) {
x.transform = next;
}
}
}
} else {
for (id, next) in updates {
s.pending_moves.insert(id, next);
}
s.revision += 1;
}
let mut reply = json!({
"occurrences": moved,
"paths": paths,
"world_transform": world_move,
"pending": !capture,
});
if !shared.is_empty()
&& let Some(o) = reply.as_object_mut()
{
o.insert("shared".into(), json!(shared));
}
Ok(reply)
}
@@ -267,6 +267,17 @@ pub(super) fn clone_assembly(
features: &BTreeMap<u64, u64>,
bodies: &BTreeMap<String, String>,
) -> Result<Vec<String>> {
clone_assembly_with_joint_ids(doc, source, occurrences, features, bodies).map(|(messages, _)| messages)
}
/// The copied joint identity map is also needed when extracting saved timeline folders.
pub(super) fn clone_assembly_with_joint_ids(
doc: &mut Document,
source: &Document,
occurrences: &BTreeMap<u64, u64>,
features: &BTreeMap<u64, u64>,
bodies: &BTreeMap<String, String>,
) -> Result<(Vec<String>, BTreeMap<u64, u64>)> {
budget(&source.assembly)?;
budget(&doc.assembly)?;
validate_map(occurrences)?;
@@ -419,7 +430,7 @@ pub(super) fn clone_assembly(
budget(&a)?;
doc.next_id = item_id.checked_add(1).ok_or_else(|| invalid("assembly identifier space is exhausted"))?;
doc.assembly = a;
Ok(messages)
Ok((messages, joints))
}
#[cfg(test)]
+10 -3
View File
@@ -357,8 +357,6 @@ fn part_design(s: &Session, sc: &Scope, stem: &str, cmd: &str) -> Result<(Sessio
for g in &mut part.timeline_groups {
g.features.retain(|f| sc.inside.contains(f));
}
let parents: BTreeSet<u64> = part.timeline_groups.iter().filter_map(|g| g.parent).collect();
part.timeline_groups.retain(|g| !g.features.is_empty() || parents.contains(&g.id));
part.named_views.clear();
part.configs = Default::default();
part.assembly = Default::default();
@@ -371,7 +369,14 @@ fn part_design(s: &Session, sc: &Scope, stem: &str, cmd: &str) -> Result<(Sessio
scope.assembly.origins.retain(|o| mapped(&o.origin.occurrence));
scope.assembly.contact_sets.retain(|c| c.occurrences.iter().all(mapped));
scope.assembly.exploded.retain(|v| v.moves.iter().all(|(o, _)| mapped(o)));
let mut notices = super::super::component_clone_assembly::clone_assembly(&mut part, &scope, &sc.occurrences, &features, &body_map)?;
let (mut notices, joints) =
super::super::component_clone_assembly::clone_assembly_with_joint_ids(&mut part, &scope, &sc.occurrences, &features, &body_map)?;
// Extracted joints receive new ids. References to joints outside the extracted part go away.
for group in &mut part.timeline_groups {
group.joints = group.joints.iter().filter_map(|id| joints.get(id).copied()).collect();
}
let parents: BTreeSet<u64> = part.timeline_groups.iter().filter_map(|g| g.parent).collect();
part.timeline_groups.retain(|g| !g.features.is_empty() || !g.joints.is_empty() || parents.contains(&g.id));
prune_params(&mut part);
if !source.configs.is_empty() {
notices.push(format!("the configurations stay in this design; {stem} has none"));
@@ -785,6 +790,7 @@ mod tests {
fn extract_moves_the_history_to_its_own_design_and_links_it() {
let dir = folder("basic");
let (mut s, plate, other) = assembly();
run(&mut s, "timeline.group", json!({"name":"Internal joints", "joints":["PinHold"]}));
run(&mut s, "file.save", json!({"path": dir.join("asm.solidcraft")}));
let (vol, cen) = (volumes(&s), centroids(&s));
let other_features: Vec<u64> = s.doc.features.iter().filter(|f| f.component == other).map(|f| f.id).collect();
@@ -815,6 +821,7 @@ mod tests {
assert_eq!(part.doc.components.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(), ["Pin"]);
assert_eq!(part.doc.occurrences.len(), 1);
assert_eq!(part.doc.assembly.joints.iter().map(|j| j.name.as_str()).collect::<Vec<_>>(), ["PinHold"]);
assert_eq!(part.doc.timeline_groups[0].joints, [part.doc.assembly.joints[0].id], "joint folders follow the extracted ids");
assert!(part.doc.param("plate_h").is_some() && part.doc.param("unused").is_none());
assert!(part.model.results.iter().all(|r| r.error.is_none()));
let part_vol = volumes(&part);
+66 -6
View File
@@ -1,6 +1,9 @@
//! Physical properties of an assembly placement, with original body material assignments.
use std::collections::{BTreeMap, BTreeSet};
use serde_json::{Value, json};
use solidcraft_doc::{Document, Mat};
use solidcraft_geom::inertia::{self, Tensor3};
use solidcraft_geom::{Aabb3, Vec3};
use crate::params::bad;
@@ -14,6 +17,23 @@ fn descendants(p: &Value, cmd: &str) -> Result<bool> {
}
}
/// Principal moments this close (a share of the largest) count as equal: a round body's
/// tessellation tells its two equal moments apart by about 1e-4, which turns their axes at random.
const EQUAL_MOMENTS: f64 = 1e-3;
/// The point `about` asks for the inertia tensor about, if any.
fn about(p: &Value, cmd: &str) -> Result<Option<Vec3>> {
match p.get("about") {
None | Some(Value::Null) => Ok(None),
Some(v) => crate::params::vec3(v).map(Some).ok_or_else(|| bad(cmd, "`about` must be a point [x, y, z] in mm")),
}
}
/// The 3 × 3 linear part of a placement, row by row: its turn, and any scale or mirror.
fn linear(m: &Mat) -> Tensor3 {
[0, 1, 2].map(|i| [0, 1, 2].map(|j| m[j][i]))
}
fn placed_doc(s: &Session) -> Document {
let mut doc = (*s.doc).clone();
for occurrence in &mut doc.occurrences {
@@ -58,7 +78,7 @@ pub(super) fn component(s: &mut Session, p: &Value) -> Result<Value> {
} else {
doc.components.iter().find(|c| c.id == id).map(|c| c.name.clone()).ok_or_else(|| bad(cmd, "no active component"))?
};
let mut out = properties(s, &doc, &path, descendants(p, cmd)?, cmd)?;
let mut out = properties(s, &doc, &path, descendants(p, cmd)?, about(p, cmd)?, cmd)?;
out["component"] = json!(id);
out["name"] = json!(name);
Ok(out)
@@ -79,13 +99,13 @@ pub(super) fn occurrence(s: &mut Session, p: &Value) -> Result<Value> {
_ => return Err(bad(cmd, "occurrence has multiple placements; supply its `path`")),
}
};
let mut out = properties(s, &doc, &path, descendants(p, cmd)?, cmd)?;
let mut out = properties(s, &doc, &path, descendants(p, cmd)?, about(p, cmd)?, cmd)?;
out["occurrence"] = json!(id);
out["name"] = json!(doc.occurrences.iter().find(|o| o.id == id).map(|o| o.name.as_str()));
Ok(out)
}
fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool, cmd: &str) -> Result<Value> {
fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool, about: Option<Vec3>, cmd: &str) -> Result<Value> {
let state = s.model.state();
let mut bodies = Vec::new();
let (mut volume, mut area, mut mass) = (0.0, 0.0, 0.0);
@@ -114,19 +134,40 @@ fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool,
}
out
};
// How each closed body's volume spreads about its centre of mass (#8095): one tessellation
// however often it is placed, which each placement then turns.
let components: BTreeSet<u64> = selected_placements.iter().map(|(component, _, _)| *component).collect();
let spread_bodies: Vec<usize> = (0..state.bodies.len())
.filter(|&i| {
state.bodies.get(i).is_some_and(|b| components.contains(&doc.model_body_component(b)) && crate::cmd::inspect::encloses_volume(&b.body))
})
.collect();
let spreads = solidcraft_kernel::map_on_threads(&spread_bodies, 16, |&i| {
state.bodies.get(i).and_then(|b| solidcraft_io::mass_properties(&b.body).ok().flatten()).and_then(|m| m.gyration())
});
let spread: BTreeMap<usize, Tensor3> = spread_bodies.into_iter().zip(spreads).filter_map(|(i, g)| Some((i, g.flatten()?))).collect();
// Closed bodies' (mass, centre of mass, inertia about it), all in world axes.
let mut parts: Vec<(f64, Vec3, Option<Tensor3>)> = Vec::new();
for (component, path, transform) in selected_placements {
placement_count += 1;
for body in state.bodies.iter().filter(|b| doc.model_body_component(b) == component) {
for (index, body) in state.bodies.iter().enumerate().filter(|(_, b)| doc.model_body_component(b) == component) {
let transform: Mat = doc.model_body_offset(body).map(|offset| solidcraft_doc::mat_mul(&transform, offset)).unwrap_or(transform);
let placed = solidcraft_kernel::transform_matrix(&body.body, transform)?;
let measured = solidcraft_kernel::measure(&placed)?;
let density = doc.density(&body.name);
let body_mass = measured.volume * density / 1000.0;
let closed = crate::cmd::inspect::encloses_volume(&placed);
// The mesh's spread with the exact mass, so the tensor agrees with the mass shown.
let body_inertia = spread
.get(&index)
.filter(|_| closed)
.map(|g| inertia::from_gyration(&inertia::transformed(g, &linear(&transform)), body_mass))
.filter(|i| i.iter().flatten().all(|x| x.is_finite()));
if closed {
volume += measured.volume;
mass += body_mass;
moment += measured.centroid * body_mass;
parts.push((body_mass, measured.centroid, body_inertia));
} else {
skipped += 1;
}
@@ -140,6 +181,7 @@ fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool,
"closed": closed, "density_g_cm3": closed.then_some(density),
"mass_g": closed.then_some(body_mass), "volume_mm3": closed.then_some(measured.volume),
"area_mm2": measured.area, "center_of_mass": closed.then_some(measured.centroid),
"inertia_g_mm2": body_inertia,
"bbox": {"min": measured.bbox.min, "max": measured.bbox.max}}));
}
}
@@ -150,11 +192,29 @@ fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool,
if centroid.is_some_and(|point| !point.is_finite()) {
return Err(bad(cmd, "center of mass exceeds supported numeric range"));
}
// About the centre of mass: each body's own tensor, plus its mass at its distance from that
// centre (parallel axes). Unknown when any closed body's mesh did not close.
let at_centre = centroid.and_then(|c| {
parts.iter().try_fold([[0.0f64; 3]; 3], |sum, (m, at, i)| {
let i = inertia::shifted(&(*i)?, *m, *at - c);
Some([0, 1, 2].map(|r| [0, 1, 2].map(|k| sum[r][k] + i[r][k])))
})
});
let at_centre = at_centre.filter(|i| i.iter().flatten().all(|x| x.is_finite()));
let principal = at_centre.and_then(|i| inertia::principal(&i, EQUAL_MOMENTS));
let bbox = if bodies.is_empty() { Value::Null } else { json!({"min": bounds.min, "max": bounds.max}) };
Ok(json!({"path": selected, "coordinate_frame": "world", "descendants": descendants,
let mut out = json!({"path": selected, "coordinate_frame": "world", "descendants": descendants,
"placement_count": placement_count, "body_count": bodies.len(), "bodies": bodies,
"total": {"open_bodies_skipped": skipped, "volume_mm3": volume, "area_mm2": area, "mass_g": mass,
"center_of_mass": centroid, "bbox": bbox, "faces": faces, "edges": edges, "vertices": vertices}}))
"center_of_mass": centroid, "first_moment_g_mm": moment, "inertia_g_mm2": at_centre,
"principal_moments_g_mm2": principal.map(|(moments, _)| moments), "principal_axes": principal.map(|(_, axes)| axes),
"bbox": bbox, "faces": faces, "edges": edges, "vertices": vertices}});
if let Some(point) = about {
let shifted =
centroid.zip(at_centre).map(|(c, i)| inertia::shifted(&i, mass, point - c)).filter(|i| i.iter().flatten().all(|x| x.is_finite()));
out["total"]["inertia_about"] = json!({"point": point, "inertia_g_mm2": shifted});
}
Ok(out)
}
#[cfg(test)]
@@ -98,6 +98,114 @@ fn properties_reject_hostile_identifiers_and_options() {
for path in [json!([]), json!(["bad"]), json!([0]), json!(vec![occurrence; 65])] {
assert!(s.execute("occurrence.properties", &json!({"occurrence": occurrence, "path": path})).is_err());
}
for about in [json!("origin"), json!([1, 2]), json!([1, 2, "z"]), json!([f64::MAX, 0, 0]), json!({"x": 1})] {
assert!(s.execute("component.properties", &json!({"about": about})).is_err(), "{about}");
}
}
fn tensor(v: &Value) -> [[f64; 3]; 3] {
[0, 1, 2].map(|r| [0, 1, 2].map(|c| v[r][c].as_f64().unwrap_or_else(|| panic!("no [{r}][{c}] in {v}"))))
}
fn tensor_close(actual: &Value, want: [[f64; 3]; 3], rel: f64) {
let got = tensor(actual);
let scale = want.iter().flatten().fold(0.0f64, |m, x| m.max(x.abs()));
for (g, w) in got.iter().flatten().zip(want.iter().flatten()) {
assert!((g - w).abs() <= rel * scale, "{got:?} != {want:?}");
}
}
/// #8095: a 10 × 20 × 30 mm steel box's inertia tensor about its centre of mass is
/// m (b² + c²) / 12 … on the diagonal, its principal axes are its edges, and turning its
/// placement turns the tensor with it.
#[test]
fn box_inertia_matches_the_closed_form_and_turns_with_its_placement() {
let mut s = Session::default();
let c = run(&mut s, "component.create", json!({"name": "Block", "grounded": false}));
run(&mut s, "solid.box", json!({"length": 10, "width": 20, "height": 30, "body_name": "Block"}));
run(&mut s, "material.assign", json!({"bodies": ["Block"], "material": "Steel"}));
let r = run(&mut s, "component.properties", json!({"component": c["component"]}));
let total = &r["total"];
close(&total["bbox"]["max"][1], 20.0);
let m = total["mass_g"].as_f64().unwrap();
close(&total["mass_g"], 6000.0 * s.doc.density("Block") / 1000.0);
let (a, b, h) = (10.0, 20.0, 30.0);
let (ixx, iyy, izz) = (m * (b * b + h * h) / 12.0, m * (a * a + h * h) / 12.0, m * (a * a + b * b) / 12.0);
tensor_close(&total["inertia_g_mm2"], [[ixx, 0.0, 0.0], [0.0, iyy, 0.0], [0.0, 0.0, izz]], 1e-9);
tensor_close(&r["bodies"][0]["inertia_g_mm2"], [[ixx, 0.0, 0.0], [0.0, iyy, 0.0], [0.0, 0.0, izz]], 1e-9);
// Smallest first: about the longest edge (z), largest about the shortest (x).
for (k, want) in [izz, iyy, ixx].into_iter().enumerate() {
assert!((total["principal_moments_g_mm2"][k].as_f64().unwrap() - want).abs() < 1e-9 * ixx, "{total}");
}
// Right-handed: z × y is −x.
assert_eq!(total["principal_axes"], json!([[0.0, 0.0, 1.0], [0.0, 1.0, 0.0], [-1.0, 0.0, 0.0]]));
for (k, want) in [5.0, 10.0, 15.0].iter().enumerate() {
close(&total["first_moment_g_mm"][k], m * want);
}
// A quarter turn about z swaps the x and y moments; the principal moments stay.
run(&mut s, "occurrence.move", json!({"occurrence": c["occurrence"], "axis": [0, 0, 1], "angle": "90 deg", "capture": false}));
let turned = run(&mut s, "component.properties", json!({"component": c["component"]}));
tensor_close(&turned["total"]["inertia_g_mm2"], [[iyy, 0.0, 0.0], [0.0, ixx, 0.0], [0.0, 0.0, izz]], 1e-9);
assert_eq!(turned["total"]["principal_moments_g_mm2"], total["principal_moments_g_mm2"]);
let x_axis = &turned["total"]["principal_axes"][2];
assert!(x_axis[0].as_f64().unwrap().abs() < 1e-9 && (x_axis[1].as_f64().unwrap().abs() - 1.0).abs() < 1e-9, "{turned}");
}
/// #8095: an assembly's tensor adds each placement's own about the shared centre of mass by
/// parallel axes, and `about` takes it about any point; a cylinder's comes from its
/// tessellation, close to m r² / 2 about its axis.
#[test]
fn assembly_inertia_adds_placements_by_parallel_axes_and_about_any_point() {
let mut s = Session::default();
let (component, _, body) = box_in(&mut s, "Block");
run(&mut s, "occurrence.copy", json!({"component": component, "translate": [20, 0, 0]}));
let root = run(&mut s, "component.properties", json!({"component": "root", "about": [0, 0, 0]}));
let total = &root["total"];
let m = s.doc.density(&body);
close(&total["mass_g"], 2.0 * m);
// Each 10 mm cube: m (10² + 10²) / 12 on its diagonal; each sits 10 mm along x from (15, 5, 5).
let own = m * 200.0 / 12.0;
let at_centre = [[2.0 * own, 0.0, 0.0], [0.0, 2.0 * own + 2.0 * m * 100.0, 0.0], [0.0, 0.0, 2.0 * own + 2.0 * m * 100.0]];
tensor_close(&total["inertia_g_mm2"], at_centre, 1e-9);
// About the origin: the parallel-axis term of the whole mass at (15, 5, 5).
let (mm, cv) = (2.0 * m, [15.0, 5.0, 5.0]);
let c2: f64 = cv.iter().map(|x| x * x).sum();
let at_origin = [0, 1, 2].map(|r| [0, 1, 2].map(|k| at_centre[r][k] + mm * (if r == k { c2 } else { 0.0 } - cv[r] * cv[k])));
assert_eq!(total["inertia_about"]["point"], json!([0.0, 0.0, 0.0]));
tensor_close(&total["inertia_about"]["inertia_g_mm2"], at_origin, 1e-9);
assert!(total.get("inertia_about").is_some() && run(&mut s, "component.properties", json!({}))["total"].get("inertia_about").is_none());
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"radius": 8, "height": 10, "body_name": "Pin"}));
let r = run(&mut s, "component.properties", json!({}));
let (m, r2) = (r["total"]["mass_g"].as_f64().unwrap(), 64.0);
let i = tensor(&r["total"]["inertia_g_mm2"]);
assert!((i[2][2] - m * r2 / 2.0).abs() < 1e-3 * m * r2 / 2.0, "{i:?}");
assert!((i[0][0] - m * (3.0 * r2 + 100.0) / 12.0).abs() < 1e-3 * i[0][0], "{i:?}");
}
/// #8095: an open surface has no volume, so no tensor; the closed bodies beside it keep theirs.
#[test]
fn open_bodies_have_no_inertia_and_leave_the_closed_ones_alone() {
let mut s = Session::default();
for (command, params) in [
("sketch.create", json!({"plane": "XY", "name": "Profile"})),
("sketch.line", json!({"points": [[30, 0], [30, 40]], "ids": ["side"]})),
("sketch.finish", json!({})),
("surface.revolve", json!({"sketch": "Profile", "curves": ["side"], "axis": "y", "angle": "270 deg", "body_name": "Tube"})),
] {
run(&mut s, command, params);
}
let open = run(&mut s, "component.properties", json!({"about": [1, 2, 3]}));
assert_eq!(open["bodies"][0]["inertia_g_mm2"], Value::Null);
for key in ["inertia_g_mm2", "principal_moments_g_mm2", "principal_axes"] {
assert_eq!(open["total"][key], Value::Null, "{key}");
}
assert_eq!(open["total"]["inertia_about"]["inertia_g_mm2"], Value::Null);
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [100, 0, 0], "body_name": "Box"}));
let both = run(&mut s, "component.properties", json!({}));
let m = both["total"]["mass_g"].as_f64().unwrap();
tensor_close(&both["total"]["inertia_g_mm2"], [[m * 200.0 / 12.0, 0.0, 0.0], [0.0, m * 200.0 / 12.0, 0.0], [0.0, 0.0, m * 200.0 / 12.0]], 1e-9);
}
#[test]
+63 -20
View File
@@ -42,19 +42,19 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "MODIFY")
.icon("delete")
.key("Delete")
.params("feature: id|name | features: [id|name] | groups: [timeline folder ids] (a folder goes with all its features and nested folders; one undo step; a sketch takes its dependent features with it) → deleted: [ids]; groups?: [folder ids deleted]; failing?: [{feature, error}] (later features that worked and now fail)"),
.params("feature: id|name | features: [id|name] | groups: [timeline folder ids] (a folder goes with all its features, joints and nested folders; one undo step; a sketch takes its dependent features with it) → deleted: [feature ids]; joints?: [joint ids]; groups?: [folder ids deleted]; failing?: [{feature, error}] (later features that worked and now fail)"),
CommandSpec::new("timeline.rename", "Rename", rename).icon("rename").params("feature: id|name, name (references to it follow)"),
CommandSpec::new("timeline.group", "New Feature Folder", group)
.icon("folder")
.doc_only()
.params("name?; features?: [id|name]; parent?: folder id (features leave any other timeline folder)"),
.params("name?; features?: [id|name]; joints?: [id|name]; groups?: [folder ids]; parent?: folder id (features and joints leave any other timeline folder)"),
CommandSpec::new("timeline.ungroup", "Remove Feature Folders", ungroup).icon("folder")
.doc_only()
.params("group: id | groups: [ids] (features remain in the timeline)"),
CommandSpec::new("timeline.rename_group", "Rename Feature Folder", rename_group).icon("rename").doc_only().params("group: id, name"),
.params("group: id | groups: [ids] (features and joints remain in the timeline)"),
CommandSpec::new("timeline.rename_group", "Rename Feature Folder", rename_group).icon("rename").doc_only().params("group: id, name (renames the folder holding features and joints)"),
CommandSpec::new("timeline.move_to_group", "Move Features to Folder", move_to_group).icon("folder")
.doc_only()
.params("features?: [id|name], groups?: [ids], group?: id (omit or null to move to the timeline root)"),
.params("features?: [id|name], joints?: [id|name], groups?: [ids], group?: id (omit or null to move to the timeline root)"),
CommandSpec::new("timeline.move_many", "Move Timeline Items", move_many).icon("reorder").params(
"features?: [id|name], groups?: [folder ids], group?: folder id, position: insertion slot, active?: bool (true = the moved contents end up before the history marker, false = behind it; other features keep their state); atomically changes folder membership and moves all selected contents as one block",
),
@@ -72,9 +72,9 @@ pub static COMMANDS: &[CommandSpec] = &[
.noundo()
.params("feature: id|name → features deleted with it, and features that would fail without it"),
CommandSpec::new("timeline.suppress", "Suppress Feature", suppress).icon("suppress").params(
"feature: id|name | features: [id|name] | groups: [timeline folder ids] (every feature in those folders and their nested \
folders; one undo step), suppressed?: bool (default toggles: several features are all \
suppressed unless all of them already are, then all are unsuppressed) → feature: id (or features: [ids]; groups: [ids] when given), suppressed",
"feature: id|name | features: [id|name] | groups: [timeline folder ids] (every feature and joint in those folders and their nested \
folders; one undo step), suppressed?: bool (default toggles: features and joints are all \
suppressed unless all of them already are, then all are unsuppressed) → feature: id (or features: [ids]; groups: [ids] and joints: [ids] when groups are given), suppressed",
),
CommandSpec::new("timeline.edit", "Edit Feature", edit_feature).icon("edit").params(
"feature: id|name, set: {fields to change, merged into the feature's definition as timeline.inspect returns it, \
@@ -83,7 +83,7 @@ pub static COMMANDS: &[CommandSpec] = &[
expressions are text, or a number in mm or degrees} → recomputed: features rebuilt, restored: features whose earlier result for the same inputs was reused",
),
CommandSpec::new("timeline.inspect", "Inspect Feature", inspect_feature).icon("inspect").noundo().params(
"feature: id|name → type, definition (the fields timeline.edit's set changes, with their current values), \
"group: folder id → group, name, parent, features, joints; or feature: id|name → type, definition (the fields timeline.edit's set changes, with their current values), \
inputs: [{label, param, expression, unit}] (named inputs parameters.change can also set), error, warning",
),
CommandSpec::new("select.set", "Select", select_set).icon("select")
@@ -205,10 +205,12 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
}
// A folder goes with its features and nested folders, its features in timeline order.
let (folders, contents) = group_tree(&s.doc, &groups);
let joints = group_joint_ids(&s.doc, &groups);
keys.extend(s.doc.features.iter().filter(|f| contents.contains(&f.id)).map(|f| f.id.to_string()));
// Features that work now; any of them failing after the delete is reported (a later
// feature that names a deleted body, #267).
let worked: std::collections::BTreeSet<u64> = s.model.results.iter().filter(|r| r.error.is_none() && !r.skipped).map(|r| r.id).collect();
super::joints::delete_joints(s.doc_mut(), &joints);
let mut gone = Vec::new();
for k in keys {
let Some(id) = s.doc.find_feature(&k).map(|f| f.id) else {
@@ -239,6 +241,9 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
.filter_map(|r| r.error.as_ref().map(|e| json!({"feature": r.name, "error": e})))
.collect();
let mut out = json!({"deleted": gone});
if !joints.is_empty() {
out["joints"] = json!(joints);
}
if !folders.is_empty() {
out["groups"] = json!(folders);
}
@@ -315,6 +320,26 @@ fn group_features(s: &Session, p: &Value, required: bool, cmd: &str) -> Result<V
Ok(ids)
}
fn group_joints(s: &Session, p: &Value, cmd: &str) -> Result<Vec<u64>> {
let values = match p.get("joints") {
None => return Ok(Vec::new()),
Some(Value::Array(values)) if values.len() <= 10_000 => values,
_ => return Err(bad(cmd, "`joints` must list at most 10000 joint ids or names")),
};
let mut ids = std::collections::BTreeSet::new();
for value in values {
ids.insert(super::joints::joint_id(s, Some(value), cmd)?);
}
Ok(ids.into_iter().collect())
}
fn group_joint_ids(doc: &solidcraft_doc::Document, roots: &[u64]) -> Vec<u64> {
let folders = group_tree(doc, roots).0;
let ids: std::collections::BTreeSet<u64> =
doc.timeline_groups.iter().filter(|group| folders.contains(&group.id)).flat_map(|group| group.joints.iter().copied()).collect();
doc.assembly.joints.iter().filter(|joint| ids.contains(&joint.id)).map(|joint| joint.id).collect()
}
fn group_ids(s: &Session, p: &Value, required: bool, cmd: &str) -> Result<Vec<u64>> {
let values = match p.get("groups") {
Some(Value::Array(values)) => values,
@@ -403,6 +428,7 @@ fn require_contiguous_groups(doc: &solidcraft_doc::Document, cmd: &str) -> Resul
fn group(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "timeline.group";
let mut features = group_features(s, p, false, cmd)?;
let mut joints = group_joints(s, p, cmd)?;
let groups = group_ids(s, p, false, cmd)?;
let name = match str_(p, "name").map(str::trim).filter(|name| !name.is_empty()) {
Some(name) if name.len() <= 128 => name.to_string(),
@@ -421,14 +447,17 @@ fn group(s: &mut Session, p: &Value) -> Result<Value> {
}
let nested = group_feature_ids(&s.doc, &groups);
features.retain(|feature| !nested.contains(feature));
let nested_joints = group_joint_ids(&s.doc, &groups);
joints.retain(|joint| !nested_joints.contains(joint));
let mut next = (*s.doc).clone();
for group in &mut next.timeline_groups {
group.joints.retain(|joint| !joints.contains(joint));
group.features.retain(|feature| !features.contains(feature));
if groups.contains(&group.id) {
group.parent = Some(id);
}
}
next.timeline_groups.push(solidcraft_doc::TimelineGroup { id, name: name.clone(), parent, features });
next.timeline_groups.push(solidcraft_doc::TimelineGroup { id, name: name.clone(), parent, features, joints });
sort_group_features(&mut next);
require_contiguous_groups(&next, cmd)?;
*s.doc_mut() = next;
@@ -469,18 +498,19 @@ fn ungroup(s: &mut Session, p: &Value) -> Result<Value> {
parent
};
let mut next = (*s.doc).clone();
let moved_features: Vec<(Option<u64>, Vec<u64>)> = next
let moved_features: Vec<(Option<u64>, Vec<u64>, Vec<u64>)> = next
.timeline_groups
.iter()
.filter(|group| ids.contains(&group.id))
.map(|group| (surviving_parent(group.parent), group.features.clone()))
.map(|group| (surviving_parent(group.parent), group.features.clone(), group.joints.clone()))
.collect();
for group in &mut next.timeline_groups {
if ids.contains(&group.id) {
continue;
}
group.parent = surviving_parent(group.parent);
for (_, features) in moved_features.iter().filter(|(parent, _)| *parent == Some(group.id)) {
for (_, features, joints) in moved_features.iter().filter(|(parent, _, _)| *parent == Some(group.id)) {
group.joints.extend(joints.iter().copied());
group.features.extend(features.iter().copied());
}
}
@@ -507,9 +537,10 @@ fn rename_group(s: &mut Session, p: &Value) -> Result<Value> {
fn move_to_group(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "timeline.move_to_group";
let features = group_features(s, p, false, cmd)?;
let joints = group_joints(s, p, cmd)?;
let groups = group_ids(s, p, false, cmd)?;
if features.is_empty() && groups.is_empty() {
return Err(bad(cmd, "list at least one feature or folder"));
if features.is_empty() && joints.is_empty() && groups.is_empty() {
return Err(bad(cmd, "list at least one feature, joint or folder"));
}
let target = match p.get("group") {
Some(Value::Null) | None => None,
@@ -522,8 +553,10 @@ fn move_to_group(s: &mut Session, p: &Value) -> Result<Value> {
}
let mut next = (*s.doc).clone();
for group in &mut next.timeline_groups {
group.joints.retain(|joint| !joints.contains(joint));
group.features.retain(|feature| !features.contains(feature));
if Some(group.id) == target {
group.joints.extend(joints.iter().copied());
group.features.extend(features.iter().copied());
}
}
@@ -535,7 +568,7 @@ fn move_to_group(s: &mut Session, p: &Value) -> Result<Value> {
sort_group_features(&mut next);
require_contiguous_groups(&next, cmd)?;
*s.doc_mut() = next;
Ok(json!({"features": features, "groups": groups, "group": target}))
Ok(json!({"features": features, "joints": joints, "groups": groups, "group": target}))
}
fn move_many(s: &mut Session, p: &Value) -> Result<Value> {
@@ -679,14 +712,18 @@ fn suppress(s: &mut Session, p: &Value) -> Result<Value> {
let contents = group_feature_ids(&s.doc, &groups);
let more: Vec<u64> = s.doc.features.iter().map(|f| f.id).filter(|id| contents.contains(id) && !ids.contains(id)).collect();
ids.extend(more);
if ids.is_empty() {
return Err(bad(cmd, "the timeline folders hold no features"));
let joints = group_joint_ids(&s.doc, &groups);
if ids.is_empty() && joints.is_empty() {
return Err(bad(cmd, "the timeline folders hold no features or joints"));
}
let want = match bool_(p, "suppressed") {
Some(b) => b,
None if p.get("suppressed").is_some_and(|v| !v.is_null()) => return Err(bad(cmd, "`suppressed` must be true or false")),
// Toggle: one feature flips; several are all suppressed unless all already are.
None => !ids.iter().all(|id| s.doc.feature(*id).is_some_and(|f| f.suppressed)),
None => {
!(ids.iter().all(|id| s.doc.feature(*id).is_some_and(|f| f.suppressed))
&& joints.iter().all(|id| s.doc.assembly.joints.iter().any(|joint| joint.id == *id && joint.suppressed)))
}
};
let keep = if want { counted_names_from(s, &ids) } else { Vec::new() };
// One command, so one undo step however many features it changes.
@@ -700,10 +737,11 @@ fn suppress(s: &mut Session, p: &Value) -> Result<Value> {
let f = doc.feature_mut(*id).ok_or_else(|| bad(cmd, format!("no feature {id}")))?;
f.suppressed = want;
}
super::joints::suppress_joints(s.doc_mut(), &joints, want);
match ids.as_slice() {
[id] if groups.is_empty() && p.get("features").is_none_or(Value::is_null) => Ok(json!({"feature": id, "suppressed": want})),
_ if groups.is_empty() => Ok(json!({"features": ids, "suppressed": want})),
_ => Ok(json!({"features": ids, "groups": groups, "suppressed": want})),
_ => Ok(json!({"features": ids, "joints": joints, "groups": groups, "suppressed": want})),
}
}
@@ -1063,6 +1101,11 @@ fn heavy_fields(kind: &FeatureKind) -> &'static [(&'static str, &'static str)] {
fn inspect_feature(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "timeline.inspect";
if p.get("group").is_some() {
let id = timeline_group_id(s, p, cmd)?;
let group = s.doc.timeline_groups.iter().find(|group| group.id == id).ok_or_else(|| bad(cmd, "folder"))?;
return Ok(json!({"group": id, "name": group.name, "parent": group.parent, "features": group.features, "joints": group.joints}));
}
let id = feature_id(s, p.get("feature"), cmd)?;
let f = s.doc.feature(id).ok_or_else(|| bad(cmd, "feature"))?;
let mut def = serde_json::to_value(f).map_err(|e| EngineError::Other(e.to_string()))?;
+83
View File
@@ -1354,3 +1354,86 @@ fn a_move_before_a_long_history_of_one_body_fits_a_window_threads_stack() {
.join();
assert!(moved.is_ok());
}
#[test]
fn timeline_joint_folders_are_mixed_saved_and_undoable() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane":"XY", "name":"Profile"}));
run(&mut s, "component.create", json!({"name":"Part"}));
let joint = run(&mut s, "joint.as_built", json!({"type":"revolute", "a":0, "b":"Part", "name":"Hinge"}))["joint"].as_u64().unwrap();
let folder = run(&mut s, "timeline.group", json!({"name":"Mixed", "features":["Profile"], "joints":["Hinge", joint]}))["group"].as_u64().unwrap();
assert_eq!(s.doc.timeline_groups[0].joints, [joint]);
assert_eq!(s.doc.timeline_groups[0].features.len(), 1);
assert_eq!(run(&mut s, "timeline.inspect", json!({"group":folder}))["joints"], json!([joint]));
let saved = s.doc.to_json();
let reopened = solidcraft_doc::Document::from_json(&saved).unwrap();
assert_eq!(reopened.to_json(), saved);
let mut damaged: Value = serde_json::from_str(&saved).unwrap();
damaged["timeline_groups"][0]["joints"] = json!([u64::MAX]);
assert!(solidcraft_doc::Document::from_json(&damaged.to_string()).is_err());
damaged["timeline_groups"][0]["joints"] = json!([joint, joint]);
assert!(solidcraft_doc::Document::from_json(&damaged.to_string()).is_err());
let destination = run(&mut s, "timeline.group", json!({"name":"Destination"}))["group"].as_u64().unwrap();
run(&mut s, "timeline.move_to_group", json!({"joints":[joint], "group":destination}));
assert!(s.doc.timeline_groups[0].joints.is_empty());
assert_eq!(s.doc.timeline_groups[1].joints, [joint]);
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.timeline_groups[0].joints, [joint]);
// Both contents of a nested folder participate in the same toggle and undo.
run(&mut s, "timeline.move_to_group", json!({"groups":[folder], "group":destination}));
let before = s.doc.clone();
run(&mut s, "timeline.suppress", json!({"groups":[destination]}));
assert!(s.doc.features[0].suppressed && s.doc.assembly.joints[0].suppressed);
run(&mut s, "timeline.suppress", json!({"groups":[destination]}));
assert!(!s.doc.features[0].suppressed && !s.doc.assembly.joints[0].suppressed);
run(&mut s, "edit.undo", json!({}));
assert!(s.doc.features[0].suppressed && s.doc.assembly.joints[0].suppressed);
run(&mut s, "edit.undo", json!({}));
assert_eq!(*s.doc, *before);
run(&mut s, "timeline.delete", json!({"groups":[destination]}));
assert!(s.doc.features.is_empty() && s.doc.assembly.joints.is_empty() && s.doc.timeline_groups.is_empty());
run(&mut s, "edit.undo", json!({}));
assert_eq!(*s.doc, *before);
run(&mut s, "timeline.ungroup", json!({"group":folder}));
assert_eq!(s.doc.timeline_groups[0].joints, [joint], "contents promote to the surviving parent");
run(&mut s, "timeline.ungroup", json!({"group":destination}));
assert!(s.doc.timeline_groups.is_empty());
assert_eq!(s.doc.assembly.joints[0].id, joint);
let folder = run(&mut s, "timeline.group", json!({"joints":vec![joint; 10_000]}))["group"].as_u64().unwrap();
assert_eq!(s.doc.timeline_groups[0].joints, [joint], "the 10000-entry cap is inclusive and deduplicated");
run(&mut s, "timeline.move_to_group", json!({"joints":["Hinge"], "group":null}));
assert!(s.doc.timeline_groups[0].joints.is_empty());
run(&mut s, "timeline.move_to_group", json!({"joints":["Hinge"], "group":folder}));
run(&mut s, "timeline.suppress", json!({"groups":[folder]}));
assert!(s.doc.assembly.joints[0].suppressed, "joint-only folders suppress too");
run(&mut s, "joint.delete", json!({"joint":joint}));
assert!(s.doc.timeline_groups[0].joints.is_empty());
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.timeline_groups[0].joints, [joint]);
run(&mut s, "component.delete", json!({"component":"Part"}));
assert!(s.doc.assembly.joints.is_empty());
assert!(s.doc.timeline_groups[0].joints.is_empty());
assert!(solidcraft_doc::Document::from_json(&s.doc.to_json()).is_ok());
}
#[test]
fn timeline_joint_folders_reject_hostile_joint_references_atomically() {
let mut s = Session::default();
run(&mut s, "component.create", json!({"name":"Part"}));
let joint = run(&mut s, "joint.as_built", json!({"type":"rigid", "a":0, "b":"Part"}))["joint"].as_u64().unwrap();
let folder = run(&mut s, "timeline.group", json!({}))["group"].as_u64().unwrap();
for command in ["timeline.group", "timeline.move_to_group"] {
for joints in
[json!([u64::MAX]), json!([-1]), json!([1e100]), json!(["missing"]), json!([{}]), json!([null]), json!("bad"), json!(vec![joint; 10_001])]
{
let before = s.doc.to_json();
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| s.execute(command, &json!({"joints":joints, "group":folder}))));
let error = result.unwrap().unwrap_err();
assert!(
matches!(&error, crate::EngineError::BadParams { .. }),
"{command} must reject invalid joint references without an internal panic: {error}"
);
assert_eq!(s.doc.to_json(), before);
}
}
}
+1 -1
View File
@@ -506,7 +506,7 @@ mod tests;
/// A snap point near `at` on the model: the nearest vertex, edge midpoint, planar face's centre
/// or circular edge's centre (the end of a cylindrical face).
pub(super) fn snap_point(s: &Session, kind: &str, at: Vec3) -> Option<Vec3> {
pub(crate) fn snap_point(s: &Session, kind: &str, at: Vec3) -> Option<Vec3> {
let st = s.world_state();
let near = |pts: Vec<Vec3>| pts.into_iter().min_by(|a, b| a.dist(at).total_cmp(&b.dist(at)));
match kind {
+59 -13
View File
@@ -43,7 +43,7 @@ pub static COMMANDS: &[CommandSpec] = &[
closed loop follow, and the motion stops where the loop can no longer close → stopped_by_loop); quiet?: bool (leave out dof \
and fixed) → values: [{name, value, unit: deg|mm, limits}] as joint.list gives them; moved: whether any occurrence moved; \
reason: why the joint can move nothing (suppressed, its origins lost, or its chain joined to nothing grounded → unconnected: \
the occurrence named)",
the occurrence named); index?: zero-based coordinate to drive with value (the other coordinates follow); continuous?: bool (drag frame, suppress UI echo)",
),
CommandSpec::new("joint.reach", "Reach Point", reach).icon("reach").params(
"occurrence: id|name; point: [x,y,z] (where its point goes, world); at?: [x,y,z] (its point, in the world where it is now; default: the centre of its bodies' box); z?: [x,y,z] (the way its axis then points); axis?: [x,y,z] (that axis, in the world now; default: its own z); joints?: [ids or names] (only these move toward the point; the other joints of a closed loop they are in follow); quiet?: bool; continuous?: bool (a frame of a drag: the answer says continuous, and the app's command line doesn't echo it). Moves the values of the joints on its chain from where they are to the nearest pose, within their limits, through motion links, keeping closed loops closed as joint.drive does (contact sets are not checked) → reached; error_mm (and error_deg with z): how close it got; point: where its point is; values: [{joint, name, values}] of the joints that moved, as joint.list gives them; moved; stopped_by_limit: [{joint, value, limit: min|max}]: the limits it rests on short of the point; stopped_by_loop: {joint}: the closed loop that stopped it (its chain alone gets closer); reason: why nothing can move",
@@ -858,7 +858,27 @@ fn drive(s: &mut Session, p: &Value) -> Result<Value> {
if kind == JointKind::Rigid {
return Err(bad(cmd, "a rigid joint does not move: joint.edit can make it revolute (turns), slider (slides) or cylindrical (both)"));
}
let mut q = values_param(s, p, kind, cmd)?;
let index = match p.get("index") {
None => None,
Some(v) => Some(
v.as_u64()
.and_then(|i| usize::try_from(i).ok())
.filter(|i| *i < kind.dofs().len())
.ok_or_else(|| bad(cmd, "index is outside this joint's coordinates"))?,
),
};
let mut q = if let Some(index) = index {
if p.get("values").is_some() {
return Err(bad(cmd, "index goes with value, not values"));
}
let mut q = joints::joint_values(&s.doc.assembly).remove(&id).unwrap_or_else(|| vec![0.0; kind.dofs().len()]);
if let Some(slot) = q.get_mut(index) {
*slot = value(s, p.get("value").ok_or_else(|| bad(cmd, "give value"))?, kind.is_angle(index), cmd)?;
}
q
} else {
values_param(s, p, kind, cmd)?
};
if p.get("values").is_none() && p.get("value").is_none() {
return Err(bad(cmd, "give `value` or `values`"));
}
@@ -869,23 +889,32 @@ fn drive(s: &mut Session, p: &Value) -> Result<Value> {
Some(Value::Array(a)) => a.len(),
_ => 1,
};
for (i, v) in q.iter_mut().enumerate().skip(given) {
*v = j.values.get(i).copied().unwrap_or(*v);
if index.is_none() {
for (i, v) in q.iter_mut().enumerate().skip(given) {
*v = j.values.get(i).copied().unwrap_or(*v);
}
}
joints::clamp(j, &mut q);
// Moved as far toward `q` as contact sets and closed loops allow. Values left out of
// `values` are not driven: in a closed loop they follow.
// Driving moves the joint by hand: the values driven drop their expressions (an expression
// given here is worked out once, as contact or a loop may stop the motion short of it).
for i in 0..given.min(kind.dofs().len()) {
j.exprs.set_value(i, None);
if let Some(index) = index {
j.exprs.set_value(index, None);
} else {
for i in 0..given.min(kind.dofs().len()) {
j.exprs.set_value(i, None);
}
}
let before = super::motion::positions(s);
let r = super::motion::drive_joint(s, id, q, given)?;
let r = if let Some(index) = index { super::motion::drive_joint_index(s, id, q, index)? } else { super::motion::drive_joint(s, id, q, given)? };
let sol = solution(s, &BTreeMap::new(), response_analysis(p));
let mut out = sol_json(&sol, p);
out["joint"] = json!(id);
out["values"] = json!(effective_values(s, id));
if bool_(p, "continuous") == Some(true) {
out["continuous"] = json!(true);
}
out["stopped_by_contact"] = r["stopped_by_contact"].clone();
out["stopped_by_loop"] = r["stopped_by_loop"].clone();
out["moved"] = json!(super::motion::moved(&before, &super::motion::positions(s)).next().is_some());
@@ -1364,12 +1393,12 @@ fn edit(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(f) = bool_(p, "flip") {
j.flip = f;
}
if let Some(x) = bool_(p, "suppressed") {
j.suppressed = x;
}
if let Some(n) = str_(p, "name").map(str::trim).filter(|n| !n.is_empty() && n.len() <= 128) {
j.name = n.to_string();
}
if let Some(x) = bool_(p, "suppressed") {
suppress_joints(s.doc_mut(), &[id], x);
}
// A relation whose type, offset, tilt or flip changes starts again from where its parts are.
let reseat = kind.is_some() || offset.is_some() || angle.is_some() || p.get("flip").is_some();
if reseat {
@@ -1384,6 +1413,25 @@ fn edit(s: &mut Session, p: &Value) -> Result<Value> {
Ok(sol_json(&sol, p))
}
/// Shared mutation for individual joints and timeline folders; the caller resolves once.
pub(super) fn suppress_joints(doc: &mut solidcraft_doc::Document, ids: &[u64], suppressed: bool) {
let ids: std::collections::BTreeSet<_> = ids.iter().copied().collect();
for joint in &mut doc.assembly.joints {
if ids.contains(&joint.id) {
joint.suppressed = suppressed;
}
}
}
pub(super) fn delete_joints(doc: &mut solidcraft_doc::Document, ids: &[u64]) {
let ids: std::collections::BTreeSet<_> = ids.iter().copied().collect();
doc.assembly.joints.retain(|joint| !ids.contains(&joint.id));
doc.assembly.links.retain(|link| !ids.contains(&link.a) && !ids.contains(&link.b));
for group in &mut doc.timeline_groups {
group.joints.retain(|id| !ids.contains(id));
}
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "joint.delete";
if let Some(name) = str_(p, "origin") {
@@ -1406,9 +1454,7 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
return Ok(json!({"deleted_link": i}));
}
let id = joint_id(s, p.get("joint"), cmd)?;
let doc = s.doc_mut();
doc.assembly.joints.retain(|j| j.id != id);
doc.assembly.links.retain(|l| l.a != id && l.b != id);
delete_joints(s.doc_mut(), &[id]);
let sol = resolve(s);
let mut out = sol_json(&sol, p);
out["deleted"] = json!(id);
+31
View File
@@ -2208,3 +2208,34 @@ fn a_parameter_change_solves_the_joints_once() {
assert!(super::solve_counts::take().0 > 0);
assert!(near(place(&s, "Bracket", Vec3::new(110.0, 210.0, 0.0)), Vec3::new(180.0, 50.0, 1.0)));
}
#[test]
fn driving_one_cylindrical_coordinate_preserves_the_other_expression_and_reports_continuous() {
let mut s = Session::default();
run(&mut s, "component.create", json!({"name":"Part","grounded":false}));
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10}));
run(&mut s, "component.activate", json!({"component":"root"}));
run(&mut s, "parameters.add", json!({"name":"turn","expression":"30 deg"}));
let joint = run(
&mut s,
"joint.create",
json!({"type":"cylindrical","a":{"occurrence":0,"point":[0,0,0]},"b":{"occurrence":"Part","point":[0,0,0]},"flip":true,"values":["turn",0]}),
)["joint"]
.as_u64()
.unwrap();
let before = s.doc.assembly.joints.iter().find(|j| j.id == joint).unwrap().exprs.value(0).map(str::to_string);
assert!(before.is_some());
let reply = run(&mut s, "joint.drive", json!({"joint":joint,"index":1,"value":12,"continuous":true,"quiet":true}));
assert_eq!(reply["continuous"], true);
let j = s.doc.assembly.joints.iter().find(|j| j.id == joint).unwrap();
assert_eq!(j.exprs.value(0).map(str::to_string), before);
assert!((j.values[0].to_degrees() - 30.).abs() < 1e-7);
assert!((j.values[1] - 12.).abs() < 1e-7);
run(&mut s, "parameters.change", json!({"name":"turn","expression":"40 deg"}));
let j = s.doc.assembly.joints.iter().find(|j| j.id == joint).unwrap();
assert!((j.values[0].to_degrees() - 40.).abs() < 1e-7, "the preserved expression still drives the angle");
assert!((j.values[1] - 12.).abs() < 1e-7);
for bad in [json!(-1), json!(2), json!(1000000), json!("0")] {
assert!(s.execute("joint.drive", &json!({"joint":joint,"index":bad,"value":0})).is_err());
}
}
+402 -398
View File
@@ -1,400 +1,404 @@
//! The command registry. Ids are SolidCraft's own dotted ids (`solid.extrude`,
//! `sketch.rectangle.two_point`); the toolbar placement (`tab`, `panel`) follows the familiar
//! Design workspace layout.
//! The command registry. Ids are SolidCraft's own dotted ids (`solid.extrude`,
//! `sketch.rectangle.two_point`); the toolbar placement (`tab`, `panel`) follows the familiar
//! Design workspace layout.
/// The params-doc note of every command that adds sketch geometry (see `sketch::created`).
macro_rules! created_doc {
() => {
". Returns created: {points, curves, constraints, dimensions}: the ids of what it added (stable and never reused: usable right away in sketch.dimension, sketch.constraint.*, sketch.delete…)"
};
}
/// A command that is designed and registered (id, toolbar place, icon, params doc) but not built
/// yet. It is disabled with the reason, so the toolbar greys it and says why, and its `run`
/// refuses too, however it is reached. `$issue` is the GitHub issue that tracks building it.
macro_rules! planned {
($id:literal, $label:literal, $issue:literal) => {
CommandSpec::new($id, $label, |_, _| Err(crate::EngineError::Other(concat!("not supported yet: ", $label, " (issue #", $issue, ")").into())))
.enabled(|_| Err(concat!("not supported yet (issue #", $issue, ")").into()))
.planned($issue)
};
}
mod bom;
mod browser;
pub(crate) mod clearance;
mod clipboard;
mod compare;
mod component;
mod component_clone;
mod component_clone_assembly;
mod construct;
mod delete;
mod diagnostics;
mod edit;
mod face;
mod features;
mod features_more;
mod join_pieces;
pub(crate) use face::{FaceRefForm, face_ref_form};
pub(crate) use features::profiles as feature_profiles;
pub(crate) use features::{EdgeRefForm, axis_dir_form, edge_ref_form, sketch_key};
pub use features::{auto_operation, extrude_reach, shown_scope};
pub use join_pieces::{Addition, addition};
pub use sketch::dimension_roles;
pub use sketch_import::drawing_import_command;
pub use sketch_modify::{extend_arc_preview, extend_preview, trim_piece};
pub use view_dxf::sketch::exportable as view_dxf_exportable;
pub use view_navigation::standard_view;
mod component_link;
pub use component_link::resolve_design_links;
mod config;
mod design_link;
mod file;
mod frame;
pub(crate) mod inspect;
mod interference;
pub(crate) mod joints;
mod libraries;
mod material_properties;
mod materials;
mod measure_sel;
mod measure_shape;
mod mesh;
mod metadata;
pub(crate) mod motion;
mod parts;
mod point_cloud;
mod script;
mod section;
pub(crate) use measure_sel::{measure_items, shape_of};
pub(crate) use measure_shape::Shape;
#[cfg(test)]
mod kernel_heal_tests;
mod parameter_sweep;
mod parameters;
mod plastic;
mod render;
mod render_ui;
#[cfg(test)]
mod script_tests;
mod sheet;
pub(crate) use sheet::{
FlangeType, ThenStep, flange_edge, flange_edge_list, flange_position, flange_type, fold_points, fold_position, perforation_pattern, sheet_points,
then_list,
};
mod silhouette;
mod sketch;
pub(crate) use sketch::author_path_for_rebind;
pub(crate) use sketch::created as sketch_created;
mod canvas_sketch;
mod sketch_3d;
mod sketch_canvas;
pub mod sketch_clipboard;
mod sketch_constraints;
mod sketch_create;
mod sketch_ends;
mod sketch_freeform;
mod sketch_import;
mod sketch_inspect;
mod sketch_modify;
mod sketch_project;
mod sketch_store;
mod surface;
mod surface_analysis;
mod surface_boundary;
mod surface_match;
mod view_dxf;
mod view_navigation;
use serde::Serialize;
use serde_json::Value;
use crate::{Result, Session};
pub type Run = fn(&mut Session, &Value) -> Result<Value>;
/// A command's form for one frame of an interactive gesture: its result and the sketch it
/// changed, for [`Session::execute`] to show without evaluating the rest of the model (`None`:
/// evaluate as usual).
pub type LiveRun = fn(&mut Session, &Value) -> Result<(Value, Option<crate::LiveEdit>)>;
pub type Enabled = fn(&Session) -> std::result::Result<(), String>;
pub type SchemaFn = fn() -> schemars::Schema;
pub struct CommandSpec {
pub id: &'static str,
pub label: &'static str,
/// Toolbar tab (`SOLID`, `SKETCH`…); empty = not on the toolbar.
pub tab: &'static str,
/// Toolbar panel (`CREATE`, `MODIFY`…).
pub panel: &'static str,
/// Icon name for the UI (drawn in code).
pub icon: &'static str,
pub shortcut: Option<&'static str>,
/// One-line parameter documentation.
pub params: &'static str,
pub enabled: Enabled,
pub run: Run,
pub undoable: bool,
/// What runs instead of `run` when the command is given `live: true` (a frame of an
/// interactive gesture, see [`Session::execute`]).
pub live: Option<LiveRun>,
/// Reads the evaluated model (the default). A command that reads only the document and the
/// session's history runs on a model left stale by a live gesture without evaluating it first.
pub reads_model: bool,
/// Registered but not built yet (see `planned!`): the GitHub issue number that tracks it.
pub planned: Option<&'static str>,
/// The command's typed parameter contract, which carries its input schema; `None` until
/// the command is converted (see `crate::schema`).
pub contract: Option<&'static dyn crate::schema::Contract>,
/// The JSON Schema of a successful result; `None` until described.
pub output: Option<SchemaFn>,
}
impl CommandSpec {
pub const fn new(id: &'static str, label: &'static str, run: Run) -> Self {
CommandSpec {
id,
label,
tab: "",
panel: "",
icon: "",
shortcut: None,
params: "",
enabled: always,
run,
undoable: true,
live: None,
reads_model: true,
planned: None,
contract: None,
output: None,
}
}
/// Not built yet; `issue` tracks building it.
pub const fn planned(mut self, issue: &'static str) -> Self {
self.planned = Some(issue);
self
}
/// Takes `live: true`, run by `run`.
pub const fn live(mut self, run: LiveRun) -> Self {
self.live = Some(run);
self
}
/// Reads only the document (and history), never the evaluated model.
pub const fn doc_only(mut self) -> Self {
self.reads_model = false;
self
}
pub const fn at(mut self, tab: &'static str, panel: &'static str) -> Self {
self.tab = tab;
self.panel = panel;
self
}
pub const fn icon(mut self, i: &'static str) -> Self {
self.icon = i;
self
}
pub const fn key(mut self, k: &'static str) -> Self {
self.shortcut = Some(k);
self
}
/// The command's typed parameter contract (see `crate::schema`).
pub const fn contract(mut self, c: &'static dyn crate::schema::Contract) -> Self {
self.contract = Some(c);
self
}
/// The JSON Schema of a successful result.
pub const fn output(mut self, f: SchemaFn) -> Self {
self.output = Some(f);
self
}
pub const fn params(mut self, p: &'static str) -> Self {
self.params = p;
self
}
pub const fn enabled(mut self, e: Enabled) -> Self {
self.enabled = e;
self
}
pub const fn noundo(mut self) -> Self {
self.undoable = false;
self
}
/// Run inside another command: when it is enabled, its `run` (the caller's command owns the
/// undo step and the evaluation).
pub(crate) fn run_nested(&self, s: &mut Session, p: &Value) -> Result<Value> {
(self.enabled)(s).map_err(|why| crate::EngineError::Disabled(self.id.to_string(), why))?;
let r = (self.run)(s, p);
// A parameter error that did not name the command names it here (see `Session::execute`).
match r {
Err(crate::EngineError::BadParams { cmd, msg }) if cmd.is_empty() => Err(crate::EngineError::BadParams { cmd: self.id.to_string(), msg }),
other => other,
}
}
pub fn info(&self, s: &Session) -> CommandInfo {
let e = (self.enabled)(s);
let (label, icon) = match self.id {
"inspect.colored_components" if s.colored_components.is_some() => ("Disable Component Colors", "colored_components_off"),
_ => (self.label, self.icon),
};
CommandInfo {
id: self.id,
label,
tab: self.tab,
panel: self.panel,
icon,
shortcut: self.shortcut,
params: self.params,
enabled: e.is_ok(),
disabled_reason: e.err(),
planned: self.planned,
}
}
}
#[derive(Clone, Debug, Serialize)]
pub struct CommandInfo {
pub id: &'static str,
pub label: &'static str,
pub tab: &'static str,
pub panel: &'static str,
pub icon: &'static str,
pub shortcut: Option<&'static str>,
pub params: &'static str,
pub enabled: bool,
pub disabled_reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub planned: Option<&'static str>,
}
pub fn always(_: &Session) -> std::result::Result<(), String> {
Ok(())
}
pub fn in_sketch(s: &Session) -> std::result::Result<(), String> {
if s.active_sketch.is_some() { Ok(()) } else { Err("no sketch is being edited (Create Sketch or Edit Sketch first)".into()) }
}
/// The mirror of [`in_sketch`], for a command that acts on bodies and has nothing to offer the
/// geometry under the cursor while a sketch is open.
pub fn not_in_sketch(s: &Session) -> std::result::Result<(), String> {
if s.active_sketch.is_none() { Ok(()) } else { Err("a sketch is being edited (this works on bodies: Finish Sketch first)".into()) }
}
/// Every command, in toolbar order.
pub fn command_specs() -> Vec<&'static CommandSpec> {
let mut v: Vec<&'static CommandSpec> = Vec::new();
v.extend(sketch::COMMANDS.iter());
v.extend(sketch_create::COMMANDS.iter());
v.extend(sketch_constraints::COMMANDS.iter());
v.extend(sketch_import::COMMANDS.iter());
v.extend(sketch_inspect::COMMANDS.iter());
v.extend(sketch_ends::COMMANDS.iter());
v.extend(sketch_canvas::COMMANDS.iter());
v.extend(canvas_sketch::COMMANDS.iter());
v.extend(point_cloud::COMMANDS.iter());
v.extend(sketch_3d::COMMANDS.iter());
v.extend(surface_analysis::COMMANDS.iter());
v.extend(surface_boundary::COMMANDS.iter());
v.extend(surface::COMMANDS.iter());
v.extend(surface_match::COMMANDS.iter());
v.extend(sketch_modify::COMMANDS.iter());
v.extend(sketch_project::COMMANDS.iter());
v.extend(features::COMMANDS.iter());
v.extend(features_more::COMMANDS.iter());
v.extend(mesh::COMMANDS.iter());
v.extend(construct::COMMANDS.iter());
v.extend(sheet::COMMANDS.iter());
v.extend(plastic::COMMANDS.iter());
v.extend(frame::COMMANDS.iter());
v.extend(component::COMMANDS.iter());
v.extend(component_link::COMMANDS.iter());
v.extend(joints::COMMANDS.iter());
v.extend(clearance::COMMANDS.iter());
v.extend(motion::COMMANDS.iter());
v.extend(config::COMMANDS.iter());
v.extend(parts::COMMANDS.iter());
v.extend(bom::COMMANDS.iter());
v.extend(browser::COMMANDS.iter());
v.extend(delete::COMMANDS.iter());
v.extend(edit::COMMANDS.iter());
v.extend(clipboard::COMMANDS.iter());
v.extend(sketch_clipboard::COMMANDS.iter());
v.extend(script::COMMANDS.iter());
v.extend(parameters::COMMANDS.iter());
v.extend(parameter_sweep::COMMANDS.iter());
v.extend(file::COMMANDS.iter());
v.extend(metadata::COMMANDS.iter());
v.extend(inspect::COMMANDS.iter());
v.extend(compare::COMMANDS.iter());
v.extend(silhouette::COMMANDS.iter());
v.extend(materials::COMMANDS.iter());
v.extend(material_properties::COMMANDS.iter());
v.extend(libraries::COMMANDS.iter());
v.extend(section::COMMANDS.iter());
v.extend(view_navigation::COMMANDS.iter());
v.extend(view_dxf::COMMANDS.iter());
v.extend(diagnostics::COMMANDS.iter());
v.extend(render::COMMANDS.iter());
v.extend(render_ui::COMMANDS.iter());
v
}
pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
let all = command_specs();
all.iter().find(|c| c.id == id).or_else(|| all.iter().find(|c| c.id.eq_ignore_ascii_case(id))).copied()
}
/// The end of an unknown command's error: up to three ids like `id`, nearest first. Ids a few
/// letters away (`solid.extrud` → `solid.extrude`) come before ids that end in the same word
/// (`file.open` → `doc.open`). Empty when nothing is close.
pub(crate) fn did_you_mean(id: &str) -> String {
let id = id.to_ascii_lowercase();
if id.len() > 64 {
return String::new();
}
let last = |s: &str| s.rsplit('.').next().unwrap_or(s).to_string();
let near = (id.chars().count() / 4).max(2);
let mut found: Vec<(bool, usize, &str)> = command_specs()
.iter()
.filter_map(|c| {
let d = edit_distance(&id, c.id);
(d <= near || last(&id) == last(c.id)).then_some((d > near, d, c.id))
})
.collect();
found.sort();
let ids: Vec<String> = found.iter().take(3).map(|(_, _, c)| format!("`{c}`")).collect();
match ids.as_slice() {
[] => String::new(),
[one] => format!(" (did you mean {one}?)"),
[rest @ .., end] => format!(" (did you mean {} or {end}?)", rest.join(", ")),
}
}
/// Levenshtein distance: the single-character edits that turn `a` into `b`.
pub(crate) fn edit_distance(a: &str, b: &str) -> usize {
let b: Vec<char> = b.chars().collect();
let mut row: Vec<usize> = (0..=b.len()).collect();
for (i, ca) in a.chars().enumerate() {
let mut prev = row[0];
row[0] = i + 1;
for (j, cb) in b.iter().enumerate() {
let next = (prev + usize::from(ca != *cb)).min(row[j] + 1).min(row[j + 1] + 1);
prev = row[j + 1];
row[j + 1] = next;
}
}
row[b.len()]
}
pub(super) fn alignment_param(p: &Value, key: &str, cmd: &str) -> Result<solidcraft_sketch::PatternAlignment> {
p.get(key).map_or(Ok(solidcraft_sketch::PatternAlignment::default()), |value| {
serde_json::from_value(value.clone()).map_err(|_| crate::params::bad(cmd, format!("`{key}` must be left, center or right")))
})
}
pub(super) fn reversed_param(p: &Value, key: &str, cmd: &str) -> Result<bool> {
p.get(key).map_or(Ok(false), |value| {
serde_json::from_value(value.clone()).map_err(|_| crate::params::bad(cmd, format!("`{key}` must be true or false")))
})
}
/// The params-doc note of every command that adds sketch geometry (see `sketch::created`).
macro_rules! created_doc {
() => {
". Returns created: {points, curves, constraints, dimensions}: the ids of what it added (stable and never reused: usable right away in sketch.dimension, sketch.constraint.*, sketch.delete…)"
};
}
/// A command that is designed and registered (id, toolbar place, icon, params doc) but not built
/// yet. It is disabled with the reason, so the toolbar greys it and says why, and its `run`
/// refuses too, however it is reached. `$issue` is the GitHub issue that tracks building it.
macro_rules! planned {
($id:literal, $label:literal, $issue:literal) => {
CommandSpec::new($id, $label, |_, _| Err(crate::EngineError::Other(concat!("not supported yet: ", $label, " (issue #", $issue, ")").into())))
.enabled(|_| Err(concat!("not supported yet (issue #", $issue, ")").into()))
.planned($issue)
};
}
mod bom;
mod browser;
pub(crate) mod clearance;
mod clipboard;
mod compare;
mod component;
mod component_clone;
mod component_clone_assembly;
mod construct;
mod delete;
mod diagnostics;
mod edit;
mod face;
mod features;
mod features_more;
mod join_pieces;
pub(crate) use face::{FaceRefForm, face_ref_form};
pub(crate) use features::profiles as feature_profiles;
pub(crate) use features::{EdgeRefForm, axis_dir_form, edge_ref_form, sketch_key};
pub use features::{auto_operation, extrude_reach, shown_scope};
pub use join_pieces::{Addition, addition};
pub use sketch::dimension_roles;
pub use sketch_import::drawing_import_command;
pub use sketch_modify::{extend_arc_preview, extend_preview, trim_piece};
pub use view_dxf::sketch::exportable as view_dxf_exportable;
pub use view_navigation::standard_view;
mod component_link;
pub use component_link::resolve_design_links;
mod config;
mod design_link;
mod file;
mod frame;
pub(crate) mod inspect;
mod interference;
pub(crate) mod joints;
mod libraries;
mod material_properties;
mod materials;
mod measure_sel;
mod measure_shape;
mod mesh;
mod metadata;
pub(crate) mod motion;
mod parts;
mod point_cloud;
mod script;
mod section;
pub(crate) use measure_sel::{measure_items, shape_of};
pub(crate) use measure_shape::Shape;
#[cfg(test)]
mod kernel_heal_tests;
mod parameter_sweep;
mod parameters;
mod plastic;
mod render;
mod render_ui;
#[cfg(test)]
mod script_tests;
mod sheet;
pub(crate) use sheet::{
FlangeType, ThenStep, flange_edge, flange_edge_list, flange_position, flange_type, fold_points, fold_position, perforation_pattern, sheet_points,
then_list,
};
mod silhouette;
mod sketch;
pub(crate) use sketch::author_path_for_rebind;
pub(crate) use sketch::created as sketch_created;
mod canvas_sketch;
mod sketch_3d;
mod sketch_canvas;
pub mod sketch_clipboard;
mod sketch_constraints;
mod sketch_create;
mod sketch_ends;
mod sketch_freeform;
mod sketch_import;
mod sketch_inspect;
mod sketch_modify;
mod sketch_project;
mod sketch_store;
mod surface;
mod surface_analysis;
mod surface_boundary;
mod surface_match;
mod view_dxf;
mod view_navigation;
use serde::Serialize;
use serde_json::Value;
use crate::{Result, Session};
pub type Run = fn(&mut Session, &Value) -> Result<Value>;
/// A command's form for one frame of an interactive gesture: its result and the sketch it
/// changed, for [`Session::execute`] to show without evaluating the rest of the model (`None`:
/// evaluate as usual).
pub type LiveRun = fn(&mut Session, &Value) -> Result<(Value, Option<crate::LiveEdit>)>;
pub type Enabled = fn(&Session) -> std::result::Result<(), String>;
pub type SchemaFn = fn() -> schemars::Schema;
pub struct CommandSpec {
pub id: &'static str,
pub label: &'static str,
/// Toolbar tab (`SOLID`, `SKETCH`…); empty = not on the toolbar.
pub tab: &'static str,
/// Toolbar panel (`CREATE`, `MODIFY`…).
pub panel: &'static str,
/// Icon name for the UI (drawn in code).
pub icon: &'static str,
pub shortcut: Option<&'static str>,
/// One-line parameter documentation.
pub params: &'static str,
pub enabled: Enabled,
pub run: Run,
pub undoable: bool,
/// What runs instead of `run` when the command is given `live: true` (a frame of an
/// interactive gesture, see [`Session::execute`]).
pub live: Option<LiveRun>,
/// Reads the evaluated model (the default). A command that reads only the document and the
/// session's history runs on a model left stale by a live gesture without evaluating it first.
pub reads_model: bool,
/// Registered but not built yet (see `planned!`): the GitHub issue number that tracks it.
pub planned: Option<&'static str>,
/// The command's typed parameter contract, which carries its input schema; `None` until
/// the command is converted (see `crate::schema`).
pub contract: Option<&'static dyn crate::schema::Contract>,
/// The JSON Schema of a successful result; `None` until described.
pub output: Option<SchemaFn>,
}
impl CommandSpec {
pub const fn new(id: &'static str, label: &'static str, run: Run) -> Self {
CommandSpec {
id,
label,
tab: "",
panel: "",
icon: "",
shortcut: None,
params: "",
enabled: always,
run,
undoable: true,
live: None,
reads_model: true,
planned: None,
contract: None,
output: None,
}
}
/// Not built yet; `issue` tracks building it.
pub const fn planned(mut self, issue: &'static str) -> Self {
self.planned = Some(issue);
self
}
/// Takes `live: true`, run by `run`.
pub const fn live(mut self, run: LiveRun) -> Self {
self.live = Some(run);
self
}
/// Reads only the document (and history), never the evaluated model.
pub const fn doc_only(mut self) -> Self {
self.reads_model = false;
self
}
pub const fn at(mut self, tab: &'static str, panel: &'static str) -> Self {
self.tab = tab;
self.panel = panel;
self
}
pub const fn icon(mut self, i: &'static str) -> Self {
self.icon = i;
self
}
pub const fn key(mut self, k: &'static str) -> Self {
self.shortcut = Some(k);
self
}
/// The command's typed parameter contract (see `crate::schema`).
pub const fn contract(mut self, c: &'static dyn crate::schema::Contract) -> Self {
self.contract = Some(c);
self
}
/// The JSON Schema of a successful result.
pub const fn output(mut self, f: SchemaFn) -> Self {
self.output = Some(f);
self
}
pub const fn params(mut self, p: &'static str) -> Self {
self.params = p;
self
}
pub const fn enabled(mut self, e: Enabled) -> Self {
self.enabled = e;
self
}
pub const fn noundo(mut self) -> Self {
self.undoable = false;
self
}
/// Run inside another command: when it is enabled, its `run` (the caller's command owns the
/// undo step and the evaluation).
pub(crate) fn run_nested(&self, s: &mut Session, p: &Value) -> Result<Value> {
(self.enabled)(s).map_err(|why| crate::EngineError::Disabled(self.id.to_string(), why))?;
let r = (self.run)(s, p);
// A parameter error that did not name the command names it here (see `Session::execute`).
match r {
Err(crate::EngineError::BadParams { cmd, msg }) if cmd.is_empty() => Err(crate::EngineError::BadParams { cmd: self.id.to_string(), msg }),
other => other,
}
}
pub fn info(&self, s: &Session) -> CommandInfo {
let e = (self.enabled)(s);
let (label, icon) = match self.id {
"inspect.colored_components" if s.colored_components.is_some() => ("Disable Component Colors", "colored_components_off"),
_ => (self.label, self.icon),
};
CommandInfo {
id: self.id,
label,
tab: self.tab,
panel: self.panel,
icon,
shortcut: self.shortcut,
params: self.params,
enabled: e.is_ok(),
disabled_reason: e.err(),
planned: self.planned,
}
}
}
#[derive(Clone, Debug, Serialize)]
pub struct CommandInfo {
pub id: &'static str,
pub label: &'static str,
pub tab: &'static str,
pub panel: &'static str,
pub icon: &'static str,
pub shortcut: Option<&'static str>,
pub params: &'static str,
pub enabled: bool,
pub disabled_reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub planned: Option<&'static str>,
}
pub fn always(_: &Session) -> std::result::Result<(), String> {
Ok(())
}
pub fn in_sketch(s: &Session) -> std::result::Result<(), String> {
if s.active_sketch.is_some() { Ok(()) } else { Err("no sketch is being edited (Create Sketch or Edit Sketch first)".into()) }
}
/// The mirror of [`in_sketch`], for a command that acts on bodies and has nothing to offer the
/// geometry under the cursor while a sketch is open.
pub fn not_in_sketch(s: &Session) -> std::result::Result<(), String> {
if s.active_sketch.is_none() { Ok(()) } else { Err("a sketch is being edited (this works on bodies: Finish Sketch first)".into()) }
}
/// Every command, in toolbar order.
pub fn command_specs() -> Vec<&'static CommandSpec> {
let mut v: Vec<&'static CommandSpec> = Vec::new();
v.extend(sketch::COMMANDS.iter());
v.extend(sketch_create::COMMANDS.iter());
v.extend(sketch_constraints::COMMANDS.iter());
v.extend(sketch_import::COMMANDS.iter());
v.extend(sketch_inspect::COMMANDS.iter());
v.extend(sketch_ends::COMMANDS.iter());
v.extend(sketch_canvas::COMMANDS.iter());
v.extend(canvas_sketch::COMMANDS.iter());
v.extend(point_cloud::COMMANDS.iter());
v.extend(sketch_3d::COMMANDS.iter());
v.extend(surface_analysis::COMMANDS.iter());
v.extend(surface_boundary::COMMANDS.iter());
v.extend(surface::COMMANDS.iter());
v.extend(surface_match::COMMANDS.iter());
v.extend(sketch_modify::COMMANDS.iter());
v.extend(sketch_project::COMMANDS.iter());
v.extend(features::COMMANDS.iter());
v.extend(features_more::COMMANDS.iter());
v.extend(mesh::COMMANDS.iter());
v.extend(construct::COMMANDS.iter());
v.extend(sheet::COMMANDS.iter());
v.extend(plastic::COMMANDS.iter());
v.extend(frame::COMMANDS.iter());
v.extend(component::COMMANDS.iter());
v.extend(component_link::COMMANDS.iter());
v.extend(joints::COMMANDS.iter());
v.extend(clearance::COMMANDS.iter());
v.extend(motion::COMMANDS.iter());
v.extend(config::COMMANDS.iter());
v.extend(parts::COMMANDS.iter());
v.extend(bom::COMMANDS.iter());
v.extend(browser::COMMANDS.iter());
v.extend(delete::COMMANDS.iter());
v.extend(edit::COMMANDS.iter());
v.extend(clipboard::COMMANDS.iter());
v.extend(sketch_clipboard::COMMANDS.iter());
v.extend(script::COMMANDS.iter());
v.extend(parameters::COMMANDS.iter());
v.extend(parameter_sweep::COMMANDS.iter());
v.extend(file::COMMANDS.iter());
v.extend(metadata::COMMANDS.iter());
v.extend(inspect::COMMANDS.iter());
v.extend(compare::COMMANDS.iter());
v.extend(silhouette::COMMANDS.iter());
v.extend(materials::COMMANDS.iter());
v.extend(material_properties::COMMANDS.iter());
v.extend(libraries::COMMANDS.iter());
v.extend(section::COMMANDS.iter());
v.extend(view_navigation::COMMANDS.iter());
v.extend(view_dxf::COMMANDS.iter());
v.extend(diagnostics::COMMANDS.iter());
v.extend(render::COMMANDS.iter());
v.extend(render_ui::COMMANDS.iter());
v
}
pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
let all = command_specs();
all.iter().find(|c| c.id == id).or_else(|| all.iter().find(|c| c.id.eq_ignore_ascii_case(id))).copied()
}
/// The end of an unknown command's error: up to three ids like `id`, nearest first. Ids a few
/// letters away (`solid.extrud` → `solid.extrude`) come before ids that end in the same word
/// (`file.open` → `doc.open`). Empty when nothing is close.
pub(crate) fn did_you_mean(id: &str) -> String {
let id = id.to_ascii_lowercase();
if id.len() > 64 {
return String::new();
}
let last = |s: &str| s.rsplit('.').next().unwrap_or(s).to_string();
let near = (id.chars().count() / 4).max(2);
let mut found: Vec<(bool, usize, &str)> = command_specs()
.iter()
.filter_map(|c| {
let d = edit_distance(&id, c.id);
(d <= near || last(&id) == last(c.id)).then_some((d > near, d, c.id))
})
.collect();
found.sort();
let ids: Vec<String> = found.iter().take(3).map(|(_, _, c)| format!("`{c}`")).collect();
match ids.as_slice() {
[] => String::new(),
[one] => format!(" (did you mean {one}?)"),
[rest @ .., end] => format!(" (did you mean {} or {end}?)", rest.join(", ")),
}
}
/// Levenshtein distance: the single-character edits that turn `a` into `b`.
pub(crate) fn edit_distance(a: &str, b: &str) -> usize {
let b: Vec<char> = b.chars().collect();
let mut row: Vec<usize> = (0..=b.len()).collect();
for (i, ca) in a.chars().enumerate() {
let mut prev = row[0];
row[0] = i + 1;
for (j, cb) in b.iter().enumerate() {
let next = (prev + usize::from(ca != *cb)).min(row[j] + 1).min(row[j + 1] + 1);
prev = row[j + 1];
row[j + 1] = next;
}
}
row[b.len()]
}
pub(super) fn alignment_param(p: &Value, key: &str, cmd: &str) -> Result<solidcraft_sketch::PatternAlignment> {
p.get(key).map_or(Ok(solidcraft_sketch::PatternAlignment::default()), |value| {
serde_json::from_value(value.clone()).map_err(|_| crate::params::bad(cmd, format!("`{key}` must be left, center or right")))
})
}
pub(super) fn reversed_param(p: &Value, key: &str, cmd: &str) -> Result<bool> {
p.get(key).map_or(Ok(false), |value| {
serde_json::from_value(value.clone()).map_err(|_| crate::params::bad(cmd, format!("`{key}` must be true or false")))
})
pub(crate) fn prepare_interactive(s: &crate::Session, params: &serde_json::Value) -> crate::Result<crate::render_interactive::Prepared> {
render::prepare_interactive(s, params)
}
+5
View File
@@ -461,6 +461,11 @@ pub fn drive_joint(s: &mut Session, id: u64, q: Vec<f64>, given: usize) -> Resul
Ok(drive_with_contact(s, &[(id, q)], &given)?.0)
}
/// Drive exactly one coordinate, letting the other values follow loops and preserving expressions.
pub fn drive_joint_index(s: &mut Session, id: u64, q: Vec<f64>, index: usize) -> Result<Value> {
Ok(drive_with_contact(s, &[(id, q)], &[(id, index)].into_iter().collect())?.0)
}
fn enable_sets(s: &mut Session, p: &Value) -> Result<Value> {
let on = bool_(p, "enabled").unwrap_or(true);
s.doc_mut().assembly.contact_enabled = on;
+131
View File
@@ -1507,3 +1507,134 @@ fn a_pattern_of_another_components_body_follows_its_own_component_when_moved() {
fresh.evaluate(&s.doc);
assert_eq!(boxes(&s.model.state()), boxes(&fresh.state()), "the kept model against a fresh evaluation");
}
#[test]
fn occurrence_align_moves_only_selected_instance_including_90_degree_face_mate() {
let mut s = Session::default();
let c = run(&mut s, "component.create", json!({"name": "Extrusion"}));
let (comp, first) = (c["component"].as_u64().unwrap(), c["occurrence"].as_u64().unwrap());
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 60, "body_name": "Beam"}));
run(&mut s, "component.activate", json!({"component": "root"}));
let second = run(&mut s, "occurrence.copy", json!({"component": comp, "translate": [40, 0, 0]}))["occurrence"].as_u64().unwrap();
// Activate the second instance (the user's exact workflow) and align its bottom face (-Z at
// [45, 5, 0]) onto the first instance's +X side face ([10, 5, 30]): instance 2 turns 90 deg
// to extend along +X, while instance 1 remains untouched.
run(&mut s, "component.activate", json!({"component": comp, "occurrence": second, "path": [second]}));
let features_before = s.doc.features.len();
run(
&mut s,
"occurrence.align",
json!({
"occurrence": second,
"path": [second],
"from_face": [45, 5, 0],
"to_face": [10, 5, 30]
}),
);
assert_eq!(s.doc.features.len(), features_before, "aligning an occurrence must not add a body feature to the shared component");
let st = s.world_state();
let b1 = st.body("Beam").unwrap().mesh().bounds();
assert!((b1.min.x - 0.0).abs() < 1e-5 && (b1.max.x - 10.0).abs() < 1e-5, "{b1:?}");
assert!((b1.min.y - 0.0).abs() < 1e-5 && (b1.max.y - 10.0).abs() < 1e-5, "{b1:?}");
assert!((b1.min.z - 0.0).abs() < 1e-5 && (b1.max.z - 60.0).abs() < 1e-5, "{b1:?}");
let b2 = st.body("Beam (Extrusion:2)").unwrap().mesh().bounds();
assert!((b2.min.x - 10.0).abs() < 1e-5 && (b2.max.x - 70.0).abs() < 1e-5, "{b2:?}");
assert!((b2.min.y - 0.0).abs() < 1e-5 && (b2.max.y - 10.0).abs() < 1e-5, "{b2:?}");
assert!((b2.min.z - 25.0).abs() < 1e-5 && (b2.max.z - 35.0).abs() < 1e-5, "{b2:?}");
// Grounded first part refuses without move_grounded, leaving both placements untouched.
let doc_before = s.doc.clone();
let err = s.execute("occurrence.align", &json!({"occurrence": first, "from": [0, 0, 0], "to": [5, 0, 0]})).unwrap_err().to_string();
assert!(err.contains("grounded"), "{err}");
assert_eq!(s.doc, doc_before);
}
#[test]
fn occurrence_align_supports_snaps_planes_flip_nested_paths_and_pending_capture() {
let mut s = Session::default();
let (_, parent) = created(&mut s, "Frame");
run(&mut s, "occurrence.move", json!({"occurrence": parent, "axis": [0, 0, 1], "angle": "90 deg"}));
let (_, child) = created(&mut s, "Bracket");
run(&mut s, "solid.box", json!({"length": 10, "width": 20, "height": 4, "corner": [5, 5, 10], "body_name": "Plate"}));
run(&mut s, "component.activate", json!({"component": "root"}));
// Align the child under a 90-deg rotated parent onto XY plane along plane normal only (capture: false).
let before = s.world_state().body("Plate").unwrap().mesh().bounds();
run(
&mut s,
"occurrence.align",
json!({
"paths": [[parent, child]],
"from_face": [before.center().x, before.center().y, 10.0],
"to_plane": "XY",
"capture": false
}),
);
let moved = s.world_state().body("Plate").unwrap().mesh().bounds();
assert!((moved.min.z - 0.0).abs() < 1e-5 && (moved.max.z - 4.0).abs() < 1e-5, "{moved:?}");
assert!((moved.center().x - before.center().x).abs() < 1e-5 && (moved.center().y - before.center().y).abs() < 1e-5, "{moved:?}");
// Revert drops the pending alignment; re-align with capture: true and vertex snap to origin.
run(&mut s, "component.revert_position", json!({}));
assert!((s.world_state().body("Plate").unwrap().mesh().bounds().min.z - 10.0).abs() < 1e-5);
run(
&mut s,
"occurrence.align",
json!({
"occurrence": child,
"path": [parent, child],
"from": {"snap": "vertex", "at": [before.min.x, before.min.y, before.min.z]},
"to": "origin"
}),
);
let at_origin = s.world_state().body("Plate").unwrap().mesh().bounds();
assert!(at_origin.min.dist(solidcraft_geom::Vec3::ZERO) < 1e-5, "{at_origin:?}");
// Hostile / invalid parameters never panic.
for bad_params in [
json!({}),
json!({"occurrence": 999999, "from": [0, 0, 0], "to": [1, 0, 0]}),
json!({"paths": [], "from": [0, 0, 0], "to": [1, 0, 0]}),
json!({"occurrence": child, "path": [parent, child], "from": [1e12, 0, 0], "to": [0, 0, 0]}),
json!({"occurrence": child, "path": [parent, child], "from": {"snap": "bogus", "at": [0, 0, 0]}, "to": [0, 0, 0]}),
json!({"occurrence": child, "path": [parent, child], "from_plane": "XY", "from": [0, 0, 0], "to": [0, 0, 1]}),
] {
assert!(s.execute("occurrence.align", &bad_params).is_err(), "{bad_params}");
}
}
#[test]
fn occurrence_align_keeps_every_pick_and_reports_shared_definitions() {
// Frame holds Bracket and is placed twice (A and B). Aligning [A, Bracket] together with B
// must move both picks: B is another placement of Frame, not an ancestor of [A, Bracket].
let mut s = Session::default();
let (frame, a) = created(&mut s, "Frame");
let (_, bracket) = created(&mut s, "Bracket");
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 4, "body_name": "Plate"}));
run(&mut s, "component.activate", json!({"component": "root"}));
let b = run(&mut s, "occurrence.copy", json!({"component": frame, "translate": [0, 50, 0]}))["occurrence"].as_u64().unwrap();
let origin = |s: &Session, path: &[u64]| solidcraft_doc::apply_point(&s.occurrence_placement(path).unwrap().world, solidcraft_geom::Vec3::ZERO);
let (a0, ab0, b0, bb0) = (origin(&s, &[a]), origin(&s, &[a, bracket]), origin(&s, &[b]), origin(&s, &[b, bracket]));
assert!(!s.occurrence_shared(&[a]) && !s.occurrence_shared(&[b]), "top-level placements are their own");
assert!(s.occurrence_shared(&[a, bracket]) && s.occurrence_shared(&[b, bracket]), "Bracket's placement lives in Frame, placed twice");
let r = run(&mut s, "occurrence.align", json!({"paths": [[a, bracket], [b]], "from": [0, 0, 0], "to": [5, 0, 0]}));
assert_eq!(r["paths"], json!([[a, bracket], [b]]), "{r}");
assert_eq!(r["shared"], json!([[a, bracket]]), "{r}");
let shift = solidcraft_geom::Vec3::new(5.0, 0.0, 0.0);
assert!(origin(&s, &[a]).dist(a0) < 1e-9, "A itself stays");
assert!(origin(&s, &[a, bracket]).dist(ab0 + shift) < 1e-9, "the picked Bracket moves");
assert!(origin(&s, &[b]).dist(b0 + shift) < 1e-9, "B moves");
// B's Bracket moves with B and, sharing Frame's definition, with the other pick too.
assert!(origin(&s, &[b, bracket]).dist(bb0 + shift + shift) < 1e-9);
// A top-level-only alignment reports nothing shared.
let r = run(&mut s, "occurrence.align", json!({"paths": [[b]], "from": [0, 0, 0], "to": [1, 0, 0]}));
assert!(r.get("shared").is_none(), "{r}");
}
File diff suppressed because one or more lines are too long
+274 -4
View File
@@ -1,6 +1,205 @@
use super::*;
use solidcraft_render::job::{CpuHost, JobId};
#[test]
fn raster_saved_ev_reopens_captures_check_start_and_changes_real_png_with_one_authority() {
let mut offline = Session::default();
offline.execute("solid.box", &json!({"length":20,"width":15,"height":10})).unwrap();
for (name, ev) in [("Minus", -1.0), ("Zero", 0.0), ("Plus", 1.0)] {
offline
.execute("render.settings.set", &json!({"name":name,"set":{"width":32,"height":32,"exposure_ev":ev,"job":{"method":"raster"}}}))
.unwrap();
}
let mut s = Session::new(solidcraft_doc::Document::from_json(&offline.doc.to_json()).unwrap());
let host = CpuHost::cooperative();
s.set_render_job_host(Some(host.clone())).unwrap();
s.execute("render.scene", &json!({})).unwrap();
let doc = s.doc.clone();
let mut images = Vec::new();
for (name, ev) in [("Minus", -1.0), ("Zero", 0.0), ("Plus", 1.0)] {
let checked = s.execute("render.preflight", &json!({"setup":name})).unwrap();
assert_eq!(checked["ignored_controls"], json!([]));
assert_eq!(checked["request"]["integrator"], "raster");
assert_eq!(checked["request"].get("raster_exposure_ev").and_then(Value::as_f64).unwrap_or(0.0), ev);
let started = s.execute("render.start", &json!({"setup":name})).unwrap();
let job: JobId = serde_json::from_value(started["job"].clone()).unwrap();
finish(&host);
let handle = s.render_job_handle(job).unwrap();
assert_eq!(json!(handle.status().unwrap().request), checked["request"]);
images.push(handle.frame().unwrap().unwrap().png().to_vec());
}
assert_ne!(images[0], images[1]);
assert_ne!(images[1], images[2]);
let explicit = s
.execute(
"render.preflight",
&json!({"settings":{"width":16,"height":16,"exposure_ev":1,
"job":{"method":"raster","lights":[{"direction":[0,0,1],"color":[1,1,1],"intensity":2}]}}}),
)
.unwrap();
assert_eq!(explicit["request"]["raster_exposure_ev"], 1.0);
assert_eq!(explicit["request"]["raster_lights"].as_array().unwrap().len(), 1);
assert!(Arc::ptr_eq(&doc, &s.doc));
for ev in [-33.0, 33.0] {
assert!(s.execute("render.settings.set", &json!({"name":"Zero","set":{"exposure_ev":ev}})).is_err());
assert!(Arc::ptr_eq(&doc, &s.doc));
}
assert!(s.execute("render.preflight", &json!({"raster_exposure_ev":1})).is_err());
}
#[test]
fn saved_raster_directional_rig_reopens_and_reaches_check_start_status_png_without_cpu_switch() {
let mut offline = Session::default();
offline.execute("solid.box", &json!({"length":20,"width":15,"height":10})).unwrap();
offline
.execute(
"render.settings.set",
&json!({"name":"SavedRig","set":{"width":32,"height":32,"job":{"method":"raster",
"lights":[{"direction":[3,-4,0],"color":[1,0,0],"intensity":2}]}}}),
)
.unwrap();
let document = solidcraft_doc::Document::from_json(&offline.doc.to_json()).unwrap();
let mut s = Session::new(document);
let host = CpuHost::cooperative();
s.set_render_job_host(Some(host.clone())).unwrap();
s.execute("render.scene", &json!({})).unwrap();
let doc = s.doc.clone();
let checked = s.execute("render.preflight", &json!({"setup":"SavedRig"})).unwrap();
assert_eq!(checked["request"]["integrator"], "raster");
assert!(checked["request"].get("raytrace").is_none());
assert_eq!(checked["request"]["raster_lights"][0]["direction"][0].as_f64().unwrap(), 3.0);
let started = s.execute("render.start", &json!({"setup":"SavedRig"})).unwrap();
let job: JobId = serde_json::from_value(started["job"].clone()).unwrap();
finish(&host);
let handle = s.render_job_handle(job).unwrap();
assert_eq!(json!(handle.status().unwrap().request), checked["request"]);
let lit_png = handle.frame().unwrap().unwrap().png().to_vec();
assert!(lit_png.starts_with(b"\x89PNG\r\n\x1a\n"));
let mut dark = serde_json::to_value(&s.doc.render.setups["SavedRig"]).unwrap();
dark["job"]["lights"] = json!([]);
let dark_started = s.execute("render.start", &json!({"settings":dark})).unwrap();
let dark_job: JobId = serde_json::from_value(dark_started["job"].clone()).unwrap();
finish(&host);
assert_ne!(s.render_job_handle(dark_job).unwrap().frame().unwrap().unwrap().png(), lit_png.as_slice());
for runtime in [Value::Null, json!([])] {
for command in ["render.preflight", "render.start"] {
let error = s.execute(command, &json!({"setup":"SavedRig","raster_lights":runtime})).unwrap_err().to_string();
assert!(error.contains("authoritative"), "{error}");
}
}
assert!(Arc::ptr_eq(&doc, &s.doc));
}
#[test]
fn raster_json_is_bounded_before_saved_normalization_and_preserves_null_empty_semantics() {
let (mut s, host) = session();
s.execute(
"render.settings.set",
&json!({"name":"Bounded","set":{"width":16,"height":16,"job":{"method":"raster","transparent_background":false}}}),
)
.unwrap();
let light = json!({"direction":[0,0,1],"color":[1,1,1],"intensity":1});
let mut unknown = light.clone();
unknown["unexpected"] = json!({"nested":vec![0;32]});
let doc = s.doc.clone();
for rig in [json!(vec![light; 9]), json!([unknown])] {
for saved in [false, true] {
let mut params = json!({"raster_lights":rig});
if saved {
params["setup"] = json!("Bounded");
}
let before = params.clone(); // deliberately small adversarial fixture, not production lowering
for command in ["render.preflight", "render.start"] {
assert!(s.execute(command, &params).is_err());
assert_eq!(params, before);
assert_eq!(host.admitted_bytes(), 0);
assert!(Arc::ptr_eq(&doc, &s.doc));
}
}
}
for saved in [false, true] {
for rig in [None, Some(Value::Null), Some(json!([]))] {
let empty = rig.as_ref().is_some_and(Value::is_array);
let mut params = json!({});
if saved {
params["setup"] = json!("Bounded");
}
if let Some(rig) = rig {
params["raster_lights"] = rig;
}
let checked = s.execute("render.preflight", &params).unwrap();
if empty {
assert_eq!(checked["request"]["raster_lights"], json!([]));
} else {
assert!(checked["request"].get("raster_lights").is_none());
}
}
}
// Source inspection, not this outcome test, proves the raw new array was
// borrowed/validated before materialization. No real OOM test is claimed.
}
#[test]
fn transient_raster_rig_survives_saved_normalization_check_start_status_and_png() {
let (mut s, host) = session();
s.execute("solid.box", &json!({"length":20,"width":15,"height":10})).unwrap();
s.execute("render.settings.set", &json!({"name":"Rig","set":{"width":32,"height":32,"job":{"method":"raster","transparent_background":false}}}))
.unwrap();
let doc = s.doc.clone();
let before = (s.revision, s.undo.len(), s.redo.len(), s.evaluations);
let legacy = s.execute("render.start", &json!({"setup":"Rig"})).unwrap();
let legacy_id: JobId = serde_json::from_value(legacy["job"].clone()).unwrap();
finish(&host);
let legacy_png = s.render_job_handle(legacy_id).unwrap().frame().unwrap().unwrap().png().to_vec();
for rig in [json!([]), json!([{"direction":[0,-1,1],"color":[1,0,0],"intensity":2}])] {
let params = json!({"setup":"Rig","raster_lights":rig});
let checked = s.execute("render.preflight", &params).unwrap();
assert_eq!(checked["request"]["raster_lights"].as_array().unwrap().len(), params["raster_lights"].as_array().unwrap().len());
assert_eq!(checked["retained_request_bytes"].as_u64().unwrap() > 0, !params["raster_lights"].as_array().unwrap().is_empty());
let started = s.execute("render.start", &params).unwrap();
let job: JobId = serde_json::from_value(started["job"].clone()).unwrap();
finish(&host);
let status = s.execute("render.status", &json!({"job":job})).unwrap();
assert_eq!(status["request"], checked["request"]);
assert_eq!(status["progress"]["phase"], "completed");
let handle = s.render_job_handle(job).unwrap();
let frame = handle.frame().unwrap().unwrap();
assert!(frame.png().starts_with(b"\x89PNG\r\n\x1a\n"));
assert_ne!(frame.png(), legacy_png.as_slice());
assert!(Arc::ptr_eq(&doc, &s.doc));
assert_eq!(before, (s.revision, s.undo.len(), s.redo.len(), s.evaluations));
assert!(!s.doc.to_json().contains("raster_lights"));
}
}
#[test]
fn raster_rig_rejects_foreign_intent_and_unsupported_setup_controls_without_reserving() {
let (mut s, host) = session();
s.execute("solid.box", &json!({"length":20,"width":15,"height":10})).unwrap();
s.execute("render.preflight", &json!({"raster_lights":[]})).unwrap();
let doc = s.doc.clone();
for command in ["render.preflight", "render.start"] {
for extra in [
json!({"integrator":"raytrace"}),
json!({"progressive":true}),
json!({"studio_weights":[1,1,1]}),
json!({"raytrace":{}}),
json!({"nested_media":true}),
json!({"progressive_feedback":true}),
json!({"settings":{"exposure_ev":33}}),
json!({"settings":{"environment_rotation":1}}),
] {
let mut params = json!({"raster_lights":[]});
for (key, value) in extra.as_object().unwrap() {
params[key] = value.clone();
}
assert!(s.execute(command, &params).is_err(), "{command}: {params}");
assert_eq!(host.admitted_bytes(), 0);
assert!(Arc::ptr_eq(&doc, &s.doc));
}
}
}
fn session() -> (Session, CpuHost) {
let host = CpuHost::cooperative();
let mut s = Session::default();
@@ -1071,7 +1270,7 @@ fn dielectric_preflight_and_start_resolve_refraction_without_broadening_boundari
let status = handle.wait_timeout(std::time::Duration::from_secs(10)).unwrap();
assert!(
matches!(status.progress.phase, solidcraft_render::job::Phase::Cancelled | solidcraft_render::job::Phase::Completed),
"phase {:?}, error {:?}",
"phase {:?}, error {:?}; full status {status:?}",
status.progress.phase,
status.error
);
@@ -1182,17 +1381,18 @@ fn explicit_render_camera_preserves_orientation_and_refuses_invalid_state_atomic
}
/// #5089: a setup saved with exposure (the render workspace and the other executors use it)
/// renders on the raster executor, which has no exposure, and the reply says what it left out.
/// now renders with captured Raster exposure; only unsupported environment controls remain ignored.
#[test]
fn a_setup_saved_with_exposure_renders_and_names_the_controls_it_leaves_out() {
let (mut s, host) = session();
s.execute("render.settings.set", &json!({"name":"x","set":{"mode":"preview","width":16,"height":16,"exposure_ev":0.3}})).unwrap();
let reply = s.execute("render.preflight", &json!({"setup":"x"})).unwrap();
assert_eq!(reply["ignored_controls"], json!(["exposure_ev"]));
assert_eq!(reply["ignored_controls"], json!([]));
assert_eq!(reply["request"]["raster_exposure_ev"], 0.3);
let plain = s.execute("render.preflight", &request()).unwrap();
assert_eq!(plain["ignored_controls"], json!([]));
let started = s.execute("render.start", &json!({"setup":"x"})).unwrap();
assert_eq!(started["ignored_controls"], json!(["exposure_ev"]));
assert_eq!(started["ignored_controls"], json!([]));
finish(&host);
let id: JobId = serde_json::from_value(started["job"].clone()).unwrap();
assert_eq!(s.execute("render.status", &json!({"job":id})).unwrap()["progress"]["phase"], "completed");
@@ -1216,3 +1416,73 @@ fn output_viewer_copy_validates_completed_job_without_mutation_or_pixel_payloads
assert!(Arc::ptr_eq(&document, &s.doc));
assert_eq!(before, (s.revision, s.undo.len(), s.redo.len(), host.admitted_bytes()));
}
#[cfg(all(feature = "raytrace-cpu", not(target_arch = "wasm32")))]
#[test]
fn cpu_exposure_full_saved_range_check_start_preserves_fixed_and_progressive_capture() {
let host = CpuHost::for_platform().unwrap();
assert_eq!(host.kind(), solidcraft_render::job::HostKind::NativeWorker);
host.validate_integrator(solidcraft_render::job::Integrator::Raytrace).unwrap();
eprintln!("CPU_EXPOSURE_RANGE_BRANCH cpu_supported=true");
for ev in [-32.0, -17.0, 17.0, 32.0] {
for progressive in [false, true] {
let mut s = Session::default();
s.execute("solid.box", &json!({"length":20,"width":20,"height":10})).unwrap();
let mut options = json!({"lights":[],"shadows":false,"primary_samples":1});
if progressive {
options["progressive"] = json!({"samples_per_pixel":3,"seed":31});
}
s.execute(
"render.settings.set",
&json!({"name":"EV","set":{"width":4,"height":4,"exposure_ev":ev,
"job":{"method":"cpu_raytrace","options":options}}}),
)
.unwrap();
let saved = s.doc.render.setups["EV"].clone();
s = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
s.set_render_job_host(Some(host.clone())).unwrap();
s.execute("render.scene", &json!({})).unwrap();
let doc = s.doc.clone();
let before = (s.revision, s.evaluations, s.undo.len(), s.redo.len(), s.is_dirty());
let named = json!({"setup":"EV"});
let checked = s.execute("render.preflight", &named).unwrap();
assert_eq!(checked, s.execute("render.preflight", &json!({"settings":saved})).unwrap());
assert_eq!(checked["resolved"]["executor"], "cpu-raytrace");
assert_eq!(checked["request"]["raytrace"]["exposure_ev"], ev);
let started = s.execute("render.start", &named).unwrap();
let id: JobId = serde_json::from_value(started["job"].clone()).unwrap();
let handle = s.render_job_handle(id).unwrap();
let status = handle.wait_timeout(std::time::Duration::from_secs(60)).unwrap();
assert_eq!(status.progress.phase, solidcraft_render::job::Phase::Completed, "{:?}", status.error);
assert_eq!(json!(status.request), checked["request"]);
let captured = status.request.raytrace.as_ref().unwrap();
assert_eq!(captured.exposure_ev, ev);
assert_eq!(captured.progressive.is_some(), progressive);
assert_eq!(captured.budget, solidcraft_render::job::CpuBudget::Compact);
assert_eq!(status.options.exposure_ev, 0.0, "no second GPU exposure authority");
assert!(handle.frame().unwrap().is_some());
assert_eq!(s.doc.render.setups["EV"], saved);
assert!(Arc::ptr_eq(&doc, &s.doc));
assert_eq!(before, (s.revision, s.evaluations, s.undo.len(), s.redo.len(), s.is_dirty()));
}
}
}
#[cfg(all(feature = "raytrace-cpu", not(target_arch = "wasm32")))]
#[test]
fn cpu_exposure_outside_expanded_range_fails_check_and_start_before_missing_hdr_input() {
let mut s = Session::default();
s.set_render_job_host(Some(CpuHost::for_platform().unwrap())).unwrap();
for ev in [-33.0, 33.0, -32.01, 32.01] {
let params = json!({"integrator":"raytrace","settings":{"width":1,"height":1},
"raytrace":{"exposure_ev":ev},"environment":{"path":"never-read-exposure-boundary.hdr"}});
let doc = s.doc.clone();
let before = (s.revision, s.evaluations, s.undo.len(), s.redo.len(), s.is_dirty());
for command in ["render.preflight", "render.start"] {
let error = s.execute(command, &params).unwrap_err().to_string();
assert!(error.contains("raytrace exposure_ev must be finite and within -32..32"), "{error}");
}
assert!(Arc::ptr_eq(&doc, &s.doc));
assert_eq!(before, (s.revision, s.evaluations, s.undo.len(), s.redo.len(), s.is_dirty()));
}
}
+762 -34
View File
@@ -11,6 +11,14 @@ use solidcraft_render::{
#[derive(Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct Start {
#[serde(default, deserialize_with = "solidcraft_render::job::deserialize_raster_lights")]
raster_lights: Option<Vec<solidcraft_render::job::DirectionalLightOptions>>,
#[serde(default, deserialize_with = "solidcraft_render::job::deserialize_raster_punctual_lights")]
raster_punctual_lights: Option<Vec<solidcraft_render::job::PunctualLightOptions>>,
#[serde(default, deserialize_with = "solidcraft_render::job::deserialize_raster_area_lights")]
raster_area_lights: Option<Vec<solidcraft_render::job::RectangleLightOptions>>,
progressive_feedback: bool,
nested_media: bool,
studio_weights: Option<[f64; 3]>,
progressive: bool,
denoise: bool,
@@ -47,6 +55,7 @@ impl Default for EnvironmentRequest {
#[derive(Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct ProgressiveStart {
progressive_feedback: bool,
studio_weights: Option<[f64; 3]>,
aging: solidcraft_doc::render_aging::SavedAgingStack,
#[serde(default)]
@@ -75,6 +84,7 @@ struct ProgressiveStart {
}
struct Prepared {
progressive_feedback: bool,
replace: bool,
scene: solidcraft_render::ProductScene,
request: ImageRequest,
@@ -99,8 +109,207 @@ fn lower_led(input: &mut Start, command: &str) -> Result<()> {
Ok(())
}
/// Pure preview lowering. Parse the existing CPU wire before changing its sampling;
/// unsupported effects remain errors rather than disappearing from the request.
fn interactive_params(params: &Value) -> Result<Value> {
let command = "render.interactive";
let mut input = Start::deserialize(params).map_err(|e| bad(command, e.to_string()))?;
let object = params.as_object().ok_or_else(|| bad(command, "Interactive requires an explicit CPU request object"))?;
if object.contains_key("setup") || object.contains_key("replace") {
return Err(bad(command, "Interactive requires an explicit settings snapshot without setup or replace"));
}
if input.raster_lights.is_some() || input.raster_punctual_lights.is_some() || input.raster_area_lights.is_some() {
return Err(bad(command, "Interactive does not accept explicit Raster lights"));
}
if input.integrator != Integrator::Raytrace || input.progressive || input.transparent_background {
return Err(bad(command, "Interactive requires opaque CPU ray tracing, not GPU progressive or raster rendering"));
}
if input.studio_weights.is_some() || input.denoise || input.samples.is_some() || input.max_depth.is_some() || input.seed.is_some() {
return Err(bad(command, "Interactive cannot accept GPU studio, denoise or sampling controls"));
}
let mut settings = input.settings.take().ok_or_else(|| bad(command, "Interactive requires explicit settings"))?;
if settings.job.is_some() {
return Err(bad(command, "Interactive requires a lowered CPU runtime request with settings.job: null"));
}
if settings.mode != solidcraft_render::RenderMode::Preview || settings.format != solidcraft_render::RenderFormat::Png {
return Err(bad(command, "Interactive CPU requires the existing Preview/Image/PNG profile"));
}
// Keep the existing CPU runtime exposure authority; never silently neutralize
// saved/common controls in order to make a request acceptable.
if settings.exposure_ev != 0.0 || settings.environment_rotation != 0.0 || settings.environment_intensity != 1.0 {
return Err(bad(command, "Interactive CPU exposure belongs to raytrace.exposure_ev; environment controls belong to the captured input"));
}
lower_led(&mut input, command)?;
if let Some(environment) = &input.environment {
// Same numerical controls as EnvironmentInput::new, checked before a path
// can be read. Resource bytes and primaries remain prepare's responsibility.
if !environment.rotation_radians.is_finite()
|| !environment.intensity.is_finite()
|| !(0.0..=64.0).contains(&environment.intensity)
|| !matches!(environment.samples, 1 | 4 | 16 | 64)
{
return Err(bad(command, "invalid Interactive environment controls"));
}
if input.raytrace.as_ref().is_some_and(|options| options.environment.is_some()) {
return Err(bad(command, "choose either captured HDR or constant CPU environment"));
}
}
let final_request = ImageRequest {
raster_punctual_lights: None,
raster_area_lights: None,
raster_exposure_ev: 0.0,
raster_lights: None,
camera: settings.camera,
width: settings.width,
height: settings.height,
background: settings.background,
transparent_background: input.transparent_background,
integrator: input.integrator,
raytrace: input.raytrace,
studio_weights: input.studio_weights,
environment: None,
textures: None,
};
let preview = solidcraft_render::job::interactive::cpu_request(&final_request).map_err(job_error)?;
settings.width = preview.width;
settings.height = preview.height;
let budget = preview.raytrace.as_ref().map_or(solidcraft_render::job::CpuBudget::Compact, |options| options.budget);
settings.validate_for_cpu(budget).map_err(|e| bad(command, e.to_string()))?;
// Preserve every original field after strict deserialization. Only the LED
// recipe has been consumed into real area lights; do not lower it twice.
let mut lowered = object.clone();
lowered.remove("led_strip");
lowered.remove("led_samples");
lowered.insert("settings".into(), serde_json::to_value(settings).map_err(|e| bad(command, e.to_string()))?);
lowered.insert("raytrace".into(), serde_json::to_value(preview.raytrace).map_err(|e| bad(command, e.to_string()))?);
Ok(Value::Object(lowered))
}
/// Capture/admit only. The engine's Interactive owner submits outside the final
/// registry; this helper never starts, replaces, cancels or exports a job.
pub(super) fn interactive(s: &Session, params: &Value) -> Result<crate::render_interactive::Prepared> {
let params = interactive_params(params)?;
let prepared = prepare(s, &params, "render.interactive")?;
if prepared.executor.as_str() != "cpu-raytrace" {
return Err(bad("render.interactive", "Interactive requires the installed cpu-raytrace executor"));
}
Ok(crate::render_interactive::Prepared {
scene: prepared.scene,
request: prepared.request,
options: prepared.options,
environment: prepared.environment,
})
}
#[cfg(test)]
mod union_lowering_tests {
#[cfg(not(target_arch = "wasm32"))]
fn raster_local_command_fixture() -> (Session, Value) {
let mut s = Session::default();
s.execute(
"solid.box",
&json!({"length":20,"width":20,"height":4,"center":[0,0,0],"operation":"new","body_name":"Raster local command fixture"}),
)
.unwrap();
s.execute("render.scene", &json!({})).unwrap();
s.set_render_job_host(Some(solidcraft_render::job::CpuHost::for_platform().unwrap())).unwrap();
let mut camera = solidcraft_render::Camera { distance: 50.0, ..Default::default() };
camera.set_view(solidcraft_render::StandardView::Top);
let wire = json!({"settings":{"width":8,"height":8,"camera":camera,"background":[20,40,60],"exposure_ev":1.25,"job":null},
"raster_punctual_lights":[{"position_mm":[0.0,0.0,20.0],"color":[0.25,0.5,1.0],"intensity":2.0,"reference_distance_mm":20.0,"minimum_distance_mm":0.001}],
"raster_area_lights":[{"center_mm":[0.0,0.0,20.0],"edge_u_mm":[4.0,0.0,0.0],"edge_v_mm":[0.0,-4.0,0.0],"color":[1.0,0.5,0.25],"intensity":2.0}]});
(s, wire)
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn raster_local_public_preflight_start_status_and_real_png_keep_both_sources_and_one_ev() {
use solidcraft_render::job::{JobId, Phase};
let (mut s, wire) = raster_local_command_fixture();
let original = wire.clone();
let checked = s.execute("render.preflight", &wire).unwrap();
assert_eq!(checked["request"]["raster_punctual_lights"], wire["raster_punctual_lights"]);
assert_eq!(checked["request"]["raster_area_lights"], wire["raster_area_lights"]);
assert_eq!(checked["request"]["raster_exposure_ev"], 1.25);
assert!(checked["request"].get("raster_lights").is_none(), "no implicit directionals added");
let started = s.execute("render.start", &wire).unwrap();
let id: JobId = serde_json::from_value(started["job"].clone()).unwrap();
let handle = s.render_job_handle(id).unwrap();
let status = handle.wait_timeout(std::time::Duration::from_secs(60)).unwrap();
assert_eq!(status.progress.phase, Phase::Completed, "{:?}", status.error);
assert_eq!(status.request.raster_exposure_ev, 1.25);
assert_eq!(serde_json::to_value(&status.request.raster_punctual_lights).unwrap(), wire["raster_punctual_lights"]);
assert_eq!(serde_json::to_value(&status.request.raster_area_lights).unwrap(), wire["raster_area_lights"]);
let frame = handle.frame().unwrap().unwrap();
let bright = image::load_from_memory(frame.png()).unwrap().to_rgba8();
assert_eq!(bright.dimensions(), (8, 8));
let mut empty = wire.clone();
empty["raster_punctual_lights"] = json!([]);
empty["raster_area_lights"] = json!([]);
let started = s.execute("render.start", &empty).unwrap();
let id: JobId = serde_json::from_value(started["job"].clone()).unwrap();
let dark_handle = s.render_job_handle(id).unwrap();
assert_eq!(dark_handle.wait_timeout(std::time::Duration::from_secs(60)).unwrap().progress.phase, Phase::Completed);
let dark_frame = dark_handle.frame().unwrap().unwrap();
let dark = image::load_from_memory(dark_frame.png()).unwrap().to_rgba8();
assert_ne!(bright, dark, "actual local lights must change the real producer output");
assert_eq!(wire, original, "input intent is immutable");
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn raster_local_public_commands_refuse_saved_and_foreign_controls_without_reservation() {
let (mut s, wire) = raster_local_command_fixture();
let before_revision = s.revision;
let mut saved = wire.clone();
saved["settings"]["job"] = serde_json::to_value(RenderJob::Raster { transparent_background: false, lights: None }).unwrap();
for command in ["render.preflight", "render.start"] {
let error = s.execute(command, &saved).unwrap_err().to_string();
assert!(error.contains("saved local-light authoring"), "{error}");
}
for (key, value) in [
("integrator", json!("raytrace")),
("studio_weights", json!([1, 1, 1])),
("raytrace", json!({})),
("environment", json!({"path":"must-not-be-opened.hdr"})),
("nested_media", json!(true)),
("progressive_feedback", json!(true)),
("progressive", json!(true)),
("samples", json!(1)),
("seed", json!(0)),
("denoise", json!(true)),
] {
let mut invalid = wire.clone();
invalid[key] = value;
for command in ["render.preflight", "render.start"] {
assert!(s.execute(command, &invalid).is_err(), "{command} accepted {key}");
assert_eq!(s.render_job_host().unwrap().admitted_bytes(), 0, "no failed request reserved work");
}
}
let mut interactive = wire;
interactive["integrator"] = json!("raytrace");
assert!(interactive_params(&interactive).is_err());
assert_eq!(s.revision, before_revision);
}
#[test]
fn raster_local_public_command_caps_refuse_before_host_lookup_or_scene_capture() {
let point = json!({"position_mm":[0,0,20],"color":[1,1,1],"intensity":1,"reference_distance_mm":20,"minimum_distance_mm":0.001});
let area = json!({"center_mm":[0,0,20],"edge_u_mm":[4,0,0],"edge_v_mm":[0,-4,0],"color":[1,1,1],"intensity":1});
let mut s = Session::default();
for (key, value, reason) in
[("raster_punctual_lights", json!(vec![point; 9]), "at most eight"), ("raster_area_lights", json!(vec![area; 5]), "at most four")]
{
let mut wire = json!({});
wire[key] = value;
for command in ["render.preflight", "render.start"] {
let error = s.execute(command, &wire).unwrap_err().to_string();
assert!(error.contains(reason), "bound must fail before absent host: {error}");
}
}
}
use super::*;
#[test]
fn led_lowering_keeps_hdr_reflection_aa_and_anisotropy_inputs_without_reading_hdr() {
@@ -123,6 +332,361 @@ mod union_lowering_tests {
assert!(input.led_strip.is_none() && input.led_samples.is_none());
assert_eq!(wire["environment"]["path"], json!("unopened.hdr"));
}
fn interactive_final_wire() -> Value {
let camera = solidcraft_render::Camera { fov: 0.8, ..Default::default() };
json!({"integrator":"raytrace","settings":{
"width":3840,"height":2160,"camera":camera,"background":[20,40,60],"job":null,
"stage":[{"id":"floor","center_mm":[0.0,0.0,-5.0],"half_u_mm":[40.0,0.0,0.0],
"half_v_mm":[0.0,40.0,0.0],"reflectance_linear":[0.1,0.2,0.3]}]},
"raytrace":{"primary_samples":4,"shadows":false,"exposure_ev":1.25,"lights":[],
"reflections":{"bodies":[{"body":"receiver","mode":"mirror"}],"samples":16,"max_bounces":3},
"punctual_lights":[{"position_mm":[0.0,0.0,20.0],"color":[1.0,0.5,0.25],"intensity":2.0,
"reference_distance_mm":20.0,"minimum_distance_mm":0.01}],
"emissions":[{"body_index":0,"body":"receiver","color":[0.2,0.4,0.8],"strength":3.0}],
"surface_effects":[{"body":"receiver","repeat_mm":[2.0,3.0,4.0],"roughness_amplitude":0.1,"bump_amplitude_mm":0.01}]},
"environment":{"path":"unopened-original.hdr","expected_sha256":"a".repeat(64),
"rotation_radians":0.75,"intensity":2.0,"samples":16}})
}
#[test]
fn interactive_fits_4k_before_admission_and_preserves_final_wire_and_transport() {
let wire = interactive_final_wire();
let original = wire.clone();
let lowered = interactive_params(&wire).unwrap();
let before: Start = serde_json::from_value(wire.clone()).unwrap();
let after: Start = serde_json::from_value(lowered.clone()).unwrap();
let final_settings = before.settings.unwrap();
let preview_settings = after.settings.unwrap();
assert_eq!((final_settings.width, final_settings.height), (3840, 2160));
assert_eq!((preview_settings.width, preview_settings.height), (1024, 576));
assert!(before.raytrace.as_ref().unwrap().budget.validate_image(3840, 2160, None).is_err(), "Compact final exceeds pixel cap");
let mut expected_settings = final_settings;
expected_settings.width = 1024;
expected_settings.height = 576;
assert_eq!(preview_settings, expected_settings, "camera, stage, background and other settings remain exact");
let mut expected_options = before.raytrace.unwrap();
expected_options.primary_samples = 1;
expected_options.progressive = Some(Default::default());
expected_options.reflections.as_mut().unwrap().samples = 1;
assert_eq!(after.raytrace.unwrap(), expected_options, "no lighting, emission, shading effect or EV is discarded");
assert_eq!(lowered["environment"], wire["environment"], "unopened HDR path and expected digest stay pinned");
assert_eq!(lowered["raytrace"]["progressive"], json!({"samples_per_pixel":16,"seed":0}));
assert_eq!(wire, original);
}
#[test]
fn interactive_small_and_extreme_aspects_fit_without_enlarging_or_zero_dimensions() {
for (width, height, expected) in [(320, 200, (320, 200)), (4096, 1, (1024, 1)), (1, 4096, (1, 1024))] {
let wire = json!({"integrator":"raytrace","settings":{"width":width,"height":height}});
let output = interactive_params(&wire).unwrap();
assert_eq!((output["settings"]["width"].as_u64().unwrap(), output["settings"]["height"].as_u64().unwrap()), (expected.0, expected.1));
}
}
#[test]
fn interactive_refuses_alternate_authorities_and_unknown_fields_without_io() {
let s = Session::default();
let base = interactive_final_wire();
for (key, value) in [
("setup", json!(null)),
("setup", json!("Saved")),
("replace", json!(false)),
("replace", json!(true)),
("integrator", json!("raster")),
("progressive", json!(true)),
("transparent_background", json!(true)),
("denoise", json!(true)),
("samples", json!(1)),
("max_depth", json!(2)),
("seed", json!(0)),
("studio_weights", json!([1, 1, 1])),
("unknown", json!(true)),
("lens", json!(null)),
] {
let mut wire = base.clone();
wire[key] = value;
let error = match interactive(&s, &wire) {
Err(error) => error.to_string(),
Ok(_) => panic!("Interactive accepted conflicting input {key}"),
};
assert!(!error.contains("could not read") && !error.contains("no image host"), "refuse before host/IO: {key}: {error}");
}
for settings in [
json!(null),
json!({"job":{"method":"cpu_raytrace"}}),
json!({"job":{"method":"gpu_path"}}),
json!({"exposure_ev":1.0}),
json!({"environment_rotation":1.0}),
json!({"width":0}),
json!({"mode":"final"}),
json!({"format":"exr"}),
] {
let mut wire = base.clone();
wire["settings"] = settings;
assert!(interactive_params(&wire).is_err());
}
let mut missing = base.clone();
missing.as_object_mut().unwrap().remove("settings");
assert!(interactive_params(&missing).is_err());
for wire in [json!(null), json!([]), json!(false)] {
assert!(interactive_params(&wire).is_err());
}
assert!(s.render_job_host().is_none());
}
#[test]
fn interactive_refuses_invalid_or_unsupported_effects_before_unopened_hdr() {
let base = interactive_final_wire();
// Retain numerically valid dielectric intent for capture-time certification.
// This fixture still mixes same-body mirror, emission and nonidentity effects;
// successful lowering is not scene or job admission.
let mut authored = base.clone();
authored["raytrace"]["dielectrics"] = json!([{"body_index":0,"body_name":"receiver","ior":1.5,"absorption_per_mm":[0,0,0],
"boundary":"closed_box","thickness_mm":0}]);
let original = authored.clone();
let authored_options: RaytraceOptions = serde_json::from_value(original["raytrace"].clone()).unwrap();
let mut expected_options = authored_options.clone();
expected_options.primary_samples = 1;
expected_options.progressive = Some(solidcraft_render::job::CpuProgressiveOptions { samples_per_pixel: 16, seed: 0 });
expected_options.reflections.as_mut().unwrap().samples = 1;
let mut expected_settings: RenderSetup = serde_json::from_value(original["settings"].clone()).unwrap();
expected_settings.width = 1024;
expected_settings.height = 576;
let mut expected_wire = original.clone();
expected_wire["settings"] = serde_json::to_value(&expected_settings).unwrap();
expected_wire["raytrace"] = serde_json::to_value(&expected_options).unwrap();
let lowered = interactive_params(&authored).unwrap();
let lowered_options: RaytraceOptions = serde_json::from_value(lowered["raytrace"].clone()).unwrap();
let lowered_settings: RenderSetup = serde_json::from_value(lowered["settings"].clone()).unwrap();
assert_eq!(lowered_options.dielectrics, authored_options.dielectrics, "the exact original dielectric selector is retained");
assert_eq!(lowered_options.surface_effects, authored_options.surface_effects, "shading intent is not removed");
assert_eq!(lowered_options.emissions, authored_options.emissions, "surface emission is not removed");
assert_eq!(lowered_options.reflections, expected_options.reflections, "only the selected reflection sample count changes");
assert_eq!(lowered_options, expected_options, "only documented Interactive sampling is normalized");
assert_eq!(lowered_settings, expected_settings, "camera, stage, background and other settings remain exact");
assert_eq!(lowered["environment"], original["environment"], "unopened HDR path and expected digest remain exact");
assert_eq!(lowered, expected_wire, "no other request field changes");
assert_eq!(authored, original, "the authored final wire remains unchanged");
for (key, value) in [
("primary_samples", json!(0)),
("exposure_ev", json!(33.0)),
(
"anisotropic_faces",
json!([{"body_index":0,"body_name":"receiver","face":0,"tangent":[1,0,0],
"orientation":"zero","roughness_x":0.2,"roughness_y":0.4},null]),
),
("textures", json!([])),
] {
let mut wire = base.clone();
wire["raytrace"][key] = value;
assert!(interactive_params(&wire).is_err(), "unsupported {key} must not disappear");
}
for (key, value) in [("thickness_mm", json!(1.0)), ("ior", json!(0.5))] {
let mut wire = authored.clone();
wire["raytrace"]["dielectrics"][0][key] = value;
assert!(interactive_params(&wire).is_err(), "invalid dielectric {key} must refuse before unopened HDR");
}
for (key, value) in [("intensity", json!(-1.0)), ("samples", json!(3))] {
let mut wire = base.clone();
wire["environment"][key] = value;
assert!(interactive_params(&wire).is_err());
}
let mut ambiguous = base.clone();
ambiguous["raytrace"]["environment"] = json!({"color":[1.0,1.0,1.0],"intensity":1.0});
assert!(interactive_params(&ambiguous).is_err());
let mut orphaned_led_samples = base;
orphaned_led_samples["led_samples"] = json!(16);
assert!(interactive_params(&orphaned_led_samples).is_err());
}
#[cfg(all(feature = "raytrace-cpu", not(target_arch = "wasm32")))]
#[test]
fn interactive_capture_reuses_cpu_scene_stage_and_admission_without_starting_a_job() {
let mut s = Session::default();
s.execute("solid.box", &json!({"length":10,"width":8,"height":4,"body_name":"receiver"})).unwrap();
s.execute(
"appearance.assign",
&json!({"bodies":["receiver"],"color":"#4080c0",
"pbr":{"metallic":1.0,"roughness":0.25,"dielectric_ior":1.5}}),
)
.unwrap();
s.execute("render.scene", &json!({})).unwrap();
let subject = crate::product_scene::extract(&s).unwrap();
let host = solidcraft_render::job::CpuHost::for_platform().unwrap();
s.set_render_job_host(Some(host.clone())).unwrap();
let mut wire = interactive_final_wire();
wire.as_object_mut().unwrap().remove("environment");
let document = s.doc.clone();
let before = (s.revision, s.undo.len(), s.redo.len(), s.evaluations, s.is_dirty());
let captured = interactive(&s, &wire).unwrap();
assert_eq!((captured.request.width, captured.request.height), (1024, 576));
assert_eq!(captured.request.integrator, Integrator::Raytrace);
assert_eq!(captured.request.raytrace.as_ref().unwrap().exposure_ev, 1.25);
let body = captured.scene.meshes.iter().find(|mesh| mesh.name == "receiver").unwrap();
assert_eq!(body.surface, subject.meshes[0].surface);
assert_eq!(body.faces, subject.meshes[0].faces);
assert!(std::sync::Arc::ptr_eq(&body.mesh, &subject.meshes[0].mesh));
assert_eq!(captured.scene.meshes.len(), 2, "subject and render-only floor are both captured");
assert!(captured.scene.meshes.iter().any(|mesh| mesh.name == "__render_stage__/floor"));
assert_eq!(captured.options, solidcraft_render::job::JobOptions::default());
assert!(captured.environment.is_none());
assert_eq!(host.admitted_bytes(), 0, "capture validates but does not submit/reserve a job");
assert!(std::sync::Arc::ptr_eq(&document, &s.doc));
assert_eq!(before, (s.revision, s.undo.len(), s.redo.len(), s.evaluations, s.is_dirty()));
assert_eq!(wire["settings"]["width"], 3840);
}
#[cfg(all(feature = "raytrace-cpu", not(target_arch = "wasm32")))]
fn nested_command_fixture() -> (Session, Value) {
let mut s = Session::default();
for (name, length, width, height) in [("Outer", 20, 20, 4), ("Inner", 10, 10, 2)] {
s.execute(
"solid.box",
&json!({
"length":length,"width":width,"height":height,"center":[0,0,2],
"operation":"new","body_name":name,
}),
)
.unwrap();
}
s.execute("render.scene", &json!({})).unwrap();
let scene = crate::product_scene::extract(&s).unwrap();
assert_eq!(scene.meshes.len(), 2);
let dielectrics: Vec<_> = scene
.meshes
.iter()
.enumerate()
.map(|(index, body)| {
assert!(matches!(body.name.as_str(), "Outer" | "Inner"));
json!({
"body_index":index,"body_name":body.name,"boundary":"closed_box","ior":1.0,
"absorption_per_mm":if body.name == "Outer" { [0.1,0.2,0.0] } else { [0.3,0.4,0.0] },
"thickness_mm":0.0,
})
})
.collect();
let mut camera = solidcraft_render::Camera { distance: 50.0, fov: 0.0, ..Default::default() };
camera.set_view(solidcraft_render::StandardView::Top);
camera.target = solidcraft_geom::Vec3::new(0.0, 0.0, 2.0);
let host = solidcraft_render::job::CpuHost::for_platform().unwrap();
assert_eq!(host.kind(), solidcraft_render::job::HostKind::NativeWorker);
assert!(host.is_available(), "this native test requires an available real CPU worker");
s.set_render_job_host(Some(host)).unwrap();
let wire = json!({
"integrator":"raytrace","nested_media":true,
"settings":{"width":1,"height":1,"camera":camera,"background":[255,255,255],"job":null},
"raytrace":{"dielectrics":dielectrics,"lights":[],"shadows":false,
"environment":{"color":[1,1,1],"intensity":1}},
});
(s, wire)
}
#[cfg(all(feature = "raytrace-cpu", not(target_arch = "wasm32")))]
#[test]
fn command_nested_opt_in_reaches_retained_job_options_and_real_beer_transport() {
use solidcraft_render::job::{JobId, Phase};
let (mut s, wire) = nested_command_fixture();
let before = (s.revision, s.undo.len(), s.redo.len(), s.evaluations, s.is_dirty());
let document = s.doc.clone();
for value in [None, Some(false)] {
let mut default = wire.clone();
if let Some(value) = value {
default["nested_media"] = json!(value);
} else {
default.as_object_mut().unwrap().remove("nested_media");
}
for command in ["render.preflight", "render.start"] {
let error = s.execute(command, &default).unwrap_err().to_string();
assert!(error.contains("nested, touching or overlapping dielectric volumes are unsupported"), "{command}: {error}");
}
}
let checked = s.execute("render.preflight", &wire).unwrap();
assert_eq!(checked["resolved"]["executor"], "cpu-raytrace");
assert_eq!(checked["request"]["raytrace"]["dielectrics"], wire["raytrace"]["dielectrics"]);
let started = s.execute("render.start", &wire).unwrap();
let id: JobId = serde_json::from_value(started["job"].clone()).unwrap();
let handle = s.render_job_handle(id).unwrap();
let status = handle.wait_timeout(std::time::Duration::from_secs(60)).unwrap();
assert_eq!(status.progress.phase, Phase::Completed, "{:?}", status.error);
assert_eq!(status.options, solidcraft_render::job::JobOptions { nested_media: true, ..Default::default() });
let retained = s.execute("render.status", &json!({"job":id})).unwrap();
assert_eq!(retained["options"]["nested_media"], true);
let frame = handle.frame().unwrap().unwrap();
let image = image::load_from_memory(frame.png()).unwrap().to_rgba8();
// Independent Beer oracle: outer2mm + inner2mm, replacing the parent
// interval, yields exp(-.8), exp(-1.2),1 -> Reinhard+sRGB [151,132,188].
assert_eq!(image.get_pixel(0, 0).0, [151, 132, 188, 255]);
assert!(std::sync::Arc::ptr_eq(&document, &s.doc));
assert_eq!(before, (s.revision, s.undo.len(), s.redo.len(), s.evaluations, s.is_dirty()));
}
#[cfg(all(feature = "raytrace-cpu", not(target_arch = "wasm32")))]
#[test]
fn command_nesting_keeps_default_false_rejects_gpu_controls_and_captures_progressive_glass() {
use solidcraft_render::job::{JobId, JobOptions, Phase};
let (mut s, wire) = nested_command_fixture();
for (key, value) in [("samples", json!(2)), ("max_depth", json!(2)), ("seed", json!(7)), ("denoise", json!(true)), ("lens", json!({}))] {
let mut invalid = wire.clone();
invalid[key] = value;
for command in ["render.preflight", "render.start"] {
assert!(s.execute(command, &invalid).is_err(), "{command} accepted unrelated control {key}");
}
}
let mut progressive = wire.clone();
progressive["raytrace"]["progressive"] = json!({"samples_per_pixel":2,"seed":0});
// Original narrow perspective fixture: every possible pixel jitter ray
// enters both boxes, rather than allowing primary misses/background.
progressive["settings"]["camera"]["fov"] = json!(1.0e-6);
// This previously blanket-negative branch is now an actual supported
// progressive transport/capture case. GPU-only control refusals above
// and the default nesting assertions below are unchanged.
let checked = s.execute("render.preflight", &progressive).unwrap();
assert_eq!(checked["resolved"]["executor"], "cpu-raytrace");
assert_eq!(checked["request"]["raytrace"]["dielectrics"], wire["raytrace"]["dielectrics"]);
assert_eq!(checked["request"]["raytrace"]["progressive"], progressive["raytrace"]["progressive"]);
let started = s.execute("render.start", &progressive).unwrap();
let id: JobId = serde_json::from_value(started["job"].clone()).unwrap();
let handle = s.render_job_handle(id).unwrap();
let status = handle.wait_timeout(std::time::Duration::from_secs(60)).unwrap();
assert_eq!(status.progress.phase, Phase::Completed, "{:?}", status.error);
assert!(status.options.nested_media);
let captured = status.request.raytrace.as_ref().unwrap();
assert_eq!(captured.progressive.unwrap().samples_per_pixel, 2);
assert_eq!(captured.progressive.unwrap().seed, 0);
assert_eq!(serde_json::to_value(&captured.dielectrics).unwrap(), wire["raytrace"]["dielectrics"]);
let frame = handle.frame().unwrap().unwrap();
let image = image::load_from_memory(frame.png()).unwrap().to_rgba8();
assert_eq!(image.dimensions(), (1, 1));
// Camera is exterior at z52. At every box face its full jitter cone
// lies within52*tan(1e-6) < 0.000053mm of the axis, far inside the
// inner +/-5mm face. Even using twice the actual half-angle bounds,
// cos(theta)>1-2e-12. Parent-only and child lengths are each2/cos mm.
// IOR1 removes Fresnel/refraction; scalar Beer is exp(-.8/cos),
// exp(-1.2/cos),1. Their bounded variation is far below the nearest
// integer thresholds of existing Reinhard/sRGB: [151,132,188].
// Actual primary-background pixels would be255 and fail this oracle.
// This remains a command-route smoke, not a Fresnel/TIR quality case.
assert_eq!(image.get_pixel(0, 0).0, [151, 132, 188, 255]);
let interactive = interactive_params(&wire).unwrap();
assert_eq!(interactive["raytrace"]["dielectrics"], wire["raytrace"]["dielectrics"]);
assert_eq!(interactive["nested_media"], true);
assert!(interactive["raytrace"]["progressive"].is_object());
// Ordinary CPU requests retain the exact default JobOptions; omission is
// not silently promoted to nesting by adding this explicit opt-in.
let mut opaque = wire;
opaque.as_object_mut().unwrap().remove("nested_media");
opaque["raytrace"].as_object_mut().unwrap().remove("dielectrics");
s.execute("render.preflight", &opaque).unwrap();
let started = s.execute("render.start", &opaque).unwrap();
let id: JobId = serde_json::from_value(started["job"].clone()).unwrap();
let status = s.render_job_handle(id).unwrap().wait_timeout(std::time::Duration::from_secs(60)).unwrap();
assert_eq!(status.progress.phase, Phase::Completed, "{:?}", status.error);
assert_eq!(status.options, JobOptions::default());
let retained = s.execute("render.status", &json!({"job":id})).unwrap();
assert_eq!(retained["options"]["nested_media"], false);
}
}
/// Resolve portable intent before choosing an executor. Legacy requests are left intact;
@@ -140,6 +704,27 @@ struct Normalized {
}
fn normalize_saved_job(s: &Session, params: &Value, command: &str) -> Result<Option<Normalized>> {
// Validate the new payload by reference even if this turns out to be an
// unsaved request. Never clone an unbounded caller-owned light array.
let raster_override = params
.get("raster_lights")
.map(|value| solidcraft_render::job::deserialize_raster_lights(value).map_err(|e| bad(command, e.to_string())))
.transpose()?;
// Validate both local arrays by reference before any saved setup or JSON clone.
if let Some(value) = params.get("raster_punctual_lights") {
let _ = solidcraft_render::job::deserialize_raster_punctual_lights(value).map_err(|e| bad(command, e.to_string()))?;
}
if let Some(value) = params.get("raster_area_lights") {
let _ = solidcraft_render::job::deserialize_raster_area_lights(value).map_err(|e| bad(command, e.to_string()))?;
}
let local_intent = ["raster_punctual_lights", "raster_area_lights"].into_iter().any(|key| params.get(key).is_some_and(|v| !v.is_null()));
if local_intent {
let inline_saved = params.get("settings").and_then(|v| v.get("job")).is_some_and(|v| !v.is_null());
let named_saved = params.get("setup").and_then(Value::as_str).and_then(|name| s.doc.render.setups.get(name)).is_some_and(|v| v.job.is_some());
if inline_saved || named_saved {
return Err(bad(command, "runtime Raster local lights require settings.job: null; saved local-light authoring is not supported yet"));
}
}
let Some(object) = params.as_object() else {
return Ok(None);
};
@@ -152,16 +737,29 @@ fn normalize_saved_job(s: &Session, params: &Value, command: &str) -> Result<Opt
if value.get("job").is_none_or(Value::is_null) {
return Ok(None);
}
serde_json::from_value::<RenderSetup>(value.clone()).map_err(|e| bad(command, e.to_string()))?
RenderSetup::deserialize(value).map_err(|e| bad(command, e.to_string()))?
} else if let Some(settings) = named.and_then(Value::as_str).and_then(|name| s.doc.render.setups.get(name)) {
if settings.job.is_none() {
return Ok(None);
}
settings.validate().map_err(|e| bad(command, e.to_string()))?;
settings.clone()
} else {
return Ok(None);
};
if let Some(key) = object.keys().find(|key| !matches!(key.as_str(), "setup" | "settings" | "replace" | "studio_weights")) {
if let Some(key) = object.keys().find(|key| {
!matches!(
key.as_str(),
"setup"
| "settings"
| "replace"
| "studio_weights"
| "progressive_feedback"
| "raster_lights"
| "raster_punctual_lights"
| "raster_area_lights"
)
}) {
return Err(bad(
command,
format!(
@@ -178,7 +776,16 @@ fn normalize_saved_job(s: &Session, params: &Value, command: &str) -> Result<Opt
let surfaces = std::mem::take(&mut settings.surfaces);
let mut hdr = None;
let mut lowered = match job {
RenderJob::Raster { transparent_background } => json!({"transparent_background":transparent_background}),
RenderJob::Raster { transparent_background, lights } => {
if lights.is_some() && object.contains_key("raster_lights") {
return Err(bad(command, "saved Raster lights are authoritative; remove the runtime raster_lights override"));
}
let mut request = json!({"transparent_background":transparent_background});
if lights.is_some() {
request["raster_lights"] = serde_json::to_value(lights).map_err(|e| bad(command, e.to_string()))?;
}
request
}
RenderJob::CpuRaytrace { options } => {
hdr = options.hdr.map(SavedHdr::Cpu);
let raytrace = options.to_runtime(settings.exposure_ev).map_err(|e| bad(command, e.to_string()))?;
@@ -200,12 +807,19 @@ fn normalize_saved_job(s: &Session, params: &Value, command: &str) -> Result<Opt
}
};
lowered["settings"] = serde_json::to_value(settings).map_err(|e| bad(command, e.to_string()))?;
if let Some(lights) = raster_override {
// Only the already validated, bounded representation is materialized.
lowered["raster_lights"] = serde_json::to_value(lights).map_err(|e| bad(command, e.to_string()))?;
}
if let Some(weights) = object.get("studio_weights") {
lowered["studio_weights"] = weights.clone();
}
if let Some(replace) = object.get("replace") {
lowered["replace"] = replace.clone();
}
if let Some(feedback) = object.get("progressive_feedback") {
lowered["progressive_feedback"] = feedback.clone();
}
Ok(Some(Normalized { params: lowered, hdr, surfaces, optics }))
}
@@ -241,7 +855,26 @@ fn prepare(s: &Session, params: &Value, command: &str) -> Result<Prepared> {
"runtime GPU transmission, texture, lens, environment, lighting, tone-map and denoise controls require progressive rendering",
));
}
let mut input: Start = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
let mut input = Start::deserialize(params).map_err(|e| bad(command, e.to_string()))?;
solidcraft_render::job::validate_raster_lights(&input.raster_lights).map_err(job_error)?;
solidcraft_render::job::validate_raster_punctual_lights(&input.raster_punctual_lights).map_err(job_error)?;
solidcraft_render::job::validate_raster_area_lights(&input.raster_area_lights).map_err(job_error)?;
if (input.raster_lights.is_some() || input.raster_punctual_lights.is_some() || input.raster_area_lights.is_some())
&& (input.integrator != Integrator::Raster
|| input.progressive
|| input.progressive_feedback
|| input.nested_media
|| input.raytrace.is_some()
|| input.studio_weights.is_some()
|| input.environment.is_some())
{
return Err(bad(command, "explicit Raster lights require Raster without CPU/GPU controls, environment or studio_weights"));
}
if input.progressive_feedback
&& (input.integrator != Integrator::Raytrace || input.raytrace.as_ref().and_then(|options| options.progressive).is_none())
{
return Err(bad(command, "progressive_feedback requires CPU progressive raytracing"));
}
validate_studio_weights(input.studio_weights, command)?;
if input.integrator == Integrator::Raytrace && input.studio_weights.is_some() {
return Err(bad(command, "studio_weights apply to raster or the implicit GPU studio; use explicit CPU light intensities"));
@@ -274,6 +907,14 @@ fn prepare(s: &Session, params: &Value, command: &str) -> Result<Prepared> {
}
if let Some(options) = &input.raytrace {
options.retained_bytes().map_err(job_error)?;
if options.directional_shadow_transmission {
if input.nested_media {
return Err(bad(command, "directional shadow transmission does not support nested media"));
}
if saved_hdr.is_some() || input.environment.is_some() {
return Err(bad(command, "directional shadow transmission does not support HDR environments"));
}
}
options.budget.validate_execution().map_err(job_error)?;
// Check the complete image envelope before any source file is read.
let dimensions = if let Some(name) = &input.setup {
@@ -364,10 +1005,13 @@ fn prepare(s: &Session, params: &Value, command: &str) -> Result<Prepared> {
} else {
settings.validate().map_err(|e| bad(command, e.to_string()))?;
}
// The raster image executor has no exposure or environment controls. A setup saved with them
// (the render workspace and the other executors use them) still renders here, and the reply
// names what this render leaves out (#5089). The raytrace path takes them only through a
// saved job, so a raytrace request with them is still refused.
// Raster captures the existing exposure authority. Legacy environment controls
// still have no transport here and are reported/refused under their old policy.
if (input.raster_lights.is_some() || input.raster_punctual_lights.is_some() || input.raster_area_lights.is_some())
&& (settings.environment_rotation != 0.0 || settings.environment_intensity != 1.0)
{
return Err(bad(command, "explicit Raster lights do not implement environment controls"));
}
let ignored = if input.integrator == Integrator::Raster { ignored_controls(&settings) } else { Vec::new() };
if input.integrator != Integrator::Raster
&& (settings.exposure_ev != 0.0 || settings.environment_rotation != 0.0 || settings.environment_intensity != 1.0)
@@ -408,6 +1052,10 @@ fn prepare(s: &Session, params: &Value, command: &str) -> Result<Prepared> {
return Err(bad(command, "resolved executor is not installed as an image job host"));
}
let request = ImageRequest {
raster_punctual_lights: input.raster_punctual_lights.take(),
raster_area_lights: input.raster_area_lights.take(),
raster_exposure_ev: if input.integrator == Integrator::Raster { settings.exposure_ev } else { 0.0 },
raster_lights: input.raster_lights.take(),
studio_weights: validate_studio_weights(input.studio_weights, command)?,
environment: None,
textures: None,
@@ -419,28 +1067,47 @@ fn prepare(s: &Session, params: &Value, command: &str) -> Result<Prepared> {
background: settings.background,
transparent_background: input.transparent_background,
};
let mut admission = solidcraft_render::job::admit(&scene, &request).map_err(job_error)?;
let environment_bytes = environment.as_ref().map_or(Ok(0), |e| e.required_bytes()).map_err(|e| bad(command, e.to_string()))?;
let options = solidcraft_render::job::JobOptions { nested_media: input.nested_media, ..Default::default() };
let mut admission = if input.progressive_feedback {
host.admit_cpu_protected_partial_with_environment_and_options(&scene, &request, options, environment.as_ref()).map_err(job_error)?
} else {
host.admit(&scene, &request, options).map_err(job_error)?
};
let retained_lights = request.raytrace.as_ref().map_or(Ok(0), |o| o.retained_bytes()).map_err(job_error)?;
let retained_lights = retained_lights
.checked_add(request.raster_lights_retained_bytes().map_err(job_error)?)
.ok_or_else(|| bad(command, "retained light metadata overflow"))?;
let retained_environment =
if environment_info.is_some() { std::mem::size_of::<solidcraft_render::lighting::environment_input::EnvironmentInfo>() as u64 } else { 0 };
let retained_request_bytes = retained_lights.checked_add(retained_environment).ok_or_else(|| bad(command, "request metadata overflow"))?;
admission.estimated_total_bytes = admission
.estimated_total_bytes
.checked_add(environment_bytes)
.and_then(|v| v.checked_add(retained_request_bytes))
.filter(|v| *v <= budget.limits().max_job_bytes)
.ok_or_else(|| bad(command, "environment job exceeds per-job memory limit"))?;
admission.preparation_bytes =
admission.preparation_bytes.checked_add(environment_bytes).ok_or_else(|| bad(command, "environment admission overflow"))?;
if !input.progressive_feedback {
let environment_bytes = environment.as_ref().map_or(Ok(0), |e| e.required_bytes()).map_err(|e| bad(command, e.to_string()))?;
admission.estimated_total_bytes = admission
.estimated_total_bytes
.checked_add(environment_bytes)
.and_then(|v| v.checked_add(retained_request_bytes))
.filter(|v| *v <= budget.limits().max_job_bytes)
.ok_or_else(|| bad(command, "environment job exceeds per-job memory limit"))?;
admission.preparation_bytes =
admission.preparation_bytes.checked_add(environment_bytes).ok_or_else(|| bad(command, "environment admission overflow"))?;
}
let transparency = match input.integrator {
Integrator::Raster => "sorted_alpha_approximation",
Integrator::Raytrace if refraction => "physical_transmission",
Integrator::Raytrace => "opaque_only",
};
let reply = json!({"ignored_controls":ignored,"stage_panels":settings.stage.len(),"stage_transport":"CPU direct/specular only; no diffuse interreflection", "cpu_budget":budget,"cpu_limits":budget.limits(),"environment":environment_info,"retained_request_bytes":retained_request_bytes,"resolved":resolved,"admission":admission,"request":request,"scene_generation":s.revision,"host":host.kind(),"transparency":transparency,"refraction":refraction,"dielectric_boundaries":if refraction {vec!["thin_sheet","closed_box"]} else {Vec::<&str>::new()}});
Ok(Prepared { replace: input.replace, scene, request, reply, environment, executor: resolved.executor, options: Default::default() })
Ok(Prepared {
progressive_feedback: input.progressive_feedback,
replace: input.replace,
scene,
request,
reply,
environment,
executor: resolved.executor,
options,
})
}
fn prepare_progressive(
@@ -465,6 +1132,9 @@ fn prepare_progressive(
return Err(bad(command, "progressive rendering does not accept CPU raytrace, environment or LED options"));
}
let mut input: ProgressiveStart = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
if input.progressive_feedback {
return Err(bad(command, "progressive_feedback requires CPU progressive raytracing, not GPU progressive rendering"));
}
validate_studio_weights(input.studio_weights, command)?;
if input.studio_weights.is_some() && (input.lighting.is_some() || input.environment.is_some() || saved_hdr.is_some()) {
return Err(bad(command, "studio_weights require the implicit GPU studio without environment lighting"));
@@ -615,6 +1285,10 @@ fn prepare_progressive(
return Err(bad(command, "image host is unavailable"));
}
let request = ImageRequest {
raster_punctual_lights: None,
raster_area_lights: None,
raster_exposure_ev: 0.0,
raster_lights: None,
studio_weights: validate_studio_weights(input.studio_weights, command)?,
integrator: Default::default(),
raytrace: None,
@@ -644,15 +1318,21 @@ fn prepare_progressive(
"raw_linear_accumulator_preserved":true, "output_stage":"linear_before_tone_map"
});
}
Ok(Prepared { replace: input.replace, scene, request, reply, executor: resolved.executor, options, environment: None })
Ok(Prepared {
progressive_feedback: false,
replace: input.replace,
scene,
request,
reply,
executor: resolved.executor,
options,
environment: None,
})
}
/// The setup's controls the raster image executor doesn't apply: those not at their neutral value.
/// Nonneutral environment controls the Raster image executor still cannot apply.
fn ignored_controls(settings: &RenderSetup) -> Vec<&'static str> {
let mut ignored = Vec::new();
if settings.exposure_ev != 0.0 {
ignored.push("exposure_ev");
}
if settings.environment_rotation != 0.0 {
ignored.push("environment_rotation");
}
@@ -668,16 +1348,13 @@ pub(super) fn preflight(s: &mut Session, params: &Value) -> Result<Value> {
pub(super) fn start(s: &mut Session, params: &Value) -> Result<Value> {
let p = prepare(s, params, "render.start")?;
let status = s
.render_jobs
.start_with_environment(
s.revision,
p.scene,
p.request,
p.replace,
crate::render_jobs::Execution { executor: p.executor, options: p.options, environment: p.environment },
)
.map_err(job_error)?;
let execution = crate::render_jobs::Execution { executor: p.executor, options: p.options, environment: p.environment };
let status = if p.progressive_feedback {
s.render_jobs.start_with_environment_and_feedback(s.revision, p.scene, p.request, p.replace, execution, true)
} else {
s.render_jobs.start_with_environment(s.revision, p.scene, p.request, p.replace, execution)
}
.map_err(job_error)?;
let mut status = json!(status);
if let (Some(out), Some(ignored)) = (status.as_object_mut(), p.reply.get("ignored_controls")) {
out.insert("ignored_controls".into(), ignored.clone());
@@ -807,3 +1484,54 @@ fn validate_studio_weights(weights: Option<[f64; 3]>, command: &str) -> Result<O
}
Ok(weights)
}
#[cfg(test)]
mod directional_shadow_guard_tests {
use super::*;
fn wire() -> Value {
json!({"integrator":"raytrace","settings":{"width":1,"height":1,"job":null},
"raytrace":{"directional_shadow_transmission":true,"shadows":true,"lights":[],
"dielectrics":[{"body_index":0,"body_name":"guard-only target","boundary":"thin_sheet",
"ior":1.0,"absorption_per_mm":[0.0,0.0,0.0],"thickness_mm":1.0}]}})
}
#[test]
fn interactive_lowering_preserves_real_directional_mode_without_selecting_it_by_default() {
for enabled in [false, true] {
let mut input = wire();
input["raytrace"]["directional_shadow_transmission"] = json!(enabled);
let lowered = interactive_params(&input).unwrap();
let after: Start = serde_json::from_value(lowered.clone()).unwrap();
assert_eq!(after.raytrace.unwrap().directional_shadow_transmission, enabled);
assert_eq!(lowered["raytrace"].get("directional_shadow_transmission").and_then(Value::as_bool), enabled.then_some(true));
}
}
#[cfg(all(feature = "raytrace-cpu", not(target_arch = "wasm32")))]
#[test]
fn directional_context_refusals_precede_unopened_hdr_and_product_capture() {
let mut session = Session::default();
let host = solidcraft_render::job::CpuHost::for_platform().unwrap();
session.set_render_job_host(Some(host)).unwrap();
for nested in [false, true] {
let mut input = wire();
if nested {
input["nested_media"] = json!(true);
} else {
input["environment"] = json!({"path":"must-not-open-original-directional.hdr","samples":4});
}
let error = prepare(&session, &input, "render.start").err().unwrap().to_string();
assert!(
error.contains(if nested {
"directional shadow transmission does not support nested media"
} else {
"directional shadow transmission does not support HDR environments"
}),
"{error}"
);
assert!(!error.contains("could not read"));
assert_eq!(session.render_job_host().unwrap().admitted_bytes(), 0);
}
}
}
+167 -146
View File
@@ -1,146 +1,167 @@
//! Read-only light inspection. No host, geometry evaluation, camera or design mutation.
use serde_json::Value;
use solidcraft_doc::render_settings::{RenderSettings, RenderSetup};
use crate::params::bad;
use crate::schema::render_lights::{EditParams, EditResult, Geometry, Light};
use crate::schema::render_lights::{ListParams, ListResult};
use crate::{Result, Session};
pub(super) fn list(s: &mut Session, params: &Value) -> Result<Value> {
let cmd = "render.lights.list";
if !params.is_object() {
return Err(bad(cmd, "expected an object"));
}
let request: ListParams = serde_json::from_value(params.clone()).map_err(|e| bad(cmd, e.to_string()))?;
if params.get("setup").is_some_and(Value::is_null) || params.get("settings").is_some_and(Value::is_null) {
return Err(bad(cmd, "setup and settings cannot be null"));
}
let setup = match (request.setup, request.settings) {
(Some(_), Some(_)) => return Err(bad(cmd, "choose setup or settings, not both")),
(Some(name), None) => {
RenderSettings::validate_name(&name).map_err(|e| bad(cmd, e.to_string()))?;
s.doc.render.setups.get(&name).cloned().ok_or_else(|| bad(cmd, "no saved render setup with that name"))?
}
(None, Some(settings)) => {
if !settings.is_object() {
return Err(bad(cmd, "settings must be an object"));
}
serde_json::from_value(settings).map_err(|e| bad(cmd, e.to_string()))?
}
(None, None) => RenderSetup::default(),
};
setup.validate().map_err(|e| bad(cmd, e.to_string()))?;
serde_json::to_value(ListResult { lights: crate::render_lights::inspect(&setup).map_err(|e| bad(cmd, e))? }).map_err(|e| bad(cmd, e.to_string()))
}
fn change(s: &mut Session, params: &Value, operation: &str) -> Result<Value> {
let command = format!("render.lights.{operation}");
if !params.is_object() {
return Err(bad(&command, "expected an object"));
}
let input: EditParams = serde_json::from_value(params.clone()).map_err(|e| bad(&command, e.to_string()))?;
RenderSettings::validate_name(&input.name).map_err(|e| bad(&command, e.to_string()))?;
if input.expected_revision.is_some_and(|revision| revision != s.revision) {
return Err(bad(&command, "light settings changed; refresh before editing"));
}
let mut setup = match input.settings {
Some(value) if value.is_object() => serde_json::from_value::<RenderSetup>(value).map_err(|e| bad(&command, e.to_string()))?,
Some(_) => return Err(bad(&command, "settings must be an object")),
None => s.doc.render.setups.get(&input.name).cloned().unwrap_or_default(),
};
let mut lights = crate::render_lights::inspect(&setup).map_err(|e| bad(&command, e))?;
let target = [setup.camera.target.x, setup.camera.target.y, setup.camera.target.z];
let mut selected = None;
match operation {
"add" => {
if input.id.is_some() {
return Err(bad(&command, "add does not accept an id"));
}
let mut patch = input.set.unwrap_or_default();
if patch.kind.is_none() {
patch.kind = Some("area".into());
}
let [x, y, z] = target;
let mut light = Light {
id: String::new(),
name: String::new(),
name_key: String::new(),
number: 0,
implicit: false,
intensity: 1.0,
colour: [1.0; 3],
geometry: Geometry::Area { position_mm: [x, y - 100.0, z + 100.0], edge_u_mm: [10.0, 0.0, 0.0], edge_v_mm: [0.0, -10.0, 0.0] },
};
crate::render_lights::patch(&mut light, &patch, target, setup.camera.distance).map_err(|e| bad(&command, e))?;
lights.push(light);
selected = lights.len().checked_sub(1);
}
"set" | "duplicate" | "remove" => {
let id = input.id.as_deref().ok_or_else(|| bad(&command, "id is required"))?;
crate::render_lights::parse_id(id).map_err(|e| bad(&command, e))?;
let index = lights.iter().position(|light| light.id == id).ok_or_else(|| bad(&command, "light no longer exists"))?;
if operation == "remove" {
if input.set.is_some() {
return Err(bad(&command, "remove does not accept set"));
}
lights.retain(|light| light.id != id);
} else if operation == "duplicate" {
if input.set.is_some() {
return Err(bad(&command, "duplicate does not accept set"));
}
let light = lights.get(index).cloned().ok_or_else(|| bad(&command, "missing light"))?;
lights.push(light);
selected = lights.len().checked_sub(1);
} else {
let patch = input.set.ok_or_else(|| bad(&command, "set is required"))?;
let light = lights.get_mut(index).ok_or_else(|| bad(&command, "missing light"))?;
if matches!(setup.job, Some(solidcraft_doc::render_settings::RenderJob::CpuRaytrace { .. }))
&& light.geometry.kind() == "directional"
&& patch.softness.is_some()
{
return Err(bad(&command, "CPU directional lights have hard shadows; softness is not supported"));
}
crate::render_lights::patch(light, &patch, target, setup.camera.distance).map_err(|e| bad(&command, e))?;
selected = Some(index);
}
}
_ => return Err(bad(&command, "unknown light operation")),
}
let id = selected.and_then(|index| {
lights.get(index).map(|light| {
let kind = light.geometry.kind();
let slot = lights
.iter()
.take(index)
.filter(|prior| {
if matches!(kind, "point" | "spot") { matches!(prior.geometry.kind(), "point" | "spot") } else { prior.geometry.kind() == kind }
})
.count();
format!("{kind}:{slot}")
})
});
crate::render_lights::write(&mut setup, &lights).map_err(|e| bad(&command, e))?;
let mut settings = (*s.doc.render).clone();
settings.setups.insert(input.name.clone(), setup.clone());
settings.validate().map_err(|e| bad(&command, e.to_string()))?;
if *s.doc.render != settings {
s.doc_mut().render = std::sync::Arc::new(settings);
}
serde_json::to_value(EditResult { id, name: input.name, setup: serde_json::to_value(setup).map_err(|e| bad(&command, e.to_string()))? })
.map_err(|e| bad(&command, e.to_string()))
}
pub(super) fn set(s: &mut Session, p: &Value) -> Result<Value> {
change(s, p, "set")
}
pub(super) fn add(s: &mut Session, p: &Value) -> Result<Value> {
change(s, p, "add")
}
pub(super) fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
change(s, p, "duplicate")
}
pub(super) fn remove(s: &mut Session, p: &Value) -> Result<Value> {
change(s, p, "remove")
}
//! Read-only light inspection. No host, geometry evaluation, camera or design mutation.
use serde_json::Value;
use solidcraft_doc::render_settings::{RenderSettings, RenderSetup};
use crate::params::bad;
use crate::schema::render_lights::{EditParams, EditResult, Geometry, Light};
use crate::schema::render_lights::{ListParams, ListResult};
use crate::{Result, Session};
pub(super) fn list(s: &mut Session, params: &Value) -> Result<Value> {
let cmd = "render.lights.list";
if let Some(settings) = params.get("settings") {
super::validate_saved_raster_lights(settings, cmd)?;
}
if !params.is_object() {
return Err(bad(cmd, "expected an object"));
}
let request: ListParams = serde_json::from_value(params.clone()).map_err(|e| bad(cmd, e.to_string()))?;
if params.get("setup").is_some_and(Value::is_null) || params.get("settings").is_some_and(Value::is_null) {
return Err(bad(cmd, "setup and settings cannot be null"));
}
let setup = match (request.setup, request.settings) {
(Some(_), Some(_)) => return Err(bad(cmd, "choose setup or settings, not both")),
(Some(name), None) => {
RenderSettings::validate_name(&name).map_err(|e| bad(cmd, e.to_string()))?;
let setup = s.doc.render.setups.get(&name).ok_or_else(|| bad(cmd, "no saved render setup with that name"))?;
setup.validate().map_err(|e| bad(cmd, e.to_string()))?;
setup.clone()
}
(None, Some(settings)) => {
if !settings.is_object() {
return Err(bad(cmd, "settings must be an object"));
}
serde_json::from_value(settings).map_err(|e| bad(cmd, e.to_string()))?
}
(None, None) => RenderSetup::default(),
};
setup.validate().map_err(|e| bad(cmd, e.to_string()))?;
serde_json::to_value(ListResult { lights: crate::render_lights::inspect(&setup).map_err(|e| bad(cmd, e))? }).map_err(|e| bad(cmd, e.to_string()))
}
fn change(s: &mut Session, params: &Value, operation: &str) -> Result<Value> {
let command = format!("render.lights.{operation}");
if let Some(settings) = params.get("settings") {
super::validate_saved_raster_lights(settings, &command)?;
}
if !params.is_object() {
return Err(bad(&command, "expected an object"));
}
let input: EditParams = serde_json::from_value(params.clone()).map_err(|e| bad(&command, e.to_string()))?;
RenderSettings::validate_name(&input.name).map_err(|e| bad(&command, e.to_string()))?;
if input.expected_revision.is_some_and(|revision| revision != s.revision) {
return Err(bad(&command, "light settings changed; refresh before editing"));
}
let mut setup = match input.settings {
Some(value) if value.is_object() => serde_json::from_value::<RenderSetup>(value).map_err(|e| bad(&command, e.to_string()))?,
Some(_) => return Err(bad(&command, "settings must be an object")),
None => {
if let Some(setup) = s.doc.render.setups.get(&input.name) {
setup.validate().map_err(|e| bad(&command, e.to_string()))?;
}
s.doc.render.setups.get(&input.name).cloned().unwrap_or_default()
}
};
let mut lights = crate::render_lights::inspect(&setup).map_err(|e| bad(&command, e))?;
if matches!(&setup.job, Some(solidcraft_doc::render_settings::RenderJob::Raster { .. })) {
if matches!(operation, "add" | "duplicate") && lights.len() == 8 {
return Err(bad(&command, "at most eight saved Raster directional lights"));
}
if let Some(patch) = &input.set {
crate::render_lights::validate_raster_patch(patch).map_err(|e| bad(&command, e))?;
}
}
let target = [setup.camera.target.x, setup.camera.target.y, setup.camera.target.z];
let mut selected = None;
match operation {
"add" => {
if input.id.is_some() {
return Err(bad(&command, "add does not accept an id"));
}
let mut patch = input.set.unwrap_or_default();
if patch.kind.is_none() {
patch.kind = Some("area".into());
}
let [x, y, z] = target;
let mut light = Light {
id: String::new(),
name: String::new(),
name_key: String::new(),
number: 0,
implicit: false,
intensity: 1.0,
colour: [1.0; 3],
geometry: Geometry::Area { position_mm: [x, y - 100.0, z + 100.0], edge_u_mm: [10.0, 0.0, 0.0], edge_v_mm: [0.0, -10.0, 0.0] },
};
crate::render_lights::patch(&mut light, &patch, target, setup.camera.distance).map_err(|e| bad(&command, e))?;
lights.push(light);
selected = lights.len().checked_sub(1);
}
"set" | "duplicate" | "remove" => {
let id = input.id.as_deref().ok_or_else(|| bad(&command, "id is required"))?;
crate::render_lights::parse_id(id).map_err(|e| bad(&command, e))?;
let index = lights.iter().position(|light| light.id == id).ok_or_else(|| bad(&command, "light no longer exists"))?;
if operation == "remove" {
if input.set.is_some() {
return Err(bad(&command, "remove does not accept set"));
}
lights.retain(|light| light.id != id);
} else if operation == "duplicate" {
if input.set.is_some() {
return Err(bad(&command, "duplicate does not accept set"));
}
let light = lights.get(index).cloned().ok_or_else(|| bad(&command, "missing light"))?;
lights.push(light);
selected = lights.len().checked_sub(1);
} else {
let patch = input.set.ok_or_else(|| bad(&command, "set is required"))?;
let light = lights.get_mut(index).ok_or_else(|| bad(&command, "missing light"))?;
if matches!(setup.job, Some(solidcraft_doc::render_settings::RenderJob::CpuRaytrace { .. }))
&& light.geometry.kind() == "directional"
&& patch.softness.is_some()
{
return Err(bad(&command, "CPU directional lights have hard shadows; softness is not supported"));
}
crate::render_lights::patch(light, &patch, target, setup.camera.distance).map_err(|e| bad(&command, e))?;
selected = Some(index);
}
}
_ => return Err(bad(&command, "unknown light operation")),
}
let id = selected.and_then(|index| {
lights.get(index).map(|light| {
let kind = light.geometry.kind();
let slot = lights
.iter()
.take(index)
.filter(|prior| {
if matches!(kind, "point" | "spot") { matches!(prior.geometry.kind(), "point" | "spot") } else { prior.geometry.kind() == kind }
})
.count();
format!("{kind}:{slot}")
})
});
crate::render_lights::write(&mut setup, &lights).map_err(|e| bad(&command, e))?;
let mut settings = (*s.doc.render).clone();
settings.setups.insert(input.name.clone(), setup.clone());
settings.validate().map_err(|e| bad(&command, e.to_string()))?;
if *s.doc.render != settings {
s.doc_mut().render = std::sync::Arc::new(settings);
}
serde_json::to_value(EditResult { id, name: input.name, setup: serde_json::to_value(setup).map_err(|e| bad(&command, e.to_string()))? })
.map_err(|e| bad(&command, e.to_string()))
}
pub(super) fn set(s: &mut Session, p: &Value) -> Result<Value> {
change(s, p, "set")
}
pub(super) fn add(s: &mut Session, p: &Value) -> Result<Value> {
change(s, p, "add")
}
pub(super) fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
change(s, p, "duplicate")
}
pub(super) fn remove(s: &mut Session, p: &Value) -> Result<Value> {
change(s, p, "remove")
}
+88 -1
View File
@@ -1,6 +1,93 @@
use super::*;
use solidcraft_doc::render_settings::RenderJob;
#[test]
fn offline_raster_directional_edit_persists_with_one_undo_and_exact_reopen() {
let mut s = Session::default();
save(&mut s, json!({"width":16,"height":16,"job":{"method":"raster"}}));
let before = s.doc.clone();
let undo = s.undo.len();
let reply = s
.execute(
"render.lights.set",
&json!({"name":"Studio","id":"directional:0","expected_revision":s.revision,
"set":{"direction":[3,-4,0],"colour":[0.25,0.5,1],"intensity_ev":1}}),
)
.unwrap();
assert_eq!(reply["setup"]["job"]["method"], "raster");
let light = &reply["setup"]["job"]["lights"][0];
assert_eq!(light["direction"][0].as_f64().unwrap(), 3.0);
assert_eq!(light["direction"][1].as_f64().unwrap(), -4.0);
assert_eq!(light["intensity"].as_f64().unwrap(), 2.0);
assert_eq!(reply["setup"]["job"]["lights"].as_array().unwrap().len(), 3);
assert_eq!(s.undo.len(), undo + 1);
let after = s.doc.clone();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc, before);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(s.doc, after);
let loaded = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
assert_eq!(loaded.render, s.doc.render);
let listed = s.execute("render.lights.list", &json!({"setup":"Studio"})).unwrap();
assert_eq!(listed["lights"][0]["implicit"], false);
assert_eq!(listed["lights"][0]["direction"][0].as_f64().unwrap(), 3.0);
}
#[test]
fn saved_raster_remove_all_and_visibility_preserve_explicit_darkness_and_authored_values() {
let mut s = Session::default();
save(&mut s, json!({"job":{"method":"raster","lights":[{"direction":[0,0,1],"color":[0.25,0.5,1],"intensity":2}]}}));
let authored = s.doc.render.setups["Studio"].clone();
let mut filtered = authored.clone();
let visibility = crate::schema::render_lights::Visibility { solo: None, muted: ["directional:0".into()].into_iter().collect() };
assert_eq!(crate::render_lights::filter(&mut filtered, &visibility).unwrap(), None);
let Some(RenderJob::Raster { lights: Some(lights), .. }) = &filtered.job else { panic!("lost rig") };
assert_eq!(lights[0].intensity, 0.0);
assert_eq!(s.doc.render.setups["Studio"], authored);
s.execute("render.lights.remove", &json!({"name":"Studio","id":"directional:0"})).unwrap();
let Some(RenderJob::Raster { lights: Some(lights), .. }) = &s.doc.render.setups["Studio"].job else { panic!("empty rig became legacy") };
assert!(lights.is_empty());
let loaded = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
assert_eq!(loaded.render, s.doc.render);
assert_eq!(s.execute("render.lights.list", &json!({"setup":"Studio"})).unwrap()["lights"], json!([]));
}
#[test]
fn saved_raster_unsupported_families_and_controls_refuse_atomically() {
let mut s = Session::default();
save(&mut s, json!({"job":{"method":"raster"}}));
let before = s.doc.clone();
let state = (s.revision, s.undo.len(), s.redo.len());
for patch in [
json!({"kind":"point"}),
json!({"kind":"spot"}),
json!({"kind":"area"}),
json!({"softness":0}),
json!({"intensity_ev":7}),
json!({"distance_mm":10}),
json!({"radiance":[1,1,1]}),
json!({"direction":[0,0,1],"azimuth_deg":0}),
] {
assert!(s.execute("render.lights.set", &json!({"name":"Studio","id":"directional:0","set":patch})).is_err());
assert!(Arc::ptr_eq(&before, &s.doc));
assert_eq!(state, (s.revision, s.undo.len(), s.redo.len()));
}
}
#[test]
fn saved_raster_array_is_bounded_before_settings_merge_and_inline_light_edit() {
let mut s = Session::default();
save(&mut s, json!({"job":{"method":"raster"}}));
let before = s.doc.clone();
let light = json!({"direction":[0,0,1],"color":[1,1,1],"intensity":1});
let invalid = json!({"job":{"method":"raster","lights":vec![light;9]}});
assert!(s.execute("render.settings.set", &json!({"name":"Studio","set":invalid})).is_err());
assert!(s.execute("render.lights.set", &json!({"name":"Studio","id":"directional:0","settings":invalid,"set":{"intensity_ev":1}})).is_err());
assert!(s.execute("render.lights.list", &json!({"settings":invalid})).is_err());
assert!(Arc::ptr_eq(&before, &s.doc));
// The small refusal fixture is not an OOM test; source order proves pre-copy admission.
}
fn save(s: &mut Session, patch: Value) {
s.execute("render.settings.set", &json!({"name":"Studio", "set":patch})).unwrap();
}
@@ -725,7 +812,7 @@ fn invalid_saved_jobs_never_partially_replace_a_setup() {
json!({"job":{"method":"gpu_path","options":{"samples":4097}}}),
json!({"job":{"method":"cpu_raytrace","options":{"emissions":[]}}}),
json!({"job":{"method":"gpu_path","options":{"environment":{"kind":"hdr","path":"not-opened.hdr"}}}}),
json!({"job":{"method":"raster"}}),
json!({"job":{"method":"raster"},"exposure_ev":33}),
json!({"job":{"method":"gpu_path"},"environment_intensity":2}),
json!({"job":{"options":{"samples":4}}}),
] {
+127 -1
View File
@@ -1,7 +1,10 @@
//! Planned Render interactions, with explicit non-mutating refusals.
use super::CommandSpec;
use crate::schema::render_lights::{MuteParams, SoloParams};
use crate::schema::{TypedContract, output_schema_for, render_ui::CheckReport};
use crate::schema::{
TypedContract, output_schema_for,
render_ui::{CheckReport, InteractiveParams},
};
use crate::{Result, Session, params::bad};
use serde::Deserialize;
use serde_json::{Value, json};
@@ -48,8 +51,16 @@ macro_rules! waiting {
}
const CHECK_REPORT: TypedContract<CheckReport> = TypedContract::new();
const INTERACTIVE: TypedContract<InteractiveParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("render.interactive", "Interactive Render", crate::checked!("render.interactive", interactive))
.icon("render_image")
.contract(&INTERACTIVE)
.output(output_schema_for::<InteractiveParams>)
.noundo()
.doc_only()
.params("action: on|off|pause|resume. Validates and echoes an explicit CPU Interactive preview request for the UI adapter. Headless calls never start, pause or cancel a render job, open UI, or change the design, final render settings or exportable images."),
CommandSpec::new("render.preflight.copy", "Copy Render Check Report", crate::checked!("render.preflight.copy", copy_check))
.icon("copy")
.contract(&CHECK_REPORT)
@@ -210,3 +221,118 @@ fn output_sections_copy_report_validates_without_design_history_or_job_changes()
assert!(std::sync::Arc::ptr_eq(&doc, &s.doc));
assert_eq!(before, (s.revision, s.undo.len(), s.redo.len(), s.evaluations, s.is_dirty()));
}
/// The UI adapter owns provisional preview actions; headless execution only echoes.
fn interactive(_: &mut Session, params: &Value) -> Result<Value> {
let command = "render.interactive";
if !params.is_object() {
return Err(bad(command, "expected an object"));
}
let request: InteractiveParams = serde_json::from_value(params.clone()).map_err(|error| bad(command, error.to_string()))?;
serde_json::to_value(request).map_err(|error| bad(command, error.to_string()))
}
#[cfg(test)]
#[test]
fn interactive_contract_describes_exact_actions_and_a_nonmutating_echo() {
use crate::schema::{input_schema_for, render_ui::Action};
let command = super::find_command("render.interactive").unwrap();
assert!(!command.undoable);
assert!(!command.reads_model);
assert!(command.planned.is_none());
assert_eq!(command.icon, "render_image");
let contract = command.contract.unwrap();
assert_eq!(contract.allowed_keys().iter().map(String::as_str).collect::<Vec<_>>(), ["action"]);
assert!(command.output.is_some());
let action_schema = serde_json::to_value(input_schema_for::<Action>()).unwrap();
assert_eq!(action_schema["enum"], json!(["on", "off", "pause", "resume"]));
let request_schema = serde_json::to_value(input_schema_for::<InteractiveParams>()).unwrap();
assert_eq!(request_schema["additionalProperties"], false);
assert_eq!(request_schema["required"], json!(["action"]));
let reply_schema = serde_json::to_value(output_schema_for::<InteractiveParams>()).unwrap();
assert_eq!(reply_schema["required"], json!(["action"]));
assert_eq!(reply_schema["properties"].as_object().unwrap().keys().map(String::as_str).collect::<Vec<_>>(), ["action"]);
}
#[cfg(test)]
#[test]
fn interactive_valid_and_hostile_requests_preserve_headless_document_history_and_host() {
use crate::schema::ParamCheck;
let mut session = Session::default();
session.execute("render.settings.set", &json!({"name":"Original","set":{"width":800}})).unwrap();
session.execute("render.settings.set", &json!({"name":"Original","set":{"width":900}})).unwrap();
session.execute("edit.undo", &json!({})).unwrap();
let document = session.doc.clone();
let before = (session.revision, session.undo.len(), session.redo.len(), session.evaluations, session.is_dirty());
assert!(session.render_job_host().is_none());
for level in [ParamCheck::Off, ParamCheck::Warn, ParamCheck::Strict] {
session.param_check = level;
for action in ["on", "off", "pause", "resume"] {
let params = json!({"action":action});
assert_eq!(session.execute("render.interactive", &params).unwrap(), params);
}
for hostile in [
json!(null),
json!(true),
json!(0),
json!("on"),
json!([]),
json!(["on"]),
json!({}),
json!({"action":null}),
json!({"action":true}),
json!({"action":1e300}),
json!({"action":["on"]}),
json!({"action":{"on":true}}),
json!({"action":"start"}),
json!({"action":"On"}),
json!({"action":" on"}),
json!({"action":"on\n"}),
json!({"action":"x".repeat(65536)}),
json!({"action":"on","slot":0}),
json!({"action":"off","job":{"session":1,"serial":1,"generation":1}}),
json!({"action":"pause","replace":true}),
json!({"action":"resume","settings":{}}),
json!({"action":"on","path":"must-not-be-read.hdr"}),
] {
assert!(session.execute("render.interactive", &hostile).is_err(), "{level:?}: {hostile}");
}
assert!(std::sync::Arc::ptr_eq(&document, &session.doc));
assert_eq!(before, (session.revision, session.undo.len(), session.redo.len(), session.evaluations, session.is_dirty()));
assert!(session.render_job_host().is_none());
}
}
#[cfg(test)]
#[test]
fn interactive_echo_preserves_a_queued_final_job_and_its_resource_lease() {
use solidcraft_render::job::{CpuHost, JobId};
let host = CpuHost::cooperative();
let mut session = Session::default();
session.set_render_job_host(Some(host.clone())).unwrap();
// Queue a tiny raster job without pumping the cooperative host or opening a device.
let started = session.execute("render.start", &json!({"settings":{"width":16,"height":16}})).unwrap();
let first_id: JobId = serde_json::from_value(started["job"].clone()).unwrap();
let first = session.render_job_handle(first_id).unwrap();
let status = serde_json::to_value(first.status().unwrap()).unwrap();
let admitted = host.admitted_bytes();
assert!(admitted > 0);
let document = session.doc.clone();
let before = (session.revision, session.undo.len(), session.redo.len(), session.evaluations, session.is_dirty());
for action in ["on", "pause", "resume", "off"] {
let params = json!({"action":action});
assert_eq!(session.execute("render.interactive", &params).unwrap(), params);
assert_eq!(serde_json::to_value(first.status().unwrap()).unwrap(), status);
assert_eq!(host.admitted_bytes(), admitted);
assert!(session.render_job_host().unwrap().same_host(&host));
assert!(std::sync::Arc::ptr_eq(&document, &session.doc));
assert_eq!(before, (session.revision, session.undo.len(), session.redo.len(), session.evaluations, session.is_dirty()));
}
assert!(session.execute("render.interactive", &json!({"action":"off","job":first_id})).is_err());
assert_eq!(serde_json::to_value(first.status().unwrap()).unwrap(), status);
assert_eq!(host.admitted_bytes(), admitted);
let next = session.execute("render.start", &json!({"settings":{"width":16,"height":16}})).unwrap();
let next_id: JobId = serde_json::from_value(next["job"].clone()).unwrap();
assert_eq!(next_id.session, first_id.session);
assert_eq!(next_id.serial, first_id.serial + 1, "echo actions must not allocate or replace final job identities");
}
+6 -6
View File
@@ -14,13 +14,13 @@ use crate::params::{bad, req_vec3, vec3};
use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.line3d", "3D Line", line3d).icon("line").enabled(in_sketch).params(concat!(
CommandSpec::new("sketch.line3d", "3D Line", line3d).icon("line").enabled(in_sketch_3d).params(concat!(
"points: [[x,y,z]…] world points (2 or more): one 3D line per segment, joined end to end (needs 3D Sketch on)",
created_doc!()
)),
CommandSpec::new("sketch.spline3d", "3D Spline", spline3d)
.icon("spline")
.enabled(in_sketch)
.enabled(in_sketch_3d)
.params(concat!("points: [[x,y,z]…] world fit points (2 or more): a smooth 3D curve through them (needs 3D Sketch on)", created_doc!())),
CommandSpec::new("sketch.arc3d", "3D Arc", arc3d)
.icon("arc3")
@@ -31,7 +31,7 @@ pub static COMMANDS: &[CommandSpec] = &[
)),
CommandSpec::new("sketch.point3d", "3D Point", point3d)
.icon("point")
.enabled(in_sketch)
.enabled(in_sketch_3d)
.params(concat!("point: [x,y,z] world point (needs 3D Sketch on)", created_doc!())),
CommandSpec::new("sketch.move3d", "Move 3D Point", move3d)
.icon("sketch_move")
@@ -51,13 +51,13 @@ fn needs_3d(sk: &solidcraft_sketch::Sketch, cmd: &str) -> Result<()> {
if sk.view.three_d { Ok(()) } else { Err(bad(cmd, "turn on 3D Sketch (sketch.options sketch_3d: true) to draw off the sketch plane")) }
}
/// The 3D Arc button starts an interactive tool, so it is only offered while a 3D sketch is
/// being edited; otherwise the tool could only fail on its third click.
/// The 3D Line, Spline, Arc and Point buttons start interactive tools, so they are only offered
/// while a 3D sketch is being edited; otherwise the tools could only fail on their first click.
fn in_sketch_3d(s: &Session) -> std::result::Result<(), String> {
let id = sketch_store::active(s).ok_or("no sketch is being edited (Create Sketch or Edit Sketch first)")?;
match sketch_store::read(&s.doc, id) {
Ok(sk) if sk.view.three_d => Ok(()),
Ok(_) => Err("turn on 3D Sketch (sketch.options sketch_3d: true) to draw 3D arcs".into()),
Ok(_) => Err("turn on 3D Sketch (sketch.options sketch_3d: true) to draw 3D curves".into()),
Err(_) => Err("no sketch is being edited (Create Sketch or Edit Sketch first)".into()),
}
}
@@ -417,3 +417,25 @@ fn arc3d_pipes_along_the_arc() {
let quarter = 20.0 * std::f64::consts::FRAC_PI_2;
assert!((measure.volume - 4.0 * std::f64::consts::PI * quarter).abs() < 0.5, "{}", measure.volume);
}
#[test]
fn line3d_spline3d_point3d_need_a_3d_sketch() {
// Same rule as sketch.arc3d: these buttons start interactive tools, so they are only
// offered while a 3D sketch is being edited.
let ids = ["sketch.line3d", "sketch.spline3d", "sketch.point3d"];
let mut s = Session::default();
for id in ids {
let info = crate::cmd::find_command(id).unwrap().info(&s);
assert!(!info.enabled, "{id} offered with no sketch being edited");
}
run(&mut s, "sketch.create", json!({"plane": "XY"}));
for id in ids {
let info = crate::cmd::find_command(id).unwrap().info(&s);
assert!(!info.enabled, "{id} offered in a plain 2D sketch: {:?}", info.disabled_reason);
}
run(&mut s, "sketch.options", json!({"sketch_3d": true}));
for id in ids {
let info = crate::cmd::find_command(id).unwrap().info(&s);
assert!(info.enabled, "{id} not offered inside a 3D sketch: {:?}", info.disabled_reason);
}
}
@@ -49,6 +49,37 @@ fn redundant_dimensions_are_refused_driven_ones_measure() {
assert_eq!(g["fully_constrained"], true);
}
/// The same constraint between the same two lines, picked again (in either order), adds nothing:
/// the one already there is reported back as redundant, and the sketch keeps one glyph (#5224).
#[test]
fn the_same_constraint_twice_adds_nothing() {
let mut s = new_sketch();
let a = ids(&run(&mut s, "sketch.line", json!({"points": [[1, 1], [10, 1]]}))["curves"])[0].clone();
let b = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 5], [10, 7]]}))["curves"])[0].clone();
let first = run(&mut s, "sketch.constraint.parallel", json!({"a": a, "b": b}));
for (x, y) in [(&a, &b), (&b, &a), (&a, &b)] {
let r = run(&mut s, "sketch.constraint.parallel", json!({"a": x, "b": y}));
assert_eq!(r["redundant"], true, "{r}");
assert_eq!(r["constraint"], first["constraint"], "the one already there: {r}");
}
let e = s.execute("sketch.constraint.parallel", &json!({"a": a, "b": b, "if_redundant": "refuse"})).unwrap_err().to_string();
assert!(e.contains("already determined"), "{e}");
let parallels = sketch(&s).constraints.iter().filter(|c| matches!(c.kind, ConstraintKind::Parallel { .. })).count();
assert_eq!(parallels, 1);
let g = run(&mut s, "sketch.glyphs", json!({}));
assert_eq!(g["glyphs"].as_array().map(Vec::len), Some(1), "{g}");
// The sketch model itself hands back the one already there.
let mut sk = sketch(&s);
let n = sk.constraints.len();
let k = sk.constraints.iter().find(|c| matches!(c.kind, ConstraintKind::Parallel { .. })).unwrap().clone();
let (ia, ib) = match k.kind {
ConstraintKind::Parallel { a, b } => (a, b),
_ => (0, 0),
};
assert_eq!(sk.add_constraint(ConstraintKind::Parallel { a: ib, b: ia }, None).unwrap(), k.id);
assert_eq!(sk.constraints.len(), n);
}
/// `measured` is the dimension on the solved sketch: a trough drawn 99.84 long and dimensioned
/// 100 reports 100, not the length it had before solving.
#[test]
@@ -97,6 +97,26 @@ fn fillet_and_chamfer_corners() {
assert!((area(&s) - (a0 - 8.0)).abs() < 1e-6, "{}", area(&s));
}
/// A chamfer where the two lines have no corner is refused, as a fillet there is: two lines
/// lying on each other made a chamfer line of no length between their cut-back ends (found by a
/// local trim fuzzer), and two lines in line with each other have no corner either.
#[test]
fn a_chamfer_where_two_lines_lie_in_line_is_refused() {
for far in [[0, 10], [0, 30]] {
let mut s = new_sketch();
let a = curves(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [0, 20]], "infer": false})))[0].clone();
let b = curves(&run(&mut s, "sketch.line", json!({"points": [far, [0, 20]], "infer": false, "connect": false})))[0].clone();
run(&mut s, "sketch.constraint.coincident", json!({"a": format!("{a}.end"), "b": format!("{b}.end")}));
let n = sk(&s).curves.len();
for cmd in ["sketch.chamfer.equal_distance", "sketch.fillet"] {
let p = if cmd == "sketch.fillet" { json!({"a": a, "b": b, "radius": 2}) } else { json!({"a": a, "b": b, "distance": 2}) };
let e = s.execute(cmd, &p).unwrap_err().to_string();
assert!(e.contains("parallel"), "{cmd} at {far:?}: {e}");
assert_eq!(sk(&s).curves.len(), n, "{cmd} at {far:?}");
}
}
}
/// The ids of the constraints driven by the model parameter `name`.
fn driven_by(s: &Session, name: &str) -> Vec<String> {
sk(s).constraints.iter().filter(|c| c.param.as_deref() == Some(name)).map(|c| c.id.clone()).collect()
+6
View File
@@ -1789,6 +1789,12 @@ fn chamfer(s: &mut Session, p: &Value, cmd: &str, dists: impl FnOnce(f64) -> Res
let (pc, pa, pb) = (sk.point(c).unwrap_or_default(), sk.point(fa).unwrap_or_default(), sk.point(fb).unwrap_or_default());
let (u, v) = ((pa - pc).normalized().unwrap_or(Vec2::X), (pb - pc).normalized().unwrap_or(Vec2::Y));
let corner_angle = u.cross(v).atan2(u.dot(v)).abs();
// Lines in line with each other, or lying on each other, have no corner to cut, as a
// fillet refuses them: on two lines lying on each other the chamfer was a line of no
// length between their cut-back ends.
if !(2e-6..=std::f64::consts::PI - 2e-9).contains(&corner_angle) {
return Err(bad(cmd, "the lines are parallel"));
}
let (da, db) = dists(corner_angle)?;
let (ta, tb, _, kept) = cut_corner(sk, la, lb, c, fa, fb, da, db, cmd)?;
let l = sk.add_line_pts(ta, tb, None)?;
@@ -2492,6 +2492,26 @@ fn ends_tied_through_a_trimmed_away_end_stay_tied() {
assert!(at("l1.end").dist(at("l2.start")) < 1e-9);
}
/// An end held on a line exactly where that line meets another curve stays tied to it when a
/// trim takes the line away (found by the local trim fuzzer: a trimmed circle ended on a line
/// where a fillet started, and trimming the line left the circle's end loose on the fillet's).
#[test]
fn an_end_held_on_a_trimmed_away_line_at_its_end_stays_tied() {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0], [10, 10]], "infer": false}));
run(&mut s, "sketch.line", json!({"points": [[20, 5], [10, 0]], "infer": false, "connect": false}));
run(&mut s, "sketch.constraint.coincident", json!({"a": "l3.end", "b": "l2"}));
assert!(solidcraft_sketch::loose_ends(&sketch(&s)).untied.is_empty());
let r = run(&mut s, "sketch.trim", json!({"curve": "l2", "at": [10, 5]}));
assert_eq!(r["result"]["deleted"], "l2");
let ends = solidcraft_sketch::loose_ends(&sketch(&s));
assert!(ends.untied.is_empty(), "l1 and l3 still meet at (10, 0): {ends:?}");
run(&mut s, "sketch.drag", json!({"entity": "l1.end", "from": [10, 0], "to": [12, 1]}));
let sk = sketch(&s);
let at = |r: &str| sk.point(sk.resolve_point(r).unwrap()).unwrap();
assert!(at("l1.end").dist(at("l3.end")) < 1e-9);
}
#[test]
fn trim_keeps_the_rest_of_a_dimensioned_line_where_it_was() {
// A 40 x 40 square, its right side dimensioned with the Dimension tool (a line length).
+26 -7
View File
@@ -21,9 +21,11 @@ macro_rules! refs_doc {
}
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.project", "Project", project_cmd).at("SKETCH", "CREATE").icon("project").key("P").enabled(in_sketch).params(concat!(refs_doc!(), created_doc!())),
CommandSpec::new("sketch.project", "Project", project_cmd).at("SKETCH", "CREATE").icon("project").key("P").enabled(in_sketch).params(
concat!(refs_doc!(), "; projection_link?: bool (default true)", created_doc!()),
),
CommandSpec::new("sketch.intersect", "Intersect", intersect_cmd).at("SKETCH", "CREATE").icon("intersect").enabled(in_sketch).params(
concat!("refs: [{body}, {face: [x,y,z], body?}, {edge: [x,y,z], body?}, {sketch, curve}]: section curves (or points) with the sketch plane, linked", created_doc!()),
concat!("refs: [{body}, {face: [x,y,z], body?}, {edge: [x,y,z], body?}, {sketch, curve}]: section curves (or points) with the sketch plane; projection_link?: bool (default true)", created_doc!()),
),
CommandSpec::new("sketch.include_3d", "Include 3D Geometry", include_cmd)
.at("SKETCH", "CREATE")
@@ -307,7 +309,7 @@ fn link_json(sk: &Sketch, link: &str) -> Value {
json!({"link": link, "curves": curves, "points": points})
}
fn run_links(s: &mut Session, p: &Value, cmd: &str, kind: LinkKind) -> Result<Value> {
fn run_links(s: &mut Session, p: &Value, cmd: &str, kind: LinkKind, projection_link: bool) -> Result<Value> {
let refs: Vec<Value> = match p.get("refs").or_else(|| p.get("ref")) {
Some(Value::Array(a)) => a.iter().take(1000).cloned().collect(),
Some(v) => vec![v.clone()],
@@ -327,7 +329,14 @@ fn run_links(s: &mut Session, p: &Value, cmd: &str, kind: LinkKind) -> Result<Va
for src in sources {
let what = src.describe();
match add_link_unless_present(s, doc, id, sk, kind, src, kind == LinkKind::Project) {
Ok(Some(l)) => out.push(link_json(sk, &l)),
Ok(Some(l)) => {
let mut result = link_json(sk, &l);
if !projection_link {
result["link"] = Value::Null;
sk.break_link(&l)?;
}
out.push(result);
}
Ok(None) => present.push(what),
Err(e) => return Err(bad(cmd, format!("{what}: {e}"))),
}
@@ -346,15 +355,25 @@ fn run_links(s: &mut Session, p: &Value, cmd: &str, kind: LinkKind) -> Result<Va
}
fn project_cmd(s: &mut Session, p: &Value) -> Result<Value> {
run_links(s, p, "sketch.project", LinkKind::Project)
let projection_link = match p.get("projection_link") {
None => true,
Some(Value::Bool(value)) => *value,
Some(_) => return Err(bad("sketch.project", "`projection_link` must be a boolean")),
};
run_links(s, p, "sketch.project", LinkKind::Project, projection_link)
}
fn intersect_cmd(s: &mut Session, p: &Value) -> Result<Value> {
run_links(s, p, "sketch.intersect", LinkKind::Intersect)
let projection_link = match p.get("projection_link") {
None => true,
Some(Value::Bool(value)) => *value,
Some(_) => return Err(bad("sketch.intersect", "`projection_link` must be a boolean")),
};
run_links(s, p, "sketch.intersect", LinkKind::Intersect, projection_link)
}
fn include_cmd(s: &mut Session, p: &Value) -> Result<Value> {
run_links(s, p, "sketch.include_3d", LinkKind::Include)
run_links(s, p, "sketch.include_3d", LinkKind::Include, true)
}
/// A constraint's operands may be model geometry (#6348): an `{edge: [x,y,z], body?}` or
@@ -216,6 +216,20 @@ fn project_edges_bodies_sketches_and_origin() {
assert!(s.execute("sketch.project", &json!({"refs": [{"plane": "XY"}]})).is_err(), "parallel plane projects to nothing");
}
#[test]
fn project_whole_body_can_create_unlinked_geometry() {
let mut s = Session::default();
box_part(&mut s);
let sketch = run(&mut s, "sketch.create", json!({"plane":"XY"}))["sketch"].as_u64().unwrap();
let body = s.model.state().bodies[0].name.clone();
let r = run(&mut s, "sketch.project", json!({"refs":[{"body":body}],"projection_link":false}));
assert_eq!(r["links"][0]["link"], Value::Null);
let sk = s.doc.sketch(sketch).unwrap();
assert!(sk.links.is_empty());
assert_eq!(sk.curves.len(), 4);
assert!(s.execute("sketch.project", &json!({"refs":[{"body":body}],"projection_link":"false"})).is_err());
}
#[test]
fn intersect_cuts_a_body_with_the_sketch_plane() {
let mut s = Session::default();
@@ -873,3 +887,199 @@ fn intersect_on_a_top_face_gives_its_outline() {
ends.sort_by(|a, b| a.partial_cmp(b).unwrap());
assert_eq!(ends, vec![[0.0, 0.0, 40.0, 0.0], [0.0, 30.0, 0.0, 0.0], [40.0, 0.0, 40.0, 30.0], [40.0, 30.0, 0.0, 30.0]]);
}
#[test]
fn intersect_whole_body_can_create_unlinked_section_geometry() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20, "body_name": "Post"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": 10}));
let result = run(&mut s, "sketch.intersect", json!({"refs": [{"body": "Post"}], "projection_link": false}));
assert_eq!(result["links"][0]["link"], Value::Null, "{result}");
assert_eq!(result["links"][0]["curves"].as_array().unwrap().len(), 4, "{result}");
let sketch = s.doc.sketch(s.active_sketch.unwrap()).unwrap();
assert!(sketch.links.is_empty(), "projection links were disabled");
assert_eq!(sketch.curves.len(), 4, "the whole body's section remains as normal sketch geometry");
assert!(s.execute("sketch.intersect", &json!({"refs": [{"body": "Post"}], "projection_link": "false"})).is_err());
}
#[test]
fn whole_body_project_preserves_the_arc_of_a_chamfered_round_edge() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20, "body_name": "Plate"}));
run(&mut s, "solid.fillet", json!({"edges": [[40, 0, 10]], "radius": 5}));
let sketch = run(&mut s, "sketch.create", json!({"plane": {"face": [20, 15, 20]}, "project_edges": false}))["sketch"].as_u64().unwrap();
run(&mut s, "sketch.project", json!({"refs": [{"body": "Plate"}]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "timeline.roll_to", json!({"position": 2}));
run(&mut s, "solid.chamfer", json!({"edges": [[40, 15, 20]], "distance": 1}));
run(&mut s, "timeline.roll_to", json!({}));
let result = s.model.result(sketch).unwrap();
let state = s.model.state();
let solved = state.sketch(sketch).unwrap();
let sketch = &solved.sketch;
assert!(result.error.is_none(), "{:?}", result.error);
assert!(result.warning.is_none(), "a rebuilt projection nothing depends on needs no warning: {:?}", result.warning);
assert!((solved.plane.origin.z - 20.).abs() < 1e-6);
assert!(
sketch.curves.iter().any(|curve| matches!(curve.kind, solidcraft_sketch::CurveKind::Arc { .. })),
"the rounded chamfer silhouette should contain an arc, not only line segments: {:?}",
sketch.curves
);
}
#[test]
fn whole_body_project_stays_on_the_body_outline_after_a_late_chamfer() {
use solidcraft_sketch::CurveKind;
let planes: [([i32; 3], &str); 3] = [([20, 15, 20], "top"), ([20, 0, 10], "front"), ([40, 15, 10], "side")];
let chamfers: [[i32; 3]; 4] = [[40, 15, 20], [20, 0, 20], [40, 0, 10], [38, 2, 20]];
for (face, plane) in planes {
for chamfer in chamfers {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20, "body_name": "Plate"}));
run(&mut s, "solid.fillet", json!({"edges": [[40, 0, 10]], "radius": 5}));
let sketch = run(&mut s, "sketch.create", json!({"plane": {"face": face}, "project_edges": false}))["sketch"].as_u64().unwrap();
run(&mut s, "sketch.project", json!({"refs": [{"body": "Plate"}]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "timeline.roll_to", json!({"position": 2}));
if s.execute("solid.chamfer", &json!({"edges": [chamfer], "distance": 1})).is_err() {
continue;
}
run(&mut s, "timeline.roll_to", json!({}));
let state = s.model.state();
let solved = state.sketch(sketch).unwrap();
let sk = &solved.sketch;
let pts: Vec<_> = sk.points.iter().map(|p| p.pos).collect();
let (x0, x1) = pts.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.x), a.1.max(p.x)));
let (y0, y1) = pts.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.y), a.1.max(p.y)));
let mut worst = 0.0_f64;
for curve in &sk.curves {
if let CurveKind::Arc { c, a, b } = curve.kind {
let (c, a, b) = (pts[c], pts[a], pts[b]);
let r = c.dist(a);
let (t0, mut sweep) = ((a - c).angle(), (b - c).angle() - (a - c).angle());
while sweep < 0.0 {
sweep += std::f64::consts::TAU;
}
for i in 0..=16 {
let t = t0 + sweep * f64::from(i) / 16.0;
let q = solidcraft_geom::Vec2::new(c.x + r * t.cos(), c.y + r * t.sin());
worst = worst.max((x0 - q.x).max(q.x - x1).max(y0 - q.y).max(q.y - y1));
}
assert!(sweep < std::f64::consts::PI + 0.01, "{plane} {chamfer:?}: arc sweeps {sweep} rad, not a rounded corner");
}
}
assert!(worst < 0.01, "{plane} {chamfer:?}: an arc leaves the body outline by {worst}");
}
}
}
#[test]
fn whole_body_project_of_a_filleted_plate_keeps_exact_lines_and_arcs() {
use solidcraft_sketch::CurveKind;
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 80, "width": 50, "height": 8, "body_name": "Plate"}));
run(&mut s, "solid.fillet", json!({"edges": [[0, 0, 4], [80, 0, 4], [80, 50, 4], [0, 50, 4]], "radius": 6}));
let sketch = run(&mut s, "sketch.create", json!({"plane": "YZ", "offset": 36, "project_edges": false}))["sketch"].as_u64().unwrap();
run(&mut s, "sketch.project", json!({"refs": [{"body": "Plate"}]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "timeline.roll_to", json!({"position": 2}));
let top = [[40., 0.], [1.7574, 1.7574], [78.2426, 1.7574], [80., 25.], [78.2426, 48.2426], [40., 50.], [1.7574, 48.2426], [0., 25.]];
let edges: Vec<_> = top.iter().map(|p| json!([p[0], p[1], 8])).collect();
run(&mut s, "solid.fillet", json!({"edges": edges, "radius": 2}));
run(&mut s, "timeline.roll_to", json!({}));
let state = s.model.state();
let sk = &state.sketch(sketch).unwrap().sketch;
let pos = |i: usize| sk.points[i].pos;
let mut arcs = Vec::new();
for curve in &sk.curves {
match curve.kind {
CurveKind::Line { a, b } => {
let d = pos(b) - pos(a);
assert!(d.x.abs() < 1e-3 || d.y.abs() < 1e-3, "slanted line {:?} -> {:?}", pos(a), pos(b));
}
CurveKind::Arc { c, a, .. } => arcs.push(pos(c).dist(pos(a))),
_ => {}
}
}
assert_eq!(arcs.len(), 2, "{arcs:?}");
assert!(arcs.iter().all(|r| (r - 2.).abs() < 5e-3), "{arcs:?}");
// Every arc end is exactly the end of a straight edge, and meets it tangentially.
for curve in &sk.curves {
let CurveKind::Arc { c, a, b } = curve.kind else { continue };
for end in [a, b] {
let touching = sk
.curves
.iter()
.filter(|o| matches!(o.kind, CurveKind::Line { a: p, b: q } if pos(p).dist(pos(end)) < 1e-6 || pos(q).dist(pos(end)) < 1e-6))
.count();
assert!(touching >= 1, "arc end {:?} meets no line end", pos(end));
let radial = pos(end) - pos(c);
assert!((radial.len() - 2.).abs() < 5e-3);
}
}
}
#[test]
fn whole_body_intersect_of_a_filleted_plate_keeps_exact_lines_and_arcs() {
use solidcraft_sketch::CurveKind;
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 80, "width": 50, "height": 8, "body_name": "Plate"}));
let sketch = run(&mut s, "sketch.create", json!({"plane": "YZ", "offset": 36, "project_edges": false}))["sketch"].as_u64().unwrap();
run(&mut s, "sketch.intersect", json!({"refs": [{"body": "Plate"}]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "timeline.roll_to", json!({"position": 1}));
let top = [[40., 0.], [80., 25.], [40., 50.], [0., 25.]];
let edges: Vec<_> = top.iter().map(|p| json!([p[0], p[1], 8])).collect();
run(&mut s, "solid.fillet", json!({"edges": edges, "radius": 2}));
run(&mut s, "timeline.roll_to", json!({}));
let result = s.model.result(sketch).unwrap().warning.clone();
assert!(result.is_none(), "{result:?}");
let state = s.model.state();
let sk = &state.sketch(sketch).unwrap().sketch;
let pos = |i: usize| sk.points[i].pos;
let mut arcs = Vec::new();
for curve in &sk.curves {
match curve.kind {
CurveKind::Line { a, b } => {
let d = pos(b) - pos(a);
assert!(d.x.abs() < 1e-3 || d.y.abs() < 1e-3, "slanted line {:?} -> {:?}", pos(a), pos(b));
}
CurveKind::Arc { c, a, .. } => arcs.push(pos(c).dist(pos(a))),
_ => {}
}
}
assert_eq!(sk.curves.len(), 6, "{} curves", sk.curves.len());
assert_eq!(arcs.len(), 2, "{arcs:?}");
assert!(arcs.iter().all(|r| (r - 2.).abs() < 5e-3), "{arcs:?}");
}
#[test]
fn a_rebuilt_projection_keeps_user_constraints_without_a_warning() {
for dimensioned in [false, true] {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20, "body_name": "Plate"}));
run(&mut s, "solid.fillet", json!({"edges": [[40, 0, 10]], "radius": 5}));
let sketch = run(&mut s, "sketch.create", json!({"plane": {"face": [20, 15, 20]}, "project_edges": false}))["sketch"].as_u64().unwrap();
run(&mut s, "sketch.project", json!({"refs": [{"body": "Plate"}]}));
if dimensioned {
// A line of the user's, pinned to the projected corner.
let line = ids(&run(&mut s, "sketch.line", json!({"points": [[5, 5], [10, 5]]}))["curves"]);
run(&mut s, "sketch.constraint.coincident", json!({"a": format!("{}.start", line[0]), "b": "p4"}));
}
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "timeline.roll_to", json!({"position": 2}));
run(&mut s, "solid.chamfer", json!({"edges": [[40, 15, 20]], "distance": 1}));
run(&mut s, "timeline.roll_to", json!({}));
let warning = s.model.result(sketch).unwrap().warning.clone();
let st = s.model.state();
let k = &st.sketch(sketch).unwrap().sketch;
// The user's constraint survives the rebuild, so there is nothing to warn about.
assert_eq!(k.constraints.len(), usize::from(dimensioned));
assert!(warning.is_none(), "{warning:?}");
}
}
@@ -87,3 +87,167 @@ fn an_editor_cannot_take_a_pinned_model_from_another_document() {
assert!(session.scratch().compute_editor_commands(&[], &source, Session::default()).is_err());
assert!(Arc::ptr_eq(&document, &session.doc));
}
fn extrusion_editor() -> (Session, Session, Session, Vec<(String, serde_json::Value)>) {
let mut session = Session::default();
session.execute("solid.box", &json!({"corner":[-10,0,0],"length":2,"width":3,"height":4,"body_name":"Other"})).unwrap();
let sketch = session.execute("sketch.create", &json!({"plane":"XY"})).unwrap()["sketch"].as_u64().unwrap();
session.execute("sketch.rectangle.two_point", &json!({"p0":[0,0],"p1":[30,20]})).unwrap();
session.execute("sketch.finish", &json!({})).unwrap();
let feature = session.execute("solid.extrude", &json!({"distance":10,"operation":"new"})).unwrap()["feature"].as_u64().unwrap();
let original = session.scratch();
// The UI's temporary rollback does not create an undo step.
let undo = std::mem::take(&mut session.undo);
session.execute("timeline.roll_to", &json!({"position":2})).unwrap();
session.undo = undo;
let source = session.scratch();
let commands = vec![(
"timeline.redefine".into(),
json!({"feature":feature,"command":"solid.extrude","params":{"sketch":sketch,"profiles":"all","distance":20,"operation":"new"}}),
)];
(session, source, original, commands)
}
fn assert_editor_volume(session: &mut Session, volume: f64) {
assert_eq!(session.doc.marker, None, "visibility history must not restore the editor's temporary rollback");
let actual = session.execute("inspect.measure", &json!({})).unwrap()["total"]["volume_mm3"].as_f64().unwrap();
assert!((actual - volume).abs() < 1e-6, "expected {volume}, got {actual}");
}
#[test]
fn editor_visibility_changes_allow_apply_and_preserve_separate_undo_steps() {
for params in [
json!({"items":["Other"],"visible":false}),
json!({"items":["XY"],"visible":false}),
json!({"items":["Sketch1"],"visible":false}),
json!({"tree_folders":["c0/bodies"],"visible":false}),
] {
let (mut session, source, original, commands) = extrusion_editor();
let shown = session.visibility.clone();
let history = session.undo.len();
session.execute("browser.visibility", &params).unwrap();
let hidden = session.visibility.clone();
assert_ne!(hidden, shown, "{params}");
assert!(Arc::ptr_eq(&source.doc, &session.doc));
let prepared = session.scratch().compute_editor_commands(&commands, &source, original).unwrap();
session.accept_commands(prepared).unwrap();
assert_eq!(session.undo.len(), history + 2, "hide and edit are separate undo steps");
assert_eq!(session.visibility, hidden);
assert_editor_volume(&mut session, 12024.0);
session.execute("edit.undo", &json!({})).unwrap();
assert_eq!(session.visibility, hidden);
assert_editor_volume(&mut session, 6024.0);
session.execute("edit.undo", &json!({})).unwrap();
assert_eq!(session.visibility, shown);
assert_editor_volume(&mut session, 6024.0);
session.execute("edit.redo", &json!({})).unwrap();
assert_eq!(session.visibility, hidden);
assert_editor_volume(&mut session, 6024.0);
session.execute("edit.redo", &json!({})).unwrap();
assert_eq!(session.visibility, hidden);
assert_editor_volume(&mut session, 12024.0);
}
}
#[test]
fn editor_visibility_changes_allow_cancel_and_keep_undo_and_redo_at_the_original_marker() {
for undo_hide in [false, true] {
let (mut session, source, original, _) = extrusion_editor();
let shown = session.visibility.clone();
session.execute("browser.visibility", &json!({"items":["Other","XY"],"visible":false})).unwrap();
let hidden = session.visibility.clone();
if undo_hide {
session.execute("edit.undo", &json!({})).unwrap();
}
let history = (session.undo.len(), session.redo.len());
let live = session.visibility.clone();
*session.model.cache.lock().unwrap() = Default::default();
let evaluations = session.evaluations;
session.restore_editor(&source, original).unwrap();
assert_eq!(session.evaluations, evaluations, "Cancel reuses the pinned model");
assert_eq!(session.visibility, live);
assert_eq!((session.undo.len(), session.redo.len()), history);
assert_editor_volume(&mut session, 6024.0);
if undo_hide {
session.execute("edit.redo", &json!({})).unwrap();
assert_eq!(session.visibility, hidden);
} else {
session.execute("edit.undo", &json!({})).unwrap();
assert_eq!(session.visibility, shown);
}
assert_editor_volume(&mut session, 6024.0);
}
}
#[test]
fn visibility_changes_while_an_editor_computes_preserve_the_latest_view() {
let (mut session, source, original, commands) = extrusion_editor();
let prepared = session.scratch().compute_editor_commands(&commands, &source, original).unwrap();
session.execute("browser.visibility", &json!({"items":["Other","XY"],"visible":false})).unwrap();
let visibility = session.visibility.clone();
assert!(prepared.is_current(&session));
session.accept_commands(prepared).unwrap();
assert_eq!(session.visibility, visibility);
assert_editor_volume(&mut session, 12024.0);
session.execute("edit.undo", &json!({})).unwrap();
assert_eq!(session.visibility, visibility);
assert_editor_volume(&mut session, 6024.0);
session.execute("edit.undo", &json!({})).unwrap();
assert_editor_volume(&mut session, 6024.0);
}
#[test]
fn visibility_does_not_make_a_stale_editor_current() {
for change in 0..7 {
let (mut session, source, original, commands) = extrusion_editor();
match change {
0 => session.revision += 1,
1 => session.active_sketch = Some(2),
2 => session.active_component = 17,
3 => session.active_occurrence_path = Some(vec![1]),
4 => {
session.execute("solid.box", &json!({"length":1,"width":1,"height":1})).unwrap();
}
5 => {
session.pending_moves.insert(1, solidcraft_doc::IDENTITY);
}
_ => session = Session::default(),
}
session.execute("browser.visibility", &json!({"items":["XY"],"visible":false})).unwrap();
let doc = session.doc.clone();
let visibility = session.visibility.clone();
assert!(session.scratch().compute_editor_commands(&commands, &source, original.scratch()).is_err(), "change {change}");
assert!(session.restore_editor(&source, original).is_err(), "change {change}");
assert!(Arc::ptr_eq(&session.doc, &doc));
assert_eq!(session.visibility, visibility);
}
}
#[test]
fn accepting_commands_keeps_a_joint_shown_while_the_worker_ran() {
let mut session = Session::default();
let part = session.execute("component.create", &json!({"name":"Part","activate":false})).unwrap();
let occurrence = part["occurrence"].as_u64().unwrap();
let joint = session.execute("joint.as_built", &json!({"type":"revolute","a":0,"b":occurrence})).unwrap()["joint"].as_u64().unwrap();
session.execute("browser.visibility", &json!({"joints":[joint],"visible":false})).unwrap();
let prepared = session.scratch().compute_commands(&commands(), None).unwrap();
session.execute("browser.visibility", &json!({"joints":[joint],"visible":true})).unwrap();
session.execute("browser.visibility", &json!({"paths":[[occurrence]],"visible":false})).unwrap();
let visibility = session.visibility.clone();
session.accept_commands(prepared).unwrap();
assert_eq!(session.visibility, visibility, "the worker's old joint hide must not be merged back");
session.execute("edit.undo", &json!({})).unwrap();
assert_eq!(session.visibility, visibility, "undoing geometry keeps both concurrent visibility changes");
}
#[test]
fn cancelling_an_editor_with_visibility_changes_restores_an_existing_history_marker() {
let (mut session, source, mut original, _) = extrusion_editor();
original.execute("timeline.roll_to", &json!({"position":1})).unwrap();
session.execute("browser.visibility", &json!({"items":["XY"],"visible":false})).unwrap();
session.restore_editor(&source, original).unwrap();
assert_eq!(session.doc.marker, Some(1));
session.execute("edit.undo", &json!({})).unwrap();
assert_eq!(session.doc.marker, Some(1));
assert!(session.visibility.hidden_origin.is_empty());
}
+80 -14
View File
@@ -91,15 +91,28 @@ fn parse_port(text: &str) -> Option<u16> {
text.trim().parse::<u16>().ok().filter(|p| *p != 0)
}
/// Remove the discovery file at exit, unless a later app has taken it over (it names another port).
pub fn remove_current_port(port: u16) {
if read_current_port() == Some(port)
&& let Some(path) = current_port_path()
{
let _ = std::fs::remove_file(path);
/// Remove the discovery file (and its process file) at exit, unless a later app has taken it
/// over: it names another port, or another launch now holds this port's token file. Under
/// Flatpak every instance has its own loopback, so two windows can listen on the same port
/// number and write the same file names; the one closing first must leave the other's (#8794).
/// Run it after [`remove_token_file`], which takes this launch's own token file away first.
pub fn remove_current_port(port: u16, token: &str) {
if let (Some(current), Some(file)) = (current_port_path(), token_path(port)) {
remove_discovery(&current, &file, port, token);
}
}
/// [`remove_current_port`] on given files: `current` is the discovery file, `file` the token file
/// of `port`.
fn remove_discovery(current: &Path, file: &Path, port: u16, token: &str) {
let named = std::fs::read_to_string(current).ok().filter(|t| t.len() <= 64).and_then(|t| parse_port(&t));
if named != Some(port) || held_by_another(file, token) {
return;
}
let _ = std::fs::remove_file(current);
let _ = std::fs::remove_file(pid_path(current));
}
/// A token set on purpose with `SOLIDCRAFT_CONTROL_TOKEN` (test harnesses, sandboxed packages
/// whose data folder the client can't see), if it is long enough.
pub fn env_token() -> Option<String> {
@@ -289,10 +302,28 @@ pub fn write_token_file(path: &Path, token: &str) -> Result<()> {
Ok(())
}
/// Remove a token file this launch wrote, and its process file.
pub fn remove_token_file(path: &Path) {
let _ = std::fs::remove_file(path);
let _ = std::fs::remove_file(pid_path(path));
/// Remove a token file this launch wrote, and its process file, while it still holds `token`.
/// Another launch may have written the same file since (a second window on the same port,
/// #8794); its file stays. The process id can't tell the two apart, as every Flatpak instance
/// has the same one, but each launch's token is its own.
pub fn remove_token_file(path: &Path, token: &str) {
if read_token(path).is_some_and(|held| tokens_match(&held, token.trim())) {
let _ = std::fs::remove_file(path);
let _ = std::fs::remove_file(pid_path(path));
}
}
/// The token a token file holds, trimmed; `None` when there is no readable token file.
fn read_token(path: &Path) -> Option<String> {
let mut text = String::new();
std::fs::File::open(path).ok()?.take(MAX_TOKEN_FILE + 1).read_to_string(&mut text).ok()?;
let token = text.trim();
(text.len() as u64 <= MAX_TOKEN_FILE && !token.is_empty()).then(|| token.to_string())
}
/// Whether the token file at `path` holds a token other than `token`: another launch's.
fn held_by_another(path: &Path, token: &str) -> bool {
read_token(path).is_some_and(|held| !tokens_match(&held, token.trim()))
}
/// How long `icacls` may take (#6145). It finishes at once; on some computers the new process
@@ -341,12 +372,12 @@ fn wait_within(mut child: std::process::Child, limit: std::time::Duration) -> Re
}
}
/// Removes the token file when the app ends.
pub struct TokenFile(pub PathBuf);
/// Removes the token file (path, token) when the app ends, while it still holds that token.
pub struct TokenFile(pub PathBuf, pub String);
impl Drop for TokenFile {
fn drop(&mut self) {
remove_token_file(&self.0);
remove_token_file(&self.0, &self.1);
}
}
@@ -490,8 +521,43 @@ mod tests {
assert_eq!(std::fs::read_to_string(&path).unwrap().trim(), "0123456789abcdef0123456789abcdef");
let writer = read_token_file(&path).unwrap().file.unwrap().writer;
assert_eq!(writer.map(|(pid, _)| pid), Some(std::process::id()));
drop(TokenFile(path.clone()));
drop(TokenFile(path.clone(), "0123456789abcdef0123456789abcdef".into()));
assert!(!path.exists() && !pid_path(&path).exists());
let _ = std::fs::remove_dir_all(&dir);
}
/// #8794: under Flatpak two windows can listen on the same port number (each has its own
/// loopback) and write the same files. The one closing first leaves the other's token, process
/// and discovery files; the last one takes them all away, `current.pid` too.
#[test]
fn a_closing_launch_leaves_another_launchs_files() {
let dir = std::env::temp_dir().join(format!("solidcraft-control-two-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let (file, current) = (dir.join("27878.token"), dir.join("current.port"));
let (first, second) = ("a".repeat(64), "b".repeat(64));
// The first window, then the second on the same port, each writing both files.
for token in [&first, &second] {
write_token_file(&file, token).unwrap();
write_token_file(&current, "27878").unwrap();
}
// The first closes: the second's files stay.
remove_token_file(&file, &first);
remove_discovery(&current, &file, 27878, &first);
assert_eq!(read_token(&file).as_deref(), Some(second.as_str()));
assert!(pid_path(&file).exists() && current.exists() && pid_path(&current).exists());
// Another port named in the discovery file is another app's too.
remove_discovery(&current, &file, 27879, &second);
assert!(current.exists());
// The second closes: nothing of either is left.
remove_token_file(&file, &format!(" {second}\n"));
remove_discovery(&current, &file, 27878, &second);
assert!(!file.exists() && !pid_path(&file).exists() && !current.exists() && !pid_path(&current).exists());
// With no token file left (removed by hand, or never written), the discovery file of the
// port still goes; a missing file is no error.
write_token_file(&current, "27878").unwrap();
remove_token_file(&file, &second);
remove_discovery(&current, &file, 27878, &second);
assert!(!current.exists());
let _ = std::fs::remove_dir_all(&dir);
}
}
+1954 -1920
View File
File diff suppressed because it is too large Load Diff

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