Compare commits

..

47 Commits

Author SHA1 Message Date
bherbruck 9dda74755e Graphics trial: close at once when it gives up, leave a planned restart without tearing down the GPU, and start again when a trial draws no window
The question's 15 s timeout set the quit in the UI pass but asked for no further
frame, so an idle window stayed open on the settings that didn't work until the
pointer moved. Request the frame.

A restart for graphics settings now ends the process once the next start has begun,
as after a lost device, instead of tearing down the swap chain, device and
pipelines of the API being left.

A start on trial that draws no frame within 60 s starts again with the saved settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 10:55:19 -05:00
Karl Von Richter 605e0a935c Preferences: choose the graphics API (Direct3D 12, Vulkan or OpenGL), tried at the next start and kept only when you confirm (#4015)
Preferences > Graphics gets a Graphics API choice: Automatic, Direct3D 12,
Vulkan and OpenGL on Windows, Vulkan and OpenGL on Linux (hidden on macOS).
A change there, and a change to anti-aliasing, is written to graphics.json
as a trial. The next start tries it and asks "Keep these graphics
settings?"; Go back, or no answer within 15 s, starts again with the saved
settings, and a start that finds the trial already started drops it with a
note. ui.prefs saves graphics_api and msaa at once, without a trial.

On computers whose Direct3D 12 GPUs are all AMD integrated, Preferences
warns before Direct3D 12, and a crash on it goes back to Automatic.

Builds on #3322's saved backend, hang check and restart.

Closes #4015.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 20:05:29 +10:00
Karl Von Richter 5a09a9c49e Windows: start on Vulkan on AMD integrated graphics, and restart on another graphics API when the GPU hangs (#3321)
On Windows PCs whose only graphics are AMD integrated GPUs, Automatic starts Vulkan instead of
Direct3D 12, whose driver hung there a few seconds after start.

A GPU that hangs after the window is up (frames that each wait out their acquire, or a
main-window surface that keeps refusing its configuration, while the user is there) closes the
app, keeping unsaved changes for recovery, and starts it again on the next API, kept in
graphics.json: D3D12 -> Vulkan -> OpenGL -> the software renderer. Only stalls after the user's
input count, so waking a display doesn't restart a healthy app. The restart doesn't inherit
WGPU_BACKEND or a start-up retry's backend (#6682), which would win over the saved one, and later
launches don't inherit its notice or the device-lost one (#4229). graphics.json is written to a
new file, then renamed over the old one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 20:05:13 +10:00
github-actions[bot] 6bca7d2854 Regenerate generated files and fold pending files [skip ci] 2026-11-04 08:13:26 +00:00
Frank eeb686f164 Measure: a circle and a line now show 'Line to centre' instead of 'Center to center' (#11524)
* Measure: a circle and a line show 'Line to centre' instead of 'Centre to centre'

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

* Measure: US spelling for Line to center, UK in en-GB

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 07:38:21 +00:00
Nathan Dane 9e3e403b3f Browser and History: keep row tools working when either panel is hidden (#11303) (#11416)
* claim: #11303

* Keep Browser and History row interactions independent

* Browser tests: use solidcore_record::Vec3 after the layer split

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

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 07:15:40 +00:00
dudedagger42 0231eec4c6 Nix flake: zenity on the app's PATH, for file dialogs without a portal (#2177) (#5183)
rfd opens file dialogs through the XDG desktop portal and falls back to zenity
when there is none. The flake's package didn't bring zenity, so on a system
without a portal or zenity, Open and Save showed nothing. The wrapper now adds
nixpkgs' zenity at the end of PATH, after the system's own.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 06:53:37 +00:00
erman 69f202f1c8 Measure: show complete point coordinates (#11069) (#11085)
* Measure: show complete point coordinates (#11069)

Render measurement item details as wrapping text instead of fixed-height cells. Keep the existing values, units and precision. Add a regression test in the existing measurement UI test module.

Local validation: the regression test compiled, but the Windows GNU test binary crashes with 0xc0000005 even for --list, before tests run. Runtime verification and visual evidence remain pending; this contribution is a draft.

* Keep repository LF line endings in the coordinate fix
2026-11-04 06:53:04 +00:00
austin62jake 1cbfb889d5 Agents and scripts: Compare Bodies describes its exact parameters and reply (#10994) (#10995)
* claim: #10994

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

* Compare Bodies: input and output schemas (#10994)

inspect.compare gets a typed contract and reply types in
schema/compare.rs and leaves unconverted.txt. Each key reads as the
handler reads it, with its message; `color` checks each colour with the
handler's own color_param, now pub(crate) and re-exported from cmd. The
reply is built from Comparison and its parts, with the same JSON.

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

* Compare: drop the json import the typed reply no longer uses

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

* Compare schema: colour strings carry the hex pattern

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-11-04 06:45:10 +00:00
thocsmith ee3cc32525 Settings profiles: export and import stay inside an agent's folders (#11362) (#11390)
* claim: #11362

* Settings profiles: export and import go through vfs, inside an agent's folders (#11362)

settings.profile.export and settings.profile.import read and wrote their
path with std::fs, so the folder scope an MCP request's allow_paths sets
up never saw it: an agent could write a profile anywhere, or probe any
path. Both now go through vfs (len, read_to_string, write), as file.export
and ui.screenshot do. requests_with_allow_paths_stay_inside_them covers
export and import inside and outside the agent's folders.

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

---------

Co-authored-by: thocsmith <193096300+thocsmith@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 06:28:11 +00:00
Colin Faulkingham 954232cef1 AI client setup: preserve configuration files when a write fails (#11363) (#11410)
* claim: #11363

* AI client setup: replace configuration files atomically (#11363)
2026-11-04 06:27:37 +00:00
github-actions[bot] 9092df9b97 Regenerate generated files and fold pending files [skip ci] 2026-11-04 07:25:51 +00:00
semmelrinde c1a23f8f97 Section Analysis: the drag arrow is back when the dialog is reopened (#11423) (#11432)
Edit reopens the dialog from the section's own geometry (Show viewport
section status, on by default): the plane is kept as numbers and nothing
is picked. The arrow was placed from the first pick only, so it was gone.
With nothing picked, it now stands on the kept plane, its tip at the cut.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 06:11:46 +00:00
austin62jake 0fc4cd2747 Agents and scripts: the render stage commands (Ground, Backdrop, Clear) describe their exact parameters and reply (#11455) (#11456)
* claim: #11455

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

* Render stage: input and output schemas for Ground, Backdrop and Clear (#11455)

The three handlers' strict parameter types move to schema/render_stage.rs as
GroundParams, BackdropParams and ClearParams, with descriptions and
JsonSchema, and become the contracts; the handlers read the same types. The
handlers build the shared StageSaved reply instead of json! literals, and the
saved setup is serialized with an error instead of json!'s panic path.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-04 06:11:09 +00:00
leValdemar19 6d83f95fba Isolate a body: other components are hidden as components, in one step (#7896) (#7951)
* claim: #7896

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

* Isolate a body: other components are hidden as components, in one step (#7896)

Isolating a body hid every other body one by one: browser.visibility with `items` writes only
hidden_bodies, so every other component's Browser row kept an open eye and normal text, and
its eye could not bring it back (the first click hid an already invisible component).

browser.visibility takes `isolate: true` with `items` (bodies), as it does with `paths`. The
picked bodies' own placements stay open, their other bodies (and the root's) are hidden one by
one, and every other placement is hidden as a placement. Isolate in the Browser and the view
runs that one command, so it is also one undo step instead of two.

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

* Isolate a body: a hidden Bodies folder or group it is in shows again (#7896)

Isolating a body inside a hidden Bodies folder or group left that folder or
group hidden, so the picked body stayed hidden. Isolate now shows the
folders and groups (with the groups they are in) that hold the picked
bodies; their other bodies are hidden one by one as before.

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

* Isolate a body: look for hidden folders only when some are hidden, and find body owners once (#7896)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 06:06:23 +00:00
austin62jake bf625dff05 Box, Sphere and Torus: clicking a point of a sketch places them there (#9403) (#9405)
* Box, Sphere and Torus: a sketch point picks where they go (#9403)

The Point input of the Box, Sphere and Torus windows took a vertex, a point on an edge or face
and the origin, but not a sketch's points, so a sphere could not be centred on a point drawn in a
sketch. It now takes the points of every shown sketch (POINTS | EXTERNAL_POINTS, as Measure
does): a click fills X, Y and Z with where the sketch puts the point in the world, and a point
selected before the window opens places the primitive there, as a vertex does. The input's chip
names the point and its sketch as Select Other does ("Point 2 · Marks").

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

* Box, Sphere and Torus: the sketch point chip label for every complete locale

Main's i18n check wants each message in the complete catalogs (ar, he, ja,
vi, zh); the label has no words, so each carries the same "{point} · {sketch}".
With main's new selection kinds and this one, every Sel variant has its arm,
so the catch-all arm is unreachable (clippy) and goes.

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

* Primitive at a sketch point: a point picked in the sketch being finished still places it

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-11-04 06:03:39 +00:00
semmelrinde ed3510a91f Typed schemas for the design properties commands (part of #2829) (#10920)
document.metadata, document.metadata.set and document.metadata.remove
get input and output schemas: contracts in schema/metadata.rs, checked
in front of the unchanged handlers, and reply types the handlers now
build their replies from. Their lines leave unconverted.txt.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 06:03:21 +00:00
github-actions[bot] 8f0130a61b Regenerate generated files and fold pending files [skip ci] 2026-11-04 06:39:31 +00:00
github-actions[bot] 72df682af7 Regenerate generated files and fold pending files [skip ci] 2026-11-04 06:27:43 +00:00
Davit Matchakhelidze 41a450be7c i18n: add Georgian (ka) interface language (#11501)
A Georgian catalog (assets/locales/ka.json) carrying every English message,
registered in crates/ui-egui/src/i18n.rs, with a row in the new-language test,
a pending attribution file and a terminology note in docs/localization.md.
The catalog is Mkhedruli only; egui's built-in Hack already draws it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 06:00:45 +00:00
HiroKenEz b227924be9 Render: the Cancel button keeps its full label during a render (#11222) (#11241)
* claim: #11222

* Render: the progress row keeps Cancel whole (#11222)

The progress card is an egui area, laid out within last frame's size. When
the status grew (9 % to 10 %, a longer phase or name), the caption and the
Cancel button were truncated, down to "..." with a long shot name.

progress_action now takes the name and the status apart and gives each part
an explicit width from the room the caller offers (the canvas for the
floating card): Cancel and the status always show whole, a long name is cut
with an ellipsis (full name on hover), then the meter narrows.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 06:00:31 +00:00
Mateusz Sych 28b47059bb Agent chat: a text selection dragged past the edge scrolls the conversation (#11527) (#11533)
* claim: #11527

* Agent chat: a text selection dragged past the edge scrolls the conversation (#11527)

Holding a text selection near or past the top or bottom of the conversation
scrolls it, faster the further out, so a long answer can be selected in one
drag. egui scrolls a label's selection only for the label under the cursor, and
not at all for a table, whose label sits in its own horizontal scroll area.

egui also leaves a label out of the text selection while it is scrolled out of
view: a selection whose start had scrolled away was dropped, and Copy missed
the hidden text. The conversation's labels now take part out of view too,
clipped to nothing, so they keep their end of the selection and give their
text to Copy without being drawn or moving the selection.

* Agent chat: only a drag towards the edge scrolls the conversation (#11527)

A press on a label creates an empty selection on the press frame, so a click,
a double click or a held press on the line by the edge scrolled the
conversation, and so did the start of a drag from there inwards. Scrolling now
waits until the pointer has moved more than 8 points towards that edge since
the press.

* Agent chat: approval titles and pending messages keep a selection scrolled away

They were plain selectable labels, so a selection that started in one was dropped
once its label scrolled out of view, as in the rest of #11527. They now use the
same off-screen-aware label as the conversation's text.

---------

Co-authored-by: MateuszSychXpl <151755914+MateuszSychXpl@users.noreply.github.com>
2026-11-04 06:00:28 +00:00
Mateusz Sych a19a32ddd7 Interference: Compute checks in the background, so the window no longer freezes (#11483) (#11541)
* claim: #11483

* Interference: intersect and mesh each overlap on a kernel worker (#11483)

Compute in Inspect > Interference intersected each overlapping pair and
meshed the shared volume on the window thread. Its stack is 1 MiB on
Windows, too small for the kernel's recursion, so the app could close
without a crash message. Each pair now runs through
kernel::map_with_local_pools, on a worker with the kernel's 8 MiB stack,
as inspect.interference and inspect.clearance already do.

* Interference: Compute checks off the window thread (#11483)

On 0.30.0 the report's two parts took the window thread 95 s (x64) to
154 s (x86) in the overlap boolean, and the window stopped responding
until the app was closed. Compute now runs inspect.clearance and the
shared volumes on a copy of the session, on a kernel thread, when the
app runs commands off the window thread (as Find Limits does). The
dialog shows the elapsed seconds with Cancel meanwhile, and drops the
reply of a design changed since.

* Interference: Cancel on its own row while Compute checks (#11483)

---------

Co-authored-by: MateuszSychXpl <151755914+MateuszSychXpl@users.noreply.github.com>
2026-11-04 06:00:15 +00:00
Homero Thompson 606e264f6d UI: the Dependents button of the INSPECT HISTORY ribbon no longer fails without a feature (#11642) 2026-11-04 05:59:52 +00:00
semmelrinde d25f8198fa Pieces of a cut keep their names when the design is evaluated again (#11658)
A cut that leaves a body in pieces names them after their faces
(`order_lumps`): the piece whose least face name sorts first takes the
first name, and so on. It moved the names but left the pieces where the
kernel put them. The session then pinned the feature's body names in
that state order, and the next full evaluation (opening the design, or
editing a parameter upstream) handed them out again in a different
order: two pieces swapped names, and a later feature that named one met
the other. Found by a local model fuzzer.

The pieces now also take the family's places in name order, so the
names pinned are the order the next evaluation hands them out in. A
design saved before keeps the names a fresh evaluation gave it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 05:59:42 +00:00
Hainlein4711 464f4ac748 Intel OpenGL: restore the grid and sketch axes (#8053) (#11573)
* claim: #8053

* Intel OpenGL: restore the grid and sketch axes
2026-11-04 05:49:15 +00:00
semmelrinde 03b5248dc1 Sketch: Line and Construction Line refuse a second point next to the first (#11595)
A through-point 0.0000001 mm from the start made an infinite line whose
direction nothing held, and a Line that short had both ends connected to
the origin, a line of length zero. Both now refuse points closer than the
distance at which a new point is connected to an existing one.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 05:49:05 +00:00
Colin Faulkingham f9dd77e383 Configurations: reject invalid suppression values when saving rows (#9088) (#11571)
* claim: #9088

* Configurations: reject invalid suppression values before saving rows (#9088)
2026-11-04 05:39:04 +00:00
semmelrinde 4afd24d29f Design tabs: the tooltip says when a saved design has unsaved changes (#11408) (#11590)
* Design tabs: the tooltip says when a saved design has unsaved changes (#11408)

A tab marks unsaved changes with a bullet after the design's name, and a
long name cut short by the tab hides it. The tab's tooltip, which says
where the design is saved, now adds "Unsaved changes" below for a saved
design with changes not saved yet. A design never saved already says
"Not saved yet". The new string is in a pending locale file, with ten
translations taken from the same words elsewhere in the catalogs.

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

* Design tabs: the unsaved line in every complete catalog (#11408)

Arabic, Hebrew and Vietnamese are complete catalogs, which must carry
every English message: they get the words they already use for
"Unsaved changes" elsewhere.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 05:29:03 +00:00
kaltenbrunnermarkus 8a74336713 Tests: the autosave recovery test no longer fails at random on a busy machine (#11815)
* claim: Recovery: the save test waits for the autosave worker before it checks

* Recovery: the save test waits for the autosave worker before it checks

saving_drops_the_entry_and_a_clean_close_leaves_nothing marked the
session saved right after the tick that started an autosave. Under load
the worker was still writing, so the next tick found the write running,
took nothing up and dropped nothing; the file landed afterwards and the
check that it is gone failed (seen in CI on #9021).

The test now ticks, bounded, until the autosave has caught up
(Autosaver::caught_up), as the app's own frames would, before it marks
the session saved. A small helper in the test module does this. The
app's behaviour is unchanged.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 05:22:20 +00:00
Colin Faulkingham 5e4c52a7cb Sketch Transform: keep the applied rectangle at the previewed rotation and size (#11617) (#11620)
* claim: #11617

* Sketch Transform: preserve dimensioned geometry on apply
2026-11-04 05:21:27 +00:00
github-actions[bot] 77e0e11775 Regenerate generated files and fold pending files [skip ci] 2026-11-04 05:42:40 +00:00
Lars Jansen 7bd66b7c06 Sketch dimensions: enter the value in a small window with step arrows (#7246) (#7251)
* claim: #7246

* feat(sketch): enter a dimension's value in a small window with step arrows (#7246)

* Dimension window: fit the layer split, and keep the wheel off the camera over the window

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

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 05:12:31 +00:00
Daniel 86db05e538 Sketch: regression test for a Line started on a snap point (#11171) (#11322)
* claim: #11171

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

* Sketch: test that a line starts on a snap point of an existing shape (#11171)

The Line tool's first click on a line's midpoint, a circle's centre or edge,
a rectangle's edge or a point part-way along a line starts the line there and
ties its start to that shape. In 0.30.0 and 0.30.1 the start was dropped on the
next hover (fixed by #11184); this covers each shape the reports name.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 05:11:14 +00:00
ABDULLAH d912f3533b Arabic: better wording for Appearance, Graphics, Assistant, Layout, Sketch and Navigation preferences, thread drill notes and other messages (#11358)
* Arabic: viewport term in the Appearance section heading, desktop app badge, deeper-overlap note in Find Limits

* Arabic: drop the stray direction mark and match the Refresh wording in the old Node.js note

* Arabic: tanween in the thread fit hint, tap drill wording, crease term in continuity, spline label and setback wording in the fillet help

* Arabic: sweep and boolean terms in the body check note

* Arabic: badge term for the status chips note and summary wording for Fold items in Graphics preferences

* Arabic: tanween, catalog terms and clearer labels in the Assistant, Design and Tools preferences

* Arabic: tanween in the shortcut key prompt, Look At wording in the Assistant follow help

* Arabic: complete the Several choice for bodies folders and use a verb form for the dialogs Open row

* Arabic: orbit style term, Select Other and center-point arc names in Sketch preferences, place-from-origin wording, tanween in the radial menu help, tap makers in the tap drill notes

* Arabic: graphics device term in the blur notes, boss and turn wording in the fillet corner option
2026-11-04 05:10:29 +00:00
kaltenbrunnermarkus 9d7bbb7fa9 Tests: two Properties tests no longer fail at random on a busy machine (#11750)
* claim: Properties: hold the measuring worker in tests until the card is checked

* Properties: hold the measuring worker in tests until the card is checked

open_object starts the measurement on a worker and looks for the reply
once without waiting. Under load the window side could be held up
between the two, the reply was already in, and two Properties tests saw
the card filled where it should still be measuring (about 0.3 % and 4 %
of runs with 18 tests at a time).

measure_beside now runs a hold before sending the reply. In tests it
waits on the app's existing apply_gate, which feature evaluation already
uses for the same purpose; outside tests it does nothing. Both tests set
the gate, check the card is measuring, then let the worker go.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 05:08:36 +00:00
leValdemar19 6312c2760a MCP list_commands: ask for commands by id prefix or panel, ids and labels only, or a page at a time (#11726) (#11788)
* claim: #11726

* MCP list_commands: ask for commands by id prefix or panel, ids and labels only, or a page at a time (#11726)

list_commands answered with the whole catalog, every key of 575 commands
(about 350 KB), and could only narrow it by a substring. It now also takes
`prefix` (start of the id), `panel` (a whole panel or tab name), `compact`
(id and label), `fields` (the keys to keep) and `limit`/`offset` (pages, as
{commands, total, offset, next_offset}). With none of them the reply is the
same as before. A batch step naming list_commands narrows the same way, and
a bad argument is an error that names it.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 05:07:29 +00:00
SilentWolf75 3a23d21795 Lip: clearer guidance when the face is not a shelled rim (#10437) (#10871)
* claim: #10437

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

* Lip: say Shell first when the face is not a rim (#10437)

A solid box top left OK disabled with a vague shelled-body hint. Name Shell and the rim-around-an-opening requirement in the refusal, the dialog tip, and the command help.

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

* Lip: keep the dialog hint as it was, match the new refusal text in the test

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 04:42:08 +00:00
austin62jake e199f9aeff Agents and scripts: Match Surface describes its exact parameters and reply (#10981) (#10982)
* claim: #10981

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

* Match Surface: input and output schemas (#10981)

surface.match gets a typed contract in schema/surface_match.rs and the
shared FeatureResult as its output, and leaves unconverted.txt. Each key
reads as the handler reads it, with its message; like solid.scale it
declares `name` and `participants` itself, since a match makes no body.

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

* Follow main's kernel merge: world_state and the shared continuity reader

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

* Surface match schema: Vec3 comes from solidcore_record now

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-11-04 04:14:11 +00:00
austin62jake e06d6e36d8 Agents and scripts: the material card checks describe their exact parameters and reply (#11036) (#11037)
* claim: #11036

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

* Physical material property commands: input and output schemas (#11036)

material.properties.validate and material.properties.directional get
typed contracts and reply types in schema/material_properties.rs and
leave unconverted.txt. The card stays the handler's to check; the
direction has the handler's messages. The replies mirror the document's
Validation and DirectionalModulus, destructured in full.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-04 04:08:15 +00:00
Kai Evans 9d1ad9f0b9 Feature editor: OK accepts the joint placements a roll back re-solves (#11094) (#11095)
* Feature editor: a roll back's re-solved placements are not a changed design

Rolling the marker solves the joints again and stores where that puts each occurrence, so the
editor's pinned original differed from its source there and OK was refused with "the original
editor model does not match its source design". The check now takes the placements as the roll
leaves them.

* Feature editor: also accept the ratio and lift of coupled joints that rolling the marker works out

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

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 03:56:46 +00:00
austin62jake d39a2d28db Agents and scripts: the clearance check describes its exact parameters and reply (#11051) (#11052)
* claim: #11051

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

* Clearance: input and output schemas (#11051)

inspect.clearance gets a typed contract and reply types in
schema/clearance.rs and leaves unconverted.txt. Each key reads as the
handler reads it, with its message; MAX_CLEARANCE and MAX_NEAREST move
to the schema module so both read one limit. The reply is built from
Clearance and its item types, with the same JSON as before.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-04 03:44:59 +00:00
github-actions[bot] 1e649653ef Regenerate generated files and fold pending files [skip ci] 2026-11-04 03:44:17 +00:00
bherbruck 6988eb0043 CodeRabbit: flag changes to existing defaults (#11832)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 03:15:51 +00:00
Tyler Chiriboga 23c2244307 Inspect: center of mass reports the same volume as measure (#6173) (#11262)
* claim: #6173

* Inspect: center of mass reports the same volume as measure (#6173)

inspect.center_of_mass measured each body's display mesh, whose flat
facets cut curved faces short, so its volume and centre differed from
inspect.measure's (a sphere of radius 10: 4183.10 vs 4188.49 mm3). It now
takes both from the same cached kernel measurement Measure uses, and like
Measure it leaves out bodies that enclose no volume, counting them in
open_bodies_skipped.

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

* Centre of mass tests: read the skipped list that main now returns

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

---------

Co-authored-by: Tyler Chiriboga <18270301+Tyler-Chiriboga@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-11-04 03:13:56 +00:00
semmelrinde a52ee850aa 15 languages: translate the 21 messages main gained after batch 4 (batch 5) (#8573)
* 15 languages: translate the 21 messages main gained after batch 4 (batch 5)

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

* Pending locales: drop the strings the catalogs already have

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-11-04 03:08:32 +00:00
bherbruck 65d23a78a5 Preferences: the 3D mouse speed readout shows × instead of garbled text (#11785)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 02:46:30 +00:00
136 changed files with 13762 additions and 773 deletions
+20
View File
@@ -0,0 +1,20 @@
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
reviews:
auto_review:
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
ignore_usernames:
- "dependabot[bot]"
path_instructions:
- path: "**"
instructions: |
Project rule: don't change anyone's existing defaults unless the change is purely additive and belongs in the application.
Flag any change to something a user already relies on: a default setting or preference value, what a command or tool does when used the usual way, the UI's default layout or behaviour, a keyboard or mouse shortcut, or the result an existing saved design rebuilds to.
Don't flag purely additive changes: a new command, a new setting whose default keeps today's behaviour, or a new option that appears only when someone chooses it. Don't flag bug fixes that only turn a crash or an error into the result the feature always promised.
When you flag one, start the comment with "Changes a default:", say what a user sees before and after, whether a setting keeps the old behaviour, and whether the PR description's `Changes a default:` line says so.
pre_merge_checks:
custom_checks:
- name: "Existing defaults unchanged"
mode: "warning"
instructions: |
Pass if the pull request changes no default a user already relies on (default setting or preference values, what a command or tool does when used the usual way, the UI's default layout or behaviour, shortcuts, or the result an existing saved design rebuilds to), or if its changes are purely additive.
Fail otherwise, naming each changed default and what a user sees before and after. Also fail if the description says `Changes a default: no` while the code changes one.
+1
View File
@@ -786,6 +786,7 @@ Original SolidCraft drawings, Open for owner review; no runtime assignments.
| tests/kernel-cases/import/4561-convert-box-mesh-with-a-zero-area-triangle/box-degenerate.stl | SolidCraft contributors | synthetic ASCII STL written by hand: a box with one zero-area triangle, for the kernel regression case of tangency-labs/solidcraft#4561 | MIT OR Apache-2.0 |
| assets/locales/pending/theme-blur-ui.json | SolidCraft contributors | Original blur controls, notices and translations | MIT OR Apache-2.0 |
| crates/ui-egui/tests/fixtures/theme-blur.json | SolidCraft contributors | Original theme for blur option scenarios and manager review | MIT OR Apache-2.0 |
| assets/locales/ka.json | David Machakhelidze (devius) | Original Georgian translation of SolidCraft's English catalog, written with Claude Code from the meaning of each English string; terminology and doubtful strings decided by a native Georgian speaker; no commercial CAD text used | MIT OR Apache-2.0 |
## Code adapted from sibling projects
+2 -1
View File
@@ -76,7 +76,8 @@ fn command(
reason: &str,
) -> std::process::Command {
let mut command = std::process::Command::new(exe);
command.args(args).env(LOST_ENV, lost_at.to_string());
// The notice of a hang that restarted this launch isn't the next one's (`App::watch_for_a_hang`).
command.args(args).env(LOST_ENV, lost_at.to_string()).env_remove(startup::HUNG_ENV);
match self::reason(Some(reason)) {
Some(reason) => command.env(REASON_ENV, reason),
// Not one inherited from an earlier loss.
+254 -42
View File
@@ -93,6 +93,12 @@ struct App {
come_forward: come_forward::ComeForward,
/// The graphics device: lost, it can't draw again (`App::watch_device`, #4172).
gpu: Option<LostWatch>,
/// The window's backend, and whether it is the software renderer.
renderer: Option<(eframe::wgpu::Backend, bool)>,
/// A GPU that hangs after the window is up (`App::watch_for_a_hang`).
hang: startup::HangWatch,
/// The GPU hung: how the next launch starts, once this one has closed.
restart: Option<startup::Restart>,
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
space_mouse: space_mouse::SpaceMouse,
#[cfg(target_os = "macos")]
@@ -213,12 +219,14 @@ impl eframe::App for App {
solidcraft_ui_egui::ai_connect::begin(&mut self.solve);
solidcraft_ui_egui::agent_chat::begin(&mut self.solve);
}
startup::first_frame_drawn();
if let Some(dir) = self.starting.take() {
startup::started(&dir);
}
}
}
self.watch_device();
self.watch_for_a_hang(ctx);
// The design on screen is the one whose commands a crash report replays.
self.solve.session.crash_journal = solidcraft_engine::crash::enabled();
// The OS closing the window (Alt+F4, Cmd+Q, the window manager): ask about unsaved
@@ -319,11 +327,22 @@ impl Drop for App {
// The window's GPU surface is still to be dropped, and on a lost device that may
// never return (#9044).
device_lost::end();
} else if let Some((restart, (backend, _))) = self.restart.zip(self.renderer) {
restart_after_hang(restart, backend);
} else if self.solve.graphics.restart {
// Preferences: to try graphics settings, or to go back from them (#4015). The next
// start has begun: this one leaves at once. Tearing down a window's swap chain, device
// and pipelines can hang or crash in a graphics driver (#9044), on the very API that
// the user is leaving, and a crash here would be taken for one of the new start.
relaunch(None);
control_server::remove_token_file();
device_lost::end();
}
// An update asked to restart into the new version or to run its installer.
if let Some((program, args)) = solidcraft_ui_egui::update::take_launch(&mut self.solve) {
let mut command = std::process::Command::new(&program);
command.args(&args);
// This launch's notices aren't the new version's.
command.args(&args).env_remove(startup::HUNG_ENV);
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
@@ -364,6 +383,109 @@ impl App {
gpu.reason = reason;
self.solve.quit_requested = true;
}
/// The GPU hung (frames wait out their acquire, or the surface refuses its configuration, while
/// the user is there, #3321): close, keeping unsaved changes for recovery as a forced quit
/// does (the window, white, can't ask about them), and start again on another renderer once
/// closed ([`restart_after_hang`]). Windows only: elsewhere a hidden window's frames can wait
/// as long (Wayland, macOS).
fn watch_for_a_hang(&mut self, ctx: &egui::Context) {
if !cfg!(target_os = "windows") {
return;
}
let (now, input) = ctx.input(|i| {
let input = i.events.iter().any(|e| {
matches!(
e,
egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. } | egui::Event::Key { .. } | egui::Event::MouseWheel { .. }
)
});
(i.time, input)
});
let stalled = eframe::egui_wgpu::stalled_frames();
let refused = eframe::egui_wgpu::refused_for().map(|d| d.as_secs_f64());
if !self.hang.hung(now, input, stalled, refused) {
return;
}
let why = match refused {
Some(s) if s > 0.0 => format!("the surface refused its configuration for {s:.0} s"),
_ => format!("{stalled} frames in a row waited out their acquire"),
};
let trial = self.solve.graphics.trial.is_some();
let Some(restart) = self.renderer.and_then(|(backend, cpu)| startup::after_hang(backend, cpu, trial)) else {
log::error!("graphics: {why}, on the software renderer");
return;
};
log::error!("graphics: {why}; the GPU hung, restarting: {restart:?}");
eprintln!("SolidCraft graphics: the GPU stopped responding; restarting ({restart:?})");
if let startup::Restart::Backend(backend) = restart {
remember_backend(backend);
}
self.restart = Some(restart);
self.solve.quit_requested = true;
}
}
/// Start SolidCraft again after the GPU hung on `hung`: on the backend [`remember_backend`]
/// saved, or as it was (settings on trial, which the next start drops).
fn restart_after_hang(restart: startup::Restart, hung: eframe::wgpu::Backend) {
match restart {
startup::Restart::Backend(_) => relaunch(Some(hung)),
startup::Restart::Again => relaunch(None),
}
}
/// Start SolidCraft again once this one has closed, with the same arguments. `hung`: the backend
/// the GPU hung on, which the new start's notice names; `WGPU_BACKEND` is then not passed on, as
/// it would win over the backend [`remember_backend`] saved and start the one that hung again. A
/// start-up retry's backend never is: it would win over the saved or tried settings.
fn relaunch(hung: Option<eframe::wgpu::Backend>) {
let Ok(exe) = std::env::current_exe() else { return };
let mut command = std::process::Command::new(exe);
// This launch's notices and start-up retry aren't the next one's.
command
.args(std::env::args_os().skip(1))
.env_remove(startup::HUNG_ENV)
.env_remove(startup::RETRY_BACKEND_ENV)
.env_remove(startup::FALLBACK_NOTE_ENV)
.env_remove(device_lost::LOST_ENV);
if let Some(hung) = hung {
command.env(startup::HUNG_ENV, format!("{hung:?}")).env_remove(startup::WGPU_BACKEND_ENV);
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
// Not tied to this process: its own process group, no console.
command.creation_flags(0x0000_0200 | 0x0000_0008);
}
if let Err(e) = command.spawn() {
log::error!("graphics: starting again: {e}");
}
}
/// Keep `backend` as `graphics.json`'s `backend` (its other settings stay). Written to a new file
/// that then replaces the old one: this runs as the GPU hangs, when the machine may reset next,
/// and a half-written file would lose the anti-aliasing setting too.
fn remember_backend(backend: &str) {
let Some(path) = graphics_path() else { return };
let mut graphics = graphics().filter(serde_json::Value::is_object).unwrap_or_else(|| serde_json::json!({}));
graphics["backend"] = serde_json::json!(backend);
if let Err(e) = write_replacing(&path, graphics.to_string().as_bytes()) {
log::error!("graphics: keeping the {backend} backend in {}: {e}", path.display());
}
}
/// Write `bytes` to `path` all at once: to a file next to it, flushed to disk, then renamed over it.
fn write_replacing(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
use std::io::Write;
let mut name = path.file_name().unwrap_or_default().to_os_string();
name.push(".new");
let new = path.with_file_name(name);
let mut file = std::fs::File::create(&new)?;
file.write_all(bytes)?;
file.sync_all()?;
drop(file);
std::fs::rename(&new, path)
}
/// Where the next file dialog opens (#7756): the folder of the file last chosen in one, or of the
@@ -785,7 +907,8 @@ fn graphics() -> Option<serde_json::Value> {
graphics_path().and_then(|p| std::fs::read_to_string(p).ok()).and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
}
/// The graphics backend preference (`graphics.json`'s `backend`: `auto`, `dx12`, `vulkan`, `gl`).
/// The graphics backend preference (`graphics.json`'s `backend`: `auto`, `dx12`, `vulkan`, `gl`,
/// or `cpu`, the software renderer).
fn backend_preference() -> Option<String> {
graphics().and_then(|v| v.get("backend").and_then(serde_json::Value::as_str).map(str::to_string))
}
@@ -849,7 +972,7 @@ const DEPTH_BITS: u8 = 24;
const FLOAT_DEPTH_BITS: u8 = 32;
/// The section cap marks where it shows in the stencil.
const STENCIL_BITS: u8 = 8;
const MSAA: u16 = 4;
const MSAA: u16 = startup::DEFAULT_MSAA;
/// `custom_titlebar`: no OS decorations, the application bar is the title bar.
fn app_viewport(custom_titlebar: bool) -> Result<egui::ViewportBuilder, eframe::Error> {
@@ -970,28 +1093,6 @@ fn main() -> eframe::Result {
safe_mode = true;
notes.push(format!("The last {failed_starts} launches stopped before the window opened, so this one is in safe mode."));
}
let cpu_renderer = safe_mode || startup::cpu_renderer_env(std::env::var(startup::RENDERER_ENV).ok().as_deref());
// The launch before this one closed because its graphics device was lost (`App::watch_device`).
let after_loss = device_lost::recent(std::env::var(device_lost::LOST_ENV).ok().as_deref(), device_lost::now_secs());
if after_loss {
let reason = std::env::var(device_lost::REASON_ENV).ok();
if let Some(reason) = device_lost::reason(reason.as_deref()) {
log::warn!("graphics: the last launch lost its device ({reason})");
}
notes.push(device_lost::note(cpu_renderer, reason.as_deref()));
}
if safe_mode {
notes.push("Safe mode: default settings (yours are kept for the next normal start), no recovery offer, software rendering.".into());
}
let fallback_note = std::env::var(startup::FALLBACK_NOTE_ENV).ok().map(|s| s.chars().take(4096).collect::<String>());
if let Some(note) = &fallback_note {
log::warn!("{note}");
eprintln!("SolidCraft: {note}");
notes.push(note.clone());
}
let saved_samples = if safe_mode { None } else { msaa() };
let wgpu_options = wgpu_options(cpu_renderer, if safe_mode { None } else { backend_preference() }.as_deref());
let depth_bits = if !safe_mode && startup::float_depth_everywhere(&wgpu_options) { FLOAT_DEPTH_BITS } else { DEPTH_BITS };
// Crashes leave a report in the crash folder (solidcraft_engine::crash, docs/crash-reports.md).
let crashed = solidcraft_engine::crash::default_dir()
.map(|dir| {
@@ -1003,6 +1104,73 @@ fn main() -> eframe::Result {
solidcraft_engine::crash::install(dir, build)
})
.unwrap_or_default();
// The graphics API and anti-aliasing: the saved ones, or ones tried from Preferences (#4015).
let mut graphics = startup::graphics_start(graphics().as_ref(), safe_mode);
if let Some(file) = graphics.rewrite.take() {
let written = graphics_path().is_some_and(|path| match write_replacing(&path, file.to_string().as_bytes()) {
Ok(()) => true,
Err(e) => {
log::error!("graphics: writing {}: {e}", path.display());
false
}
});
// A trial that can't be marked as started would be tried at every start.
if !written && matches!(graphics.trial, startup::Trial::Trying(_)) {
graphics.trial = startup::Trial::None;
}
}
notes.extend(graphics.note());
// The last launch crashed on an API Preferences warns about on this system: back to Automatic.
let last_crashed = crashed.iter().any(|r| startup::launch_crashed(&r.kind));
if !safe_mode
&& graphics.trial == startup::Trial::None
&& let Some(auto) = startup::after_crash(&graphics.saved, last_crashed, || {
startup::apis_that_freeze(cfg!(target_os = "windows"), startup::hardware_adapters_once)
})
{
notes.push(startup::after_crash_note(&graphics.saved.backend));
remember_backend(&auto.backend);
graphics.saved = auto;
}
let current = graphics.current().clone();
let trying = matches!(graphics.trial, startup::Trial::Trying(_));
// The software renderer: safe mode, SOLIDCRAFT_RENDERER, or kept after the GPU hung on every
// API (`startup::after_hang`).
let cpu_renderer = safe_mode
|| startup::cpu_renderer_env(std::env::var(startup::RENDERER_ENV).ok().as_deref())
|| startup::cpu_renderer_env(Some(&current.backend));
// The launch before this one closed because its graphics device was lost (`App::watch_device`).
let after_loss = device_lost::recent(std::env::var(device_lost::LOST_ENV).ok().as_deref(), device_lost::now_secs());
if after_loss {
let reason = std::env::var(device_lost::REASON_ENV).ok();
if let Some(reason) = device_lost::reason(reason.as_deref()) {
log::warn!("graphics: the last launch lost its device ({reason})");
}
notes.push(device_lost::note(cpu_renderer, reason.as_deref()));
}
// The launch before this one closed because its GPU hung (`App::watch_for_a_hang`).
if let Some(hung) = std::env::var(startup::HUNG_ENV).ok().filter(|v| !v.trim().is_empty()) {
notes.push(startup::hang_note(&hung, safe_mode, backend_preference().as_deref()));
}
if safe_mode {
notes.push("Safe mode: default settings (yours are kept for the next normal start), no recovery offer, software rendering.".into());
}
let fallback_note = std::env::var(startup::FALLBACK_NOTE_ENV).ok().map(|s| s.chars().take(4096).collect::<String>());
if let Some(note) = &fallback_note {
log::warn!("{note}");
eprintln!("SolidCraft: {note}");
notes.push(note.clone());
}
// Anti-aliasing on trial is asked for; a saved one only once Graphics changed it (`msaa`).
let saved_samples = if safe_mode {
None
} else if trying {
Some(current.msaa)
} else {
msaa()
};
let wgpu_options = wgpu_options(cpu_renderer, (!safe_mode).then_some(current.backend.as_str()));
let depth_bits = if !safe_mode && startup::float_depth_everywhere(&wgpu_options) { FLOAT_DEPTH_BITS } else { DEPTH_BITS };
// A crash in native code (a graphics driver) says where it happened: the exception or signal
// and the module on Windows and macOS, and on every system the startup step the app was in.
#[cfg(target_os = "windows")]
@@ -1074,6 +1242,15 @@ fn main() -> eframe::Result {
#[cfg(target_os = "macos")]
let _driver_client = DriverClient;
mark("run_native");
if trying {
// A new graphics API that hangs before the window draws leaves nothing to click on.
startup::watch_trial_start(startup::TRIAL_START_LIMIT, || {
eprintln!("SolidCraft graphics: the settings on trial drew no window in {} s; starting again", startup::TRIAL_START_LIMIT.as_secs());
relaunch(None);
control_server::remove_token_file();
device_lost::end();
});
}
let result = eframe::run_native(
"SolidCraft",
options,
@@ -1122,8 +1299,21 @@ fn main() -> eframe::Result {
});
if !safe_mode {
app.preferences.graphics_adapter =
graphics().and_then(|v| v.get("adapter").and_then(serde_json::Value::as_str).map(str::to_string)).unwrap_or_default();
crate::graphics().and_then(|v| v.get("adapter").and_then(serde_json::Value::as_str).map(str::to_string)).unwrap_or_default();
}
// Preferences shows the graphics settings this start uses, saved or on trial (#4015).
app.preferences.graphics_api = current.backend.clone();
app.preferences.msaa = u32::from(current.msaa);
let saved = solidcraft_ui_egui::prefs::GraphicsChoice { api: graphics.saved.backend.clone(), msaa: u32::from(graphics.saved.msaa) };
app.graphics = solidcraft_ui_egui::prefs::Graphics {
apis: startup::GRAPHICS_APIS.to_vec(),
freezes: startup::apis_that_freeze(cfg!(target_os = "windows"), startup::hardware_adapters_once),
env_override: std::env::var(startup::WGPU_BACKEND_ENV).ok().filter(|v| !v.trim().is_empty()),
trial: trying.then(|| solidcraft_ui_egui::prefs::GraphicsTrial { previous: saved.clone(), shown: 0.0, last_frame: None }),
saved,
restart: false,
};
let mut renderer = None;
if let Some(rs) = &cc.wgpu_render_state {
app.services.graphics_adapters = rs
.available_adapters
@@ -1147,6 +1337,7 @@ fn main() -> eframe::Result {
let scene_samples = scene_samples(info.backend, u32::from(samples), &supported);
eprintln!("SolidCraft graphics: {:?}, MSAA {scene_samples}x (requested {}x)", rs.adapter.get_info().backend, samples);
solidcraft_engine::crash::phase("viewport pipelines (shader compile)");
renderer = Some((rs.adapter.get_info().backend, cpu_renderer));
let scene_samples = app.set_wgpu_split(rs, depth_bits, STENCIL_BITS, scene_samples);
app.msaa_supported = supported;
app.preferences.msaa = u32::from(samples);
@@ -1252,6 +1443,9 @@ fn main() -> eframe::Result {
take_focus_unwanted: control_port.is_some(),
come_forward: come_forward::ComeForward::default(),
gpu,
renderer,
hang: startup::HangWatch::default(),
restart: None,
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
space_mouse,
#[cfg(target_os = "macos")]
@@ -1263,7 +1457,7 @@ fn main() -> eframe::Result {
);
control_server::remove_token_file();
match result {
Err(e) if !created.load(std::sync::atomic::Ordering::SeqCst) => no_window(e, cpu_renderer, started, settings.as_deref()),
Err(e) if !created.load(std::sync::atomic::Ordering::SeqCst) => no_window(e, cpu_renderer, started, trying, settings.as_deref()),
r => r,
}
}
@@ -1279,8 +1473,8 @@ fn startup_step(step: &'static str, detail: &str) {
}
/// The renderer settings: with `cpu`, the software adapter (WARP, lavapipe, llvmpipe). The
/// backends come from `startup::choose_backends` (on Windows D3D12 first, and only the backend
/// in use is started); `preference` is `graphics.json`'s `backend`.
/// backends come from `startup::choose_backends` (on Windows D3D12 first, Vulkan on AMD
/// integrated GPUs, and only the backend in use is started); `preference` is `graphics.json`'s `backend`.
fn wgpu_options(cpu: bool, preference: Option<&str>) -> eframe::egui_wgpu::WgpuConfiguration {
let mut options = eframe::egui_wgpu::WgpuConfiguration::default();
if let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &mut options.wgpu_setup {
@@ -1288,7 +1482,7 @@ fn wgpu_options(cpu: bool, preference: Option<&str>) -> eframe::egui_wgpu::WgpuC
// A retry applies only to this launch. WGPU_BACKEND always has priority.
let retry = std::env::var(startup::RETRY_BACKEND_ENV).ok();
let preference = if cpu { preference } else { retry.as_deref().or(preference) };
if let Some(backends) = startup::choose_backends(cfg!(target_os = "windows"), env_set, cpu, preference, startup::has_hardware_adapter) {
if let Some(backends) = startup::choose_backends(cfg!(target_os = "windows"), env_set, cpu, preference, startup::hardware_adapters_once) {
eprintln!("SolidCraft graphics: starting {backends:?}");
setup.instance_descriptor.backends = backends;
}
@@ -1312,35 +1506,53 @@ fn wgpu_options(cpu: bool, preference: Option<&str>) -> eframe::egui_wgpu::WgpuC
}
/// A failed renderer returns here before app creation. A fresh process avoids reusing winit's
/// event loop or a failed device; each retry advances through the APIs, then software once.
fn no_window(error: eframe::Error, tried_cpu: bool, failed: eframe::wgpu::Backends, settings: Option<&std::path::Path>) -> eframe::Result {
/// event loop or a failed device; each retry advances through the APIs, then software once. With
/// graphics settings on trial (`trial`), once more as usual instead: that start drops them and
/// goes back to the saved ones.
fn no_window(
error: eframe::Error,
tried_cpu: bool,
failed: eframe::wgpu::Backends,
trial: bool,
settings: Option<&std::path::Path>,
) -> eframe::Result {
log::error!("the window could not open: {error}");
eprintln!("SolidCraft: the window could not open: {error}");
// The retry below runs as a child that this process waits for, for as long as it runs: not a
// start that never draws.
startup::first_frame_drawn();
// This failure was handled, so neither the marker nor the journal should call it a crash.
if let Some(dir) = settings {
startup::started(dir);
}
solidcraft_engine::crash::end_session();
let explicit = std::env::var(startup::WGPU_BACKEND_ENV).is_ok_and(|v| !v.trim().is_empty());
if let Some(next) = startup::fallback_backend(cfg!(target_os = "windows"), explicit, tried_cpu, failed)
let next = if trial { None } else { startup::fallback_backend(cfg!(target_os = "windows"), explicit, tried_cpu, failed) };
if (trial || next.is_some())
&& let Ok(exe) = std::env::current_exe()
{
let previous = std::env::var(startup::FALLBACK_NOTE_ENV).unwrap_or_default();
let note = startup::fallback_note(&previous, failed, next, &error.to_string());
log::warn!("{note}");
eprintln!("SolidCraft: {note}");
let mut command = std::process::Command::new(exe);
command.args(std::env::args_os().skip(1)).env(startup::FALLBACK_NOTE_ENV, note);
if next == "cpu" {
command.env(startup::RENDERER_ENV, "cpu").env_remove(startup::RETRY_BACKEND_ENV);
command.args(std::env::args_os().skip(1)).env_remove(startup::HUNG_ENV);
if let Some(next) = next {
let previous = std::env::var(startup::FALLBACK_NOTE_ENV).unwrap_or_default();
let note = startup::fallback_note(&previous, failed, next, &error.to_string());
log::warn!("{note}");
eprintln!("SolidCraft: {note}");
command.env(startup::FALLBACK_NOTE_ENV, note);
if next == "cpu" {
command.env(startup::RENDERER_ENV, "cpu").env_remove(startup::RETRY_BACKEND_ENV);
} else {
command.env(startup::RETRY_BACKEND_ENV, next);
}
} else {
command.env(startup::RETRY_BACKEND_ENV, next);
eprintln!("SolidCraft: trying again with the saved graphics settings");
command.env_remove(startup::RETRY_BACKEND_ENV).env_remove(startup::FALLBACK_NOTE_ENV);
}
match command.status() {
Ok(status) if status.success() => return Ok(()),
// The final try showed its own message.
Ok(_) => return Err(error),
Err(e) => log::error!("could not start again on {next}: {e}"),
Err(e) => log::error!("could not start again on {}: {e}", next.unwrap_or("the saved graphics settings")),
}
}
let message = if explicit {
+569 -19
View File
@@ -11,6 +11,13 @@
//! - **A renderer that cannot start**: on Windows, try the remaining graphics APIs in order,
//! then the software renderer (`SOLIDCRAFT_RENDERER=cpu`). An explicit `WGPU_BACKEND` fails
//! cleanly without switching. Exhausted retries show a message box with the error.
//! - **A GPU that hangs after the window is up** (Windows, [`HangWatch`]): frames that each wait
//! out their acquire while the user is there. The app closes, keeping unsaved changes for
//! recovery, and starts again on the next API (D3D12 → Vulkan → OpenGL → the software
//! renderer), kept in `graphics.json`.
//! - **Graphics settings tried from Preferences** (the graphics API, anti-aliasing; [`graphics_start`]):
//! the start that tries them asks to keep them. One that never says so (a crash, a hang, the
//! question timed out) leaves the trial marked as started, and the next start drops it.
use std::path::{Path, PathBuf};
@@ -109,6 +116,46 @@ pub fn begin(dir: &Path) -> u32 {
before
}
/// How long a start that tries graphics settings has to draw its first frame (`watch_trial_start`).
pub const TRIAL_START_LIMIT: std::time::Duration = std::time::Duration::from_secs(60);
static FIRST_FRAME: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// The first frame was drawn (or the start handed over to a retry): `watch_trial_start` stands down.
pub fn first_frame_drawn() {
FIRST_FRAME.store(true, std::sync::atomic::Ordering::SeqCst);
}
/// A start that tries a new graphics API can hang inside the driver before any window draws (a
/// black or frozen window, no question, nothing to click). If [`first_frame_drawn`] isn't called
/// within `limit`, run `give_up` once (it starts again with the saved settings: the trial is
/// marked as started, so the next start drops it). Returns the thread, for tests.
pub fn watch_trial_start(limit: std::time::Duration, give_up: impl FnOnce() + Send + 'static) -> Option<std::thread::JoinHandle<()>> {
watch_start(&FIRST_FRAME, limit, give_up)
}
fn watch_start(
drawn: &'static std::sync::atomic::AtomicBool,
limit: std::time::Duration,
give_up: impl FnOnce() + Send + 'static,
) -> Option<std::thread::JoinHandle<()>> {
std::thread::Builder::new()
.name("solidcraft-trial-start".into())
.spawn(move || {
let begun = std::time::Instant::now();
while begun.elapsed() < limit {
if drawn.load(std::sync::atomic::Ordering::SeqCst) {
return;
}
std::thread::sleep(std::time::Duration::from_millis(50).min(limit));
}
if !drawn.load(std::sync::atomic::Ordering::SeqCst) {
give_up();
}
})
.ok()
}
/// The window is up: this launch did not fail to start.
pub fn started(dir: &Path) {
let _ = std::fs::remove_file(dir.join(MARKER));
@@ -188,8 +235,14 @@ pub const WGPU_BACKEND_ENV: &str = "WGPU_BACKEND";
/// together and prefer Vulkan, and starting Vulkan loads every installed Vulkan driver: Intel's
/// (`igvk64.dll`) crashed SolidCraft at startup that way, inside the driver, before anything
/// could be reported. D3D12 is Windows' own graphics API, the one its drivers are tested with.
/// `has_hardware(backends)` says whether those backends have an adapter that isn't software
/// (it is only asked about the backends being tried, one at a time).
/// `hardware(backends)` lists those backends' adapters that aren't software (it is only asked
/// about the backends being tried, one at a time).
///
/// One exception: when D3D12's only hardware adapters are AMD integrated GPUs and Vulkan has a
/// hardware adapter, Vulkan. On those (Ryzen 5400U, 5600U, 5700G) D3D12 hung the driver a few
/// seconds into the first frames and Windows reset it (a black screen, then a crash, #1789,
/// #2806); 0.11.0, which preferred Vulkan, worked there, and `WGPU_BACKEND=vulkan` runs it
/// smoothly (#3321).
///
/// - `WGPU_BACKEND` set (`env_set`): `None`, whatever it says wins.
/// - The software renderer (`cpu`, safe mode): D3D12 alone on Windows (WARP); `None` elsewhere.
@@ -202,7 +255,7 @@ pub fn choose_backends(
env_set: bool,
cpu: bool,
preference: Option<&str>,
mut has_hardware: impl FnMut(eframe::wgpu::Backends) -> bool,
mut hardware: impl FnMut(eframe::wgpu::Backends) -> Vec<HardwareAdapter>,
) -> Option<eframe::wgpu::Backends> {
use eframe::wgpu::Backends;
if env_set {
@@ -221,7 +274,150 @@ pub fn choose_backends(
if !windows {
return None;
}
Some(WINDOWS_ORDER.into_iter().find(|b| has_hardware(*b)).unwrap_or(Backends::DX12))
for b in WINDOWS_ORDER {
let found = hardware(b);
if found.is_empty() {
continue;
}
if b == Backends::DX12 && found.iter().all(HardwareAdapter::hangs_on_d3d12) && !hardware(Backends::VULKAN).is_empty() {
log::warn!("graphics: Vulkan rather than D3D12 on an AMD integrated GPU (#3321)");
return Some(Backends::VULKAN);
}
return Some(b);
}
Some(Backends::DX12)
}
/// A hardware (not software) adapter a backend offers, as [`choose_backends`] needs it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct HardwareAdapter {
/// The PCI vendor id.
pub vendor: u32,
pub device_type: eframe::wgpu::DeviceType,
}
/// AMD's PCI vendor id.
const AMD: u32 = 0x1002;
impl HardwareAdapter {
/// An AMD integrated GPU, whose D3D12 driver hung on SolidCraft's first frames (#3321).
pub fn hangs_on_d3d12(&self) -> bool {
self.vendor == AMD && self.device_type == eframe::wgpu::DeviceType::IntegratedGpu
}
}
/// Frames in a row that stalled ([`eframe::egui_wgpu::stalled_frames`]: each waited out wgpu's
/// one-second acquire) after which the GPU is taken as hung.
pub const STALLED_FRAMES_FOR_RESTART: u32 = 5;
/// Seconds the window's surface may refuse its configuration ([`eframe::egui_wgpu::refused_for`])
/// while the user is there before the GPU is taken as hung: after a reset, a surface that stays
/// unsupported leaves a blank window. Refusals that clear up (a resize, a display waking up)
/// take a moment, not this long.
pub const REFUSED_S_FOR_RESTART: f64 = 10.0;
/// Stalled frames count only this many seconds after the user's input: a hung GPU leaves a white
/// window the user clicks at, while a switched-off display can hold frames back with nobody there.
pub const STALL_INPUT_WINDOW_S: f64 = 10.0;
/// Set on the launch that replaces one whose GPU hung: the hung backend, for the notice.
pub const HUNG_ENV: &str = "SOLIDCRAFT_GPU_HUNG";
/// Watches for a hung GPU after the window is up (#3321). The failed-start count misses it: frames
/// still come, a second apart, so the start counts as good.
#[derive(Debug, Default)]
pub struct HangWatch {
last_input: Option<f64>,
/// When the user came back: the first input after none for [`STALL_INPUT_WINDOW_S`].
arrived: f64,
/// The stalled streak when the user came back. Those frames stalled with nobody there (a
/// display switched off holds them back too), and the input that wakes the display comes
/// before the next frame shows whether the GPU is fine, so only stalls after it count.
stalled_before: u32,
hung: bool,
}
impl HangWatch {
/// Per frame, at `now` seconds: whether the user gave input this frame, the frames in a row
/// that stalled, and the seconds the surface has refused its configuration. True once, the
/// frame the GPU is taken as hung.
pub fn hung(&mut self, now: f64, input: bool, stalled: u32, refused_s: Option<f64>) -> bool {
if stalled < self.stalled_before {
// The streak broke: every stall since came after the user was back.
self.stalled_before = 0;
}
if input {
if !self.user_there(now) {
self.arrived = now;
self.stalled_before = stalled;
}
self.last_input = Some(now);
}
if self.hung || !self.user_there(now) {
return false;
}
// A surface refused since before the user came back counts from then.
let refused = refused_s.map_or(0.0, |r| r.min(now - self.arrived));
self.hung = stalled.saturating_sub(self.stalled_before) >= STALLED_FRAMES_FOR_RESTART || refused >= REFUSED_S_FOR_RESTART;
self.hung
}
fn user_there(&self, now: f64) -> bool {
self.last_input.is_some_and(|t| (0.0..=STALL_INPUT_WINDOW_S).contains(&(now - t)))
}
}
/// How a launch whose GPU hung starts again.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Restart {
/// On this backend (`graphics.json`'s `backend`), kept for later launches too.
Backend(&'static str),
/// As it was: the hang came with graphics settings on trial, which the next start drops
/// ([`graphics_start`]).
Again,
}
/// The restart after the GPU hung on `backend` (`cpu`: the software renderer, which has nothing
/// to fall back to; `trial`: with graphics settings on trial, which the next start drops). Each
/// hang moves on to the next of [`WINDOWS_ORDER`], then to the software renderer, kept in
/// `graphics.json` for later launches too, so a GPU hangs at most once on each: a safe-mode
/// restart would leave the backend that hung saved for the next normal start. D3D12 goes to
/// Vulkan, which runs the GPUs whose D3D12 driver hung (#1789); it isn't probed here, as loading
/// Vulkan's drivers into this process is what crashed once (`WINDOWS_ORDER`): the new launch
/// finds out, and falls back to software if it has no GPU.
pub fn after_hang(backend: eframe::wgpu::Backend, cpu: bool, trial: bool) -> Option<Restart> {
match backend {
_ if trial => Some(Restart::Again),
_ if cpu => None,
eframe::wgpu::Backend::Dx12 => Some(Restart::Backend("vulkan")),
eframe::wgpu::Backend::Vulkan => Some(Restart::Backend("gl")),
_ => Some(Restart::Backend(SOFTWARE_BACKEND)),
}
}
/// `graphics.json`'s `backend` for the software renderer, kept after the GPU hung on every API.
pub const SOFTWARE_BACKEND: &str = "cpu";
/// The notice on the launch after the GPU hung on `hung` (a backend's name): this one is in safe
/// mode, or on the `backend` preference.
pub fn hang_note(hung: &str, safe_mode: bool, backend: Option<&str>) -> String {
let now = match backend {
_ if safe_mode => "in safe mode".to_string(),
Some(b) => format!("on {}, which it keeps using (the backend in graphics.json)", api_name(b)),
None => "with another renderer".to_string(),
};
let unsaved = if safe_mode { "the next normal start offers unsaved changes" } else { "unsaved changes are offered for recovery" };
format!("The graphics driver stopped responding on {}, so SolidCraft restarted {now}; {unsaved}.", api_name(hung))
}
/// A backend's name (`Dx12`, `vulkan`, …) as people know the graphics API.
fn api_name(backend: &str) -> &str {
match backend.trim().to_ascii_lowercase().as_str() {
"" | "auto" => "Automatic",
"dx12" => "Direct3D 12",
"vulkan" => "Vulkan",
"gl" => "OpenGL",
"metal" => "Metal",
"cpu" | "software" => "the software renderer",
_ => backend,
}
}
/// Internal, process-local retry choice; never written to the graphics preference.
@@ -252,6 +448,164 @@ pub fn fallback_note(previous: &str, failed: eframe::wgpu::Backends, next: &str,
line.split_whitespace().collect::<Vec<_>>().join(" ").chars().take(4096).collect()
}
/// The graphics APIs Preferences offers on this system besides Automatic, as `graphics.json`'s
/// `backend` names them (#4015). None on macOS, which has Metal alone.
pub const GRAPHICS_APIS: &[&str] = if cfg!(target_os = "windows") {
&["dx12", "vulkan", "gl"]
} else if cfg!(target_os = "macos") {
&[]
} else {
&["vulkan", "gl"]
};
/// The APIs Preferences warns about on this system: D3D12 when its GPUs are all AMD integrated
/// ones, whose driver it froze, at times until the computer was restarted (#3321).
pub fn apis_that_freeze(windows: bool, hardware: impl FnOnce(eframe::wgpu::Backends) -> Vec<HardwareAdapter>) -> Vec<&'static str> {
if !windows {
return Vec::new();
}
let found = hardware(eframe::wgpu::Backends::DX12);
if !found.is_empty() && found.iter().all(HardwareAdapter::hangs_on_d3d12) { vec!["dx12"] } else { Vec::new() }
}
/// The saved settings to start with after the last launch crashed (`crashed`) on `saved`: an API
/// Preferences warns about on this system (`freezes`, [`apis_that_freeze`]) goes back to Automatic.
/// AMD's integrated GPUs crashed on D3D12 once a design opened, after the start and its trial had
/// gone fine and the user kept it (#4015). `None`: they stay.
pub fn after_crash(saved: &GraphicsChoice, crashed: bool, freezes: impl FnOnce() -> Vec<&'static str>) -> Option<GraphicsChoice> {
let chosen: Vec<&str> = saved.backend.split(',').map(str::trim).filter(|b| !b.is_empty() && *b != "auto").collect();
if !crashed || chosen.is_empty() {
return None;
}
let freezes = freezes();
chosen.iter().any(|b| freezes.contains(b)).then(|| GraphicsChoice { backend: "auto".into(), msaa: saved.msaa })
}
/// The notice when [`after_crash`] put `backend` (a backend's name) back to Automatic.
pub fn after_crash_note(backend: &str) -> String {
format!(
"SolidCraft closed unexpectedly on {}, which can freeze this computer's graphics, so it went back to Automatic. Preferences can choose it again.",
api_name(backend)
)
}
/// Whether a crash report `kind` ([`solidcraft_engine::crash::Summary`]) means the launch ended:
/// a panic nothing caught or an unclean exit, not a command or kernel call recovered from.
pub fn launch_crashed(kind: &str) -> bool {
matches!(kind, "panic" | "unclean_exit")
}
/// [`hardware_adapters`], keeping D3D12's: the start asks for them ([`choose_backends`]), then
/// Preferences ([`apis_that_freeze`]).
pub fn hardware_adapters_once(backends: eframe::wgpu::Backends) -> Vec<HardwareAdapter> {
static D3D12: std::sync::OnceLock<Vec<HardwareAdapter>> = std::sync::OnceLock::new();
if backends == eframe::wgpu::Backends::DX12 { D3D12.get_or_init(|| hardware_adapters(backends)).clone() } else { hardware_adapters(backends) }
}
/// Anti-aliasing samples when `graphics.json` has none (or one that isn't 1, 2, 4 or 8).
pub const DEFAULT_MSAA: u16 = 4;
/// Graphics settings a launch starts with (`graphics.json`'s `backend` and `msaa`).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GraphicsChoice {
/// `auto`, or the backends to start (`dx12`, `vulkan`, `gl`, or a comma list).
pub backend: String,
pub msaa: u16,
}
impl GraphicsChoice {
/// The choice in a JSON object, with `fallback`'s values where it has none (or a bad one).
fn read(json: Option<&serde_json::Value>, fallback: &GraphicsChoice) -> GraphicsChoice {
let backend = json.and_then(|v| v.get("backend")).and_then(serde_json::Value::as_str).map(str::trim).filter(|b| !b.is_empty());
let msaa = json
.and_then(|v| v.get("msaa"))
.and_then(serde_json::Value::as_u64)
.and_then(|m| u16::try_from(m).ok())
.filter(|m| [1, 2, 4, 8].contains(m));
GraphicsChoice { backend: backend.map_or_else(|| fallback.backend.clone(), str::to_string), msaa: msaa.unwrap_or(fallback.msaa) }
}
/// As the notice names it, with only what differs from `other` ("Vulkan, 8× anti-aliasing").
fn describe(&self, other: &GraphicsChoice) -> String {
let mut parts = Vec::new();
if self.backend != other.backend || self.msaa == other.msaa {
parts.push(api_name(&self.backend).to_string());
}
if self.msaa != other.msaa {
parts.push(format!("{}× anti-aliasing", self.msaa));
}
parts.join(", ")
}
}
/// Settings tried from Preferences, as a launch finds them (#4015).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Trial {
None,
/// This launch tries them: it starts with them and asks whether to keep them.
Trying(GraphicsChoice),
/// The launch that tried them ended without keeping them: they are dropped.
Failed(GraphicsChoice),
}
/// `graphics.json` as a launch reads it.
#[derive(Clone, Debug, PartialEq)]
pub struct GraphicsStart {
/// The settings kept for every launch.
pub saved: GraphicsChoice,
pub trial: Trial,
/// The file to write back before the window opens, when it changes: the trial marked as
/// started, or dropped.
pub rewrite: Option<serde_json::Value>,
}
impl GraphicsStart {
/// The settings this launch starts with.
pub fn current(&self) -> &GraphicsChoice {
match &self.trial {
Trial::Trying(tried) => tried,
_ => &self.saved,
}
}
/// The notice when a trial was dropped.
pub fn note(&self) -> Option<String> {
let Trial::Failed(tried) = &self.trial else { return None };
Some(format!(
"The graphics settings tried at the last start ({}) weren't kept, so SolidCraft went back to {}.",
tried.describe(&self.saved),
self.saved.describe(tried)
))
}
}
/// Read `graphics.json` (`file`) at start. Preferences writes settings to try as its `trial`
/// object, next to the saved `backend` and `msaa`. The first launch to find it tries them and
/// marks it `started`; the app removes the trial when the user keeps the settings (they become
/// the saved ones) or goes back. A trial already started belongs to a launch that never got
/// that far, so it is dropped. Safe mode leaves a trial that hasn't started for the next start.
pub fn graphics_start(file: Option<&serde_json::Value>, safe_mode: bool) -> GraphicsStart {
let defaults = GraphicsChoice { backend: "auto".into(), msaa: DEFAULT_MSAA };
let saved = GraphicsChoice::read(file, &defaults);
let Some(trial) = file.and_then(|f| f.get("trial")).filter(|t| t.is_object()) else {
return GraphicsStart { saved, trial: Trial::None, rewrite: None };
};
let tried = GraphicsChoice::read(Some(trial), &saved);
let started = trial.get("started").and_then(serde_json::Value::as_bool).unwrap_or(false);
let mut rewrite = file.cloned().unwrap_or_default();
if started {
if let Some(o) = rewrite.as_object_mut() {
o.remove("trial");
}
return GraphicsStart { saved, trial: Trial::Failed(tried), rewrite: Some(rewrite) };
}
if safe_mode {
return GraphicsStart { saved, trial: Trial::None, rewrite: None };
}
rewrite["trial"]["started"] = serde_json::json!(true);
GraphicsStart { saved, trial: Trial::Trying(tried), rewrite: Some(rewrite) }
}
/// The device feature for a 32-bit float depth buffer with a stencil (reversed depth, #1434).
pub const FLOAT_DEPTH_STENCIL: eframe::wgpu::Features = eframe::wgpu::Features::DEPTH32FLOAT_STENCIL8;
@@ -272,8 +626,8 @@ pub fn float_depth_everywhere(options: &eframe::egui_wgpu::WgpuConfiguration) ->
/// The order backends are tried in on Windows.
pub const WINDOWS_ORDER: [eframe::wgpu::Backends; 3] = [eframe::wgpu::Backends::DX12, eframe::wgpu::Backends::VULKAN, eframe::wgpu::Backends::GL];
/// Whether `backends` (started on their own, nothing else) have a hardware adapter.
pub fn has_hardware_adapter(backends: eframe::wgpu::Backends) -> bool {
/// The hardware adapters of `backends` (started on their own, nothing else).
pub fn hardware_adapters(backends: eframe::wgpu::Backends) -> Vec<HardwareAdapter> {
let mut desc = eframe::wgpu::InstanceDescriptor::new_without_display_handle();
desc.backends = backends;
desc.flags = eframe::wgpu::InstanceFlags::from_build_config().with_env();
@@ -282,7 +636,12 @@ pub fn has_hardware_adapter(backends: eframe::wgpu::Backends) -> bool {
let adapters = pollster::block_on(instance.enumerate_adapters(backends));
let found: Vec<String> = adapters.iter().map(|a| format!("{} ({:?})", a.get_info().name, a.get_info().device_type)).collect();
log::info!("graphics: {backends:?} adapters: {found:?}");
adapters.iter().any(|a| a.get_info().device_type != eframe::wgpu::DeviceType::Cpu)
adapters
.iter()
.map(|a| a.get_info())
.filter(|i| i.device_type != eframe::wgpu::DeviceType::Cpu)
.map(|i| HardwareAdapter { vendor: i.vendor, device_type: i.device_type })
.collect()
}
/// The environment variable that switches wgpu's indirect-call validation on or off.
@@ -403,6 +762,34 @@ mod tests {
["Integrated", "Discrete small", "Discrete large"].into_iter().map(|name| AdapterChoice { name: name.into(), compatible: true }).collect()
}
/// A start on trial that draws no frame in time gives up once; one that draws stands down
/// (#4015).
#[test]
fn a_trial_start_that_draws_nothing_gives_up_once() {
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::time::Duration;
static HUNG: AtomicBool = AtomicBool::new(false);
static HUNG_GAVE_UP: AtomicU32 = AtomicU32::new(0);
watch_start(&HUNG, Duration::from_millis(150), || {
HUNG_GAVE_UP.fetch_add(1, Ordering::SeqCst);
})
.map(|t| t.join())
.unwrap()
.unwrap();
assert_eq!(HUNG_GAVE_UP.load(Ordering::SeqCst), 1);
static DRAWN: AtomicBool = AtomicBool::new(false);
static DRAWN_GAVE_UP: AtomicU32 = AtomicU32::new(0);
let watch = watch_start(&DRAWN, Duration::from_millis(400), || {
DRAWN_GAVE_UP.fetch_add(1, Ordering::SeqCst);
})
.unwrap();
std::thread::sleep(Duration::from_millis(60));
DRAWN.store(true, Ordering::SeqCst);
watch.join().unwrap();
assert_eq!(DRAWN_GAVE_UP.load(Ordering::SeqCst), 0, "the window drew: nothing to give up");
}
#[test]
fn max_file_size_parses_sizes_and_refuses_bad_ones() {
assert_eq!(max_file_size(Some("2GB")), Ok(2 << 30));
@@ -562,21 +949,32 @@ mod tests {
let _ = std::fs::remove_dir_all(&d);
}
/// A discrete NVIDIA GPU, and AMD's integrated one (#3321).
const NVIDIA: HardwareAdapter = HardwareAdapter { vendor: 0x10de, device_type: eframe::wgpu::DeviceType::DiscreteGpu };
const AMD_IGPU: HardwareAdapter = HardwareAdapter { vendor: AMD, device_type: eframe::wgpu::DeviceType::IntegratedGpu };
/// A probe where the backends `with` have `gpus`, and the others none.
fn probe_with(
with: &'static [eframe::wgpu::Backends],
gpus: &'static [HardwareAdapter],
) -> impl FnMut(eframe::wgpu::Backends) -> Vec<HardwareAdapter> {
use eframe::wgpu::Backends;
move |b: Backends| {
assert!(b == Backends::DX12 || b == Backends::VULKAN || b == Backends::GL, "one backend at a time: {b:?}");
if with.contains(&b) { gpus.to_vec() } else { Vec::new() }
}
}
#[test]
fn windows_starts_d3d12_first_and_vulkan_only_when_d3d12_has_no_gpu() {
use eframe::wgpu::Backends;
let probe = |with: &'static [Backends]| {
move |b: Backends| {
assert!(b == Backends::DX12 || b == Backends::VULKAN || b == Backends::GL, "one backend at a time: {b:?}");
with.contains(&b)
}
};
let probe = |with: &'static [Backends]| probe_with(with, &[NVIDIA]);
// D3D12 works: Vulkan and OpenGL are never even asked about (starting Vulkan loads
// its drivers, which is what crashed).
let mut asked = Vec::new();
let chosen = choose_backends(true, false, false, None, |b| {
asked.push(b);
b == Backends::DX12
if b == Backends::DX12 { vec![NVIDIA] } else { Vec::new() }
});
assert_eq!(chosen, Some(Backends::DX12));
assert_eq!(asked, [Backends::DX12]);
@@ -591,6 +989,158 @@ mod tests {
assert_eq!(choose_backends(true, false, false, Some(""), probe(&[Backends::GL])), Some(Backends::GL));
}
/// AMD integrated GPUs hung under D3D12 at the first frames, the driver reset and the app
/// crashed (#1789, #2806); they run on Vulkan (#3321).
#[test]
fn an_amd_integrated_gpu_starts_vulkan_rather_than_d3d12() {
use eframe::wgpu::Backends;
let all = &[Backends::DX12, Backends::VULKAN, Backends::GL];
assert_eq!(choose_backends(true, false, false, None, probe_with(all, &[AMD_IGPU])), Some(Backends::VULKAN));
// Without a Vulkan GPU: D3D12 still, rather than OpenGL or software.
assert_eq!(choose_backends(true, false, false, None, probe_with(&[Backends::DX12], &[AMD_IGPU])), Some(Backends::DX12));
// Another GPU beside it (a laptop's discrete one): D3D12, which can pick that one.
assert_eq!(choose_backends(true, false, false, None, probe_with(all, &[AMD_IGPU, NVIDIA])), Some(Backends::DX12));
// A discrete AMD GPU keeps D3D12.
let discrete = &[HardwareAdapter { vendor: AMD, device_type: eframe::wgpu::DeviceType::DiscreteGpu }];
assert_eq!(choose_backends(true, false, false, None, probe_with(all, discrete)), Some(Backends::DX12));
// The preference and WGPU_BACKEND still win, and other systems are left to wgpu.
let never = |b: Backends| -> Vec<HardwareAdapter> { panic!("no probing expected, asked about {b:?}") };
assert_eq!(choose_backends(true, false, false, Some("dx12"), never), Some(Backends::DX12));
assert_eq!(choose_backends(true, true, false, None, never), None);
assert_eq!(choose_backends(false, false, false, None, never), None);
}
/// After the window was up, D3D12 hung the GPU: frames came a second apart and never showed,
/// a white window with no crash (#1789, #3321). Five such frames while the user is there
/// restart the app, D3D12 on Vulkan and anything else in safe mode.
#[test]
fn a_gpu_that_hangs_after_the_window_is_up_restarts_on_another_renderer() {
use eframe::wgpu::Backend;
let stalled = STALLED_FRAMES_FOR_RESTART;
let mut watch = HangWatch::default();
assert!(!watch.hung(0.0, true, 0, None));
assert!(!watch.hung(4.0, false, stalled - 1, None), "not yet");
assert!(watch.hung(5.0, false, stalled, None), "input 5 s ago");
assert!(!watch.hung(6.0, true, stalled + 1, None), "once");
// Nobody at the computer (a display switched off holds frames back too): no restart.
let mut away = HangWatch::default();
assert!(!away.hung(100.0, false, stalled, None));
assert!(!away.hung(200.0, false, 100, None));
// The input that wakes the display comes before a frame shows the GPU is fine: the
// stalls from before don't count, and the frames on time that follow end the streak.
let mut woke = HangWatch::default();
assert!(!woke.hung(0.0, true, 0, None));
assert!(!woke.hung(STALL_INPUT_WINDOW_S + 1.0, false, 30, None));
assert!(!woke.hung(STALL_INPUT_WINDOW_S + 2.0, true, 30, None), "back at a healthy app");
assert!(!woke.hung(STALL_INPUT_WINDOW_S + 3.0, true, 0, None));
let later: Vec<bool> = (1..=stalled).map(|n| woke.hung(STALL_INPUT_WINDOW_S + 3.0 + f64::from(n), true, n, None)).collect();
assert_eq!(later.last(), Some(&true), "then it hangs: {later:?}");
// Back at a GPU that hung while nobody was there: five more stalls.
let mut back = HangWatch::default();
assert!(!back.hung(100.0, true, 30, None));
assert!(!back.hung(103.0, true, 30 + stalled - 1, None));
assert!(back.hung(104.0, true, 30 + stalled, None));
// A surface refused for good after a reset never reaches the acquire: a blank window.
let mut refused = HangWatch::default();
assert!(!refused.hung(0.0, true, 0, Some(60.0)), "refused since before the user came");
assert!(!refused.hung(REFUSED_S_FOR_RESTART - 1.0, true, 0, Some(60.0 + REFUSED_S_FOR_RESTART - 1.0)));
assert!(refused.hung(REFUSED_S_FOR_RESTART, true, 0, Some(60.0 + REFUSED_S_FOR_RESTART)));
let mut resized = HangWatch::default();
assert!(!resized.hung(0.0, true, 0, Some(0.5)), "a moment's refusal");
// Each API hangs at most once: D3D12, then Vulkan, then OpenGL, then software.
assert_eq!(after_hang(Backend::Dx12, false, false), Some(Restart::Backend("vulkan")));
assert_eq!(after_hang(Backend::Vulkan, false, false), Some(Restart::Backend("gl")));
assert_eq!(after_hang(Backend::Gl, false, false), Some(Restart::Backend(SOFTWARE_BACKEND)));
assert_eq!(after_hang(Backend::Dx12, true, false), None, "software: nothing to fall back to");
assert!(cpu_renderer_env(Some(SOFTWARE_BACKEND)), "the saved software renderer starts as such");
// Settings on trial: start again as it was, and the next start drops them (#4015).
assert_eq!(after_hang(Backend::Vulkan, false, true), Some(Restart::Again));
assert_eq!(after_hang(Backend::Dx12, true, true), Some(Restart::Again));
let note = hang_note("Dx12", false, Some("vulkan"));
assert!(note.contains("on Direct3D 12") && note.contains("on Vulkan") && note.contains("recovery"), "{note}");
assert!(hang_note("Vulkan", true, Some("gl")).contains("in safe mode"));
assert!(hang_note("Gl", false, Some("cpu")).contains("on the software renderer, which it keeps using"));
}
/// Settings tried from Preferences (#4015): the first start tries them and marks the trial
/// started; one that finds it started drops it and says so; safe mode leaves it waiting.
#[test]
fn graphics_settings_on_trial_are_tried_once_and_dropped_unless_kept() {
use serde_json::json;
let saved = GraphicsChoice { backend: "dx12".into(), msaa: 4 };
let vulkan_8x = GraphicsChoice { backend: "vulkan".into(), msaa: 8 };
// No file, or one without a trial: the saved settings (defaults where missing), unchanged.
let none = graphics_start(None, false);
assert_eq!(none.current(), &GraphicsChoice { backend: "auto".into(), msaa: DEFAULT_MSAA });
assert_eq!((none.trial, none.rewrite), (Trial::None, None));
let plain = graphics_start(Some(&json!({"backend": "dx12", "msaa": 16})), false);
assert_eq!(plain.current(), &saved, "16 isn't a sample count: the default");
assert_eq!(plain.rewrite, None);
// The first start tries the trial and marks it started.
let file = json!({"backend": "dx12", "msaa": 4, "trial": {"backend": "vulkan", "msaa": 8}});
let first = graphics_start(Some(&file), false);
assert_eq!(first.trial, Trial::Trying(vulkan_8x.clone()));
assert_eq!(first.current(), &vulkan_8x);
assert_eq!(first.saved, saved);
assert_eq!(first.note(), None);
let marked = first.rewrite.clone().unwrap();
assert_eq!(marked["trial"]["started"], json!(true));
assert_eq!((marked["backend"].clone(), marked["msaa"].clone()), (json!("dx12"), json!(4)), "the saved settings stay");
// That start never kept them (it crashed, hung, or the question timed out): the next one
// drops the trial, starts with the saved settings and says what happened.
let next = graphics_start(Some(&marked), false);
assert_eq!(next.trial, Trial::Failed(vulkan_8x));
assert_eq!(next.current(), &saved);
assert_eq!(next.rewrite, Some(json!({"backend": "dx12", "msaa": 4})));
let note = next.note().unwrap();
assert!(note.contains("(Vulkan, 8× anti-aliasing)") && note.contains("back to Direct3D 12, 4× anti-aliasing"), "{note}");
// Only what differs is named; Automatic by name.
let api_only = graphics_start(Some(&json!({"msaa": 4, "trial": {"backend": "gl", "started": true}})), false);
assert_eq!(
api_only.note().unwrap(),
"The graphics settings tried at the last start (OpenGL) weren't kept, so SolidCraft went back to Automatic."
);
// Safe mode leaves a trial that hasn't started for the next normal start, but still drops
// one that has.
let safe = graphics_start(Some(&file), true);
assert_eq!((safe.current(), &safe.trial, &safe.rewrite), (&saved, &Trial::None, &None));
assert!(matches!(graphics_start(Some(&marked), true).trial, Trial::Failed(_)));
// Every API Preferences offers is a backend the start understands.
for api in GRAPHICS_APIS {
assert!(!eframe::wgpu::Backends::from_comma_list(api).is_empty(), "{api}");
}
}
#[test]
fn preferences_warn_about_d3d12_only_where_it_froze_the_gpu() {
let with = |adapters: Vec<HardwareAdapter>| move |_: eframe::wgpu::Backends| adapters;
assert_eq!(apis_that_freeze(true, with(vec![AMD_IGPU])), vec!["dx12"]);
assert!(apis_that_freeze(true, with(vec![AMD_IGPU, NVIDIA])).is_empty(), "a graphics card besides");
assert!(apis_that_freeze(true, with(vec![NVIDIA])).is_empty());
assert!(apis_that_freeze(true, with(Vec::new())).is_empty(), "WARP alone");
assert!(apis_that_freeze(false, |_| panic!("only Windows has D3D12")).is_empty());
}
#[test]
fn a_crash_on_an_api_that_freezes_this_gpu_goes_back_to_automatic() {
let d3d12 = GraphicsChoice { backend: "dx12".into(), msaa: 1 };
let freezes = || vec!["dx12"];
assert_eq!(after_crash(&d3d12, true, freezes), Some(GraphicsChoice { backend: "auto".into(), msaa: 1 }), "anti-aliasing stays");
assert_eq!(after_crash(&d3d12, false, freezes), None, "no crash");
assert_eq!(after_crash(&d3d12, true, Vec::new), None, "D3D12 doesn't freeze this GPU");
let vulkan = GraphicsChoice { backend: "vulkan".into(), msaa: 4 };
assert_eq!(after_crash(&vulkan, true, freezes), None);
let auto = GraphicsChoice { backend: "auto".into(), msaa: 4 };
assert_eq!(after_crash(&auto, true, || panic!("Automatic isn't probed")), None);
assert!(launch_crashed("unclean_exit") && launch_crashed("panic"));
assert!(!launch_crashed("command_panic") && !launch_crashed("kernel_panic_recovered"));
assert!(after_crash_note("dx12").contains("on Direct3D 12"));
}
#[test]
fn failed_windows_backends_fall_back_in_order_and_stop_after_software() {
use eframe::wgpu::Backends as B;
@@ -617,7 +1167,7 @@ mod tests {
assert_eq!(
choose_backends(windows, true, cpu, Some("vulkan"), |_| {
probed = true;
false
Vec::new()
}),
None
);
@@ -642,7 +1192,7 @@ mod tests {
#[test]
fn wgpu_backend_the_preference_and_the_software_renderer_override_the_order() {
use eframe::wgpu::Backends;
let never = |b: Backends| -> bool { panic!("no probing expected, asked about {b:?}") };
let never = |b: Backends| -> Vec<HardwareAdapter> { panic!("no probing expected, asked about {b:?}") };
// WGPU_BACKEND: wgpu reads it itself, nothing is probed.
assert_eq!(choose_backends(true, true, false, Some("gl"), never), None);
assert_eq!(choose_backends(true, true, true, None, never), None);
@@ -655,7 +1205,7 @@ mod tests {
assert_eq!(choose_backends(true, false, false, Some("dx12,vulkan"), never), Some(Backends::DX12 | Backends::VULKAN));
assert_eq!(choose_backends(false, false, false, Some("vulkan"), never), Some(Backends::VULKAN));
// An unknown preference is the default order.
assert_eq!(choose_backends(true, false, false, Some("glide"), |b| b == Backends::DX12), Some(Backends::DX12));
assert_eq!(choose_backends(true, false, false, Some("glide"), probe_with(&[Backends::DX12], &[NVIDIA])), Some(Backends::DX12));
// Elsewhere: wgpu's own choice.
assert_eq!(choose_backends(false, false, false, None, never), None);
assert_eq!(choose_backends(false, false, false, Some("auto"), never), None);
@@ -668,10 +1218,10 @@ mod tests {
// D3D12 never loads its OpenGL driver into the test process, and AMD's (`atio6axx.dll`)
// aborted the process as it exited (#1156).
if cfg!(target_os = "windows") {
let _ = choose_backends(true, false, false, None, has_hardware_adapter);
let _ = choose_backends(true, false, false, None, hardware_adapters);
} else {
for b in WINDOWS_ORDER {
let _ = has_hardware_adapter(b);
let _ = hardware_adapters(b);
}
}
}
+58 -44
View File
@@ -744,22 +744,22 @@
"ai.prefs.availability": "التوفر",
"ai.prefs.background_commands": "تشغيل الأوامر البطيئة في الخلفية",
"ai.prefs.connect_apps": "إضافة SolidCraft إلى تطبيقات الذكاء الاصطناعي",
"ai.prefs.connect_apps_note": "إيقافه يزيل الإدخالات مجدداً.\n\nعند بدء تشغيل SolidCraft، يُضاف كخادم MCP إلى تطبيقات الذكاء الاصطناعي الموجودة على هذا الحاسوب (OpenCode وClaude Desktop وClaude Code وCursor وCodex وGemini CLI وCopilot CLI وWindsurf وZed وVS Code)، ليتمكن الوكيل من استخدامه دون أي إعداد. لا يُضاف إلا مُدخل solidcraft أو يُحدَّث مساره؛ ويُترك المُدخل الذي غيّرته كما هو، ويُنسخ الملف احتياطيًا أولًا. عند الإيقاف تُزال المُدخلات التي أضافها. الأمر «المساعدة > ربط مساعدي الذكاء الاصطناعي…» يفعل ذلك يدويًا.",
"ai.prefs.connect_apps_note": "إيقافه يزيل الإدخالات مجددًا.\n\nعند بدء تشغيل SolidCraft، يُضاف كخادم MCP إلى تطبيقات الذكاء الاصطناعي الموجودة على هذا الحاسوب (OpenCode وClaude Desktop وClaude Code وCursor وCodex وGemini CLI وCopilot CLI وWindsurf وZed وVS Code)، ليتمكن الوكيل من استخدامه دون أي إعداد. لا يُضاف إلا مُدخل solidcraft أو يُحدَّث مساره؛ ويُترك المُدخل الذي غيّرته كما هو، ويُنسخ الملف احتياطيًا أولًا. عند الإيقاف تُزال المُدخلات التي أضافها. الأمر «المساعدة > ربط مساعدي الذكاء الاصطناعي…» يفعل ذلك يدويًا.",
"ai.prefs.connect_at_start": "توصيل آخر وكيل عند البدء",
"ai.prefs.connect_at_start_note": "يبدأ في الخلفية الوكيل الذي اتصلت به آخر مرة (أو الوكيل الافتراضي) في كل مرة يبدأ فيها SolidCraft، ليكون جاهزًا للمحادثة. لا يحدث ذلك عند بدء SolidCraft مع --control. إذا تعذّر الاتصال، يعرض المساعد «فشل الاتصال» وزر «إعادة المحاولة».",
"ai.prefs.follow_camera": "اتباع الكاميرا",
"ai.prefs.follow_camera": "تتبّع الكاميرا",
"ai.prefs.follow_camera_off": "تتبّع الكاميرا التلقائي متوقف.",
"ai.prefs.follow_camera_on": "تتجه الكاميرا نحو الرسومات التي ينشئها الوكيل.",
"ai.prefs.intro": "وكلاء الذكاء الاصطناعي في SolidCraft.",
"ai.prefs.make_default": "تعيين كافتراضي",
"ai.prefs.make_default": "تعيين افتراضيًا",
"ai.prefs.no_agents": "لا يوجد وكلاء بعد. أضف وكيلًا أو حدّث القائمة للعثور على الوكلاء المثبّتين.",
"ai.prefs.no_saved_approvals": "لا توجد موافقات محفوظة.",
"ai.prefs.open_at_start": "فتح المحادثة عند البدء",
"ai.prefs.open_at_start_note": "يعرض لوحة المساعد (أو نافذتها المستقلة) في كل مرة يبدأ فيها SolidCraft. لا يحدث ذلك عند بدء SolidCraft مع --control.",
"ai.prefs.override_all_warning": "قد يحفظ هذا الوكيل التصميم أو يكتب فوقه أو يصدّره أو يستبدله دون أن يسأل.",
"ai.prefs.permission_all": "فعل كل شيء",
"ai.prefs.permission_all_summary": "لا يسأل أبداً",
"ai.prefs.permission_ask": "السؤال أولاً",
"ai.prefs.permission_all": "تنفيذ كل شيء",
"ai.prefs.permission_all_summary": "لا يسأل أبدًا",
"ai.prefs.permission_ask": "السؤال أولًا",
"ai.prefs.permission_ask_summary": "يسأل عن كل تغيير",
"ai.prefs.permission_edits": "إجراء تعديلات",
"ai.prefs.permission_edits_summary": "يعدّل بحرية ويسأل عن الباقي",
@@ -1396,6 +1396,9 @@
"dialog.custom_axis": "↖ محور مخصص",
"dialog.dimension_insert_hint": "انقر على بُعد قائد آخر لإدراج اسمه",
"dialog.dimension_name": "البُعد: {name}",
"dialog.dimension_step_down": "إنقاص بمقدار {step}",
"dialog.dimension_step_expression": "يتغير الرقم فقط بالخطوات؛ يبقى التعبير كما كُتب",
"dialog.dimension_step_up": "زيادة بمقدار {step}",
"dialog.edit_title": "تعديل {title}",
"dialog.no_shown_body": "لا يوجد جسم ظاهر لتغييره: أظهر جسمًا، أو اختر «جسم جديد»",
"dialog.pattern_sketch_not_object": "اختر أجسامًا أو ميزات: الرسم ليس أيًّا منهما. كرّر منحنياته بأداة النمط أو المرآة داخل الرسم، أو ابثقه أولًا.",
@@ -1403,6 +1406,7 @@
"dialog.selected_count": "↖ {count} محدد",
"dialog.selection_constraint": "قيد {id}",
"dialog.selection_external_curve": "{sketch} · منحنى {id}",
"dialog.selection_external_point": "{point} · {sketch}",
"dialog.selection_guidance": "حدد مُدخلًا لتغييره. انقر على العناصر المحددة لإزالتها؛ Ctrl/Cmd مع النقر يبدّل تحديدها أيضًا. راجع المعاينة ثم اضغط موافق.",
"dialog.selection_midpoint": "نقطة المنتصف · {id}",
"dialog.selection_profile": "{sketch} · مقطع {number}",
@@ -1420,6 +1424,7 @@
"document.read_only_save": "فُتح هذا التصميم للقراءة فقط: احفظه كملف جديد",
"document.save_changes": "حفظ التغييرات على {name}؟",
"document.save_copy": "حفظ نسخة…",
"document.tab_unsaved_changes": "تغييرات غير محفوظة",
"document.unsaved_changes": "في {name} تغييرات غير محفوظة. إن لم تحفظها فستضيع.",
"drive.rigid": "{joint} مفصل صلب: يثبّت أجزاءه في مكانها، لذا لا يوجد ما يمكن تحريكه. لتتحرك الأجزاء، غيّر نوعه في «تعديل المفصل»: «دوراني» للدوران، «منزلق» للانزلاق، «أسطواني» لكليهما.",
"drive.stopped_by_contact": "توقف عند {value}: سيخترق {a} الجسم {b}",
@@ -1918,7 +1923,7 @@
"joint.limit_loop": "{side} {value}: لا يمكن لحلقة مغلقة أن تنغلق بعده",
"joint.limit_range": "{side}: لا تلامس ضمن {range}، تُرك كما كان",
"joint.limit_turn": "{side} {value}: لا تلامس ضمن دورة واحدة",
"joint.limits_overlapping": "متداخلة بالفعل هنا: {pairs}. لا توقف البحث إلا إذا تداخلت أعمق.",
"joint.limits_overlapping": "متداخلة بالفعل هنا: {pairs}. لا توقف البحث إلا إذا ازداد تداخلها.",
"joint.one_body": "كلا الاختيارين على {body}. اختر الثاني على قطعة أخرى.",
"joint.one_component": "هذه القطع أجسام لمكوّن واحد: {bodies}. المفصل يحرّك مكوّنًا مقابل آخر، لذا تحتاج كل قطعة إلى مكوّن خاص بها.",
"joint.rigid_group_one_component": "هذه الأجسام وعددها {count} كلها في مكوّن واحد (أو في التصميم نفسه): استخدم {command} أولًا.",
@@ -1933,7 +1938,7 @@
"joint_move.fine": "الخطوة بعيدًا عن المحور",
"joint_move.fixed": "لا يملك هذا الجزء محور مفصل قابلًا للحركة.",
"joint_move.help": "تحريك — اسحب سهمًا أو قوسًا. يلغى السحب الحالي بمفتاح Esc.",
"joint_move.legacy_hint": "إيقاف: لوحة التحريك السابقة.",
"joint_move.legacy_hint": "عند الإيقاف: لوحة التحريك السابقة.",
"joint_move.precision": "اسحب خارج الدليل المتقطع للخطوة الدقيقة.",
"joint_move.rotation": "الدوران من نقطة البداية",
"joint_move.select": "اختر وجهًا أو جزءًا واحدًا من التجميع، ثم اختر تحريك.",
@@ -1999,6 +2004,7 @@
"material.selection.mixed": "مواد مختلفة",
"measure.at": "عند",
"measure.item": "العنصر {number}",
"measure.line_to_centre": "من الخط إلى المركز",
"measure.quick_copied": "تم نسخ القياس",
"measure.quick_copy": "انقر لنسخ القياس",
"measure.quick_edges": "{count} حواف",
@@ -2240,7 +2246,7 @@
"prefs.anti_aliasing_amd_dx12": "يستخدم تنعيم الحواف 1× لأن فحص بدء التشغيل لم يرسم شيئًا مع أخذ العينات المتعددة ونجح عند 1×. يمكنك اختيار إعداد آخر أعلاه؛ أعد التشغيل لتطبيقه إن نجح الفحص.",
"prefs.anti_aliasing_note": "ينعّم الحواف المسننة.",
"prefs.appearance.colour_vision_help": "يغيّر الألوان ذات الدلالة: حالات الرسم والمحاور والثنيات والتحليلات والأيقونات.",
"prefs.appearance.desktop_only": "لسطح المكتب فقط",
"prefs.appearance.desktop_only": "لتطبيق سطح المكتب فقط",
"prefs.appearance.follow_help": "يعيد التحميل عندما تعيد أداة أخرى كتابة الملف.",
"prefs.appearance.interface_scale": "مقياس الواجهة",
"prefs.appearance.next_themes": "السمات التالية",
@@ -2250,11 +2256,11 @@
"prefs.appearance.theme_count": "{count} سمات",
"prefs.appearance.theme_own": "ألوان السمة",
"prefs.appearance.viewport_colours": "ألوان العرض ثلاثي الأبعاد",
"prefs.appearance.viewport_logo": "منفذ العرض والشعار",
"prefs.appearance.viewport_logo": "منطقة العرض والشعار",
"prefs.applies_on_restart": "يُطبَّق عند إعادة التشغيل",
"prefs.assistant": "المساعد",
"prefs.assistant_connecting": "الاتصال",
"prefs.assistant_follow_help": "تحرّك عروض Look At للرسم المنظور مع المؤشر.",
"prefs.assistant_follow_help": "النظر إلى الرسومات يحرّك العرض مع المؤشر.",
"prefs.assistant_limit": "الحد",
"prefs.assistant_limited": "محدود",
"prefs.assistant_pointer_speed": "سرعة المؤشر",
@@ -2262,21 +2268,21 @@
"prefs.assistant_seconds_aria": "الثواني",
"prefs.assistant_use_care": "استخدم بحذر",
"prefs.auto_operation": "اختيار العملية تلقائيًا",
"prefs.auto_operation_note": "ضم أو جسم جديد أو قطع، حسب ما تلامسه المادة.",
"prefs.auto_operation_note": "ضم أو جسم جديد أو قص، حسب ما تلامسه المادة.",
"prefs.axes": "المحاور",
"prefs.axis": "المحور",
"prefs.blur_cost": "تكلفة العرض النسبية. زيادة الأشرطة تعني عادةً مزيدًا من العمل.",
"prefs.blur_device": "غير متاح على جهاز الرسومات أو مسار العرض هذا",
"prefs.blur_device": "غير متاح على جهاز الرسوميات أو مسار العرض هذا",
"prefs.blur_guard": "خُفّضت جودة التمويه للحفاظ على استجابة الإطارات. لم يتغير نصف القطر.",
"prefs.blur_off": "إيقاف",
"prefs.blur_reduce_transparency": "معطّل بسبب إعداد تقليل الشفافية في النظام",
"prefs.blur_software": "غير متاح مع الرسومات البرمجية (gpu-software)",
"prefs.blur_software": "غير متاح مع الرسوميات البرمجية (gpu-software)",
"prefs.blur_source": "غير متاح في عرض مساحة العمل هذا",
"prefs.blur_web": "غير متاح في مسار عرض الويب هذا",
"prefs.bodies_folders": "مجلدات الأجسام",
"prefs.bodies_folders_always": "دائمًا",
"prefs.bodies_folders_never": "أبدًا",
"prefs.bodies_folders_several": "عدة",
"prefs.bodies_folders_several": "عند التعدد",
"prefs.bottom": "أسفل",
"prefs.bottom_centre": "أسفل الوسط",
"prefs.bottom_right": "أسفل اليمين",
@@ -2331,6 +2337,13 @@
"prefs.dialogs_edge": "عند حافة العرض",
"prefs.dialogs_left_column": "في العمود الأيسر",
"prefs.dialogs_note": "مكان فتح حوارات الأوامر: مثبتة عند حافة العرض، أو في العمود الأيسر مكان المستعرض والسجل، اللذين يعودان كما كانا عند إغلاق الحوار.",
"prefs.dimension_step_angle": "ما تضيفه أسهم النافذة وأعلى/أسفل والعجلة إلى الزاوية",
"prefs.dimension_step_length": "ما تضيفه أسهم النافذة وأعلى/أسفل والعجلة إلى الطول",
"prefs.dimension_steps": "خطوات النافذة",
"prefs.dimension_values": "قيم الأبعاد",
"prefs.dimension_values_note": "مكان كتابة قيمة البعد. للنافذة أسهم تغيّرها بخطوات؛ تضع أداة البعد النص أولًا.",
"prefs.dimension_values_view": "في العرض",
"prefs.dimension_values_window": "في نافذة",
"prefs.display": "العرض",
"prefs.edges": "الحواف والتظليل",
"prefs.extend": "توسيع",
@@ -2368,7 +2381,7 @@
"prefs.graphics_note": "وازن بين السرعة والنعومة. كل خيار يعرض أثره على عيّنة.",
"prefs.graphics_page.current_view": "العرض الحالي",
"prefs.graphics_page.fastest": "الأسرع",
"prefs.graphics_page.fold_items": "طي العناصر",
"prefs.graphics_page.fold_items": "تلخيص العناصر",
"prefs.graphics_page.in_view": "داخل العرض",
"prefs.graphics_page.live_details": "تفاصيل مباشرة",
"prefs.graphics_page.own_mix": "مزيجك الخاص",
@@ -2377,7 +2390,7 @@
"prefs.graphics_page.restart": "تلزم إعادة التشغيل",
"prefs.graphics_page.sharpest": "الأكثر وضوحًا",
"prefs.graphics_page.single_surface_analysis": "تحليل سطح واحد",
"prefs.graphics_page.status_chips": "شرائح الحالة",
"prefs.graphics_page.status_chips": "شارات الحالة",
"prefs.graphics_page.subtitle": "وازن بين السرعة والنعومة.",
"prefs.graphics_page.supersampling": "التنعيم الفائق",
"prefs.graphics_page.supersampling_adaptive": "فقط أثناء سكون العرض",
@@ -2424,7 +2437,7 @@
"prefs.layout_page.icons_labels": "الأيقونات + التسميات",
"prefs.layout_page.ok_cancel": "موافق وإلغاء",
"prefs.layout_page.on_hover": "عند مرور المؤشر",
"prefs.layout_page.open": "فتح",
"prefs.layout_page.open": "تُفتح",
"prefs.layout_page.origin_bodies": "الأصل والأجسام في الأعلى",
"prefs.layout_page.panels": "اللوحات",
"prefs.layout_page.pinned_menus": "استبعاد الأوامر المثبتة من القوائم",
@@ -2464,8 +2477,8 @@
"prefs.navigation.cube_compact": "المكعب",
"prefs.navigation.device": "الجهاز",
"prefs.navigation.free": "حر",
"prefs.navigation.orbit_style": "نمط الدوران",
"prefs.navigation.radial_help": "اضغط مطولاً أو انقر سريعاً بالزر الأيمن لعرض حلقة من الأدوات.",
"prefs.navigation.orbit_style": "نمط التدوير",
"prefs.navigation.radial_help": "اضغط مطولًا أو انقر سريعًا بالزر الأيمن لعرض حلقة من الأدوات.",
"prefs.navigation.turntable": "طاولة دوارة",
"prefs.navigation_note": "كيف تحرك الفأرة ولوحة اللمس والفأرة ثلاثية الأبعاد العرض.",
"prefs.navigator_fit": "ملاءمة النموذج بعد تغيير العرض من متصفح العرض",
@@ -2483,7 +2496,7 @@
"prefs.origin_plane_names_note": "الأسماء المعروضة في المستعرض والحوارات والقوائم. {top} هو XY و{front} هو XZ و{right} هو YZ، والمحور Z للأعلى. تحتفظ الأوامر والسكربتات والتصاميم المحفوظة بـ XY وXZ وYZ.",
"prefs.overall_speed": "السرعة العامة",
"prefs.parameter_list_rows": "صفوف قائمة المعاملات",
"prefs.parameter_list_rows_tip": "الحد الأقصى للمعلمات التي يسردها السهم لأسفل في مربع تعبير فارغ.",
"prefs.parameter_list_rows_tip": "الحد الأقصى للمعاملات التي يسردها السهم لأسفل في مربع تعبير فارغ.",
"prefs.persistent_ribbon_pressed": "تمييز أوامر الشريط النشطة",
"prefs.persistent_ribbon_pressed_help": "تمييز أوامر الشريط أثناء تفعيل تأثيراتها. أوقف هذا الخيار للاحتفاظ بالمظهر المحايد السابق ومساعدة الأوامر.",
"prefs.perspective_fov": "مجال الرؤية",
@@ -2552,16 +2565,16 @@
"prefs.shortcuts_import": "استيراد",
"prefs.shortcuts_intro": "مفاتيح كل أمر.",
"prefs.shortcuts_not_assigned": "غير معيّن",
"prefs.shortcuts_press": "اضغط مفتاحاً...",
"prefs.shortcuts_press": "اضغط مفتاحًا...",
"prefs.single_surface_analysis": "استخدام تحليل سطح واحد",
"prefs.single_surface_analysis_help": "عند إيقاف هذا الخيار، يمكن إبقاء تظليل السطح وتحليل الاستمرارية مفعّلين معًا. عند تشغيله، يبقى آخر تحليل تم بدؤه أو تغييره فقط.",
"prefs.sketch.align_points": "المحاذاة مع نقاط الرسم الأخرى",
"prefs.sketch.align_points_note": "تتحاذى الخطوط أثناء الرسم مع النقاط على المحورين معًا.",
"prefs.sketch.auto_dimensions": "إضافة كل الأبعاد للأشكال الجديدة",
"prefs.sketch.auto_dimensions_note": "القيم المدخلة وكل بُعد يثبّت الشكل.",
"prefs.sketch.auto_locate": "التحديد انطلاقًا من أصل الرسم",
"prefs.sketch.auto_locate": "تحديد الموضع من أصل الرسم",
"prefs.sketch.auto_locate_note": "مع إضافة كل الأبعاد.",
"prefs.sketch.center_arc": "قوس المركز يتبع المؤشر",
"prefs.sketch.center_arc": "القوس بنقطة مركز يتبع المؤشر",
"prefs.sketch.click_selects": "النقر يحدد",
"prefs.sketch.conflict_colour": "لون التعارض",
"prefs.sketch.drawing": "الرسم",
@@ -2576,7 +2589,7 @@
"prefs.sketch.prefer_free_points": "تفضيل النقاط القابلة للتحريك عند تداخلها",
"prefs.sketch.project_options": "السؤال عن خيارات الإسقاط أولًا",
"prefs.sketch.recent_snap": "الالتقاط عند تقاطع دليلين",
"prefs.sketch.select_other_hidden": "اختيار آخر يشمل الهندسة المخفية",
"prefs.sketch.select_other_hidden": "تحديد آخر يشمل الهندسة المخفية",
"prefs.sketch.shortcut_cycles_modes": "الاختصار مجددًا يبدّل الوضع",
"prefs.sketch.shortcut_cycles_modes_tip": "عند الإيقاف: تبدأ الأداة مجددًا.",
"prefs.sketch.snapping_guides": "الالتقاط والأدلة",
@@ -2699,7 +2712,7 @@
"prefs.validation.each_step": "بعد كل خطوة",
"prefs.validation.label": "فحص النواة الكامل",
"prefs.validation.off": "متوقف (تجريبي)",
"prefs.validation_note": "بعد كل خطوة: يفحص كل عملية سحب ودمج وتقويس.\nمرة لكل ميزة: يفحص ناتج كل ميزة مرة واحدة.\nمتوقف (تجريبي): لا فحص.",
"prefs.validation_note": "بعد كل خطوة: يفحص كل عملية كنس وعملية منطقية وتقويس.\nمرة لكل ميزة: يفحص ناتج كل ميزة مرة واحدة.\nمتوقف (تجريبي): لا فحص.",
"prefs.value_boxes_in_view": "إظهار القيم بجانب مقابضها",
"prefs.value_boxes_in_view_tip": "مسافة البثق ونصف قطر التدوير... في مربع داخل العرض.",
"prefs.view": "سلوك العرض",
@@ -4214,13 +4227,13 @@
"thread.automatic_size": "حجم تلقائي",
"thread.chamfer_depth_needed": "مطلوب لهذا الشكل",
"thread.designation": "التسمية",
"thread.drill_range": "مثقاب صانع اللقمة {drill} مم. القطر الأصغر القياسي للسن الداخلي {minor} مم. يُقبل مثقاب من {low} إلى {high} مم.",
"thread.drill_range": "مثقاب التسنين الذي يوصي به صانعو ذكور التسنين {drill} مم. القطر الأصغر القياسي للسن الداخلي {minor} مم. يُقبل مثقاب من {low} إلى {high} مم.",
"thread.drill_range_custom": "المثقاب هو القطر الأكبر ناقص الخطوة، {drill} مم. القطر الأصغر القياسي للسن الداخلي {minor} مم. يُقبل مثقاب من {low} إلى {high} مم.",
"thread.drill_range_taper": "مثقاب صانع اللقمة {drill} مم. القطر الأصغر القياسي عند نهاية السن {minor} مم. يُقبل مثقاب من {low} إلى {high} مم.",
"thread.drill_range_taper": "مثقاب التسنين الذي يوصي به صانعو ذكور التسنين {drill} مم. القطر الأصغر القياسي عند نهاية السن {minor} مم. يُقبل مثقاب من {low} إلى {high} مم.",
"thread.end_square": "قائمة",
"thread.fade_short": "1.25 P (ISO 3508)",
"thread.fade_standard": "2.5 P (ISO 3508)",
"thread.fits_the_face": "يناسب الوجه، مثلاً {example}",
"thread.fits_the_face": "يناسب الوجه، مثلًا {example}",
"thread.form_bspp": "سن أنابيب G (ISO 228-1)",
"thread.form_bspt": "سن أنابيب مخروطي R، Rc (ISO 7-1)",
"thread.form_npt": "سن أنابيب مخروطي NPT (ASME B1.20.1)",
@@ -4477,7 +4490,7 @@
"ui.agent_chat_no_images": "هذا الوكيل لا يقبل الصور",
"ui.agent_chat_no_picture": "لا توجد صورة في الحافظة",
"ui.agent_chat_no_tool_call": "لم يستخدم النموذج أدوات SolidCraft، لذا لم يتغيّر التصميم. اختر نموذجًا يدعم استدعاء الأدوات؛ راجع «النماذج المحلية» في دليل محادثة الوكيل.",
"ui.agent_chat_node_too_old": "إصدار Node.js ‏{version} قديم جدًا لهذا الوكيل. ثبّت Node.js 20.10 أو أحدث، ثم اضغط على تحديث.",
"ui.agent_chat_node_too_old": "إصدار Node.js {version} قديم جدًا لهذا الوكيل. ثبّت Node.js 20.10 أو أحدث، ثم اضغط تحديث.",
"ui.agent_chat_none_found": "لم يُعثر على وكيل برمجي. ثبّت أحد هذه ثم اضغط تحديث:",
"ui.agent_chat_none_found_short": "لم يُعثر على وكيل",
"ui.agent_chat_not_installed": "غير مثبت",
@@ -4518,7 +4531,7 @@
"ui.agent_chat_send_now_tip": "إيقاف خطوة الوكيل الحالية وإرسال هذه بعدها (Ctrl+Enter)",
"ui.agent_chat_setup_body": "يتواصل SolidCraft مع وكيل برمجة مثبّت على هذا الحاسوب. اختر واحدًا من التفضيلات › الوكيل.",
"ui.agent_chat_setup_title": "إعداد مساعد",
"ui.agent_chat_side": "إرساء المحادثة",
"ui.agent_chat_side": "تثبيت المحادثة",
"ui.agent_chat_signed_in": "سجّل الدخول",
"ui.agent_chat_state_auth": "يلزم تسجيل الدخول",
"ui.agent_chat_state_busy": "جارٍ العمل…",
@@ -4583,10 +4596,10 @@
"ui.ai_key_note": "يُخزّن في سلسلة مفاتيح النظام، وليس في إعدادات SolidCraft أبدًا. اتركه فارغًا لخادم محلي.",
"ui.ai_keychain_error": "رفضت سلسلة مفاتيح النظام: {error}",
"ui.ai_kind": "النوع",
"ui.ai_mode_all": "فعل كل شيء",
"ui.ai_mode_all": "تنفيذ كل شيء",
"ui.ai_mode_all_note": "لا شيء يسأل أولًا. تبقى تقارير الأعطال محظورة وتظل حدود المجلدات سارية.",
"ui.ai_mode_all_warning": "يمكن للوكيل حفظ تصميمك والكتابة فوقه وتصديره واستبداله دون سؤال. استخدم هذا فقط مع وكيل تثق به، وعلى تصاميم حفظتها.",
"ui.ai_mode_ask": "السؤال أولاً",
"ui.ai_mode_ask": "السؤال أولًا",
"ui.ai_mode_ask_note": "كل تغيير في التصميم وكل إجراء على الملفات ينتظر سماحك.",
"ui.ai_mode_edits": "إجراء تعديلات",
"ui.ai_mode_edits_note": "أوامر النمذجة تعمل دون سؤال؛ أما الحفظ والفتح والتصدير وإجراءات الملفات أو الإجراءات الهدّامة الأخرى فتسأل.",
@@ -4757,21 +4770,21 @@
"ui.construction.ribbon_label": "خطوط مساعدة",
"ui.construction.side_hint": "خط إنشائي: انقر الجهة التي يمتد نحوها الخط؛ انقر بزر الفأرة الأيمن للبدء من جديد",
"ui.context_menu": "القائمة السياقية",
"ui.continuity_crease": "تجعّد",
"ui.continuity_crease": "ثنية",
"ui.continuity_curvature": "الانحناء",
"ui.continuity_curvature_change": "تغيّر الانحناء",
"ui.continuity_curvature_change_tip": "تغيّر الانحناء (G3): يتغير الانحناء أيضًا بسلاسة عبر خط الالتقاء.",
"ui.continuity_curvature_tip": "متصل الانحناء (G2): تمر الانعكاسات دون انكسار.",
"ui.continuity_g0": "G0 تجعّد",
"ui.continuity_g0": "G0 ثنية",
"ui.continuity_g1": "G1 مماس",
"ui.continuity_g2": "G2 انحناء",
"ui.continuity_gap": "فجوة",
"ui.continuity_position": "الموضع",
"ui.continuity_position_tip": "الموضع (G0): تلتقي الأسطح، مع تجعّد عند التقائها.",
"ui.continuity_position_tip": "الموضع (G0): تلتقي الأسطح، مع ثنية عند التقائها.",
"ui.continuity_smooth_curvature_change": "تغيّر انحناء سلس",
"ui.continuity_smooth_curvature_change_tip": "تغيّر انحناء سلس (G4): حتى طريقة تغيّر الانحناء تستمر بسلاسة عبر خط الالتقاء.",
"ui.continuity_tangent": "مماس",
"ui.continuity_tangent_tip": "مماس (G1): لا تجعّد، لكن الانحناء يقفز عبر خط الالتقاء.",
"ui.continuity_tangent_tip": "مماس (G1): لا ثنية، لكن الانحناء يقفز عبر خط الالتقاء.",
"ui.copy": "نسخ",
"ui.copy_details": "نسخ التفاصيل",
"ui.copy_details_tip": "انسخ الرسالة كاملة للبحث عنها أو للإبلاغ عنها.",
@@ -4797,6 +4810,7 @@
"ui.default_modelling_orientation": "اتجاه النمذجة الافتراضي",
"ui.delete": "حذف",
"ui.delete_select_first": "حدّد أولًا ما تريد حذفه: الميزات أو الأجسام أو المكوّنات أو هندسة الرسم",
"ui.dependents_select_first": "حدّد أولًا ما تريد سرد معتمداته",
"ui.depth": "العمق",
"ui.design": "التصميم",
"ui.design_properties.author": "المؤلف",
@@ -4922,7 +4936,7 @@
"ui.extract_done": "أصبح المكوّن تصميمًا مستقلًا، مرتبطًا هنا.",
"ui.extrude": "بثق",
"ui.extrude_auto_to_object": "البثق إلى هدف مختار",
"ui.extrude_auto_to_object_tip": "وجه أو رأس أو مستوى موازٍ يغيّر المدى إلى «إلى كائن».",
"ui.extrude_auto_to_object_tip": "وجه أو رأس أو مستوى موازٍ يغيّر الامتداد إلى «إلى كائن».",
"ui.factor": "المعامل",
"ui.fade": "تلاشٍ",
"ui.fade_length": "طول التلاشي",
@@ -4952,13 +4966,13 @@
"ui.fillet_setback": "مسافة الإرجاع",
"ui.fillet_setback_hint": "{factor} × نصف القطر",
"ui.fillet_setback_least": "أكثر من {least}",
"ui.fillet_setback_tip": "حيث تبدأ رقعة الزاوية الناعمة: المسافة على طول كل حافة من الزاوية. الافتراضي {factor} ضعف نصف القطر. يجب أن تكون أكبر من أقل مسافة إرجاع، حيث تلتقي الاستدارات بدونها: نصف القطر حيث تكون الأوجه متعامدة.",
"ui.fillet_setback_tip": "حيث تبدأ رقعة الزاوية الناعمة: المسافة على طول كل حافة من الزاوية. الافتراضي {factor} ضعف نصف القطر. يجب أن تكون أكبر من أقل مسافة إرجاع، وهي التي تلتقي عندها التقويسات دون إرجاع: نصف القطر حين تكون الأوجه متعامدة.",
"ui.fillet_smooth_radius": "تغيّر ناعم لنصف القطر",
"ui.fillet_turn": "المنعطفات",
"ui.fillet_turn": "الالتفاف",
"ui.fillet_turn_mitre": "وصلة مائلة",
"ui.fillet_turn_swept": "مُدار",
"ui.fillet_turn_tip": "حيث يلتف تقويسان حول حافة ركن حادة بينهما، مثل التقويسات على قاعدة بروز عند حوافه القائمة.\nوصلة مائلة: يمتد التقويسان حتى يلتقيا في وصلة مائلة.\nمُدار: يتوقف التقويسان عند حافة الركن ويلتفان حولها، والركن طارة مماسة لكليهما. فقط لتقويسين بنصف قطر واحد حول حافة ركن مستقيمة ومحدبة.",
"ui.fillet_type_choices_tip": "نصف قطر ثابت: نصف قطر واحد على طول الحافة.\nطول الوتر: يبقى عرض التقويس نفسه مهما كانت الزاوية بين الوجوه.\nنصف قطر متغيّر: نصف قطر عند كل طرف، ومزيد على طول الحافة إن أضفته.\nمتصل الانحناء: مقطع بلا انكسار في الانعكاسات حيث يلتقي بالوجوه المستوية (G2).\nمنحنى: يتبع نصف القطر على طول الحافة أنصاف أقطار تحكّم تحدّدها.",
"ui.fillet_turn_swept": "ملتف",
"ui.fillet_turn_tip": "حيث يلتف تقويسان حول حافة ركن حادة بينهما، مثل التقويسات على قاعدة نتوء عند حوافه القائمة.\nوصلة مائلة: يمتد التقويسان حتى يلتقيا في وصلة مائلة.\nملتف: يتوقف التقويسان عند حافة الركن ويلتفان حولها، والركن طارة مماسة لكليهما. فقط لتقويسين بنصف قطر واحد حول حافة ركن مستقيمة ومحدبة.",
"ui.fillet_type_choices_tip": "نصف قطر ثابت: نصف قطر واحد على طول الحافة.\nطول الوتر: يبقى عرض التقويس نفسه مهما كانت الزاوية بين الوجوه.\nنصف قطر متغيّر: نصف قطر عند كل طرف، ومزيد على طول الحافة إن أضفته.\nمتصل الانحناء: مقطع بلا انكسار في الانعكاسات حيث يلتقي بالوجوه المستوية (G2).\nمنحنى شريحي: يتبع نصف القطر على طول الحافة أنصاف أقطار تحكّم تحدّدها.",
"ui.fillet_type_curvature": "متصل الانحناء",
"ui.fillet_type_tip": "المقطع عبر الحافة. متصل الانحناء: مقطع متصل الانحناء (G2) بين وجوه مستوية، فتمر الانعكاسات من الوجوه إلى التقويس دون انكسار.",
"ui.filter_commands_or_keys": "تصفية الأوامر أو المفاتيح",
@@ -5070,7 +5084,7 @@
"ui.hidePlanes": "إخفاء المستويات",
"ui.hideProfile": "إخفاء المقطع",
"ui.hide_component_history": "إخفاء سجله",
"ui.hide_origin_by_default": "إخفاء مجلد الأصل في التصاميم الجديدة",
"ui.hide_origin_by_default": "إخفاء مجلد نقطة الأصل في التصاميم الجديدة",
"ui.hide_origin_note": "تبدأ التصاميم الجديدة ومجلد نقطة الأصل مخفي. يحتفظ كل تصميم بإعداد نقطة الأصل الخاص به بمجرد إظهارها أو إخفائها.",
"ui.history_back": "الرجوع خطوة",
"ui.history_below_browser": "أسفل المستعرض",
+13
View File
@@ -1391,6 +1391,9 @@
"dialog.custom_axis": "↖ Eigene Achse",
"dialog.dimension_insert_hint": "Doppelklicken Sie auf eine andere steuernde Bemaßung, um ihren Namen einzufügen",
"dialog.dimension_name": "Bemaßung: {name}",
"dialog.dimension_step_down": "Um {step} verringern",
"dialog.dimension_step_expression": "Nur eine Zahl lässt sich schrittweise ändern; ein Ausdruck bleibt, wie er eingegeben ist",
"dialog.dimension_step_up": "Um {step} erhöhen",
"dialog.edit_title": "{title} bearbeiten",
"dialog.no_shown_body": "Kein sichtbarer Körper zum Ändern vorhanden. Blenden Sie einen Körper ein oder wählen Sie „Neuer Körper“.",
"dialog.pattern_sketch_not_object": "Körper oder Funktionen wählen: Eine Skizze ist keines von beiden. Ihre Kurven mit Muster oder Spiegeln in der Skizze wiederholen oder sie zuerst extrudieren.",
@@ -1415,6 +1418,7 @@
"document.read_only_save": "Dieses Design wurde schreibgeschützt geöffnet: Speichern Sie es als neue Datei",
"document.save_changes": "Änderungen in {name} speichern?",
"document.save_copy": "Kopie speichern…",
"document.tab_unsaved_changes": "Ungespeicherte Änderungen",
"document.unsaved_changes": "{name} enthält ungespeicherte Änderungen. Wenn Sie nicht speichern, gehen sie verloren.",
"drive.rigid": "{joint} ist ein starres Gelenk. Es hält die verbundenen Teile fest zusammen und ermöglicht daher keine Bewegung. Um die Teile beweglich zu machen, ändern Sie den Gelenktyp unter „Gelenk bearbeiten“: Drehgelenk für Drehbewegungen, Schubgelenk für lineare Bewegungen oder Zylindergelenk für beides.",
"drive.stopped_by_contact": "Angehalten bei {value}: {a} würde {b} durchdringen",
@@ -1982,6 +1986,7 @@
"material.selection.mixed": "Unterschiedliche Materialien",
"measure.at": "Bei",
"measure.item": "Element {number}",
"measure.line_to_centre": "Linie zum Mittelpunkt",
"measure.quick_copied": "Messwert kopiert",
"measure.quick_copy": "Klicken, um den Messwert zu kopieren",
"measure.quick_edges": "{count} Kanten",
@@ -2314,6 +2319,13 @@
"prefs.dialogs_edge": "Am Rand der Ansicht",
"prefs.dialogs_left_column": "In der linken Spalte",
"prefs.dialogs_note": "Wo Befehlsdialoge öffnen: am Rand der Ansicht angedockt oder in der linken Spalte anstelle von Browser und Verlauf, die beim Schließen des Dialogs wie zuvor zurückkehren.",
"prefs.dimension_step_angle": "Was die Pfeile des Fensters, Auf/Ab und das Mausrad zu einem Winkel addieren",
"prefs.dimension_step_length": "Was die Pfeile des Fensters, Auf/Ab und das Mausrad zu einer Länge addieren",
"prefs.dimension_steps": "Schritte im Fenster",
"prefs.dimension_values": "Bemaßungswerte",
"prefs.dimension_values_note": "Wo der Wert einer Bemaßung eingegeben wird. Ein Fenster hat Pfeile, die ihn schrittweise ändern; das Bemaßungswerkzeug platziert zuerst den Text.",
"prefs.dimension_values_view": "In der Ansicht",
"prefs.dimension_values_window": "In einem Fenster",
"prefs.display": "Anzeige",
"prefs.edges": "Kanten und Schattierung",
"prefs.extend": "Erweitern",
@@ -4772,6 +4784,7 @@
"ui.default_modelling_orientation": "Standard-Modellierungsorientierung",
"ui.delete": "Löschen",
"ui.delete_select_first": "Wählen Sie zuerst aus, was gelöscht werden soll: Features, Körper, Komponenten oder Skizzengeometrie",
"ui.dependents_select_first": "Wählen Sie zuerst aus, wessen Abhängige aufgelistet werden sollen",
"ui.depth": "Tiefe",
"ui.design": "Design",
"ui.design_properties.author": "Autor",
+1
View File
@@ -69,6 +69,7 @@
"help.solid.split_face": "Divides the selected faces along a plane, the face of another body or sketch curves, without cutting the body. Use it to give part of a face its own draft, colour or offset.",
"help.third_party": "Third-party licences",
"home.new_mm_hint": "Millimetres · opens in Design",
"measure.line_to_centre": "Line to centre",
"navbar.custom.anchor.bottom_centre": "Bottom centre",
"navbar.custom.anchor.top_centre": "Top centre",
"navbar.custom.customise": "Customise navigation bar…",
+15 -1
View File
@@ -1396,6 +1396,9 @@
"dialog.custom_axis": "↖ Custom Axis",
"dialog.dimension_insert_hint": "Click another driving dimension to insert its name",
"dialog.dimension_name": "Dimension: {name}",
"dialog.dimension_step_down": "Down by {step}",
"dialog.dimension_step_expression": "Only a number steps; an expression stays as typed",
"dialog.dimension_step_up": "Up by {step}",
"dialog.edit_title": "EDIT {title}",
"dialog.no_shown_body": "No shown body to change: show one, or choose New Body",
"dialog.pattern_sketch_not_object": "Pick bodies or features: a sketch is neither. Repeat its curves with the sketch's own Pattern or Mirror, or extrude it first.",
@@ -1403,6 +1406,7 @@
"dialog.selected_count": "↖ {count} selected",
"dialog.selection_constraint": "Constraint {id}",
"dialog.selection_external_curve": "{sketch} · curve {id}",
"dialog.selection_external_point": "{point} · {sketch}",
"dialog.selection_guidance": "Select an input to change it. Click selected objects to remove them; Ctrl/Cmd-click also toggles them. Review the preview, then press OK.",
"dialog.selection_midpoint": "Midpoint · {id}",
"dialog.selection_profile": "{sketch} · profile {number}",
@@ -1420,6 +1424,7 @@
"document.read_only_save": "This design was opened read-only: save it as a new file",
"document.save_changes": "Save changes to {name}?",
"document.save_copy": "Save a Copy…",
"document.tab_unsaved_changes": "Unsaved changes",
"document.unsaved_changes": "{name} has unsaved changes. If you don't save, they are lost.",
"drive.rigid": "{joint} is a rigid joint: it holds its parts still, so it has nothing to drive. To let them move, change its type in Edit Joint: Revolute to rotate, Slider to slide, Cylindrical for both.",
"drive.stopped_by_contact": "Stopped at {value}: {a} would pass through {b}",
@@ -1600,7 +1605,7 @@
"help.parts.resize": "Changes the size or length of an inserted standard part and rebuilds it; its joints keep it seated. Use it when a screw turns out too long or a hole needs another size.",
"help.plastic.assign_rule": "Assigns a plastic rule to bodies, so their plastic features take its draft and clearance as defaults. Use it to set up a moulded part once for all its features.",
"help.plastic.boss": "Adds a screw boss on a face: a post with an optional hole, draft, root fillet and supporting ribs. Use it for screw points in moulded or printed housings.",
"help.plastic.lip": "Adds a lip or a matching groove along the rim of a shelled body. Use it so that two housing halves locate on each other and close tightly.",
"help.plastic.lip": "Adds a lip or a matching groove along the rim of a shelled body (Shell a face open first, then pick the rim around that opening). Use it so that two housing halves locate on each other and close tightly.",
"help.plastic.manage_rules": "Creates and edits plastic rules: material, wall thickness, radii, clearance and draft. Use them to keep the features of a moulded part consistent.",
"help.plastic.rest": "Adds a rest on a face: a round or rectangular pad, solid or hollow, with draft. Use it as a support or standoff in a moulded housing.",
"help.plastic.snap_fit": "Adds a snap-fit hook on a face, with its length, thickness and catch. Use it to clip parts together without screws.",
@@ -1999,6 +2004,7 @@
"material.selection.mixed": "Different materials",
"measure.at": "At",
"measure.item": "Item {number}",
"measure.line_to_centre": "Line to center",
"measure.quick_copied": "Measurement copied",
"measure.quick_copy": "Click to copy the measurement",
"measure.quick_edges": "{count} edges",
@@ -2331,6 +2337,13 @@
"prefs.dialogs_edge": "At the view's edge",
"prefs.dialogs_left_column": "In the left column",
"prefs.dialogs_note": "Where command dialogs open: docked at the view's edge, or in the left column in place of the browser and the History, which come back as they were when the dialog closes.",
"prefs.dimension_step_angle": "What the window's arrows, Up/Down and the wheel add to an angle",
"prefs.dimension_step_length": "What the window's arrows, Up/Down and the wheel add to a length",
"prefs.dimension_steps": "Window steps",
"prefs.dimension_values": "Dimension values",
"prefs.dimension_values_note": "Where you type a dimension's value. A window has arrows that step it; the Dimension tool places the text first.",
"prefs.dimension_values_view": "In the view",
"prefs.dimension_values_window": "In a window",
"prefs.display": "Display",
"prefs.edges": "Edges and shading",
"prefs.extend": "Extend",
@@ -4797,6 +4810,7 @@
"ui.default_modelling_orientation": "Default modeling orientation",
"ui.delete": "Delete",
"ui.delete_select_first": "Select what to delete first: features, bodies, components or sketch geometry",
"ui.dependents_select_first": "Select what to list the dependents of first",
"ui.depth": "Depth",
"ui.design": "Design",
"ui.design_properties.author": "Author",
+1
View File
@@ -1358,6 +1358,7 @@
"document.read_only_save": "Este diseño se abrió en solo lectura: guárdalo como un archivo nuevo",
"document.save_changes": "¿Guardar los cambios de {name}?",
"document.save_copy": "Guardar una copia…",
"document.tab_unsaved_changes": "Cambios sin guardar",
"document.unsaved_changes": "{name} tiene cambios sin guardar. Si no los guardas, se perderán.",
"drive.rigid": "{joint} es una junta rígida: mantiene sus partes quietas, así que no tiene nada que impulsar.",
"drive.stopped_by_contact": "Detenido en {value}: {a} atravesaría {b}",
+1
View File
@@ -1391,6 +1391,7 @@
"document.read_only_save": "Cette conception a été ouverte en lecture seule : enregistrez-la dans un nouveau fichier",
"document.save_changes": "Enregistrer les modifications de {name} ?",
"document.save_copy": "Enregistrer une copie…",
"document.tab_unsaved_changes": "Modifications non enregistrées",
"document.unsaved_changes": "{name} contient des modifications non enregistrées. Si vous n'enregistrez pas, elles seront perdues.",
"drive.rigid": "{joint} est une liaison rigide : elle maintient ses pièces immobiles, il n'y a donc rien à piloter. Pour les laisser bouger, changer son type dans Modifier la liaison : Revolute pour tourner, Slider pour coulisser, Cylindrical pour les deux.",
"drive.stopped_by_contact": "Arrêté à {value} : {a} traverserait {b}",
+14
View File
@@ -1396,6 +1396,9 @@
"dialog.custom_axis": "↖ ציר מותאם",
"dialog.dimension_insert_hint": "יש ללחוץ על מידה מניעה אחרת כדי להוסיף את שמה",
"dialog.dimension_name": "מידה: {name}",
"dialog.dimension_step_down": "הפחתה ב־{step}",
"dialog.dimension_step_expression": "רק מספר משתנה בצעדים; ביטוי נשאר כפי שנכתב",
"dialog.dimension_step_up": "הוספה של {step}",
"dialog.edit_title": "עריכת {title}",
"dialog.no_shown_body": "אין גוף מוצג לשינוי: הציגו גוף, או בחרו „גוף חדש”",
"dialog.pattern_sketch_not_object": "בחרו גופים או פיצ'רים: סקיצה אינה אף אחד מהם. חזרו על העקומות שלה בעזרת המערך או השיקוף של הסקיצה עצמה, או בצעו לה שליפה קודם.",
@@ -1403,6 +1406,7 @@
"dialog.selected_count": "↖ {count} נבחרו",
"dialog.selection_constraint": "אילוץ {id}",
"dialog.selection_external_curve": "{sketch} · עקומה {id}",
"dialog.selection_external_point": "{point} · {sketch}",
"dialog.selection_guidance": "יש לבחור קלט כדי לשנות אותו. לחיצה על אובייקט שנבחר מסירה אותו; גם Ctrl/Cmd+לחיצה מחליפה את מצבו. לאחר בדיקת התצוגה המקדימה יש ללחוץ על אישור.",
"dialog.selection_midpoint": "נקודת אמצע · {id}",
"dialog.selection_profile": "{sketch} · פרופיל {number}",
@@ -1420,6 +1424,7 @@
"document.read_only_save": "התכן הזה נפתח לקריאה בלבד: יש לשמור אותו כקובץ חדש",
"document.save_changes": "לשמור את השינויים ב־{name}?",
"document.save_copy": "שמירת עותק…",
"document.tab_unsaved_changes": "שינויים שלא נשמרו",
"document.unsaved_changes": "ב־{name} יש שינויים שלא נשמרו. ללא שמירה, הם יאבדו.",
"drive.rigid": "{joint} הוא מפרק קשיח: הוא מחזיק את החלקים שלו במקומם, ולכן אין מה להניע. כדי שהחלקים יזוזו, שנו את הסוג שלו ב״עריכת מפרק״: Revolute לסיבוב, Slider להחלקה, Cylindrical לשניהם.",
"drive.stopped_by_contact": "נעצר ב־{value}: {a} היה עובר דרך {b}",
@@ -1999,6 +2004,7 @@
"material.selection.mixed": "חומרים שונים",
"measure.at": "מיקום",
"measure.item": "פריט {number}",
"measure.line_to_centre": "מהקו למרכז",
"measure.quick_copied": "המדידה הועתקה",
"measure.quick_copy": "לחיצה מעתיקה את המדידה",
"measure.quick_edges": "{count} מקצועות",
@@ -2331,6 +2337,13 @@
"prefs.dialogs_edge": "בקצה התצוגה",
"prefs.dialogs_left_column": "בעמודה השמאלית",
"prefs.dialogs_note": "היכן נפתחות תיבות הדו־שיח של הפקודות: מעוגנות בקצה התצוגה, או בעמודה השמאלית במקום הדפדפן וההיסטוריה, שחוזרים כפי שהיו כשהתיבה נסגרת.",
"prefs.dimension_step_angle": "מה שהחצים בחלון, למעלה/למטה והגלגלת מוסיפים לזווית",
"prefs.dimension_step_length": "מה שהחצים בחלון, למעלה/למטה והגלגלת מוסיפים לאורך",
"prefs.dimension_steps": "צעדי החלון",
"prefs.dimension_values": "ערכי מידות",
"prefs.dimension_values_note": "היכן מקלידים את ערך המידה. לחלון יש חצים שמשנים אותו בצעדים; כלי המידה מציב קודם את הטקסט.",
"prefs.dimension_values_view": "בתצוגה",
"prefs.dimension_values_window": "בחלון",
"prefs.display": "תצוגה",
"prefs.edges": "מקצועות והצללה",
"prefs.extend": "הרחבה",
@@ -4797,6 +4810,7 @@
"ui.default_modelling_orientation": "כיוון מידול ברירת מחדל",
"ui.delete": "מחיקה",
"ui.delete_select_first": "בחרו קודם מה למחוק: תכונות, גופים, רכיבים או גאומטריית סקיצה",
"ui.dependents_select_first": "בחרו קודם מה להציג את התלויים שלו",
"ui.depth": "עומק",
"ui.design": "תכן",
"ui.design_properties.author": "מחבר",
+1
View File
@@ -1392,6 +1392,7 @@
"document.read_only_save": "Questa progettazione è stata aperta in sola lettura: salvala come nuovo file",
"document.save_changes": "Salvare le modifiche a {name}?",
"document.save_copy": "Salva una copia…",
"document.tab_unsaved_changes": "Modifiche non salvate",
"document.unsaved_changes": "{name} contiene modifiche non salvate. Se non salvi, andranno perse.",
"drive.rigid": "{joint} è un giunto rigido: tiene ferme le sue parti, quindi non c'è nulla da azionare. Per farle muovere, cambia il suo tipo in Modifica giunto: Rotante per ruotare, Scorrevole per scorrere, Cilindrico per entrambi.",
"drive.stopped_by_contact": "Arrestato a {value}: {a} attraverserebbe {b}",
+14
View File
@@ -1396,6 +1396,9 @@
"dialog.custom_axis": "↖ カスタム軸",
"dialog.dimension_insert_hint": "別の駆動寸法をクリックして名前を挿入",
"dialog.dimension_name": "寸法: {name}",
"dialog.dimension_step_down": "{step} 減らす",
"dialog.dimension_step_expression": "増減できるのは数値だけです。式はそのまま残ります",
"dialog.dimension_step_up": "{step} 増やす",
"dialog.edit_title": "{title}を編集",
"dialog.no_shown_body": "変更できる表示中のボディがありません。ボディを表示するか、新規ボディを選択してください",
"dialog.pattern_sketch_not_object": "ボディかフィーチャーを選んでください。スケッチはどちらでもありません。曲線はスケッチ内のパターンかミラーで繰り返すか、先に押し出してください。",
@@ -1403,6 +1406,7 @@
"dialog.selected_count": "↖ {count} 個選択中",
"dialog.selection_constraint": "拘束 {id}",
"dialog.selection_external_curve": "{sketch} · 曲線 {id}",
"dialog.selection_external_point": "{point} · {sketch}",
"dialog.selection_guidance": "入力欄を選んで変更します。選択済みのオブジェクトをクリックすると解除できます。Ctrl/Cmd を押しながらクリックしても切り替えられます。プレビューを確認してから OK を押してください。",
"dialog.selection_midpoint": "中点 · {id}",
"dialog.selection_profile": "{sketch} · プロファイル {number}",
@@ -1420,6 +1424,7 @@
"document.read_only_save": "このデザインは読み取り専用で開かれています:新しいファイルとして保存してください",
"document.save_changes": "{name} への変更を保存しますか?",
"document.save_copy": "コピーを保存…",
"document.tab_unsaved_changes": "未保存の変更",
"document.unsaved_changes": "{name} には保存されていない変更があります。保存しない場合、変更内容は失われます。",
"drive.rigid": "{joint} は剛体ジョイントです。部品をその場に固定するため、駆動するものがありません。動かすには「ジョイントを編集」で種類を変更してください: 回転させるなら Revolute、スライドさせるなら Slider、両方なら Cylindrical。",
"drive.stopped_by_contact": "{value} で停止: {a} が {b} を通り抜けてしまいます",
@@ -1999,6 +2004,7 @@
"material.selection.mixed": "異なる材料",
"measure.at": "位置",
"measure.item": "項目 {number}",
"measure.line_to_centre": "線から中心まで",
"measure.quick_copied": "測定値をコピーしました",
"measure.quick_copy": "クリックで測定値をコピー",
"measure.quick_edges": "エッジ {count} 本",
@@ -2331,6 +2337,13 @@
"prefs.dialogs_edge": "ビューの端",
"prefs.dialogs_left_column": "左の列",
"prefs.dialogs_note": "コマンドダイアログを開く場所:ビューの端にドッキングするか、左の列でブラウザーと履歴の代わりに表示します。ダイアログを閉じるとブラウザーと履歴は元どおりに戻ります。",
"prefs.dimension_step_angle": "ウィンドウの矢印、上下キー、ホイールで角度に加える量",
"prefs.dimension_step_length": "ウィンドウの矢印、上下キー、ホイールで長さに加える量",
"prefs.dimension_steps": "ウィンドウの増減量",
"prefs.dimension_values": "寸法値の入力",
"prefs.dimension_values_note": "寸法値を入力する場所です。ウィンドウでは矢印で値を増減でき、寸法ツールは先に文字を配置します。",
"prefs.dimension_values_view": "ビュー内",
"prefs.dimension_values_window": "ウィンドウ",
"prefs.display": "表示",
"prefs.edges": "エッジと陰影",
"prefs.extend": "拡張",
@@ -4797,6 +4810,7 @@
"ui.default_modelling_orientation": "モデリングのデフォルト方向",
"ui.delete": "削除",
"ui.delete_select_first": "先に削除するものを選択してください: フィーチャ、ボディ、コンポーネント、スケッチ ジオメトリ",
"ui.dependents_select_first": "先に、依存先を表示する対象を選択してください",
"ui.depth": "深さ",
"ui.design": "デザイン",
"ui.design_properties.author": "作成者",
File diff suppressed because it is too large Load Diff
+12
View File
@@ -1207,6 +1207,9 @@
"dialog.custom_axis": "↖ 사용자 정의 축",
"dialog.dimension_insert_hint": "다른 구동 치수를 두 번 클릭하여 이름 삽입",
"dialog.dimension_name": "치수: {name}",
"dialog.dimension_step_down": "{step} 줄이기",
"dialog.dimension_step_expression": "숫자만 증감할 수 있으며 수식은 입력한 그대로 유지됩니다",
"dialog.dimension_step_up": "{step} 늘리기",
"dialog.edit_title": "{title} 편집",
"dialog.pattern_sketch_not_object": "바디나 피처를 선택하세요. 스케치는 둘 다 아닙니다. 곡선은 스케치 안의 패턴이나 대칭 복사로 반복하거나, 먼저 돌출하세요.",
"dialog.select": "↖ 선택",
@@ -1431,6 +1434,7 @@
"material.selection.mixed": "서로 다른 재질",
"measure.at": "위치",
"measure.item": "항목 {number}",
"measure.line_to_centre": "선에서 중심까지",
"messages.ago_h": "{n}시간 전",
"messages.ago_min": "{n}분 전",
"messages.ago_s": "{n}초 전",
@@ -1720,6 +1724,13 @@
"prefs.dialogs_edge": "뷰 가장자리",
"prefs.dialogs_left_column": "왼쪽 열",
"prefs.dialogs_note": "명령 대화 상자가 열리는 위치: 뷰 가장자리에 고정하거나, 왼쪽 열에서 브라우저와 이력 대신 표시합니다. 대화 상자를 닫으면 브라우저와 이력이 그대로 돌아옵니다.",
"prefs.dimension_step_angle": "창의 화살표, 위/아래 키와 휠이 각도에 더하는 값",
"prefs.dimension_step_length": "창의 화살표, 위/아래 키와 휠이 길이에 더하는 값",
"prefs.dimension_steps": "창의 증감 단위",
"prefs.dimension_values": "치수 값",
"prefs.dimension_values_note": "치수 값을 입력하는 위치입니다. 창에는 값을 증감하는 화살표가 있으며, 치수 도구는 텍스트를 먼저 배치합니다.",
"prefs.dimension_values_view": "뷰에서",
"prefs.dimension_values_window": "창에서",
"prefs.display": "표시",
"prefs.edges": "모서리 및 음영",
"prefs.extend": "확장",
@@ -3575,6 +3586,7 @@
"ui.default_modelling_orientation": "기본 모델링 방향",
"ui.delete": "삭제",
"ui.delete_select_first": "먼저 삭제할 항목을 선택하세요: 피처, 바디, 컴포넌트 또는 스케치 형상",
"ui.dependents_select_first": "먼저 종속 항목을 표시할 대상을 선택하세요",
"ui.depth": "깊이",
"ui.design": "디자인",
"ui.design_properties.author": "작성자",
+11
View File
@@ -1368,6 +1368,9 @@
"dialog.custom_axis": "↖ Aangepaste as",
"dialog.dimension_insert_hint": "Klik op een andere aandrijvende maat om de naam ervan in te voegen",
"dialog.dimension_name": "Maat: {name}",
"dialog.dimension_step_down": "{step} omlaag",
"dialog.dimension_step_expression": "Alleen een getal verspringt; een uitdrukking blijft zoals ingevoerd",
"dialog.dimension_step_up": "{step} omhoog",
"dialog.edit_title": "{title} BEWERKEN",
"dialog.no_shown_body": "Geen zichtbaar lichaam om te wijzigen: toon er een of kies Nieuw lichaam",
"dialog.pattern_sketch_not_object": "Kies lichamen of kenmerken: een schets is geen van beide. Herhaal de curven ervan met het eigen Patroon of Spiegelen van de schets, of extrudeer de schets eerst.",
@@ -1392,6 +1395,7 @@
"document.read_only_save": "Dit ontwerp is alleen-lezen geopend: sla het op als nieuw bestand",
"document.save_changes": "Wijzigingen in {name} opslaan?",
"document.save_copy": "Kopie opslaan…",
"document.tab_unsaved_changes": "Niet-opgeslagen wijzigingen",
"document.unsaved_changes": "{name} heeft niet-opgeslagen wijzigingen. Als u niet opslaat, gaan ze verloren.",
"drive.rigid": "{joint} is een starre verbinding: die houdt zijn onderdelen vast en heeft dus niets om aan te sturen. Wijzig het type in Verbinding bewerken om ze te laten bewegen: Draaiend om te draaien, Schuivend om te schuiven, Cilindrisch voor beide.",
"drive.stopped_by_contact": "Gestopt bij {value}: {a} zou door {b} heen gaan",
@@ -2194,6 +2198,13 @@
"prefs.dialogs_edge": "Aan de rand van de weergave",
"prefs.dialogs_left_column": "In de linkerkolom",
"prefs.dialogs_note": "Waar opdrachtdialogen openen: vastgezet aan de rand van de weergave, of in de linkerkolom in plaats van de browser en de geschiedenis, die terugkomen zoals ze waren wanneer de dialoog sluit.",
"prefs.dimension_step_angle": "Wat de pijlen van het venster, Omhoog/Omlaag en het wieltje bij een hoek optellen",
"prefs.dimension_step_length": "Wat de pijlen van het venster, Omhoog/Omlaag en het wieltje bij een lengte optellen",
"prefs.dimension_steps": "Stappen in het venster",
"prefs.dimension_values": "Maatwaarden",
"prefs.dimension_values_note": "Waar de waarde van een maat wordt ingevoerd. Een venster heeft pijlen die de waarde laten verspringen; het maatgereedschap plaatst eerst de tekst.",
"prefs.dimension_values_view": "In de weergave",
"prefs.dimension_values_window": "In een venster",
"prefs.display": "Weergave",
"prefs.edges": "Randen en schaduwing",
"prefs.extend": "Verlengen",
@@ -0,0 +1,86 @@
{
"ar": {
"settings.graphics_api_env": "يحدده متغير البيئة WGPU_BACKEND ({value})",
"settings.graphics_api_freezes": "على بطاقات AMD الرسومية المدمجة، قد تؤدي {api} إلى تجمد الشاشة، وأحيانًا حتى إعادة تشغيل الكمبيوتر. يستخدم الخيار التلقائي Vulkan هنا.",
"settings.graphics_applies_on_restart": "تُطبّق هذه الإعدادات عند تشغيل SolidCraft مجددًا. سيسألك حينها إن كنت تريد الاحتفاظ بها، ويعود إلى الإعدادات الحالية إن لم توافق.",
"settings.graphics_msaa": "تنعيم الحواف بمقدار {samples}×",
"settings.graphics_restart_now": "إعادة التشغيل الآن",
"settings.graphics_trial_body": "بدأ SolidCraft باستخدام {settings}. دون إجابة، سيعود خلال {seconds} ثانية إلى {previous}.",
"settings.graphics_trial_first": "احتفظ بإعدادات الرسومات قيد التجربة أو ارجع عنها أولًا",
"settings.graphics_trial_keep": "احتفاظ",
"settings.graphics_trial_revert": "رجوع",
"settings.graphics_trial_title": "هل تريد الاحتفاظ بإعدادات الرسومات هذه؟",
"ui.graphics_api": "واجهة برمجة الرسومات",
"ui.graphics_api_auto": "تلقائي"
},
"en": {
"settings.graphics_api_env": "Set by the WGPU_BACKEND environment variable ({value})",
"settings.graphics_api_freezes": "On AMD integrated graphics, {api} can freeze the screen, sometimes until the computer is restarted. Automatic uses Vulkan here.",
"settings.graphics_applies_on_restart": "These apply when SolidCraft starts again. It then asks whether to keep them, and goes back to the current ones if you don't.",
"settings.graphics_msaa": "{samples}× anti-aliasing",
"settings.graphics_restart_now": "Restart now",
"settings.graphics_trial_body": "SolidCraft started with {settings}. Without an answer, in {seconds} s it goes back to {previous}.",
"settings.graphics_trial_first": "Keep or go back from the graphics settings being tried first",
"settings.graphics_trial_keep": "Keep",
"settings.graphics_trial_revert": "Go back",
"settings.graphics_trial_title": "Keep these graphics settings?",
"ui.graphics_api": "Graphics API",
"ui.graphics_api_auto": "Automatic"
},
"he": {
"settings.graphics_api_env": "נקבע על ידי משתנה הסביבה WGPU_BACKEND ({value})",
"settings.graphics_api_freezes": "בגרפיקה המשולבת של AMD, ‏{api} עלול להקפיא את המסך, לפעמים עד להפעלה מחדש של המחשב. האפשרות האוטומטית משתמשת כאן ב־Vulkan.",
"settings.graphics_applies_on_restart": "הגדרות אלה יחולו בהפעלה הבאה של SolidCraft. אז תופיע שאלה אם לשמור אותן, ואם לא, ההגדרות הנוכחיות ישוחזרו.",
"settings.graphics_msaa": "החלקת קצוות פי {samples}",
"settings.graphics_restart_now": "הפעלה מחדש כעת",
"settings.graphics_trial_body": "SolidCraft הופעל עם {settings}. ללא תשובה, בעוד {seconds} שניות הוא יחזור אל {previous}.",
"settings.graphics_trial_first": "יש לשמור את הגדרות הגרפיקה שבניסיון או לחזור מהן תחילה",
"settings.graphics_trial_keep": "שמירה",
"settings.graphics_trial_revert": "חזרה",
"settings.graphics_trial_title": "לשמור את הגדרות הגרפיקה האלה?",
"ui.graphics_api": "ממשק הגרפיקה",
"ui.graphics_api_auto": "אוטומטי"
},
"ja": {
"settings.graphics_api_env": "環境変数 WGPU_BACKEND で指定されています ({value})",
"settings.graphics_api_freezes": "AMD の内蔵グラフィックスでは、{api} で画面が固まり、コンピューターを再起動するまで戻らないことがあります。自動ではここで Vulkan を使います。",
"settings.graphics_applies_on_restart": "これらは SolidCraft の次回起動時に適用されます。起動後に設定を維持するか確認し、維持しない場合は現在の設定に戻ります。",
"settings.graphics_msaa": "アンチエイリアス {samples}×",
"settings.graphics_restart_now": "今すぐ再起動",
"settings.graphics_trial_body": "SolidCraft は {settings} で起動しました。応答がない場合、{seconds} 秒後に {previous} に戻ります。",
"settings.graphics_trial_first": "先に、試用中のグラフィックス設定を維持するか戻すかを選んでください",
"settings.graphics_trial_keep": "維持",
"settings.graphics_trial_revert": "元に戻す",
"settings.graphics_trial_title": "このグラフィックス設定を維持しますか?",
"ui.graphics_api": "グラフィックス API",
"ui.graphics_api_auto": "自動"
},
"vi": {
"settings.graphics_api_env": "Được đặt bởi biến môi trường WGPU_BACKEND ({value})",
"settings.graphics_api_freezes": "Trên đồ họa tích hợp AMD, {api} có thể làm màn hình bị treo, đôi khi cho đến khi khởi động lại máy tính. Tự động sử dụng Vulkan ở đây.",
"settings.graphics_applies_on_restart": "Các thiết lập này có hiệu lực khi SolidCraft khởi động lại. Khi đó ứng dụng sẽ hỏi có giữ chúng không và trở về thiết lập hiện tại nếu bạn không giữ.",
"settings.graphics_msaa": "Khử răng cưa {samples}×",
"settings.graphics_restart_now": "Khởi động lại ngay",
"settings.graphics_trial_body": "SolidCraft đã khởi động với {settings}. Nếu không trả lời, sau {seconds} giây ứng dụng sẽ trở về {previous}.",
"settings.graphics_trial_first": "Trước tiên hãy giữ hoặc quay lại từ các thiết lập đồ họa đang thử",
"settings.graphics_trial_keep": "Giữ",
"settings.graphics_trial_revert": "Quay lại",
"settings.graphics_trial_title": "Giữ các thiết lập đồ họa này?",
"ui.graphics_api": "API đồ họa",
"ui.graphics_api_auto": "Tự động"
},
"zh": {
"settings.graphics_api_env": "由环境变量 WGPU_BACKEND 设置({value})",
"settings.graphics_api_freezes": "在 AMD 集成显卡上,{api} 可能导致屏幕冻结,有时需要重启电脑才能恢复。自动选项在这里使用 Vulkan。",
"settings.graphics_applies_on_restart": "这些设置在 SolidCraft 下次启动时生效。启动后会询问是否保留;如果不保留,将恢复为当前设置。",
"settings.graphics_msaa": "{samples}× 抗锯齿",
"settings.graphics_restart_now": "立即重启",
"settings.graphics_trial_body": "SolidCraft 已使用 {settings} 启动。如果没有回应,将在 {seconds} 秒后恢复为 {previous}。",
"settings.graphics_trial_first": "请先决定保留还是撤销正在试用的图形设置",
"settings.graphics_trial_keep": "保留",
"settings.graphics_trial_revert": "恢复",
"settings.graphics_trial_title": "保留这些图形设置吗?",
"ui.graphics_api": "图形 API",
"ui.graphics_api_auto": "自动"
}
}
+1
View File
@@ -1347,6 +1347,7 @@
"document.read_only_save": "Ten projekt otwarto tylko do odczytu: zapisz go jako nowy plik",
"document.save_changes": "Zapisać zmiany w {name}?",
"document.save_copy": "Zapisz kopię…",
"document.tab_unsaved_changes": "Niezapisane zmiany",
"document.unsaved_changes": "{name} ma niezapisane zmiany. Jeśli ich nie zapiszesz, zostaną utracone.",
"drive.rigid": "{joint} to złącze sztywne: utrzymuje swoje części nieruchomo, więc nie ma czym sterować. Aby mogły się poruszać, zmień jego typ w Edytuj złącze: Obrotowe do obrotu, Przesuwne do przesuwu, Walcowe do obu.",
"drive.stopped_by_contact": "Zatrzymano przy {value}: {a} przeszedłby przez {b}",
+21
View File
@@ -182,6 +182,8 @@
"Designs you open or save appear here.": "Здесь появляются открытые и сохранённые дизайны.",
"Determined by To Object. Choose Angle to edit the angular extent.": "Определяется параметром «До объекта». Выберите «Угол», чтобы задать угловой предел.",
"Diameter": "Диаметр",
"Did this assembly open the wrong way up?": "Эта сборка открылась не той стороной вверх?",
"Did this part open the wrong way up?": "Эта деталь открылась не той стороной вверх?",
"Dimension new shapes": "Проставлять размеры новых фигур",
"Direction": "Направление",
"Discard": "Отменить",
@@ -606,6 +608,7 @@
"Square": "Квадрат",
"Stand it up": "Поставить вертикально",
"Stand it up: a quarter turn about X, as one Move feature you can undo.": "Поставить вертикально: четверть оборота вокруг X одной операцией «Переместить», которую можно отменить.",
"Stand this assembly up?": "Поставить эту сборку вертикально?",
"Stand this part up?": "Поставить эту деталь вертикально?",
"Start angle": "Начальный угол",
"Start bulge": "Выпуклость в начале",
@@ -1356,6 +1359,7 @@
"dialog.dimension_insert_hint": "Дважды щёлкните другой управляющий размер, чтобы вставить его имя",
"dialog.dimension_name": "Размер: {name}",
"dialog.edit_title": "ИЗМЕНИТЬ: {title}",
"dialog.no_shown_body": "Нет отображаемого тела для изменения: покажите тело или выберите «Новое тело»",
"dialog.pattern_sketch_not_object": "Выберите тела или операции: эскиз — ни то, ни другое. Повторите его кривые собственным инструментом «Массив» или «Зеркало» в эскизе либо сначала выдавите его.",
"dialog.select": "↖ Выбрать",
"dialog.selected_count": "↖ Выбрано: {count}",
@@ -1717,6 +1721,8 @@
"help.view.spin": "Непрерывно вращает вид вокруг модели до следующего щелчка или нажатия клавиши. Используйте, чтобы показать модель со всех сторон.",
"help.view.tool": "Закрепляет инструмент камеры (орбиту, панорамирование или масштаб), так что перетаскивание левой кнопкой мыши двигает вид, пока вы его не отключите. Используйте на трекпаде или без средней кнопки мыши.",
"help.view.visual_style": "Переключает способ отрисовки модели: тонированная с рёбрами или без них, каркас или со скрытыми рёбрами. Используйте каркас, чтобы заглянуть внутрь, а тонированный вид — для чистого вида.",
"history.sketch_under_constrained": "Недоопределён (степеней свободы: {count})",
"history.sketch_under_constrained_one": "Недоопределён (степеней свободы: {count})",
"hole_presets.confirm_delete": "Подтвердить удаление",
"hole_presets.delete": "Удалить",
"hole_presets.delete_prompt": "Удалить этот личный пресет? Существующие отверстия не изменятся.",
@@ -2128,6 +2134,8 @@
"prefs.anti_aliasing_note": "Сглаживает неровные края.",
"prefs.applies_on_restart": "Применяется после перезапуска",
"prefs.assistant": "Ассистент",
"prefs.auto_operation": "Выбирать операцию автоматически",
"prefs.auto_operation_note": "Окна операций сами выбирают «Объединить», «Новое тело» или «Вырезать» в зависимости от того, чего касается новый материал, пока вы не выберете операцию вручную. Выкл.: окна сохраняют ту операцию, с которой открылись.",
"prefs.axes": "Оси",
"prefs.axis": "Ось",
"prefs.bodies_folders": "Папки тел",
@@ -2349,6 +2357,8 @@
"prefs.sketch_selection": "Эскиз и выделение",
"prefs.sketch_selection_heading": "Эскиз и выделение",
"prefs.sketch_selection_note": "Как работает привязка в эскизах и как щелчки выделяют объекты.",
"prefs.sketch_state_icons": "Значки эскизов в цвете их состояния",
"prefs.sketch_state_icons_note": "В истории и браузере значок эскиза принимает цвет его состояния (недоопределён, полностью определён или с конфликтом), а подсказка называет это состояние. Выкл.: у всех эскизов одинаковый значок.",
"prefs.sketch_tab_always": "Всегда показывать вкладку «Эскиз»",
"prefs.sketch_tab_always_note": "Вкладка «Эскиз» остаётся на ленте сразу после «Модель», пока не открыт ни один эскиз. Её инструменты рисования создают эскиз на выбранной плоскости или грани либо на плоскости, которую вы выберете следом, и сразу включают нужный инструмент.",
"prefs.sketch_tracking_guides": "Направляющие выравнивания по центру и середине",
@@ -2360,6 +2370,11 @@
"prefs.source": "Источник",
"prefs.speed": "Скорость",
"prefs.stand_up_note": "После открытия файла STEP или IGES с осью Z вверх предлагать повернуть его на четверть оборота вокруг X.",
"prefs.stand_up_unknown": "Для файлов из неизвестных программ",
"prefs.stand_up_unknown.ask": "Спрашивать в диалоговом окне",
"prefs.stand_up_unknown.note": "Показывать уведомление",
"prefs.stand_up_unknown.off": "Не предлагать",
"prefs.stand_up_unknown_note": "Как делается предложение, когда в заголовке файла не указана программа, о которой известно, что она сохраняет с осью Y вверх. Уведомление показывается, только если файл, судя по всему, лежит на спине; «Спрашивать в диалоговом окне» спрашивает о каждом таком файле, как раньше.",
"prefs.status_bar": "Строка состояния",
"prefs.status_bar_note": "Тонкая полоса вдоль нижнего края окна показывает, что изменяется, состояние открытого эскиза его цветом и единицы дизайна; щелчок по единицам изменяет их. Состояние эскиза при этом больше не показывается в углу вида.",
"prefs.status_done": "Готово",
@@ -3902,8 +3917,10 @@
"timeline.move_to_group": "Переместить операции в папку",
"timeline.rename_group": "Переименовать папку операций",
"timeline.reorder_many": "Изменить порядок операций",
"timeline.suppress_features": "Подавить операции",
"timeline.suppress_folders_tip": "Подавить или снять подавление со всех операций в папках и вложенных папках",
"timeline.ungroup": "Убрать папки операций",
"timeline.unsuppress_features": "Снять подавление с операций",
"to the face": "до грани",
"to the next face": "до следующей грани",
"toolbar.customize_note": "Вкладка {tab}. Отметьте команду, чтобы добавить её кнопку на панель инструментов. Можно также перетащить строку из выпадающего меню панели на панель инструментов, перетащить кнопку за пределы панели для удаления или использовать значок закрепления в строке выпадающего меню.",
@@ -4349,6 +4366,8 @@
"ui.cancel": "Отмена",
"ui.canvases": "Подложки",
"ui.center": "Центр",
"ui.center_arc_follows_cursor": "Дуга из центра следует направлению курсора",
"ui.center_arc_follows_cursor_tip": "Дуга из центра идёт по часовой стрелке или против неё — в ту сторону, в которую курсор обходит её центр. Выкл.: всегда против часовой стрелки от начальной точки.",
"ui.centerline": "Осевая линия",
"ui.chain": "Цепочка",
"ui.chamfer": "Фаска",
@@ -5160,11 +5179,13 @@
"update.installed": "SolidCraft {version} установлен. Перезапустите, чтобы использовать его.",
"update.installer_started": "Установщик запустится после закрытия SolidCraft.",
"update.later": "Напомнить позже",
"update.managed": "Доступно обновление: обновите SolidCraft в {manager}",
"update.no_notes": "У этого выпуска нет примечаний.",
"update.package_hint": "Эта копия установлена из {kind}. Скачайте новый пакет, затем установите его командой:",
"update.prefs_dev": "Это сборка для разработки ({version}): она не проверяет обновления.",
"update.prefs_flatpak": "Эту копию ({version}) обновляет Flatpak: выполните {command} или воспользуйтесь центром приложений. SolidCraft не проверяет обновления.",
"update.prefs_label": "Обновления",
"update.prefs_managed": "Это SolidCraft {version}, которым управляет {manager}. Проверка запрашивает у GitHub последний релиз и больше ничего не отправляет; обновляйте SolidCraft в {manager}.",
"update.prefs_note": "Это SolidCraft {version} ({kind}). При проверке у GitHub запрашивается последний выпуск, больше ничего не отправляется.",
"update.prefs_web": "Перезагрузите страницу, чтобы получить последнюю версию веб-приложения. Если сеть недоступна или обновление не успевает загрузиться, открывается сохранённая версия; незавершённая загрузка продолжится до следующей перезагрузки.",
"update.refresh_checking": "Проверка наличия новейшего выпуска…",
+1
View File
@@ -1392,6 +1392,7 @@
"document.read_only_save": "Den här konstruktionen öppnades skrivskyddad: spara den som en ny fil",
"document.save_changes": "Spara ändringar i {name}?",
"document.save_copy": "Spara en kopia…",
"document.tab_unsaved_changes": "Osparade ändringar",
"document.unsaved_changes": "{name} har osparade ändringar. Om du inte sparar går de förlorade.",
"drive.rigid": "{joint} är ett stelt led: det håller sina delar stilla och har därför inget att driva. Om delarna ska kunna röra sig, ändra ledets typ i Redigera led: Roterande för att rotera, Glidande för att glida, Cylindrisk för båda.",
"drive.stopped_by_contact": "Stoppade vid {value}: {a} skulle gå igenom {b}",
+14
View File
@@ -1396,6 +1396,9 @@
"dialog.custom_axis": "↖ Trục tùy chỉnh",
"dialog.dimension_insert_hint": "Nhấp vào một kích thước điều khiển khác để chèn tên của nó",
"dialog.dimension_name": "Kích thước: {name}",
"dialog.dimension_step_down": "Giảm {step}",
"dialog.dimension_step_expression": "Chỉ số mới thay đổi theo bước; biểu thức giữ nguyên như đã nhập",
"dialog.dimension_step_up": "Tăng {step}",
"dialog.edit_title": "CHỈNH SỬA {title}",
"dialog.no_shown_body": "Không có thân khối đang hiển thị để thay đổi: hãy hiển thị một thân khối, hoặc chọn “Tạo mới Thân khối”",
"dialog.pattern_sketch_not_object": "Hãy chọn thân khối hoặc tính năng: bản phác thảo không phải là cả hai. Lặp lại các đường cong của nó bằng Mảng hoặc Lấy đối xứng trong chính bản phác thảo, hoặc đùn khối nó trước.",
@@ -1403,6 +1406,7 @@
"dialog.selected_count": "↖ {count} đã chọn",
"dialog.selection_constraint": "Ràng buộc {id}",
"dialog.selection_external_curve": "{sketch} · đường cong {id}",
"dialog.selection_external_point": "{point} · {sketch}",
"dialog.selection_guidance": "Chọn một mục đầu vào để thay đổi. Nhấp vào đối tượng đã chọn để bỏ chọn; Ctrl/Cmd-nhấp để bật/tắt chọn. Xem trước kết quả, sau đó nhấn OK.",
"dialog.selection_midpoint": "Trung điểm · {id}",
"dialog.selection_profile": "{sketch} · biên dạng {number}",
@@ -1420,6 +1424,7 @@
"document.read_only_save": "Thiết kế này được mở ở chế độ chỉ đọc: hãy lưu thành tệp mới",
"document.save_changes": "Lưu các thay đổi đối với {name}?",
"document.save_copy": "Lưu bản sao…",
"document.tab_unsaved_changes": "Thay đổi chưa lưu",
"document.unsaved_changes": "{name} có các thay đổi chưa lưu. Nếu không lưu, các thay đổi sẽ bị mất.",
"drive.rigid": "{joint} là khớp nối cứng: nó giữ cố định các chi tiết, nên không có gì để dẫn động. Để các chi tiết chuyển động, hãy đổi loại khớp nối trong “Chỉnh sửa khớp nối”: Revolute để xoay, Slider để trượt, Cylindrical cho cả hai.",
"drive.stopped_by_contact": "Đã dừng tại {value}: {a} sẽ xuyên qua {b}",
@@ -1999,6 +2004,7 @@
"material.selection.mixed": "Các vật liệu khác nhau",
"measure.at": "Tại",
"measure.item": "Mục {number}",
"measure.line_to_centre": "Từ đường đến tâm",
"measure.quick_copied": "Đã sao chép số đo",
"measure.quick_copy": "Nhấp để sao chép số đo",
"measure.quick_edges": "{count} cạnh",
@@ -2331,6 +2337,13 @@
"prefs.dialogs_edge": "Ở cạnh khung nhìn",
"prefs.dialogs_left_column": "Trong cột bên trái",
"prefs.dialogs_note": "Nơi hộp thoại lệnh mở ra: gắn ở cạnh khung nhìn, hoặc trong cột bên trái thay cho trình duyệt và Lịch sử, hai phần này trở lại như cũ khi hộp thoại đóng.",
"prefs.dimension_step_angle": "Lượng mà mũi tên của cửa sổ, Lên/Xuống và con lăn cộng vào một góc",
"prefs.dimension_step_length": "Lượng mà mũi tên của cửa sổ, Lên/Xuống và con lăn cộng vào một độ dài",
"prefs.dimension_steps": "Bước của cửa sổ",
"prefs.dimension_values": "Giá trị kích thước",
"prefs.dimension_values_note": "Nơi bạn nhập giá trị kích thước. Cửa sổ có mũi tên để thay đổi theo bước; công cụ Kích thước đặt văn bản trước.",
"prefs.dimension_values_view": "Trong khung nhìn",
"prefs.dimension_values_window": "Trong một cửa sổ",
"prefs.display": "Hiển thị",
"prefs.edges": "Cạnh và đổ bóng",
"prefs.extend": "Kéo dài",
@@ -4797,6 +4810,7 @@
"ui.default_modelling_orientation": "Hướng mô hình hóa mặc định",
"ui.delete": "Xóa",
"ui.delete_select_first": "Hãy chọn trước những gì cần xóa: tính năng, khối, thành phần hoặc hình học phác thảo",
"ui.dependents_select_first": "Hãy chọn trước đối tượng để liệt kê các phần phụ thuộc của nó",
"ui.depth": "Độ sâu",
"ui.design": "Thiết kế",
"ui.design_properties.author": "Tác giả",
+14
View File
@@ -1396,6 +1396,9 @@
"dialog.custom_axis": "↖ 自定义轴",
"dialog.dimension_insert_hint": "单击其他驱动尺寸以插入其名称",
"dialog.dimension_name": "尺寸:{name}",
"dialog.dimension_step_down": "减少 {step}",
"dialog.dimension_step_expression": "只有数字可以增减;表达式保持不变",
"dialog.dimension_step_up": "增加 {step}",
"dialog.edit_title": "编辑 {title}",
"dialog.no_shown_body": "没有可更改的显示实体:请显示一个实体,或选择新建实体",
"dialog.pattern_sketch_not_object": "请选择实体或特征:草图两者都不是。请用草图自身的阵列或镜像重复其曲线,或先将其拉伸。",
@@ -1403,6 +1406,7 @@
"dialog.selected_count": "↖ 已选择 {count} 个",
"dialog.selection_constraint": "约束 {id}",
"dialog.selection_external_curve": "{sketch} · 曲线 {id}",
"dialog.selection_external_point": "{point} · {sketch}",
"dialog.selection_guidance": "选择输入项进行修改。点击已选对象可取消选择;按住 Ctrl/Cmd 点击也可切换选择。检查预览后,再按“确定”。",
"dialog.selection_midpoint": "中点 · {id}",
"dialog.selection_profile": "{sketch} · 轮廓 {number}",
@@ -1420,6 +1424,7 @@
"document.read_only_save": "此设计以只读方式打开:请另存为新文件",
"document.save_changes": "保存对 {name} 的更改?",
"document.save_copy": "保存副本…",
"document.tab_unsaved_changes": "未保存的更改",
"document.unsaved_changes": "{name} 有未保存的更改。如果不保存,将丢失这些更改。",
"drive.rigid": "{joint} 是刚性接头:它把零件固定在原位,因此没有可驱动的运动。要让零件运动,请在“编辑接头”中更改其类型:旋转用 Revolute,滑动用 Slider,两者兼有用 Cylindrical。",
"drive.stopped_by_contact": "停在 {value}:{a} 会穿过 {b}",
@@ -1999,6 +2004,7 @@
"material.selection.mixed": "不同材料",
"measure.at": "位置",
"measure.item": "项目 {number}",
"measure.line_to_centre": "线到圆心",
"measure.quick_copied": "已复制测量值",
"measure.quick_copy": "点击复制测量值",
"measure.quick_edges": "{count} 条边",
@@ -2331,6 +2337,13 @@
"prefs.dialogs_edge": "视图边缘",
"prefs.dialogs_left_column": "左侧栏",
"prefs.dialogs_note": "命令对话框的打开位置:停靠在视图边缘,或在左侧栏中替代浏览器和历史;关闭对话框后,浏览器和历史恢复原样。",
"prefs.dimension_step_angle": "窗口的箭头、上下方向键和滚轮给角度增减的量",
"prefs.dimension_step_length": "窗口的箭头、上下方向键和滚轮给长度增减的量",
"prefs.dimension_steps": "窗口步长",
"prefs.dimension_values": "尺寸值",
"prefs.dimension_values_note": "输入尺寸值的位置。窗口带有可增减数值的箭头;尺寸工具会先放置文字。",
"prefs.dimension_values_view": "在视图中",
"prefs.dimension_values_window": "在窗口中",
"prefs.display": "显示",
"prefs.edges": "边缘与着色",
"prefs.extend": "扩展",
@@ -4797,6 +4810,7 @@
"ui.default_modelling_orientation": "默认建模方向",
"ui.delete": "删除",
"ui.delete_select_first": "请先选择要删除的内容:特征、实体、组件或草图几何",
"ui.dependents_select_first": "请先选择要列出其依赖项的对象",
"ui.depth": "深度",
"ui.design": "设计",
"ui.design_properties.author": "作者",
+3 -1
View File
@@ -802,7 +802,9 @@ fn vs_line(@builtin(vertex_index) vi: u32, @location(0) a: vec3<f32>, @location(
let sb = cb.xy / max(cb.w, 1e-6) * half;
var dir = sb - sa;
let len = length(dir);
if (len < 1e-6) { dir = vec2<f32>(1.0, 0.0); } else { dir = dir / len; }
// Intel OpenGL can miscompile vector/scalar division here (#8053). Scalar
// quotients keep the same direction and the fallback for a point.
if (len < 1e-6) { dir = vec2<f32>(1.0, 0.0); } else { dir = vec2<f32>(dir.x / len, dir.y / len); }
let nrm = vec2<f32>(-dir.y, dir.x);
// Two triangles: corners (t, side).
var ts = array<f32, 6>(0.0, 1.0, 1.0, 0.0, 1.0, 0.0);
+8 -3
View File
@@ -2318,11 +2318,16 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
_ => false,
};
let (Some(_), Some(_), false) = (outer, openings.first(), alike) else {
// A solid box top has no opening: Shell that face first, then pick the rim
// around the cavity (#10437).
let what = match (loops.len(), openings.len()) {
(0 | 1, _) => "pick the rim face of a shelled body (a face with an inner and an outer edge)".to_string(),
(_, 0) => "pick the rim face of a shelled body: this face's inner edges are where other features stand on it, not an opening"
(0 | 1, _) => "Lip needs the rim of a shelled body: Shell a face open first, then pick the rim around that opening (a face with an outer edge and an inner hole)"
.to_string(),
(_, k) => format!("pick the rim face of a shelled body (round one opening): this face has {k} openings alike in size"),
(_, 0) => "Lip needs a rim around an opening: this face's inner edges are where other features stand on it, not a shell cavity. Shell a face open, then pick that rim"
.to_string(),
(_, k) => format!(
"Lip needs one clear opening as the rim: this face has {k} openings alike in size (for example a plate of holes). Shell one cavity, or pick a rim that rings a single opening"
),
};
return Err(DocError::Invalid(what));
};
+13 -4
View File
@@ -757,10 +757,19 @@ pub(crate) fn order_lumps(bodies: &mut [crate::ModelBody], origin: &Arc<crate::n
// cut body's own feature's and the rest are the cut's, and `body.rename` stores a name in
// the `body_names` of the feature a body says made it (#10438).
let names: Vec<(String, u64)> = members.iter().filter_map(|i| bodies.get(*i).map(|b| (b.name.clone(), b.feature))).collect();
for ((name, feature), (_, i)) in names.into_iter().zip(keys) {
if let Some(b) = bodies.get_mut(i) {
b.name = name;
b.feature = feature;
// The pieces take the family's places in that order too, so the state lists them as
// their names run. The session pins a feature's body names in state order, and the next
// evaluation hands them out in this order: listed in the kernel's order, the pieces
// swapped names once the design was evaluated again from its pinned names (opened).
let moved: Vec<crate::ModelBody> = keys.iter().filter_map(|(_, i)| bodies.get(*i).cloned()).collect();
if moved.len() != members.len() || names.len() != members.len() {
continue;
}
for ((mut b, (name, feature)), place) in moved.into_iter().zip(names).zip(&members) {
b.name = name;
b.feature = feature;
if let Some(slot) = bodies.get_mut(*place) {
*slot = b;
}
}
}
+115 -19
View File
@@ -1,9 +1,9 @@
//! `solidcraft-cli mcp setup` and Help > Connect AI assistants: add a `solidcraft` MCP server entry
//! to the AI clients installed on this machine, using the `solidcraft-cli` path given.
//!
//! The clients' own files are edited in place, not rewritten: only the `solidcraft` entries change,
//! every other byte (other servers, other settings, comments, key order) stays as it was. The file
//! is copied to `<name>.bak` first. A file that cannot be read as JSON (with comments) or TOML is
//! Only the `solidcraft` entries change; every other byte (other servers, other settings, comments,
//! key order) stays as it was. Updates replace the file atomically, keeping the previous version
//! as `<name>.bak`. A file that cannot be read as JSON (with comments) or TOML is
//! left alone, and so is an entry the user changed (its command is not a SolidCraft CLI, or its
//! arguments differ); an entry that only has an old path to the CLI is updated.
//! A client counts as installed when its configuration folder exists; no client program is run.
@@ -606,11 +606,7 @@ fn toml_to_json(t: &toml_edit::Table) -> Value {
/// One client's outcome, a line of the report.
fn configure_file(t: &Target, exe: &str, live: bool, dry_run: bool, mode: Mode) -> Result<(String, Vec<(&'static str, Change)>), String> {
let fail = |e: String| format!("{}: {e}; left unchanged", t.file.display());
let existing = match std::fs::read_to_string(&t.file) {
Ok(s) => Some(s),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => return Err(format!("{}: {e}", t.file.display())),
};
let existing = read_config(&t.file).map_err(|e| format!("{}: {e}", t.file.display()))?;
let mut text = existing.clone().unwrap_or_default();
let changes =
if t.shape == Shape::Codex { edit_toml(&mut text, exe, live, mode) } else { edit_json(&mut text, t.shape, exe, live, mode) }.map_err(fail)?;
@@ -633,20 +629,30 @@ fn configure_file(t: &Target, exe: &str, live: bool, dry_run: bool, mode: Mode)
if !changed {
return done(said);
}
if existing.is_some() {
let mut bak = t.file.clone().into_os_string();
bak.push(".bak");
// The first backup is the user's original: later runs keep it.
if !Path::new(&bak).exists() {
std::fs::copy(&t.file, &bak).map_err(|e| format!("{}: cannot back up: {e}", t.file.display()))?;
}
} else if let Some(dir) = t.file.parent() {
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
}
std::fs::write(&t.file, text).map_err(|e| format!("{}: {e}", t.file.display()))?;
write_config(&t.file, existing.as_deref(), &text).map_err(|e| format!("{}: {e}", t.file.display()))?;
done(format!("{said} ({})", t.file.display()))
}
fn read_config(path: &Path) -> std::io::Result<Option<String>> {
match std::fs::read_to_string(path) {
Ok(text) => Ok(Some(text)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e),
}
}
/// Refuse a stale edit before writing. Other clients do not share a lock with SolidCraft, so
/// this narrows the race with their writes; it cannot exclude a write after the comparison.
fn write_config(path: &Path, expected: Option<&str>, text: &str) -> std::io::Result<()> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
if read_config(path)?.as_deref() != expected {
return Err(std::io::Error::other("configuration changed during setup; left unchanged, try again"));
}
solidcraft_io::safe_write::write_atomic(path, text.as_bytes(), true)
}
/// Set up every detected client (or `--client NAME`; flags `--dry-run`, `--live`) for the
/// `solidcraft-cli` at `exe`. Returns the result lines, one per client file, and whether every
/// client succeeded.
@@ -842,6 +848,96 @@ mod tests {
assert!(toml.contains("[mcp_servers.other]") && !toml.contains("solidcraft"), "{toml}");
}
fn config_dir(label: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("solidcraft-agent-config-{label}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn configuration_backup_tracks_the_file_before_each_change() {
let dir = config_dir("backup");
let target = Target { client: "claude-code", file: dir.join("config.json"), shape: Shape::ClaudeCode };
let backup = dir.join("config.json.bak");
let original = "{\n \"personal_setting\": true\n}\n";
std::fs::write(&target.file, original).unwrap();
configure_file(&target, EXE, false, false, Mode::Add).unwrap();
assert_eq!(std::fs::read_to_string(&backup).unwrap(), original);
let changed_by_client = std::fs::read_to_string(&target.file).unwrap().replace("true", "false");
std::fs::write(&target.file, &changed_by_client).unwrap();
configure_file(&target, "/new/solidcraft-cli", false, false, Mode::Add).unwrap();
assert_eq!(std::fs::read_to_string(&backup).unwrap(), changed_by_client);
let current = std::fs::read_to_string(&target.file).unwrap();
assert!(current.contains("false") && current.contains("/new/solidcraft-cli"));
// A no-op or dry run must leave both the file and its backup alone.
configure_file(&target, "/new/solidcraft-cli", false, false, Mode::Add).unwrap();
configure_file(&target, "/future/solidcraft-cli", false, true, Mode::Add).unwrap();
assert_eq!(std::fs::read_to_string(&target.file).unwrap(), current);
assert_eq!(std::fs::read_to_string(&backup).unwrap(), changed_by_client);
configure_file(&target, "/new/solidcraft-cli", false, false, Mode::Remove).unwrap();
assert_eq!(std::fs::read_to_string(&backup).unwrap(), current);
assert!(!std::fs::read_to_string(&target.file).unwrap().contains("solidcraft"));
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn configuration_is_unchanged_when_its_backup_cannot_be_replaced() {
let dir = config_dir("backup-failure");
let target = Target { client: "codex", file: dir.join("config.toml"), shape: Shape::Codex };
let original = "# personal setting\nmodel = \"custom\"\n";
std::fs::write(&target.file, original).unwrap();
std::fs::create_dir(dir.join("config.toml.bak")).unwrap();
assert!(configure_file(&target, EXE, false, false, Mode::Add).is_err());
assert_eq!(std::fs::read_to_string(&target.file).unwrap(), original);
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2, "no temporary files remain");
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn configuration_refuses_an_edit_based_on_an_outdated_read() {
let dir = config_dir("concurrent");
let path = dir.join("config.json");
let backup = dir.join("config.json.bak");
std::fs::write(&backup, "previous backup").unwrap();
for (expected, current) in [(Some("old"), Some("new")), (Some("old"), None), (None, Some("new"))] {
if let Some(text) = current {
std::fs::write(&path, text).unwrap();
} else {
std::fs::remove_file(&path).unwrap();
}
let error = write_config(&path, expected, "stale edit").unwrap_err();
assert!(error.to_string().contains("configuration changed"), "{error}");
assert_eq!(read_config(&path).unwrap().as_deref(), current);
assert_eq!(std::fs::read_to_string(&backup).unwrap(), "previous backup");
}
std::fs::remove_dir_all(dir).unwrap();
}
#[cfg(unix)]
#[test]
fn configuration_replaces_a_complete_file_and_keeps_links_and_permissions() {
use std::io::Read;
use std::os::unix::fs::{PermissionsExt, symlink};
let dir = config_dir("atomic");
let real = dir.join("real.json");
let target = Target { client: "claude-code", file: dir.join("config.json"), shape: Shape::ClaudeCode };
let original = "{\"personal_setting\": true}\n";
std::fs::write(&real, original).unwrap();
std::fs::set_permissions(&real, std::fs::Permissions::from_mode(0o600)).unwrap();
symlink("real.json", &target.file).unwrap();
let mut reader = std::fs::File::open(&target.file).unwrap();
configure_file(&target, EXE, false, false, Mode::Add).unwrap();
let mut old = String::new();
reader.read_to_string(&mut old).unwrap();
assert_eq!(old, original, "an existing reader sees the complete old file");
assert!(std::fs::read_to_string(&target.file).unwrap().contains(EXE));
assert!(std::fs::symlink_metadata(&target.file).unwrap().file_type().is_symlink());
assert_eq!(std::fs::metadata(&real).unwrap().permissions().mode() & 0o777, 0o600);
assert_eq!(std::fs::read_to_string(dir.join("real.json.bak")).unwrap(), original);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn quiet_adds_only_the_plain_entry_and_names_new_clients_once() {
let h = std::env::temp_dir().join(format!("solidcraft-quiet-{}", std::process::id()));
+87 -1
View File
@@ -64,7 +64,7 @@ pub static COMMANDS: &[CommandSpec] = &[
or features?: [feature id or name] (the bodies they made; path?: [occurrence ids] for only that placement's); \
or folder?: bodies|sketches|construction|origin|joints|all (all of it; showing also shows its browser folders and groups, and `all` takes in the occurrence hides too); \
or tree_folders?: [browser folder or group key: c0/bodies, o:4/sketches, c0/g7, c0/joints, c0/canvases, c0/point_clouds] (the folder or group itself: its items keep their own state); \
or paths?: [[occurrence ids]] (exact placements and descendants), isolate?: bool (requires paths and visible:true). Saved with the design and undone like any other change (the camera is not)",
or paths?: [[occurrence ids]] (exact placements and descendants), isolate?: bool (with paths or items of bodies, and visible:true: everything else is hidden, other placements as placements). Saved with the design and undone like any other change (the camera is not)",
),
CommandSpec::new("browser.expand_all", "Expand All", expand_all).icon("folder").noundo().params(
"path?: [occurrence ids] (that placement and its descendants); component?: id (that component's own folders); \
@@ -436,6 +436,89 @@ fn tree_folder_key(k: &str) -> bool {
k.len() <= 2048 && scope_ok && last_ok
}
/// Isolate bodies (#7896): the picked bodies stay, and so do the placements that hold them, down
/// to their own; those placements' other bodies and the root's other bodies are hidden one by
/// one, and every other placement is hidden as a placement, so its Browser row shows it is off and
/// its eye brings it back.
fn isolate_bodies(s: &mut Session, p: &Value, visible: bool) -> Result<Value> {
let cmd = "browser.visibility";
if !visible {
return Err(bad(cmd, "isolate requires visible:true"));
}
if ["folder", "features", "joints"].iter().any(|k| p.get(*k).is_some()) {
return Err(bad(cmd, "isolate takes `paths` or `items` (bodies)"));
}
let keys = keys(p, "items", cmd)?;
if keys.is_empty() {
return Err(bad(cmd, "isolate needs `paths` or `items` (bodies)"));
}
let world = s.world_state()?;
let keep: Vec<String> = keys
.iter()
.map(|k| match shown_item(s, &world, k) {
Some(Shown::Body(name)) => Ok(name),
_ => Err(bad(cmd, format!("no body `{k}`"))),
})
.collect::<Result<_>>()?;
let mut hidden_bodies: Vec<String> =
s.model.state().bodies.iter().filter(|b| s.doc.model_body_component(b) == 0 && !keep.contains(&b.name)).map(|b| b.name.clone()).collect();
let mut hidden = Vec::new();
let mut stack = vec![Vec::<u64>::new()];
let mut count = 0;
while let Some(parent) = stack.pop() {
for child in s.occurrence_children(&parent)? {
count += 1;
if count > 100_000 {
return Err(bad(cmd, "isolate exceeds 100000 placements"));
}
if s.occurrence_bodies_shared(&child.path, true)?.iter().any(|b| keep.contains(b)) {
hidden_bodies.extend(child.bodies.into_iter().map(|b| b.world).filter(|b| !keep.contains(b)));
stack.push(child.path);
} else {
hidden.push(child.path);
}
}
}
// A hidden Bodies folder or group a picked body is in would keep it hidden: those show
// again. Their other bodies are hidden one by one above, as the root's are.
let shown_folders = folders_holding(s, &keep);
s.visibility.hidden_folders.retain(|k| !shown_folders.contains(k));
s.visibility.hidden_occurrences = hidden;
s.visibility.hidden_bodies = hidden_bodies;
s.refresh_display();
Ok(json!({"visible":true,"isolated":keep,"visibility":s.visibility}))
}
/// The tree keys of the Bodies folders and groups (with the groups they are in) that hold the
/// bodies named `world` in some placement.
fn folders_holding(s: &Session, world: &[String]) -> std::collections::BTreeSet<String> {
let mut keys = std::collections::BTreeSet::new();
if s.visibility.hidden_folders.is_empty() {
return keys;
}
let st = s.model.state();
let names = solidcraft_doc::WorldNames::new(&s.doc, &st);
// Each body's component, found once rather than once per placement.
let owned: Vec<(u64, &str)> = s.doc.model_body_components(&st).into_iter().zip(st.bodies.iter().map(|b| b.name.as_str())).collect();
for (component, path, _) in s.doc.placements() {
let scope = Session::folder_scope(&path);
for b in owned.iter().filter(|(c, _)| *c == component).map(|(_, name)| *name) {
if !world.contains(&names.name(b, component, &path)) {
continue;
}
keys.insert(format!("{scope}/bodies"));
for g in s.doc.browser_groups.iter().filter(|g| g.component == component && g.folder == "bodies" && g.has_item(b)) {
for id in std::iter::successors(Some(g.id), |id| s.doc.browser_group(*id).and_then(|g| g.parent))
.take(solidcraft_doc::MAX_GROUP_NESTING + 1)
{
keys.insert(format!("{scope}/g{id}"));
}
}
}
}
keys
}
fn visibility(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "browser.visibility";
let visible = bool_(p, "visible").ok_or_else(|| bad(cmd, "`visible` must be true or false"))?;
@@ -525,6 +608,9 @@ fn visibility(s: &mut Session, p: &Value) -> Result<Value> {
s.refresh_display();
return Ok(json!({"visible":visible,"changed":paths.len(),"visibility":s.visibility}));
}
if bool_(p, "isolate").unwrap_or(false) {
return isolate_bodies(s, p, visible);
}
let items: Vec<Shown> = match (str_(p, "folder"), p.get("items")) {
_ if p.get("features").is_some() => {
if ["items", "folder", "joints"].iter().any(|k| p.get(*k).is_some()) {
+53 -36
View File
@@ -9,7 +9,7 @@
use std::collections::{BTreeMap, HashMap};
use std::sync::Arc;
use serde_json::{Value, json};
use serde_json::Value;
use solidcraft_geom::mesh_distance::box_gap;
use solidcraft_geom::prelude::*;
use solidcraft_geom::{Aabb3, Mesh};
@@ -17,9 +17,20 @@ use solidcraft_kernel::{BodyDistance, DistanceShape};
use super::CommandSpec;
use crate::params::{bad, num, str_, string_list};
use crate::schema::clearance::{
Clearance as ClearanceReply, ClearanceParams, MAX_CLEARANCE, MAX_NEAREST, NearPair, Neighbour, Overlap, OverlapBounds, Unchecked,
};
use crate::schema::{TypedContract, output_schema_for};
use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("inspect.clearance", "Clearance", clearance).icon("interference").noundo().params(
const CONTRACT: TypedContract<ClearanceParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("inspect.clearance", "Clearance", crate::checked!("inspect.clearance", clearance))
.icon("interference")
.noundo()
.contract(&CONTRACT)
.output(output_schema_for::<ClearanceReply>)
.params(
"bodies?: [names] (default: every solid body, in world placement, but those in `against`; mesh bodies are not measured); against?: [names] (only pairs with one body in `bodies` and the other here); \
min_volume?: mm³ (default 0.01: smaller overlaps count as contact); clearance?: mm (default 0: report pairs closer than this); \
nearest?: n (each body of `bodies` gets its n nearest others); skip_within?: \"occurrence\" (pairs of bodies of one occurrence) | \
@@ -34,10 +45,6 @@ pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("inspect.clearance", "C
const MAX_BODIES: usize = 20_000;
/// Most pairs whose grown boxes meet that one call measures.
const MAX_PAIRS: usize = 200_000;
/// Most neighbours a body's `nearest` list holds.
const MAX_NEAREST: usize = 1000;
/// Largest clearance (mm).
const MAX_CLEARANCE: f64 = 1e6;
#[derive(Clone, Copy, PartialEq, Eq)]
enum SkipWithin {
@@ -261,48 +268,56 @@ fn clearance(s: &mut Session, p: &Value) -> Result<Value> {
for k in ["bodies", "against"] {
asked.extend(string_list(cmd, p, k)?);
}
let mut unchecked: Vec<Value> = mesh_bodies
let mut unchecked: Vec<Unchecked> = mesh_bodies
.iter()
.filter(|b| p.get("bodies").is_none_or(Value::is_null) || asked.contains(&b.name))
.map(|b| json!({"a": b.name, "b": null, "reason": super::interference::MESH_NOT_CHECKED}))
.map(|b| Unchecked { a: b.name.clone(), b: None, reason: super::interference::MESH_NOT_CHECKED.to_string() })
.collect();
let name = |i: usize| solid.get(i).map_or("", |b| b.name.as_str());
let pair = |i: usize, j: usize, reason: String| Unchecked { a: name(i).to_string(), b: Some(name(j).to_string()), reason };
for (&(i, j), r) in close.iter().zip(results) {
let r = match r {
Ok(r) => r,
Err(e) => {
unchecked.push(json!({"a": name(i), "b": name(j), "reason": e.to_string()}));
unchecked.push(pair(i, j, e.to_string()));
continue;
}
};
if let Some(reason) = r.unchecked {
unchecked.push(json!({"a": name(i), "b": name(j), "reason": reason}));
unchecked.push(pair(i, j, reason));
continue;
}
if let Some((volume, area, bbox, center)) = r.overlap
&& volume >= min_volume
{
overlaps.push(json!({"a": name(i), "b": name(j), "volume_mm3": volume, "area_mm2": area,
"bbox": {"min": bbox.min, "max": bbox.max}, "center": center}));
overlaps.push(Overlap {
a: name(i).to_string(),
b: name(j).to_string(),
volume_mm3: volume,
area_mm2: area,
bbox: OverlapBounds { min: bbox.min.into(), max: bbox.max.into() },
center: center.into(),
});
known.insert((i, j), 0.0);
continue;
}
let Some(d) = r.distance else { continue };
known.insert((i, j), d.distance);
if d.distance < gap {
let mut e = json!({"a": name(i), "b": name(j), "distance_mm": d.distance, "from": d.from, "to": d.to});
if let Some((v, ..)) = r.overlap.filter(|o| o.0 > 0.0) {
e["overlap_mm3"] = json!(v);
}
if !d.exact {
e["exact"] = json!(false);
}
near.push(e);
near.push(NearPair {
a: name(i).to_string(),
b: name(j).to_string(),
distance_mm: d.distance,
from: d.from.into(),
to: d.to.into(),
overlap_mm3: r.overlap.filter(|o| o.0 > 0.0).map(|o| o.0),
exact: (!d.exact).then_some(false),
});
}
}
near.sort_by(|x, y| x["distance_mm"].as_f64().unwrap_or(0.0).total_cmp(&y["distance_mm"].as_f64().unwrap_or(0.0)));
near.sort_by(|x, y| x.distance_mm.total_cmp(&y.distance_mm));
let mut nearest_out = serde_json::Map::new();
let mut nearest_out = BTreeMap::new();
if nearest > 0 {
let lists = nearest_lists(&subjects, n, nearest, &boxes, &allowed, &mut known, |pairs| {
solidcraft_kernel::map_with_local_pools(pairs, usize::MAX, |&(i, j)| {
@@ -311,23 +326,25 @@ fn clearance(s: &mut Session, p: &Value) -> Result<Value> {
})
});
for (i, list) in lists {
let rows: Vec<Value> = list.iter().map(|&(j, d)| json!({"body": name(j), "distance_mm": d})).collect();
nearest_out.insert(name(i).to_string(), Value::Array(rows));
let rows = list.iter().map(|&(j, d)| Neighbour { body: name(j).to_string(), distance_mm: d }).collect();
nearest_out.insert(name(i).to_string(), rows);
}
}
Ok(json!({
"overlaps": overlaps,
"near": near,
"nearest": nearest_out,
"unchecked": unchecked,
"bodies": subjects.len(),
"pairs_checked": close.len(),
"pairs_reused": reused,
"pairs_pruned": total.saturating_sub(close.len()),
"truncated": truncated > 0,
"ms": started.elapsed().as_secs_f64() * 1000.0,
}))
let reply = ClearanceReply {
overlaps,
near,
nearest: nearest_out,
unchecked,
bodies: subjects.len(),
pairs_checked: close.len(),
pairs_reused: reused,
pairs_pruned: total.saturating_sub(close.len()),
truncated: truncated > 0,
ms: started.elapsed().as_secs_f64() * 1000.0,
param_warnings: Vec::new(),
};
serde_json::to_value(reply).map_err(|e| bad(cmd, e.to_string()))
}
/// One pair: its overlap when the meshes meet or one holds the other, otherwise its distance
+75 -56
View File
@@ -5,7 +5,7 @@
//! well as any; each place's volume, centre and box are then measured exactly, on the pieces of
//! the two cuts a − b and b − a that lie there, as `inspect.measure` measures a body.
use serde_json::{Value, json};
use serde_json::Value;
use solidcore_record::Vec3;
use solidcraft_geom::Aabb3;
use solidcraft_geom::compare::{self, CompareOptions, Side};
@@ -13,9 +13,19 @@ use solidcraft_geom::prelude::*;
use super::CommandSpec;
use crate::params::bad;
use crate::schema::compare::{
Bounds, CompareParams, CompareSide, Comparison, Deviation, Deviations, Difference, MeasuredBy, Region, Resolution, Sampling, SideTotal,
};
use crate::schema::{TypedContract, output_schema_for};
use crate::{Result, Sel, Session};
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("inspect.compare", "Compare Bodies", compare_bodies).icon("compare").params(
const CONTRACT: TypedContract<CompareParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("inspect.compare", "Compare Bodies", crate::checked!("inspect.compare", compare_bodies))
.icon("compare")
.contract(&CONTRACT)
.output(output_schema_for::<Comparison>)
.params(
"a, b: body names (default: the two selected bodies, or the design's only two), in world placement; tolerance?: mm (0.001 to 10, default 0.01): \
surfaces closer than this agree; spacing?: mm (default 0.5): the largest piece of a face sampled as one point, coarser for a body that would need \
more than max_points? (default 8 million, up to 50 million) samples; max_regions? (default 50); color?: true | {only_in_a?, only_in_b?: \"#rrggbb\"} \
@@ -32,10 +42,10 @@ pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("inspect.compare", "Com
const ONLY_IN_A: [u8; 3] = [0xe0, 0x31, 0x31];
const ONLY_IN_B: [u8; 3] = [0x2f, 0x9e, 0x44];
fn side_name(s: Side) -> &'static str {
fn side_of(s: Side) -> CompareSide {
match s {
Side::OnlyInA => "only_in_a",
Side::OnlyInB => "only_in_b",
Side::OnlyInA => CompareSide::OnlyInA,
Side::OnlyInB => CompareSide::OnlyInB,
}
}
@@ -105,21 +115,21 @@ fn v3(v: &Value) -> Option<Vec3> {
}
/// b − a of the two bodies' measures.
fn difference(ma: &Value, mb: &Value) -> Value {
fn difference(ma: &Value, mb: &Value) -> Difference {
let f = |m: &Value, k: &str| m.get(k).and_then(Value::as_f64);
let sub = |x: Option<f64>, y: Option<f64>| x.zip(y).map(|(x, y)| y - x);
let (va, vb) = (f(ma, "volume_mm3"), f(mb, "volume_mm3"));
let com = v3(&ma["center_of_mass"]).zip(v3(&mb["center_of_mass"])).map(|(a, b)| b - a);
let corner = |k: &str| v3(&ma["bbox"][k]).zip(v3(&mb["bbox"][k])).map(|(a, b)| b - a);
json!({
"volume_mm3": sub(va, vb),
"volume_percent": va.zip(vb).filter(|(a, _)| a.abs() > 0.0).map(|(a, b)| 100.0 * (b - a) / a),
"area_mm2": sub(f(ma, "area_mm2"), f(mb, "area_mm2")),
"center_of_mass": com,
"center_of_mass_distance_mm": com.map(Vec3::len),
"bbox_min": corner("min"),
"bbox_max": corner("max"),
})
let corner = |k: &str| v3(&ma["bbox"][k]).zip(v3(&mb["bbox"][k])).map(|(a, b)| (b - a).into());
Difference {
volume_mm3: sub(va, vb),
volume_percent: va.zip(vb).filter(|(a, _)| a.abs() > 0.0).map(|(a, b)| 100.0 * (b - a) / a),
area_mm2: sub(f(ma, "area_mm2"), f(mb, "area_mm2")),
center_of_mass: com.map(Into::into),
center_of_mass_distance_mm: com.map(Vec3::len),
bbox_min: corner("min"),
bbox_max: corner("max"),
}
}
/// The volume a closed mesh encloses (divergence theorem, about its box's centre).
@@ -245,8 +255,8 @@ fn exact_regions(
(measured, notes)
}
fn deviation_json(d: &compare::Deviation) -> Value {
json!({"max_mm": d.max, "mean_mm": d.mean, "at": d.at, "area_beyond_mm2": d.beyond_area, "area_mm2": d.area, "points": d.points})
fn deviation(d: &compare::Deviation) -> Deviation {
Deviation { max_mm: d.max, mean_mm: d.mean, at: d.at.into(), area_beyond_mm2: d.beyond_area, area_mm2: d.area, points: d.points }
}
fn compare_bodies(s: &mut Session, p: &Value) -> Result<Value> {
@@ -308,30 +318,36 @@ fn compare_bodies(s: &mut Session, p: &Value) -> Result<Value> {
exact_notes.iter().find(mine).or_else(|| exact_notes.last()).cloned().unwrap_or_else(|| "the exact cut gave no piece here".to_string())
};
let axis = |k: usize| ["X", "Y", "Z"].get(k).copied().unwrap_or("?");
let regions: Vec<Value> = order
let regions: Vec<Region> = order
.iter()
.enumerate()
.filter_map(|(i, &k)| c.regions.get(k).map(|r| (i, k, r)))
.map(|(i, k, r)| {
let m = measured(k);
json!({
"index": i + 1,
"side": side_name(r.side),
"material_of": if r.side == Side::OnlyInA { &na } else { &nb },
"volume_mm3": m.map(|m| m.volume),
"measured_by": if m.is_some() { "boolean" } else { "not measured" },
"not_measured": if m.is_some() { Value::Null } else { json!(reason(k)) },
"area_mm2": r.area_a + r.area_b,
"area_a_mm2": r.area_a,
"area_b_mm2": r.area_b,
"max_deviation_mm": r.max_deviation,
"at": r.at,
"center": m.map(|m| m.center),
"bbox": m.map(|m| json!({"min": m.bbox.min, "max": m.bbox.max})).unwrap_or_else(|| json!({"min": r.bbox.min, "max": r.bbox.max})),
"faces_a": r.faces_a.iter().map(|x| x.0).collect::<Vec<u32>>(),
"faces_b": r.faces_b.iter().map(|x| x.0).collect::<Vec<u32>>(),
"resolution": {"along": axis(r.lines.axis), "line_spacing_mm": r.lines.spacing, "lines": r.lines.count, "skipped_lines": r.lines.skipped},
})
let bbox = m.map_or(r.bbox, |m| m.bbox);
Region {
index: i + 1,
side: side_of(r.side),
material_of: if r.side == Side::OnlyInA { na.clone() } else { nb.clone() },
volume_mm3: m.map(|m| m.volume),
measured_by: if m.is_some() { MeasuredBy::Boolean } else { MeasuredBy::NotMeasured },
not_measured: if m.is_some() { None } else { Some(reason(k)) },
area_mm2: r.area_a + r.area_b,
area_a_mm2: r.area_a,
area_b_mm2: r.area_b,
max_deviation_mm: r.max_deviation,
at: r.at.into(),
center: m.map(|m| m.center.into()),
bbox: Bounds { min: bbox.min.into(), max: bbox.max.into() },
faces_a: r.faces_a.iter().map(|x| x.0).collect(),
faces_b: r.faces_b.iter().map(|x| x.0).collect(),
resolution: Resolution {
along: axis(r.lines.axis).to_string(),
line_spacing_mm: r.lines.spacing,
lines: r.lines.count,
skipped_lines: r.lines.skipped,
},
}
})
.collect();
// A side's total, or none when one of its regions is not measured.
@@ -361,25 +377,28 @@ fn compare_bodies(s: &mut Session, p: &Value) -> Result<Value> {
if !warnings.is_empty() {
verdict += &format!(" ({} warning{})", warnings.len(), if warnings.len() == 1 { "" } else { "s" });
}
let mut out = json!({
"a": measure_a,
"b": measure_b,
"difference": difference(&measure_a, &measure_b),
"tolerance_mm": tolerance,
"deviation": {"a_to_b": deviation_json(&c.a_to_b), "b_to_a": deviation_json(&c.b_to_a), "max_mm": worst},
"identical": identical,
"verdict": verdict,
"regions": regions,
"only_in_a": {"regions": ca, "volume_mm3": va},
"only_in_b": {"regions": cb, "volume_mm3": vb},
"unlisted_regions": c.unlisted,
"warnings": warnings,
"sampling": {"spacing_mm": c.spacing, "chord_mm": chord, "triangles_a": mesh_a.triangles.len(), "triangles_b": mesh_b.triangles.len()},
});
if let Some(colors) = colors {
out["colored_faces"] = json!(color_regions(s, &c, [&na, &nb], colors)?);
}
Ok(out)
let colored_faces = match colors {
Some(colors) => Some(color_regions(s, &c, [&na, &nb], colors)?),
None => None,
};
let out = Comparison {
difference: difference(&measure_a, &measure_b),
a: measure_a.as_object().cloned().unwrap_or_default(),
b: measure_b.as_object().cloned().unwrap_or_default(),
tolerance_mm: tolerance,
deviation: Deviations { a_to_b: deviation(&c.a_to_b), b_to_a: deviation(&c.b_to_a), max_mm: worst },
identical,
verdict,
regions,
only_in_a: SideTotal { regions: ca, volume_mm3: va },
only_in_b: SideTotal { regions: cb, volume_mm3: vb },
unlisted_regions: c.unlisted,
warnings,
sampling: Sampling { spacing_mm: c.spacing, chord_mm: chord, triangles_a: mesh_a.triangles.len(), triangles_b: mesh_b.triangles.len() },
colored_faces,
param_warnings: Vec::new(),
};
serde_json::to_value(out).map_err(|e| bad(cmd, e.to_string()))
}
/// A face is coloured for a region when at least this share of it deviates there.
+77
View File
@@ -331,6 +331,11 @@ fn row(s: &mut Session, p: &Value) -> Result<Value> {
if rename.as_ref().is_some_and(|n| n != &name && s.doc.configs.rows.iter().any(|r| &r.name == n)) {
return Err(bad(cmd, "configuration name already exists"));
}
for (column, value) in s.doc.configs.columns.iter().zip(&cells) {
if matches!(column, Column::Suppress(_)) {
solidcraft_doc::config::suppression_cell(value).map_err(|e| bad(cmd, e.to_string()))?;
}
}
let changes: Vec<_> = s
.doc
.configs
@@ -743,6 +748,78 @@ mod tests {
}
}
#[test]
fn configuration_rows_reject_invalid_suppression_without_changes() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane":"XY", "name":"TestSketch"}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "config.configure", json!({"features":["TestSketch"]}));
let before = s.doc.clone();
let undo_len = s.undo.len();
for value in [json!("banana"), json!(""), json!(2), json!(-1)] {
for params in [
json!({"name":"Bad", "values":{"suppress:TestSketch":value}}),
json!({"name":"Copy", "from":"Default", "values":{"TestSketch":value}}),
json!({"name":"Default", "rename":"Renamed", "values":{"TestSketch":value}}),
] {
let error = s.execute("config.row", &params).expect_err("invalid suppression must be rejected when saving a row");
assert!(error.to_string().contains("suppression must be true or false"), "{params}: {error}");
assert_eq!(s.doc, before, "{params}");
assert_eq!(s.undo.len(), undo_len, "{params}");
}
}
}
#[test]
fn configuration_rows_validate_inherited_suppression_and_allow_repair() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane":"XY", "name":"TestSketch"}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "config.configure", json!({"features":["TestSketch"]}));
// A row saved by an older reader may already contain invalid suppression text.
s.doc_mut().configs.rows[0].cells[0] = "banana".into();
let before = s.doc.clone();
let undo_len = s.undo.len();
for params in [json!({"name":"Copy", "from":"Default"}), json!({"name":"Default", "rename":"Renamed"})] {
let error = s.execute("config.row", &params).expect_err("inherited invalid suppression must also be checked");
assert!(error.to_string().contains("suppression must be true or false"), "{params}: {error}");
assert_eq!(s.doc, before, "{params}");
assert_eq!(s.undo.len(), undo_len, "{params}");
}
run(&mut s, "config.row", json!({"name":"Default", "values":{"TestSketch":false}}));
assert_eq!(s.doc.configs.rows[0].cells[0], "false");
run(&mut s, "config.activate", json!({"row":"Default"}));
assert!(!s.doc.features[0].suppressed);
}
#[test]
fn configuration_rows_keep_supported_suppression_spellings() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane":"XY", "name":"TestSketch"}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "config.configure", json!({"features":["TestSketch"]}));
for (value, suppressed) in [
(json!(true), true),
(json!(false), false),
(json!(1), true),
(json!(0), false),
(json!("true"), true),
(json!("false"), false),
(json!(" YES "), true),
(json!(" No "), false),
(json!("1"), true),
(json!("0"), false),
] {
let before = s.doc.features.clone();
run(&mut s, "config.row", json!({"name":"Accepted", "values":{"suppress:TestSketch":value}}));
assert_eq!(s.doc.features, before, "saving a row does not activate it");
let text = value.as_str().map(str::to_owned).unwrap_or_else(|| value.to_string());
assert_eq!(s.doc.configs.rows[1].cells[0], text, "accepted spellings are preserved");
run(&mut s, "config.activate", json!({"row":"Accepted"}));
assert_eq!(s.doc.features[0].suppressed, suppressed, "{value}");
}
}
#[test]
fn configuration_rows_validate_the_final_parameter_graph_without_activating_it() {
let mut s = Session::default();
@@ -408,3 +408,24 @@ fn a_motion_link_takes_its_driven_joints_freedom_with_nothing_grounded() {
}
}
}
/// Rolling the marker back works a gear's ratio out from the faces as they were then, so the
/// editor's pinned model and its source differ in the link's ratio, and the edit still goes
/// through.
#[test]
fn an_editor_accepts_the_ratios_rolling_the_marker_works_out() {
let mut s = gear_train();
let before = s.doc.features.len();
// G1's wall moves out to a radius of 12, so the link's ratio goes from -0.5 to -0.6.
run(&mut s, "solid.offset_face", json!({"faces": [[10, 0, 2.5]], "distance": 2}));
let ratio = |s: &Session| s.doc.assembly.links.first().map(|l| l.ratio).unwrap_or(f64::NAN);
assert!(close_to(ratio(&s), -0.6), "{}", ratio(&s));
let original = s.scratch();
run(&mut s, "timeline.roll_to", json!({"position": before}));
let source = s.scratch();
assert!(close_to(ratio(&source), -0.5), "before the offset, the pitch radius is 10: {}", ratio(&source));
let prepared = s.scratch().compute_editor_commands(&[], &source, original).unwrap();
s.accept_commands(prepared).unwrap();
assert_eq!(s.doc.marker, None);
assert!(close_to(ratio(&s), -0.6), "{}", ratio(&s));
}
+16 -5
View File
@@ -1,15 +1,24 @@
//! Read-only validation of candidate engineering data; no assignment or simulation.
use super::CommandSpec;
use crate::params::bad;
use crate::schema::material_properties::{CardModulus, CardValidated, DirectionalModulusParams, ValidateCardParams};
use crate::schema::{TypedContract, output_schema_for};
use crate::{Result, Session};
use serde_json::{Value, json};
use serde_json::Value;
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("material.properties.validate", "Validate Physical Material Data", validate)
const VALIDATE_CONTRACT: TypedContract<ValidateCardParams> = TypedContract::new();
const DIRECTIONAL_CONTRACT: TypedContract<DirectionalModulusParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("material.properties.validate", "Validate Physical Material Data", crate::checked!("material.properties.validate", validate))
.icon("material")
.noundo()
.doc_only()
.contract(&VALIDATE_CONTRACT)
.output(output_schema_for::<CardValidated>)
.params("card: schema_version 1 engineering material card in SI units. Returns numerical consistency, derived isotropic moduli, missing conditions and sources needing review. Read-only; does not assign/save properties or provide an analysis solver. See docs/physical-material-cards.md"),
CommandSpec::new("material.properties.directional", "Directional Elastic Modulus", directional).icon("material").noundo().doc_only()
CommandSpec::new("material.properties.directional", "Directional Elastic Modulus", crate::checked!("material.properties.directional", directional)).icon("material").noundo().doc_only()
.contract(&DIRECTIONAL_CONTRACT)
.output(output_schema_for::<CardModulus>)
.params("card: schema_version 1 engineering material card; direction: three finite nonzero-vector components in the card's material-local axes. Returns normalized direction, Young modulus in Pa and source/condition review. Read-only constitutive query; no structural solver, persistence or body-axis assignment. See docs/physical-material-cards.md")];
fn directional(_: &mut Session, params: &Value) -> Result<Value> {
@@ -28,7 +37,7 @@ fn directional(_: &mut Session, params: &Value) -> Result<Value> {
let direction = [component(0)?, component(1)?, component(2)?];
let card = solidcraft_doc::physical_material::admit_card(value).map_err(|e| bad(cmd, e.to_string()))?;
let response = card.directional_modulus(direction).map_err(|e| bad(cmd, e.to_string()))?;
serde_json::to_value(response).map_err(|e| bad(cmd, e.to_string()))
serde_json::to_value(CardModulus::from(response)).map_err(|e| bad(cmd, e.to_string()))
}
fn validate(_: &mut Session, params: &Value) -> Result<Value> {
@@ -38,13 +47,15 @@ fn validate(_: &mut Session, params: &Value) -> Result<Value> {
.ok_or_else(|| bad("material.properties.validate", "expected {card: ...}"))?;
let card = object.get("card").ok_or_else(|| bad("material.properties.validate", "missing card"))?;
let result = solidcraft_doc::physical_material::validate_card(card).map_err(|e| bad("material.properties.validate", e.to_string()))?;
Ok(json!({"valid":true,"validation":result}))
let reply = CardValidated { valid: true, validation: result.into(), param_warnings: Vec::new() };
serde_json::to_value(reply).map_err(|e| bad("material.properties.validate", e.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::doc::Document;
use serde_json::json;
use std::sync::Arc;
fn card() -> Value {
+38 -13
View File
@@ -10,28 +10,52 @@ use solidcraft_doc::metadata::{self, BOM_TYPE, MAX_KEYS, Owner};
use super::CommandSpec;
use crate::params::{bad, str_};
use crate::schema::metadata::{MetadataParams, MetadataRemoveParams, MetadataRemoveResult, MetadataResult, MetadataSetParams, MetadataSetResult};
use crate::schema::{TypedContract, output_schema_for};
use crate::{Result, Session};
const READ_CONTRACT: TypedContract<MetadataParams> = TypedContract::new();
const SET_CONTRACT: TypedContract<MetadataSetParams> = TypedContract::new();
const REMOVE_CONTRACT: TypedContract<MetadataRemoveParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("document.metadata", "Design Properties", read).icon("settings").noundo().doc_only().params(
"→ metadata: the design's metadata object as saved (other tools' keys too); properties: each of SolidCraft's design \
properties (project, part number, revision…), its text or null (absent, or not text)",
),
CommandSpec::new("document.metadata.set", "Change Design Properties", set).icon("settings").doc_only().params(
"key and value, or values: {key: value, …} (all or nothing, one undo step). SolidCraft's keys, the ones document.metadata's \
properties lists, take text (\"\" or null removes); another tool's data goes under its reverse-DNS key such as \
com.example.tool (an object; null removes) → metadata, changed: [keys]",
),
CommandSpec::new("document.metadata.remove", "Remove Design Property", remove)
CommandSpec::new("document.metadata", "Design Properties", crate::checked!("document.metadata", read))
.icon("settings")
.noundo()
.doc_only()
.contract(&READ_CONTRACT)
.output(output_schema_for::<MetadataResult>)
.params(
"→ metadata: the design's metadata object as saved (other tools' keys too); properties: each of SolidCraft's design \
properties (project, part number, revision…), its text or null (absent, or not text)",
),
CommandSpec::new("document.metadata.set", "Change Design Properties", crate::checked!("document.metadata.set", set))
.icon("settings")
.doc_only()
.contract(&SET_CONTRACT)
.output(output_schema_for::<MetadataSetResult>)
.params(
"key and value, or values: {key: value, …} (all or nothing, one undo step). SolidCraft's keys, the ones document.metadata's \
properties lists, take text (\"\" or null removes); another tool's data goes under its reverse-DNS key such as \
com.example.tool (an object; null removes) → metadata, changed: [keys]",
),
CommandSpec::new("document.metadata.remove", "Remove Design Property", crate::checked!("document.metadata.remove", remove))
.icon("delete")
.doc_only()
.contract(&REMOVE_CONTRACT)
.output(output_schema_for::<MetadataRemoveResult>)
.params("key, or keys: [keys] (any key, also one another tool wrote) → metadata, removed: [keys that were there]"),
];
/// A reply built from its schema's type, so the two can't drift apart.
fn reply(cmd: &str, r: impl serde::Serialize) -> Result<Value> {
serde_json::to_value(r).map_err(|e| bad(cmd, e.to_string()))
}
fn read(s: &mut Session, _: &Value) -> Result<Value> {
let m = &s.doc.metadata;
let properties: Map<String, Value> = metadata::keys(Owner::Design).map(|k| (k.name.to_string(), json!(m.text(k.name)))).collect();
Ok(json!({"metadata": m, "properties": properties}))
reply("document.metadata", MetadataResult { metadata: m.0.clone(), properties })
}
fn set(s: &mut Session, p: &Value) -> Result<Value> {
@@ -60,7 +84,7 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
if next != s.doc.metadata {
s.doc_mut().metadata = next;
}
Ok(json!({"metadata": s.doc.metadata, "changed": changed}))
reply(cmd, MetadataSetResult { metadata: s.doc.metadata.0.clone(), changed, param_warnings: Vec::new() })
}
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
@@ -84,7 +108,8 @@ fn remove(s: &mut Session, p: &Value) -> Result<Value> {
m.remove(k);
}
}
Ok(json!({"metadata": s.doc.metadata, "removed": removed}))
let removed = removed.into_iter().map(str::to_string).collect();
reply(cmd, MetadataRemoveResult { metadata: s.doc.metadata.0.clone(), removed, param_warnings: Vec::new() })
}
#[cfg(test)]
+1
View File
@@ -46,6 +46,7 @@ pub(crate) use features::profiles as feature_profiles;
pub(crate) use features::{EdgeRefForm, PLANE_FORMS, axis_dir_form, axis_line_object, edge_ref_form, explicit_plane, plane_name_problem, sketch_key};
pub use features::{auto_operation, extrude_reach, shown_scope};
pub use file::open_steps::OpenSteps;
pub(crate) use inspect::color_param;
pub use join_pieces::{Addition, addition};
pub use parts::part_length_label;
pub use sketch::dimension_roles;
+78
View File
@@ -541,6 +541,84 @@ fn isolate_nested_child_hides_ancestor_material_and_sibling() {
}
}
/// #7896: isolating a body hides its own component's other bodies one by one and every other
/// placement as a placement, so the Browser row of a component shows it is off and its eye
/// brings it back. A second placement of the body's own component is another placement.
/// #7896: isolating a body inside a hidden Bodies folder or group shows that folder and group
/// again, so the body is visible; the folder's other bodies stay hidden.
#[test]
fn isolate_shows_the_hidden_folder_and_group_a_picked_body_is_in() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Kept"}));
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"corner":[20,0,0],"body_name":"Other","operation":"new"}));
let group = run(&mut s, "browser.group", json!({"folder": "bodies", "items": ["Kept"], "name": "Held"}));
let group = group["group"].as_u64().or_else(|| group["id"].as_u64()).expect("the group's id");
run(&mut s, "browser.visibility", json!({"tree_folders": ["c0/bodies", format!("c0/g{group}")], "visible": false}));
assert!(!s.body_visible("Kept") && !s.body_visible("Other"));
run(&mut s, "browser.visibility", json!({"items": ["Kept"], "visible": true, "isolate": true}));
assert!(s.body_visible("Kept"), "the picked body shows: {:?}", s.visibility.hidden_folders);
assert!(!s.body_visible("Other"), "the folder's other body stays hidden");
assert!(s.visibility.hidden_folders.is_empty(), "{:?}", s.visibility.hidden_folders);
}
#[test]
fn isolate_bodies_hides_other_components_as_components() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"corner":[-20,0,0],"body_name":"Root"}));
let comp = |s: &mut Session, name: &str| {
let c = run(s, "component.create", json!({"name": name, "parent": "root", "grounded": false}));
c["occurrence"].as_u64().unwrap()
};
let a = comp(&mut s, "CompA");
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"BoxA"}));
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"corner":[0,0,20],"body_name":"BoxA2"}));
let b = comp(&mut s, "CompB");
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"corner":[20,0,0],"body_name":"BoxB1"}));
run(&mut s, "solid.box", json!({"length":5,"width":5,"height":5,"corner":[40,0,0],"body_name":"BoxB2"}));
let a2 = run(&mut s, "occurrence.copy", json!({"occurrence":a,"translate":[0,40,0]}))["occurrence"].as_u64().unwrap();
let in_a = s.occurrence_bodies(&[a], false).unwrap();
let (box_a, box_a2) = (in_a.iter().find(|n| !n.contains("BoxA2")).unwrap().clone(), in_a.iter().find(|n| n.contains("BoxA2")).unwrap().clone());
let out = run(&mut s, "browser.visibility", json!({"visible":true,"items":[box_a],"isolate":true}));
assert_eq!(out["isolated"], json!([box_a]));
let mut occurrences = s.visibility.hidden_occurrences.clone();
occurrences.sort();
let mut others = vec![vec![b], vec![a2]];
others.sort();
assert_eq!(occurrences, others, "CompB and CompA's other placement go as placements");
let mut bodies = s.visibility.hidden_bodies.clone();
bodies.sort();
assert_eq!(bodies, vec![box_a2.clone(), "Root".to_string()], "only bodies beside BoxA go one by one");
assert!(s.body_visible(&box_a) && !s.body_visible(&box_a2) && !s.body_visible("Root"));
for name in s.occurrence_bodies(&[b], false).unwrap().iter().chain(&s.occurrence_bodies(&[a2], false).unwrap()) {
assert!(!s.body_visible(name), "{name}");
}
// CompB's eye brings it back whole.
run(&mut s, "browser.visibility", json!({"visible":true,"paths":[[b]]}));
assert!(s.occurrence_bodies(&[b], false).unwrap().iter().all(|n| s.body_visible(n)));
// Isolating a root body hides every placement as a placement and no body one by one.
run(&mut s, "browser.visibility", json!({"visible":true,"items":["Root"],"isolate":true}));
let mut occurrences = s.visibility.hidden_occurrences.clone();
occurrences.sort();
let mut all = vec![vec![a], vec![b], vec![a2]];
all.sort();
assert_eq!(occurrences, all);
assert!(s.visibility.hidden_bodies.is_empty() && s.body_visible("Root"), "{:?}", s.visibility.hidden_bodies);
// Only bodies isolate, and only shown; a refusal changes nothing.
let before = s.visibility.clone();
for p in [
json!({"visible":false,"items":["Root"],"isolate":true}),
json!({"visible":true,"items":["XY"],"isolate":true}),
json!({"visible":true,"items":["Nope"],"isolate":true}),
json!({"visible":true,"folder":"bodies","isolate":true}),
] {
assert!(s.execute("browser.visibility", &p).is_err(), "{p}");
}
assert_eq!(s.visibility, before);
}
#[test]
fn context_new_child_uses_inactive_parent_placement() {
let mut s = Session::default();
+1 -1
View File
@@ -404,7 +404,7 @@ mod tests {
s.run_script(&crate::sample::script()).unwrap();
let lip = json!({"body": "Plate", "face": [51.89, 45.2, 8.0], "width": "1.5 mm", "height": "3.5 mm", "side": "inside", "type": "lip"});
let e = s.execute("plastic.lip", &lip).unwrap_err().to_string();
assert!(e.contains("rim face") && e.contains("2 openings alike"), "{e}");
assert!(e.contains("one clear opening") && e.contains("2 openings alike"), "{e}");
// The boss alone, no holes: its foot is not an opening either.
let mut b = Session::default();
run(&mut b, "solid.box", json!({"length": 80, "width": 50, "height": 8, "body_name": "Plate"}));
+9 -3
View File
@@ -91,11 +91,17 @@ CommandSpec::new("render.library.preview", "Inspect Original Surface Map", libra
.params("id: SURF-*. Returns bounded hash-verified original base-color PNG bytes and source-map fidelity label, not a rendered material thumbnail. No external paths, rendering or document edits."),
CommandSpec::new("render.library.physical.prepare", "Prepare Physical Reference", physical_library::prepare).icon("inspect").noundo().doc_only()
.params("id: named built-in physical profile, name: proposed new material label, acknowledge_reference:true. Read-only loss-aware creation plan using native ValidatedCard admission and one point density; returns source receipts, extended fields and omissions. Does not execute material.create, assign bodies or infer effective-part physics."),
CommandSpec::new("render.stage.ground", "Save Render Ground", staging::ground).icon("render_scene")
CommandSpec::new("render.stage.ground", "Save Render Ground", crate::checked!("render.stage.ground", staging::ground)).icon("render_scene")
.contract(&staging::GROUND_CONTRACT)
.output(crate::schema::output_schema_for::<crate::schema::render_stage::StageSaved>)
.params("name: typed raytrace setup, set?: setup patch, margin_fraction?:0.25, gap_mm?:0.1, reflectance_linear?:[0.18,0.18,0.18], replace_stage?:false. Fits one finite passive ground below the visible product once and saves its physical transforms in one undo. Product AABB separation is rechecked on every render."),
CommandSpec::new("render.stage.backdrop", "Save Physical Render Background", staging::backdrop).icon("render_scene").doc_only()
CommandSpec::new("render.stage.backdrop", "Save Physical Render Background", crate::checked!("render.stage.backdrop", staging::backdrop)).icon("render_scene").doc_only()
.contract(&staging::BACKDROP_CONTRACT)
.output(crate::schema::output_schema_for::<crate::schema::render_stage::StageSaved>)
.params("name: typed raytrace setup, set?: setup patch, kind: floor_wall or cyclorama, reflectance_linear?: [0.18,0.18,0.18], expected_revision: reviewed revision, replace_stage?:false. Fits a finite floor/wall or a 24-strip polygonal cyclorama behind the visible subject using the saved camera azimuth; world +Z up and a minimum one-mm stage envelope. Preserves subject geometry, lighting, output, pose and engineering materials; one undo and rechecked product separation. Not an infinite/analytically smooth/terrain background."),
CommandSpec::new("render.stage.clear", "Remove Render Stage", staging::clear).icon("render_scene").doc_only()
CommandSpec::new("render.stage.clear", "Remove Render Stage", crate::checked!("render.stage.clear", staging::clear)).icon("render_scene").doc_only()
.contract(&staging::CLEAR_CONTRACT)
.output(crate::schema::output_schema_for::<crate::schema::render_stage::StageSaved>)
.params("name: setup, set?: setup patch. Removes saved render-only panels atomically; CAD geometry and lighting are unchanged."),
CommandSpec::new("render.library.surface.bind", "Save Surface Binding", surface_bindings::bind).icon("appearance")
.params("name: GPU setup; set?: setup patch; surface: same input as render.library.surface.prepare; replace?: false. Pins exact source hashes and definition/feature/occurrence identity with local-mm mapping in one undo. No paths, mesh ordinals or physical material changes."),
+45 -75
View File
@@ -1,7 +1,8 @@
//! Explicit saved render-only ground. Does not add engineering bodies or material cards.
use crate::schema::TypedContract;
use crate::schema::render_stage::{BackdropKind, BackdropParams, ClearParams, GroundParams, StageSaved};
use crate::{Result, Session, params::bad};
use serde::Deserialize;
use serde_json::{Value, json};
use serde_json::Value;
use solidcraft_doc::render_settings::{RenderJob, RenderSetup};
use solidcraft_render::{
ProductScene,
@@ -9,49 +10,41 @@ use solidcraft_render::{
stage::{Stage, StagePanel},
};
use std::sync::Arc;
fn patch() -> Value {
json!({})
}
fn margin() -> f64 {
0.25
}
fn gap() -> f64 {
0.1
}
fn reflectance() -> [f64; 3] {
[0.18; 3]
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Ground {
name: String,
#[serde(default = "patch")]
set: Value,
#[serde(default = "margin")]
margin_fraction: f64,
#[serde(default = "gap")]
gap_mm: f64,
#[serde(default = "reflectance")]
reflectance_linear: [f64; 3],
#[serde(default)]
replace_stage: bool,
}
pub(super) const GROUND_CONTRACT: TypedContract<GroundParams> = TypedContract::new();
pub(super) const BACKDROP_CONTRACT: TypedContract<BackdropParams> = TypedContract::new();
pub(super) const CLEAR_CONTRACT: TypedContract<ClearParams> = TypedContract::new();
pub(super) fn apply(scene: &mut ProductScene, setup: &RenderSetup, command: &str) -> Result<()> {
setup.stage.append_to(scene, &CancelToken::default()).map_err(|e| bad(command, e.to_string()))
}
fn save(s: &mut Session, name: String, setup: RenderSetup, command: &str) -> Result<Value> {
fn save(s: &mut Session, name: String, setup: RenderSetup, command: &str) -> Result<StageSaved> {
let mut settings = (*s.doc.render).clone();
settings.setups.insert(name.clone(), setup.clone());
settings.validate().map_err(|e| bad(command, e.to_string()))?;
let setup = serde_json::to_value(&setup).map_err(|e| bad(command, e.to_string()))?;
if settings != *s.doc.render {
s.doc_mut().render = Arc::new(settings);
}
Ok(json!({"name":name,"setup":setup,"engineering_geometry_unchanged":true,"stage_only":true,
"clearance":"conservative product AABB separation; not a B-rep collision proof"}))
Ok(StageSaved {
name,
setup,
engineering_geometry_unchanged: true,
stage_only: true,
clearance: "conservative product AABB separation; not a B-rep collision proof".into(),
geometry_kind: None,
light_transport: None,
camera_preserved: None,
limitations: None,
param_warnings: Vec::new(),
})
}
fn reply(command: &str, saved: StageSaved) -> Result<Value> {
serde_json::to_value(saved).map_err(|e| bad(command, e.to_string()))
}
pub(super) fn ground(s: &mut Session, params: &Value) -> Result<Value> {
let command = "render.stage.ground";
let input: Ground = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
let input: GroundParams = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
if !input.margin_fraction.is_finite()
|| !(0.0..=10.0).contains(&input.margin_fraction)
|| !input.gap_mm.is_finite()
@@ -88,48 +81,22 @@ pub(super) fn ground(s: &mut Session, params: &Value) -> Result<Value> {
}])
.map_err(|e| bad(command, e.to_string()))?;
apply(&mut scene, &setup, command)?;
save(s, input.name, setup, command)
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Clear {
name: String,
#[serde(default = "patch")]
set: Value,
reply(command, save(s, input.name, setup, command)?)
}
pub(super) fn clear(s: &mut Session, params: &Value) -> Result<Value> {
let command = "render.stage.clear";
let input: Clear = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
let input: ClearParams = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
let mut setup = super::patched_settings(s, &input.name, &input.set, command)?;
setup.stage = Stage::default();
save(s, input.name, setup, command)
reply(command, save(s, input.name, setup, command)?)
}
#[derive(Clone, Copy, Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
enum BackdropKind {
FloorWall,
Cyclorama,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct BackdropInput {
name: String,
#[serde(default = "patch")]
set: Value,
kind: BackdropKind,
#[serde(default = "reflectance")]
reflectance_linear: [f64; 3],
#[serde(default)]
replace_stage: bool,
expected_revision: u64,
}
/// Original world-space stage derived from captured subject bounds and the chosen
/// camera azimuth. Twenty-four explicit planar strips approximate the round cove;
/// it is not an analytic smooth surface, terrain or engineering model operation.
pub(super) fn backdrop(s: &mut Session, params: &Value) -> Result<Value> {
let command = "render.stage.backdrop";
let input: BackdropInput = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
let input: BackdropParams = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
if input.expected_revision != s.revision {
return Err(bad(command, "the design changed; review the stage again"));
}
@@ -204,17 +171,20 @@ pub(super) fn backdrop(s: &mut Session, params: &Value) -> Result<Value> {
setup.stage = Stage::new(panels).map_err(|e| bad(command, e.to_string()))?;
apply(&mut scene, &setup, command)?;
let mut result = save(s, input.name, setup, command)?;
result["geometry_kind"] = json!(match input.kind {
BackdropKind::FloorWall => "finite_floor_and_wall",
BackdropKind::Cyclorama => "24_segment_polygonal_cyclorama",
});
result["light_transport"] =
json!("nonemissive world-space stage; receives native lighting and casts reflections/shadows supported by the selected executor");
result["camera_preserved"] = json!(true);
result["limitations"] = json!([
"Finite background, not an infinite floor or a terrain mesh.",
"Cyclorama is an explicit 24-strip polygonal approximation, not a smooth CAD face.",
"No change to HDR, light energy, subject optics, pose or engineering properties."
result.geometry_kind = Some(
match input.kind {
BackdropKind::FloorWall => "finite_floor_and_wall",
BackdropKind::Cyclorama => "24_segment_polygonal_cyclorama",
}
.into(),
);
result.light_transport =
Some("nonemissive world-space stage; receives native lighting and casts reflections/shadows supported by the selected executor".into());
result.camera_preserved = Some(true);
result.limitations = Some(vec![
"Finite background, not an infinite floor or a terrain mesh.".into(),
"Cyclorama is an explicit 24-strip polygonal approximation, not a smooth CAD face.".into(),
"No change to HDR, light energy, subject optics, pose or engineering properties.".into(),
]);
Ok(result)
reply(command, result)
}
+3 -2
View File
@@ -481,7 +481,7 @@ pub(super) mod solve_counts {
}
/// How close (mm) a new point must land to an existing one to be connected to it.
const CONNECT_TOL: f64 = 1e-6;
pub(super) const CONNECT_TOL: f64 = 1e-6;
/// New points (index `first` on) that land on an existing point, the origin included, get a
/// coincident constraint to it, as when a click snaps there; `connect: false` leaves them free.
@@ -1171,7 +1171,8 @@ fn draw_line(s: &mut Session, p: &Value) -> Result<Value> {
for i in 0..segs {
let j = (i + 1) % n;
let (Some(a), Some(b)) = (args.get(i), args.get(j)) else { continue };
if a.pos().dist(b.pos()) < 1e-9 {
// Ends closer than this would both be connected to one point (#11001).
if a.pos().dist(b.pos()) < CONNECT_TOL {
return Err(bad(cmd, "zero-length line"));
}
let id = ids.get(i).map(String::as_str);
+5 -1
View File
@@ -6,7 +6,7 @@ use solidcore_record::{Seg2, Vec2};
use solidcraft_geom::prelude::*;
use solidcraft_sketch::{ConstraintKind, CurveKind, Sketch};
use super::sketch::{add_c, curve_ref, edit, ids_of, line_or_axis_ref, line_pts, req_parg, result};
use super::sketch::{CONNECT_TOL, add_c, curve_ref, edit, ids_of, line_or_axis_ref, line_pts, req_parg, result};
use super::{CommandSpec, in_sketch};
use crate::params::{bad, expr, num, str_, vec2};
use crate::schema::sketch_create::{ConstructionLineParams, ConstructionLineResult};
@@ -261,6 +261,10 @@ fn run(s: &mut Session, p: &Value, default_mode: &str) -> Result<Value> {
let first_new = sk.points.len();
let at = req_parg(sk, doc, &p, "at", CMD)?;
let through = if mode == "free" { Some(req_parg(sk, doc, &p, "through", CMD)?) } else { None };
// A through-point next to the start leaves a line whose direction no constraint holds (#11001).
if through.as_ref().is_some_and(|q| q.pos().dist(at.pos()) < CONNECT_TOL) {
return Err(bad(CMD, "the through point must lie away from the start point"));
}
let mut params = p.clone();
params["at"] = json!([at.pos().x, at.pos().y]);
let angle_expression = if mode == "angle" {
@@ -326,6 +326,25 @@ fn construction_tangents_respect_arc_sweeps_and_invalid_inputs_are_atomic() {
}
}
#[test]
fn a_second_point_next_to_the_first_is_refused() {
// #11001: a through-point 0.0000001 mm from the start made an infinite line whose direction
// Horizontal no longer held, and a line that short collapsed onto the origin.
let mut s = new_sketch();
for (id, p) in [
("sketch.construction_line", json!({"at":[0,0],"through":[0.0000001,0]})),
("sketch.construction_line", json!({"at":[5,5],"through":[5,5.0000005]})),
("sketch.line", json!({"points":[[0,0],[0.0000001,0]]})),
("sketch.line", json!({"points":[[5,5],[10,5],[10,5.0000005]]})),
] {
let before = s.doc.to_json();
assert!(s.execute(id, &p).is_err(), "{id} {p}");
assert_eq!(s.doc.to_json(), before, "{id} {p}");
}
run(&mut s, "sketch.construction_line", json!({"at":[0,0],"through":[0.00001,0]}));
run(&mut s, "sketch.line", json!({"points":[[5,5],[5.00001,5]]}));
}
#[test]
fn construction_relations_follow_source_edits_without_an_auxiliary_dimension() {
let mut s = new_sketch();
+60 -1
View File
@@ -3538,17 +3538,76 @@ fn transform(s: &mut Session, p: &Value) -> Result<Value> {
None => 0.0,
};
let t = if p.get("translate").is_some() { req_vec2(cmd, p, "translate")? } else { Vec2::ZERO };
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let (out, info) = edit(s, p, cmd, false, |sk, doc| {
let (cs, ps) = entities(sk, p, cmd)?;
let c = if p.get("center").is_some() { point_or_ref(sk, p, "center", cmd)? } else { Vec2::ZERO };
let rot = rotate(c, ang);
let f = move |q: Vec2| rot(c + (q - c) * k) + t;
transform_in_place(sk, &cs, &ps, &f, k, cmd)?;
let moved = involved_points(sk, &cs, &ps);
rotate_axis_constraints(sk, doc, &moved, ang)?;
scale_dimensions(sk, doc, &cs, &moved, k)?;
Ok(ids_of(sk, &cs))
})?;
s.doc_mut().prune_model_params();
Ok(json!({"curves": out, "sketch": info}))
}
/// The driving lengths wholly inside a transformed selection scale with it. Keep formulas
/// tied to their user parameters, and compensate references between scaled dimensions so a
/// dimension such as `d1 / 2` does not get the factor twice.
fn scale_dimensions(sk: &mut Sketch, doc: &mut solidcraft_doc::Document, curves: &[usize], points: &[usize], factor: f64) -> Result<()> {
if factor == 1.0 {
return Ok(());
}
let selected: Vec<usize> = sk
.constraints
.iter()
.enumerate()
.filter(|(_, c)| {
let pts = sk.constraint_points(&c.kind);
!c.driven
&& c.kind.is_dimension()
&& !c.kind.is_angle()
&& !pts.is_empty()
&& pts.iter().all(|p| points.contains(p))
// A circle's centre may move with another curve while its radius stays put.
&& c.kind.indices().1.iter().all(|i| {
curves.contains(i)
|| !matches!(sk.curves.get(*i).map(|c| &c.kind), Some(CurveKind::Circle { .. } | CurveKind::Ellipse { .. }))
})
})
.map(|(i, _)| i)
.collect();
let mut expressions = std::collections::BTreeMap::new();
for i in selected {
let Some(c) = sk.constraints.get_mut(i) else { continue };
if let Some(value) = c.kind.value() {
c.kind.set_value(value * factor);
}
if let Some(name) = &c.param
&& let Some(param) = doc.param(name)
{
let expression = if solidcraft_doc::expr::references(&param.expr).is_empty() {
format!("{} mm", doc.eval(name, solidcraft_doc::expr::Kind::Length)? * factor)
} else {
let formula = param.expr.trim().strip_prefix('=').unwrap_or(&param.expr);
format!("({formula}) * {factor}")
};
expressions.insert(name.clone(), expression);
}
}
for (name, mut expression) in expressions.clone() {
for reference in solidcraft_doc::expr::references(&expression) {
if expressions.contains_key(&reference) {
expression = solidcraft_doc::expr::rename_reference(&expression, &reference, &format!("({reference} / {factor})"));
}
}
doc.set_param(&name, &expression, None, None)?;
}
Ok(())
}
fn scale(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.scale";
let k = num(p, "factor").filter(|k| *k > 1e-9 && *k < 1e6).ok_or_else(|| bad(cmd, "`factor` must be a positive number"))?;
@@ -1194,6 +1194,65 @@ fn transform_scales_turns_and_moves_in_one_step() {
}
}
/// Transform must leave a dimensioned rectangle where its preview put it, rather than letting
/// the old horizontal/vertical relations and size dimensions pull it back (#11617).
#[test]
fn transform_turns_and_scales_a_dimensioned_rectangle() {
for angle in [30.0_f64, 90.0, 180.0] {
let mut s = new_sketch();
let drawn = run(&mut s, "sketch.rectangle.center", json!({"center": [0, 0], "corner": [25, 12.5]}));
let curves = ids(&drawn["curves"]);
let width = run(&mut s, "sketch.dimension", json!({"entities": [curves[0]], "type": "length", "value": "50 mm"}));
let height = run(&mut s, "sketch.dimension", json!({"entities": [curves[1]], "type": "length", "value": "25 mm"}));
let before = sketch(&s);
let result = run(&mut s, "sketch.transform", json!({"entities": curves, "factor": 2, "angle": angle, "center": [0, 0]}));
assert_eq!(result["sketch"]["solved"], true);
let after = sketch(&s);
let (sin, cos) = angle.to_radians().sin_cos();
for id in ["p1", "p2", "p3", "p4"] {
let p = before.point(before.resolve_point(id).unwrap()).unwrap();
let expected = Vec2::new(p.x * cos - p.y * sin, p.x * sin + p.y * cos) * 2.0;
let actual = after.point(after.resolve_point(id).unwrap()).unwrap();
assert!(actual.dist(expected) < 1e-6, "{angle} degrees, {id}: {actual:?}, expected {expected:?}");
}
for (dimension, expected) in [(&width, 100.0), (&height, 50.0)] {
let name = dimension["param"].as_str().unwrap();
assert!(close(s.doc.eval(name, solidcraft_doc::expr::Kind::Length).unwrap(), expected));
}
assert!(close(profiles(&s)[0], 5000.0));
run(&mut s, "edit.undo", json!({}));
assert_eq!(sketch(&s).constraints, before.constraints);
assert!(close(profiles(&s)[0], 1250.0));
run(&mut s, "edit.redo", json!({}));
assert!(close(profiles(&s)[0], 5000.0));
}
}
#[test]
fn transform_scales_dimension_formulas_once_and_keeps_unselected_dimensions() {
let mut s = new_sketch();
run(&mut s, "parameters.add", json!({"name": "width", "expression": "50 mm"}));
let rectangle = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [10, 10], "p1": [60, 35]}));
let curves = ids(&rectangle["curves"]);
let width = run(&mut s, "sketch.dimension", json!({"entities": [curves[0]], "type": "length", "value": "=width"}));
let width_param = width["param"].as_str().unwrap();
let height = run(&mut s, "sketch.dimension", json!({"entities": [curves[1]], "type": "length", "value": format!("{width_param} / 2")}));
run(&mut s, "sketch.dimension", json!({"entities": [curves[2]], "type": "length", "driven": true}));
let circle = run(&mut s, "sketch.circle.center", json!({"center": [100, 100], "radius": 5}));
let radius = run(&mut s, "sketch.dimension", json!({"entities": [circle["curves"][0]], "type": "radius", "value": "5 mm"}));
let outside = s.doc.param(radius["param"].as_str().unwrap()).unwrap().clone();
run(&mut s, "sketch.transform", json!({"entities": curves, "factor": 2, "angle": 90, "center": [35, 22.5]}));
let length = solidcraft_doc::expr::Kind::Length;
assert!(close(s.doc.eval("width", length).unwrap(), 50.0), "the user parameter stays unchanged");
assert!(close(s.doc.eval(width_param, length).unwrap(), 100.0));
assert!(close(s.doc.eval(height["param"].as_str().unwrap(), length).unwrap(), 50.0));
assert_eq!(s.doc.param(&outside.name).unwrap(), &outside);
assert!(close(sketch(&s).radius(sketch(&s).curve_index(circle["curves"][0].as_str().unwrap()).unwrap()).unwrap(), 5.0));
run(&mut s, "parameters.change", json!({"name": "width", "expression": "60 mm"}));
assert!(close(s.doc.eval(width_param, length).unwrap(), 120.0), "the formula still follows its user parameter");
assert!(close(s.doc.eval(height["param"].as_str().unwrap(), length).unwrap(), 60.0), "dependent dimensions scale only once");
}
/// #1485: after a circle is trimmed between two construction lines, clicking the trimmed end
/// picks the construction line's end lying there, not the arc's own; the tangent arc still starts
/// from the trimmed arc, tangent to it.
+8 -1
View File
@@ -10,12 +10,19 @@ use solidcraft_kernel::{Construction, Continuity};
use super::CommandSpec;
use super::features::add_feature;
use crate::params::{bad, str_, vec3};
use crate::schema::features::FeatureResult;
use crate::schema::surface_match::MatchSurfaceParams;
use crate::schema::{TypedContract, output_schema_for};
use crate::{Result, Session};
const CONTRACT: TypedContract<MatchSurfaceParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("surface.match", "Match Surface", match_surface)
CommandSpec::new("surface.match", "Match Surface", crate::checked!("surface.match", match_surface))
.at("SURFACE", "MODIFY")
.icon("continuity")
.contract(&CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params(
"body: surface name (the body whose face is rebuilt); faces?: [[x,y,z]] (points on that face, default: the body's first); target: body name (the body the seam must meet); edges: [[x,y,z]] (points on one edge chain of the target: the rebuilt boundary takes its place); order?: g1|g2|g3 (default g2: position and tangent plane, the curvature too, or how that curvature changes along the seam). The far boundary stays where it was and the body keeps its name",
),
@@ -280,3 +280,41 @@ fn cancelling_an_editor_with_visibility_changes_restores_an_existing_history_mar
assert_eq!(session.doc.marker, Some(1));
assert!(session.visibility.hidden_origin.is_empty());
}
/// Rolling the marker back before a move of the body a joint seats on solves the joints again,
/// which puts the seated occurrence back where the body was: the editor's pinned model differs
/// from its source there, and only there, and the edit still goes through.
#[test]
fn an_editor_accepts_the_placements_rolling_the_marker_solves() {
let mut session = Session::default();
for (name, x) in [("A", 0), ("B", 20)] {
session.execute("component.activate", &json!({"component": "root"})).unwrap();
session.execute("component.create", &json!({"name": name})).unwrap();
session.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0]})).unwrap();
}
let component = |s: &Session, name: &str| s.doc.components.iter().find(|c| c.name == name).map(|c| c.id).unwrap();
let occurrence = |s: &Session, name: &str| {
let component = component(s, name);
s.doc.occurrences.iter().find(|o| o.component == component).cloned().unwrap()
};
session.execute("occurrence.ground", &json!({"occurrence": occurrence(&session, "A").id, "grounded": true})).unwrap();
session
.execute("joint.create", &json!({"type": "rigid", "a": {"face": [5, 5, 10]}, "b": {"face": [25, 5, 0]}, "offset": 5, "name": "Seat"}))
.unwrap();
let seated = occurrence(&session, "B").transform;
// A's box moves along, and B, seated on it, with it.
session.execute("component.activate", &json!({"component": "A"})).unwrap();
let a = component(&session, "A");
let body = session.model.state().bodies.iter().find(|b| b.component == a).map(|b| b.name.clone()).unwrap();
session.execute("solid.move", &json!({"bodies": [body], "translate": [30, 0, 0]})).unwrap();
let moved = occurrence(&session, "B").transform;
assert_ne!(moved, seated, "B follows the box it is seated on");
let original = session.scratch();
session.execute("timeline.roll_to", &json!({"position": 2})).unwrap();
let source = session.scratch();
assert_eq!(occurrence(&session, "B").transform, seated, "before the move, B sits where the box was");
let prepared = session.scratch().compute_editor_commands(&[], &source, original).unwrap();
session.accept_commands(prepared).unwrap();
assert_eq!(session.doc.marker, None);
assert_eq!(occurrence(&session, "B").transform, moved);
}
+17
View File
@@ -1961,6 +1961,23 @@ impl Session {
}
let mut expected = (*original.doc).clone();
expected.marker = source.doc.marker;
// Rolling the marker solves the joints again and stores where that puts each occurrence.
if expected.occurrences.len() == source.doc.occurrences.len() {
for (e, s) in expected.occurrences.iter_mut().zip(&source.doc.occurrences) {
if e.id == s.id {
e.transform = s.transform;
}
}
}
// ...and works out each coupled joint's ratio and lift from the faces at that marker.
if expected.assembly.links.len() == source.doc.assembly.links.len() {
for (e, s) in expected.assembly.links.iter_mut().zip(&source.doc.assembly.links) {
if (e.a, e.ia, e.b, e.ib) == (s.a, s.ia, s.b, s.ib) {
e.ratio = s.ratio;
e.lift = s.lift.clone();
}
}
}
if expected != *source.doc {
return Err(EngineError::Other("the original editor model does not match its source design".into()));
}
+38
View File
@@ -517,6 +517,7 @@ fn open_surface_measurements_have_no_volume_properties() {
assert_eq!(props["total"]["open_bodies_skipped"], 1);
assert!(props["bodies"][0]["mass_g"].is_null());
assert!(props["total"]["center_of_mass"].is_null());
assert!(s.execute("inspect.center_of_mass", &json!({"bodies": ["OpenTube"]})).is_err(), "an open surface has no centre of mass");
}
#[test]
@@ -545,6 +546,43 @@ fn volume_totals_skip_open_surfaces_and_keep_closed_box_properties() {
for key in ["volume_mm3", "mass_g", "center_of_mass"] {
assert_eq!(props_before["total"][key], props_after["total"][key]);
}
let com = run(&mut s, "inspect.center_of_mass", json!({}));
assert_eq!(com["skipped"].as_array().map(Vec::len), Some(1), "{com}");
assert_eq!(com["bodies"].as_array().map(Vec::len), Some(1), "{com}");
assert!(close(&com["volume"], 6000.0, 1e-9), "{com}");
for (axis, expected) in [5.0, 10.0, 15.0].into_iter().enumerate() {
assert!(close(&com["center"][axis], expected, 1e-9), "{com}");
}
}
/// #6173: Center of Mass reports the volume and centre Measure does. It measured the display
/// mesh, whose flat facets cut curved faces short: a sphere of radius 10 came out 0.13 % small.
#[test]
fn center_of_mass_reports_the_same_volume_and_centre_as_measure() {
let mut s = Session::default();
run(&mut s, "solid.sphere", json!({"radius": 10, "center": [0, 0, 0], "body_name": "Ball"}));
run(&mut s, "solid.cylinder", json!({"base": [50, 0, 0], "radius": 10, "height": 20, "body_name": "Cup"}));
run(&mut s, "solid.shell", json!({"faces": [[50, 0, 20]], "thickness": 2}));
let measured = run(&mut s, "inspect.measure", json!({}));
let com = run(&mut s, "inspect.center_of_mass", json!({}));
let entry = |list: &Value, key: &str, name: &str| -> Value {
list.as_array().and_then(|l| l.iter().find(|b| b[key] == name)).cloned().unwrap_or_else(|| panic!("{name} in {list}"))
};
for name in ["Ball", "Cup"] {
let (m, c) = (entry(&measured["bodies"], "name", name), entry(&com["bodies"], "body", name));
assert!(close(&c["volume"], m["volume_mm3"].as_f64().unwrap(), 1e-9), "{name}: {c} vs {m}");
for axis in 0..3 {
assert!(close(&c["center"][axis], m["center_of_mass"][axis].as_f64().unwrap(), 1e-9), "{name}: {c} vs {m}");
}
}
assert!(close(&com["volume"], measured["total"]["volume_mm3"].as_f64().unwrap(), 1e-6), "{com} vs {measured}");
assert_eq!(com["skipped"].as_array().map(Vec::len), Some(0), "{com}");
// Worked out by hand: the sphere is 4/3 pi r^3, the cup a cylinder less its 8 x 18 bore.
let ball = 4.0 / 3.0 * PI * 1000.0;
let cup = PI * (100.0 * 20.0 - 64.0 * 18.0);
let (b, c) = (entry(&com["bodies"], "body", "Ball"), entry(&com["bodies"], "body", "Cup"));
assert!(close(&b["volume"], ball, ball * 2e-4), "{b}: want {ball}");
assert!(close(&c["volume"], cup, cup * 2e-4), "{c}: want {cup}");
}
/// An ASCII STL of the tetrahedron (0,0,0), (10,0,0), (0,10,0), (0,0,10), outward facing; `open`
+34
View File
@@ -264,6 +264,40 @@ fn pieces_of_a_cut_body_keep_their_body_names_when_the_cut_moves() {
}
}
/// A hole that cuts a body in three pieces names them as the design names them when it is
/// evaluated again from the names it pinned, as on opening it. Found by the model fuzzer: the two
/// smaller pieces swapped names there, so a later feature naming one of them met the other.
#[test]
fn pieces_of_a_cut_keep_their_names_when_the_design_is_evaluated_again() {
let mut s = Session::default();
run(&mut s, "construct.plane.offset", json!({"base": "XZ", "name": "P8", "offset": 5}));
run(&mut s, "sketch.create", json!({"plane": "P8"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-7, -9], "p1": [5, -7]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 5, "operation": "join"}));
run(&mut s, "sketch.create", json!({"plane": "XZ"}));
run(&mut s, "sketch.circle.center", json!({"center": [7, -3], "radius": 4}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 5, "operation": "join"}));
run(&mut s, "solid.hole", json!({"depth": 14, "diameter": 5, "position": [-1, 10, 8]}));
// Each body by name, with where it is (two pieces have the same volume).
let pieces = |st: &solidcraft_doc::ModelState| -> Vec<(String, String)> {
st.bodies
.iter()
.map(|b| {
let m = solidcraft_kernel::measure(&b.body).unwrap();
(b.name.clone(), format!("{:.3} {:.3} {:.3} {:.3}", m.volume, m.centroid.x, m.centroid.y, m.centroid.z))
})
.collect()
};
let live = pieces(&s.model.state());
assert_eq!(live.len(), 3, "{live:?}");
let mut again = solidcraft_doc::Model::new();
again.no_cache = true;
again.evaluate(&s.doc);
assert_eq!(pieces(&again.state()), live);
}
#[test]
fn a_name_of_a_body_without_history_is_found_by_geometry_and_says_so_once_it_moves() {
let mut s = Session::default();
+14
View File
@@ -466,6 +466,19 @@ mod tests {
d
}
/// Ticks (bounded) until the autosave a tick started has been written and taken up, as the
/// app's own frames would. A tick right after the one that started a write can find it still
/// running under load and take nothing up, so a test that then changes the session (marking
/// it saved) and checks the files would race the worker.
fn tick_until_caught_up(a: &mut Autosaver, s: &Session) {
let began = Instant::now();
while !a.caught_up(s) {
assert!(began.elapsed() < Duration::from_secs(30), "the autosave never caught up");
std::thread::sleep(Duration::from_millis(1));
a.tick(s, false).unwrap();
}
}
#[test]
fn autosave_only_unsaved_changes_and_recover_after_a_crash() {
let d = dir("crash");
@@ -590,6 +603,7 @@ mod tests {
let mut a = Autosaver::new(&d, Duration::ZERO).unwrap();
s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10})).unwrap();
assert!(a.tick(&s, false).unwrap());
tick_until_caught_up(&mut a, &s);
s.mark_saved();
a.tick(&s, false).unwrap();
assert!(!file(&d, &a.id, solidcraft_io::DESIGN_EXT).exists());
+197
View File
@@ -0,0 +1,197 @@
//! Contract and result types for `inspect.clearance` in `cmd/clearance.rs` (#11051).
use std::borrow::Cow;
use std::collections::BTreeMap;
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
use serde::de::Error as _;
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::Value;
use super::check::whole;
use super::types::StringList;
use crate::params;
/// Most neighbours a body's `nearest` list holds.
pub const MAX_NEAREST: usize = 1000;
/// Largest clearance (mm).
pub const MAX_CLEARANCE: f64 = 1e6;
/// Find the solid bodies that overlap, and those closer than `clearance`, in world placement.
/// Distances are measured on the exact faces and edges; the meshes only rule pairs out. Mesh
/// bodies aren't measured: each is listed in `unchecked`.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct ClearanceParams {
/// The bodies to check (default: every solid body but those in `against`).
#[serde(default)]
pub bodies: Option<StringList>,
/// Only pairs with one body in `bodies` and the other here.
#[serde(default)]
pub against: Option<StringList>,
/// Smaller overlaps count as contact, in mm³ (default 0.01).
#[serde(default, deserialize_with = "min_volume")]
#[schemars(schema_with = "min_volume_schema")]
pub min_volume: Option<f64>,
/// Report pairs closer than this, in mm: 0 to 1 000 000 (default 0).
#[serde(default, deserialize_with = "clearance")]
#[schemars(schema_with = "clearance_schema")]
pub clearance: Option<f64>,
/// A gap below this is contact, 0, in mm: 0 to 1 (default 1e-6).
#[serde(default, deserialize_with = "tolerance")]
#[schemars(schema_with = "tolerance_schema")]
pub tolerance: Option<f64>,
/// Each body of `bodies` gets its this many nearest others, 0 to 1000 (default 0); a
/// fraction is rounded down.
#[serde(default, deserialize_with = "nearest")]
#[schemars(schema_with = "nearest_schema")]
pub nearest: Option<f64>,
#[serde(default)]
pub skip_within: Option<SkipWithin>,
}
/// A number from 0 to `max`, read as the handler reads it (`params::num`: finite, under 1e9 in
/// size), with its message; `null` is the default.
macro_rules! up_to {
($read:ident, $schema:ident, $key:literal, $max:expr) => {
fn $read<'de, D: Deserializer<'de>>(d: D) -> Result<Option<f64>, D::Error> {
match Value::deserialize(d)? {
Value::Null => Ok(None),
v => v
.as_f64()
.and_then(params::finite)
.filter(|x| (0.0..=$max).contains(x))
.map(Some)
.ok_or_else(|| D::Error::custom(whole(format!("`{}` must be a number from 0 to {}", $key, $max)))),
}
}
fn $schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({"type": ["number", "null"], "minimum": 0, "maximum": f64::min($max, 1e9), "exclusiveMaximum": 1e9})
}
};
}
up_to!(min_volume, min_volume_schema, "min_volume", f64::MAX);
up_to!(clearance, clearance_schema, "clearance", MAX_CLEARANCE);
up_to!(tolerance, tolerance_schema, "tolerance", 1.0);
up_to!(nearest, nearest_schema, "nearest", MAX_NEAREST as f64);
/// Pairs not to check: of bodies of one occurrence (`occurrence`), or inside one placed
/// component, its subcomponents included (`component`). `null` checks every pair.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SkipWithin {
Occurrence,
Component,
}
impl<'de> Deserialize<'de> for SkipWithin {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
match Value::deserialize(d)?.as_str() {
Some("occurrence") => Ok(SkipWithin::Occurrence),
Some("component") => Ok(SkipWithin::Component),
_ => Err(D::Error::custom(whole("`skip_within` must be \"occurrence\", \"component\" or null"))),
}
}
}
impl JsonSchema for SkipWithin {
fn schema_name() -> Cow<'static, str> {
"SkipWithin".into()
}
fn inline_schema() -> bool {
true
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "Pairs not to check: of bodies of one occurrence (`occurrence`), or inside one placed component, its subcomponents included (`component`). null checks every pair.",
"enum": ["occurrence", "component", null]
})
}
}
/// What `inspect.clearance` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct Clearance {
/// Pairs that share at least `min_volume`.
pub overlaps: Vec<Overlap>,
/// Pairs closer than `clearance`, nearest first; a pair in contact is at 0.
pub near: Vec<NearPair>,
/// With `nearest`: each body of `bodies`, with its nearest others.
pub nearest: BTreeMap<String, Vec<Neighbour>>,
/// Pairs whose overlap couldn't be worked out, and each mesh body, which isn't measured
/// (`b` is then `null`).
pub unchecked: Vec<Unchecked>,
/// How many solid bodies were checked.
pub bodies: usize,
pub pairs_checked: usize,
/// Of those, the pairs measured by an earlier call: neither body changed or moved since.
pub pairs_reused: usize,
/// Pairs ruled out by their boxes.
pub pairs_pruned: usize,
/// Whether there were more pairs than one call measures.
pub truncated: bool,
/// How long it took (ms).
pub ms: f64,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
/// Two bodies that overlap.
#[derive(Debug, Serialize, JsonSchema)]
pub struct Overlap {
pub a: String,
pub b: String,
/// The volume they share (mm³).
pub volume_mm3: f64,
/// The area of the shared volume's surface (mm²).
pub area_mm2: f64,
pub bbox: OverlapBounds,
pub center: [f64; 3],
}
/// A box, by its corners.
#[derive(Debug, Serialize, JsonSchema)]
pub struct OverlapBounds {
pub min: [f64; 3],
pub max: [f64; 3],
}
/// Two bodies closer than `clearance`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct NearPair {
pub a: String,
pub b: String,
pub distance_mm: f64,
/// The nearest point on `a`.
pub from: [f64; 3],
/// The nearest point on `b`.
pub to: [f64; 3],
/// For a contact: the volume they share, below `min_volume` (mm³).
#[serde(skip_serializing_if = "Option::is_none")]
pub overlap_mm3: Option<f64>,
/// `false` when measured on the meshes; absent when measured on the exact faces and edges.
#[serde(skip_serializing_if = "Option::is_none")]
pub exact: Option<bool>,
}
/// One of a body's nearest others.
#[derive(Debug, Serialize, JsonSchema)]
pub struct Neighbour {
pub body: String,
pub distance_mm: f64,
}
/// A pair, or a mesh body, that wasn't measured.
#[derive(Debug, Serialize, JsonSchema)]
pub struct Unchecked {
pub a: String,
/// The other body; `null` for a mesh body.
pub b: Option<String>,
pub reason: String,
}
#[cfg(test)]
#[path = "clearance_tests.rs"]
mod tests;
@@ -0,0 +1,62 @@
use serde_json::{Value, json};
use super::{Clearance, ClearanceParams};
use crate::Session;
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
fn run(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
}
#[test]
fn schemas_and_the_key_check_agree() {
assert_keys(input_schema_for::<ClearanceParams>(), &["against", "bodies", "clearance", "min_volume", "nearest", "skip_within", "tolerance"]);
}
/// The handler reads the numbers and `skip_within` before the bodies, so each of these fails
/// the same way on a design with no bodies.
#[test]
fn strict_keeps_todays_errors() {
assert_strict_keeps_todays_errors(&[
("inspect.clearance", json!({"min_volume": -1})),
("inspect.clearance", json!({"min_volume": "x"})),
("inspect.clearance", json!({"min_volume": 2e9})),
("inspect.clearance", json!({"clearance": 2e6})),
("inspect.clearance", json!({"tolerance": 2})),
("inspect.clearance", json!({"nearest": 1001})),
("inspect.clearance", json!({"skip_within": "x"})),
("inspect.clearance", json!({"skip_within": 5})),
("inspect.clearance", json!({"bodies": ["Nope"]})),
("inspect.clearance", json!({"against": "Nope"})),
]);
}
/// #11051: a clearance check replies as its output schema says, in Warn without warnings for
/// good calls and with them for a misspelt key: two overlapping boxes and a third 2 mm away,
/// checked with every key, and with `against` alone.
#[test]
fn a_clearance_check_replies_with_its_schema() {
let schema = output_schema_for::<Clearance>();
let matches = |r: &Value, warned: bool| {
let schema = schema.as_value();
assert!(conforms(schema, schema, r), "{r} doesn't match {schema}");
assert_eq!(r.get("param_warnings").is_some(), warned, "{r}");
};
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "A"}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [5, 5, 5], "body_name": "B", "operation": "new"}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [12, -20, 0], "body_name": "C", "operation": "new"}));
let r = run(&mut s, "inspect.clearance", json!({}));
matches(&r, false);
assert_eq!(r["overlaps"].as_array().map(Vec::len), Some(1), "{r}");
let r = run(
&mut s,
"inspect.clearance",
json!({"bodies": ["A", "B"], "against": "C", "min_volume": 0.01, "clearance": 30, "tolerance": 1e-6, "nearest": 2, "skip_within": "occurrence"}),
);
matches(&r, false);
assert!(r["near"].as_array().is_some_and(|n| !n.is_empty()) && r["nearest"]["A"].is_array(), "{r}");
matches(&run(&mut s, "inspect.clearance", json!({"against": ["A"], "skip_within": null})), false);
matches(&run(&mut s, "inspect.clearance", json!({"clearence": 3})), true);
}
+307
View File
@@ -0,0 +1,307 @@
//! Contract and result types for `inspect.compare` in `cmd/compare.rs` (#10994).
use std::borrow::Cow;
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
use serde::de::Error as _;
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::{Map, Value};
use super::check::whole;
/// Compare two bodies laid over each other, in world placement: measure both, sample each
/// surface's distance to the other, and find, measure and (with `color`) colour the places where
/// only one of them has material. It works from the two surfaces, without a boolean of the
/// bodies, so near-identical bodies compare too.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct CompareParams {
/// The first body, by name. Give both `a` and `b`, or neither: then the two selected bodies,
/// or the design's only two.
#[serde(default, deserialize_with = "body_name")]
pub a: Option<String>,
/// The second body, by name.
#[serde(default, deserialize_with = "body_name")]
pub b: Option<String>,
/// Surfaces closer than this agree, in mm: 0.001 to 10 (default 0.01).
#[serde(default, deserialize_with = "tolerance")]
#[schemars(schema_with = "tolerance_schema")]
pub tolerance: Option<f64>,
/// The largest piece of a face sampled as one point, in mm: 0.01 to 1000 (default 0.5),
/// coarser for a body that would need more than `max_points` samples.
#[serde(default, deserialize_with = "spacing")]
#[schemars(schema_with = "spacing_schema")]
pub spacing: Option<f64>,
/// The most samples per body: 10 000 to 50 million (default 8 million).
#[serde(default, deserialize_with = "max_points")]
#[schemars(schema_with = "max_points_schema")]
pub max_points: Option<f64>,
/// The most regions measured: 1 to 1000 (default 50); more are counted in `unlisted_regions`.
#[serde(default, deserialize_with = "max_regions")]
#[schemars(schema_with = "max_regions_schema")]
pub max_regions: Option<f64>,
#[serde(default)]
pub color: Option<CompareColor>,
}
/// A body name, with the handler's message for anything else; `null` is none.
fn body_name<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
match Value::deserialize(d)? {
Value::Null => Ok(None),
Value::String(s) => Ok(Some(s)),
_ => Err(D::Error::custom("must be a body name")),
}
}
/// A number in `lo..=hi` with the handler's message, `null` none, for each ranged key.
macro_rules! ranged {
($read:ident, $schema:ident, $lo:literal, $hi:literal) => {
fn $read<'de, D: Deserializer<'de>>(d: D) -> Result<Option<f64>, D::Error> {
match Value::deserialize(d)? {
Value::Null => Ok(None),
v => v
.as_f64()
.filter(|x| x.is_finite() && ($lo..=$hi).contains(x))
.map(Some)
.ok_or_else(|| D::Error::custom(format!("must be a number from {} to {}", $lo, $hi))),
}
}
fn $schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({"type": ["number", "null"], "minimum": $lo, "maximum": $hi})
}
};
}
ranged!(tolerance, tolerance_schema, 0.001, 10.0);
ranged!(spacing, spacing_schema, 0.01, 1000.0);
ranged!(max_points, max_points_schema, 1e4, 5e7);
ranged!(max_regions, max_regions_schema, 1.0, 1000.0);
/// Colour the faces that hold a region: `true` for red where only `a` has material and green
/// where only `b` has, or `{only_in_a?, only_in_b?}` to choose either colour (`"#rrggbb"` or
/// `[r, g, b]`, 0 to 255). `false` colours nothing. One undo step.
#[derive(Clone, Debug, PartialEq)]
pub struct CompareColor(pub Value);
impl<'de> Deserialize<'de> for CompareColor {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let v = Value::deserialize(d)?;
match &v {
Value::Bool(_) => {}
Value::Object(o) => {
if o.keys().any(|k| k != "only_in_a" && k != "only_in_b") {
return Err(D::Error::custom("must be true or {only_in_a, only_in_b}"));
}
for k in ["only_in_a", "only_in_b"] {
if o.get(k).is_some_and(|c| !c.is_null() && crate::cmd::color_param(c).is_none()) {
return Err(D::Error::custom(whole(format!("`color.{k}` must be \"#rrggbb\" or [r, g, b] with values 0–255"))));
}
}
}
_ => return Err(D::Error::custom("must be true or {only_in_a, only_in_b}")),
}
Ok(CompareColor(v))
}
}
impl JsonSchema for CompareColor {
fn schema_name() -> Cow<'static, str> {
"CompareColor".into()
}
fn inline_schema() -> bool {
true
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
let color = || {
serde_json::json!({
"anyOf": [
{"description": "\"#rrggbb\": six hex digits, the # optional.", "type": "string", "pattern": "^\\s*#?[0-9a-fA-F]{6}\\s*$"},
{"type": "array", "items": {"type": "number", "minimum": 0, "maximum": 255}, "minItems": 3, "maxItems": 3},
{"type": "null"}
]
})
};
json_schema!({
"description": "Colour the faces that hold a region: true for red where only `a` has material and green where only `b` has, or {only_in_a?, only_in_b?} to choose either colour (\"#rrggbb\" or [r, g, b], 0 to 255). false colours nothing.",
"anyOf": [
{"type": "boolean"},
{"type": "object", "properties": {"only_in_a": color(), "only_in_b": color()}, "additionalProperties": false}
]
})
}
}
/// What `inspect.compare` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct Comparison {
/// `a`'s measures, as `inspect.measure` gives a body's.
pub a: Map<String, Value>,
/// `b`'s measures, as `inspect.measure` gives a body's.
pub b: Map<String, Value>,
/// `b` less `a`.
pub difference: Difference,
/// The tolerance used (mm).
pub tolerance_mm: f64,
/// How far each surface is from the other.
pub deviation: Deviations,
/// Whether no region differs by more than the tolerance.
pub identical: bool,
/// The comparison in a sentence.
pub verdict: String,
/// The regions where only one body has material, largest first.
pub regions: Vec<Region>,
/// The regions with material only in `a`, and their volume together.
pub only_in_a: SideTotal,
/// The regions with material only in `b`, and their volume together.
pub only_in_b: SideTotal,
/// Regions beyond `max_regions`, counted but not measured.
pub unlisted_regions: usize,
/// What makes the comparison less certain: a body whose surface doesn't enclose what it
/// measures, a cut that couldn't be made, a body that encloses no volume.
pub warnings: Vec<String>,
/// How the surfaces were sampled.
pub sampling: Sampling,
/// With `color`: how many faces were coloured.
#[serde(skip_serializing_if = "Option::is_none")]
pub colored_faces: Option<usize>,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
/// `b` less `a`; `null` where either has no such measure.
#[derive(Debug, Serialize, JsonSchema)]
pub struct Difference {
pub volume_mm3: Option<f64>,
/// The volume difference as a share of `a`'s (%).
pub volume_percent: Option<f64>,
pub area_mm2: Option<f64>,
pub center_of_mass: Option<[f64; 3]>,
pub center_of_mass_distance_mm: Option<f64>,
pub bbox_min: Option<[f64; 3]>,
pub bbox_max: Option<[f64; 3]>,
}
/// How far each surface is from the other.
#[derive(Debug, Serialize, JsonSchema)]
pub struct Deviations {
/// From `a`'s surface to `b`'s.
pub a_to_b: Deviation,
/// From `b`'s surface to `a`'s.
pub b_to_a: Deviation,
/// The larger of the two maxima (mm).
pub max_mm: f64,
}
/// One surface's distance to the other, over its samples.
#[derive(Debug, Serialize, JsonSchema)]
pub struct Deviation {
pub max_mm: f64,
pub mean_mm: f64,
/// Where the largest is.
pub at: [f64; 3],
/// The area further than the tolerance (mm²).
pub area_beyond_mm2: f64,
/// The area sampled (mm²).
pub area_mm2: f64,
/// How many samples.
pub points: usize,
}
/// Which body alone has a region's material.
#[derive(Clone, Copy, Debug, Serialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum CompareSide {
OnlyInA,
OnlyInB,
}
/// A region where only one body has material.
#[derive(Debug, Serialize, JsonSchema)]
pub struct Region {
/// Its number, from 1, largest first.
pub index: usize,
pub side: CompareSide,
/// The body that has the material.
pub material_of: String,
/// Its volume (mm³), measured exactly on the pieces of the cut on its side (a − b or b − a)
/// that lie there; `null` when it isn't measured, never an estimate.
pub volume_mm3: Option<f64>,
/// `boolean` when the volume, centre and box were measured on the cut, else `not measured`.
pub measured_by: MeasuredBy,
/// Why it isn't measured (the cut couldn't be made, a body encloses no volume, no piece fell
/// here); `null` when it is.
pub not_measured: Option<String>,
/// Its surface on both bodies (mm²).
pub area_mm2: f64,
pub area_a_mm2: f64,
pub area_b_mm2: f64,
/// How far the surfaces are apart there at most (mm).
pub max_deviation_mm: f64,
/// Where that is.
pub at: [f64; 3],
/// Its centre of volume, measured with the volume; `null` when it isn't measured.
pub center: Option<[f64; 3]>,
/// The measured pieces' box, or the box the region was found in when it isn't measured.
pub bbox: Bounds,
/// The faces of `a` it lies on, by index, as `model.faces` lists them.
pub faces_a: Vec<u32>,
/// The faces of `b` it lies on.
pub faces_b: Vec<u32>,
/// The lines through it the surfaces were sampled along.
pub resolution: Resolution,
}
/// How a region was measured.
#[derive(Clone, Copy, Debug, Serialize, JsonSchema)]
pub enum MeasuredBy {
/// Exactly, on the pieces of the cut that lie there.
#[serde(rename = "boolean")]
Boolean,
/// Not at all: see `not_measured`.
#[serde(rename = "not measured")]
NotMeasured,
}
/// A box, by its corners.
#[derive(Debug, Serialize, JsonSchema)]
pub struct Bounds {
pub min: [f64; 3],
pub max: [f64; 3],
}
/// The lines through a region the surfaces were sampled along.
#[derive(Debug, Serialize, JsonSchema)]
pub struct Resolution {
/// The axis they run along: `X`, `Y` or `Z`.
pub along: String,
/// Their spacing across the other two axes (mm).
pub line_spacing_mm: [f64; 2],
pub lines: usize,
/// Lines that crossed a gap in a surface and were left out.
pub skipped_lines: usize,
}
/// The regions on one side, and their volume together.
#[derive(Debug, Serialize, JsonSchema)]
pub struct SideTotal {
pub regions: usize,
/// Their volume together (mm³); `null` when one of them isn't measured.
pub volume_mm3: Option<f64>,
}
/// How the surfaces were sampled.
#[derive(Debug, Serialize, JsonSchema)]
pub struct Sampling {
/// The sample spacing used (mm).
pub spacing_mm: f64,
/// How closely the meshes follow the surfaces (mm): a quarter of the tolerance.
pub chord_mm: f64,
pub triangles_a: usize,
pub triangles_b: usize,
}
#[cfg(test)]
#[path = "compare_tests.rs"]
mod tests;
+74
View File
@@ -0,0 +1,74 @@
use serde_json::{Value, json};
use super::{CompareParams, Comparison};
use crate::Session;
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
fn run(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
}
#[test]
fn schemas_and_the_key_check_agree() {
assert_keys(input_schema_for::<CompareParams>(), &["a", "b", "color", "max_points", "max_regions", "spacing", "tolerance"]);
}
/// The handler reads the numbers and the colours before it looks for the bodies, so each of
/// these fails the same way on a design with none.
#[test]
fn strict_keeps_todays_errors() {
assert_strict_keeps_todays_errors(&[
("inspect.compare", json!({"tolerance": 0})),
("inspect.compare", json!({"tolerance": "x"})),
("inspect.compare", json!({"spacing": 2000})),
("inspect.compare", json!({"max_points": 1})),
("inspect.compare", json!({"max_regions": 0})),
("inspect.compare", json!({"color": 5})),
("inspect.compare", json!({"color": "red"})),
("inspect.compare", json!({"color": {"only_in_a": "#zz0000"}})),
("inspect.compare", json!({"color": {"only_in_b": [1, 2]}})),
("inspect.compare", json!({"a": 5})),
("inspect.compare", json!({"a": "A"})),
("inspect.compare", json!({})),
("inspect.compare", json!({"a": "X", "b": "Y"})),
]);
}
/// #10994: a comparison replies as its output schema says, in Warn without warnings for good
/// calls and with them for a misspelt key: a box and its copy (identical, no regions), and the
/// copy with a boss, compared with every key and coloured both ways.
#[test]
fn a_comparison_replies_with_its_schema() {
let schema = output_schema_for::<Comparison>();
let matches = |r: &Value, warned: bool| {
let schema = schema.as_value();
assert!(conforms(schema, schema, r), "{r} doesn't match {schema}");
assert_eq!(r.get("param_warnings").is_some(), warned, "{r}");
};
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "body_name": "A"}));
run(&mut s, "solid.move", json!({"bodies": ["A"], "translate": [0, 0, 0], "copy": true}));
let r = run(&mut s, "inspect.compare", json!({}));
matches(&r, false);
assert!(r["identical"] == true && r["regions"] == json!([]) && r.get("colored_faces").is_none(), "{r}");
run(&mut s, "solid.box", json!({"length": 6, "width": 5, "height": 2, "corner": [5, 5, 10], "operation": "join", "targets": ["A (2)"]}));
let r = run(
&mut s,
"inspect.compare",
json!({"a": "A", "b": "A (2)", "tolerance": 0.01, "spacing": 0.5, "max_points": 1e6, "max_regions": 10, "color": true}),
);
matches(&r, false);
assert_eq!((r["regions"][0]["side"].as_str(), r["identical"].as_bool()), (Some("only_in_b"), Some(false)), "{r}");
assert!(r["colored_faces"].as_u64().is_some_and(|n| n > 0), "{r}");
let r = run(&mut s, "inspect.compare", json!({"a": "A", "b": "A (2)", "color": {"only_in_b": "#123456", "only_in_a": null}}));
matches(&r, false);
matches(&run(&mut s, "inspect.compare", json!({"a": "A", "b": "A (2)", "color": false, "tolerence": 1})), true);
}
/// A `color` string in the published schema carries the same pattern the handler checks.
#[test]
fn the_color_string_has_a_pattern() {
let schema = serde_json::to_string(&input_schema_for::<CompareParams>()).unwrap();
assert!(schema.contains(r"^\\s*#?[0-9a-fA-F]{6}\\s*$"), "{schema}");
}
@@ -0,0 +1,184 @@
//! Contracts and result types for the commands in `cmd/material_properties.rs`: checking an
//! engineering material card, and its elastic modulus in a direction (#11036).
use std::borrow::Cow;
use std::collections::BTreeMap;
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
use serde::de::Error as _;
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::Value;
use solidcraft_doc::physical_material::{DirectionalModulus, IsotropicModuli, MaterialAxes, Validation};
use super::check::whole;
/// Check an engineering material card: its numbers' consistency, the isotropic moduli they
/// imply, and the conditions and sources still to review. Read-only: nothing is assigned or
/// saved, and there is no analysis solver.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct ValidateCardParams {
#[schemars(schema_with = "card_schema")]
pub card: Value,
}
/// The Young modulus of an engineering material card in one direction, in the card's own
/// material axes. Read-only: no load case or structural solver.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct DirectionalModulusParams {
#[schemars(schema_with = "card_schema")]
pub card: Value,
pub direction: CardDirection,
}
/// The card, checked by the handler (`admit_card`), with its messages.
fn card_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "A schema_version 1 engineering material card in SI units (see docs/physical-material-cards.md).",
"type": "object"
})
}
/// A direction in the card's material axes: three numbers, not all zero, with the handler's
/// messages.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct CardDirection(pub [f64; 3]);
impl<'de> Deserialize<'de> for CardDirection {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let v = Value::deserialize(d)?;
let a =
v.as_array().filter(|a| a.len() == 3).ok_or_else(|| D::Error::custom(whole("direction: expected exactly three numeric components")))?;
let c = |i: usize| a.get(i).and_then(Value::as_f64).ok_or_else(|| D::Error::custom(whole("direction: expected numeric components")));
Ok(CardDirection([c(0)?, c(1)?, c(2)?]))
}
}
impl JsonSchema for CardDirection {
fn schema_name() -> Cow<'static, str> {
"CardDirection".into()
}
fn inline_schema() -> bool {
true
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "A direction in the card's material axes: three numbers, not all zero.",
"type": "array",
"items": {"type": "number"},
"minItems": 3,
"maxItems": 3
})
}
}
/// What `material.properties.validate` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct CardValidated {
/// Always true: a card that isn't valid is refused.
pub valid: bool,
pub validation: CardValidation,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
/// What `material.properties.directional` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct CardModulus {
/// The direction, made a unit vector.
pub direction: [f64; 3],
/// The card's material axes, when it has directional properties.
pub axes: Option<CardAxes>,
/// The Young modulus in that direction (Pa).
pub young_modulus_pa: f64,
pub validation: CardValidation,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
impl From<DirectionalModulus> for CardModulus {
fn from(m: DirectionalModulus) -> Self {
let DirectionalModulus { direction, axes, young_modulus_pa, validation } = m;
CardModulus { direction, axes: axes.map(CardAxes::from), young_modulus_pa, validation: validation.into(), param_warnings: Vec::new() }
}
}
/// A card's material axes.
#[derive(Clone, Copy, Debug, Serialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum CardAxes {
/// The material's own axes 1, 2 and 3.
Material123,
/// Wood's longitudinal, radial and tangential axes.
WoodLrt,
}
impl From<MaterialAxes> for CardAxes {
fn from(a: MaterialAxes) -> Self {
match a {
MaterialAxes::Material123 => CardAxes::Material123,
MaterialAxes::WoodLrt => CardAxes::WoodLrt,
}
}
}
/// What checking a card found.
#[derive(Debug, Serialize, JsonSchema)]
pub struct CardValidation {
pub schema_version: u32,
/// The kinds of property the card gives.
pub property_kinds: Vec<String>,
/// The shear and bulk moduli an isotropic card's numbers imply.
pub isotropic_moduli: Option<CardModuli>,
/// The property kinds given without every condition they hold at (temperature, state, and
/// moisture for wood or strain rate for polymers).
pub unspecified_conditions: Vec<String>,
/// Per property kind: the condition fields it lacks.
pub missing_condition_fields: BTreeMap<String, Vec<String>>,
/// The sources the card's properties cite, which still need review.
pub source_review_required: Vec<String>,
/// Always false: there is no analysis solver.
pub analysis_solver_available: bool,
/// Always false: a card holds no rendering properties.
pub rendering_properties_available: bool,
}
impl From<Validation> for CardValidation {
fn from(v: Validation) -> Self {
// In full, so a field added to `Validation` fails here rather than going missing.
let Validation {
schema_version,
property_kinds,
isotropic_moduli,
unspecified_conditions,
missing_condition_fields,
source_review_required,
analysis_solver_available,
rendering_properties_available,
} = v;
CardValidation {
schema_version,
property_kinds,
isotropic_moduli: isotropic_moduli.map(|IsotropicModuli { shear_pa, bulk_pa }| CardModuli { shear_pa, bulk_pa }),
unspecified_conditions,
missing_condition_fields,
source_review_required,
analysis_solver_available,
rendering_properties_available,
}
}
}
/// The isotropic moduli a card's numbers imply.
#[derive(Debug, Serialize, JsonSchema)]
pub struct CardModuli {
pub shear_pa: f64,
pub bulk_pa: f64,
}
#[cfg(test)]
#[path = "material_properties_tests.rs"]
mod tests;
@@ -0,0 +1,58 @@
use serde_json::{Value, json};
use super::{CardModulus, CardValidated, DirectionalModulusParams, ValidateCardParams};
use crate::Session;
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
/// An isotropic card, as the command's own tests write one.
fn card() -> Value {
json!({"schema_version":1,"designation":"Original synthetic card, not design data","family":"other",
"sources":[{"id":"fixture","title":"Original analytic constants","kind":"illustrative","license":"MIT OR Apache-2.0"}],
"properties":[{"source_id":"fixture","conditions":{"temperature_k":293.15,"state":"synthetic"},
"data":{"kind":"density","kg_m3":700.0}},
{"source_id":"fixture","data":{"kind":"elasticity","model":{
"symmetry":"isotropic","young_modulus_pa":12.25e9,"poisson_ratio":0.25}}}]})
}
#[test]
fn schemas_and_the_key_check_agree() {
assert_keys(input_schema_for::<ValidateCardParams>(), &["card"]);
assert_keys(input_schema_for::<DirectionalModulusParams>(), &["card", "direction"]);
}
#[test]
fn strict_keeps_todays_errors() {
assert_strict_keeps_todays_errors(&[
("material.properties.validate", json!({"card": {}})),
("material.properties.validate", json!({"card": null})),
("material.properties.directional", json!({"card": {}, "direction": [1, 0, 0]})),
("material.properties.directional", json!({"card": card(), "direction": [1, 0]})),
("material.properties.directional", json!({"card": card(), "direction": "x"})),
("material.properties.directional", json!({"card": card(), "direction": [1, "a", 0]})),
("material.properties.directional", json!({"card": card(), "direction": [0, 0, 0]})),
]);
}
/// #11036: both commands reply as their output schemas say, in Warn without warnings. Like the
/// handlers, warn and strict refuse a key they don't take, so no reply carries a warning.
#[test]
fn card_commands_reply_with_their_schemas() {
let (validated, modulus) = (output_schema_for::<CardValidated>(), output_schema_for::<CardModulus>());
let matches = |schema: &schemars::Schema, r: &Value| {
let schema = schema.as_value();
assert!(conforms(schema, schema, r), "{r} doesn't match {schema}");
assert!(r.get("param_warnings").is_none(), "{r}");
};
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
let r = s.execute("material.properties.validate", &json!({"card": card()})).unwrap_or_else(|e| panic!("{e}"));
matches(&validated, &r);
assert!(r["valid"] == true && r["validation"]["isotropic_moduli"]["shear_pa"].is_number(), "{r}");
let r = s.execute("material.properties.directional", &json!({"card": card(), "direction": [1, 1, 0]})).unwrap_or_else(|e| panic!("{e}"));
matches(&modulus, &r);
assert!(r["axes"].is_null() && (r["young_modulus_pa"].as_f64().unwrap_or(0.0) - 12.25e9).abs() < 1.0, "{r}");
for level in [ParamCheck::Warn, ParamCheck::Strict] {
let mut s = Session { param_check: level, ..Session::default() };
assert!(s.execute("material.properties.validate", &json!({"card": card(), "crd": 1})).is_err(), "{level:?}");
}
}
+84
View File
@@ -0,0 +1,84 @@
//! Contracts and result types for the commands in `cmd/metadata.rs`.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
/// Read the design's properties: its `metadata` object as saved, and SolidCraft's own design
/// properties (project, part number, revision…) as text.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct MetadataParams {}
/// What `document.metadata` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct MetadataResult {
/// The design's metadata object as saved, other tools' keys included.
pub metadata: Map<String, Value>,
/// Each of SolidCraft's design properties, by key: its text, or null when it is absent or
/// not text.
pub properties: Map<String, Value>,
}
/// Change design properties in one undo step: `key` and `value`, or `values` for several at
/// once (all or nothing). A change that sets what is already there makes no undo step.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct MetadataSetParams {
/// The property to set: one of SolidCraft's keys (the ones `document.metadata`'s
/// `properties` lists), which take text, or another tool's reverse-DNS key such as
/// `com.example.tool`, which takes an object. `bom_type` is a component's property and is
/// refused here. Needs `value`; not with `values`.
#[serde(default)]
pub key: Option<String>,
/// The value for `key`: text for SolidCraft's keys, an object for another tool's. `""` or
/// null removes the key. Required with `key`.
#[serde(default)]
#[schemars(with = "Value")]
pub value: Option<Value>,
/// Several properties at once, `{key: value, …}`, at most 256 keys, each as `key` and
/// `value` take them. Not with `key`.
#[serde(default)]
pub values: Option<Map<String, Value>>,
}
/// What `document.metadata.set` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct MetadataSetResult {
/// The design's metadata object after the change.
pub metadata: Map<String, Value>,
/// The keys whose value changed, in key order.
pub changed: Vec<String>,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
/// Remove design properties in one undo step: `key`, or `keys` for several. Any key can go,
/// also one another tool wrote; a key that isn't there is left out of `removed`.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct MetadataRemoveParams {
/// The property to remove. Not with `keys`.
#[serde(default)]
pub key: Option<String>,
/// The properties to remove, at most 256. Not with `key`.
#[serde(default)]
pub keys: Option<Vec<String>>,
}
/// What `document.metadata.remove` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct MetadataRemoveResult {
/// The design's metadata object after the change.
pub metadata: Map<String, Value>,
/// The keys that were there and are removed, each once, in the order asked.
pub removed: Vec<String>,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
#[cfg(test)]
#[path = "metadata_tests.rs"]
mod tests;
@@ -0,0 +1,54 @@
use serde_json::{Value, json};
use super::{MetadataParams, MetadataRemoveParams, MetadataRemoveResult, MetadataResult, MetadataSetParams, MetadataSetResult};
use crate::Session;
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
#[test]
fn schemas_and_the_key_check_agree() {
assert_keys(input_schema_for::<MetadataParams>(), &[]);
assert_keys(input_schema_for::<MetadataSetParams>(), &["key", "value", "values"]);
assert_keys(input_schema_for::<MetadataRemoveParams>(), &["key", "keys"]);
}
#[test]
fn strict_keeps_todays_errors() {
assert_strict_keeps_todays_errors(&[
("document.metadata.set", json!({"key": 5, "value": "Gearbox"})),
("document.metadata.remove", json!({"key": 5})),
]);
}
/// Every form of the three commands, in Warn: each reply matches its output schema and carries
/// no warning; a misspelt key's warning still matches.
#[test]
fn replies_match_the_output_schema() {
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
let mut run = |id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
let matches = |schema: schemars::Schema, reply: &Value| {
let schema = schema.as_value();
assert!(conforms(schema, schema, reply), "{reply} doesn't match {schema}");
};
let set = |reply: &Value| matches(output_schema_for::<MetadataSetResult>(), reply);
let remove = |reply: &Value| matches(output_schema_for::<MetadataRemoveResult>(), reply);
let calls = [
("document.metadata.set", json!({"values": {"author": "Jane Doe", "com.acme.shop": {"shop_id": 88123}}})),
("document.metadata.set", json!({"key": "project", "value": "Gearbox"})),
("document.metadata.set", json!({"key": "project", "value": null})),
("document.metadata.remove", json!({"keys": ["author", "author", "not.there"]})),
("document.metadata.remove", json!({"key": "com.acme.shop"})),
];
for (id, p) in calls {
let reply = run(id, p.clone());
assert!(reply.get("param_warnings").is_none(), "{id} {p}: {reply}");
if id == "document.metadata.set" { set(&reply) } else { remove(&reply) }
}
run("document.metadata.set", json!({"key": "part_number", "value": "ACME-1042"}));
let read = run("document.metadata", json!({}));
assert_eq!(read["properties"]["part_number"], "ACME-1042");
matches(output_schema_for::<MetadataResult>(), &read);
let warned = run("document.metadata.set", json!({"key": "author", "value": "J", "valeu": 1}));
assert!(warned["param_warnings"].as_array().is_some_and(|w| !w.is_empty()), "{warned}");
set(&warned);
}
+6
View File
@@ -57,6 +57,8 @@ mod tests;
pub mod appearance;
pub mod bom;
pub mod browser;
pub mod clearance;
pub mod compare;
pub mod component;
pub mod config;
pub mod construct;
@@ -68,11 +70,14 @@ pub mod inspect;
pub mod joints;
pub mod libraries;
pub mod mass_properties;
pub mod material_properties;
pub mod metadata;
pub mod motion;
pub mod panels;
pub mod print_face;
pub mod render;
pub mod render_lights;
pub mod render_stage;
pub mod render_ui;
pub mod section;
pub mod sheet;
@@ -84,6 +89,7 @@ pub mod sketch_equation;
pub mod sketch_inspect;
pub mod surface_analysis;
pub mod surface_boundary;
pub mod surface_match;
pub mod ui_prefs;
pub mod view_navigation;
+145
View File
@@ -0,0 +1,145 @@
//! Contracts and result type for the render stage commands in `cmd/render/staging.rs` (#11455).
//! A stage is a set of render-only panels saved with a render setup. The handlers read these
//! same parameter types, so each contract is exactly what its handler takes; like the handlers,
//! a contract refuses unknown keys.
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
use serde::{Deserialize, Serialize};
use serde_json::Value;
fn patch() -> Value {
serde_json::json!({})
}
fn set_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({"type": "object", "maxProperties": 13, "default": {}})
}
fn margin() -> f64 {
0.25
}
fn gap() -> f64 {
0.1
}
fn reflectance() -> [f64; 3] {
[0.18; 3]
}
/// Fit one ground panel under the visible product, a little below its lowest point, and save
/// it with a render setup in one undo step. The setup must render with the CPU raytracer or the
/// GPU path tracer. The product's clearance from the panel is checked again on every render.
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct GroundParams {
/// The render setup, saved or new.
pub name: String,
/// Changes to the setup before the ground is fitted, as `render.settings.set` takes them:
/// an object of at most 13 setup fields. Default: none.
#[serde(default = "patch")]
#[schemars(schema_with = "set_schema")]
pub set: Value,
/// How far the ground reaches past the product on each side, as a share of the product's
/// largest size: 0 to 10. Default: 0.25.
#[serde(default = "margin")]
#[schemars(range(min = 0.0, max = 10.0))]
pub margin_fraction: f64,
/// The gap between the product's lowest point and the ground (mm): 0.0001 to 1000.
/// Default: 0.1.
#[serde(default = "gap")]
#[schemars(range(min = 0.0001, max = 1000.0))]
pub gap_mm: f64,
/// How much light the ground reflects, as linear red, green and blue: each 0 to 1.
/// Default: 0.18 each.
#[serde(default = "reflectance")]
pub reflectance_linear: [f64; 3],
/// Replace a stage the setup already has. Without it, such a setup is refused.
#[serde(default)]
pub replace_stage: bool,
}
/// Fit a floor and a wall, or a curved cyclorama of 24 flat strips, behind the visible product,
/// facing the setup's camera, and save it with the render setup in one undo step. The setup
/// must render with the CPU raytracer or the GPU path tracer. World +Z is up. This is a finite
/// background, not an infinite floor or a smooth CAD face.
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct BackdropParams {
/// The render setup, saved or new.
pub name: String,
/// Changes to the setup before the backdrop is fitted, as `render.settings.set` takes them:
/// an object of at most 13 setup fields. Default: none.
#[serde(default = "patch")]
#[schemars(schema_with = "set_schema")]
pub set: Value,
pub kind: BackdropKind,
/// How much light the panels reflect, as linear red, green and blue: each 0 to 1.
/// Default: 0.18 each.
#[serde(default = "reflectance")]
pub reflectance_linear: [f64; 3],
/// Replace a stage the setup already has. Without it, such a setup is refused.
#[serde(default)]
pub replace_stage: bool,
/// The design's revision when the caller last read it, as `document.inspect` gives it. If
/// the design changed since, the call is refused: read it again and retry.
pub expected_revision: u64,
}
/// The backdrop's shape.
#[derive(Clone, Copy, Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum BackdropKind {
/// A floor and a flat wall.
FloorWall,
/// A floor that curves up into the wall, in 24 flat strips.
Cyclorama,
}
/// Remove the stage from a render setup in one undo step. The design's geometry and the
/// setup's lighting stay as they are.
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ClearParams {
/// The render setup, saved or new.
pub name: String,
/// Changes to the setup made with the removal, as `render.settings.set` takes them:
/// an object of at most 13 setup fields. Default: none.
#[serde(default = "patch")]
#[schemars(schema_with = "set_schema")]
pub set: Value,
}
/// What `render.stage.ground`, `render.stage.backdrop` and `render.stage.clear` return.
#[derive(Debug, Serialize, JsonSchema)]
pub struct StageSaved {
/// The render setup.
pub name: String,
/// The setup as saved, as the render settings commands give it.
pub setup: Value,
/// Always true: the design's bodies don't change.
pub engineering_geometry_unchanged: bool,
/// Always true: the panels exist only in renders.
pub stage_only: bool,
/// How the product's clearance from the panels is checked.
pub clearance: String,
/// A backdrop's shape: `finite_floor_and_wall` or `24_segment_polygonal_cyclorama`.
#[serde(skip_serializing_if = "Option::is_none")]
pub geometry_kind: Option<String>,
/// A backdrop's part in the lighting.
#[serde(skip_serializing_if = "Option::is_none")]
pub light_transport: Option<String>,
/// A backdrop: always true, the setup's camera stays as it is.
#[serde(skip_serializing_if = "Option::is_none")]
pub camera_preserved: Option<bool>,
/// A backdrop's limits, in sentences.
#[serde(skip_serializing_if = "Option::is_none")]
pub limitations: Option<Vec<String>>,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
#[cfg(test)]
#[path = "render_stage_tests.rs"]
mod tests;
@@ -0,0 +1,99 @@
use serde_json::{Value, json};
use super::{BackdropParams, ClearParams, GroundParams, StageSaved};
use crate::Session;
use crate::schema::tests::{assert_keys, conforms};
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
/// A design with a box and a saved render setup, `Shot`, that renders with the GPU path tracer,
/// at the parameter-check level `level`.
fn with_a_shot(level: ParamCheck) -> Session {
let mut s = Session { param_check: level, ..Session::default() };
for (id, p) in [
("solid.box", json!({"length": 20, "width": 15, "height": 10, "body_name": "Subject"})),
("render.settings.set", json!({"name": "Shot", "set": {"width": 96, "height": 64, "job": {"method": "gpu_path", "options": {}}}})),
] {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
}
s
}
#[test]
fn schemas_and_the_key_check_agree() {
assert_keys(input_schema_for::<GroundParams>(), &["gap_mm", "margin_fraction", "name", "reflectance_linear", "replace_stage", "set"]);
assert_keys(input_schema_for::<BackdropParams>(), &["expected_revision", "kind", "name", "reflectance_linear", "replace_stage", "set"]);
assert_keys(input_schema_for::<ClearParams>(), &["name", "set"]);
}
/// The handlers refuse these calls after reading them; strict refuses them with their message.
/// They need a design with a body and a saved setup, and the design's revision, so the shared
/// helper, which has a sketch only, doesn't fit.
#[test]
fn strict_keeps_todays_errors() {
let cases: [(&str, fn(u64) -> Value); 12] = [
("render.stage.ground", |_| json!({"name": "Shot", "margin_fraction": 11})),
("render.stage.ground", |_| json!({"name": "Shot", "gap_mm": 0})),
("render.stage.ground", |_| json!({"name": "Shot", "reflectance_linear": [2, 0, 0]})),
("render.stage.ground", |_| json!({"name": "Raster"})),
("render.stage.ground", |_| json!({"name": " Shot"})),
("render.stage.ground", |_| json!({"name": "Shot", "set": {"width": 0}})),
("render.stage.backdrop", |_| json!({"name": "Shot", "kind": "cyclorama", "expected_revision": 999_999})),
("render.stage.backdrop", |r| json!({"name": "Raster", "kind": "floor_wall", "expected_revision": r})),
("render.stage.backdrop", |r| json!({"name": "Shot", "kind": "floor_wall", "reflectance_linear": [0, -1, 0], "expected_revision": r})),
("render.stage.clear", |_| json!({"name": ""})),
("render.stage.clear", |_| json!({"name": "Shot", "set": {"width": 0}})),
("render.stage.clear", |_| json!({"name": "Shot", "set": 5})),
];
for (id, p) in cases {
let error = |level: ParamCheck| {
let mut s = with_a_shot(level);
let p = p(s.revision);
s.execute(id, &p).err().map(|e| e.to_string())
};
let off = error(ParamCheck::Off);
assert!(off.is_some(), "{id} {} should fail today", p(0));
assert_eq!(error(ParamCheck::Strict), off, "{id} {}", p(0));
}
// A stage already there needs `replace_stage`, for a ground and a backdrop alike.
for level in [ParamCheck::Off, ParamCheck::Strict] {
let mut s = with_a_shot(level);
s.execute("render.stage.ground", &json!({"name": "Shot"})).unwrap_or_else(|e| panic!("{e}"));
let ground = s.execute("render.stage.ground", &json!({"name": "Shot"})).err().map(|e| e.to_string());
let p = json!({"name": "Shot", "kind": "floor_wall", "expected_revision": s.revision});
let backdrop = s.execute("render.stage.backdrop", &p).err().map(|e| e.to_string());
for error in [ground, backdrop] {
assert!(error.as_deref().is_some_and(|e| e.contains("replace_stage: true")), "{level:?}: {error:?}");
}
}
}
/// #11455: a ground, both backdrops and a clear reply as the output schema says, in Warn
/// without warnings; only a backdrop carries its shape and limits. Like the handlers, warn and
/// strict refuse a misspelt key.
#[test]
fn stage_commands_reply_with_their_schema() {
let schema = output_schema_for::<StageSaved>();
let schema = schema.as_value();
let mut s = with_a_shot(ParamCheck::Warn);
let run = |s: &mut Session, id: &str, p: Value| {
let r = s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
assert!(conforms(schema, schema, &r), "{r} doesn't match {schema}");
assert!(r.get("param_warnings").is_none(), "{r}");
r
};
let p = json!({"name": "Shot", "set": {}, "margin_fraction": 0.5, "gap_mm": 1, "reflectance_linear": [0.2, 0.2, 0.2], "replace_stage": false});
let r = run(&mut s, "render.stage.ground", p);
assert!(r["setup"].is_object() && r.get("geometry_kind").is_none(), "{r}");
for kind in ["floor_wall", "cyclorama"] {
let revision = s.revision;
let p = json!({"name": "Shot", "kind": kind, "replace_stage": true, "expected_revision": revision});
let r = run(&mut s, "render.stage.backdrop", p);
assert!(r["geometry_kind"].is_string() && r["limitations"].is_array(), "{r}");
}
run(&mut s, "render.stage.clear", json!({"name": "Shot", "set": {}}));
for level in [ParamCheck::Warn, ParamCheck::Strict] {
let mut s = with_a_shot(level);
let error = s.execute("render.stage.clear", &json!({"nme": "Shot"})).err().map(|e| e.to_string()).unwrap_or_default();
assert!(error.contains("nme"), "{level:?}: {error}");
}
}
+165
View File
@@ -0,0 +1,165 @@
//! Contract for `surface.match` in `cmd/surface_match.rs` (#10981). It returns `add_feature`'s
//! reply, a [`super::features::FeatureResult`].
use std::borrow::Cow;
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
use serde::de::Error as _;
use serde::{Deserialize, Deserializer};
use serde_json::Value;
use solidcore_record::Vec3;
use solidcraft_kernel::{Construction, Continuity};
use super::check::whole;
use super::types::{Point3, StringList, present};
use crate::params;
/// Rebuild one face of a surface body so that its boundary nearest a picked edge chain of another
/// body lies on that chain and repeats that body's shape there up to `order`, while the opposite
/// boundary keeps its place. The body keeps its name: a match changes it rather than making one.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct MatchSurfaceParams {
/// The surface body whose face is rebuilt, by name.
#[serde(deserialize_with = "body")]
pub body: String,
#[serde(default)]
pub faces: Option<MatchFaces>,
/// The body the seam must meet, by name.
#[serde(deserialize_with = "target")]
pub target: String,
pub edges: MatchEdges,
/// How closely the seam follows the target: `g1` (tangent), `g2` (curvature, the default) or
/// `g3`, in any case.
#[serde(default, deserialize_with = "present")]
#[schemars(with = "MatchOrder")]
pub order: Option<MatchOrder>,
// Not `FeatureCommon`: a match makes no body, so `body_name` and `body_names` would do
// nothing. `participants` stays because `timeline.redefine` passes a feature's own back.
/// The feature's name in the timeline.
#[serde(default)]
pub name: Option<String>,
/// The bodies taking part, which every feature records; each an existing body or `*`. A match
/// changes only `body`, whatever this lists.
#[serde(default)]
pub participants: Option<StringList>,
}
/// A body name, with the handler's message for anything that isn't text.
fn body<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> {
match Value::deserialize(d)? {
Value::String(s) => Ok(s),
_ => Err(D::Error::custom(whole("`body` is required"))),
}
}
/// The target's name, with the handler's message for anything that isn't text.
fn target<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> {
match Value::deserialize(d)? {
Value::String(s) => Ok(s),
_ => Err(D::Error::custom(whole("`target` is required"))),
}
}
/// Points `params::vec3` reads, or none if any isn't one.
fn points(v: &[Value]) -> Option<Vec<Vec3>> {
v.iter().map(params::vec3).collect()
}
/// Points on the face to rebuild, at most 64 (default: the body's first face).
#[derive(Clone, Debug, PartialEq)]
pub struct MatchFaces(pub Vec<Vec3>);
impl<'de> Deserialize<'de> for MatchFaces {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let faces =
Value::deserialize(d)?.as_array().and_then(|a| points(a)).ok_or_else(|| D::Error::custom("must be a list of points [x, y, z]"))?;
if faces.len() > 64 {
return Err(D::Error::custom(whole("too many points on the face to rebuild")));
}
Ok(MatchFaces(faces))
}
}
impl JsonSchema for MatchFaces {
fn schema_name() -> Cow<'static, str> {
"MatchFaces".into()
}
fn inline_schema() -> bool {
true
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let point = generator.subschema_for::<Point3>();
json_schema!({
"description": "Points on the face to rebuild, at most 64 (default: the body's first face).",
"type": "array",
"items": point,
"maxItems": 64
})
}
}
/// Points on one edge chain of the target: the rebuilt boundary takes its place. 1 to 10 000,
/// with the handler's message for anything else.
#[derive(Clone, Debug, PartialEq)]
pub struct MatchEdges(pub Vec<Vec3>);
impl<'de> Deserialize<'de> for MatchEdges {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
Value::deserialize(d)?
.as_array()
.and_then(|a| points(a))
.filter(|edges| !edges.is_empty() && edges.len() <= 10_000)
.map(MatchEdges)
.ok_or_else(|| D::Error::custom(whole("`edges` must list 1 to 10000 points on the target's edge chain")))
}
}
impl JsonSchema for MatchEdges {
fn schema_name() -> Cow<'static, str> {
"MatchEdges".into()
}
fn inline_schema() -> bool {
true
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let point = generator.subschema_for::<Point3>();
json_schema!({
"description": "Points on one edge chain of the target: the rebuilt boundary takes its place.",
"type": "array",
"items": point,
"minItems": 1,
"maxItems": 10000
})
}
}
/// A match's continuity, as `params::continuity` reads it for a match (g1 to g3), with its messages.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct MatchOrder(pub Continuity);
impl<'de> Deserialize<'de> for MatchOrder {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let v = Value::deserialize(d)?;
params::continuity("surface.match", Some(&v), "`order`", Construction::Match).map(MatchOrder).map_err(|e| match e {
crate::EngineError::BadParams { msg, .. } => D::Error::custom(whole(msg)),
other => D::Error::custom(whole(other.to_string())),
})
}
}
impl JsonSchema for MatchOrder {
fn schema_name() -> Cow<'static, str> {
"MatchOrder".into()
}
fn inline_schema() -> bool {
true
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({"type": "string", "pattern": "^[gG][123]$"})
}
}
#[cfg(test)]
#[path = "surface_match_tests.rs"]
mod tests;
@@ -0,0 +1,121 @@
use serde_json::{Value, json};
use solidcore_record::Vec3;
use super::MatchSurfaceParams;
use crate::Session;
use crate::schema::features::FeatureResult;
use crate::schema::tests::{assert_keys, conforms};
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
fn run(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
}
/// Two points on the cylinder's straight vertical wall seam, as the command's own tests pick it.
fn picks(s: &Session) -> Vec<Vec3> {
let boss = s.world_state().ok().and_then(|st| st.body("Boss").map(|b| b.body.clone())).unwrap_or_else(|| panic!("no Boss"));
let e = boss
.edges(boss.size() * 1e-2)
.unwrap_or_default()
.into_iter()
.find(|e| e.length > 25.0 && (e.mid.x.hypot(e.mid.y) - 10.0).abs() < 0.05 && e.mid.z > 5.0 && e.mid.z < 25.0)
.unwrap_or_else(|| panic!("the cylinder has a straight vertical wall seam"));
let (a, b) = (e.points.first().copied().unwrap_or(e.mid), e.points.last().copied().unwrap_or(e.mid));
vec![a + (b - a) * 0.25, a + (b - a) * 0.75]
}
/// A cylinder `Boss` of radius 10, 30 high, and a cylindrical surface `Skirt` of radius 11 from
/// the cylinder's wall seam round 60 degrees, as in the command's own tests: a point on the skirt.
fn boss_and_skirt(s: &mut Session) -> [f64; 3] {
run(s, "sketch.create", json!({"plane": "XY", "name": "Round"}));
run(s, "sketch.circle.center", json!({"center": [0, 0], "radius": 10}));
run(s, "sketch.finish", json!({}));
run(s, "solid.extrude", json!({"sketch": "Round", "distance": 30, "body_name": "Boss"}));
let seam = picks(s)[0];
let a = seam.y.atan2(seam.x);
let at = |t: f64| json!([11.0 * t.cos(), 11.0 * t.sin()]);
run(s, "sketch.create", json!({"plane": "XY", "name": "Skirt sketch"}));
let arc = run(s, "sketch.arc.three_point", json!({"start": at(a), "end": at(a + 60f64.to_radians()), "through": at(a + 30f64.to_radians())}))
["created"]["curves"][0]
.clone();
run(s, "sketch.finish", json!({}));
run(s, "surface.extrude", json!({"sketch": "Skirt sketch", "curves": [arc], "distance": 30, "body_name": "Skirt"}));
let mid = a + 30f64.to_radians();
[11.0 * mid.cos(), 11.0 * mid.sin(), 15.0]
}
#[test]
fn schemas_and_the_key_check_agree() {
assert_keys(input_schema_for::<MatchSurfaceParams>(), &["body", "edges", "faces", "name", "order", "participants", "target"]);
}
/// A call that fails today gets the same message in strict. The handler looks both bodies up
/// before it reads the picks, so each call runs on a design with two (a missing `edges` is left
/// out: strict says it is required, the handler what it must list).
#[test]
fn strict_keeps_todays_errors() {
let many = vec![[0, 0, 0]; 10_001];
let faces = vec![[0, 0, 0]; 65];
let e = json!([[0, 0, 0]]);
for p in [
json!({"target": "T", "edges": e}),
json!({"body": 5, "target": "T", "edges": e}),
json!({"body": "Nope", "target": "T", "edges": e}),
json!({"body": "B", "edges": e}),
json!({"body": "B", "target": [], "edges": e}),
json!({"body": "B", "target": "Nope", "edges": e}),
json!({"body": "B", "target": "T", "faces": faces, "edges": e}),
json!({"body": "B", "target": "T", "edges": []}),
json!({"body": "B", "target": "T", "edges": null}),
json!({"body": "B", "target": "T", "edges": 5}),
json!({"body": "B", "target": "T", "edges": [[1, 2]]}),
json!({"body": "B", "target": "T", "edges": many}),
json!({"body": "B", "target": "T", "edges": e, "order": "g5"}),
json!({"body": "B", "target": "T", "edges": e, "order": "g0"}),
json!({"body": "B", "target": "T", "edges": e, "order": null}),
json!({"body": "B", "target": "T", "edges": e, "order": 2}),
json!({"body": "B", "target": "T", "edges": e}),
] {
let error = |level: ParamCheck| {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 4, "body_name": "B"}));
run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 4, "corner": [10, 0, 0], "body_name": "T"}));
s.param_check = level;
s.execute("surface.match", &p).err().map(|e| e.to_string())
};
let off = error(ParamCheck::Off);
assert!(off.is_some(), "{p} should fail today");
assert_eq!(error(ParamCheck::Strict), off, "{p}");
}
}
/// #10981: a match replies with `FeatureResult`, in Warn without warnings for good calls (the
/// body's first face, and a picked face with every other key) and with them for a misspelt key;
/// strict refuses a face point the handler would drop.
#[test]
fn a_match_replies_with_the_feature_result() {
let schema = output_schema_for::<FeatureResult>();
let matches = |r: &Value, warned: bool| {
let schema = schema.as_value();
assert!(conforms(schema, schema, r), "{r} doesn't match {schema}");
assert_eq!(r.get("param_warnings").is_some(), warned, "{r}");
};
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
let on_skirt = boss_and_skirt(&mut s);
let chain = picks(&s);
let r = run(&mut s, "surface.match", json!({"body": "Skirt", "target": "Boss", "edges": chain, "order": "G2"}));
matches(&r, false);
assert_eq!(r["changed_bodies"], json!(["Skirt"]), "{r}");
let r = run(
&mut s,
"surface.match",
json!({"body": "Skirt", "faces": [on_skirt], "target": "Boss", "edges": chain, "order": "g1", "name": "Seam", "participants": ["*"]}),
);
matches(&r, false);
assert_eq!(r["name"], "Seam", "{r}");
matches(&run(&mut s, "surface.match", json!({"body": "Skirt", "target": "Boss", "edges": chain, "ordr": "g3"})), true);
s.param_check = ParamCheck::Strict;
let error = s.execute("surface.match", &json!({"body": "Skirt", "faces": [on_skirt, [1, 2]], "target": "Boss", "edges": chain}));
let error = error.err().map(|e| e.to_string());
assert!(error.as_deref().is_some_and(|e| e.contains("`faces` must be a list of points")), "{error:?}");
}
-11
View File
@@ -55,9 +55,6 @@ doc.recover
doc.recovery_discard
doc.recovery_list
document.inspect
document.metadata
document.metadata.remove
document.metadata.set
engine.commands
explode.create
explode.delete
@@ -78,8 +75,6 @@ frame.member
frame.profile.insert
frame.profile.list
frame.profile.properties
inspect.clearance
inspect.compare
inspect.interference
inspect.measure
inspect.parameter_sweep
@@ -107,8 +102,6 @@ material.delete
material.edit
material.inspect
material.list
material.properties.directional
material.properties.validate
mesh.convert
motion.delete
motion.export
@@ -187,9 +180,6 @@ render.settings.set
render.shot.duplicate
render.shot.remove
render.shot.rename
render.stage.backdrop
render.stage.clear
render.stage.ground
render.start
render.status
select.clear
@@ -332,7 +322,6 @@ surface.extend
surface.extrude
surface.fair
surface.loft
surface.match
surface.offset
surface.patch
surface.reverse_normal
+10
View File
@@ -3291,6 +3291,16 @@ fn unequal_chamfers_on_circular_rims() {
assert!(rel(full - volume(&mut s), flipped) < 5e-3, "{} vs {flipped}", full - volume(&mut s));
}
/// A solid box top is not a rim: Lip says to Shell first (#10437).
#[test]
fn lip_on_a_solid_box_says_to_shell_first() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10}));
let err = s.execute("plastic.lip", &json!({"face": [20, 15, 10], "width": 1, "height": 2})).unwrap_err().to_string();
assert!(err.contains("Shell"), "{err}");
assert!(err.contains("rim") || err.contains("opening"), "{err}");
}
/// An enclosure built in the usual order: shell, bosses, then the lip on the rim.
#[test]
fn lip_after_boss() {
+49
View File
@@ -4681,6 +4681,55 @@ mod tests {
mod glass_tests {
use super::*;
/// Intel UHD 620 OpenGL collapsed long line quads or expanded short ones across the
/// viewport with vector/scalar direction division (#8053). Exercise the actual
/// OpenGL shader rather than the native headless selector, which skips GL on Windows.
#[test]
fn opengl_draws_grid_lines_in_every_direction_and_at_a_point() {
let mut descriptor = crate::instance_descriptor_from_env();
descriptor.backends = wgpu::Backends::GL;
let instance = wgpu::Instance::new(descriptor);
let policy = crate::select::Policy::from_env().expect("a valid GPU test policy");
let Some((adapter, _)) = crate::select::pick_among(pollster::block_on(instance.enumerate_adapters(wgpu::Backends::GL)), policy) else {
eprintln!("skipped: no OpenGL adapter qualifies under {}", policy.name());
return;
};
eprintln!("OpenGL regression adapter: {:?}", adapter.get_info());
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_limits: wgpu::Limits::downlevel_webgl2_defaults().using_resolution(adapter.limits()),
..Default::default()
}))
.expect("the OpenGL adapter opens a device");
let window = GpuTarget { format: wgpu::TextureFormat::Rgba8Unorm, depth: None, samples: 1, tess: false, oit: false };
// Ten world units span 32 pixels here; a four-pixel line covers over 100 pixels.
// A point or a very short line stays a small cap, never a fill over the viewport.
let cases = [
([-5.0, 0.0, 0.0], [5.0, 0.0, 0.0], 100..=256),
([5.0, 0.0, 0.0], [-5.0, 0.0, 0.0], 100..=256),
([0.0, -5.0, 0.0], [0.0, 5.0, 0.0], 100..=256),
([0.0, 5.0, 0.0], [0.0, -5.0, 0.0], 100..=256),
([-5.0, -5.0, 0.0], [5.0, 5.0, 0.0], 120..=256),
([5.0, 5.0, 0.0], [-5.0, -5.0, 0.0], 120..=256),
([-5.0, 5.0, 0.0], [5.0, -5.0, 0.0], 120..=256),
([0.0; 3], [0.0; 3], 10..=64),
([0.0; 3], [0.0001, 0.0001, 0.0], 10..=64),
];
for offscreen in [false, true] {
let target = if offscreen { GpuTarget { depth: Some(wgpu::TextureFormat::Depth24PlusStencil8), ..window } } else { window };
let mut renderer = Renderer::with_window_target(&device, target, window, None);
for (index, (a, b, expected)) in cases.iter().enumerate() {
let mut scene = GpuScene::default();
let red = scene.color([255, 0, 0, 255]);
scene.under_line(*a, *b, red, 4.0);
let mut frame = test_frame(scene, 1.0);
frame.key = index as u64 + 1;
let pixels = render_frame(&mut renderer, &device, &queue, &frame, false);
let coloured = pixels.as_chunks::<4>().0.iter().filter(|p| p[0] > 240 && p[1] < 30 && p[2] < 30).count();
assert!(expected.contains(&coloured), "{a:?} to {b:?}, offscreen {offscreen}: {coloured} line pixels, expected {expected:?}");
}
}
}
#[test]
fn ground_shadow_does_not_require_order_independent_transparency() {
let batch = || Some(Batch { parts: Vec::new(), chunk: TRI_SIZE * 6 });
+1 -1
View File
@@ -38,7 +38,7 @@ ids, plane axes, worked examples and the known traps of this version; `file: \"S
Plan: work the numbers out first (the parameters, the expected volume and bounding box after each stage, the world point you \
will use for each edge or face pick). Then build in a few `batch` calls (all-or-nothing; the reply names the failing step; \
`save`, `measure` and `set_parameter` can be steps). Use `verbose: false`. Name every feature. `list_commands {\"filter\": word}` \
gives ids and one-line parameter docs (never unfiltered); `describe_command` gives one command's docs and schemas.\n\n\
gives ids and one-line parameter docs (never unfiltered: it also takes prefix, panel, compact: true and limit); `describe_command` gives one command's docs and schemas.\n\n\
Routes: a plate, block, boss, standoff or hole is cheapest as a primitive with expression fields (`solid.box`, `solid.cylinder`, \
`solid.hole`, `solid.fillet`, `solid.shell`, patterns and mirror), no sketch. Sketch when the profile is not a box or a circle. \
Fillet outside edges before shelling, build inside features after the shell, then the holes and cuts through walls. Give a blind \
+69
View File
@@ -1741,6 +1741,75 @@ fn list_commands_filter_by_a_command_id_matches_ids() {
assert!(ids.iter().all(|id| id.contains("sketch.delete")), "{} commands matched: {ids:?}", ids.len());
}
/// #11726: `list_commands` narrows by id prefix and by panel, shrinks each entry to id and label
/// or to chosen keys, and pages; with none of these the reply is the whole catalog as before. A
/// batch step narrows the same way, and a bad argument is a tool error that names it.
#[test]
fn list_commands_narrows_by_prefix_panel_fields_and_page() {
let mut s = server();
let all = tool(&mut s, "list_commands", json!({}));
let all = all.as_array().unwrap();
assert!(all.len() > 100 && all[0].get("params").is_some(), "the whole catalog, every key");
let ids = |v: &Value| v.as_array().unwrap().iter().map(|c| c["id"].as_str().unwrap().to_string()).collect::<Vec<_>>();
let sketch = tool(&mut s, "list_commands", json!({"prefix": "SKETCH."}));
assert!(!ids(&sketch).is_empty() && ids(&sketch).iter().all(|id| id.starts_with("sketch.")), "{:?}", ids(&sketch));
let want = all.iter().filter(|c| c["id"].as_str().unwrap().starts_with("sketch.")).count();
assert_eq!(ids(&sketch).len(), want, "every sketch command, not only those on a toolbar");
let modify = tool(&mut s, "list_commands", json!({"panel": "modify"}));
assert!(!ids(&modify).is_empty(), "{modify}");
assert!(
modify
.as_array()
.unwrap()
.iter()
.all(|c| c["panel"].as_str().unwrap().eq_ignore_ascii_case("modify") || c["tab"].as_str().unwrap().eq_ignore_ascii_case("modify")),
"only the whole panel name matches"
);
let compact = tool(&mut s, "list_commands", json!({"compact": true}));
assert_eq!(ids(&compact), ids(&Value::Array(all.clone())), "compact keeps every command");
assert!(compact.as_array().unwrap().iter().all(|c| c.as_object().unwrap().keys().eq(["id", "label"].iter())), "{}", compact[0]);
assert!(compact.to_string().len() * 5 < Value::Array(all.clone()).to_string().len(), "compact is far smaller");
let picked = tool(&mut s, "list_commands", json!({"prefix": "solid.fillet", "fields": ["id", "enabled"], "compact": true}));
assert_eq!(picked[0], json!({"id": "solid.fillet", "enabled": picked[0]["enabled"]}), "fields wins over compact");
// Pages: together they are the whole match list, in order.
let mut paged = Vec::new();
let mut offset = json!(0);
while let Some(at) = offset.as_u64() {
let page = tool(&mut s, "list_commands", json!({"prefix": "sketch.", "compact": true, "limit": 7, "offset": at}));
assert_eq!(page["total"], want, "{page}");
assert_eq!(page["offset"], at);
assert!(page["commands"].as_array().unwrap().len() <= 7);
paged.extend(ids(&page["commands"]));
offset = page["next_offset"].clone();
}
assert_eq!(paged, ids(&sketch));
let past = tool(&mut s, "list_commands", json!({"limit": 5, "offset": 1_000_000}));
assert_eq!((past["commands"].as_array().unwrap().len(), &past["next_offset"]), (0, &Value::Null), "{past}");
// A batch step narrows as the tool does.
let r = tool(&mut s, "batch", json!({"commands": [{"command": "list_commands", "params": {"prefix": "sketch.", "compact": true, "limit": 3}}]}));
let step = r["results"][0].clone();
assert_eq!(step["commands"].as_array().map(Vec::len), Some(3), "{r}");
assert_eq!(step["total"], want, "{r}");
for bad in [
json!({"limit": 0}),
json!({"limit": -1}),
json!({"offset": "x"}),
json!({"fields": ["nope"]}),
json!({"fields": "id"}),
json!({"compact": 1}),
json!({"prefix": 3}),
] {
// The argument schema refuses a wrong type before the tool runs; the tool refuses the rest.
let r = raw_tool(&mut s, "list_commands", bad.clone());
let text = r["result"]["content"][0]["text"].as_str().or(r["error"]["message"].as_str()).unwrap_or_default().to_string();
assert!(r["result"]["isError"] == json!(true) || r["error"]["code"].is_i64(), "{bad}: {r}");
let key = bad.as_object().unwrap().keys().next().unwrap().clone();
assert!(text.contains(&format!("`{key}`")), "{bad}: the error names the argument: {text}");
let r = raw_tool(&mut s, "batch", json!({"commands": [{"command": "list_commands", "params": bad.clone()}]}));
assert!(r["result"]["isError"] == json!(true) || r.to_string().contains(&format!("`{key}`")), "{bad} as a step: {r}");
}
}
/// Compatibility shims (docs/compatibility.md): a design saved before the rename (legacy
/// extension and format tag) opens through MCP, and the legacy resource URIs still read.
#[test]
+124 -13
View File
@@ -102,8 +102,26 @@ pub fn tool_definitions() -> Vec<Value> {
enabled right now. Ids are dotted and lower-case (solid.extrude, sketch.create, \
solid.fillet, sketch.inspect, timeline.rollback). Optional case-insensitive \
substring `filter` over id, label, tab and panel (not the parameter docs, so a command id \
finds that command rather than every command whose docs mention it).",
obj(json!({"filter": string("Substring to match, e.g. \"sketch\", \"fillet\", \"pattern\"")}), &[]),
finds that command rather than every command whose docs mention it). Narrow it further with `prefix` \
(start of the id) and `panel` (a whole panel or tab name); shrink each entry with `compact` (id and label) \
or `fields`; page it with `limit` and `offset`, which make the reply {commands, total, offset, next_offset}. \
With none of these the reply is the whole catalog, about 350 KB.",
obj(
json!({
"filter": string("Substring to match, e.g. \"sketch\", \"fillet\", \"pattern\""),
"prefix": string("Case-insensitive start of the command id, e.g. \"sketch.\""),
"panel": string("A whole toolbar panel or tab name, case-insensitive, e.g. \"Modify\" or \"SKETCH\""),
"compact": {"type": "boolean", "description": "Each entry is only {id, label} (default false)"},
"fields": {
"type": "array",
"items": {"type": "string", "enum": COMMAND_FIELDS},
"description": "Keep only these keys of each entry; overrides `compact`",
},
"limit": {"type": "integer", "minimum": 1, "description": "At most this many commands; the reply becomes {commands, total, offset, next_offset}"},
"offset": {"type": "integer", "minimum": 0, "description": "Skip this many matches first (default 0); with it the reply is paged as with `limit`"},
}),
&[],
),
Effect::ReadOnly,
),
tool(
@@ -146,7 +164,7 @@ pub fn tool_definitions() -> Vec<Value> {
"Run commands in order, stopping at the first error. Each step is {command, params}: an engine command id \
(as list_commands lists them). A step may also name one of these tools with that tool's arguments as params, \
and runs the engine command it stands for: set_parameter (parameters.change; `value` or `expression`), \
list_commands (engine.commands, with `filter`), describe_command (engine.describe), execute ({command, params}), \
list_commands (engine.commands, narrowed by its arguments), describe_command (engine.describe), execute ({command, params}), \
measure (inspect.measure), compare_picture (inspect.compare_picture, numbers only: no overlay PNG), \
inspect_design (document.inspect), undo, redo, export, save, open, new_design, crash_reports; screenshot, \
body_topology, batch and pending can't be steps. On an error the steps already run are undone (unless \
@@ -867,8 +885,9 @@ fn measure_summary_wanted(a: &Map<String, Value>) -> bool {
fn dispatch(b: &mut dyn Backend, name: &str, a: &Map<String, Value>) -> Result<Value, String> {
match name {
"list_commands" => {
let query = CommandQuery::parse(a)?;
let all = b.call("engine.commands", json!({}))?;
Ok(filter_commands(all, str_arg(a, "filter")))
Ok(query.apply(all))
}
"describe_command" => {
let id = str_arg(a, "id").ok_or("`id` is required")?;
@@ -941,11 +960,100 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Map<String, Value>) -> Result<V
/// The command catalog filtered by a case-insensitive substring of id, label, panel or tab
/// (`list_commands`; not the parameter docs, which name other commands, #269).
fn filter_commands(all: Value, filter: Option<&str>) -> Value {
let Some(f) = filter.map(str::to_lowercase).filter(|f| !f.is_empty()) else { return all };
let hit =
|c: &Value| ["id", "label", "panel", "tab"].iter().any(|k| c.get(*k).and_then(Value::as_str).is_some_and(|s| s.to_lowercase().contains(&f)));
Value::Array(all.as_array().map(|v| v.iter().filter(|c| hit(c)).cloned().collect()).unwrap_or_default())
/// The keys of a `list_commands` entry, which `fields` may keep.
const COMMAND_FIELDS: &[&str] = &["id", "label", "tab", "panel", "icon", "shortcut", "params", "enabled", "disabled_reason", "planned"];
/// How `list_commands` narrows the catalog (#11726): which commands, which keys of each, and
/// which page. The default keeps the whole catalog as it is.
#[derive(Clone, Debug, Default, PartialEq)]
struct CommandQuery {
filter: Option<String>,
prefix: Option<String>,
panel: Option<String>,
fields: Option<Vec<String>>,
limit: Option<usize>,
offset: Option<usize>,
}
impl CommandQuery {
fn parse(a: &Map<String, Value>) -> Result<Self, String> {
let text = |k: &str| -> Result<Option<String>, String> {
match a.get(k) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(s)) => Ok(Some(s.to_lowercase()).filter(|s| !s.is_empty())),
Some(_) => Err(format!("`{k}` must be a string")),
}
};
let count = |k: &str, least: u64| -> Result<Option<usize>, String> {
match a.get(k) {
None | Some(Value::Null) => Ok(None),
Some(v) => v
.as_u64()
.filter(|n| *n >= least)
.map(|n| Some(usize::try_from(n).unwrap_or(usize::MAX)))
.ok_or_else(|| format!("`{k}` must be a whole number of at least {least}")),
}
};
let compact = match a.get("compact") {
None | Some(Value::Null) => false,
Some(v) => v.as_bool().ok_or("`compact` must be true or false")?,
};
let fields = match a.get("fields") {
None | Some(Value::Null) => compact.then(|| vec!["id".to_string(), "label".to_string()]),
Some(Value::Array(v)) => {
let mut keep = Vec::new();
for f in v {
let name = f.as_str().filter(|n| COMMAND_FIELDS.contains(n)).ok_or_else(|| {
let shown: String = f.to_string().chars().take(64).collect();
format!("`fields` takes the entry keys {}; not {shown}", COMMAND_FIELDS.join(", "))
})?;
if !keep.iter().any(|k| k == name) {
keep.push(name.to_string());
}
}
Some(keep)
}
Some(_) => return Err("`fields` must be a list of entry keys".into()),
};
Ok(Self {
filter: text("filter")?,
prefix: text("prefix")?,
panel: text("panel")?,
fields,
limit: count("limit", 1)?,
offset: count("offset", 0)?,
})
}
fn matches(&self, c: &Value) -> bool {
let field = |k: &str| c.get(k).and_then(Value::as_str).map(str::to_lowercase).unwrap_or_default();
self.filter.as_ref().is_none_or(|f| ["id", "label", "panel", "tab"].iter().any(|k| field(k).contains(f.as_str())))
&& self.prefix.as_ref().is_none_or(|p| field("id").starts_with(p.as_str()))
&& self.panel.as_ref().is_none_or(|p| field("panel") == *p || field("tab") == *p)
}
/// The catalog narrowed: a list, or `{commands, total, offset, next_offset}` when paged.
fn apply(&self, all: Value) -> Value {
let Value::Array(all) = all else { return all };
let hits: Vec<Value> = all.into_iter().filter(|c| self.matches(c)).collect();
let total = hits.len();
let offset = self.offset.unwrap_or(0).min(total);
let take = self.limit.unwrap_or(usize::MAX);
let page: Vec<Value> = hits
.into_iter()
.skip(offset)
.take(take)
.map(|c| match (&self.fields, c) {
(Some(keep), Value::Object(m)) => Value::Object(m.into_iter().filter(|(k, _)| keep.contains(k)).collect()),
(_, c) => c,
})
.collect();
if self.limit.is_none() && self.offset.is_none() {
return Value::Array(page);
}
let end = offset + page.len();
json!({"commands": page, "total": total, "offset": offset, "next_offset": (end < total).then_some(end)})
}
}
/// `pending`: the end of a request still running when its call returned (bridged only; a
@@ -975,8 +1083,8 @@ fn pending(b: &mut dyn Backend) -> Result<Value, String> {
/// How a tool step's result is cut down to what the tool itself would reply.
enum Narrow {
/// A `list_commands` step's catalog, filtered as the tool filters it.
Commands(String),
/// A `list_commands` step's catalog, narrowed as the tool narrows it.
Commands(CommandQuery),
/// A `measure {summary: true}` step's reply, as `measure_summary` shapes it.
MeasureSummary,
}
@@ -984,7 +1092,7 @@ enum Narrow {
impl Narrow {
fn apply(&self, v: Value) -> Value {
match self {
Narrow::Commands(f) => filter_commands(v, Some(f)),
Narrow::Commands(q) => q.apply(v),
Narrow::MeasureSummary => measure_summary(v),
}
}
@@ -1010,7 +1118,10 @@ fn batch_step(step: &Value) -> Result<(String, Value, Option<Narrow>), String> {
let a = params.as_object().unwrap_or(&empty);
let mapped = |c: &str, p: Value| Ok((c.to_string(), p, None));
match command {
"list_commands" => Ok(("engine.commands".into(), json!({}), str_arg(a, "filter").map(|f| Narrow::Commands(f.to_string())))),
"list_commands" => {
let query = CommandQuery::parse(a)?;
Ok(("engine.commands".into(), json!({}), (query != CommandQuery::default()).then_some(Narrow::Commands(query))))
}
"set_parameter" => {
let mut p = pick(a, &["name", "unit", "comment"]);
p["expression"] = parameter_value(a)?;
+29 -7
View File
@@ -60,10 +60,33 @@ fn host(url: &str) -> &str {
rest.split(['/', '?', '#']).next().unwrap_or(rest)
}
/// A selectable label of the conversation. egui leaves a label scrolled out of view out of the
/// text selection, so a selection whose start had scrolled away was dropped, and Copy missed the
/// hidden text (#11527). Out of view this one still takes part: it keeps its end of the selection
/// and gives its text to Copy, clipped to nothing so it is not drawn and never moves the selection.
pub(super) fn selectable(ui: &mut egui::Ui, label: egui::Label) -> egui::Response {
use egui::text_selection::LabelSelectionState;
let (pos, galley, response) = label.selectable(true).layout_in_ui(ui);
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, ui.is_enabled(), galley.text()));
// As `egui::Label` draws a label that is not a button.
let colour = ui.visuals().text_color();
if ui.is_rect_visible(response.rect) {
let underline = if response.has_focus() || response.highlighted() { Stroke::new(1.0, colour) } else { Stroke::NONE };
LabelSelectionState::label_text_selection(ui, &response, pos, galley, colour, underline);
} else if ui.ctx().plugin::<LabelSelectionState>().lock().has_selection() {
let clip = ui.clip_rect();
ui.set_clip_rect(egui::Rect::NOTHING);
LabelSelectionState::label_text_selection(ui, &response, pos, galley, colour, Stroke::NONE);
ui.set_clip_rect(clip);
}
response
}
/// A paragraph's text, then its links as buttons (a laid-out text cannot be clicked in parts).
fn spans(ui: &mut egui::Ui, spans: &[Span], size: f32, color: Color32) {
let t = Tokens::get();
ui.add(egui::Label::new(job(ui, spans, size, color)).selectable(true).wrap());
let text = job(ui, spans, size, color);
selectable(ui, egui::Label::new(text).wrap());
let links: Vec<(&str, &str)> =
spans.iter().filter_map(|s| if let Span::Link { label, url } = s { Some((label.as_str(), url.as_str())) } else { None }).collect();
if !links.is_empty() {
@@ -92,7 +115,7 @@ pub fn show(ui: &mut egui::Ui, slot: usize, src: &str) {
_ => t.font_heading,
};
ui.add_space(4.0);
ui.add(egui::Label::new(RichText::new(markdown::plain(s)).size(size).semibold().color(t.text)).selectable(true).wrap());
selectable(ui, egui::Label::new(RichText::new(markdown::plain(s)).size(size).semibold().color(t.text)).wrap());
}
Block::Code { text, .. } => {
crate::ui_kit::Container::show_backing(
@@ -100,10 +123,9 @@ pub fn show(ui: &mut egui::Ui, slot: usize, src: &str) {
egui::Frame::NONE.fill(t.field).corner_radius(t.radius_sm).inner_margin(Margin::same(6)),
|ui| {
ui.set_width(ui.available_width());
ui.add(
egui::Label::new(RichText::new(text).monospace().size(size - (t.font_button - t.font_body)).color(t.text))
.selectable(true)
.wrap(),
selectable(
ui,
egui::Label::new(RichText::new(text).monospace().size(size - (t.font_button - t.font_body)).color(t.text)).wrap(),
);
if crate::ui_kit::button(ui, tr("ui.agent_chat_copy"), crate::ui_kit::Button::Small).clicked() {
ui.ctx().copy_text(text.clone());
@@ -129,7 +151,7 @@ pub fn show(ui: &mut egui::Ui, slot: usize, src: &str) {
}
Block::Pre(text) => {
egui::ScrollArea::horizontal().id_salt(("agent_chat_pre", slot, text.len())).show(ui, |ui| {
ui.add(egui::Label::new(RichText::new(text).monospace().size(t.font_small).color(t.text)).selectable(true));
selectable(ui, egui::Label::new(RichText::new(text).monospace().size(t.font_small).color(t.text)));
});
}
Block::Rule => {
+40 -4
View File
@@ -1056,6 +1056,8 @@ fn desktop_body(app: &mut SolveApp, ui: &mut egui::Ui) {
ui.spacing_mut().item_spacing.y = Tokens::get().space_10;
conversation(app, ui);
});
// Last, so the scroll goes to this area and not to one nested in the conversation.
scroll_while_selecting(ui);
});
if esc || stop_clicked {
cancel(app);
@@ -1678,6 +1680,40 @@ fn copy_message_menu(ui: &egui::Ui, response: &egui::Response, n: usize, text: &
}
}
/// A text selection dragged near or past the top or bottom of the conversation scrolls it, faster
/// the further out, so a long answer can be selected in one drag (#11527). Only a drag towards
/// that edge does: a click, a double click or a held press on a line by the edge, or a drag from
/// there into the conversation, scrolls nothing. `ui`: the conversation's scroll area, once its
/// content is drawn.
#[cfg(not(target_arch = "wasm32"))]
fn scroll_while_selecting(ui: &egui::Ui) {
// Scrolling starts this far inside the edge, and goes this many points a second for each
// point further out, up to four times as far out; once the pointer has moved more than
// `TOWARDS` that way since the press.
const EDGE: f32 = 32.0;
const SPEED: f32 = 16.0;
const TOWARDS: f32 = 8.0;
let view = ui.clip_rect();
let pointer = ui.input(|i| i.pointer.press_origin().filter(|p| i.pointer.primary_down() && view.contains(*p)).zip(i.pointer.latest_pos()));
let Some((from, at)) = pointer else {
return;
};
if !ui.ctx().plugin::<egui::text_selection::LabelSelectionState>().lock().has_selection() {
return;
}
let content = ui.min_rect();
let out = if at.y < view.top() + EDGE && from.y - at.y > TOWARDS && content.top() < view.top() - 0.5 {
at.y - (view.top() + EDGE)
} else if at.y > view.bottom() - EDGE && at.y - from.y > TOWARDS && content.bottom() > view.bottom() + 0.5 {
at.y - (view.bottom() - EDGE)
} else {
return;
};
let step = out.clamp(-4.0 * EDGE, 4.0 * EDGE) * SPEED * ui.input(|i| i.stable_dt).min(0.1);
// A positive delta moves the content down, showing what is above.
ui.scroll_with_delta_animation(vec2(0.0, -step), egui::style::ScrollAnimation::none());
}
#[cfg(not(target_arch = "wasm32"))]
fn conversation(app: &mut SolveApp, ui: &mut egui::Ui) {
let t = Tokens::get();
@@ -1696,7 +1732,7 @@ fn conversation(app: &mut SolveApp, ui: &mut egui::Ui) {
let before = ui.cursor();
ui_kit::assistant_bubble(ui, |ui| {
if !text.is_empty() {
ui.add(egui::Label::new(RichText::new(text).color(t.text)).selectable(true).wrap());
super::markdown_view::selectable(ui, egui::Label::new(RichText::new(text).color(t.text)).wrap());
}
if !pictures.is_empty() {
if let Some(at) = picture_strip(ui, n, pictures) {
@@ -1720,7 +1756,7 @@ fn conversation(app: &mut SolveApp, ui: &mut egui::Ui) {
}
Item::Agent { thought, text } => {
if *thought {
ui.add(egui::Label::new(RichText::new(text).italics().color(t.text_dim)).selectable(true).wrap());
super::markdown_view::selectable(ui, egui::Label::new(RichText::new(text).italics().color(t.text_dim)).wrap());
} else {
let before = ui.cursor();
ui_kit::assistant_prose(ui, |ui| super::markdown_view::show(ui, n, text));
@@ -1801,7 +1837,7 @@ fn conversation(app: &mut SolveApp, ui: &mut egui::Ui) {
Item::Approval { id, title, tool, answer: given } => {
ui_kit::assistant_approval(ui, tr("ui.agent_chat_approval"), |ui| {
ui_kit::assistant_tool_detail(ui, |ui| {
ui.add(egui::Label::new(RichText::new(title).monospace().size(t.font_caption)).selectable(true).wrap());
super::markdown_view::selectable(ui, egui::Label::new(RichText::new(title).monospace().size(t.font_caption)).wrap());
});
match given {
Some(true) => {
@@ -1910,7 +1946,7 @@ fn conversation(app: &mut SolveApp, ui: &mut egui::Ui) {
}
_ => {
if !p.text.is_empty() {
ui.add(egui::Label::new(RichText::new(&p.text).color(t.text)).selectable(true).wrap());
super::markdown_view::selectable(ui, egui::Label::new(RichText::new(&p.text).color(t.text)).wrap());
}
if p.image {
ui.label(RichText::new(tr("ui.agent_chat_view_attached")).color(t.text_dim).size(crate::theme::Tokens::get().font_small));
+127
View File
@@ -2905,6 +2905,133 @@ fn narrow_chat() -> crate::scenario::Harness {
h
}
/// The rows `a_selection_held_at_the_edge_scrolls_and_keeps_its_start` draws that are in view:
/// each row's number, where its text is, and the conversation's view.
fn rows_in_view(h: &crate::scenario::Harness) -> Vec<(usize, egui::Rect, egui::Rect)> {
let mut rows = Vec::new();
for clipped in &h.shapes {
crate::test_support::each_shape(&clipped.shape, &mut |shape| {
if let egui::Shape::Text(text) = shape
&& let Some(n) = text.galley.text().strip_prefix("Parts list row ").and_then(|n| n.parse::<usize>().ok())
{
let at = egui::Rect::from_min_size(text.pos, text.galley.size());
if clipped.clip_rect.intersects(at) {
rows.push((n, at, clipped.clip_rect));
}
}
});
}
rows.sort_by_key(|r| r.0);
rows
}
/// #11527: a text selection held at the conversation's edge scrolls it, and keeps its start once
/// that has scrolled out of view, so Copy takes the hidden rows too. Resting there with no button
/// down, or after letting go, scrolls nothing.
#[test]
fn a_selection_held_at_the_edge_scrolls_and_keeps_its_start() {
let mut h = narrow_chat();
for n in 0..80 {
h.app.agent_chat.items.push(Item::Agent { thought: false, text: format!("Parts list row {n}") });
}
h.frames(4);
let rows = rows_in_view(&h);
let (&(top, _, view), &(last, last_at, _)) = (rows.first().expect("rows in view"), rows.last().expect("rows in view"));
assert_eq!(last, 79, "the conversation shows its end");
let edge = egui::pos2(last_at.left() + 10.0, view.top() + 4.0);
let press = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
h.frame_with(vec![egui::Event::PointerMoved(edge)]);
h.frames(20);
assert_eq!(rows_in_view(&h)[0].0, top, "no button down: the pointer at the edge scrolls nothing");
// Start the selection at the end of the last row and hold it at the top edge.
let start = last_at.right_center() - egui::vec2(1.0, 0.0);
h.frame_with(vec![egui::Event::PointerMoved(start), press(start, true)]);
h.frame_with(vec![egui::Event::PointerMoved(edge)]);
for _ in 0..600 {
if !rows_in_view(&h).iter().any(|r| r.0 == last) {
break;
}
h.frame();
}
let rows = rows_in_view(&h);
assert!(!rows.iter().any(|r| r.0 == last), "holding the selection at the top scrolls the last row out of view");
let reached = rows[0].0;
assert!(reached < top, "row {reached} is in view, it was {top}");
h.frame_with(vec![press(edge, false)]);
let after = rows_in_view(&h)[0].0;
h.frames(20);
assert_eq!(rows_in_view(&h)[0].0, after, "letting go stops the scrolling");
assert!(h.ctx.plugin::<egui::text_selection::LabelSelectionState>().lock().has_selection(), "the selection keeps its start out of view");
h.frame_with(vec![egui::Event::Copy]);
let copied = h
.output
.commands
.iter()
.find_map(|c| if let egui::OutputCommand::CopyText(text) = c { Some(text.clone()) } else { None })
.expect("Copy takes the selection");
// The selection's moving end is under the pointer, in the first row or two in view.
for n in [reached + 2, 78] {
assert!(copied.contains(&format!("Parts list row {n}\n")), "row {n} is copied whole: {copied:?}");
}
assert!(copied.trim_end().ends_with("Parts list row 79"), "the selection ends where it started, out of view: {copied:?}");
}
/// #11527: only a drag towards the edge scrolls. Holding a press on the line at the top edge, a
/// double click there, or a drag from there into the conversation leaves it where it is.
#[test]
fn a_press_at_the_edge_without_a_drag_towards_it_scrolls_nothing() {
let mut h = narrow_chat();
// One paragraph taller than the conversation: every point in view is on its text.
h.app.agent_chat.items.push(Item::Agent { thought: false, text: "Bill of materials ".repeat(400) });
h.frames(4);
// Where the answer's text starts, the conversation's view, and the first line wholly in view
// (egui itself scrolls a pressed line that is cut off at the edge into view).
let answer = |h: &crate::scenario::Harness| {
let mut found = None;
for clipped in &h.shapes {
crate::test_support::each_shape(&clipped.shape, &mut |shape| {
if let egui::Shape::Text(text) = shape
&& text.galley.text().starts_with("Bill of materials")
&& clipped.clip_rect.intersects(egui::Rect::from_min_size(text.pos, text.galley.size()))
{
let line = text.galley.rows.iter().map(|r| r.rect().translate(text.pos.to_vec2())).find(|r| r.top() >= clipped.clip_rect.top());
found = Some((text.pos.y, clipped.clip_rect, line.expect("a line wholly in view")));
}
});
}
found.expect("the answer is in view")
};
let (rest, view, line) = answer(&h);
let press = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
let edge = egui::pos2(view.left() + 40.0, line.center().y);
assert!(edge.y < view.top() + 32.0, "the first line in view is by the edge");
h.frame_with(vec![egui::Event::PointerMoved(edge), press(edge, true)]);
h.frames(30);
assert_eq!(answer(&h).0, rest, "a press held at the edge scrolls nothing");
h.frame_with(vec![press(edge, false)]);
for _ in 0..2 {
h.frame_with(vec![press(edge, true)]);
h.frame_with(vec![press(edge, false)]);
}
h.frames(10);
assert_eq!(answer(&h).0, rest, "a double click at the edge scrolls nothing");
let inside = edge + egui::vec2(0.0, 20.0);
h.frame_with(vec![egui::Event::PointerMoved(edge), press(edge, true)]);
h.frame_with(vec![egui::Event::PointerMoved(inside)]);
h.frames(30);
assert_eq!(answer(&h).0, rest, "a drag from the edge into the conversation scrolls nothing");
h.frame_with(vec![press(inside, false)]);
// Towards the edge it does.
let middle = view.center();
h.frame_with(vec![egui::Event::PointerMoved(middle), press(middle, true)]);
h.frame_with(vec![egui::Event::PointerMoved(edge)]);
h.frames(30);
assert!(answer(&h).0 > rest, "a drag to the top edge scrolls the answer down");
h.frame_with(vec![press(edge, false)]);
}
/// #10172: before a connection, what the panel draws under the agent switcher (the Agent row
/// with Add agent and Refresh) keeps the panel's side margin, docked and floating: nothing starts
/// at the panel's edge or is cut off at the other one.
+357 -172
View File
@@ -36,10 +36,12 @@ pub(crate) fn reveal_feature(app: &mut SolveApp, id: u64, body: &str) {
/// to it, its Bodies folder and any group around it) and scroll its row into view. The rows
/// are opened here, not while drawing, because a collapsed ancestor never draws its children.
pub(crate) fn reveal_body(app: &mut SolveApp, body: &str) {
sync_reveals(app);
app.ui.show_browser = true;
app.preferences.timeline_left_browser_collapsed = false;
app.tree.reveal_feature = None;
app.tree.reveal = Some(body.to_string());
remember_reveal_source(app, body);
let state = app.session.model.state();
let Some(source) = crate::open_meshing::session_world(&app.session).body(body).map(|selected| selected.definition_name().to_string()) else {
return;
@@ -96,20 +98,14 @@ use crate::theme::Tokens;
use crate::SolveApp;
thread_local! {
/// The rows drawn this frame: (label, row, fold arrow area, open). For automation
/// (`ui.at {browser | chevron}`, `ui.browser`).
static DRAWN: std::cell::RefCell<Vec<(String, Rect, Option<Rect>, Option<bool>)>> = const { std::cell::RefCell::new(Vec::new()) };
static OCCURRENCES: std::cell::RefCell<Vec<(Vec<u64>, String, Rect, bool)>> = const { std::cell::RefCell::new(Vec::new()) };
/// The Browser's rows on screen: (label, row rect, fold arrow area, open).
/// Other presentations expose their geometry through `semantic_content::output`.
pub fn drawn_rows() -> Vec<crate::semantic_content::DrawnRow> {
crate::semantic_content::output(crate::semantic_content::ContentOwner::BROWSER).rows
}
/// The rows on screen: (label, row rect, fold arrow area, open).
pub fn drawn_rows() -> Vec<(String, Rect, Option<Rect>, Option<bool>)> {
DRAWN.with(|d| d.borrow().clone())
}
pub fn drawn_occurrences() -> Vec<(Vec<u64>, String, Rect, bool)> {
OCCURRENCES.with(|d| d.borrow().clone())
pub fn drawn_occurrences() -> Vec<crate::semantic_content::DrawnOccurrence> {
crate::semantic_content::output(crate::semantic_content::ContentOwner::BROWSER).occurrences
}
/// Browser state kept between frames.
@@ -125,6 +121,10 @@ pub struct TreeState {
/// of a part apart) and open the components, placements and folders above it: Find in
/// Browser, and a body picked in the viewport (#3005).
pub reveal: Option<String>,
/// Enough provenance to distinguish temporary rollback/suppression from target removal.
reveal_source: Option<BodyRevealSource>,
/// Runtime owner of the active pending requests, including requests with numeric IDs.
reveal_session: Option<u64>,
/// A native feature to reveal in its placed component's Features folder (Find in Timeline).
pub(super) reveal_feature: Option<features::Reveal>,
/// The Move/Copy panel of an occurrence.
@@ -155,13 +155,8 @@ pub struct TreeState {
pub view_pick: Option<u64>,
/// The Edit Canvas / Calibrate panel.
pub canvas_panel: Option<CanvasPanel>,
/// The bodies a row at the top of the history lights this frame (`history_rows`), for the
/// browser, drawn after it, to keep.
history_hover: Vec<String>,
/// The Edit Point Cloud panel.
pub cloud_panel: Option<CloudPanel>,
/// The plane a row at the top of the history lights this frame, kept the same way (#6975).
history_plane: Option<String>,
/// A Section Analysis hidden with its eye in the Analysis folder: its plane (origin,
/// normal), so the eye can bring it back (#822).
pub hidden_section: Option<(solidcore_record::Vec3, solidcore_record::Vec3)>,
@@ -175,15 +170,8 @@ pub struct TreeState {
pub capture_prompt: Option<String>,
/// The design, undo depth and visibility when the button last went down: a drag over the eyes
/// merges the steps it made into one (`eye_drag_frame`).
eye_press: Option<(Arc<Document>, usize, solidcraft_engine::Visibility)>,
/// The last row clicked (for Shift ranges).
anchor: Option<String>,
/// Row keys in drawing order this frame (for Shift ranges).
order: Vec<String>,
/// The previous frame's complete row order: a Shift click resolves its range here, since
/// this frame's `order` does not yet hold the rows below the clicked one (#4991).
order_prev: Vec<String>,
order_len: usize,
eye_press: Option<(u64, Arc<Document>, usize, solidcraft_engine::Visibility)>,
pub(crate) content: crate::semantic_content::Frame,
cache: Option<cache::Cache>,
}
@@ -193,6 +181,71 @@ struct FolderPick {
doc: Weak<Document>,
}
struct BodyRevealSource {
body: String,
session: u64,
creator: u64,
}
/// Pending presentation requests parked alongside their session in a bounded document slot.
/// Pointer input (`view_pick`) deliberately never travels with the slot.
#[derive(Default)]
pub(crate) struct PendingReveals {
session: Option<u64>,
body: Option<String>,
source: Option<BodyRevealSource>,
feature: Option<features::Reveal>,
timeline: Option<u64>,
}
pub(crate) fn discard_reveals(app: &mut SolveApp) {
app.tree.reveal = None;
app.tree.reveal_source = None;
app.tree.reveal_feature = None;
app.tree.timeline_reveal = None;
app.tree.view_pick = None;
app.tree.reveal_session = Some(app.session.uid);
}
/// Guard request creation and presentation even when a session changes after frame begin.
pub(crate) fn sync_reveals(app: &mut SolveApp) {
if app.tree.reveal_session.is_some_and(|session| session != app.session.uid) {
discard_reveals(app);
}
if app.tree.reveal_source.as_ref().is_some_and(|source| source.session != app.session.uid) {
app.tree.reveal = None;
app.tree.reveal_source = None;
}
app.tree.reveal_session = Some(app.session.uid);
}
pub(crate) fn take_reveals(app: &mut SolveApp) -> PendingReveals {
sync_reveals(app);
app.tree.view_pick = None;
PendingReveals {
session: Some(app.session.uid),
body: app.tree.reveal.take(),
source: app.tree.reveal_source.take(),
feature: app.tree.reveal_feature.take(),
timeline: app.tree.timeline_reveal.take(),
}
}
pub(crate) fn restore_reveals(app: &mut SolveApp, pending: PendingReveals) {
discard_reveals(app);
if pending.session == Some(app.session.uid) {
app.tree.reveal = pending.body;
app.tree.reveal_source = pending.source;
app.tree.reveal_feature = pending.feature;
app.tree.timeline_reveal = pending.timeline;
}
}
pub(crate) fn request_timeline_reveal(app: &mut SolveApp, id: u64) {
sync_reveals(app);
app.tree.timeline_reveal = Some(id);
}
impl FolderPick {
fn current(&self, app: &SolveApp) -> bool {
self.session == app.session.uid && std::ptr::eq(self.doc.as_ptr(), Arc::as_ptr(&app.session.doc))
@@ -200,6 +253,10 @@ impl FolderPick {
}
impl TreeState {
pub(crate) fn for_session(session: u64) -> Self {
Self { reveal_session: Some(session), ..Default::default() }
}
pub(crate) fn clear_folder_pick(&mut self) {
self.picked_folder = None;
}
@@ -224,15 +281,7 @@ impl TreeState {
}
fn remember(&mut self, key: &str) {
if let Some(slot) = self.order.get_mut(self.order_len) {
if slot != key {
slot.clear();
slot.push_str(key);
}
} else {
self.order.push(key.to_owned());
}
self.order_len += 1;
self.content.current_mut().remember(key);
}
}
@@ -478,15 +527,29 @@ pub fn toggle_folder(app: &mut SolveApp, key: &str) {
/// hidden items are noted; when it comes up, the steps the drag made are merged into the first of
/// them. Runs every frame, whatever else is open, so a release is never missed.
pub(crate) fn eye_drag_frame(app: &mut SolveApp, ctx: &egui::Context) {
crate::semantic_content::ensure_session(app, ctx);
let (pressed, down) = ctx.input(|i| (i.pointer.primary_pressed(), i.pointer.primary_down()));
if pressed {
app.tree.eye_press = Some((app.session.doc.clone(), app.session.undo.len(), without_origin(app.session.visibility.clone())));
if pressed && app.tree.eye_press.is_none() {
app.tree.eye_press = Some((app.session.uid, app.session.doc.clone(), app.session.undo.len(), without_origin(app.session.visibility.clone())));
}
if down {
return;
}
end_eye_drag(app, ctx);
}
/// Also called when the presentation holding the gesture disappears while the button is down.
pub(crate) fn end_eye_drag(app: &mut SolveApp, ctx: &egui::Context) {
let capture = crate::semantic_content::release_eye(ctx);
let dragged = ctx.data_mut(|d| d.remove_temp::<bool>(crate::ui_kit::eye_drag_id())).is_some();
let Some((doc, depth, visibility)) = app.tree.eye_press.take().filter(|_| dragged) else { return };
let Some((_, doc, depth, visibility)) = app
.tree
.eye_press
.take()
.filter(|(session, ..)| dragged && *session == app.session.uid && capture.is_none_or(|source| source.session == *session))
else {
return;
};
// shortcut: once the history is full (200 steps) its length stops growing, so the drag is left as
// separate steps, like the point-cloud and canvas drags; the visibility test keeps a trimmed front
// from being merged wrongly.
@@ -496,6 +559,10 @@ pub(crate) fn eye_drag_frame(app: &mut SolveApp, ctx: &egui::Context) {
}
}
pub(crate) fn discard_eye_press(app: &mut SolveApp) {
app.tree.eye_press = None;
}
/// The Origin's own eye is not an undo step (`set_origin_shown`), so the drag's steps do not hold it:
/// it is left out of the comparison.
fn without_origin(mut visibility: solidcraft_engine::Visibility) -> solidcraft_engine::Visibility {
@@ -612,11 +679,12 @@ fn eye_left(ui: &egui::Ui) -> bool {
pub(crate) fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row<'_>) -> RowResp {
let compact = compact_tree(ui);
let out = crate::ui_kit::tree_row(ui, id, &Row { compact, eye_left: eye_left(ui), ..*row });
let mut out = crate::ui_kit::tree_row(ui, id, &Row { compact, eye_left: eye_left(ui), ..*row });
crate::semantic_content::route_response(ui, id, &mut out);
if let Some(r) = out.rect.filter(|r| ui.is_rect_visible(*r)) {
let x = r.left() + if compact { Tokens::get().space_8 } else { 2.0 } + row.depth as f32 * INDENT;
let fold = row.fold.map(|_| Rect::from_min_max(pos2(x - 4.0, r.top()), pos2(x + 15.0, r.bottom())));
DRAWN.with(|d| d.borrow_mut().push((row.label.to_string(), r, fold, row.fold)));
crate::semantic_content::record_row(ui, (row.label.to_string(), r, fold, row.fold), &out);
}
out
}
@@ -624,11 +692,11 @@ pub(crate) fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row<'_>) -> RowRes
/// A Ctrl/Shift-aware click on a selectable row.
fn click_select(app: &mut SolveApp, ui: &egui::Ui, key: String, sel: Option<Sel>) {
let mods = ui.input(|i| i.modifiers);
let rows = app.tree.content.current_mut();
if mods.shift
&& let Some(a) = app.tree.anchor.as_deref()
&& let Some(a) = rows.anchor.as_deref()
{
let order = app.tree.order.get(..app.tree.order_len).unwrap_or(&[]);
let items = shift_range(&app.tree.order_prev, a, &key).or_else(|| shift_range(order, a, &key));
let items = shift_range(&rows.previous, a, &key).or_else(|| shift_range(rows.current_order(), a, &key));
if let Some(items) = items {
// Move/Copy (and any open command whose active input takes bodies) gets every body
// in the range. Otherwise the range is an ordinary selection.
@@ -646,7 +714,7 @@ fn click_select(app: &mut SolveApp, ui: &egui::Ui, key: String, sel: Option<Sel>
return;
}
}
app.tree.anchor = Some(key);
app.tree.content.current_mut().anchor = Some(key);
let Some(sel) = sel else { return };
if mods.command || mods.ctrl {
if app.session.selection.contains(&sel) {
@@ -701,18 +769,29 @@ pub(crate) fn picked_body(sel: &Sel) -> Option<&str> {
/// A pick in the viewport: the Browser, when shown, opens down to the picked body's row and
/// scrolls to it (#3005). The body of the last item picked.
pub(crate) fn reveal_pick(app: &mut SolveApp) {
sync_reveals(app);
if app.ui.show_browser
&& let Some(body) = app.session.selection.last().and_then(picked_body)
{
app.tree.reveal = Some(body.to_string());
let body = body.to_owned();
remember_reveal_source(app, &body);
app.tree.reveal = Some(body);
}
}
fn remember_reveal_source(app: &mut SolveApp, body: &str) {
app.tree.reveal_source = crate::open_meshing::session_world(&app.session).body(body).map(|selected| BodyRevealSource {
body: body.to_owned(),
session: app.session.uid,
creator: selected.feature,
});
}
/// The placement path of the body being revealed, resolved once per model: a row opens when
/// the body's path runs through it, instead of every row expanding its subtree to test
/// containment (#7502). `None` when nothing is revealed or the body is a top-level design body.
fn reveal_path(app: &mut SolveApp) -> Option<Vec<u64>> {
let body = app.tree.reveal.clone()?;
let body = body_reveal(app)?.to_owned();
prepare_model(app);
app.tree.model.as_mut()?.body_path(&app.session, &body)
}
@@ -774,17 +853,24 @@ pub fn apply_fold(app: &mut SolveApp, open: bool, open_by_default: &[String], cl
/// Everything a frame's rows want done, applied after drawing.
#[derive(Default)]
struct Actions {
pub(crate) struct Actions {
pub(crate) session: Option<u64>,
menu: Option<(Pos2, Target)>,
hover_bodies: Vec<String>,
hovered: bool,
pub(crate) hover_bodies: Vec<String>,
pub(crate) hovered: bool,
/// The plane whose row is under the pointer, lit in the view (#6975).
hover_plane: Option<String>,
drop: Option<DropAction>,
pub(crate) hover_plane: Option<String>,
drop: Option<(crate::semantic_content::ContentSource, DropAction)>,
/// What the selection and the 3D view's pointer light in the tree.
lit: Lit,
}
impl Actions {
fn drop_from(&mut self, source: crate::semantic_content::ContentSource, action: DropAction) {
self.drop = Some((source, action));
}
}
/// Something in the 3D view with a row in the tree.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
enum Thing {
@@ -1018,16 +1104,11 @@ fn reveal_tab(ui: &egui::Ui, card: Rect, opacity: f32) {
/// One content-hugging browser; the shared container supplies floating or flush compact chrome.
pub fn browser(app: &mut SolveApp, ui: &mut egui::Ui) {
sync_reveals(app);
let available = ui.available_rect_before_wrap();
let t = Tokens::get();
std::mem::swap(&mut app.tree.order_prev, &mut app.tree.order);
app.tree.order_len = 0;
DRAWN.with(|d| d.borrow_mut().clear());
OCCURRENCES.with(|d| d.borrow_mut().clear());
let mut acts = Actions { hover_bodies: std::mem::take(&mut app.viewport.hover_bodies), ..Default::default() };
if crate::render_workspace::active(app) {
render_card(app, ui, &mut acts);
finish_browser(app, ui, acts);
render_card(app, ui);
return;
}
let stacked = crate::timeline_left::stacked(app);
@@ -1084,7 +1165,7 @@ pub fn browser(app: &mut SolveApp, ui: &mut egui::Ui) {
if collapsed {
browser_collapsed_rail(app, ui);
} else {
browser_contents(app, ui, &mut acts, true);
browser_contents(app, ui, true);
}
})
})
@@ -1104,10 +1185,9 @@ pub fn browser(app: &mut SolveApp, ui: &mut egui::Ui) {
crate::scenario::publish_rect("browser:float", rect);
}
}
finish_browser(app, ui, acts);
}
fn render_card(app: &mut SolveApp, ui: &mut egui::Ui, acts: &mut Actions) {
fn render_card(app: &mut SolveApp, ui: &mut egui::Ui) {
let bounds = crate::shell::panel_bounds(app, ui.available_rect_before_wrap());
let narrow = bounds.width() < 800.0;
crate::shell::register_panel(app, "render:browser:show", None, None);
@@ -1154,7 +1234,7 @@ fn render_card(app: &mut SolveApp, ui: &mut egui::Ui, acts: &mut Actions) {
}
}
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("sc_compact_tree"), true));
browser_contents(app, ui, acts, true);
browser_contents(app, ui, true);
ui.ctx().data_mut(|d| d.remove::<bool>(egui::Id::new("sc_compact_tree")));
},
);
@@ -1220,7 +1300,7 @@ pub(crate) fn panel_bounds(available: Rect, history: Option<Rect>, width: f32, g
Rect::from_min_max(min, pos2((min.x + width).min(right), bottom))
}
fn browser_contents(app: &mut SolveApp, ui: &mut egui::Ui, acts: &mut Actions, collapsible: bool) {
fn browser_contents(app: &mut SolveApp, ui: &mut egui::Ui, collapsible: bool) {
let t = Tokens::get();
// Only the floating card folds; the docked column keeps its full height.
let render = crate::render_workspace::active(app);
@@ -1248,9 +1328,9 @@ fn browser_contents(app: &mut SolveApp, ui: &mut egui::Ui, acts: &mut Actions, c
}
crate::edit_in_place::browser_strip(app, ui);
if collapsible && !stacked {
browser_rows_hugging(app, ui, acts);
browser_rows_hugging(app, ui);
} else {
browser_rows(app, ui, acts);
browser_rows(app, ui);
}
}
@@ -1362,19 +1442,28 @@ fn model_feature_indices(app: &SolveApp, component: u64, sketches: bool) -> Vec<
}
}
fn browser_rows(app: &mut SolveApp, ui: &mut egui::Ui, acts: &mut Actions) {
rows_scrolling(app, ui, acts, false);
fn browser_rows(app: &mut SolveApp, ui: &mut egui::Ui) {
crate::semantic_content::show(
app,
ui,
crate::semantic_content::ContentOwner::BROWSER,
crate::semantic_content::ContentRequest::Browser { shrink_height: false },
);
}
/// As [browser_rows], but the area hugs the rows it drew (the floating card), up to the height
/// the caller allowed.
fn browser_rows_hugging(app: &mut SolveApp, ui: &mut egui::Ui, acts: &mut Actions) {
rows_scrolling(app, ui, acts, true);
fn browser_rows_hugging(app: &mut SolveApp, ui: &mut egui::Ui) {
crate::semantic_content::show(
app,
ui,
crate::semantic_content::ContentOwner::BROWSER,
crate::semantic_content::ContentRequest::Browser { shrink_height: true },
);
}
fn rows_scrolling(app: &mut SolveApp, ui: &mut egui::Ui, acts: &mut Actions, shrink_height: bool) {
prepare_model(app);
acts.lit = Lit::of(app);
let mut cache = app.tree.cache.take().unwrap_or_default();
cache.prepare(app);
egui::ScrollArea::vertical()
@@ -1394,23 +1483,32 @@ fn rows_scrolling(app: &mut SolveApp, ui: &mut egui::Ui, acts: &mut Actions, shr
component_rows(app, ui, 0, 0, acts, &mut cache);
}
});
// The row was reached and scrolled to, or isn't in the tree (a body a later feature removes):
// either way the reveal is done.
app.tree.reveal = None;
app.tree.cache = Some(cache);
}
/// The Bodies folder and the Origin at the top of the vertical History (Preferences › Interface:
/// Show origin and bodies at the top, #6490): the browser's own rows, with their eyes, selection,
/// menus and fold state. The bodies are the active component's, in the placement being edited,
/// as that component's folder in the browser lists them. The rows stay out of the browser's row
/// order, automation list and reveal, which the browser keeps for its own rows.
/// Existing semantic rows, reusable by either presentation without a panel host.
pub(crate) fn render_content(app: &mut SolveApp, ui: &mut egui::Ui, request: crate::semantic_content::ContentRequest) -> Actions {
crate::semantic_content::ensure_session(app, ui.ctx());
let mut acts = Actions { session: Some(app.session.uid), lit: Lit::of(app), ..Default::default() };
match request {
crate::semantic_content::ContentRequest::Browser { shrink_height } => rows_scrolling(app, ui, &mut acts, shrink_height),
crate::semantic_content::ContentRequest::ActiveBodiesOrigin => active_bodies_origin(app, ui, &mut acts),
}
acts
}
/// History's optional prefix shares the Browser's cache, folds, row renderers and commands.
pub(crate) fn history_rows(app: &mut SolveApp, ui: &mut egui::Ui) {
crate::semantic_content::show(
app,
ui,
crate::semantic_content::ContentOwner::HISTORY,
crate::semantic_content::ContentRequest::ActiveBodiesOrigin,
);
}
fn active_bodies_origin(app: &mut SolveApp, ui: &mut egui::Ui, acts: &mut Actions) {
prepare_model(app);
let mut acts = Actions { lit: Lit::of(app), ..Default::default() };
let reveal = app.tree.reveal.take();
let order = app.tree.order_len;
let drawn = DRAWN.with(|d| d.borrow().len());
let component = app.session.active_component;
let path = (component != 0)
.then(|| app.session.active_occurrence_path.clone().unwrap_or_else(|| app.session.doc.first_path(component)))
@@ -1422,18 +1520,18 @@ pub(crate) fn history_rows(app: &mut SolveApp, ui: &mut egui::Ui) {
app.tree.cache = Some(cache);
ui.scope(|ui| {
ui.spacing_mut().item_spacing.y = 0.0;
folder_rows_at(app, ui, component, path.as_deref(), "bodies", 0, &mut acts, &bodies);
origin_rows(app, ui, 0, &mut acts);
folder_rows_at(app, ui, component, path.as_deref(), "bodies", 0, acts, &bodies);
origin_rows(app, ui, 0, acts);
});
app.tree.reveal = reveal;
// The browser's own rows reuse these order slots (`TreeState::remember`).
app.tree.order_len = order;
DRAWN.with(|d| d.borrow_mut().truncate(drawn));
app.viewport.hover_bodies = acts.hover_bodies.clone();
app.tree.history_hover = acts.hover_bodies;
app.viewport.hover_plane = acts.hover_plane.clone();
app.tree.history_plane = acts.hover_plane;
if let Some(d) = acts.drop {
}
pub(crate) fn apply_content_actions(app: &mut SolveApp, acts: Actions) {
if acts.session != Some(app.session.uid) {
return;
}
if let Some((_, d)) = acts.drop.filter(|(source, _)| crate::semantic_content::source_present(app, *source)) {
// The target may have consumed a payload before its source's absence was known.
// Commit only after all owners have reported, using the payload's original source.
apply_drop(app, d);
}
if let Some((at, target)) = acts.menu {
@@ -1441,22 +1539,16 @@ pub(crate) fn history_rows(app: &mut SolveApp, ui: &mut egui::Ui) {
}
}
fn finish_browser(app: &mut SolveApp, ui: &mut egui::Ui, mut acts: Actions) {
app.tree.order.truncate(app.tree.order_len);
if !acts.hovered {
acts.hover_bodies.clear();
}
// The history's rows, drawn before the browser, light bodies too (`history_rows`).
let history = std::mem::take(&mut app.tree.history_hover);
app.viewport.hover_bodies = if acts.hover_bodies.is_empty() { history } else { acts.hover_bodies };
let history_plane = app.tree.history_plane.take();
app.viewport.hover_plane = acts.hover_plane.or(history_plane);
if let Some(d) = acts.drop {
apply_drop(app, d);
}
if let Some((at, target)) = acts.menu {
crate::context_menu::open_for(app, at, target);
pub(crate) fn cancel_content_drag(ctx: &egui::Context, session: u64) {
if egui::DragAndDrop::payload::<Drag>(ctx).is_some_and(|payload| payload.source.session == session)
|| egui::DragAndDrop::payload::<OccurrenceDrag>(ctx).is_some_and(|payload| payload.source.session == session)
{
egui::DragAndDrop::clear_payload(ctx);
}
}
/// App-owned editor lifecycle; these windows do not depend on any tree being presented.
pub(crate) fn editors(app: &mut SolveApp, ui: &mut egui::Ui) {
crate::browser_prompts::occurrence_panel(app, ui);
crate::browser_prompts::capture_prompt(app, ui.ctx());
crate::browser_canvas::panel(app, ui.ctx());
@@ -1464,6 +1556,43 @@ fn finish_browser(app: &mut SolveApp, ui: &mut egui::Ui, mut acts: Actions) {
crate::sketch_attachment_ui::panel(app, ui.ctx());
}
/// History's prefix must neither open ancestors nor acknowledge a Browser reveal.
fn body_reveal(app: &SolveApp) -> Option<&str> {
app.tree.content.browser_presentation().then_some(app.tree.reveal.as_deref()).flatten()
}
/// A hidden or collapsed presentation retains its request. Removed targets expire here.
pub(crate) fn prune_reveals(app: &mut SolveApp) {
sync_reveals(app);
if let Some(body) = app.tree.reveal.as_ref() {
let world = crate::open_meshing::session_world(&app.session);
let source = app.tree.reveal_source.as_ref().filter(|source| source.body == *body);
let removed = source.is_some_and(|source| source.session != app.session.uid || app.session.doc.feature(source.creator).is_none());
let evaluated = match source {
Some(source) => app.session.doc.features.iter().enumerate().any(|(index, feature)| {
feature.id == source.creator && !feature.suppressed && app.session.doc.marker.is_none_or(|marker| index < marker)
}),
None => app.session.doc.marker.is_none() && !app.session.doc.features.iter().any(|feature| feature.suppressed),
};
// A refused feature can temporarily remove a body from evaluation too. Its pending
// reveal remains useful when the feature is repaired.
let healthy =
app.session.model.results.iter().all(|result| result.error.is_none()) && crate::open_meshing::placement_error(&app.session).is_none();
if world.body(body).is_none() && (removed || (evaluated && healthy)) {
app.tree.reveal = None;
app.tree.reveal_source = None;
}
} else {
app.tree.reveal_source = None;
}
if app.tree.reveal_feature.as_ref().is_some_and(|target| app.session.doc.feature(target.id).is_none()) {
app.tree.reveal_feature = None;
}
if app.tree.timeline_reveal.is_some_and(|id| app.session.doc.feature(id).is_none()) {
app.tree.timeline_reveal = None;
}
}
fn component_name(app: &SolveApp, id: u64) -> String {
if id == 0 {
return app.session.doc.name.clone();
@@ -1500,7 +1629,7 @@ fn component_rows_inner(
return;
}
let key = &root.key;
let reveal = app.tree.reveal.clone();
let reveal = body_reveal(app).map(str::to_owned);
if let Some(body) = reveal.as_ref() {
let reveals =
cache.root_bodies.contains(body) || reveal_path(app).is_some_and(|rp| root.children.iter().any(|path| rp.starts_with(path.as_slice())));
@@ -1583,7 +1712,7 @@ fn component_rows_inner(
if let Some(resp) = &r.resp
&& let Some(d) = drop_target(ui, resp, |d: &Drag| d.comp != id && d.folder != "construction" && d.groups.is_empty(), DropMark::Box)
{
acts.drop = Some(DropAction::Component { to: id, folder: d.folder, keys: d.keys.clone() });
acts.drop_from(d.source, DropAction::Component { to: id, folder: d.folder, keys: d.keys.clone() });
}
// A placement row dropped on the design row moves out to the design.
@@ -1591,7 +1720,7 @@ fn component_rows_inner(
&& let Some(d) = occurrence_drop_target(ui, resp, |d| occurrence_can_land(app, d, id))
{
let occurrence = d.path.last().copied().unwrap_or(0);
acts.drop = Some(DropAction::Reparent { occurrence, path: d.path.clone(), parent: id, parent_path: None });
acts.drop_from(d.source, DropAction::Reparent { occurrence, path: d.path.clone(), parent: id, parent_path: None });
}
if !open {
return;
@@ -1705,7 +1834,7 @@ fn occurrence_rows_inner(
},
);
if let Some(rect) = r.rect.filter(|_| r.resp.is_some()) {
OCCURRENCES.with(|d| d.borrow_mut().push((path.to_vec(), placement.name.clone(), rect, open)));
crate::semantic_content::record_occurrence(ui, (path.to_vec(), placement.name.clone(), rect, open));
// Near the row's end, clear of an eye there.
let end = r.eye_at.filter(|e| e.x > rect.center().x).map_or(rect.right() - 12.0, |e| e.x - 24.0);
crate::scenario::publish_handle(&format!("row:occurrence:{}", key.trim_start_matches("o:")), pos2(end, rect.center().y));
@@ -1725,8 +1854,11 @@ fn occurrence_rows_inner(
});
// Dragging the row moves the placement it stands for. A press on its fold arrow, eye or
// radio is not a drag of the row, so those controls keep their own jobs.
if resp.drag_started() && !r.on_control {
resp.dnd_set_drag_payload(OccurrenceDrag { path: path.to_vec(), component: placement.component });
if resp.drag_started()
&& !r.on_control
&& let Some(source) = crate::semantic_content::current_source(app)
{
resp.dnd_set_drag_payload(OccurrenceDrag { source, path: path.to_vec(), component: placement.component });
}
}
if r.fold {
@@ -1770,7 +1902,7 @@ fn occurrence_rows_inner(
&& let Some(d) =
drop_target(ui, resp, |d: &Drag| d.comp != placement.component && d.folder != "construction" && d.groups.is_empty(), DropMark::Box)
{
acts.drop = Some(DropAction::Component { to: placement.component, folder: d.folder, keys: d.keys.clone() });
acts.drop_from(d.source, DropAction::Component { to: placement.component, folder: d.folder, keys: d.keys.clone() });
}
// The top of a sibling's row puts the dragged placement before it; the rest of the row, and
@@ -1786,8 +1918,7 @@ fn occurrence_rows_inner(
app.session.doc.sort_placements(parent, &mut order);
let at = order.iter().position(|o| *o == placement.occurrence).unwrap_or(order.len());
order.insert(at, dragged);
let order: Vec<String> = order.iter().map(u64::to_string).collect();
let _ = app.run("browser.order", json!({ "folder": "components", "component": parent, "order": order }));
acts.drop_from(d.source, DropAction::OccurrenceOrder { parent, order });
}
// A placement row dropped on this one moves into this row's component.
if let Some(resp) = &r.resp
@@ -1796,7 +1927,10 @@ fn occurrence_rows_inner(
})
{
let occurrence = d.path.last().copied().unwrap_or(0);
acts.drop = Some(DropAction::Reparent { occurrence, path: d.path.clone(), parent: placement.component, parent_path: Some(path.to_vec()) });
acts.drop_from(
d.source,
DropAction::Reparent { occurrence, path: d.path.clone(), parent: placement.component, parent_path: Some(path.to_vec()) },
);
}
if !open {
return;
@@ -1914,7 +2048,7 @@ fn origin_rows(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize, acts: &mut A
app.set_origin_shown(!app.origin_shown());
}
if let Some(p) = r.secondary {
crate::context_menu::open_for(app, p, Target::Origin);
acts.menu = Some((p, Target::Origin));
}
if !open {
return;
@@ -1957,7 +2091,7 @@ fn origin_rows(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize, acts: &mut A
_ => None,
};
if let Some(target) = target {
crate::context_menu::open_for(app, p, target);
acts.menu = Some((p, target));
}
}
if r.hovered && icon == "plane" {
@@ -2450,6 +2584,7 @@ impl Entry {
/// What is being dragged in the tree: items of one component folder.
#[derive(Clone, Debug)]
struct Drag {
source: crate::semantic_content::ContentSource,
comp: u64,
folder: &'static str,
keys: Vec<String>,
@@ -2467,6 +2602,7 @@ impl Drag {
/// A placement row dragged in the tree: the exact path it sits at, and the component it places.
#[derive(Clone, Debug)]
struct OccurrenceDrag {
source: crate::semantic_content::ContentSource,
/// The placement's path from the design root (its own occurrence id last).
path: Vec<u64>,
/// The component the placement is an instance of.
@@ -2622,7 +2758,7 @@ fn folder_rows_at(
let fstatic = folder_static(folder);
let key = &data.key;
let prefix = key.rsplit_once('/').map_or(key.as_str(), |(prefix, _)| prefix);
if let Some(r) = app.tree.reveal.as_deref()
if let Some(r) = body_reveal(app)
&& folder == "bodies"
&& let Some(e) = data.entries().find(|e| e.row_key.strip_prefix("b:") == Some(r))
{
@@ -2693,17 +2829,20 @@ fn folder_rows_at(
DropMark::Box,
)
{
acts.drop = Some(if d.comp == comp {
DropAction::Order {
comp,
folder: fstatic,
keys: d.keys.clone(),
groups: d.groups.clone(),
order: order_before(&data.top_order, &d.entries(), None),
}
} else {
DropAction::Component { to: comp, folder: d.folder, keys: d.keys.clone() }
});
acts.drop_from(
d.source,
if d.comp == comp {
DropAction::Order {
comp,
folder: fstatic,
keys: d.keys.clone(),
groups: d.groups.clone(),
order: order_before(&data.top_order, &d.entries(), None),
}
} else {
DropAction::Component { to: comp, folder: d.folder, keys: d.keys.clone() }
},
);
}
if !open {
return;
@@ -2790,8 +2929,11 @@ fn folder_level(
}
if let Some(resp) = &r.resp {
// Dragging a group row moves the group; a press on its arrow or eye is not a drag.
if resp.drag_started() && !r.on_control {
resp.dnd_set_drag_payload(Drag { comp, folder: fstatic, keys: Vec::new(), groups: vec![id] });
if resp.drag_started()
&& !r.on_control
&& let Some(source) = crate::semantic_content::current_source(app)
{
resp.dnd_set_drag_payload(Drag { source, comp, folder: fstatic, keys: Vec::new(), groups: vec![id] });
}
// The top of the row puts the drop before the group; the rest puts it inside.
let level = app.session.doc.group_level(id);
@@ -2803,26 +2945,29 @@ fn folder_level(
d.comp == comp && d.folder == fstatic && fits(d, if zone == DropMark::Above { level - 1 } else { level })
}) {
let before = BrowserEntry::Group(id);
acts.drop = Some(match (zone, holder) {
(DropMark::Box, _) => {
DropAction::Move { comp, folder: fstatic, keys: d.keys.clone(), groups: d.groups.clone(), group: Some(id), before: None }
}
(DropMark::Above, Some(h)) => DropAction::Move {
comp,
folder: fstatic,
keys: d.keys.clone(),
groups: d.groups.clone(),
group: Some(h),
before: Some(before),
acts.drop_from(
d.source,
match (zone, holder) {
(DropMark::Box, _) => {
DropAction::Move { comp, folder: fstatic, keys: d.keys.clone(), groups: d.groups.clone(), group: Some(id), before: None }
}
(DropMark::Above, Some(h)) => DropAction::Move {
comp,
folder: fstatic,
keys: d.keys.clone(),
groups: d.groups.clone(),
group: Some(h),
before: Some(before),
},
(DropMark::Above, None) => DropAction::Order {
comp,
folder: fstatic,
order: order_before(top, &d.entries(), Some(&before)),
keys: d.keys.clone(),
groups: d.groups.clone(),
},
},
(DropMark::Above, None) => DropAction::Order {
comp,
folder: fstatic,
order: order_before(top, &d.entries(), Some(&before)),
keys: d.keys.clone(),
groups: d.groups.clone(),
},
});
);
}
}
if gopen {
@@ -2901,7 +3046,7 @@ fn entry_row(
let pattern_key = e.patterns.as_deref();
let patterns_open = pattern_key.is_some_and(|key| is_open(app, key, true));
let rect = row_rect(ui);
let revealed = folder == "bodies" && app.tree.reveal.as_ref().is_some_and(|b| e.row_key.strip_prefix("b:") == Some(b.as_str()));
let revealed = folder == "bodies" && body_reveal(app).is_some_and(|b| e.row_key.strip_prefix("b:") == Some(b));
if !ui.is_rect_visible(rect) && !patterns_open && !revealed {
ui.allocate_space(rect.size());
return;
@@ -2959,10 +3104,10 @@ fn entry_row(
toggle(app, key, true);
}
if folder == "bodies"
&& app.tree.reveal.as_ref().is_some_and(|b| e.row_key.strip_prefix("b:") == Some(b.as_str()))
&& body_reveal(app).is_some_and(|b| e.row_key.strip_prefix("b:") == Some(b))
&& let Some(rect) = r.rect
&& crate::semantic_content::scroll_acknowledged(ui, rect)
{
ui.scroll_to_rect(rect, Some(egui::Align::Center));
app.tree.reveal = None;
}
if r.eye {
@@ -2979,7 +3124,7 @@ fn entry_row(
if folder == "construction" || (folder == "bodies" && !shift && app.dialog.is_some()) {
if let Some(s) = e.sel.clone() {
if matches!(s, Sel::Body { .. }) {
app.tree.anchor = Some(e.row_key.clone());
app.tree.content.current_mut().anchor = Some(e.row_key.clone());
}
pick_from_browser(app, s);
}
@@ -3030,9 +3175,12 @@ fn entry_row(
}
let Some(resp) = &r.resp else { return };
// Drag: the selected items of this folder when this one is selected, else just this one.
if resp.drag_started() && !r.on_control {
if resp.drag_started()
&& !r.on_control
&& let Some(source) = crate::semantic_content::current_source(app)
{
let keys: Vec<String> = if selected { selected_keys(app, comp, folder) } else { vec![e.key.clone()] };
resp.dnd_set_drag_payload(Drag { comp, folder, keys, groups: Vec::new() });
resp.dnd_set_drag_payload(Drag { source, comp, folder, keys, groups: Vec::new() });
}
let fits = |d: &Drag| {
let doc = &app.session.doc;
@@ -3043,16 +3191,19 @@ fn entry_row(
};
if let Some(d) = drop_target(ui, resp, |d: &Drag| d.comp == comp && d.folder == folder && !d.keys.contains(&e.key) && fits(d), DropMark::Above) {
let before = BrowserEntry::item(&e.key);
acts.drop = Some(match group {
Some(g) => DropAction::Move { comp, folder, keys: d.keys.clone(), groups: d.groups.clone(), group: Some(g), before: Some(before) },
None => DropAction::Order {
comp,
folder,
order: order_before(top, &d.entries(), Some(&before)),
keys: d.keys.clone(),
groups: d.groups.clone(),
acts.drop_from(
d.source,
match group {
Some(g) => DropAction::Move { comp, folder, keys: d.keys.clone(), groups: d.groups.clone(), group: Some(g), before: Some(before) },
None => DropAction::Order {
comp,
folder,
order: order_before(top, &d.entries(), Some(&before)),
keys: d.keys.clone(),
groups: d.groups.clone(),
},
},
});
);
}
}
@@ -3071,6 +3222,7 @@ enum DropMark {
/// While a matching payload hovers the row, mark where it would land; on release, the payload.
fn drop_target(ui: &egui::Ui, resp: &egui::Response, accept: impl Fn(&Drag) -> bool, mark: DropMark) -> Option<std::sync::Arc<Drag>> {
let accept = |drag: &Drag| crate::semantic_content::same_document(ui, drag.source) && accept(drag);
let t = Tokens::get();
let hovering = resp.dnd_hover_payload::<Drag>().filter(|d| accept(d));
if hovering.is_some() {
@@ -3091,6 +3243,7 @@ fn drop_target(ui: &egui::Ui, resp: &egui::Response, accept: impl Fn(&Drag) -> b
/// Like [`drop_target`] for a row that takes a drop above it (its top part) or inside it.
fn drop_target_zone(ui: &egui::Ui, resp: &egui::Response, accept: impl Fn(&Drag, DropMark) -> bool) -> Option<(std::sync::Arc<Drag>, DropMark)> {
let accept = |drag: &Drag, zone| crate::semantic_content::same_document(ui, drag.source) && accept(drag, zone);
let t = Tokens::get();
let r = resp.rect;
let at = ui.input(|i| i.pointer.hover_pos().or(i.pointer.latest_pos()));
@@ -3143,6 +3296,7 @@ fn occurrence_reorder_target(
resp: &egui::Response,
accept: impl Fn(&OccurrenceDrag) -> bool,
) -> Option<std::sync::Arc<OccurrenceDrag>> {
let accept = |drag: &OccurrenceDrag| crate::semantic_content::same_document(ui, drag.source) && accept(drag);
if resp.dnd_hover_payload::<OccurrenceDrag>().is_some_and(|d| accept(&d)) {
ui.painter().hline(resp.rect.x_range(), resp.rect.top(), Stroke::new(2.0, Tokens::get().accent));
}
@@ -3154,6 +3308,7 @@ fn occurrence_reorder_target(
/// While a matching placement drag hovers the row, mark where it would land; on release, the payload.
fn occurrence_drop_target(ui: &egui::Ui, resp: &egui::Response, accept: impl Fn(&OccurrenceDrag) -> bool) -> Option<std::sync::Arc<OccurrenceDrag>> {
let accept = |drag: &OccurrenceDrag| crate::semantic_content::same_document(ui, drag.source) && accept(drag);
let t = Tokens::get();
let hovering = resp.dnd_hover_payload::<OccurrenceDrag>().filter(|d| accept(d));
if hovering.is_some() {
@@ -3169,14 +3324,40 @@ fn occurrence_drop_target(ui: &egui::Ui, resp: &egui::Response, accept: impl Fn(
/// A drop, applied after the tree is drawn.
enum DropAction {
OccurrenceOrder {
parent: u64,
order: Vec<u64>,
},
/// Into a group (before an item of it), or out of groups.
Move { comp: u64, folder: &'static str, keys: Vec<String>, groups: Vec<u64>, group: Option<u64>, before: Option<BrowserEntry> },
Move {
comp: u64,
folder: &'static str,
keys: Vec<String>,
groups: Vec<u64>,
group: Option<u64>,
before: Option<BrowserEntry>,
},
/// Out of groups, in this order of the folder's top level.
Order { comp: u64, folder: &'static str, keys: Vec<String>, groups: Vec<u64>, order: Vec<BrowserEntry> },
Order {
comp: u64,
folder: &'static str,
keys: Vec<String>,
groups: Vec<u64>,
order: Vec<BrowserEntry>,
},
/// Into another component (bodies and sketches).
Component { to: u64, folder: &'static str, keys: Vec<String> },
Component {
to: u64,
folder: &'static str,
keys: Vec<String>,
},
/// A placement moved into another component (or the design, `parent` 0).
Reparent { occurrence: u64, path: Vec<u64>, parent: u64, parent_path: Option<Vec<u64>> },
Reparent {
occurrence: u64,
path: Vec<u64>,
parent: u64,
parent_path: Option<Vec<u64>>,
},
}
fn entry_json(e: &BrowserEntry) -> Value {
@@ -3193,6 +3374,10 @@ fn entries_json(keys: &[String], groups: &[u64]) -> Vec<Value> {
fn apply_drop(app: &mut SolveApp, d: DropAction) {
match d {
DropAction::OccurrenceOrder { parent, order } => {
let order: Vec<String> = order.iter().map(u64::to_string).collect();
let _ = app.run("browser.order", json!({ "folder": "components", "component": parent, "order": order }));
}
DropAction::Move { comp, folder, keys, groups, group, before } => {
let items = entries_json(&keys, &groups);
let before = before.map(|b| entry_json(&b));
+71 -18
View File
@@ -2,11 +2,14 @@
use super::*;
use crate::history_rows::{HistoryFeature, HistoryRows};
use crate::semantic_content::FeatureDrag;
#[derive(Clone, Debug)]
pub(crate) struct Reveal {
pub id: u64,
pub path: Vec<u64>,
/// Native Features and combined History are distinct presentations of the same feature.
history: bool,
}
#[cfg(test)]
@@ -19,6 +22,7 @@ thread_local! {
/// active component or history marker. The folder is the one of the placement `body` belongs to:
/// without a body there is no telling which copy of a component was meant.
pub(crate) fn reveal(app: &mut SolveApp, id: u64, body: &str) {
sync_reveals(app);
let Some(feature) = app.session.doc.feature(id) else { return };
if matches!(feature.kind, FeatureKind::Sketch { .. }) {
return; // Sketches are listed in Sketches, not Features.
@@ -46,17 +50,31 @@ pub(crate) fn reveal(app: &mut SolveApp, id: u64, body: &str) {
let scope = if path.is_empty() { format!("c{component}") } else { occurrence_key(&path) };
app.tree.open_row(&scope);
app.tree.expanded.insert(format!("{scope}/features"));
app.tree.reveal_feature = Some(Reveal { id, path });
app.tree.reveal_feature = Some(Reveal { id, path, history: false });
}
/// With the history in the browser: open the feature's component and history, and scroll to it.
pub(crate) fn reveal_in_history(app: &mut SolveApp, id: u64) {
sync_reveals(app);
let Some(component) = app.session.doc.feature(id).map(|feature| feature.component) else { return };
let path = if component == 0 {
Vec::new()
} else {
app.session
.active_occurrence_path
.clone()
.filter(|_| app.session.active_component == component)
.unwrap_or_else(|| app.session.doc.first_path(component))
};
app.ui.show_browser = true;
app.tree.collapsed.remove("c0");
app.tree.collapsed.remove(&format!("c{component}"));
app.tree.collapsed.remove(&format!("c{component}/history"));
app.tree.reveal_feature = Some(Reveal { id, path: Vec::new() });
for depth in 1..=path.len() {
app.tree.open_row(&occurrence_key(&path[..depth]));
}
let scope = if path.is_empty() { format!("c{component}") } else { occurrence_key(&path) };
app.tree.open_row(&scope);
app.tree.collapsed.remove(&format!("{scope}/history"));
app.tree.reveal_feature = Some(Reveal { id, path, history: true });
}
/// Feature folders hide later rows only while editing; history always lists every feature.
@@ -150,8 +168,8 @@ pub(super) fn rows(app: &mut SolveApp, ui: &mut egui::Ui, component: u64, path:
let mut slots: Vec<(f32, usize)> = Vec::new();
// Where dragged rows can be dropped, as in the History at the left: these slots and one
// before the marker, each with the folder the rows join (#9565).
let drag_id = egui::Id::new(("sc_browser_history_drag", &scope));
let mut dragged: Option<Vec<u64>> = ui.data(|d| d.get_temp(drag_id));
let drag_id = ui.id().with(("sc_browser_history_drag", &scope));
let mut dragged = app.tree.content.current_mut().feature_drags.get(&drag_id).cloned();
let mut drops: Vec<DropSlot> = Vec::new();
let mut marker_resp = None;
for row in &plan.rows {
@@ -251,7 +269,7 @@ fn feature_row(
depth: usize,
fold: Option<bool>,
mark: Mark,
drag: Option<&mut Option<Vec<u64>>>,
drag: Option<&mut Option<FeatureDrag>>,
) -> bool {
let Some(feature) = app.session.doc.features.get(index) else { return false };
let (component, path) = (here.component, here.path);
@@ -315,10 +333,16 @@ fn feature_row(
let scope = here.scope;
if let Some(rect) = r.rect {
crate::scenario::publish_handle(&format!("row:browser-feature:{scope}:{id}"), rect.center());
if app.tree.reveal_feature.as_ref().is_some_and(|target| target.id == id && target.path == path) {
ui.scroll_to_rect(rect, Some(egui::Align::Center));
let history = app.preferences.history_in_browser;
if app.tree.content.browser_presentation()
&& app.tree.reveal_feature.as_ref().is_some_and(|target| target.id == id && target.path == path && target.history == history)
&& crate::semantic_content::scroll_acknowledged(ui, rect)
{
app.tree.reveal_feature = None;
}
if history && app.tree.timeline_reveal == Some(id) && crate::semantic_content::scroll_acknowledged(ui, rect) {
app.tree.timeline_reveal = None;
}
}
if r.double {
if !path.is_empty() {
@@ -335,8 +359,9 @@ fn feature_row(
if let Some(drag) = drag
&& !r.on_control
&& r.resp.as_ref().is_some_and(egui::Response::drag_started)
&& let Some(source) = crate::semantic_content::current_source(app)
{
*drag = Some(crate::timeline_left::drag_features(app, id, selected));
*drag = Some(FeatureDrag { source, features: crate::timeline_left::drag_features(app, id, selected) });
}
r.fold
}
@@ -345,6 +370,7 @@ fn feature_row(
fn marker_row(app: &SolveApp, ui: &mut egui::Ui, depth: usize) -> egui::Response {
let t = Tokens::get();
let (rect, response) = ui.allocate_exact_size(vec2(ui.available_width(), t.space_12), Sense::drag());
crate::semantic_content::record_capture(ui, &response);
let line = Rect::from_min_max(pos2(rect.left() + 2.0 + depth as f32 * INDENT, rect.top()), rect.max);
crate::ui_kit::history_marker(ui.painter(), line, true, crate::theme::TabAccent::for_tab(&t, &app.ui.tab));
crate::scenario::publish_handle("browser:rollback", rect.center());
@@ -366,7 +392,11 @@ fn drag_marker(app: &mut SolveApp, ui: &egui::Ui, resp: &egui::Response, slots:
let accent = crate::theme::TabAccent::current(ui.ctx()).color;
ui.painter().hline(resp.rect.left() + 4.0..=resp.rect.right() - 4.0, slot_y, Stroke::new(2.0, accent));
}
if resp.drag_stopped() && position != marker {
if resp.drag_stopped()
&& position != marker
&& crate::semantic_content::current_source(app).is_some_and(|source| crate::semantic_content::source_present(app, source))
&& crate::semantic_content::claim_action(ui, resp.id)
{
let n = app.session.doc.features.len();
let _ = app.run_heavy("timeline.roll_to", if position >= n { json!({}) } else { json!({ "position": position }) });
}
@@ -374,11 +404,23 @@ fn drag_marker(app: &mut SolveApp, ui: &egui::Ui, resp: &egui::Response, slots:
/// Dragging feature rows shows where they land with the History's drop bar; letting go moves them
/// with the History's own move, before or behind the marker by the side of it they land on.
fn drag_rows(app: &mut SolveApp, ui: &egui::Ui, plan: &HistoryRows, drag_id: egui::Id, dragged: Option<Vec<u64>>, drops: &[DropSlot], marker_y: f32) {
let Some(features) = dragged else { return };
fn drag_rows(
app: &mut SolveApp,
ui: &egui::Ui,
plan: &HistoryRows,
drag_id: egui::Id,
dragged: Option<FeatureDrag>,
drops: &[DropSlot],
marker_y: f32,
) {
let Some(drag) = dragged else { return };
if !crate::semantic_content::source_present(app, drag.source) {
app.tree.content.current_mut().feature_drags.remove(&drag_id);
return;
}
let (released, down) = ui.input(|i| (i.pointer.any_released(), i.pointer.any_down()));
if released || !down {
ui.data_mut(|d| d.remove::<Vec<u64>>(drag_id));
app.tree.content.current_mut().feature_drags.remove(&drag_id);
}
if !released && !down {
return;
@@ -394,7 +436,7 @@ fn drag_rows(app: &mut SolveApp, ui: &egui::Ui, plan: &HistoryRows, drag_id: egu
.and_then(|p| drops.iter().min_by(|a, b| (a.y - p.y).abs().total_cmp(&(b.y - p.y).abs())).copied());
if released {
if let Some(drop) = slot {
crate::timeline_left::drop_items(app, plan, &features, &[], drop.group, drop.position, drop.y < marker_y);
crate::timeline_left::drop_items(app, plan, &drag.features, &[], drop.group, drop.position, drop.y < marker_y);
}
return;
}
@@ -407,7 +449,7 @@ fn drag_rows(app: &mut SolveApp, ui: &egui::Ui, plan: &HistoryRows, drag_id: egu
Tokens::get().sketch_accent,
);
}
ui.data_mut(|d| d.insert_temp(drag_id, features));
app.tree.content.current_mut().feature_drags.insert(drag_id, drag);
}
#[cfg(test)]
@@ -423,9 +465,20 @@ mod tests {
_ => None,
};
h.app.tree.expanded.insert("c0/features".into());
DRAWN.with(|d| d.borrow_mut().clear());
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() };
h.ctx.run_ui(raw, |ui| rows(&mut h.app, ui, 0, &[], 0, &mut Actions::default())).textures_delta.clear();
h.ctx
.run_ui(raw, |ui| {
use crate::semantic_content::{self, ContentOwner, ContentRequest};
semantic_content::begin(&mut h.app, ui.ctx());
semantic_content::render_scope(&mut h.app, ui, ContentOwner::BROWSER, ContentRequest::Browser { shrink_height: false }, |app, ui| {
let mut actions = Actions::default();
rows(app, ui, 0, &[], 0, &mut actions);
actions
});
semantic_content::finalize(&mut h.app, ui.ctx());
})
.textures_delta
.clear();
drawn_rows().into_iter().map(|(label, ..)| label).collect()
}
File diff suppressed because it is too large Load Diff
+16 -4
View File
@@ -95,7 +95,10 @@ fn a_face_picked_in_the_viewport_opens_the_browser_to_its_placements_body() {
let (one, two) = (super::occurrence_key(&[occurrence]), super::occurrence_key(&[copy]));
h.app.tree.collapsed.extend([one.clone(), two.clone(), format!("{two}/bodies")]);
h.frames(2);
assert!(!h.app.tree.order.contains(&format!("b:{second}")), "the body's row is closed away");
assert!(
!h.app.tree.content.owner(crate::semantic_content::ContentOwner::BROWSER).unwrap().order.contains(&format!("b:{second}")),
"the body's row is closed away"
);
let face = Sel::Face { body: second.clone(), index: 0, point: solidcore_record::Vec3::new(55.0, 5.0, 10.0) };
crate::viewport::select(&mut h.app, Some(face), false, false);
assert_eq!(h.app.tree.reveal.as_deref(), Some(second.as_str()));
@@ -103,8 +106,14 @@ fn a_face_picked_in_the_viewport_opens_the_browser_to_its_placements_body() {
assert!(h.app.tree.reveal.is_none(), "revealed");
assert!(!h.app.tree.collapsed.contains(&two) && !h.app.tree.collapsed.contains(&format!("{two}/bodies")));
assert!(h.app.tree.collapsed.contains(&one), "the other placement stays as it was");
assert!(h.app.tree.order.contains(&format!("b:{second}")), "the row is drawn");
assert!(!h.app.tree.order.contains(&format!("b:{first}")), "the other placement's row stays closed away");
assert!(
h.app.tree.content.owner(crate::semantic_content::ContentOwner::BROWSER).unwrap().order.contains(&format!("b:{second}")),
"the row is drawn"
);
assert!(
!h.app.tree.content.owner(crate::semantic_content::ContentOwner::BROWSER).unwrap().order.contains(&format!("b:{first}")),
"the other placement's row stays closed away"
);
}
/// Hovering a placement row lights its whole subtree: the subtree is walked once per model, not
@@ -553,5 +562,8 @@ fn find_in_browser_opens_placements_that_start_closed() {
super::reveal_body(&mut h.app, &body);
h.frames(3);
assert!(super::is_open(&h.app, &super::occurrence_key(&[occurrence]), true));
assert!(h.app.tree.order.contains(&format!("b:{body}")), "the body's row is drawn");
assert!(
h.app.tree.content.owner(crate::semantic_content::ContentOwner::BROWSER).unwrap().order.contains(&format!("b:{body}")),
"the body's row is drawn"
);
}
+14 -1
View File
@@ -23,7 +23,8 @@ use crate::viewport::{Proj, projected_polygon};
/// direction a growing value points.
pub fn anchor(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Option<Vec3>)> {
let st = crate::open_meshing::session_world(&app.session);
let first = &anchor_item(app, d)?;
let item = anchor_item(app, d);
let Some(first) = &item else { return section_plane(d) };
let (at, normal) = match first {
Sel::Profile { sketch, index } => {
let ss = st.sketch(*sketch)?;
@@ -253,6 +254,18 @@ pub(crate) fn blend_grow_dir(m: &Mesh, index: usize, p: Vec3) -> Option<Vec3> {
Some(if edge_concave(m, index, p).unwrap_or(false) { n } else { -n })
}
/// A Section Analysis dialog reopened from its section's own geometry (Show viewport section
/// status): the plane it keeps as numbers when nothing is picked, so its arrow stands there as on
/// a picked plane (#11423).
fn section_plane(d: &Dialog) -> Option<(Vec3, Option<Vec3>)> {
if !matches!(d.kind, Kind::Section { .. }) {
return None;
}
let plane = d.extra.get("plane")?;
let point = |key: &str| serde_json::from_value::<[f64; 3]>(plane.get(key)?.clone()).ok().map(|[x, y, z]| Vec3::new(x, y, z));
Some((point("origin")?, Some(point("normal")?.normalized()?)))
}
/// The pick the dialog's arrow sits on: the first, or for a fillet or chamfer reopened from a
/// click on one of its faces (Press Pull), its edge nearest the click, at the point on it nearest
/// the click (#4986).
+4 -8
View File
@@ -1507,14 +1507,10 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
let _ = app.run("browser.visibility", json!({ "folder": "all", "visible": true }));
}
"ui.isolate" => {
// The picked bodies stay (shown first if they were hidden), everything else goes.
let others: Vec<String> =
crate::open_meshing::session_world(&app.session).bodies.iter().map(|b| b.name.clone()).filter(|b| !bodies.contains(b)).collect();
// The picked bodies stay (shown if they were hidden) and everything else goes, other
// components as components (#7896), in one step.
if !bodies.is_empty() {
let _ = app.run("browser.visibility", json!({ "items": bodies, "visible": true }));
}
if !others.is_empty() {
let _ = app.run("browser.visibility", json!({ "items": others, "visible": false }));
let _ = app.run("browser.visibility", json!({ "items": bodies, "visible": true, "isolate": true }));
}
}
"ui.lock" => {
@@ -1607,7 +1603,7 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
}
}
let _ = app.run("select.set", json!({"items": [{"type": "feature", "id": f}]}));
app.tree.timeline_reveal = Some(f);
crate::browser::request_timeline_reveal(app, f);
}
}
"ui.tangentChain" => tangent_chain(app),
+11
View File
@@ -2635,12 +2635,23 @@ mod tests {
assert_eq!(call("ui.render", json!({"path": at(&root, "a.png"), "width": 32, "height": 32}), true)["ok"], true);
}
assert_eq!(call("ui.screenshot", json!({"path": at(&root, "b.png")}), true)["ok"], true);
// Settings profiles are read and written by the app, not the engine, and stay inside too (#11362).
let profile = |id: &str, params: Value| json!({"command": id, "params": params});
let poc = json!({"format": "solidcraft-profile/1", "name": "scope-poc"});
assert_eq!(call("engine.execute", profile("settings.profile.import", json!({"profile": poc})), true)["ok"], true);
let inside = json!({"name": "scope-poc", "path": at(&root, "p.json")});
assert_eq!(call("engine.execute", profile("settings.profile.export", inside), true)["ok"], true);
assert_eq!(call("engine.execute", profile("settings.profile.import", json!({"path": at(&root, "p.json")})), true)["ok"], true);
std::fs::write(at(&base, "p.json"), poc.to_string()).unwrap();
for (method, params) in [
("engine.execute", export(at(&out, "a.step"))),
("engine.execute", export(at(&root, "../out/a.step"))),
("engine.script", json!({"commands": [{"command": "file.export", "params": {"path": at(&out, "a.step")}}]})),
("ui.render", json!({"path": at(&out, "a.png"), "width": 32, "height": 32})),
("ui.screenshot", json!({"path": at(&out, "b.png")})),
("engine.execute", profile("settings.profile.export", json!({"name": "scope-poc", "path": at(&out, "p.json")}))),
("engine.execute", profile("settings.profile.import", json!({"path": at(&base, "p.json")}))),
("engine.execute", profile("settings.profile.import", json!({"path": at(&out, "missing.json")}))),
] {
let v = call(method, params, true);
assert!(v["ok"] == false && v.to_string().contains("outside the folders this agent may use"), "{method}: {v}");
+26 -2
View File
@@ -133,8 +133,20 @@ pub fn open_object(app: &mut SolveApp, command: &str, params: &Value, fallback:
// copy beside it, the card shows when done. Headless and in tests (no wait set), here.
// The worker reports how many bodies it has measured, and stops when the card closes (#8706).
let progress = Arc::new(Progress::default());
// Tests hold the reply until let go (`apply_gate`), so a window thread held up under load
// cannot find the measurement already in where the card should still be measuring.
#[cfg(test)]
let gate = app.apply_gate.clone();
let hold = move || {
#[cfg(test)]
if let Some(gate) = gate
&& let Ok(gate) = gate.lock()
{
let _ = gate.recv();
}
};
if let Some(wait) = app.apply_off_thread
&& let Some(rx) = measure_beside(app.session.scratch(), command, params, progress.clone())
&& let Some(rx) = measure_beside(app.session.scratch(), command, params, progress.clone(), hold)
{
match rx.recv_timeout(wait) {
Ok(reply) => opened(app, reply, fallback),
@@ -190,13 +202,14 @@ fn opened(app: &mut SolveApp, reply: Result<Value, String>, fallback: String) {
}
/// Run `command` on `copy` on a worker, watched by `progress`; the reply comes on the returned
/// channel.
/// channel once `hold` returns (which only tests make wait).
#[cfg(not(target_arch = "wasm32"))]
fn measure_beside(
mut copy: solidcraft_engine::Session,
command: &str,
params: &Value,
progress: Arc<Progress>,
hold: impl FnOnce() + Send + 'static,
) -> Option<std::sync::mpsc::Receiver<Result<Value, String>>> {
let (tx, rx) = std::sync::mpsc::channel();
let (command, params) = (command.to_string(), params.clone());
@@ -204,6 +217,7 @@ fn measure_beside(
.name("properties".into())
.spawn(move || {
let reply = solidcraft_engine::doc::progress::watch(progress, || copy.execute(&command, &params));
hold();
let _ = tx.send(reply.map_err(|e| e.to_string()));
})
.ok()?;
@@ -217,6 +231,7 @@ fn measure_beside(
_command: &str,
_params: &Value,
_progress: Arc<Progress>,
_hold: impl FnOnce() + Send + 'static,
) -> Option<std::sync::mpsc::Receiver<Result<Value, String>>> {
None
}
@@ -631,9 +646,14 @@ mod tests {
h.app.run("solid.box", json!({"length": 10, "width": 20, "height": 30, "body_name": "Block"})).unwrap();
let here = h.app.session.execute("component.properties", &json!({})).unwrap();
h.app.apply_off_thread = Some(std::time::Duration::ZERO);
// The worker holds its reply until let go, so a window thread held up under load cannot
// find it already in.
let (finish, gate) = std::sync::mpsc::channel();
h.app.apply_gate = Some(std::sync::Arc::new(std::sync::Mutex::new(gate)));
super::open_object(&mut h.app, "component.properties", &json!({}), String::new());
assert!(h.app.menu.props.is_none(), "nothing is waited for on the window thread");
let m = h.app.menu.props_measuring.take().expect("measuring beside the window");
finish.send(()).unwrap();
assert_eq!(m.rx.recv().unwrap().unwrap(), here, "the copy measures what the design here does");
let (tx, rx) = std::sync::mpsc::channel();
@@ -674,11 +694,15 @@ mod tests {
h.app.run("occurrence.copy", json!({"component": block, "translate": [20 * i, 0, 0]})).unwrap();
}
h.app.apply_off_thread = Some(std::time::Duration::ZERO);
// Held until checked: under load the worker could otherwise finish before the menu returns.
let (finish, gate) = std::sync::mpsc::channel();
h.app.apply_gate = Some(std::sync::Arc::new(std::sync::Mutex::new(gate)));
let items = crate::context_menu::items(&h.app, &crate::context_menu::Target::Component { id: 0 });
let item = items.iter().find(|i| i.label == "Properties").expect("Properties item");
crate::context_menu::run_item(&mut h.app, item, egui::pos2(0.0, 0.0));
assert!(h.app.menu.props_measuring.is_some(), "the menu returns at once, the card measuring");
assert!(h.app.menu.props.is_none());
finish.send(()).unwrap();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(300);
while h.app.menu.props.is_none() {
assert!(std::time::Instant::now() < deadline, "the measurements never came in");
+13 -3
View File
@@ -1229,11 +1229,16 @@ impl Dialog {
}
}
Self::take_component_bodies(app, &mut d);
// A primitive goes where a face or vertex is picked, standing on a planar face.
if let Kind::Primitive { at, .. } = &mut d.kind {
// A primitive goes where a face or vertex is picked, standing on a planar face. The Box,
// Sphere and Torus also go to a sketch point (#9403).
if let Kind::Primitive { cmd, at, .. } = &mut d.kind {
let sketch_points = !crate::primitive_placement::inputs(cmd).is_empty();
*at = s.selection.iter().find_map(|sel| match sel {
Sel::Face { body, index, point } => Some((*point, planar_face(s, body, *index).map(|(_, n)| n))),
Sel::Vertex { point, .. } => Some((*point, None)),
Sel::ExternalSketchPoint { .. } | Sel::SketchPoint { .. } if sketch_points => {
crate::primitive_placement::world_point(s, sel).map(|p| (p, None))
}
_ => None,
});
}
@@ -2852,6 +2857,12 @@ pub(crate) fn selection_label(s: &Session, names: crate::prefs::OriginPlaneNames
Sel::ExternalSketchCurve { sketch, id } => {
crate::i18n::format("dialog.selection_external_curve", &[("sketch", &sketch_name(*sketch)), ("id", id)])
}
// Another sketch's point (a Sphere's centre, #9403), named as Select Other names it.
Sel::ExternalSketchPoint { sketch, id } => {
let state = crate::open_meshing::session_world(s);
let point = state.sketch(*sketch).map_or_else(|| id.clone(), |ss| crate::select_other::point_label(&ss.sketch, id));
crate::i18n::format("dialog.selection_external_point", &[("point", &point), ("sketch", &sketch_name(*sketch))])
}
Sel::SketchConstraint { id } => crate::i18n::format("dialog.selection_constraint", &[("id", id)]),
Sel::Profile { sketch, index } => {
crate::i18n::format("dialog.selection_profile", &[("sketch", &sketch_name(*sketch)), ("number", &index.saturating_add(1).to_string())])
@@ -2876,7 +2887,6 @@ pub(crate) fn selection_label(s: &Session, names: crate::prefs::OriginPlaneNames
format!("({:.2}, {:.2}) mm", p.x, p.y)
}
Sel::Vertex { body, .. } => format!("{body} · vertex"),
_ => format!("{item:?}"),
}
}
+118 -31
View File
@@ -126,11 +126,23 @@ pub enum Asm {
of: Vec<Sel>,
/// Pairs closer than this (mm, as typed) are listed too.
clearance: String,
/// Compute's check under way (its reply lands here).
check: Option<Check>,
},
/// Motion Trace: a picked point's path, previewed; OK keeps it as a 3D sketch spline.
Trace(Box<TraceForm>),
}
/// Interference's check off the window thread: where it leaves its reply (the check's and the
/// shared volumes' meshes), the design and the picks it checked, and when it started.
#[derive(Clone, Debug)]
pub struct Check {
reply: std::sync::Arc<std::sync::Mutex<Option<(Value, Vec<Mesh>)>>>,
design: std::sync::Weak<solidcraft_engine::doc::Document>,
of: Vec<Sel>,
started: f64,
}
/// The snap under the cursor, remembered for the selection it was found for.
pub type Hover = Option<(Sel, Option<SnapPt>)>;
@@ -288,7 +300,7 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
"joint.gear" | "joint.rack_pinion" | "joint.screw" => crate::dialogs_coupling::start(app, id)?,
"joint.cam" => crate::dialogs_coupling::cam::start(app),
"inspect.interference" => (
Asm::Interference { result: None, overlaps: Vec::new(), of: Vec::new(), clearance: "0".into() },
Asm::Interference { result: None, overlaps: Vec::new(), of: Vec::new(), clearance: "0".into(), check: None },
vec![SelInput::new("Bodies (all if none)", BODIES, true)],
),
_ => return None,
@@ -957,10 +969,45 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Asm, inputs: &mut [Se
}
Asm::Coupling(f) => enter |= crate::dialogs_coupling::rows(app, ui, f, inputs),
Asm::Cam(f) => enter |= crate::dialogs_coupling::cam::rows(app, ui, f, inputs),
Asm::Interference { result, overlaps, of, clearance } => {
Asm::Interference { result, overlaps, of, clearance, check } => {
let picked: Vec<Sel> = inputs.first().map(|i| i.items.clone()).unwrap_or_default();
if let Some(c) = check.take() {
match c.reply.lock().map(|mut r| r.take()).map_err(|_| ()) {
Ok(None) => {
*check = Some(c);
ui.ctx().request_repaint_after(std::time::Duration::from_millis(100));
}
// Done: shown unless the design changed meanwhile (a script, the control
// channel, undo). A poisoned lock (its thread panicked) leaves nothing.
Ok(Some((r, o))) => {
if c.design.upgrade().is_some_and(|d| std::sync::Arc::ptr_eq(&d, &app.session.doc)) {
(*result, *overlaps, *of) = (Some(r), o, c.of);
}
ui.ctx().request_repaint();
}
Err(()) => {}
}
}
enter |= expr_row(ui, "Clearance", clearance);
// While it checks: the seconds so far, and Cancel where Compute was.
if let Some(started) = check.as_ref().map(|c| c.started) {
let t = Tokens::get();
form_row(ui, "", |ui| {
ui.horizontal(|ui| {
let seconds = ((crate::now_ms() - started) / 1000.0).floor().max(0.0);
ui.add(egui::Spinner::new().size(t.font_small));
ui.label(RichText::new(crate::i18n::format("dialog.computing", &[("seconds", &format!("{seconds:.0}"))])).color(t.text_dim));
});
});
}
form_row(ui, "", |ui| {
if check.is_some() {
if crate::ui_kit::button(ui, crate::i18n::tr("ui.cancel"), crate::ui_kit::Button::Normal).clicked() {
// Its thread finishes the check; the reply is left unread.
*check = None;
}
return;
}
let stale = result.is_some() && *of != picked;
if crate::ui_kit::button(
ui,
@@ -969,13 +1016,14 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Asm, inputs: &mut [Se
)
.clicked()
{
let (r, o) = interference(app, &picked, clearance);
*result = Some(r);
*overlaps = o;
*of = picked;
(*result, *overlaps) = (None, Vec::new());
*check = Some(interference(app, &picked, clearance));
ui.ctx().request_repaint();
}
});
if let Some(r) = result.as_ref() {
if check.is_none()
&& let Some(r) = result.as_ref()
{
interference_rows(app, ui, r);
}
}
@@ -1175,34 +1223,73 @@ fn fill_limits(doc: &solidcraft_engine::doc::Document, f: &mut JointForm, reply:
}
}
/// Run the check (overlaps, pairs closer than the clearance, each body's nearest neighbour) and
/// find the overlapping volumes (world placement) to show in red.
fn interference(app: &mut SolveApp, picked: &[Sel], clearance: &str) -> (Value, Vec<Mesh>) {
/// Start Compute's check (overlaps, pairs closer than the clearance, each body's nearest
/// neighbour) and find the overlapping volumes (world placement) to show in red. Off the window
/// thread, on a kernel thread, when the app runs commands there: a slow pair froze the window
/// until the app was closed (#11483). Else at once.
fn interference(app: &SolveApp, picked: &[Sel], clearance: &str) -> Check {
let check =
Check { reply: Default::default(), design: std::sync::Arc::downgrade(&app.session.doc), of: picked.to_vec(), started: crate::now_ms() };
let names: Vec<String> = picked.body_names();
let (scale, _) = crate::prefs::unit_scale(&app.session.doc.units);
let clearance = match crate::prefs::parse_length_mm(clearance, scale) {
Some(v) if v >= 0.0 => v,
_ => return (json!({ "error": crate::i18n::tr("dialog.clearance_invalid") }), Vec::new()),
};
let mut p = json!({ "clearance": clearance, "nearest": 1 });
if !names.is_empty() {
p["bodies"] = json!(names);
}
let r = match app.run("inspect.clearance", p) {
Ok(v) => v,
Err(e) => return (json!({ "error": e }), Vec::new()),
};
let st = crate::open_meshing::session_world(&app.session);
let mut meshes = Vec::new();
for hit in r["overlaps"].as_array().into_iter().flatten() {
let (Some(a), Some(b)) = (hit["a"].as_str().and_then(|n| st.body(n)), hit["b"].as_str().and_then(|n| st.body(n))) else { continue };
if let Ok(Some(o)) = solidcraft_engine::kernel::boolean(&a.body, &b.body, solidcraft_engine::kernel::BoolOp::Intersect)
&& let Ok(m) = o.mesh(&solidcraft_engine::kernel::MeshTolerance::chord((o.size() * 2e-3).max(1e-3)))
{
meshes.push(m);
let reply = match crate::prefs::parse_length_mm(clearance, scale) {
Some(v) if v >= 0.0 => {
let mut p = json!({ "clearance": v, "nearest": 1 });
if !names.is_empty() {
p["bodies"] = json!(names);
}
#[cfg(not(target_arch = "wasm32"))]
if app.apply_off_thread.is_some() {
let (ahead, world, out, params) =
(app.session.ahead(), crate::open_meshing::session_world(&app.session), check.reply.clone(), p.clone());
let spawned = solidcraft_engine::kernel::kernel_thread("interference").spawn(move || {
let r = check_now(ahead, &world, params);
if let Ok(mut slot) = out.lock() {
*slot = Some(r);
}
});
if spawned.is_ok() {
return check;
}
}
check_now(app.session.ahead(), &crate::open_meshing::session_world(&app.session), p)
}
_ => (json!({ "error": crate::i18n::tr("dialog.clearance_invalid") }), Vec::new()),
};
if let Ok(mut slot) = check.reply.lock() {
*slot = Some(reply);
}
(r, meshes)
check
}
/// `inspect.clearance` with `p` on a copy of the session, and the volume each pair it finds
/// overlapping shares ([`overlap_meshes`]).
fn check_now(ahead: solidcraft_engine::Ahead, world: &solidcraft_engine::doc::ModelState, p: Value) -> (Value, Vec<Mesh>) {
// One reply for the one command.
match ahead.replies(&[("inspect.clearance".to_string(), p)]).pop() {
Some(Ok(r)) => {
let meshes = overlap_meshes(world, &r);
(r, meshes)
}
Some(Err(e)) => (json!({ "error": e.to_string() }), Vec::new()),
None => (json!({}), Vec::new()),
}
}
/// The volume each pair in an `inspect.clearance` reply's `overlaps` shares, meshed in world
/// placement. Each pair runs on a kernel worker with the kernel's stack, whatever thread asks
/// (the window thread's is 1 MiB on Windows).
fn overlap_meshes(st: &solidcraft_engine::doc::ModelState, r: &Value) -> Vec<Mesh> {
use solidcraft_engine::kernel;
let pairs: Vec<_> =
r["overlaps"].as_array().into_iter().flatten().filter_map(|hit| Some((st.body(hit["a"].as_str()?)?, st.body(hit["b"].as_str()?)?))).collect();
kernel::map_with_local_pools(&pairs, usize::MAX, |&(a, b)| {
let Some(o) = kernel::boolean(&a.body, &b.body, kernel::BoolOp::Intersect)? else { return Ok(None) };
o.mesh(&kernel::MeshTolerance::chord((o.size() * 2e-3).max(1e-3))).map(Some)
})
.into_iter()
.filter_map(|m| m.ok().flatten())
.collect()
}
fn interference_rows(app: &SolveApp, ui: &mut egui::Ui, r: &Value) {
@@ -1337,3 +1337,66 @@ fn find_limits_searches_the_joint_as_edited_and_leaves_a_free_side() {
find_limits(&app, f, id, &d.inputs);
assert!(f.found_note.is_some() && f.found.is_empty(), "{:?}", f.found_note);
}
/// Compute in Interference intersects and meshes each overlap on the kernel's workers. On the
/// window thread (a 1 MiB stack on Windows) the kernel ran out of stack and the app closed
/// (#11483); here the dialog's own thread has 64 KiB.
#[test]
fn interference_meshes_each_overlap_on_the_kernels_stack() {
let mut s = Session::default();
crate::test_support::grounded_boxes(&[("A", 0), ("B", 10)], |id, p| s.execute(id, &p).unwrap());
let r = s.execute("inspect.clearance", &json!({"clearance": 0, "nearest": 1})).unwrap();
assert_eq!(r["overlaps"].as_array().map(Vec::len), Some(1), "{r}");
let st = s.world_state().unwrap();
let meshes = std::thread::Builder::new().stack_size(64 << 10).spawn(move || overlap_meshes(&st, &r)).unwrap().join().unwrap();
// A's 0..20 and B's 10..30 along X share 10..20.
let b = meshes.first().map(Mesh::bounds).expect("the shared volume");
assert_eq!(meshes.len(), 1);
let near = |p: Vec3, q: [f64; 3]| (p.x - q[0]).abs() < 1e-6 && (p.y - q[1]).abs() < 1e-6 && (p.z - q[2]).abs() < 1e-6;
assert!(near(b.min, [10.0, 0.0, 0.0]) && near(b.max, [20.0, 20.0, 10.0]), "{b:?}");
}
/// With the app running commands off the window thread, Compute in Interference checks on a
/// thread of its own (a slow pair froze the window until the app was closed, #11483): the
/// overlap and its shared volume arrive in a later frame, and a check of a design changed
/// meanwhile is dropped.
#[test]
fn interference_checks_off_the_window_thread_and_drops_a_check_of_a_changed_design() {
let mut s = Session::default();
crate::test_support::grounded_boxes(&[("A", 0), ("B", 10)], |id, p| s.execute(id, &p).unwrap());
let mut app = SolveApp::new(s, Services::default());
app.apply_off_thread = Some(std::time::Duration::ZERO);
app.start("inspect.interference");
let ctx = crate::fonts::test_context();
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0));
let state = |app: &SolveApp| match app.dialog.as_ref().map(|d| &d.kind) {
Some(Kind::Assembly(Asm::Interference { result, overlaps, check, .. })) => (result.clone(), overlaps.len(), check.is_some()),
_ => panic!("the dialog stays open"),
};
// Compute as its button starts it, then frames until its reply is read.
let compute = |app: &mut SolveApp, change: bool| {
let c = interference(app, &[], "0");
if let Some(Kind::Assembly(Asm::Interference { result, overlaps, check, .. })) = app.dialog.as_mut().map(|d| &mut d.kind) {
(*result, *overlaps, *check) = (None, Vec::new(), Some(c));
}
if change {
app.run("solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [100, 0, 0]})).unwrap();
}
for _ in 0..3000 {
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| crate::dialogs::show(app, ui.ctx()));
out.textures_delta.clear();
if !state(app).2 {
return;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
panic!("the check never came back");
};
compute(&mut app, false);
let (result, overlaps, _) = state(&app);
let result = result.expect("the check's reply");
assert_eq!(result["overlaps"].as_array().map(Vec::len), Some(1), "{result}");
assert_eq!(overlaps, 1, "the shared volume, drawn in red");
compute(&mut app, true);
assert_eq!(state(&app), (None, 0, false), "a check of the design before the change is dropped");
}
+6 -3
View File
@@ -1111,17 +1111,20 @@ fn measure_rows(app: &SolveApp, ui: &mut egui::Ui, r: Option<&Value>, show: &mut
let lines = crate::quick_measure::item_lines(app, it);
for (k, line) in lines.into_iter().enumerate() {
let label = if k == 0 { crate::i18n::format("measure.item", &[("number", &(i + 1).to_string())]) } else { String::new() };
value(ui, &label, &line);
// Coordinates need all three values; a fixed-height form cell elides the end.
let text = if label.is_empty() { line } else { format!("{label} {line}") };
ui_kit::text(ui, &text, ui_kit::Text::Report(&format!("measure_item_{i}_{k}"), false));
}
}
if let Some((now, d, a, b)) = shown_distance(r, *show) {
if r.get("center_distance").is_some() {
// Centre, min and max all listed; the chosen one is drawn and broken into ΔX/Y/Z.
let centre_label = crate::quick_measure::centre_distance_label(r);
for (which, label, key) in
[(Shown::Centre, "Centre to centre", "center_distance"), (Shown::Min, "Min", "distance_mm"), (Shown::Max, "Max", "max")]
[(Shown::Centre, centre_label, "center_distance"), (Shown::Min, tr("Min"), "distance_mm"), (Shown::Max, tr("Max"), "max")]
{
let Some(v) = r.get(key).filter(|v| v.is_number()) else { continue };
if ui_kit::list_picker(ui, key, &format!("{} {}", tr(label), mm(v)), now == which).clicked() {
if ui_kit::list_picker(ui, key, &format!("{} {}", label, mm(v)), now == which).clicked() {
*show = Some(which);
}
}
@@ -648,3 +648,36 @@ fn transform_handles_and_fields_scale_move_and_turn_the_picks() {
let top = (0..sk.curves.len()).flat_map(|i| sk.polyline(i)).fold(f64::MIN, |m, p| m.max(p.x));
assert!((top - 40.0).abs() < 1e-6, "one undo puts it back: {top}");
}
#[test]
fn transform_applies_the_dimensioned_rectangle_shown_in_its_preview() {
for profile in [false, true] {
let mut h = fixture();
for (line, value) in [("l1", 30), ("l2", 20)] {
h.app.run("sketch.dimension", json!({"entities": [line], "type": "length", "value": value})).unwrap();
}
let mut dialog = Dialog::for_command(&h.app, "sketch.transform").unwrap();
dialog.inputs[0].items = if profile {
vec![Sel::Profile { sketch: h.app.session.active_sketch.unwrap(), index: 0 }]
} else {
["l1", "l2", "l3", "l4"].map(|id| Sel::SketchCurve { id: id.into(), sketch: None }).to_vec()
};
h.app.dialog = Some(dialog);
h.frames(4);
if let Some(Kind::SketchModify(Mod::Transform { factor, angle, .. })) = h.app.dialog.as_mut().map(|d| &mut d.kind) {
*factor = "2".into();
*angle = "30 deg".into();
}
h.frames(3);
h.step(&json!({"click": {"handle": "dialog:ok"}})).unwrap();
h.settle();
assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog.as_ref().and_then(|d| d.error.clone()));
let state = h.app.session.world_state().unwrap();
let sk = &state.sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch;
let a = sk.point(sk.resolve_point("l1.start").unwrap()).unwrap();
let b = sk.point(sk.resolve_point("l1.end").unwrap()).unwrap();
let edge = b - a;
assert!((edge.len() - 60.0).abs() < 1e-6, "the applied width matches Scale 2: {edge:?}");
assert!((edge.angle().to_degrees() - 30.0).abs() < 1e-6, "the applied rotation matches Angle 30: {edge:?}");
}
}
+20 -1
View File
@@ -31,6 +31,8 @@ use crate::SolveApp;
use crate::theme::Tokens;
use crate::viewport::Proj;
pub(crate) mod window;
/// A dimension as drawn this frame (screen space), for hit testing.
#[derive(Clone, Debug)]
struct Drawn {
@@ -153,6 +155,7 @@ fn shown_error(id: &str, e: &str) -> String {
pub fn cancel() {
EDIT.with(|edit| *edit.borrow_mut() = None);
PLACEMENT.with(|preview| *preview.borrow_mut() = None);
window::forget();
}
/// Infer the measured value through the same command as scripts, without creating a real
@@ -192,6 +195,11 @@ pub fn begin_dimension(app: &mut SolveApp, entities: Vec<String>, at: Vec2) -> R
/// it. Midpoint pairs and dimensions that would only be driven keep the place-first flow:
/// the pointer chooses the orientation, and a click places the text before entering a value.
pub fn begin_live(app: &mut SolveApp, entities: Vec<String>, pointer: Option<Pos2>) -> Result<bool, String> {
// The value window opens on placed text (`window`): a window following the pointer could
// not be reached.
if window::on(app) {
return Ok(false);
}
// A midpoint pair chooses its orientation and text position before entering a value.
// This also permits switching freely between driving and reference spans.
let midpoint_pair = entities.len() == 2 && entities.iter().all(|id| id.starts_with("mid:"));
@@ -844,6 +852,7 @@ pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &P
}
}
DRAWN.with(|d| *d.borrow_mut() = drawn);
window::paint(app, painter, proj);
edit_box(app, ui.ctx());
}
@@ -1005,6 +1014,10 @@ fn edit_box(app: &mut SolveApp, ctx: &egui::Context) {
}
return;
};
if window::on(app) {
let (done, escaped) = window::show(app, ctx, &mut e, at);
return settle(app, e, done, escaped);
}
let id = egui::Id::new("sc_dim_edit");
let mut done: Option<bool> = None;
let mut escaped = false;
@@ -1074,8 +1087,14 @@ fn edit_box(app: &mut SolveApp, ctx: &egui::Context) {
});
});
});
e.fresh = e.fresh.saturating_sub(1);
e.shown = Some(anchor);
settle(app, e, done, escaped);
}
/// Apply or drop the edit as the box or the window answered (`done`: Some(true) applies, Some(false)
/// drops it, and `escaped` the Dimension tool's picks with it), or keep it open.
fn settle(app: &mut SolveApp, mut e: Edit, done: Option<bool>, escaped: bool) {
e.fresh = e.fresh.saturating_sub(1);
match done {
Some(true) => {
let result = if let Some(draft) = &e.draft {
+345
View File
@@ -0,0 +1,345 @@
//! The dimension value window (Preferences › Sketch › Dimension values: In a window, #7246).
//! Placing a dimension with the Dimension tool, or double-clicking one, opens a small window at
//! its text, kept inside the view, instead of the box on the dimension:
//! - the value field, which takes what the box takes (numbers, units, expressions, parameter
//! names, `w = 20`; a click on another dimension inserts its name);
//! - arrows that step a plain number by the step preferences: a length by `dimension_step_mm`,
//! written in the unit the value is written in (none: the design's), an angle by
//! `dimension_step_deg`. Up/Down and the mouse wheel over the field step it too (Shift: ten
//! steps, Alt: a tenth, as in the other value fields). An expression is left as typed;
//! - a check (OK, also Enter) and a cross (Cancel, also Esc).
//!
//! While it is open the sketch shows the value: what it moves is drawn in the preview colour, and
//! why OK would refuse it shows under the field. Nothing reaches the design until OK, which
//! applies through the box's own path (`edit_box`), so undo, driven dimensions and refusals are
//! the same as there. Cancel drops this edit only: a new dimension is not added, and the
//! Dimension tool stays on for the next one. A click elsewhere drops it as well, as it does the
//! box.
//!
//! With the window the Dimension tool places a new dimension's text first (`begin_live` keeps the
//! place-first flow), and the window opens on the placed text: a window following the pointer
//! could never be reached.
use super::*;
use crate::prefs::{DIMENSION_STEP_DEG, DIMENSION_STEP_MM, DimensionInput};
use solidcraft_engine::doc::expr::{Kind, unit_info};
/// The window's area.
const AREA: &str = "sc_dim_window";
/// The window is on (Preferences › Sketch).
pub(super) fn on(app: &SolveApp) -> bool {
app.preferences.dimension_input == DimensionInput::Window
}
/// What a step adds to a length (millimetres) and to an angle (degrees). A saved value outside
/// what Preferences accepts (a hand edit) steps by the default.
fn steps(app: &SolveApp) -> (f64, f64) {
let or_default = |value: f64, accepted: std::ops::RangeInclusive<f64>| if accepted.contains(&value) { value } else { 1.0 };
(or_default(app.preferences.dimension_step_mm, DIMENSION_STEP_MM), or_default(app.preferences.dimension_step_deg, DIMENSION_STEP_DEG))
}
/// Whether a dimension's value is a length or an angle, from its parameter's unit.
fn kind(doc: &solidcraft_engine::doc::Document, param: &str) -> Kind {
doc.param(param).and_then(|p| unit_info(&p.unit)).map_or(Kind::Length, |(kind, _)| kind)
}
/// `text` moved by `steps` steps (negative: down) of `step_mm` (a length) or `step_deg` (an
/// angle). A plain number keeps the unit it is written in (none: `design_unit` for a length,
/// degrees for an angle) and its decimal mark, gaining the decimal places the step needs in that
/// unit. `None` for anything else: an expression is left as typed.
pub(crate) fn stepped(text: &str, steps: f64, kind: Kind, design_unit: &str, step_mm: f64, step_deg: f64) -> Option<String> {
crate::value_step::shifted(text, |written| {
let unit = match written {
"" if kind == Kind::Angle => "deg",
"" => design_unit,
"\"" | "″" => "in",
"'" | "′" => "ft",
unit => unit,
};
let (unit_kind, per_unit) = unit_info(unit)?;
let step = match (kind, unit_kind) {
(Kind::Length, Kind::Length) => step_mm,
(Kind::Angle, Kind::Angle) => step_deg.to_radians(),
_ => return None,
};
let amount = step / per_unit * steps;
(amount.is_finite() && per_unit > 0.0).then(|| (amount, places(amount)))
})
}
/// The fewest decimal places, at most six, that write `amount` as it is.
fn places(amount: f64) -> usize {
let a = amount.abs();
(0..6)
.find(|p| {
let scale = 10f64.powi(*p);
((a * scale).round() / scale - a).abs() <= a * 1e-9
})
.map_or(6, |p| p as usize)
}
/// What OK would make of an existing dimension's typed value, worked out once per text: the
/// sketch it gives, or the refusal OK would show. It runs OK's own command on a scratch copy of
/// the session, so nothing reaches the design.
struct Existing {
doc: Arc<solidcraft_engine::doc::Document>,
param: String,
expr: String,
sketch: Option<Arc<solidcraft_engine::doc::SolvedSketch>>,
error: Option<String>,
}
thread_local! {
static EXISTING: RefCell<Option<Existing>> = const { RefCell::new(None) };
/// Where the open window sits, for which parameter, and the frame it was last drawn in. It
/// stays where it opened while it is open, so its arrows stay under the pointer while the
/// stepped value moves the dimension's text.
static ANCHOR: RefCell<Option<(String, u64, Pos2)>> = const { RefCell::new(None) };
}
/// Whether the open window is under `p`: the wheel there steps its value, so the camera under it
/// does not zoom (navigation asks).
pub(crate) fn covers(ctx: &egui::Context, p: Pos2) -> bool {
super::editing() && ctx.memory(|m| m.area_rect(egui::Id::new(AREA))).is_some_and(|r| r.contains(p))
}
/// Forget the worked-out value (the edit closed).
pub(super) fn forget() {
EXISTING.with(|e| *e.borrow_mut() = None);
}
fn existing(app: &SolveApp, param: &str, expr: &str) -> (Option<Arc<solidcraft_engine::doc::SolvedSketch>>, Option<String>) {
EXISTING.with(|cell| {
let mut cell = cell.borrow_mut();
let current = cell.as_ref().is_some_and(|x| Arc::ptr_eq(&x.doc, &app.session.doc) && x.param == param && x.expr == expr);
if !current {
let expression = crate::length_input::expression(&app.session.doc, expr, kind(&app.session.doc, param));
let mut scratch = app.session.scratch();
let (sketch, error) = match scratch.execute("parameters.change", &json!({"name": param, "expression": expression})) {
Ok(_) => {
(app.session.active_sketch.and_then(|id| scratch.world_state().ok()?.sketches.iter().find(|s| s.feature == id).cloned()), None)
}
Err(e) => (None, Some(shown_error("parameters.change", &e.to_string()))),
};
*cell = Some(Existing { doc: app.session.doc.clone(), param: param.to_string(), expr: expr.to_string(), sketch, error });
}
cell.as_ref().map(|x| (x.sketch.clone(), x.error.clone())).unwrap_or_default()
})
}
/// Draw what the open window's value moves in the sketch, in the preview colour, over the sketch
/// as it is. A value OK would refuse shows nothing.
pub(super) fn paint(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
if !on(app) {
return;
}
let Some((param, expr, draft)) = EDIT.with(|edit| {
let edit = edit.borrow();
let e = edit.as_ref()?;
let draft = e.draft.as_ref().map(|d| (d.sketch, d.preview.clone(), d.error.is_some()));
Some((e.param.clone(), e.expr.clone(), draft))
}) else {
return;
};
let Some(sketch) = app.session.active_sketch else { return };
let after = match draft {
Some((id, preview, false)) => preview.sketches.iter().find(|s| s.feature == id).cloned(),
Some((_, _, true)) => None,
None => existing(app, &param, &expr).0,
};
let state = app.session.world_state().ok();
let changes = after.and_then(|after| crate::preview::sketch_changes(state.as_ref().and_then(|state| state.sketch(sketch)), &after));
let mut budget = solidcraft_engine::render::SamplingBudget::new(32_768, 65_536);
let curves = changes.map_or(0, |changes| crate::preview::paint_sketch(&changes, painter, proj, &mut budget));
crate::scenario::publish_count("dimwin:preview_curves", curves as f64);
}
/// Where the window goes: where it already is while it stays open, else under the dimension's
/// text `at` (over it where the view has no room below); always inside the view's free area.
/// `size` is the window's as drawn last frame.
fn place(app: &SolveApp, ctx: &egui::Context, param: &str, at: Pos2, size: egui::Vec2) -> Pos2 {
let frame = ctx.cumulative_frame_nr();
let view = app.viewport.layout.as_ref().map(|layout| layout.drawable).filter(|r| r.is_finite() && r.is_positive());
ANCHOR.with(|anchor| {
let mut anchor = anchor.borrow_mut();
let open = anchor.as_ref().filter(|(open, drawn, _)| open == param && drawn.saturating_add(1) >= frame).map(|x| x.2);
let mut pos = open.unwrap_or_else(|| {
let gap = Tokens::get().space_12;
let below = Pos2::new(at.x - size.x * 0.5, at.y + gap);
if view.is_some_and(|rect| below.y + size.y > rect.bottom()) { Pos2::new(below.x, at.y - gap - size.y) } else { below }
});
if let Some(rect) = view {
pos.x = pos.x.clamp(rect.left(), (rect.right() - size.x).max(rect.left()));
pos.y = pos.y.clamp(rect.top(), (rect.bottom() - size.y).max(rect.top()));
}
*anchor = Some((param.to_string(), frame, pos));
pos
})
}
/// The check or the cross in `rect`, its icon in colour as the view's own OK and Cancel draw it
/// (`view_confirm`): hover, the pointing hand, its tooltip, and its handle `key` for scenarios and
/// the control channel. True when clicked.
fn button(ui: &mut egui::Ui, rect: Rect, key: &str, tip: &str, icon: &str) -> bool {
let t = Tokens::get();
let response = ui.interact(rect, ui.id().with(key), egui::Sense::click());
if response.hovered() {
ui.painter().rect_filled(rect, t.control_radius, t.hover);
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
crate::icons::paint_icon(ui.painter(), rect.shrink(t.space_4), icon, 1.0);
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tip));
crate::scenario::publish_target(key, rect);
response.on_hover_text(tip).clicked()
}
/// Draw the window for the open edit `e` at its dimension's text `at` (screen) and answer it:
/// `Some(true)` applies it (the check, Enter), `Some(false)` drops it; the second value says
/// whether a new dimension's picks go too (the cross, Esc), as Esc in the box does.
pub(super) fn show(app: &mut SolveApp, ctx: &egui::Context, e: &mut Edit, at: Pos2) -> (Option<bool>, bool) {
let t = Tokens::get();
let id = egui::Id::new("sc_dim_edit");
let area = egui::Id::new(AREA);
let (kind, error) = match &e.draft {
Some(draft) => (kind(&draft.preview_doc, &e.param), draft.error.clone()),
None => (kind(&app.session.doc, &e.param), existing(app, &e.param, &e.expr).1),
};
let (step_mm, step_deg) = steps(app);
let unit = crate::length_input::unit(&app.session.doc).to_string();
let step = |text: &str, steps: f64| stepped(text, steps, kind, &unit, step_mm, step_deg);
let step_text = if kind == Kind::Angle {
format!("{:.*}°", usize::from(app.preferences.angle_decimals), step_deg)
} else {
crate::prefs::format_length(step_mm, &app.session.doc.units, app.preferences.length_decimals)
};
let last = ctx.memory(|m| m.area_rect(area));
let width = t.space_24 * 8.0;
let guess = egui::vec2(width + t.space_6 * 2.0, t.control_height * 2.0 + t.space_6 * 3.0);
let anchor = place(app, ctx, &e.param, at, last.map_or(guess, |r| r.size()));
// A click on the window (an arrow) takes the keyboard from its field, as any click away does;
// the field takes it back instead of closing.
let on_window = ctx.input(|i| i.pointer.any_click() && i.pointer.interact_pos().is_some_and(|p| last.is_some_and(|r| r.contains(p))));
let mut answer: Option<bool> = None;
let mut escaped = false;
let mut by = 0.0;
Container::new(ctx, Surface::Canvas).area(area).fixed_pos(anchor).order(egui::Order::Foreground).show(ctx, |ui| {
Container::popup_frame(ui.style()).fill(t.dialog_body).inner_margin(t.space_6).show(ui, |ui| {
crate::ui_kit::scope(ui, |ui| {
ui.set_width(width);
// The parameter's name, then the check and the cross.
let (title, _) = ui.allocate_exact_size(egui::vec2(width, t.control_height), egui::Sense::hover());
let name = crate::i18n::format("dialog.dimension_name", &[("name", &e.param)]);
ui.scope_builder(egui::UiBuilder::new().max_rect(title).layout(egui::Layout::left_to_right(egui::Align::Center)), |ui| {
crate::ui_kit::text(ui, &name, crate::ui_kit::Text::Inline).on_hover_text(crate::i18n::tr("dialog.dimension_insert_hint"));
});
let side = t.control_height;
let cancel = Rect::from_min_size(egui::pos2(title.right() - side, title.top()), egui::vec2(side, side));
let ok = cancel.translate(egui::vec2(-(side + t.space_2), 0.0));
if button(ui, ok, "dimwin:ok", crate::i18n::tr("ui.heading_ok"), "finish") {
answer = Some(true);
}
if button(ui, cancel, "dimwin:cancel", crate::i18n::tr("ui.cancel"), "close") {
answer = Some(false);
escaped = true;
}
// The value, with its arrows at its right (the kit's own up and down arrows).
let (row, _) = ui.allocate_exact_size(egui::vec2(width, t.control_height), egui::Sense::hover());
let arrows = 2.0 * (t.form_close_size - t.space_4) + t.space_2;
let (r, up, down) = ui
.scope_builder(egui::UiBuilder::new().max_rect(row).layout(egui::Layout::left_to_right(egui::Align::Center)), |ui| {
ui.spacing_mut().item_spacing.x = t.space_2;
let field = egui::vec2((row.width() - arrows - t.space_2).max(t.space_24), t.control_height);
let r = ui.allocate_ui(field, |ui| crate::ui_kit::cell_editor(ui, id, &mut e.expr, "", false)).inner;
crate::scenario::publish_target("dimwin:value", r.rect);
if r.has_focus() {
e.selection = egui::TextEdit::load_state(ctx, id).and_then(|s| s.cursor.char_range());
}
if crate::expression_complete::complete(ui, &r, &mut e.expr) {
e.selection = egui::TextEdit::load_state(ctx, id).and_then(|s| s.cursor.char_range());
}
let can_step = step(&e.expr, 1.0).is_some();
let tip = |id: &str| {
if can_step {
crate::i18n::format(id, &[("step", &step_text)])
} else {
crate::i18n::tr("dialog.dimension_step_expression").to_string()
}
};
let up = crate::ui_kit::compact_button(ui, crate::ui_kit::MOVE_UP, &tip("dialog.dimension_step_up"), can_step, false);
crate::scenario::publish_target("dimwin:up", up.rect);
let down = crate::ui_kit::compact_button(ui, crate::ui_kit::MOVE_DOWN, &tip("dialog.dimension_step_down"), can_step, false);
crate::scenario::publish_target("dimwin:down", down.rect);
(r, up.clicked(), down.clicked())
})
.inner;
if up {
by += 1.0;
}
if down {
by -= 1.0;
}
// Up/Down in the field and the wheel over it or its arrows step too.
if r.has_focus() {
by += ui.input(crate::value_step::key_tenths) as f64 / 10.0;
}
let wheel = id.with("dimwin_wheel");
if ui.rect_contains_pointer(row) {
by += crate::value_step::wheel_tenths(ui, wheel) as f64 / 10.0;
crate::navigation::claim_wheel(ui.ctx());
} else {
ui.data_mut(|d| d.remove::<f32>(wheel));
}
if let Some(error) = &error {
crate::ui_kit::text(ui, error, crate::ui_kit::Text::Report("dimension_error", true));
}
if e.fresh == FRESH {
select_all(ctx, id, e);
}
let enter = ui.input(|i| i.key_pressed(egui::Key::Enter));
let field = if r.lost_focus() && enter {
Some(true)
} else if e.fresh > 0 || on_window || by != 0.0 {
if !r.has_focus() {
r.request_focus();
}
None
} else if r.lost_focus() {
Some(false)
} else {
None
};
answer = answer.or(field);
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
answer = Some(false);
escaped = true;
}
});
});
});
if let Some(rect) = ctx.memory(|m| m.area_rect(area)) {
crate::scenario::publish_rect("dimwin", rect);
}
if by != 0.0
&& answer.is_none()
&& let Some(stepped) = step(&e.expr, by)
{
e.expr = stepped;
// The whole new value is selected, so typing replaces it.
select_all(ctx, id, e);
ctx.request_repaint();
}
if answer.is_some() {
forget();
}
(answer, escaped)
}
/// Select the whole value in the field, so typing replaces it.
fn select_all(ctx: &egui::Context, id: egui::Id, e: &mut Edit) {
let n = e.expr.chars().count();
let mut state = egui::TextEdit::load_state(ctx, id).unwrap_or_default();
state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(n))));
e.selection = state.cursor.char_range();
state.store(ctx, id);
}
@@ -192,6 +192,215 @@ fn dimension_tool_click_respects_the_selection_filter() {
crate::dim_view::cancel();
}
/// A line from the origin to `end` with Preferences › Sketch › Dimension values set to In a
/// window (#7246).
fn windowed(end: [f64; 2]) -> Harness {
let mut h = line(end);
h.call("ui.prefs", json!({"dimension_input": "window"}));
h
}
/// The value the open new dimension's window holds.
fn draft_value() -> String {
crate::dim_view::draft_preview().map(|(expr, _, _)| expr).unwrap_or_default()
}
/// In a window, the Dimension tool places the text first and the window opens on it. Its arrows,
/// Up/Down and the wheel over the field step the value while the sketch previews it, nothing
/// reaches the design before the check, the check adds the dimension in one undo step, and the
/// tool stays on for the next dimension.
#[test]
fn a_new_dimension_in_the_window_steps_previews_and_applies_once() {
let mut h = windowed([20., 0.]);
let original = h.app.session.doc.to_json();
let undo = h.app.session.undo.len();
h.step(&json!({"start":"sketch.dimension"})).unwrap();
h.step(&json!({"click":{"world":[10,0,0]}})).unwrap();
assert_eq!(crate::dim_view::live(), None, "no value follows the pointer before the text is placed");
assert!(!crate::dim_view::editing());
h.step(&json!({"click":{"world":[10,6,0]}})).unwrap();
h.frames(3);
assert!(crate::dim_view::editing(), "placing the text opens the window");
assert_eq!(h.ctx.memory(|m| m.focused()), Some(egui::Id::new("sc_dim_edit")), "its field takes the keyboard");
let window = crate::scenario::rect_of("dimwin").expect("the window is shown");
let drawable = h.app.viewport.layout.as_ref().unwrap().drawable;
assert!(drawable.contains_rect(window), "the window {window:?} stays in the view {drawable:?}");
assert_eq!(draft_value(), "20 mm");
let arrow = crate::scenario::handle_at("dimwin:up");
for _ in 0..3 {
h.step(&json!({"click":{"handle":"dimwin:up"}})).unwrap();
}
h.frames(2);
assert_eq!(crate::scenario::handle_at("dimwin:up"), arrow, "the window stays put while the value moves the text");
let (value, dimension, _) = crate::dim_view::draft_preview().unwrap();
assert_eq!(value, "23 mm");
assert!((dimension.kind.value().unwrap() - 23.).abs() < 1e-6, "the sketch previews the stepped value");
assert!(crate::scenario::count_of("dimwin:preview_curves").unwrap_or(0.0) > 0.0, "what the value moves is drawn");
assert_eq!(h.app.session.doc.to_json(), original, "nothing reaches the design before OK");
assert_eq!(h.app.session.undo.len(), undo);
assert_eq!(h.ctx.memory(|m| m.focused()), Some(egui::Id::new("sc_dim_edit")), "the arrows leave the keyboard with the field");
// The wheel over the field steps it instead of zooming the view.
let cam = h.app.cam;
let field = crate::scenario::rect_of("dimwin:value").unwrap();
h.call("ui.scroll", json!({"x": field.center().x, "y": field.center().y, "delta": -40.0}));
h.frames(2);
assert_eq!(draft_value(), "22 mm");
assert_eq!(h.app.cam, cam, "the view did not zoom");
h.step(&json!({"key":"ArrowUp"})).unwrap();
h.frames(2);
assert_eq!(draft_value(), "23 mm");
h.step(&json!({"click":{"handle":"dimwin:ok"}})).unwrap();
h.frames(2);
assert!(!crate::dim_view::editing(), "{}", h.call("ui.inspect", json!({})));
assert_eq!(h.app.session.undo.len(), undo + 1, "one undo step");
let state = h.app.session.world_state().unwrap();
let sk = &state.sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch;
let dimension = sk.constraints.iter().find(|c| c.param.is_some()).expect("the dimension was added");
assert!((dimension.kind.value().unwrap() - 23.).abs() < 1e-6);
assert_eq!(h.app.tool.as_ref().map(|t| (t.cmd, t.picks.len())), Some(("sketch.dimension", 0)), "the tool stays on for the next one");
crate::dim_view::cancel();
}
/// The cross and Esc drop only the dimension in the window: nothing is added and the Dimension
/// tool stays on. Esc with no window open then puts the tool down, as before.
#[test]
fn the_window_cross_and_escape_drop_only_this_dimension() {
let mut h = windowed([20., 0.]);
let original = h.app.session.doc.to_json();
let undo = h.app.session.undo.len();
place(&mut h, &[[10., 0.]], [10., 6.]);
h.step(&json!({"click":{"handle":"dimwin:up"}})).unwrap();
h.step(&json!({"click":{"handle":"dimwin:cancel"}})).unwrap();
h.frames(2);
assert!(!crate::dim_view::editing());
assert_eq!(h.app.session.doc.to_json(), original);
assert_eq!(h.app.session.undo.len(), undo);
assert_eq!(h.app.tool.as_ref().map(|t| (t.cmd, t.picks.len())), Some(("sketch.dimension", 0)));
place(&mut h, &[[10., 0.]], [10., 6.]);
h.step(&json!({"key":"Escape"})).unwrap();
assert!(!crate::dim_view::editing());
assert_eq!(h.app.session.doc.to_json(), original);
assert_eq!(h.app.tool.as_ref().map(|t| t.cmd), Some("sketch.dimension"), "Esc in the window leaves the tool on");
h.frames(40);
h.step(&json!({"key":"Escape"})).unwrap();
assert!(h.app.tool.is_none(), "Esc with no window open ends the tool");
crate::dim_view::cancel();
}
/// Double-clicking an existing dimension opens the window too. Typing and stepping preview the
/// sketch without touching the design, a value OK would refuse says why under the field, an
/// expression is left as typed by the arrows, and Enter applies the value in one undo step.
#[test]
fn double_clicking_a_dimension_opens_the_window_with_a_live_preview() {
let mut h = windowed([20., 0.]);
h.app.run("parameters.add", json!({"name":"width","expression":"30 mm"})).unwrap();
h.app.run("sketch.dimension", json!({"entities":["l1"],"value":20,"text_at":[10,8]})).unwrap();
h.settle();
let before = h.app.session.doc.to_json();
let undo = h.app.session.undo.len();
h.frames(40);
h.step(&json!({"click":{"dimension":"d1"},"double":true})).unwrap();
h.frames(3);
assert!(crate::dim_view::editing() && crate::scenario::rect_of("dimwin").is_some(), "the window opens on the dimension");
type_value(&mut h, "26");
h.frames(2);
assert!(crate::scenario::count_of("dimwin:preview_curves").unwrap_or(0.0) > 0.0, "the sketch previews 26");
h.step(&json!({"key":"ArrowUp"})).unwrap();
h.frames(2);
assert!(h.widget("27").is_some(), "Up stepped the value: {}", h.call("ui.inspect", json!({})));
assert_eq!(h.app.session.doc.to_json(), before, "nothing reaches the design before OK");
type_value(&mut h, "no_such_name");
h.frames(2);
assert!(crate::scenario::rect_of("dialog:report:dimension_error").is_some(), "the refusal shows under the field");
assert_eq!(crate::scenario::count_of("dimwin:preview_curves"), Some(0.0), "a refused value previews nothing");
type_value(&mut h, "width - 4");
h.step(&json!({"click":{"handle":"dimwin:up"}})).unwrap();
h.frames(2);
assert!(h.widget("width - 4").is_some(), "an expression is left as typed");
type_value(&mut h, "27");
h.step(&json!({"key":"Enter"})).unwrap();
h.frames(2);
assert!(!crate::dim_view::editing());
assert!((h.app.session.doc.eval("d1", solidcraft_engine::doc::expr::Kind::Length).unwrap() - 27.).abs() < 1e-6);
assert_eq!(h.app.session.undo.len(), undo + 1, "one undo step");
crate::dim_view::cancel();
}
/// The arrows keep the unit and decimal mark a value is written in: a length steps by the length
/// step converted to that unit (a bare number is in the design's unit), an angle by the angle
/// step, and anything but a plain number is left alone.
#[test]
fn window_steps_keep_the_written_unit_and_decimal_mark() {
use crate::dim_view::window::stepped;
use solidcraft_engine::doc::expr::Kind::{Angle, Length};
assert_eq!(stepped("20 mm", 1.0, Length, "mm", 1.0, 1.0).as_deref(), Some("21 mm"));
assert_eq!(stepped("20", -1.0, Length, "mm", 0.5, 1.0).as_deref(), Some("19.5"));
assert_eq!(stepped("1 in", 1.0, Length, "mm", 2.54, 1.0).as_deref(), Some("1.1 in"));
assert_eq!(stepped("1", 1.0, Length, "in", 2.54, 1.0).as_deref(), Some("1.1"), "a bare length is in the design's unit");
assert_eq!(stepped("3\"", 10.0, Length, "mm", 2.54, 1.0).as_deref(), Some("4\""));
assert_eq!(stepped("2,5 cm", 1.0, Length, "mm", 1.0, 1.0).as_deref(), Some("2,6 cm"));
assert_eq!(stepped("45 deg", -1.0, Angle, "mm", 1.0, 5.0).as_deref(), Some("40 deg"));
assert_eq!(stepped("45", 0.1, Angle, "in", 1.0, 1.0).as_deref(), Some("45.1"), "a bare angle is in degrees");
assert_eq!(stepped("width / 2", 1.0, Length, "mm", 1.0, 1.0), None);
assert_eq!(stepped("20 deg", 1.0, Length, "mm", 1.0, 1.0), None, "a length does not step an angle");
}
/// Dimension values default to In the view with steps of 1 mm and 1°; the choice and the steps
/// save, out-of-range steps and unknown choices are refused, and the Sketch page's Restore
/// Defaults and Cancel treat them as the page's own.
#[test]
fn the_dimension_window_preferences_default_save_and_reset() {
use crate::prefs::{DimensionInput, Prefs, control};
let d = Prefs::default();
assert_eq!((d.dimension_input, d.dimension_step_mm, d.dimension_step_deg), (DimensionInput::View, 1.0, 1.0));
let old: Prefs = serde_json::from_value(json!({})).unwrap();
assert_eq!(old.dimension_input, DimensionInput::View, "saved preferences without it keep the box");
let read = crate::prefs::lenient(&json!({"dimension_input": "sideways", "dimension_step_mm": 2.0}));
assert_eq!((read.dimension_input, read.dimension_step_mm), (DimensionInput::View, 2.0), "an unknown choice costs only itself");
let mut app = crate::SolveApp::new(solidcraft_engine::Session::default(), Default::default());
for refused in [
json!({"dimension_input": "sideways"}),
json!({"dimension_step_mm": 0.0}),
json!({"dimension_step_mm": 5000.0}),
json!({"dimension_step_deg": 0.0}),
json!({"dimension_step_deg": 200.0}),
] {
assert!(control(&mut app, &refused).is_err(), "{refused}");
}
assert_eq!(app.preferences, Prefs::default(), "a refusal changes nothing");
control(&mut app, &json!({"open": "Sketch", "dimension_input": "window", "dimension_step_mm": 0.5, "dimension_step_deg": 15.0})).unwrap();
control(&mut app, &json!({"button": "apply"})).unwrap();
let mut other = crate::SolveApp::new(solidcraft_engine::Session::default(), Default::default());
other.load_prefs(&app.prefs());
assert_eq!(
(other.preferences.dimension_input, other.preferences.dimension_step_mm, other.preferences.dimension_step_deg),
(DimensionInput::Window, 0.5, 15.0)
);
control(&mut app, &json!({"button": "defaults"})).unwrap();
assert_eq!(
(app.preferences.dimension_input, app.preferences.dimension_step_mm, app.preferences.dimension_step_deg),
(DimensionInput::View, 1.0, 1.0)
);
control(&mut app, &json!({"button": "cancel"})).unwrap();
assert_eq!(
(app.preferences.dimension_input, app.preferences.dimension_step_mm, app.preferences.dimension_step_deg),
(DimensionInput::Window, 0.5, 15.0)
);
}
/// The Sketch page's Dimension values switch is a real control.
#[test]
fn the_sketch_page_switches_dimension_values_to_a_window() {
let mut h = Harness::new();
h.call("ui.prefs", json!({"open": "Sketch"}));
h.frames(4);
let handle = format!("dialog:{}:{}", crate::i18n::tr("prefs.dimension_values"), crate::i18n::tr("prefs.dimension_values_window"));
crate::prefs::reveal_preference(&mut h, &handle);
h.step(&json!({"click":{"handle": handle}})).unwrap();
h.frames(2);
assert_eq!(h.app.preferences.dimension_input, crate::prefs::DimensionInput::Window);
}
#[test]
fn dimension_parameters_use_typing_and_ctrl_space_completion() {
for (prefix, expected) in [("wid", "width"), ("", "width"), ("d", "d1")] {
+29 -9
View File
@@ -47,6 +47,7 @@ pub struct DocSlot {
pre_sketch_component: Option<crate::PreSketchComponent>,
view: DocView,
occurrence_move: Option<crate::browser::OccMove>,
reveals: crate::browser::PendingReveals,
agent_chat: crate::agent_chat::AgentChat,
/// Its linked design files as the design last read them (`Documents::link_stamps`).
link_stamps: Vec<(u64, crate::linked_source::Stamp)>,
@@ -142,6 +143,7 @@ impl DocSlot {
pre_sketch_component: None,
view: DocView::default(),
occurrence_move: None,
reveals: Default::default(),
agent_chat: Default::default(),
link_stamps: Vec::new(),
edit_in_place: None,
@@ -215,6 +217,8 @@ fn put_view(app: &mut SolveApp, v: DocView) {
/// Put `slot` into the app and return what was there.
fn swap_in(app: &mut SolveApp, mut slot: DocSlot) -> DocSlot {
let reveals = crate::browser::take_reveals(app);
crate::semantic_content::leave_document(app);
if crate::render_workspace::active(app) {
let _ = crate::render_workspace::enter(app, false);
}
@@ -248,12 +252,14 @@ fn swap_in(app: &mut SolveApp, mut slot: DocSlot) -> DocSlot {
pre_sketch_component: std::mem::replace(&mut app.pre_sketch_component, slot.pre_sketch_component),
view: take_view(app),
occurrence_move: std::mem::replace(&mut app.tree.occurrence_move, slot.occurrence_move),
reveals,
agent_chat: slot.agent_chat,
link_stamps: std::mem::replace(&mut app.docs.link_stamps, slot.link_stamps),
edit_in_place: std::mem::replace(&mut app.edit_in_place.active, slot.edit_in_place),
work,
jobs: app.swap_jobs(slot.jobs),
};
crate::browser::restore_reveals(app, slot.reveals);
// Only the displayed design owns the process-wide crash replay journal.
app.session.crash_journal = out.session.crash_journal;
out.session.crash_journal = false;
@@ -458,6 +464,8 @@ pub fn active_is_blank(app: &SolveApp) -> bool {
pub fn adopt(app: &mut SolveApp, session: Session) {
if active_is_blank(app) {
let rev = app.session.revision;
crate::browser::discard_reveals(app);
crate::semantic_content::leave_document(app);
crate::dialogs::cancel(app);
app.tool = None;
crate::design_properties::close_object(app);
@@ -545,6 +553,8 @@ pub fn close(app: &mut SolveApp, i: usize, force: bool) {
}
app.docs.closing = None;
if n <= 1 {
crate::browser::discard_reveals(app);
crate::semantic_content::leave_document(app);
// The last design: a blank one takes its place.
reset_chat(app);
if let Some(a) = app.autosave.as_mut().and_then(|a| a.active.take()) {
@@ -741,9 +751,11 @@ fn tab_paths(app: &SolveApp) -> Vec<Option<String>> {
}
/// A tab's tooltip: where its design is saved (home shortened to `~`), or that it isn't yet; with
/// the full name above when the tab cut it short (#5868).
fn tab_tooltip(name: &str, path: Option<&str>, cut: bool) -> String {
/// the full name above when the tab cut it short (#5868), and below, that a saved design has
/// changes not saved yet (`dirty`), which the bullet after a cut name no longer shows (#11408).
fn tab_tooltip(name: &str, path: Option<&str>, cut: bool, dirty: bool) -> String {
let place = path.map_or_else(|| crate::i18n::tr_label("Not saved yet").to_string(), crate::home::friendly_path);
let place = if dirty && path.is_some() { format!("{place}\n{}", crate::i18n::tr("document.tab_unsaved_changes")) } else { place };
if cut { format!("{name}\n{place}") } else { place }
}
@@ -926,7 +938,7 @@ pub fn tabs(app: &mut SolveApp, ui: &mut egui::Ui, r: Rect, x0: f32, x1: f32) {
};
let g = ui.painter().layout_no_wrap(label, font, ink);
let text_w = (tr.width() - 30.0).max(10.0);
let tip = tab_tooltip(&name_display, paths.get(i).cloned().flatten().as_deref(), g.size().x > text_w);
let tip = tab_tooltip(&name_display, paths.get(i).cloned().flatten().as_deref(), g.size().x > text_w, *dirty);
let clip = Rect::from_min_size(tr.min, vec2(text_w + 10.0, tr.height()));
ui.painter().with_clip_rect(clip).galley(pos2(tr.left() + 10.0, tr.center().y - g.size().y / 2.0), g, ink);
// ×
@@ -1351,18 +1363,26 @@ mod tests {
use egui::Color32;
/// #5868: a tab's tooltip says where its design is saved (home as `~`), or that it isn't
/// yet, with the full name above when the tab cut the name short.
/// yet, with the full name above when the tab cut the name short. #11408: a saved design with
/// changes not saved yet says so below, since a cut name hides the tab's bullet.
#[test]
fn tab_tooltip_says_where_the_design_is_saved() {
assert_eq!(tab_tooltip("plate", Some("/srv/designs/plate.solidcraft"), false), "/srv/designs/plate.solidcraft");
assert_eq!(tab_tooltip("a-long-name", Some("/srv/a-long-name.solidcraft"), true), "a-long-name\n/srv/a-long-name.solidcraft");
assert_eq!(tab_tooltip("Untitled", None, false), "Not saved yet");
assert_eq!(tab_tooltip("Untitled", None, true), "Untitled\nNot saved yet");
assert_eq!(tab_tooltip("plate", Some("/srv/designs/plate.solidcraft"), false, false), "/srv/designs/plate.solidcraft");
assert_eq!(tab_tooltip("a-long-name", Some("/srv/a-long-name.solidcraft"), true, false), "a-long-name\n/srv/a-long-name.solidcraft");
assert_eq!(tab_tooltip("Untitled", None, false, false), "Not saved yet");
assert_eq!(tab_tooltip("Untitled", None, true, false), "Untitled\nNot saved yet");
assert_eq!(tab_tooltip("plate", Some("/srv/designs/plate.solidcraft"), false, true), "/srv/designs/plate.solidcraft\nUnsaved changes");
assert_eq!(
tab_tooltip("a-long-name", Some("/srv/a-long-name.solidcraft"), true, true),
"a-long-name\n/srv/a-long-name.solidcraft\nUnsaved changes"
);
// A design never saved says so already, changed or not.
assert_eq!(tab_tooltip("Untitled", None, true, true), "Untitled\nNot saved yet");
if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).filter(|h| !h.is_empty()) {
// The abbreviation keeps the relative path's native separators after `~/`.
let relative = std::path::Path::new("Designs").join("bracket.solidcraft");
let path = std::path::Path::new(&home).join(&relative);
assert_eq!(tab_tooltip("bracket", path.to_str(), false), format!("~/{}", relative.display()));
assert_eq!(tab_tooltip("bracket", path.to_str(), false, false), format!("~/{}", relative.display()));
}
}
+3
View File
@@ -73,6 +73,7 @@ static HUNGARIAN: Catalog = Catalog::new("hu", "Magyar", include_str!(concat!(en
static INDONESIAN: Catalog = Catalog::new("id", "Bahasa Indonesia", include_str!(concat!(env!("OUT_DIR"), "/locales/id.json")));
static ITALIAN: Catalog = Catalog::new("it", "Italiano", include_str!(concat!(env!("OUT_DIR"), "/locales/it.json")));
static JAPANESE: Catalog = Catalog::new("ja", "日本語", include_str!(concat!(env!("OUT_DIR"), "/locales/ja.json")));
static GEORGIAN: Catalog = Catalog::new("ka", "ქართული", include_str!(concat!(env!("OUT_DIR"), "/locales/ka.json")));
static KOREAN: Catalog = Catalog::new("ko", "한국어", include_str!(concat!(env!("OUT_DIR"), "/locales/ko.json")));
static MALAY: Catalog = Catalog::new("ms", "Bahasa Melayu", include_str!(concat!(env!("OUT_DIR"), "/locales/ms.json")));
static NORWEGIAN: Catalog = Catalog::new("nb", "Norsk bokmål", include_str!(concat!(env!("OUT_DIR"), "/locales/nb.json")));
@@ -109,6 +110,7 @@ pub const LOCALES: &[&Catalog] = &[
&INDONESIAN,
&ITALIAN,
&JAPANESE,
&GEORGIAN,
&KOREAN,
&MALAY,
&NORWEGIAN,
@@ -1417,6 +1419,7 @@ mod tests {
("bs", "Bosanski", "Postavke", "Ekstrudiraj", "Skica1", "Sačuvati promjene u My {design}?"),
("hr", "Hrvatski", "Postavke", "Ekstrudiraj", "Skica1", "Spremiti promjene u My {design}?"),
("sr", "Српски", "Подешавања", "Екструдирај", "Скица1", "Сачувати промене у My {design}?"),
("ka", "ქართული", "პარამეტრები", "ამოწევა", "ესკიზი1", "შევინახოთ ცვლილებები My {design}-ში?"),
("hu", "Magyar", "Beállítások", "Kihúzás", "Vázlat1", "Menti a(z) My {design} módosításait?"),
("id", "Bahasa Indonesia", "Preferensi", "Ekstrusi", "Sketsa1", "Simpan perubahan ke My {design}?"),
("nl", "Nederlands", "Voorkeuren", "Extruderen", "Schets1", "Wijzigingen in My {design} opslaan?"),
+46 -2
View File
@@ -131,6 +131,8 @@ mod surface_clamp_tests;
#[cfg(test)]
mod surface_errors_tests;
#[cfg(test)]
mod timeline_dependents_tests;
#[cfg(test)]
mod timeline_marks_tests;
#[cfg(test)]
mod status_tests {
@@ -237,6 +239,7 @@ pub mod scenario;
pub(crate) mod scene_palette;
pub mod select_other;
pub mod selection;
pub mod semantic_content;
pub mod settings_profiles;
#[cfg(test)]
mod settings_profiles_tests;
@@ -619,7 +622,8 @@ pub struct SolveApp {
/// How long OK waits for a feature before computing it off the window thread, saying so;
/// `None`: it waits as long as it takes (headless use, tests). The desktop app sets it.
pub apply_off_thread: Option<std::time::Duration>,
/// Tests: the off-thread evaluation waits for this before it reports done.
/// Tests: work started off the window thread (a feature's evaluation, a Properties
/// measurement) waits for this before it reports done.
#[cfg(test)]
pub(crate) apply_gate: Option<std::sync::Arc<std::sync::Mutex<std::sync::mpsc::Receiver<()>>>>,
/// The last command started interactively (id, label), for Repeat.
@@ -694,6 +698,8 @@ pub struct SolveApp {
/// Settings put back at start because they kept SolidCraft from starting, one line each,
/// shown until dismissed (`prefs::show_reset_notice`).
pub settings_reset: Vec<prefs::SettingReset>,
/// The graphics settings that apply at start, and settings on trial (`prefs::Graphics`).
pub graphics: prefs::Graphics,
/// The last command was a big operation: autosave after it.
pub big_operation: bool,
/// Autosave interval (minutes; 0 = only after big operations). A preference.
@@ -789,6 +795,7 @@ impl SolveApp {
render_workspace.image_host = image_host;
#[cfg(not(target_arch = "wasm32"))]
solidcraft_engine::slicer_files::clean_old();
let tree = browser::TreeState::for_session(session.uid);
let mut app = SolveApp {
panel_runtime: None,
viewport_status: Default::default(),
@@ -821,7 +828,7 @@ impl SolveApp {
material_card_review: None,
menu: context_menu::MenuState::default(),
gesture: mouse_gesture::State::default(),
tree: browser::TreeState::default(),
tree,
linked_design_clipboard: None,
sbox: shortcut_box::ShortcutBox::default(),
keymap: keymap::Keymap::default(),
@@ -884,6 +891,7 @@ impl SolveApp {
msaa_compatibility: false,
graphics_fallback: None,
settings_reset: Vec::new(),
graphics: Default::default(),
big_operation: false,
autosave_minutes: 5.0,
custom_titlebar: false,
@@ -1609,6 +1617,24 @@ impl SolveApp {
}
return;
}
// The Dependents button of INSPECT ▸ HISTORY asks for the dependents of ONE
// feature, so it needs a target. With nothing selected it used to run the
// command with no feature and the engine answered "Feature Dependents:
// `feature` must be an id or name". It reports the dependents of what is
// selected, one command call per selected feature.
if id == "timeline.dependents" {
let selected = timeline::selected_feature_ids(self);
if selected.is_empty() {
self.set_status(self.tr("ui.dependents_select_first"), false);
return;
}
for feature_id in selected {
if let Err(why) = self.run("timeline.dependents", json!({ "feature": feature_id })) {
self.set_status(why, true);
}
}
return;
}
// Front … Isometric (Ctrl+1…7 by default) turn the view as the view cube's faces do. The
// flat pattern is a 2D page: it has no other side to turn to.
if solidcraft_engine::standard_view(id).is_some() {
@@ -1933,6 +1959,15 @@ impl SolveApp {
// it was clicked in, so take that one before the turn starts (#10571).
self.pre_command_cam = finished_sketch.is_some().then(|| self.settled_cam());
if finished_sketch.is_some() {
// A point picked in the sketch being finished stays a pick of that sketch: its
// placement reads where the sketch puts it once the sketch is no longer the open one.
if let (Some(sketch), "solid.box" | "solid.sphere" | "solid.torus") = (finished_sketch, id) {
for sel in self.session.selection.iter_mut() {
if let solidcraft_engine::Sel::SketchPoint { id } = sel {
*sel = solidcraft_engine::Sel::ExternalSketchPoint { sketch, id: id.clone() };
}
}
}
self.finish_sketch();
// A feature committed before the marker is back would go in where the sketch was.
if self.deferred.is_some() {
@@ -2991,7 +3026,9 @@ impl SolveApp {
let _language = crate::i18n::scope(&self.preferences.language);
let mut report_input = None;
issue_report::CadInput::block(self, ui, &mut report_input);
semantic_content::begin(self, ui.ctx());
self.cad_ui(ui, &mut report_input);
semantic_content::finalize(self, ui.ctx());
drop(report_input);
// After everything else, on every page (the start page too, which returns early from
// `cad_ui`): the window's edges resize it (#1381).
@@ -3050,6 +3087,9 @@ impl SolveApp {
agent_chat::poll(self, ui.ctx());
// Preferences and Keyboard Shortcuts open from Home too (its links, the gear, Ctrl+,).
prefs::show(self, ui.ctx());
browser::editors(self, ui);
// A start that tries graphics settings asks about them over Home too (#4015).
prefs_notice::graphics_trial(self, ui.ctx());
return;
}
}
@@ -3133,6 +3173,10 @@ impl SolveApp {
if self.ui.show_browser && !render_ui::focused(self) {
browser::browser(self, ui);
}
// All shared semantic content has now submitted its output. Apply it before the
// viewport/dialog/menu consumers, regardless of which presentations were visible.
semantic_content::finalize(self, ui.ctx());
browser::editors(self, ui);
if floating {
agent_chat::show(self, ui);
}
@@ -120,6 +120,60 @@ fn painted_text(h: &mut Harness) -> Vec<String> {
output.shapes.iter().filter_map(|s| if let egui::Shape::Text(t) = &s.shape { Some(t.galley.text().to_string()) } else { None }).collect()
}
/// A point's complete position must be painted, not merely retained in an ellipsized galley.
#[test]
fn point_coordinates_are_not_truncated_in_the_measure_panel() {
for layout in ["floating", "compact"] {
for decimals in [2, 8] {
let mut h = Harness::new();
h.app.home.open = false;
h.app.preferences.length_decimals = decimals;
h.call("ui.set", json!({"layout":layout}));
let point = [-99.56, -75.0, 12.345678];
h.app.run("solid.box", json!({"corner":point,"length":1,"width":1,"height":1,"body_name":"Box"})).unwrap();
h.app.session.selection = vec![Sel::Vertex { body: "Box".into(), point: point.into() }];
h.app.start("inspect.measure");
h.call("ui.view", json!({"view":"fit"}));
h.frames(6);
let line = crate::quick_measure::item_lines(&h.app, &result(&h)["items"][0]).remove(0);
h.ctx = egui::Context::default();
crate::fonts::setup_bundled_fonts(&h.ctx);
let mut textures = std::collections::HashMap::new();
let mut last = None;
for frame in 0..6 {
let mut output = h.ctx.run_ui(
egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0))),
time: Some(frame as f64 / 60.0),
..Default::default()
},
|ui| h.app.ui(ui),
);
crate::ui_kit::test_textures_update(&mut textures, &output.textures_delta);
output.textures_delta.clear();
last = Some(output);
}
let output = last.unwrap();
if let Ok(dir) = std::env::var("SOLIDCRAFT_MEASURE_EVIDENCE") {
let bounds = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0));
let image = crate::ui_kit::test_raster(&h.ctx, output.shapes.clone(), output.pixels_per_point, bounds, &textures);
std::fs::create_dir_all(&dir).unwrap();
image.save(std::path::Path::new(&dir).join(format!("coordinates-{layout}-{decimals}.png"))).unwrap();
}
let painted = output
.shapes
.iter()
.find_map(|s| match &s.shape {
egui::Shape::Text(t) if t.galley.text().contains(&line) => Some((s.clip_rect, t)),
_ => None,
})
.expect("the position is painted");
assert!(!painted.1.galley.elided, "{layout}, {decimals} decimals: {line}");
assert!(painted.0.expand(1.0).contains_rect(painted.1.visual_bounding_rect()), "the complete position stays inside its clip");
}
}
}
fn component_rects() -> Vec<egui::Rect> {
["measure:label", "measure:label:x", "measure:label:y", "measure:label:z"].into_iter().map(|key| crate::scenario::rect_of(key).unwrap()).collect()
}
+1
View File
@@ -582,6 +582,7 @@ pub(crate) fn handle(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect, resp: &e
let wheel_inside = inside
|| (ui.is_enabled()
&& !crate::value_step::enabled(ui.ctx())
&& !hover.is_some_and(|p| crate::dim_view::window::covers(ui.ctx(), p))
&& hover.is_some_and(|p| {
rect.contains(p)
&& !axes.contains(p)
+8 -2
View File
@@ -499,7 +499,10 @@ fn find_in_browser_reaches_a_folded_body() {
let scope = super::occurrence_key(&[second]);
h.app.tree.collapsed.extend([super::occurrence_key(&[first]), scope.clone(), format!("{scope}/bodies")]);
h.frames(2);
assert!(!h.app.tree.order.contains(&format!("b:{body}")), "the body's row is closed away");
assert!(
!h.app.tree.content.owner(crate::semantic_content::ContentOwner::BROWSER).unwrap().order.contains(&format!("b:{body}")),
"the body's row is closed away"
);
h.app.session.selection = vec![Sel::Body { name: body.clone() }];
let find = context_menu::items(&h.app, &Target::Viewport).into_iter().find(|i| i.id == "ui.findBrowser").unwrap();
context_menu::run_item(&mut h.app, &find, egui::Pos2::ZERO);
@@ -507,7 +510,10 @@ fn find_in_browser_reaches_a_folded_body() {
h.frames(2);
assert!(h.app.tree.reveal.is_none(), "revealed");
assert!(!h.app.tree.collapsed.contains(&scope));
assert!(h.app.tree.order.contains(&format!("b:{body}")), "the row is drawn");
assert!(
h.app.tree.content.owner(crate::semantic_content::ContentOwner::BROWSER).unwrap().order.contains(&format!("b:{body}")),
"the row is drawn"
);
assert!(crate::scenario::handle_at("fold:Bodies").is_none(), "with no folder row");
}
+302 -26
View File
@@ -676,6 +676,21 @@ impl OriginPlaneNames {
}
}
/// Where a sketch dimension's value is typed (Preferences › Sketch, "Dimension values", #7246).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DimensionInput {
/// In a box on the dimension (also what a value this version doesn't know reads as).
#[default]
View,
/// In a small window at the dimension, with arrows that step the value (`dim_view::window`).
Window,
}
/// The lengths (millimetres) and angles (degrees) the dimension window's arrows may step by.
pub const DIMENSION_STEP_MM: std::ops::RangeInclusive<f64> = 0.001..=1000.0;
pub const DIMENSION_STEP_DEG: std::ops::RangeInclusive<f64> = 0.001..=180.0;
/// What an orbit turns around (Preferences: "Orbit around").
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
@@ -1097,7 +1112,7 @@ preference_settings! {
nav_bridge_enabled: bool = false, PAGE_MOUSE3D;
/// The loopback port the device app listens on (0: none).
nav_bridge_port: u16 = 8765, PAGE_MOUSE3D;
/// Anti-aliasing samples (applies on restart).
/// Anti-aliasing samples (applies on restart, on trial: [`Graphics`]).
msaa: u32 = 4, PAGE_GRAPHICS;
/// Share shading and Continuity; false preserves their independent legacy behavior.
single_surface_analysis: bool = true, PAGE_GRAPHICS => check_id("prefs.graphics_page.single_surface_analysis");
@@ -1106,6 +1121,8 @@ preference_settings! {
window_system: String = "auto".into(), PAGE_GRAPHICS;
/// Exact graphics adapter name; empty selects automatically on the next start.
graphics_adapter: String = String::new(), PAGE_GRAPHICS;
/// The graphics API: `auto`, or one of [`Graphics::apis`] (applies on restart, on trial).
graphics_api: String = "auto".into(), PAGE_GRAPHICS;
/// Soft contact shading in the GPU viewport; optional and applied immediately.
ambient_occlusion: bool = false, PAGE_GRAPHICS;
/// Shade the viewport in linear light with a sky fill, so dark parts keep their shape
@@ -1567,6 +1584,13 @@ preference_settings! {
line_hover_tangent_arc: bool = false, PAGE_SKETCH;
/// Include hidden geometry and a small pointer neighbourhood in Select Other.
select_other_hidden: bool = true, PAGE_SKETCH;
/// Where a dimension's value is typed: in a box on the dimension (the default), or in a small
/// window at it whose arrows step the value (#7246, `dim_view::window`).
dimension_input: DimensionInput = DimensionInput::View, PAGE_SKETCH;
/// What the dimension window's arrows, Up/Down and the wheel add to a length (millimetres,
/// converted to the unit the value is written in) and to an angle (degrees).
dimension_step_mm: f64 = 1.0, PAGE_SKETCH, accept(DIMENSION_STEP_MM);
dimension_step_deg: f64 = 1.0, PAGE_SKETCH, accept(DIMENSION_STEP_DEG);
/// When true: Shift+click adds without removing; Primary toggles.
/// When false (default): Shift and Primary both toggle.
selection_shift_add_only: bool = false, PAGE_SKETCH;
@@ -1783,7 +1807,7 @@ impl Snapshot {
}
}
fn restore(self, app: &mut SolveApp) {
let graphics = (app.preferences.msaa, app.preferences.window_system.clone(), app.preferences.graphics_adapter.clone());
let graphics = (selected_graphics(app), app.preferences.window_system.clone(), app.preferences.graphics_adapter.clone());
let units_changed = app.preferences.default_units != self.prefs.default_units;
// Update bookkeeping isn't a setting: a check that finished while the dialog was open stays.
let update = std::mem::take(&mut app.preferences.update);
@@ -1810,8 +1834,8 @@ impl Snapshot {
app.toolbar_custom.load(&self.toolbar);
app.sbox.pinned = self.favourites;
app.settings_profiles.active = self.profile.filter(|name| app.settings_profiles.names().contains(name));
if (app.preferences.msaa, app.preferences.window_system.clone(), app.preferences.graphics_adapter.clone()) != graphics {
save_graphics(app);
if (selected_graphics(app), app.preferences.window_system.clone(), app.preferences.graphics_adapter.clone()) != graphics {
save_graphics(app, true);
}
}
@@ -2191,7 +2215,7 @@ fn ui_font_row(app: &mut SolveApp, ui: &mut egui::Ui) {
/// Restore Defaults for one page.
fn defaults(app: &mut SolveApp, page: usize) {
let (msaa, window_system, adapter) = (app.preferences.msaa, app.preferences.window_system.clone(), app.preferences.graphics_adapter.clone());
let graphics = (selected_graphics(app), app.preferences.window_system.clone(), app.preferences.graphics_adapter.clone());
let units = app.preferences.default_units.clone();
reset_preferences(&mut app.preferences, page);
sync_surface_analysis(app);
@@ -2223,8 +2247,8 @@ fn defaults(app: &mut SolveApp, page: usize) {
app.ui.show_grid = true;
app.ui.ground_shadow = true;
app.ui.perspective = false;
if app.preferences.msaa != msaa || app.preferences.window_system != window_system || app.preferences.graphics_adapter != adapter {
save_graphics(app);
if (selected_graphics(app), app.preferences.window_system.clone(), app.preferences.graphics_adapter.clone()) != graphics {
save_graphics(app, true);
}
}
PAGE_SKETCH => {
@@ -2582,23 +2606,165 @@ fn window_system_row(app: &mut SolveApp, ui: &mut egui::Ui, label_width: f32) {
}
}
/// Write the anti-aliasing and window system settings where the host reads them at start (neither
/// can change while the window is open).
fn save_graphics(app: &SolveApp) {
if let Some(path) = &app.services.graphics_path {
// Other settings in the file (the graphics `backend`) stay as they are.
let mut graphics = std::fs::read_to_string(path)
.ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
.filter(serde_json::Value::is_object)
.unwrap_or_else(|| json!({}));
graphics["msaa"] = json!(app.preferences.msaa);
graphics["window_system"] = json!(app.preferences.window_system);
graphics["adapter"] = json!(app.preferences.graphics_adapter);
let _ = std::fs::write(path, graphics.to_string());
/// Graphics settings that apply when SolidCraft starts: the API and the anti-aliasing (#4015).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GraphicsChoice {
/// `auto`, or a backend as `graphics.json` names it (`dx12`, `vulkan`, `gl`).
pub api: String,
pub msaa: u32,
}
impl Default for GraphicsChoice {
fn default() -> Self {
let p = Prefs::default();
GraphicsChoice { api: p.graphics_api, msaa: p.msaa }
}
}
impl GraphicsChoice {
/// As the trial question names it, with only what differs from `other` ("Vulkan, 8×
/// anti-aliasing").
pub fn describe(&self, other: &GraphicsChoice) -> String {
let mut parts = Vec::new();
if self.api != other.api || self.msaa == other.msaa {
parts.push(graphics_api_label(&self.api));
}
if self.msaa != other.msaa {
parts.push(crate::i18n::format("settings.graphics_msaa", &[("samples", &self.msaa.to_string())]));
}
parts.join(", ")
}
}
/// The graphics settings that apply at start, as the host reports them (#4015). The window
/// can't change its API or samples while it is open, so a change in Preferences is tried at the
/// next start: the host starts with it, [`crate::prefs_notice`] asks whether to keep it and goes
/// back unless it is kept, and a start that never gets that far is dropped by the next one.
#[derive(Clone, Debug, Default)]
pub struct Graphics {
/// The APIs offered besides Automatic, as `graphics.json` names them. None: no choice
/// (macOS, which has Metal alone; the browser).
pub apis: Vec<&'static str>,
/// Of those, the ones known to freeze this GPU (D3D12 on AMD integrated graphics): a warning
/// shows while one is chosen, as no trial start helps once the whole screen is frozen.
pub freezes: Vec<&'static str>,
/// `WGPU_BACKEND`, which decides the API when it is set.
pub env_override: Option<String>,
/// The settings kept for every start (`graphics.json`'s `backend` and `msaa`).
pub saved: GraphicsChoice,
/// This start tries settings, now in the preferences.
pub trial: Option<GraphicsTrial>,
/// Start again once closed: to try settings, or to go back from them.
pub restart: bool,
}
/// Settings on trial at this start.
#[derive(Clone, Debug, PartialEq)]
pub struct GraphicsTrial {
/// What a "go back" returns to: the saved settings.
pub previous: GraphicsChoice,
/// Seconds the question has waited ([`crate::prefs_notice::TRIAL_SECONDS`]).
pub shown: f64,
/// The `egui` input time of the last frame that drew the question.
pub last_frame: Option<f64>,
}
/// An API's name as people know it; Automatic in the interface language.
pub fn graphics_api_label(api: &str) -> String {
match api {
"auto" => crate::i18n::tr("ui.graphics_api_auto").to_string(),
"dx12" => "Direct3D 12".into(),
"vulkan" => "Vulkan".into(),
"gl" => "OpenGL".into(),
"metal" => "Metal".into(),
other => other.to_string(),
}
}
/// The warning under the graphics API while the chosen one is known to freeze this GPU
/// ([`Graphics::freezes`]); none while `WGPU_BACKEND` decides.
fn freeze_warning(app: &SolveApp) -> Option<String> {
let api = app.preferences.graphics_api.as_str();
(app.graphics.env_override.is_none() && app.graphics.freezes.contains(&api))
.then(|| crate::i18n::format("settings.graphics_api_freezes", &[("api", &graphics_api_label(api))]))
}
/// The graphics settings chosen in the preferences.
pub fn selected_graphics(app: &SolveApp) -> GraphicsChoice {
GraphicsChoice { api: app.preferences.graphics_api.clone(), msaa: app.preferences.msaa }
}
/// `graphics.json` with the chosen settings. `trial`: chosen in the Preferences window, so they
/// are tried at the next start (its `trial` object) and the saved `backend` and `msaa` stay as
/// they are; none when they are the saved ones. Else (the control channel, a setting put back at
/// start, kept settings) they are saved at once when they aren't the saved ones. Other entries
/// stay.
pub fn graphics_file(old: Option<serde_json::Value>, selected: &GraphicsChoice, saved: &GraphicsChoice, trial: bool) -> serde_json::Value {
let mut graphics = old.filter(serde_json::Value::is_object).unwrap_or_else(|| json!({}));
if let Some(o) = graphics.as_object_mut() {
o.remove("trial");
}
if selected == saved {
return graphics;
}
if trial {
graphics["trial"] = json!({"backend": selected.api, "msaa": selected.msaa});
} else {
graphics["backend"] = json!(selected.api);
graphics["msaa"] = json!(selected.msaa);
}
graphics
}
/// Write the graphics settings where the host reads them at start ([`graphics_file`], the window
/// system and the graphics card).
fn save_graphics(app: &mut SolveApp, trial: bool) {
let selected = selected_graphics(app);
if let Some(path) = &app.services.graphics_path {
let old = std::fs::read_to_string(path).ok().and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok());
let mut file = graphics_file(old, &selected, &app.graphics.saved, trial);
// The window system and the graphics card apply at the next start too, without a trial;
// written once they aren't the defaults.
let defaults = Prefs::default();
for (key, value, default) in [
("window_system", &app.preferences.window_system, &defaults.window_system),
("adapter", &app.preferences.graphics_adapter, &defaults.graphics_adapter),
] {
if value != default || file.get(key).is_some() {
file[key] = json!(value);
}
}
if let Err(e) = std::fs::write(path, file.to_string()) {
log::warn!("graphics: writing {path}: {e}");
}
}
if !trial {
app.graphics.saved = selected;
}
}
/// Keep the settings on trial: they become the saved ones.
pub fn keep_graphics(app: &mut SolveApp) {
app.graphics.trial = None;
save_graphics(app, false);
}
/// Go back from the settings on trial: the saved ones return, and SolidCraft starts again with
/// them once closed (asking about unsaved designs first).
pub fn revert_graphics(app: &mut SolveApp) {
let Some(trial) = app.graphics.trial.take() else { return };
app.preferences.graphics_api = trial.previous.api;
app.preferences.msaa = trial.previous.msaa;
save_graphics(app, false);
restart_for_graphics(app);
}
/// Close (asking about unsaved designs), then start again: the host reads the graphics settings.
fn restart_for_graphics(app: &mut SolveApp) {
app.graphics.restart = true;
crate::documents::request_quit(app);
}
/// At start, reflect a confirmed startup fallback in Graphics, or reset an unsupported
/// saved count to the supported default. The compatibility hint keeps higher choices
/// available for another try; a probe result alone does not overwrite the saved choice.
@@ -2615,7 +2781,10 @@ pub fn check_msaa(app: &mut SolveApp, requested: u32) {
let reset = supported.iter().copied().filter(|&n| n <= default).max().unwrap_or(1);
let supported = supported.clone();
app.preferences.msaa = reset;
save_graphics(app);
// Settings on trial are kept or dropped as a whole.
if app.graphics.trial.is_none() {
save_graphics(app, false);
}
app.settings_reset.push(SettingReset::Msaa { requested, reset, supported });
}
@@ -2744,8 +2913,9 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
if app.preferences.msaa != before.msaa
|| app.preferences.window_system != before.window_system
|| app.preferences.graphics_adapter != before.graphics_adapter
|| app.preferences.graphics_api != before.graphics_api
{
save_graphics(app);
save_graphics(app, true);
}
if app.preferences.up_axis != before.up_axis {
up_axis_changed(app);
@@ -3983,6 +4153,11 @@ fn prepare_preferences(
{
return Err("`stand_up_unknown` must be ask, note or off".into());
}
if let Some(v) = p.get("dimension_input")
&& !matches!(v.as_str(), Some("view" | "window"))
{
return Err("`dimension_input` must be view or window".into());
}
let mut new: Prefs = serde_json::from_value(cur).map_err(|e| e.to_string())?;
migrate_gruvbox(&mut new);
if let Some(update) = p.get("theme_options") {
@@ -4001,6 +4176,11 @@ fn prepare_preferences(
if !WINDOW_SYSTEMS.contains(&new.window_system.as_str()) {
return Err(format!("`window_system` must be one of {}", WINDOW_SYSTEMS.join(", ")));
}
let api_offered = new.graphics_api == "auto" || app.graphics.apis.contains(&new.graphics_api.as_str());
if new.graphics_api != app.preferences.graphics_api && !api_offered {
let apis: Vec<&str> = std::iter::once("auto").chain(app.graphics.apis.iter().copied()).collect();
return Err(format!("`graphics_api` must be one of: {}", apis.join(", ")));
}
if !NAV_PRESETS.iter().any(|p| p.id == new.nav) || !MSAA.contains(&new.msaa) {
return Err(format!("`nav` must be one of {} and `msaa` 1, 2, 4 or 8", NAV_PRESETS.iter().map(|p| p.id).collect::<Vec<_>>().join(", ")));
}
@@ -4076,6 +4256,9 @@ fn prepare_preferences(
if !accepted::perspective_fov(&new) {
return Err("`perspective_fov` must be 15°…90° in radians (0.2618…1.5708)".into());
}
if !accepted::dimension_step_mm(&new) || !accepted::dimension_step_deg(&new) {
return Err("`dimension_step_mm` must be 0.001…1000 and `dimension_step_deg` 0.001…180".into());
}
if solidcraft_engine::doc::display_quality::DisplayQuality::parse(&new.display_quality).is_none() {
return Err("`display_quality` must be coarse, normal, fine or ultra".into());
}
@@ -4104,7 +4287,8 @@ fn apply_preferences(app: &mut SolveApp, new: Prefs, p: &serde_json::Value) -> R
let up_changed = new.up_axis != app.preferences.up_axis;
let graphics_changed = new.msaa != app.preferences.msaa
|| new.window_system != app.preferences.window_system
|| new.graphics_adapter != app.preferences.graphics_adapter;
|| new.graphics_adapter != app.preferences.graphics_adapter
|| new.graphics_api != app.preferences.graphics_api;
if p.get("layout").is_some() {
app.ui.layout = new.layout;
}
@@ -4117,8 +4301,10 @@ fn apply_preferences(app: &mut SolveApp, new: Prefs, p: &serde_json::Value) -> R
if units_changed {
default_units_changed(app);
}
// Kept at once, without a question: a script that changes them knows what it asked for.
if graphics_changed {
save_graphics(app);
app.graphics.trial = None;
save_graphics(app, false);
}
if up_changed {
up_axis_changed(app);
@@ -8754,7 +8940,9 @@ mod tests {
let mut app = SolveApp::new(solidcraft_engine::Session::default(), services);
// WebGPU's guarantee for the surface format: 8x saved, the device can only do 1x and 4x.
app.msaa_supported = vec![1, 4];
// As the host reads them from the file at start.
app.preferences.msaa = 8;
app.preferences.graphics_api = "vulkan".into();
std::fs::write(&path, r#"{"msaa":8,"backend":"vulkan"}"#).unwrap();
check_msaa(&mut app, 8);
assert_eq!(app.preferences.msaa, 4);
@@ -8790,6 +8978,90 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// D3D12 froze AMD integrated GPUs past what a trial start can undo (#3321): choosing it there
/// warns first, and nowhere else.
#[test]
fn choosing_an_api_that_freezes_this_gpu_warns() {
let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default());
app.graphics = Graphics { apis: vec!["dx12", "vulkan", "gl"], freezes: vec!["dx12"], ..Default::default() };
app.preferences.graphics_api = "vulkan".into();
assert_eq!(freeze_warning(&app), None);
app.preferences.graphics_api = "dx12".into();
assert_eq!(
freeze_warning(&app).as_deref(),
Some(
"On AMD integrated graphics, Direct3D 12 can freeze the screen, sometimes until the computer is restarted. Automatic uses Vulkan here."
)
);
app.graphics.env_override = Some("dx12".into());
assert_eq!(freeze_warning(&app), None, "WGPU_BACKEND decides, and its own note says so");
app.graphics = Graphics { apis: vec!["dx12", "vulkan", "gl"], ..Default::default() };
assert_eq!(freeze_warning(&app), None);
}
/// The graphics API and anti-aliasing (#4015): a change in the Preferences window is saved as
/// a trial for the next start, and Cancel takes it back; the control channel saves at once.
/// Keep makes the settings on trial the saved ones; Go back returns to those and restarts.
#[test]
fn graphics_settings_are_tried_from_the_window_and_saved_at_once_from_scripts() {
let dir = std::env::temp_dir().join(format!("sc_graphics_trial_{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let path = dir.join("graphics.json");
let read = || serde_json::from_str::<serde_json::Value>(&std::fs::read_to_string(&path).unwrap_or_default()).unwrap_or_default();
let services = crate::Services { graphics_path: Some(path.to_string_lossy().to_string()), ..Default::default() };
let mut app = SolveApp::new(solidcraft_engine::Session::default(), services);
let dx12 = GraphicsChoice { api: "dx12".into(), msaa: 4 };
let vulkan = GraphicsChoice { api: "vulkan".into(), msaa: 8 };
app.graphics = Graphics { apis: vec!["dx12", "vulkan", "gl"], saved: dx12.clone(), ..Default::default() };
(app.preferences.graphics_api, app.preferences.msaa) = ("dx12".into(), 4);
std::fs::write(&path, r#"{"backend":"dx12","msaa":4,"other":1}"#).unwrap();
// The window: a trial, next to the saved settings; back to them, no trial.
app.prefs_window.open = true;
let snapshot = Snapshot::take(&app);
(app.preferences.graphics_api, app.preferences.msaa) = ("vulkan".into(), 8);
save_graphics(&mut app, true);
assert_eq!(read(), json!({"backend": "dx12", "msaa": 4, "other": 1, "trial": {"backend": "vulkan", "msaa": 8}}));
assert_eq!(app.graphics.saved, dx12, "only tried");
snapshot.restore(&mut app);
assert_eq!(read(), json!({"backend": "dx12", "msaa": 4, "other": 1}), "Cancel takes the trial back");
// The control channel: only an API this system offers; saved at once, any trial dropped.
assert!(set(&mut app, &json!({"graphics_api": "metal"})).is_err());
app.graphics.trial = Some(GraphicsTrial { previous: dx12.clone(), shown: 0.0, last_frame: None });
set(&mut app, &json!({"graphics_api": "vulkan", "msaa": 8})).unwrap();
assert_eq!((app.graphics.saved.clone(), app.graphics.trial.clone()), (vulkan.clone(), None));
assert_eq!(read(), json!({"backend": "vulkan", "msaa": 8, "other": 1}));
// On trial: a device that can't do the samples changes the selection but saves nothing,
// as the trial is kept or dropped as a whole.
std::fs::write(&path, r#"{"backend":"dx12","msaa":4,"trial":{"backend":"vulkan","msaa":8,"started":true}}"#).unwrap();
app.graphics.saved = dx12.clone();
app.graphics.trial = Some(GraphicsTrial { previous: dx12.clone(), shown: 0.0, last_frame: None });
app.msaa_supported = vec![1, 4];
check_msaa(&mut app, 8);
assert_eq!(app.preferences.msaa, 4);
assert_eq!(read()["trial"]["started"], json!(true));
// Keep: the tried API (with the samples the device does) is saved, the trial goes.
keep_graphics(&mut app);
assert_eq!(read(), json!({"backend": "vulkan", "msaa": 4}));
assert!(app.graphics.trial.is_none() && !app.graphics.restart);
// Go back: the saved settings return, and SolidCraft starts again once closed.
std::fs::write(&path, r#"{"backend":"dx12","msaa":4,"trial":{"backend":"gl","msaa":4,"started":true}}"#).unwrap();
app.graphics.saved = dx12.clone();
app.graphics.trial = Some(GraphicsTrial { previous: dx12.clone(), shown: 0.0, last_frame: None });
app.preferences.graphics_api = "gl".into();
revert_graphics(&mut app);
assert_eq!((app.preferences.graphics_api.as_str(), app.preferences.msaa), ("dx12", 4));
assert_eq!(read(), json!({"backend": "dx12", "msaa": 4}));
assert!(app.graphics.trial.is_none() && app.graphics.restart && app.quit_requested, "no unsaved design: closes at once");
assert_eq!(GraphicsChoice { api: "gl".into(), msaa: 4 }.describe(&dx12), "OpenGL");
assert_eq!(vulkan.describe(&dx12), "Vulkan, 8× anti-aliasing");
assert_eq!(GraphicsChoice { api: "auto".into(), msaa: 2 }.describe(&GraphicsChoice { api: "auto".into(), msaa: 4 }), "2× anti-aliasing");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn control_channel_rejects_msaa_the_device_cannot_do_and_bad_saved_msaa_loads_as_default() {
let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default());
@@ -10685,9 +10957,12 @@ mod graphics_preset_tests {
assert!(tree.nodes.iter().any(|(_, node)| node.role() == Role::RadioButton && node.label() == Some("8×") && node.is_disabled()));
click_graphics(&mut h, "Anti-aliasing", "prefs:msaa:2");
assert_eq!(h.app.preferences.msaa, 2);
// A change in the window is tried at the next start; the saved level stays (#4015).
let saved: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
assert_eq!(saved["msaa"], 2);
assert_eq!(saved["msaa"], 4, "{saved}");
assert_eq!(saved["backend"], "vulkan");
// The trial holds the chosen API (this harness's default) with the new level.
assert_eq!(saved["trial"], json!({"backend":h.app.preferences.graphics_api,"msaa":2}), "{saved}");
assert_eq!(
h.app.viewport.gpu.as_ref().map(|gpu| gpu.samples),
active_samples,
@@ -10710,6 +10985,7 @@ mod graphics_preset_tests {
let saved: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
assert_eq!(saved["msaa"], 4, "Cancel restores startup persistence too");
assert_eq!(saved["backend"], "vulkan");
assert!(saved.get("trial").is_none(), "Cancel removes the trial: {saved}");
std::fs::remove_dir_all(dir).unwrap();
}
}
+62 -4
View File
@@ -41,6 +41,19 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
}
});
});
// While settings are on trial, the question about them comes first (#4015).
let on_trial = app.graphics.trial.is_some();
if !app.graphics.apis.is_empty() {
ui_kit::settings_section(ui, "api", app.tr("ui.graphics_api"), |ui| {
// The dropdown takes the whole width, as the graphics card's does; the note below
// says when it applies.
graphics_api(app, ui, on_trial);
if let Some(warning) = freeze_warning(app) {
ui_kit::text(ui, &warning, ui_kit::Text::Error("graphics_api_freezes"));
}
restart_now(app, ui, on_trial);
});
}
ui_kit::settings_section(ui, "quality", app.tr("prefs.curved_surfaces"), |ui| {
use solidcraft_engine::doc::display_quality::DisplayQuality;
let current = DisplayQuality::parse(&app.preferences.display_quality).unwrap_or_default();
@@ -95,13 +108,15 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = egui::vec2(t.space_10, t.space_10);
for (m, figure) in MSAA.into_iter().zip(figures) {
let enabled = app.msaa_supported.is_empty() || app.msaa_supported.contains(&m);
let supported = app.msaa_supported.is_empty() || app.msaa_supported.contains(&m);
let enabled = supported && !on_trial;
let label = if m == 1 { app.tr("ui.colour_vision_off").to_owned() } else { format!("{m}×") };
let hint = app.tr(if on_trial { "settings.graphics_trial_first" } else { "settings.graphics_applies_on_restart" });
let response = ui_kit::picture_card(
ui,
&ui_kit::Card {
label: &label,
sub: Some(if enabled { "" } else { app.tr("prefs.not_on_gpu") }),
sub: Some(if supported { "" } else { app.tr("prefs.not_on_gpu") }),
selected: app.preferences.msaa == m,
enabled,
options: None,
@@ -111,8 +126,8 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
},
(t.space_24 * 5.0 + t.space_10).min(ui.max_rect().width()),
)
.backing_on_hover_text(app.tr("settings.msaa_applies_on_restart"))
.backing_on_disabled_hover_text(app.tr("settings.msaa_applies_on_restart"));
.backing_on_hover_text(hint)
.backing_on_disabled_hover_text(if supported { hint } else { app.tr("prefs.not_on_gpu") });
crate::scenario::publish_target(&format!("prefs:msaa:{m}"), response.rect);
crate::scenario::publish_target(&format!("prefs:option:{label}"), response.rect);
if enabled && response.clicked() {
@@ -123,6 +138,10 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
if app.msaa_compatibility {
ui_kit::text(ui, app.tr("prefs.anti_aliasing_amd_dx12"), ui_kit::Text::Hint("msaa_compatibility"));
}
// Without an API to choose (macOS), the restart for the anti-aliasing comes here.
if app.graphics.apis.is_empty() {
restart_now(app, ui, on_trial);
}
ui_kit::settings_form(ui, help_label_width, |ui| {
ui_kit::settings_row_help(ui, app.tr("prefs.graphics_page.supersampling"), app.tr("settings.supersampling_note"), |ui| {
let mut selected = SUPERSAMPLING.iter().position(|f| *f == app.preferences.supersampling).unwrap_or(0);
@@ -548,3 +567,42 @@ fn field_of_view_row(app: &mut SolveApp, ui: &mut egui::Ui) {
response
});
}
/// The graphics API (#4015): Automatic or one of [`Graphics::apis`], tried at the next start.
/// `WGPU_BACKEND` decides it when set, and settings on trial are kept or dropped first.
fn graphics_api(app: &mut SolveApp, ui: &mut egui::Ui, on_trial: bool) {
if let Some(value) = app.graphics.env_override.clone() {
let note = crate::i18n::format("settings.graphics_api_env", &[("value", &value)]);
ui.add_enabled_ui(false, |ui| ui_kit::dropdown_menu(ui, "pref_graphics_api", &value, |_| {})).response.on_disabled_hover_text(note);
return;
}
let apis: Vec<&str> = std::iter::once("auto").chain(app.graphics.apis.iter().copied()).collect();
ui.add_enabled_ui(!on_trial, |ui| {
ui_kit::dropdown_menu(ui, "pref_graphics_api", &graphics_api_label(&app.preferences.graphics_api), |ui| {
for api in apis {
if ui_kit::list_picker(ui, api, &graphics_api_label(api), app.preferences.graphics_api == api).clicked() {
app.preferences.graphics_api = api.into();
}
}
})
})
.response
.on_disabled_hover_text(app.tr("settings.graphics_trial_first"));
}
/// The graphics API and anti-aliasing apply at start ([`Graphics`]): a note, and Restart now
/// once the chosen ones differ from the saved ones.
fn restart_now(app: &mut SolveApp, ui: &mut egui::Ui, on_trial: bool) {
if app.services.graphics_path.is_none() {
return;
}
ui_kit::text(ui, app.tr("settings.graphics_applies_on_restart"), ui_kit::Text::Hint("graphics_restart"));
if !on_trial
&& selected_graphics(app) != app.graphics.saved
&& ui_kit::button(ui, app.tr("settings.graphics_restart_now"), ui_kit::Button::Normal).clicked()
{
// The choice stays (as OK keeps it) and is tried by the start that follows.
close(app, false);
restart_for_graphics(app);
}
}
+1 -1
View File
@@ -130,7 +130,7 @@ fn device(app: &mut SolveApp, ui: &mut egui::Ui) {
ui,
&mut app.preferences.space_mouse_speed,
0.25..=5.0,
ui_kit::Slider { step: Some(0.05), format: |value| format!("{value:.2}×"), ..Default::default() },
ui_kit::Slider { step: Some(0.05), format: |value| format!("{value:.2}×"), ..Default::default() },
);
crate::scenario::publish_target("prefs:space_mouse_speed", response.rect);
});
+33
View File
@@ -172,6 +172,7 @@ fn drawing(app: &mut SolveApp, ui: &mut egui::Ui) {
);
});
});
dimension_values(app, ui, t.space_24 * 8.0 + t.space_8);
});
}
@@ -480,3 +481,35 @@ fn targets(key: &str, response: &egui::Response) {
};
crate::scenario::publish_target(&format!("dialog:{legacy}"), response.rect);
}
/// Where a dimension's value is typed, and what the window's arrows step lengths and angles by
/// (#7246). The steps only matter in the window, so they wait for it.
fn dimension_values(app: &mut SolveApp, ui: &mut egui::Ui, label_width: f32) {
let label = app.tr("prefs.dimension_values").to_string();
ui_kit::settings_block_hint(ui, &label, app.tr("prefs.dimension_values_note"), label_width, |ui| {
let options = [app.tr("prefs.dimension_values_view"), app.tr("prefs.dimension_values_window")];
let mut selected = usize::from(app.preferences.dimension_input == DimensionInput::Window);
if ui_kit::settings_segments(ui, &label, &mut selected, &options) {
app.preferences.dimension_input = if selected == 1 { DimensionInput::Window } else { DimensionInput::View };
}
});
let window = app.preferences.dimension_input == DimensionInput::Window;
let units = app.session.doc.units.clone();
let decimals = app.preferences.length_decimals;
ui_kit::settings_block(ui, app.tr("prefs.dimension_steps"), label_width, |ui| {
ui.add_enabled_ui(window, |ui| {
ui.horizontal(|ui| {
let t = Tokens::get();
ui_kit::settings_control_width(ui, t.form_label_width, |ui| {
let p = &mut app.preferences;
ui_kit::bounded_length_field(ui, &mut p.dimension_step_mm, &units, decimals, DIMENSION_STEP_MM, 0.1)
.on_hover_text(crate::i18n::tr("prefs.dimension_step_length"));
});
ui_kit::settings_control_width(ui, t.form_label_width, |ui| {
ui_kit::number_field(ui, &mut app.preferences.dimension_step_deg, DIMENSION_STEP_DEG, 0.1, "°")
.on_hover_text(crate::i18n::tr("prefs.dimension_step_angle"));
});
});
});
});
}
+57 -1
View File
@@ -1,12 +1,20 @@
//! The notice for settings put back at start because they would keep SolidCraft from starting
//! on this computer (`SolveApp::settings_reset`), shown until dismissed.
//! on this computer (`SolveApp::settings_reset`), shown until dismissed; and the question about
//! graphics settings on trial (`prefs::Graphics`, #4015).
use crate::SolveApp;
use crate::ui_kit;
/// Seconds the question about graphics settings on trial waits before going back.
pub const TRIAL_SECONDS: f64 = 15.0;
/// The most one frame adds to that wait, so a window that drew nothing for a while (hidden)
/// doesn't give up the moment it draws again, while a hung GPU's frames, a second apart, count.
const TRIAL_FRAME_SECONDS: f64 = 1.5;
/// Draw the notice while any setting was reset. OK dismisses it; "Open Preferences" dismisses it
/// and opens the Graphics page.
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
graphics_trial(app, ctx);
if app.settings_reset.is_empty() {
return;
}
@@ -25,3 +33,51 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
crate::prefs::open_at(app, crate::prefs::PAGE_GRAPHICS);
}
}
/// While this start tries graphics settings: Keep saves them; Go back (or the window's close)
/// returns to the saved ones and starts again. Unanswered for [`TRIAL_SECONDS`], it gives up
/// and closes at once (unsaved changes stay for recovery, as after a crash), for a window the
/// new settings left white or black can't show the question or ask about unsaved designs. The
/// next start finds the trial unkept and goes back, saying so.
pub fn graphics_trial(app: &mut SolveApp, ctx: &egui::Context) {
// A restart whose quit the user cancelled (Cancel at "save changes?") is off.
if app.graphics.restart && app.docs.quitting.is_none() && !app.quit_requested {
app.graphics.restart = false;
}
let now = ctx.input(|i| i.time);
let Some(trial) = &mut app.graphics.trial else { return };
if let Some(last) = trial.last_frame {
trial.shown += (now - last).clamp(0.0, TRIAL_FRAME_SECONDS);
}
trial.last_frame = Some(now);
let left = TRIAL_SECONDS - trial.shown;
if left <= 0.0 {
log::warn!("graphics: the settings on trial were not kept in {TRIAL_SECONDS} s; starting again with the saved ones");
eprintln!("SolidCraft graphics: the settings on trial weren't kept in {TRIAL_SECONDS} s; starting again");
app.graphics.trial = None;
app.graphics.restart = true;
app.quit_requested = true;
// The host closes the window in its next frame, and an idle window draws none: without
// this it waited for the pointer to move, still on the settings that didn't work.
ctx.request_repaint();
return;
}
ctx.request_repaint_after(std::time::Duration::from_millis(250));
let previous = trial.previous.clone();
let current = crate::prefs::selected_graphics(app);
let body = crate::i18n::format(
"settings.graphics_trial_body",
&[("settings", &current.describe(&previous)), ("previous", &previous.describe(&current)), ("seconds", &format!("{}", left.ceil()))],
);
let (closed, action) = ui_kit::Window::new(egui::Id::new("sc_graphics_trial"), "settings", app.tr("settings.graphics_trial_title"))
.actions(app.tr("settings.graphics_trial_keep"), app.tr("settings.graphics_trial_revert"), None)
.show(ctx, true, |ui| {
ui_kit::text(ui, &body, ui_kit::Text::Report("graphics_trial", false));
});
match action {
ui_kit::Action::Ok => crate::prefs::keep_graphics(app),
ui_kit::Action::Cancel => crate::prefs::revert_graphics(app),
_ if closed => crate::prefs::revert_graphics(app),
_ => {}
}
}
+53 -45
View File
@@ -774,38 +774,7 @@ pub fn build(before: &ModelState, after: &ModelState, c: Colors) -> Built {
replaced.push(b.name.clone());
}
}
let sketches = after
.sketches
.iter()
.filter_map(|sketch| {
let old = before.sketch(sketch.feature);
let old_curves =
old.into_iter().flat_map(|s| s.sketch.curves.iter().map(|c| (c.id.as_str(), c))).collect::<std::collections::HashMap<_, _>>();
let old_points =
old.into_iter().flat_map(|s| s.sketch.points.iter().map(|p| (p.id.as_str(), p.pos))).collect::<std::collections::HashMap<_, _>>();
let indices = sketch
.sketch
.curves
.iter()
.enumerate()
.filter_map(|(index, curve)| {
let unchanged = old_curves.get(curve.id.as_str()).is_some_and(|previous| {
previous.kind == curve.kind
&& curve.kind.point_ids().iter().all(|i| old.and_then(|s| s.sketch.point(*i)) == sketch.sketch.point(*i))
});
if unchanged { None } else { Some(index) }
})
.collect::<Vec<_>>();
let points = sketch
.sketch
.points
.iter()
.enumerate()
.filter_map(|(i, p)| if old_points.get(p.id.as_str()) == Some(&p.pos) { None } else { Some(i) })
.collect::<Vec<_>>();
if indices.is_empty() && points.is_empty() { None } else { Some(SketchPreview { sketch: sketch.clone(), curves: indices, points }) }
})
.collect();
let sketches = after.sketches.iter().filter_map(|sketch| sketch_changes(before.sketch(sketch.feature), sketch)).collect();
Built { scene: sc, sketches, replaced, error: None, bounds: Aabb3::EMPTY, applied: None, tapered: Vec::new(), stopped: None }
}
@@ -821,6 +790,37 @@ fn on_surface(index: &SurfaceIndex, [a, b, c]: [Vec3; 3]) -> bool {
/// bounded, so a modeled thread's thousands of faces still cost little.
const FACE_SAMPLES: usize = 24;
/// What of `sketch` differs from `old`, the same sketch before a change: the curves whose shape
/// or points moved and the points that moved (`None`: nothing did).
pub(crate) fn sketch_changes(
old: Option<&solidcraft_engine::doc::SolvedSketch>,
sketch: &std::sync::Arc<solidcraft_engine::doc::SolvedSketch>,
) -> Option<SketchPreview> {
let old_curves = old.into_iter().flat_map(|s| s.sketch.curves.iter().map(|c| (c.id.as_str(), c))).collect::<std::collections::HashMap<_, _>>();
let old_points =
old.into_iter().flat_map(|s| s.sketch.points.iter().map(|p| (p.id.as_str(), p.pos))).collect::<std::collections::HashMap<_, _>>();
let indices = sketch
.sketch
.curves
.iter()
.enumerate()
.filter_map(|(index, curve)| {
let unchanged = old_curves.get(curve.id.as_str()).is_some_and(|previous| {
previous.kind == curve.kind && curve.kind.point_ids().iter().all(|i| old.and_then(|s| s.sketch.point(*i)) == sketch.sketch.point(*i))
});
if unchanged { None } else { Some(index) }
})
.collect::<Vec<_>>();
let points = sketch
.sketch
.points
.iter()
.enumerate()
.filter_map(|(i, p)| if old_points.get(p.id.as_str()) == Some(&p.pos) { None } else { Some(i) })
.collect::<Vec<_>>();
if indices.is_empty() && points.is_empty() { None } else { Some(SketchPreview { sketch: sketch.clone(), curves: indices, points }) }
}
/// Which of the `nf` faces of `m` are new surface, i.e. not on the old mesh? Each is decided by
/// a vote of points on it (triangle centroids spread over the face). One pass over `m`'s
/// triangles gathers the centroids, and the old mesh is indexed once, so the cost stays
@@ -962,22 +962,30 @@ pub fn paint_sketches(app: &SolveApp, painter: &egui::Painter, proj: &crate::vie
{
return;
}
let stroke = egui::Stroke::new(2.5, Tokens::get().preview_sketch);
let mut budget = solidcraft_engine::render::SamplingBudget::new(32_768, 65_536);
let mut curves = 0;
for preview in &app.preview.sketches {
let sketch = &preview.sketch;
for index in &preview.curves {
crate::tools::paint_curve(&sketch.sketch, *index, &sketch.plane, painter, proj, stroke, &mut budget);
curves += 1;
}
for index in &preview.points {
if let Some(point) = sketch.sketch.point(*index).and_then(|p| proj.to_screen(sketch.plane.to_world(p))) {
painter.circle_filled(point, 3.0, stroke.color);
}
let curves: usize = app.preview.sketches.iter().map(|preview| paint_sketch(preview, painter, proj, &mut budget)).sum();
crate::scenario::publish_count("sketch_preview_curves", curves as f64);
}
/// Draw what a change moves in a sketch, its curves and points, in the preview colour; returns
/// how many curves it drew.
pub(crate) fn paint_sketch(
preview: &SketchPreview,
painter: &egui::Painter,
proj: &crate::viewport::Proj,
budget: &mut solidcraft_engine::render::SamplingBudget,
) -> usize {
let stroke = egui::Stroke::new(2.5, Tokens::get().preview_sketch);
let sketch = &preview.sketch;
for index in &preview.curves {
crate::tools::paint_curve(&sketch.sketch, *index, &sketch.plane, painter, proj, stroke, budget);
}
for index in &preview.points {
if let Some(point) = sketch.sketch.point(*index).and_then(|p| proj.to_screen(sketch.plane.to_world(p))) {
painter.circle_filled(point, 3.0, stroke.color);
}
}
crate::scenario::publish_count("sketch_preview_curves", curves as f64);
preview.curves.len()
}
#[cfg(test)]

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