Compare commits

..

93 Commits

Author SHA1 Message Date
bherbruck 65d288227e Release 0.21.0 (rc.2's code, version only)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 20:51:08 -04:00
bherbruck fedb3bcdb1 Release 0.21.0-rc.2 2026-10-23 17:14:38 -04:00
bherbruck a51dfc9e1f Release 0.21.0-rc.2 (#5691)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 17:14:35 -04:00
bherbruck 9aaa8016e5 gpu tests: give the two test frames the new hover and selection tints (#5698)
#5109 added Frame::hover_tint and selection_tint; two test frames that landed alongside it didn't set them, so solidcraft-gpu's tests stopped compiling on main.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 17:04:32 -04:00
bherbruck 632ec4157c Sketch on a picked origin plane uses the plane shown after standing an import up (#5659)
* Sketch on a picked origin plane uses the plane shown, also in a moved component

After a STEP file opens its component stays the active one, and standing it up
turns that component a quarter about X. Starting a sketch on XY picked in the
viewport then sent `{"plane": "XY"}`, which names the active component's own
XY: upright after the stand-up. The sketch landed on the world XZ, Look At
turned to face Y and the extrude went along Y.

The viewport and browser only show the design's origin planes, so a picked
origin plane now goes to sketch.create written out as that world plane while
the active component is moved; sketch.create maps it into the component's
frame once. The flip toward the viewer uses the same plane.

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

* Stand-up axes tests: stand the file up through the offer when one is waiting

#5655 offers to stand up SolidWorks files instead of turning them silently.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 16:11:39 -04:00
bherbruck c3ed8b1c07 Offer to stand up SolidWorks files instead of turning them silently; add Turn Up Axis to the component menu (#5655)
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 16:05:46 -04:00
Jackson Oursland 8301d1c48e Viewport: whole-body highlights reuse the model's own triangles (#4554) (#5109)
Hovering or selecting whole bodies -- a browser row, a History group, a body
under the pointer in body-pick mode -- built a CPU copy of every body's
triangles into the highlight scene. On a 40-body 4.3 MB STEP assembly that was
147 bodies, 70 MB of triangle copies and a 519 ms frame.

The model already has the same geometry on the GPU. The highlight now names it
and the renderer draws it again in the highlight colour:

- bodies drawn from a display plan: the model's evaluated plan chunks, with a
  hover/selection tint uniform (the chunks stay resident in the model scene);
- bodies drawn as placements of shared triangles: the same placements of the
  model's shared triangles, coloured per instance;
- bodies the model builds on the CPU: their vertex range in the model scene's
  block.

See-through and faded bodies keep the CPU feedback, which colours them; faces
and edges highlighted on their own are unchanged. The highlight looks the same
(verified side by side).

On the assembly the highlight build drops from 519 ms to 9 ms and no longer
copies any triangles; selecting the whole assembly takes 9-20 ms.

Co-authored-by: Claude Code <noreply@anthropic.com>
2026-10-23 16:05:41 -04:00
github-actions[bot] 2a02eb71ac Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci] 2026-10-23 20:04:02 +00:00
bherbruck 097b8b7550 Release 0.21.0-rc.1
The tree is main at f120285c9 exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-23 13:48:05 -04:00
bherbruck f120285c96 Fix design format 33 for 0.21.0-rc.1 (#5610)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:47:48 -04:00
bherbruck af7ca3a8a2 Release 0.21.0-rc.1
The tree is main at b31fddb98 exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-23 13:37:43 -04:00
bherbruck b31fddb98e OpenGL: the viewport scene renders 1x again, like the window (#5605)
#5187 gave the scene its own offscreen target with the app's sample count,
which skipped the OpenGL clamp to 1x from 6dd202d. The scene's count now
goes through effective_msaa_samples as well.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:37:27 -04:00
bherbruck 27ddb725c7 Release 0.21.0-rc.1
The tree is main at 2bf3d4e11 exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-23 12:10:02 -04:00
bherbruck 2bf3d4e11f Release 0.21.0-rc.1 (#5566)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:09:47 -04:00
github-actions[bot] 9852a2e758 Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci] 2026-10-23 16:05:55 +00:00
raykholo 17bac141c8 An intersect that would leave nothing is refused instead of deleting the body (#5295) (#5303)
* claim: #5295

* An empty intersect is refused instead of deleting the body (#5295)

An intersect whose boolean came out empty removed the target body and
reported success. The shared evaluator now refuses it when nothing of a
named target would remain, when nothing of any target would remain, or
when the tools meet a target and the result is still empty. An unnamed
target the tools miss altogether is still removed, as an intersect with
every body does (#5160). Sheet metal flat machining follows the same rule.

* Sheet metal: an intersect with already removed material is refused (#5295)
2026-10-23 13:50:47 +00:00
gsrunion 4b42ef50c7 Mesh leftovers from the ribbon redesign (#133) (#4471)
* claim: #133

* mesh: Save As Mesh declares the panel that shows it (#133)

The ribbon redesign (#2176) removed the MESH tab, but three things still
pointed at it:

- `file.save_mesh` declared `.at("MESH", "MODIFY")`. The command inventory
  prints the placement a spec declares, so `docs/features.md` advertised a
  `MESH > MODIFY` location no ribbon has. Its real home is already
  INSERT / FILES (`SECTIONS` lists it), so the declaration now agrees with
  the ribbon; `destination()` resolved it there before and after, so nothing
  moves on screen.
- The user guide sent people to "the Mesh tab under Modify" for Save as mesh.
- A test now refuses any command that declares a tab the ribbon removed, and
  pins where Save As Mesh lives.

`PLACEMENTS` keeps its MESH entries: those migrate toolbars users saved under
the old panels, which is a different question from what a spec declares.

---------

Co-authored-by: Geraldrunion <geraldrunion@users.noreply.github.com>
2026-10-23 13:45:35 +00:00
Peppe Bergqvist fdde3f9da9 Add macOS native menu bar (#2361) (#4531)
* claim: #2361

Co-Authored-By: GitHub Copilot <noreply@github.com>

* feat: add native macOS menu bar (#2361)

Reuse document actions and unsaved quit handling in native application, File, Edit, Window and Help menus. Replace duplicate header controls only when native installation succeeds, preserving other platforms and the fallback UI.

Co-Authored-By: GitHub Copilot <noreply@github.com>

---------

Co-authored-by: GitHub Copilot <noreply@github.com>
2026-10-23 13:45:31 +00:00
Colin Faulkingham 74ea81519b Keep Parameters responsive in large feature histories (#2733) (#4549)
* claim: #2733

* Keep Parameters responsive with many feature groups (#2733)
2026-10-23 13:45:26 +00:00
SS-EN 769dfff1a5 Section Analysis: a Hatch option (#4563) (#4565)
* claim: #4563

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

* Section Analysis: a Hatch option, off for a plain cut in the body's colour (#4563)

The Section Analysis dialog gets a Hatch check box beside Flip, and
inspect.section a hatch?: bool (default true), so scripts, the CLI and the
control channel reach it too. Hatched, a cut is its body's colour tinted
toward the cap colour with 45 degree stripes, as before; off, it is the
body's own solid colour. The viewport (cap alpha in the uniform), the CPU
rasterizer and ui.render follow it, and the section keeps it when hidden
and shown again or edited.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:45:23 +00:00
SS-EN 3ef68c708f Expression suggestions stay closed while another control is dragged (#4589) (#4590)
* claim: #4589

* Expression suggestions stay closed while another control is dragged (#4589)

A drag elsewhere leaves a text box its focus (egui gives it up on a click,
not a drag), so dragging the Drive Joints slider rewrote the focused Angle
box under its old selection. A letter before the caret ("5.0 d|eg") made a
new word, a fresh undismissed popup, and the list opened over the slider.

Dismissed suggestions now stay closed until the box is typed in, a drag of
another widget dismisses them, and a hidden list forgets its row rects.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:45:17 +00:00
Reefwalker 3ed1829cc7 fix: retain circular sketch pattern Quantity expressions (#4435) (#4607) 2026-10-23 13:45:15 +00:00
raykholo b982ccd8c4 Mesh Convert works without activating the mesh's component first (#4609) (#4613)
* claim: #4609

* Mesh Convert works without activating the component first (#4609)

Inserting a mesh puts it in a new component while the parent stays
active, so Convert Mesh always refused the first time. Convert now
resolves the mesh body's owning component and adds the conversion
there when the active component is that component or an ancestor,
leaving the active component unchanged. From another branch it still
refuses, and the error names the component to activate.
2026-10-23 13:45:13 +00:00
dudedagger42 2586b09f5a Control: a whole-design measure reads a copy off the window thread (#4604) (#4614)
* Control: a whole-design measure reads a copy off the window thread

`inspect.measure` and `document.inspect` (which measures every body by
default) ran on the window thread. On a large assembly that took about a
minute, the window answered nothing meanwhile, and Windows closed it as
not responding (#4604). They change nothing, so they now run on a copy of
the session on a worker and are answered from there, like exports, but
whatever the "agent commands off the window thread" preference says: an
answer from the design as it was when asked has no order to keep. The
window keeps answering `ui.*` and drawing meanwhile.

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

* Control: clearance and interference checks read a copy off the window thread too

inspect.clearance and inspect.interference only read the design, and on a
large assembly measure every pair of bodies: on a 153-body design a clearance
check computed for over five minutes on the window thread, the window showing
"not responding". They join inspect.measure and document.inspect in running on
a copy, answered from there.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:45:10 +00:00
DuBearte a5f9ff45dd Sketch Symmetry: a dimensioned shape slides onto the axis (#4030) (#4626)
* claim: #4030

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

* Sketch Symmetry: a dimensioned shape slides onto the axis (#4030)

With a rectangle's width dimensioned, Symmetry of its two sides about an
axis was refused: the second side can't move on its own, the axis never
moves, and the ordinary solve that slides the whole rectangle only ran
when every first-picked entity could move. One movable first entity is
now enough.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:45:08 +00:00
raykholo 61360c3b0d Convert to Sheet Metal: a bend picked on its tangent edge connects, on rotated parts too (#4328) (#4630)
* claim: #4328

* Fix sheet conversion picks on tangent bend edges (#4328)

Resolve cylindrical bends through the nearest edge's incident faces when a
pick on a tangent boundary resolves to its planar panel. This keeps valid
remote bends connected, including picks on the other skin of a shell.

Keep conversion geometry and picks in the body's local frame, and format
conversion diagnostics through its world placement. Cover rotated bend
picks and disconnected-pick coordinates with synthetic regressions.
2026-10-23 13:45:06 +00:00
Reefwalker eec6da060f Measure: report obtuse angles at a shared corner (#4625) (#4634)
* claim: #4625

* fix: measure obtuse corners independently of edge direction (#4625)
2026-10-23 13:45:04 +00:00
DuBearte f422bb1263 Intersection edges divide at 1e-6 when asked for less, instead of panicking (#4623) (#4650)
A join failed with the kernel's internal error "tolerance must be no less
than 1e-6" at vendor/truck-geometry/src/decorators/intersection_curve.rs:172:
IntersectionCurve::parameter_division asserted truck's minimum tolerance,
and the guard turned the panic into a failed operation. A tolerance below
truck's TOLERANCE, or one that is not a number, now divides at TOLERANCE:
the curve's points are only found to within it anyway.

The test divides the intersection edges of a hole cut through a B-spline
face at 5e-7, 1e-12, 0, -1 and NaN: each gives the division at 1e-6. Without
the fix it panics with the reported message at the reported line.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:44:56 +00:00
Martin Pålsson 96ce31153c Pattern and mirror: a rib as the source (#4593) (#4667)
* claim: #4593

* Pattern and mirror: a rib as the source (#4593)

A pattern or mirror of features refused a rib as a source ("Rib1
cannot be patterned or mirrored yet"). A rib's walls run until they
meet the body, so each copy is built again where it is: the rib's
sketch is moved by the copy's transform and the walls are run against
the bodies there, as a hole is drilled again at each copy. The copies
join the body, as the rib does. The rib's own tool building moves into
rib_tools, which the rib feature calls unmoved.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:44:52 +00:00
Steve Jason a9c3c8bb8a Accept a leading = in expressions, so a fillet radius can be =d3 (#4681) (#4686)
* Sketch fillet radius: accept a leading = in an expression (#4681)

* A second = after a name is still not a definition
2026-10-23 13:44:48 +00:00
dudedagger42 a147a7c083 Sketch Point: a point put on a midpoint or a curve snap is held there (#4691) (#4745)
* Sketch Point: a point put on a midpoint or a curve snap is held there (#4691)

The Point tool sent only a snapped point's coordinates and sketch.point
took only [x, y], so a point clicked on a line's midpoint (or on a curve,
or a model vertex) was free to move off it. sketch.point now takes the
snap forms the other draw commands take ("mid:<line>", "on:<curve>:x,y",
{vertex}, {on_edge}); the snap makes the point with its constraint. The
tool passes those snaps as themselves; a snap to an existing point still
goes as coordinates, where `connect` joins them.

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

* Sketch Point: refuse a snap it can't read instead of answering as if it made a point

sketch_project::snap_points leaves a snap it can't parse ("on:c1:abc",
"on:c1", "on:c1:1,2,3") as it was given, and sketch.point then read
that string back as the point it had made: no error, no point, and an id
a script would go on to dimension. It now takes the snap only when it
names a point of the sketch, and otherwise refuses, listing the snap
forms (found by the test agent on #4745).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:44:42 +00:00
dudedagger42 89a11d75c0 Imports through the app keep their matrix, name and options (#4676) (#4746)
The app's background loader (file_load::control) kept only `up` and
`names` of a control-channel open or import, so file.insert_step's
`matrix` and `name` were dropped and the part landed at the origin, and
file.insert_mesh lost all its options. It keeps every parameter but the
path, as given; each command checks them as it does when run directly.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:44:40 +00:00
dudedagger42 84cad43154 Timeline marker: refuse a negative position, and name the command asked for (#4712) (#4749)
timeline.rollback / timeline.roll_to read a negative position as 0, so
an agent passing -1 for "one step back" rolled the whole timeline back
and every body disappeared, with no error. A negative position is now
refused. The two commands shared one function that refused in
roll_to's name; each now refuses in its own.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:44:38 +00:00
rlartot 5c280b9a94 fr: Répétition instead of Réseau for patterns (#4751)
Nine messages: the pattern tools, their headings and two sample
descriptions. "Réseau" can be mistaken for a network.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 13:44:36 +00:00
DuBearte 1a953736ff Combine takes bodies clicked in the Browser (#4452) (#4766)
* claim: #4452

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

* Combine takes bodies clicked in the Browser (#4452)

A body row in the Browser only replaced the selection, so with Combine
open its target and tools stayed empty and overlapping bodies could only
be picked in the view. When the open dialog's active input takes bodies,
a body row now goes there, as plane and axis rows already do.

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

* Combine Browser picks: test both layouts and control-channel clicks (#4452)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:44:32 +00:00
DuBearte 4106332738 Shortcuts: a sketch tool and a model command can share a key (#4163) (#4781)
The Keyboard Shortcuts page refused a key another command used, even a
sketch tool and a model command that never run in the same mode, so
Shift+S could not open Create Sketch in the model and place a point in a
sketch. Edited by hand, the press ran Create Sketch inside the sketch.

A sketch tool (SKETCH tab) and a model command (the other tabs) may now
share a key. A press runs the enabled command of the mode the view is in
first: the tool in a sketch, the model command outside. Two commands of
one mode still clash, and commands on no tab (undo, views, files) clash
with everything.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:44:29 +00:00
CamWight 9559ad398a Physical material: one rule for a body's material and its density (#4783) (#4785)
* claim: #4783

* Physical material: one rule for a body's material and its density (#4783)

inspect.measure, component.properties, occurrence.properties and the
Properties window took a body's material name from the raw assignment,
but its density from Document::density, which falls back to the default
material when the assigned one is unknown or has an unusable density. So
a damaged or hand-edited design read one material and weighed another: a
design material "Broken" at -3 g/cm³ read "Broken" at 1.29 g/cm³.

One rule, in one place:
- DEFAULT_MATERIAL (new, beside DEFAULT_DENSITY in the material library)
  is the default material's name. The library's first row uses it.
- Document::body_material (new, beside Document::density) is the material
  a body's mass is computed with, as the library or the design spells it:
  the assigned one when it is known and has a usable density, else
  DEFAULT_MATERIAL. Document::density is that material's density, with
  the same value as before for every input.

inspect.measure and the physical properties name body_material.
inspect.measure now names every body's material, measured or not, since
naming it takes no measuring; the density stays with the mass, on the
bodies measured. So the Properties window shows the engine's name without
a fallback of its own. The places that keep the assignment as stored
(configuration cells, material.assign, material.edit, material.delete
and the dialogs) only use the constant, so a configuration still keeps a
damaged assignment as it is.

The visible change: a body whose material is unknown, or has an unusable
density, reads "Default", the material it weighs, and a miscased name
reads as the library or the design spells it. A body inspect.measure
can't measure now has its material too. Masses and densities don't
change.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:44:27 +00:00
DuBearte bddb52c96a Move: the value box follows the dragged axis, and Tab steps through X, Y, Z (#4776) (#4788)
The Move dialog's floating value box always showed and edited the X
distance, whichever arrow was dragged, and Tab left the box for another
widget. Now:
- dragging an arrow shows that axis's distance, turning a ring (Free Move)
  that axis's angle; a plane square or the ball leaves the box as it is;
- Tab in the box goes to the next value (X, Y, Z, then in Free Move the
  X, Y and Z angles) and Shift+Tab back, the keyboard staying in the box.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:44:25 +00:00
raykholo a0fd39a7be Flange on a plain solid says how to start sheet metal (#2987) (#4789)
* claim: #2987

* Flange on a plain solid says how to start sheet metal (#2987)

Flange, Flat Pattern, and Export DXF on a design with no sheet metal body
now say how to make one: Flange > Base, or Convert to Sheet Metal on a
plate's large flat face. An edge flange checks for a sheet metal body
before it adds a feature. The message is in the catalogs, and engine
messages that match a catalog entry are shown in the interface language.

* Dialogs show a refusal without the engine's parameter prefix (#2987)

The Flange dialog showed "invalid parameters for `sheet.flange`: " in front
of the new guidance. The dialog already names its command, so the red
refusal text leaves that prefix out and follows the interface language
when the message is in the catalogs. Create Issue still gets the full text.
2026-10-23 13:44:24 +00:00
raykholo be87303683 Web: a trackpad swipe pans instead of zooming (#4116) (#4834)
* claim: #4116

* Web: a trackpad swipe pans instead of zooming (#4116)

Browsers deliver trackpad swipes as wheel events without scroll phases, so
the shared navigation treated every vertical swipe as a mouse-wheel zoom.
The web raw-input hook now brackets point-mode swipes (small or horizontal
deltas) with gesture start/end phases, ending after 100 ms idle or before
line/page wheel or pinch input. Wheel notches and pinch still zoom, and
camera input still goes through navigation::handle.
2026-10-23 13:44:20 +00:00
raykholo bd0cc4bb51 Units of new designs apply to the empty design already open (#221) (#4835)
* claim: #221

* Units of new designs apply to the blank design already open (#221)

Changing Preferences > Units of new designs only took effect at the next
File > New, so the empty design open at launch stayed in millimetres. When
the preference changes (the dialog, Cancel, Restore Defaults, or
preferences.set) and the open design is blank and unsaved, it takes the new
units at once, without an undo step or unsaved changes. A design with
content or a file keeps its units.
2026-10-23 13:44:18 +00:00
raykholo 4c8ae5d078 Hem: the direction arrow points the way the hem builds (#4794) (#4836)
* claim: #4794

* Hem: the direction arrow points the way the hem builds (#4794)

The Hem dialog shared the edge flange's arrow, which points out of the
sheet's larger neighbouring face. A hem folds back the other way, so the
arrow pointed away from the material it adds. Flip the hem's arrow so it
matches the geometry; edge flanges keep theirs.

A regression test compares the arrow with the side the finished hem adds
material on, for both Flip states, top and bottom edge picks, and both
dialog layouts.
2026-10-23 13:44:17 +00:00
Mateusz Sych e33c371e99 Model cache: thin the checkpoints of a design too big for its file instead of leaving the cache out (#3220) (#4882)
* claim: #3220

* Model cache: keep fewer states of a design too big for its file instead of none (#3220)

A model cache over the container's budget keeps the final state, then the
slowest features' states while the bodies they add fit (a body several
states hold counts once), and is left out only when not even the final
state fits. Opening evaluates just the features whose state the file left
out, each from the state before it, so the model is whole afterwards.

* Model cache: keep each encoded body apart, copy only what a cache stores

Encoding a model too big for its file no longer copies every distinct body
into one buffer before thinning: the payload is copied from the encodings
once its size fits, so saving holds no more than before.

* Model cache tests: canonical runs in the timeline fixture; rustfmt

* Model cache: count the records a thinned cache keeps; always try the final state

Thinning counted only the bodies, so a model of many copies of one part
(records, not bodies, filling the room) shrank its budget to nothing and
saved no cache although its final state fitted. The choice now counts the
records of what it keeps, always keeps the final state, and says what it
counted, so each retry keeps less and the final state alone is always tried.

* docs: a thinned model cache counts its records and always tries the final state

* Model cache test: the slowest feature's state holds bodies 16 to 23

* Model cache: thinned opens with fingerprints that read the state before a feature

Since #3397 a feature's fingerprint reads where the bodies before it lie.
After a state the file left out, adopt takes the saved fingerprint and the
evaluation that works the state out checks it. A result evaluated ahead
is never taken as one the file left out. The thinned round trips include
the placed components' design, whose cut reads those placements.

---------

Co-authored-by: MateuszSychXpl <151755914+MateuszSychXpl@users.noreply.github.com>
2026-10-23 13:44:11 +00:00
raykholo e104e241a3 Rename a body: keep the names of siblings a later feature removed (#4880) (#4904)
* claim: #4880

* Rename a body: keep the names of siblings a later feature removed (#4880)

Renaming a body sets the stored names of all bodies its feature made, so
the siblings are not renumbered. Those names were read from the final
model, where a body that a later feature removed no longer exists: its
stored name was lost, it came back as `Ext (3)`, and the later Remove
failed with "no body `Body3`". Read the siblings from the feature's own
result instead, as the later features already are.

* Test: renaming one half of a split leaves the other

Renaming one half keeps the other half's name, also once a later feature
joins that other half away, after both an incremental and a full
evaluation.
2026-10-23 13:44:09 +00:00
raykholo 3bca787213 MCP tools: unknown command ids suggest the closest ids (#4846) (#4915)
* claim: #4846

* MCP tools: unknown command ids suggest the closest ids (#4846)

An unknown command id in `execute` or `batch` now lists up to three closest
ids, and an id with parameters packed into it says to pass them in `params`.
2026-10-23 13:44:07 +00:00
raykholo 21257c39c2 Keyboard Shortcuts page: draw only the visible rows (#4804) (#4919)
* claim: #4804

* Keyboard Shortcuts page: draw only the visible rows (#4804)

Off-screen rows keep their height but skip their cells, the list is
filtered once before measuring and drawing, and each row's shortcut
comes from the command spec already in hand instead of a registry
lookup.
2026-10-23 13:44:06 +00:00
raykholo ed6e123d54 Sketch: offer Break Offset Association for offsets of lines (#4899) (#4920)
* claim: #4899

* Sketch: offer Break Offset Association for offsets of lines (#4899)

Trim and Extend refuse an associated offset output and point to Break
Offset Association, but the right-click menu offered it only for
free-form offsets. Offer it whenever the curve is an offset output
(analytic, topology or free-form), using the sketch's shared lookup.
2026-10-23 13:44:04 +00:00
raykholo dad28abecf solidcraft-cli bench-assistant: the standard Assistant model test (#4851) (#4930)
* claim: #4851

* solidcraft-cli bench-assistant: the standard Assistant model test

Runs the eleven v1 tasks of #4851 against a desktop app that is already
running with a control port. Each task starts a new design and a new chat
with All permissions, runs only the prescribed setup, sends the prompt
once and waits for the turn. The result is checked on the design
(document.inspect with measurements): body count, volume within 1%
(0.3% for the fillet), bounding-box size in any orientation, the lowest
corner, the body name, live feature kinds, and changed or unchanged
content; the volume question is checked on the reply. Failures get the
issue's categories from the conversation's items. Prints a table, and
writes the issue's comment template (--markdown) and the raw results
with each conversation (--json).
2026-10-23 13:44:02 +00:00
Jasper van Loenen d1f5d6e223 History: drag features across the marker in one step, clearer drop feedback (#4938)
Dropping a feature just behind the history marker now rolls it back, and
dropping it just before the marker makes it active again, in both the
bottom strip and the left panel. timeline.reorder and timeline.move_many
take an optional `active` param that sets the marker in the same command,
so the move is one undo step and one recompute. A position on the wrong
side of the marker for the requested `active` is refused.

Drop feedback:
- bottom strip: the drop line sits centred between two features (it
  used to touch the previous one) and shows at the first position
  (it used to be drawn outside the strip there)
- left panel: a drop slot below the marker when it is at the end
- no drop line when the marker is dragged back to where it is
- a dragged feature is outlined in the drop line's green
- grab cursor on the marker, grabbing cursor while dragging the marker
  or a feature

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:44:00 +00:00
Logan Night 1b215b521d icon_audit: compare the requested-icons manifest with CRLF checkouts ignored (#4932) (#4960)
* claim: #4932

* icon_audit: compare the requested-icons manifest with CRLF checkouts ignored (#4932)

On Windows, Git for Windows checks out requested_icons.txt with CRLF line
endings (core.autocrlf; the repo has no .gitattributes), while the test
builds the manifest with \n, so the comparison fails although the lists
are the same. Read the file with \r\n normalised to \n, as #1931 did for
the icon tools. Regenerating still writes \n.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:43:56 +00:00
raykholo 36a354e3a7 Agent chat: serve the bundled skills' reference through an MCP tool (#4966) (#4970)
* claim: #4966

* Agent chat: serve the bundled skills' reference through an MCP tool (#4966)

The bundled skills start with "read reference.md", but SolidCraft's own
chat turns the agent's file tools off, so its agent could not follow them.

The MCP server gains a read-only `skill_reference` tool that returns a
skill's documents from copies built into the binary. It takes a skill
name and a file name from a fixed list, never a path, and refuses an
unknown skill or file with the available ones. The skills point to it,
and docs/mcp.md and skills/README.md describe it.
2026-10-23 13:43:54 +00:00
Taha Zarif a05a0f1053 Fix/mcp stdio bounded request lines (#4972)
* test(mcp): reproduce whitespace bypass of request-size limit

Add a source-level regression that rejects a JSON-RPC request larger than
MAX_LINE even when most of it is whitespace, plus bounded-stdio framing
coverage and invalid UTF-8 recovery.

* fix(mcp): bound stdio line buffering and validate raw request size

Consume oversize JSON-RPC lines in bounded memory while keeping the
following request available. Reject whitespace-padded lines before
trimming, preventing a request-size cap bypass.

* docs(mcp): keep server framing comment idiomatic
2026-10-23 13:43:52 +00:00
HiroKenEz 60efbe39e3 Sketch drag: a linkage with redundant parallelogram constraints still moves (#4585) (#4983)
* claim: #4585

* Sketch drag: a linkage held by redundant parallelograms still moves (#4585)

Cursor motion looks for the sketch's freedom in the null space of the
constraint Jacobian. Redundant constraints (a parallelogram held both
parallel and equal on each pair of sides) are dependent only at an exact
solution; after a correction to the solver's tolerance their rows differ
by about 1e-9, which the 1e-9 pivot tolerance counted as rank, so the
null space came out empty and a one-DoF linkage stayed locked. Use the
rank analysis' 1e-7, so the drag sees the freedom the sketch reports.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:43:49 +00:00
raykholo 9e20a373ca Browser: Shift+click above the anchor selects the whole range (#4991) (#4997)
* claim: #4991

* Browser: Shift+click above the anchor selects the whole range (#4991)

The browser rebuilds its row order while drawing, and a Shift click looked
the anchor up in that half-built list, so an anchor further down was not
there yet and only the clicked row was selected. Resolve the range against
the previous frame's complete row order instead.
2026-10-23 13:43:47 +00:00
Daniel 2f55a39c9c Timeline: delete the selected features, Shift+click ranges, delete after the history marker (#5005) (#5006)
* claim: #5005

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

* Timeline: delete the selected features, Shift+click ranges, delete after the history marker (#5005)

Delete in a feature's menu deletes the selected features when the
right-clicked one is among them ("Delete 3 Selected Features").
Shift+click selects the range from the last feature clicked (or the last
selected one), in the order taken before drawing. The menu of a feature
after the history marker deletes every feature after the marker, with
their number in the label. All of it goes through delete_features: one
timeline.delete and one undo step. The confirmation no longer lists
features that are themselves being deleted.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:43:45 +00:00
Jackson Oursland 80fd2fbbf1 Assistant: add Anthropic-compatible API agents (#5015) (#5017)
* claim: #5015

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

* Assistant: add Anthropic-compatible API agents (#5015)

The Add agent wizard now offers Anthropic-compatible endpoints beside OpenAI-compatible ones. The API backend speaks the Anthropic Messages protocol with the same MCP bridge, tool gate, limits, undo and keychain rules: x-api-key and anthropic-version, a top-level system note, input_schema tools, content-block answers and one user tool_result message per round. Test sends a short message because compatible endpoints (Qwen's) need not list models.

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

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
2026-10-23 13:43:41 +00:00
raykholo c3d1ce8237 Opening a design: range-check integer-valued coordinates too (#5021) (#5026)
* claim: #5021

* Opening a design: range-check integer-valued coordinates too (#5021)

The file-level number check skips every integer-valued number so that ids
and counters may be large, which let a coordinate such as
9223372036854775808 through. The typed design is now checked as well:
float fields (coordinates, sizes) are serialized as floats and must be
within the same +-1e9 limit, while integer fields stay integers and are
not limited.
2026-10-23 13:43:40 +00:00
raykholo 567bc50723 Snap fit: refuse sizes too small to build (#5022) (#5027)
* claim: #5022

* Plastic features: refuse sizes under 0.01 mm (#5022)

A snap fit with a near-zero thickness (1e-9 mm) got past the size checks,
which only asked for a positive number, and panicked in the kernel's
intersection code. The boss, lip, groove and rest had the same gap.

Every length of a plastic feature that becomes geometry (snap fit length,
thickness, width, catch_depth and catch_length; boss diameter, height,
hole_diameter, hole_depth and rib sizes; lip and groove width and height;
rest width, length, height and thickness) must now be at least 0.01 mm.
The check sits where the feature is evaluated, so a parameter changed later
is refused the same way, and the message names the parameter.
2026-10-23 13:43:38 +00:00
raykholo 1a5fcd05d6 Extrude and loft: refuse duplicate profile indexes (#5023) (#5028)
* claim: #5023

* Extrude and loft: refuse repeated profile indexes (#5023)

`profiles: [0, 0, ...]` with 10 000 copies built 10 000 identical bodies in
about 35 s, and a loft section listing one profile 10 000 times took 25 s
before it was refused.

A list of profile indexes now refuses a repeated index while it is parsed
("profile 0 is listed twice"), as offset association ids already were.
Before the profiles are resolved, a selection naming more profiles than its
sketch has, an index the sketch doesn't have, or two points in the same
profile is refused too. A design file that repeats an index still opens,
using the profile once.
2026-10-23 13:43:36 +00:00
Jackson Oursland 3a6153d18f Add .vscode to .gitignore. (#5588) 2026-10-23 12:49:05 -04:00
Cyryl Sochacki d273c45de6 i18n(pl): translate 213 missing messages (#5031)
Catch up with en.json: the new Preferences pages, assistant settings,
3D mouse axis assignment, bug report form, history names and the tool
help texts. pl now covers every message of en.json.
2026-10-23 13:43:32 +00:00
dudedagger42 da46c660df Coincident on two circles lays the second on the first (#5038)
Coincident with two circles (or arcs) picked was refused: "two curves must both
be straight lines; otherwise choose a point and a curve". It now lays the second
pick on the first: Concentric and Equal together, in one step, with the first
staying put. A pair already concentric or equal gets only the other half; one
already lying on the other is refused as nothing to add.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:43:30 +00:00
vdirienzo 92d1331978 A renamed body keeps its number: the next body does not take the freed name (#4957) (#5053)
* A renamed body keeps its number (#4957)

The design's body counter starts over at every evaluation, so a body
renamed after a join (the join changed it, so the rename cannot be
followed without evaluating the design again) gave its number back: the
next body took the name the rename freed, and a script that names a body
found another one.

The design now saves the names a rename freed, and the counter skips
them.

* A renamed body keeps its number (#4957)

The design's body counter starts over at every evaluation, so a body
renamed after a join (the join changed it, so the rename cannot be
followed without evaluating the design again) gave its number back: the
next body took the name the rename freed, and a script that names a body
found another one.

The design now saves the names a rename freed, and the counter skips
them.

* Format
2026-10-23 13:43:28 +00:00
dudedagger42 01bbc0aa9f Parameters: a favourite renames by double-click (#4952) (#5070)
A favourite is listed twice, under Favourites and under User. Double-clicking its name opened a
name editor in both rows, under one widget id; the two took each other's focus, one lost it at
once, and the rename closed with the name unchanged. The rename now opens only in the table it
was started in, under an id that includes that table.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:43:26 +00:00
raykholo dbbad6d4ee view.apply: keep the camera's roll, and refuse an up axis (#5087) (#5094)
* claim: #5087

* view.apply: keep the camera's roll, and refuse an up axis (#5087)

An explicit camera was read as a named view, which has no roll or up, so
roll was silently reset to 0 and up was ignored. Read roll as a finite
number in radians, clamped to ±π, and refuse up with a message saying the
up axis comes from the design settings.
2026-10-23 13:43:24 +00:00
raykholo 4f69e6540c Dragging a docked dialog no longer highlights the view under it (#5086) (#5095)
A docked dialog is placed from the previous frame's drag offset, so a fast
drag can leave the pointer outside the dialog, over the model. The viewport
then treated the pointer as hovering the model and highlighted faces and
edges under it.

drag_dock now marks the frame in egui temp data while its title band is
dragged, and navigation::handle treats the pointer as not hovering the view
while that mark is current and a button is held. This covers every dialog
docked through drag_dock.
2026-10-23 13:43:23 +00:00
ivisaf 55608665ef gpu: open the OpenGL regression device without indirect-call validation (#5101)
NVIDIA's EGL answers wgpu's request for a GL 3.3 context with exactly 3.3
(GLSL 3.30) yet lists GL_ARB_compute_shader. wgpu-hal then reports compute
shaders, wgpu-core builds its indirect-call validation pipeline, naga cannot
emit compute GLSL below 4.30, and request_device fails as Device(Lost).
The test draws nothing indirect, so it opts out of that validation.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:43:20 +00:00
raykholo 0fb4a3e868 GPU tessellation: an empty plan no longer fails buffer validation (#5049) (#5104)
* claim: #5049

* GPU tessellation: an empty plan no longer fails buffer validation (#5049)

Empty tessellation inputs were padded to 16 bytes, smaller than the
shader's 32-byte vertex and 48-byte surface records, so wgpu refused the
dispatch. Skip the dispatch when there is nothing to evaluate, and pad
each empty buffer to one whole element of what the shader reads.
2026-10-23 13:43:18 +00:00
Christian Hammacher 2dc31a30d4 Extract to File: move a component into its own linked design (#5113)
The component's sketches, features, bodies, subcomponents, the joints among
them and the parameters they use move to a new .solidcraft design, keeping
their ids and names, and the component becomes linked to it as Insert Design
makes it. The new design must build the same bodies and the assembly must
look the same afterwards, else nothing changes and no file is written. Joint
faces on the component are found again on the linked bodies, and components
that build the same bodies can become placements of the link.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:43:16 +00:00
sthifen 8b68eb7eac fix(extrude): a sketch on an inserted part joins and cuts it, through all too (#5115)
A STEP file inserted into a new design sits in its own component under the
empty root, and a sketch on its face must join that part going out and cut it
going in rather than make a separate body beside it. With a distance, main
already resolves this; Through All with no distance gave `auto` no reach to
probe, so it fell back to New Body and cut nothing. Through All now reaches
past every body in its direction, and `auto` reads it as a cut (#1234).

Co-authored-by: Steven <steven2021067613@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:43:14 +00:00
bherbruck 96679f3e2b Themes: drop the logo's fourth colour, which #5465 retired (#5548)
The 14 new built-in themes still listed four logo colours, so none of them read after #5465 and main's theme test failed.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 11:39:30 -04:00
froglemonade2 3ba17bb517 App icon: draw the borderless mark everywhere (#5461) (#5465)
* claim: #5461

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

* App icon: draw the borderless mark everywhere (#5461)

app_icon.rs drew the older mark with white lines between the faces and a
white outline around it, so the Windows, Linux, web and README icons did
not match the macOS icon, the social card or the title bar. It now draws
the borderless mark from the faces and gradients of the macOS artwork
(03-solid-blue-cutout.svg): five faces with transparent gaps between them.
The filler fills even-odd spans so the concave faces work.

The Linux hicolor PNGs and both Windows .ico files are regenerated with
`cargo xtask app-icons`. The README logo, the web app's icons and every
package pick them up from there.

The logo colour token's fourth entry (the white edge) is gone: nothing
draws it any more.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:40:09 -04:00
N3MI-DG ae3b36ac88 Agent cursor follows the agent's own pointer input (ui.move, ui.click, ui.drag, ui.scroll) (#5162)
The agent cursor only acted out engine commands: pointer input sent over the control channel
reached the app as synthetic events without moving it, so an agent's clicks, right-clicks and
drags were invisible to a person watching. While the cursor is shown, each synthetic pointer event
now moves it to where the input lands, a press leaves a ripple, and a drag is followed step by
step. A request being acted out keeps the pointer; with the cursor hidden nothing changes.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:42:59 +00:00
raykholo 5367ef4367 Extrude without a profile selection keeps holes in nested loops (#5172) (#5176)
* claim: #5172

* Extrude without profiles keeps nested loops as holes (#5172)

With no `profiles`, solid.extrude took every region of the sketch, so
circles drawn inside a plate outline were extruded as well and filled the
plate's holes. The default now applies even-odd nesting to the drawn
profiles: a loop inside an outline is its hole, a loop inside that hole is
material again, and separate outlines all extrude. Sketches without nesting
keep the `all` selection, and explicit `profiles` (including "all") are
unchanged. Saved designs store their selection, so they recompute as before.
2026-10-23 13:42:55 +00:00
raykholo caa1e6e196 component.create refuses parameters it does not take (#5174) (#5178)
* claim: #5174

* component.create refuses parameters it does not take (#5174)

A call with `bodies` (or a misspelled key) used to succeed and make an empty
component. Unknown keys are now refused before anything changes, the error
lists the accepted parameters, and `bodies` points to component.move_bodies
and component.from_bodies.
2026-10-23 13:42:53 +00:00
raykholo 8bd17cea42 Joints: a joint origin's name given as a face says to pass it as the origin (#5175) (#5179)
* claim: #5175

* Joints: a joint origin's name given as a face says to pass it as the origin (#5175)

When the name in `face` or `circle` matches no face but is a joint origin,
the error now says so instead of "no face is named".
2026-10-23 13:42:51 +00:00
takuto-NA 4256775274 Joint: avoid redundant analysis and residual placement work (#5185) (#5186)
* claim: #5185

* Joint: reuse residual placements and skip discarded mobility reports (#5185)
2026-10-23 13:42:49 +00:00
Jackson Oursland 5962f56f87 Viewport: reuse still pictures and draw scenes into a 1x window pass (#4820) (#5187)
* Viewport: show the picture it drew last when nothing changed (#4820)

A frame redrew the whole scene whenever egui asked for a frame, even when
the camera, scene, highlight and overlays were identical: pointer moves over
empty space and UI animations over a still view paid for the full scene.

The renderer now hashes everything the picture depends on (scene keys,
camera, size, section, images, analysis, accessibility map, occlusion) and
blits the last offscreen picture when the hash matches. The first still
frame filters a supersampled picture down once and later frames copy it as
it is. A frame with the same content costs a blit instead of a scene draw.

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

* Viewport: draw the scene offscreen, into a 1x window pass (#4820)

The scene keeps the app's depth, stencil and MSAA, but egui's window pass now
has one sample and no depth. Whenever the two targets differ, the scene
(product previews too) is drawn offscreen and blitted, so the UI pass no
longer clears and resolves a multisampled window.

Over 8 s of pointer moves on the 450E rotor head, the app's GPU intervals fell
from 1325 ms to 1006 ms and egui_render from 892 ms to 509 ms; idle stays at
0%. A GPU test checks the split path's picture matches the direct one.

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

* 3D mouse: a repeated report at rest asks for no frame (#4820)

A device may repeat its latest report while the cap rests. Publishing it
again asked egui for a frame although nothing changed. Only a report that
changes the reading now asks for one; while the cap moves, the view asks for
the next frame itself.

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

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
2026-10-23 13:42:47 +00:00
github-actions[bot] b6a45b8278 Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci] 2026-10-23 14:19:26 +00:00
N3MI-DG 8ae90f9667 Home: a recent design's location shows in one tooltip, not two (#5208)
The File view's recent row already shows its path on hover; Home added
a second hover text with the raw path, so both appeared.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:42:43 +00:00
William Dunn 17f0d6bc68 Format registry: one table of formats, extensions and options (#5215) (#5217)
* Add the format registry: one table of formats, extensions and options in crates/io.

The hosts' Open-dialog filters and the web accept list derive from it, and
registry tests pin it to the path predicates and the command docs.

* Fix format-registry tests that compared FormatSpec without Eq.

assert_eq on Option<&FormatSpec> does not compile; the none cases
only need is_none, matching the other rows that compare ids.
2026-10-23 13:42:40 +00:00
raykholo 8b1a722f06 Viewport: the ground shadow no longer sends every frame through the transparency path (#5230) (#5255)
* claim: #5230

* Viewport: draw the ground shadow on the direct path (#5230)

The ground shadow went into the model's translucent triangles, so every
frame with a body counted as see-through and took the order-independent
transparency path. Give it its own batch, drawn alpha-blended after the
opaque geometry as before, so only truly see-through bodies use that path.
2026-10-23 13:42:38 +00:00
takuto-NA 63d81cc096 Motion Trace: regenerate kept curves only when inputs change (#5248) (#5270)
* claim: #5248

* feat(motion): regenerate kept traces when source inputs change

* feat(motion): add a parametric Chebyshev trace demo

* perf(joints): reuse face measurements across motion samples

Face-centred joint origins called Body::faces on every solve, bypassing the
reference-mesh reuse used for rigid placements. Share face measurements with
unchanged model geometry at the existing tolerance, while guarding replaced
bodies and applying offsets after the cached measurements.

Cover repeated named/nearest-face solves, shape edits, retained snapshots and
direct replacement with deterministic work counts. The 145-point Chebyshev
dimension and range updates retain identical sampled and interpolated curves.

* perf(naming): skip preparation for cached face names

Both face lookup paths spawned fresh workers over every body even when their name cells were ready. Prepare only cold cells, with direct handling of zero or one cold body. Preserve the existing parallel naming path and all face selection semantics.

Deterministic regressions cover repeated picked and unique-name lookups, misses, partial warmup, renames, snapshots and moved copies. Release Chebyshev medians fall from 859 to 50 ms for a dimension edit and 871 to 34 ms for a sweep edit; all 145 samples and interpolated points remain identical. Full cargo xtask ci passes with RUST_TEST_THREADS=4 and the existing local Windows validation overlays.
2026-10-23 13:42:36 +00:00
raykholo 46a8f86e80 web-offline-test: wait only for the files the browser can ask for at once (#5287) (#5294)
* claim: #5287

* web-offline-test: wait only for the files the browser can ask for at once

The fail-fast step waited for every other file of build v2 to reach the
server. Chrome sends at most six requests to one server at a time, and
the page's failure cancels the download, with it the files still waiting
for a connection. A real build keeps nine files, so only six were ever
asked for and the Pages job failed with "only 6 of 8 slow files were
asked for" whenever the installing worker didn't happen to ask again.

Wait for as many as can be in flight (less the page's connection), and
give the stand-in app more files than that, so the default run covers it.
2026-10-23 13:42:33 +00:00
Mateusz Sych 94279fe059 Web: Ctrl+Tab and Ctrl+Shift+Tab switch browser tabs again (#5227) (#5350)
* claim: #5227

* Web: leave Ctrl+Tab and Ctrl+Shift+Tab to the browser (#5227)

eframe cancels the default action of every Tab keydown on its canvas
(egui moves focus with Tab), so browsers that let a page cancel their
shortcuts, such as Vivaldi, could not switch tabs while SolidCraft had
the keyboard. A capture listener on the window now stops Ctrl+Tab and
Ctrl+Shift+Tab before they reach the canvas, without cancelling them,
so the browser acts on them as usual. Plain Tab and Shift+Tab still
move between the app's fields. Desktop builds are unchanged.

web-api-test checks it in headless Chrome: Ctrl+Tab and Ctrl+Shift+Tab
keydowns are not cancelled, Tab still is.

---------

Co-authored-by: MateuszSychXpl <151755914+MateuszSychXpl@users.noreply.github.com>
2026-10-23 13:42:27 +00:00
raykholo 867df396ea Agent chat in the Flatpak says why no agent is found and how to allow one (#4765) (#4769)
* claim: #4765

* Agent chat: explain the Flatpak sandbox when no agent is found (#4765)

In the Flatpak the sandbox has no home-folder or network access, so agents
installed through nvm or ~/.local/bin are never discovered and the chat only
said that no agent was found. With FLATPAK_ID set and nothing discovered, the
picker and Preferences > AI now say why and offer the exact
`flatpak override --user ai.storyteller.solidcraft ...` command with a Copy
button. The message is in every catalog, and docs/agent-chat.md gains a
Flatpak section listing what each permission is for. The manifest is
unchanged.
2026-10-23 13:42:14 +00:00
JEREMY 858e5995d6 Plane at Angle: a planar face is a base plane (#1939) (#1956)
Co-authored-by: jeremymiribung-blip <jeremymiribung-blip@users.noreply.github.com>
2026-10-23 11:32:24 +00:00
bherbruck edf57cec29 Stand up Y-up STEP and IGES files from SolidWorks automatically (#5191)
* claim: #5150

* Stand it up: don't offer it for SolidCraft's own STEP files (#5150)

STEP replies from file.insert_step and doc.open now carry the file's
originating system, and the window skips the stand-up offer when it is
exactly SolidCraft, as SolidCraft's STEP export writes it.

* Stand up Y-up STEP and IGES files from SolidWorks automatically

The STEP header (originating system, preprocessor version, FILE_DESCRIPTION) and the IGES
global section's native system ID say which program wrote a file. A SolidWorks header means
Y up: the import's component is stood up as it opens, as one undo step, and the message offers
Undo. SolidCraft's own files are left alone; an unknown exporter keeps the offer.

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

---------

Co-authored-by: Ray <raykholo@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 02:53:20 +00:00
raykholo 0c72ad56d2 An idle window draws nothing with the frame-rate readout on (#5165)
The viewport's frame-rate readout asked for a new frame every frame, so with
it on an untouched window redrew as fast as the display allowed and kept the
GPU busy: 77% GPU at 60 fps on an M1 with a 5K display showing the sample,
against 0% with the readout off. It now asks for no frames of its own and
shows how often the view really draws; one frame a little over a second after
the frames stop shows the view idle at 0 FPS. The preference's note no longer
says the view redraws continuously.

The status toast also asked for a frame every half second for the six
seconds it shows; it now asks for one frame, when it is due to go.
2026-10-23 02:19:37 +00:00
raykholo 92d3a2fde5 GPU: a lost device stops drawing instead of crashing (#4172) (#4229)
* claim: #4172

* GPU: a lost device stops drawing instead of crashing (#4172)

After the driver resets the GPU (Windows TDR), the first queue write to meet the
lost device panicked inside wgpu-core (PendingWrites::activate unwrapping
DeviceError::Lost). Nothing watched the device for its loss.

- egui-wgpu: RenderState::device_lost (DeviceLost) is set by the device's lost
  callback. The painter draws nothing on a lost device, and before a frame's
  uploads it submits nothing (DeviceLost::probe), so a loss the driver reports
  is found by that submission instead of the first write.
- Pictures (thumbnails of saves, ui.render) watch the same flag: on a lost
  device the capturer refuses with GpuError::DeviceLost without touching it,
  and the picture comes from the CPU rasterizer. Headless capturers watch
  their own devices.
- The app, seeing the loss, closes keeping unsaved changes for recovery (as a
  forced quit does) and starts again; the new start says what happened. A
  second loss within ten minutes starts on the software renderer. The loss is
  noted in the crash journal.
2026-10-23 02:19:35 +00:00
Jackson Oursland b0048a6104 Mesh bodies: measure their size once, when made (#4662) (#4663)
`Body::size` walked a mesh body's vertices on every call, and it is asked
for every frame. A mesh body's triangles never change after `mesh_body` or
`mesh_transformed` makes it, so both now measure it then (`TriMesh::span`)
and `size` returns it like a B-rep body's. `ModelBody::mesh` returns a mesh
it already has before working out the display tolerance.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 21:51:40 -04:00
raykholo 0db2abbeca Viewport legend and readouts stay above the floating History card (#4107) (#4129)
* claim: #4107

* Keep bottom viewport overlays above the floating History card (#4107)

In the floating layout the History card covered the analysis legend and
the frame-rate readout (#3896). One bottom inset, computed from the
History card (zero when History is hidden or docked), now positions every
bottom-anchored viewport overlay: the surface-analysis and Continuity
legends, the frame-rate readout, the command-progress chip and the
navigation bar fallback.
2026-10-23 00:48:27 +00:00
bherbruck 9761d75699 Release: a failed snap build no longer holds up the release (#5168)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 20:50:38 -04:00
252 changed files with 13810 additions and 1183 deletions
+2
View File
@@ -203,6 +203,8 @@ jobs:
name: Snap ${{ matrix.arch }}
needs: [version, linux]
if: needs.version.outputs.build == 'true'
# The Snap Store hasn't accepted SolidCraft yet, so a snap failure never holds up a release.
continue-on-error: true
# Repackages the Linux job's tarball, like the Flatpak. On 24.04 because the core24 base is
# Ubuntu 24.04, which lets snapcraft build on the runner itself (--destructive-mode).
runs-on: ${{ matrix.runner }}
+1
View File
@@ -7,6 +7,7 @@
/.claude/worktrees/
/target-snapshot
__pycache__/
.vscode/
# cargo-mutants output (docs/mutation-testing.md)
mutants.out*/
+2 -1
View File
@@ -14,6 +14,7 @@ Editable drawing code, native projects and shipped exports are collected under `
| File | Author | Source | Licence |
|---|---|---|---|
| examples/chebyshev-motion-trace.json | SolidCraft contributors | Original parametric Chebyshev lambda mechanism and associative-trace demo, constructed from analytic circle intersections; classical link proportions documented at https://doi.org/10.1002/rcs.70095; no external CAD geometry or artwork | MIT OR Apache-2.0 |
| examples/render-studio.json | SolidCraft contributors | original procedural box/cylinder enclosure script for the integrated Render workspace | MIT OR Apache-2.0 |
| examples/render-opaque-studio.json | SolidCraft contributors | original procedural sphere study varying opaque metal fraction and roughness independently; no external assets or engineering data | MIT OR Apache-2.0 |
| crates/gpu/src/pbr.rs | SolidCraft contributors | original WGSL translation of SolidCraft's opaque surface and fixed studio reference, embedded as Rust source; no external shader code | MIT OR Apache-2.0 |
@@ -107,7 +108,7 @@ Editable drawing code, native projects and shipped exports are collected under `
| assets/icons/app/linux/exports/hicolor/48x48/apps/ai.storyteller.solidcraft.png | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
| assets/icons/app/linux/exports/hicolor/512x512/apps/ai.storyteller.solidcraft.png | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
| assets/icons/app/linux/exports/hicolor/64x64/apps/ai.storyteller.solidcraft.png | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
| assets/icons/app/shared/source/app_icon.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/icons/app/shared/source/app_icon.rs | SolidCraft contributors | Original SolidCraft drawing code: the borderless mark, drawn from the faces and gradients of assets/icons/app/macos/source/SolidCraft.icon/Assets/03-solid-blue-cutout.svg | MIT OR Apache-2.0 |
| assets/icons/app/shared/source/windows_ico.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/icons/app/windows/exports/solidcraft-executable.ico | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
| assets/icons/app/windows/exports/solidcraft.ico | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
Generated
+55 -17
View File
@@ -996,6 +996,15 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-channel"
version = "0.5.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.8"
@@ -2271,6 +2280,16 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "keyboard-types"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d"
dependencies = [
"bitflags 2.13.2",
"serde",
]
[[package]]
name = "keyring"
version = "3.6.3"
@@ -2480,6 +2499,24 @@ dependencies = [
"pxfm",
]
[[package]]
name = "muda"
version = "0.21.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4340e89c178916e62879c5c36b7f699019369be413e496178a6983ce91b8bc03"
dependencies = [
"crossbeam-channel",
"dpi",
"keyboard-types",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"png",
"thiserror 2.0.21",
"windows-sys 0.61.2",
]
[[package]]
name = "multiversion_no_op"
version = "1.0.0"
@@ -4162,7 +4199,7 @@ dependencies = [
[[package]]
name = "solidcraft"
version = "0.20.0"
version = "0.21.0"
dependencies = [
"block2 0.6.2",
"eframe",
@@ -4172,6 +4209,7 @@ dependencies = [
"hidapi",
"keyring",
"log",
"muda",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
@@ -4193,11 +4231,11 @@ dependencies = [
[[package]]
name = "solidcraft-3dx-client"
version = "0.20.0"
version = "0.21.0"
[[package]]
name = "solidcraft-cli"
version = "0.20.0"
version = "0.21.0"
dependencies = [
"image",
"log",
@@ -4209,7 +4247,7 @@ dependencies = [
[[package]]
name = "solidcraft-derive"
version = "0.20.0"
version = "0.21.0"
dependencies = [
"proc-macro2",
"quote",
@@ -4218,7 +4256,7 @@ dependencies = [
[[package]]
name = "solidcraft-doc"
version = "0.20.0"
version = "0.21.0"
dependencies = [
"log",
"serde",
@@ -4235,7 +4273,7 @@ dependencies = [
[[package]]
name = "solidcraft-engine"
version = "0.20.0"
version = "0.21.0"
dependencies = [
"log",
"schemars",
@@ -4254,7 +4292,7 @@ dependencies = [
[[package]]
name = "solidcraft-geom"
version = "0.20.0"
version = "0.21.0"
dependencies = [
"serde",
"serde_json",
@@ -4263,7 +4301,7 @@ dependencies = [
[[package]]
name = "solidcraft-gpu"
version = "0.20.0"
version = "0.21.0"
dependencies = [
"log",
"naga",
@@ -4280,7 +4318,7 @@ dependencies = [
[[package]]
name = "solidcraft-io"
version = "0.20.0"
version = "0.21.0"
dependencies = [
"cadmpeg-core",
"cadmpeg-ir",
@@ -4303,7 +4341,7 @@ dependencies = [
[[package]]
name = "solidcraft-kernel"
version = "0.20.0"
version = "0.21.0"
dependencies = [
"log",
"proptest",
@@ -4323,7 +4361,7 @@ dependencies = [
[[package]]
name = "solidcraft-mcp"
version = "0.20.0"
version = "0.21.0"
dependencies = [
"serde_json",
"solidcraft-engine",
@@ -4332,7 +4370,7 @@ dependencies = [
[[package]]
name = "solidcraft-render"
version = "0.20.0"
version = "0.21.0"
dependencies = [
"image",
"moxcms",
@@ -4346,7 +4384,7 @@ dependencies = [
[[package]]
name = "solidcraft-sketch"
version = "0.20.0"
version = "0.21.0"
dependencies = [
"serde",
"serde_json",
@@ -4357,14 +4395,14 @@ dependencies = [
[[package]]
name = "solidcraft-space-mouse"
version = "0.20.0"
version = "0.21.0"
dependencies = [
"serde",
]
[[package]]
name = "solidcraft-ui-egui"
version = "0.20.0"
version = "0.21.0"
dependencies = [
"arboard",
"egui",
@@ -4392,7 +4430,7 @@ dependencies = [
[[package]]
name = "solidcraft-web"
version = "0.20.0"
version = "0.21.0"
dependencies = [
"eframe",
"egui",
@@ -5922,7 +5960,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.20.0"
version = "0.21.0"
dependencies = [
"proc-macro2",
"serde_json",
+1 -1
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["vendor"]
[workspace.package]
version = "0.20.0"
version = "0.21.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
+698 -22
View File
@@ -1,6 +1,6 @@
Third-party software in SolidCraft
SolidCraft (the desktop app, solidcraft-cli and the web app) is built from the 560 crates below.
SolidCraft (the desktop app, solidcraft-cli and the web app) is built from the 563 crates below.
Generated by `cargo xtask licences` from Cargo.lock and the crates' own licence files.
SolidCraft's own licence is in LICENSE-MIT and LICENSE-APACHE, and NOTICE.
@@ -95,6 +95,7 @@ core_detect 1.0.0 (MIT/Apache-2.0)
cpufeatures 0.2.17 (MIT OR Apache-2.0)
cpufeatures 0.3.1 (MIT OR Apache-2.0)
crc32fast 1.5.2 (MIT OR Apache-2.0)
crossbeam-channel 0.5.17 (MIT OR Apache-2.0)
crossbeam-deque 0.8.8 (MIT OR Apache-2.0)
crossbeam-epoch 0.9.21 (MIT OR Apache-2.0)
crossbeam-utils 0.8.23 (MIT OR Apache-2.0)
@@ -210,6 +211,7 @@ jni-sys 0.4.1 (MIT OR Apache-2.0)
jni-sys-macros 0.4.1 (MIT OR Apache-2.0)
js-sys 0.3.106 (MIT OR Apache-2.0)
katexit 0.1.5 (MIT OR Apache-2.0)
keyboard-types 0.8.3 (MIT OR Apache-2.0)
keyring 3.6.3 (MIT OR Apache-2.0)
khronos-egl 6.0.0 (MIT/Apache-2.0)
kurbo 0.13.1 (Apache-2.0 OR MIT)
@@ -234,6 +236,7 @@ minisign-verify 0.3.0 (MIT)
miniz_oxide 0.8.9 (MIT OR Zlib OR Apache-2.0)
miniz_oxide 0.9.1 (MIT OR Zlib OR Apache-2.0)
moxcms 0.8.1 (BSD-3-Clause OR Apache-2.0)
muda 0.21.2 (Apache-2.0 OR MIT)
multiversion_no_op 1.0.0 (Apache-2.0 OR MIT)
naga 30.0.1 (MIT OR Apache-2.0)
naga-types 30.0.1 (MIT OR Apache-2.0)
@@ -1108,26 +1111,27 @@ ahash 0.8.12, arrayvec 0.7.8, async-channel 2.5.0, async-executor 1.14.0, async-
4.7.1, atomic-waker 1.1.2, base64 0.23.1, bitflags 1.3.2, bitflags 2.13.2, blocking 1.7.0, bumpalo
3.20.3, cfg-if 1.0.5, cgl 0.3.2, concurrent-queue 2.5.0, core-foundation 0.10.1, core-foundation
0.9.4, core-foundation-sys 0.8.7, core-graphics 0.23.2, core-graphics-types 0.1.3, core_detect
1.0.0, crossbeam-deque 0.8.8, crossbeam-epoch 0.9.21, crossbeam-utils 0.8.23, dbus-secret-service
4.1.0, displaydoc 0.2.7, either 1.19.0, equivalent 1.0.2, errno 0.3.14, euclid 0.22.14,
event-listener 5.4.2, event-listener-strategy 0.5.4, fastrand 2.5.0, flate2 1.1.10, form_urlencoded
1.2.2, futures-lite 2.6.1, gethostname 1.1.0, gif 0.14.2, glow 0.17.0, gltf 1.4.1, gltf-derive
1.4.1, gltf-json 1.4.1, hashbrown 0.16.1, hashbrown 0.17.1, hermit-abi 0.5.3, httparse 1.10.1, idna
1.1.0, idna_adapter 1.2.2, indexmap 2.14.2, itertools 0.13.0, itertools 0.15.0, js-sys 0.3.106,
khronos-egl 6.0.0, lazy_static 1.5.1, linux-raw-sys 0.12.1, linux-raw-sys 0.4.15, lock_api 0.4.14,
log 0.4.34, num 0.4.3, num-bigint 0.4.8, num-complex 0.4.6, num-integer 0.1.47, num-iter 0.1.46,
num-rational 0.4.2, num-traits 0.2.19, once_cell 1.21.4, openssl-probe 0.2.1, ordered-stream 0.2.0,
parking 2.2.1, parking_lot 0.12.5, parking_lot_core 0.9.12, percent-encoding 2.3.2, piper 0.2.5,
plain 0.2.3, png 0.18.1, polling 3.11.0, pollster 0.4.0, pollster 1.0.1, rayon 1.12.0, rayon-core
1.13.0, renderdoc-sys 1.1.0, robust 1.2.0, ron 0.12.2, roxmltree 0.21.1, rustc-hash 1.1.0, rustix
0.38.44, rustix 1.1.5, rustls 0.23.46, rustls-native-certs 0.8.5, scoped-tls 1.0.1, scopeguard
1.2.0, secret-service 4.0.0, security-framework 2.11.1, security-framework 3.7.0,
security-framework-sys 2.17.0, signal-hook-registry 1.4.8, simd_cesu8 1.2.0, smallvec 1.16.2,
smol_str 0.2.2, stable_deref_trait 1.2.1, syn 1.0.109, tempfile 3.27.0, ttf-parser 0.25.1,
unicode-segmentation 1.13.3, unicode-width 0.2.2, url 2.5.8, uuid 1.27.0, wasi
0.11.1+wasi-snapshot-preview1, wasip2 1.0.4+wasi-0.2.12, wasm-bindgen 0.2.129, wasm-bindgen-futures
0.4.79, wasm-bindgen-macro 0.2.129, wasm-bindgen-macro-support 0.2.129, wasm-bindgen-shared 0.2.129,
web-sys 0.3.106, weezl 0.1.12, wit-bindgen 0.57.1
1.0.0, crossbeam-channel 0.5.17, crossbeam-deque 0.8.8, crossbeam-epoch 0.9.21, crossbeam-utils
0.8.23, dbus-secret-service 4.1.0, displaydoc 0.2.7, either 1.19.0, equivalent 1.0.2, errno 0.3.14,
euclid 0.22.14, event-listener 5.4.2, event-listener-strategy 0.5.4, fastrand 2.5.0, flate2 1.1.10,
form_urlencoded 1.2.2, futures-lite 2.6.1, gethostname 1.1.0, gif 0.14.2, glow 0.17.0, gltf 1.4.1,
gltf-derive 1.4.1, gltf-json 1.4.1, hashbrown 0.16.1, hashbrown 0.17.1, hermit-abi 0.5.3, httparse
1.10.1, idna 1.1.0, idna_adapter 1.2.2, indexmap 2.14.2, itertools 0.13.0, itertools 0.15.0, js-sys
0.3.106, keyboard-types 0.8.3, khronos-egl 6.0.0, lazy_static 1.5.1, linux-raw-sys 0.12.1,
linux-raw-sys 0.4.15, lock_api 0.4.14, log 0.4.34, muda 0.21.2, num 0.4.3, num-bigint 0.4.8,
num-complex 0.4.6, num-integer 0.1.47, num-iter 0.1.46, num-rational 0.4.2, num-traits 0.2.19,
once_cell 1.21.4, openssl-probe 0.2.1, ordered-stream 0.2.0, parking 2.2.1, parking_lot 0.12.5,
parking_lot_core 0.9.12, percent-encoding 2.3.2, piper 0.2.5, plain 0.2.3, png 0.18.1, polling
3.11.0, pollster 0.4.0, pollster 1.0.1, rayon 1.12.0, rayon-core 1.13.0, renderdoc-sys 1.1.0, robust
1.2.0, ron 0.12.2, roxmltree 0.21.1, rustc-hash 1.1.0, rustix 0.38.44, rustix 1.1.5, rustls 0.23.46,
rustls-native-certs 0.8.5, scoped-tls 1.0.1, scopeguard 1.2.0, secret-service 4.0.0,
security-framework 2.11.1, security-framework 3.7.0, security-framework-sys 2.17.0,
signal-hook-registry 1.4.8, simd_cesu8 1.2.0, smallvec 1.16.2, smol_str 0.2.2, stable_deref_trait
1.2.1, syn 1.0.109, tempfile 3.27.0, ttf-parser 0.25.1, unicode-segmentation 1.13.3, unicode-width
0.2.2, url 2.5.8, uuid 1.27.0, wasi 0.11.1+wasi-snapshot-preview1, wasip2 1.0.4+wasi-0.2.12,
wasm-bindgen 0.2.129, wasm-bindgen-futures 0.4.79, wasm-bindgen-macro 0.2.129,
wasm-bindgen-macro-support 0.2.129, wasm-bindgen-shared 0.2.129, web-sys 0.3.106, weezl 0.1.12,
wit-bindgen 0.57.1
================================================================================
Apache License
@@ -4030,7 +4034,605 @@ Apache License
limitations under the License.
================================================================================
crossbeam-deque 0.8.8, crossbeam-epoch 0.9.21, crossbeam-utils 0.8.23
crossbeam-channel 0.5.17
================================================================================
===============================================================================
matching.go
https://creativecommons.org/licenses/by/3.0/legalcode
Creative Commons Legal Code
Attribution 3.0 Unported
CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
LEGAL SERVICES. DISTRIBUTION OF THIS LICENSE DOES NOT CREATE AN
ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
REGARDING THE INFORMATION PROVIDED, AND DISCLAIMS LIABILITY FOR
DAMAGES RESULTING FROM ITS USE.
License
THE WORK (AS DEFINED BELOW) IS PROVIDED UNDER THE TERMS OF THIS CREATIVE
COMMONS PUBLIC LICENSE ("CCPL" OR "LICENSE"). THE WORK IS PROTECTED BY
COPYRIGHT AND/OR OTHER APPLICABLE LAW. ANY USE OF THE WORK OTHER THAN AS
AUTHORIZED UNDER THIS LICENSE OR COPYRIGHT LAW IS PROHIBITED.
BY EXERCISING ANY RIGHTS TO THE WORK PROVIDED HERE, YOU ACCEPT AND AGREE
TO BE BOUND BY THE TERMS OF THIS LICENSE. TO THE EXTENT THIS LICENSE MAY
BE CONSIDERED TO BE A CONTRACT, THE LICENSOR GRANTS YOU THE RIGHTS
CONTAINED HERE IN CONSIDERATION OF YOUR ACCEPTANCE OF SUCH TERMS AND
CONDITIONS.
1. Definitions
a. "Adaptation" means a work based upon the Work, or upon the Work and
other pre-existing works, such as a translation, adaptation,
derivative work, arrangement of music or other alterations of a
literary or artistic work, or phonogram or performance and includes
cinematographic adaptations or any other form in which the Work may be
recast, transformed, or adapted including in any form recognizably
derived from the original, except that a work that constitutes a
Collection will not be considered an Adaptation for the purpose of
this License. For the avoidance of doubt, where the Work is a musical
work, performance or phonogram, the synchronization of the Work in
timed-relation with a moving image ("synching") will be considered an
Adaptation for the purpose of this License.
b. "Collection" means a collection of literary or artistic works, such as
encyclopedias and anthologies, or performances, phonograms or
broadcasts, or other works or subject matter other than works listed
in Section 1(f) below, which, by reason of the selection and
arrangement of their contents, constitute intellectual creations, in
which the Work is included in its entirety in unmodified form along
with one or more other contributions, each constituting separate and
independent works in themselves, which together are assembled into a
collective whole. A work that constitutes a Collection will not be
considered an Adaptation (as defined above) for the purposes of this
License.
c. "Distribute" means to make available to the public the original and
copies of the Work or Adaptation, as appropriate, through sale or
other transfer of ownership.
d. "Licensor" means the individual, individuals, entity or entities that
offer(s) the Work under the terms of this License.
e. "Original Author" means, in the case of a literary or artistic work,
the individual, individuals, entity or entities who created the Work
or if no individual or entity can be identified, the publisher; and in
addition (i) in the case of a performance the actors, singers,
musicians, dancers, and other persons who act, sing, deliver, declaim,
play in, interpret or otherwise perform literary or artistic works or
expressions of folklore; (ii) in the case of a phonogram the producer
being the person or legal entity who first fixes the sounds of a
performance or other sounds; and, (iii) in the case of broadcasts, the
organization that transmits the broadcast.
f. "Work" means the literary and/or artistic work offered under the terms
of this License including without limitation any production in the
literary, scientific and artistic domain, whatever may be the mode or
form of its expression including digital form, such as a book,
pamphlet and other writing; a lecture, address, sermon or other work
of the same nature; a dramatic or dramatico-musical work; a
choreographic work or entertainment in dumb show; a musical
composition with or without words; a cinematographic work to which are
assimilated works expressed by a process analogous to cinematography;
a work of drawing, painting, architecture, sculpture, engraving or
lithography; a photographic work to which are assimilated works
expressed by a process analogous to photography; a work of applied
art; an illustration, map, plan, sketch or three-dimensional work
relative to geography, topography, architecture or science; a
performance; a broadcast; a phonogram; a compilation of data to the
extent it is protected as a copyrightable work; or a work performed by
a variety or circus performer to the extent it is not otherwise
considered a literary or artistic work.
g. "You" means an individual or entity exercising rights under this
License who has not previously violated the terms of this License with
respect to the Work, or who has received express permission from the
Licensor to exercise rights under this License despite a previous
violation.
h. "Publicly Perform" means to perform public recitations of the Work and
to communicate to the public those public recitations, by any means or
process, including by wire or wireless means or public digital
performances; to make available to the public Works in such a way that
members of the public may access these Works from a place and at a
place individually chosen by them; to perform the Work to the public
by any means or process and the communication to the public of the
performances of the Work, including by public digital performance; to
broadcast and rebroadcast the Work by any means including signs,
sounds or images.
i. "Reproduce" means to make copies of the Work by any means including
without limitation by sound or visual recordings and the right of
fixation and reproducing fixations of the Work, including storage of a
protected performance or phonogram in digital form or other electronic
medium.
2. Fair Dealing Rights. Nothing in this License is intended to reduce,
limit, or restrict any uses free from copyright or rights arising from
limitations or exceptions that are provided for in connection with the
copyright protection under copyright law or other applicable laws.
3. License Grant. Subject to the terms and conditions of this License,
Licensor hereby grants You a worldwide, royalty-free, non-exclusive,
perpetual (for the duration of the applicable copyright) license to
exercise the rights in the Work as stated below:
a. to Reproduce the Work, to incorporate the Work into one or more
Collections, and to Reproduce the Work as incorporated in the
Collections;
b. to create and Reproduce Adaptations provided that any such Adaptation,
including any translation in any medium, takes reasonable steps to
clearly label, demarcate or otherwise identify that changes were made
to the original Work. For example, a translation could be marked "The
original work was translated from English to Spanish," or a
modification could indicate "The original work has been modified.";
c. to Distribute and Publicly Perform the Work including as incorporated
in Collections; and,
d. to Distribute and Publicly Perform Adaptations.
e. For the avoidance of doubt:
i. Non-waivable Compulsory License Schemes. In those jurisdictions in
which the right to collect royalties through any statutory or
compulsory licensing scheme cannot be waived, the Licensor
reserves the exclusive right to collect such royalties for any
exercise by You of the rights granted under this License;
ii. Waivable Compulsory License Schemes. In those jurisdictions in
which the right to collect royalties through any statutory or
compulsory licensing scheme can be waived, the Licensor waives the
exclusive right to collect such royalties for any exercise by You
of the rights granted under this License; and,
iii. Voluntary License Schemes. The Licensor waives the right to
collect royalties, whether individually or, in the event that the
Licensor is a member of a collecting society that administers
voluntary licensing schemes, via that society, from any exercise
by You of the rights granted under this License.
The above rights may be exercised in all media and formats whether now
known or hereafter devised. The above rights include the right to make
such modifications as are technically necessary to exercise the rights in
other media and formats. Subject to Section 8(f), all rights not expressly
granted by Licensor are hereby reserved.
4. Restrictions. The license granted in Section 3 above is expressly made
subject to and limited by the following restrictions:
a. You may Distribute or Publicly Perform the Work only under the terms
of this License. You must include a copy of, or the Uniform Resource
Identifier (URI) for, this License with every copy of the Work You
Distribute or Publicly Perform. You may not offer or impose any terms
on the Work that restrict the terms of this License or the ability of
the recipient of the Work to exercise the rights granted to that
recipient under the terms of the License. You may not sublicense the
Work. You must keep intact all notices that refer to this License and
to the disclaimer of warranties with every copy of the Work You
Distribute or Publicly Perform. When You Distribute or Publicly
Perform the Work, You may not impose any effective technological
measures on the Work that restrict the ability of a recipient of the
Work from You to exercise the rights granted to that recipient under
the terms of the License. This Section 4(a) applies to the Work as
incorporated in a Collection, but this does not require the Collection
apart from the Work itself to be made subject to the terms of this
License. If You create a Collection, upon notice from any Licensor You
must, to the extent practicable, remove from the Collection any credit
as required by Section 4(b), as requested. If You create an
Adaptation, upon notice from any Licensor You must, to the extent
practicable, remove from the Adaptation any credit as required by
Section 4(b), as requested.
b. If You Distribute, or Publicly Perform the Work or any Adaptations or
Collections, You must, unless a request has been made pursuant to
Section 4(a), keep intact all copyright notices for the Work and
provide, reasonable to the medium or means You are utilizing: (i) the
name of the Original Author (or pseudonym, if applicable) if supplied,
and/or if the Original Author and/or Licensor designate another party
or parties (e.g., a sponsor institute, publishing entity, journal) for
attribution ("Attribution Parties") in Licensor's copyright notice,
terms of service or by other reasonable means, the name of such party
or parties; (ii) the title of the Work if supplied; (iii) to the
extent reasonably practicable, the URI, if any, that Licensor
specifies to be associated with the Work, unless such URI does not
refer to the copyright notice or licensing information for the Work;
and (iv) , consistent with Section 3(b), in the case of an Adaptation,
a credit identifying the use of the Work in the Adaptation (e.g.,
"French translation of the Work by Original Author," or "Screenplay
based on original Work by Original Author"). The credit required by
this Section 4 (b) may be implemented in any reasonable manner;
provided, however, that in the case of a Adaptation or Collection, at
a minimum such credit will appear, if a credit for all contributing
authors of the Adaptation or Collection appears, then as part of these
credits and in a manner at least as prominent as the credits for the
other contributing authors. For the avoidance of doubt, You may only
use the credit required by this Section for the purpose of attribution
in the manner set out above and, by exercising Your rights under this
License, You may not implicitly or explicitly assert or imply any
connection with, sponsorship or endorsement by the Original Author,
Licensor and/or Attribution Parties, as appropriate, of You or Your
use of the Work, without the separate, express prior written
permission of the Original Author, Licensor and/or Attribution
Parties.
c. Except as otherwise agreed in writing by the Licensor or as may be
otherwise permitted by applicable law, if You Reproduce, Distribute or
Publicly Perform the Work either by itself or as part of any
Adaptations or Collections, You must not distort, mutilate, modify or
take other derogatory action in relation to the Work which would be
prejudicial to the Original Author's honor or reputation. Licensor
agrees that in those jurisdictions (e.g. Japan), in which any exercise
of the right granted in Section 3(b) of this License (the right to
make Adaptations) would be deemed to be a distortion, mutilation,
modification or other derogatory action prejudicial to the Original
Author's honor and reputation, the Licensor will waive or not assert,
as appropriate, this Section, to the fullest extent permitted by the
applicable national law, to enable You to reasonably exercise Your
right under Section 3(b) of this License (right to make Adaptations)
but not otherwise.
5. Representations, Warranties and Disclaimer
UNLESS OTHERWISE MUTUALLY AGREED TO BY THE PARTIES IN WRITING, LICENSOR
OFFERS THE WORK AS-IS AND MAKES NO REPRESENTATIONS OR WARRANTIES OF ANY
KIND CONCERNING THE WORK, EXPRESS, IMPLIED, STATUTORY OR OTHERWISE,
INCLUDING, WITHOUT LIMITATION, WARRANTIES OF TITLE, MERCHANTIBILITY,
FITNESS FOR A PARTICULAR PURPOSE, NONINFRINGEMENT, OR THE ABSENCE OF
LATENT OR OTHER DEFECTS, ACCURACY, OR THE PRESENCE OF ABSENCE OF ERRORS,
WHETHER OR NOT DISCOVERABLE. SOME JURISDICTIONS DO NOT ALLOW THE EXCLUSION
OF IMPLIED WARRANTIES, SO SUCH EXCLUSION MAY NOT APPLY TO YOU.
6. Limitation on Liability. EXCEPT TO THE EXTENT REQUIRED BY APPLICABLE
LAW, IN NO EVENT WILL LICENSOR BE LIABLE TO YOU ON ANY LEGAL THEORY FOR
ANY SPECIAL, INCIDENTAL, CONSEQUENTIAL, PUNITIVE OR EXEMPLARY DAMAGES
ARISING OUT OF THIS LICENSE OR THE USE OF THE WORK, EVEN IF LICENSOR HAS
BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
7. Termination
a. This License and the rights granted hereunder will terminate
automatically upon any breach by You of the terms of this License.
Individuals or entities who have received Adaptations or Collections
from You under this License, however, will not have their licenses
terminated provided such individuals or entities remain in full
compliance with those licenses. Sections 1, 2, 5, 6, 7, and 8 will
survive any termination of this License.
b. Subject to the above terms and conditions, the license granted here is
perpetual (for the duration of the applicable copyright in the Work).
Notwithstanding the above, Licensor reserves the right to release the
Work under different license terms or to stop distributing the Work at
any time; provided, however that any such election will not serve to
withdraw this License (or any other license that has been, or is
required to be, granted under the terms of this License), and this
License will continue in full force and effect unless terminated as
stated above.
8. Miscellaneous
a. Each time You Distribute or Publicly Perform the Work or a Collection,
the Licensor offers to the recipient a license to the Work on the same
terms and conditions as the license granted to You under this License.
b. Each time You Distribute or Publicly Perform an Adaptation, Licensor
offers to the recipient a license to the original Work on the same
terms and conditions as the license granted to You under this License.
c. If any provision of this License is invalid or unenforceable under
applicable law, it shall not affect the validity or enforceability of
the remainder of the terms of this License, and without further action
by the parties to this agreement, such provision shall be reformed to
the minimum extent necessary to make such provision valid and
enforceable.
d. No term or provision of this License shall be deemed waived and no
breach consented to unless such waiver or consent shall be in writing
and signed by the party to be charged with such waiver or consent.
e. This License constitutes the entire agreement between the parties with
respect to the Work licensed here. There are no understandings,
agreements or representations with respect to the Work not specified
here. Licensor shall not be bound by any additional provisions that
may appear in any communication from You. This License may not be
modified without the mutual written agreement of the Licensor and You.
f. The rights granted under, and the subject matter referenced, in this
License were drafted utilizing the terminology of the Berne Convention
for the Protection of Literary and Artistic Works (as amended on
September 28, 1979), the Rome Convention of 1961, the WIPO Copyright
Treaty of 1996, the WIPO Performances and Phonograms Treaty of 1996
and the Universal Copyright Convention (as revised on July 24, 1971).
These rights and subject matter take effect in the relevant
jurisdiction in which the License terms are sought to be enforced
according to the corresponding provisions of the implementation of
those treaty provisions in the applicable national law. If the
standard suite of rights granted under applicable copyright law
includes additional rights not granted under this License, such
additional rights are deemed to be included in the License; this
License is not intended to restrict the license of any rights under
applicable law.
Creative Commons Notice
Creative Commons is not a party to this License, and makes no warranty
whatsoever in connection with the Work. Creative Commons will not be
liable to You or any party on any legal theory for any damages
whatsoever, including without limitation any general, special,
incidental or consequential damages arising in connection to this
license. Notwithstanding the foregoing two (2) sentences, if Creative
Commons has expressly identified itself as the Licensor hereunder, it
shall have all rights and obligations of Licensor.
Except for the limited purpose of indicating to the public that the
Work is licensed under the CCPL, Creative Commons does not authorize
the use by either party of the trademark "Creative Commons" or any
related trademark or logo of Creative Commons without the prior
written consent of Creative Commons. Any permitted use will be in
compliance with Creative Commons' then-current trademark usage
guidelines, as may be published on its website or otherwise made
available upon request from time to time. For the avoidance of doubt,
this trademark restriction does not form part of this License.
Creative Commons may be contacted at https://creativecommons.org/.
===============================================================================
The Go Programming Language
https://golang.org/LICENSE
Copyright (c) 2009 The Go Authors. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
* Neither the name of Google Inc. nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
===============================================================================
The Rust Programming Language
https://github.com/rust-lang/rust/blob/master/LICENSE-MIT
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
===============================================================================
The Rust Programming Language
https://github.com/rust-lang/rust/blob/master/LICENSE-APACHE
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
================================================================================
crossbeam-channel 0.5.17, crossbeam-deque 0.8.8, crossbeam-epoch 0.9.21, crossbeam-utils 0.8.23
================================================================================
The MIT License (MIT)
@@ -7716,6 +8318,30 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================================================
keyboard-types 0.8.3
================================================================================
Copyright (c) 2017 Pyfisch
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
================================================================================
keyring 3.6.3
================================================================================
@@ -9452,6 +10078,56 @@ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
================================================================================
muda 0.21.2
================================================================================
MIT License
Copyright (c) 2022-2022 Tauri Programme within The Commons Conservancy
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================================================
muda 0.21.2
================================================================================
SPDXVersion: SPDX-2.1
DataLicense: CC0-1.0
PackageName: muda
DataFormat: SPDXRef-1
PackageSupplier: Organization: The Tauri Programme in the Commons Conservancy
PackageHomePage: https://tauri.app
PackageLicenseDeclared: Apache-2.0
PackageLicenseDeclared: MIT
PackageCopyrightText: 2020-2022, The Tauri Programme in the Commons Conservancy
PackageSummary: <text>Menu Utilities for Desktop Applications.
</text>
PackageComment: <text>The package includes the following libraries; see
Relationship information.
</text>
Created: 2022-12-05T09:00:00Z
PackageDownloadLocation: git://github.com/tauri-apps/muda
PackageDownloadLocation: git+https://github.com/tauri-apps/muda.git
PackageDownloadLocation: git+ssh://github.com/tauri-apps/muda.git
Creator: Person: Daniel Thompson-Yvetot
================================================================================
multiversion_no_op 1.0.0
================================================================================
+700
View File
@@ -0,0 +1,700 @@
//! `bench-assistant`: the standard Assistant model test (v1, #4851), run against a desktop app.
//!
//! The app is already running with `--control PORT`; everything goes through its control channel
//! (`docs/control-protocol.md`), so the command needs no window code of its own. For each task it
//! starts a new design, runs the task's prescribed setup (a box, for some), adds the endpoint as an
//! API agent with All permissions, starts a new chat, sends the prompt once and waits for the turn
//! to end. The result is checked on the design (`document.inspect` with measurements), never on
//! what the model says, except for the one question whose answer is the reply. A failed task is
//! put in one of the issue's failure categories from the conversation's items.
//!
//! ```text
//! solidcraft-cli bench-assistant --connect 127.0.0.1:PORT --base-url URL --model NAME
//! [--token T] [--tasks 1,2,5] [--repeat N] [--timeout-s 300]
//! [--json out.json] [--markdown out.md]
//! ```
//! prints a table of the results; `--markdown` writes the issue's comment template filled in,
//! `--json` every task's rules, conversation and design before and after.
use std::io::{BufRead, BufReader, Write};
use std::net::{SocketAddr, TcpStream};
use std::time::{Duration, Instant};
use serde_json::{Value, json};
/// Standard test v1, as #4851 states it. Changing a prompt or a rule makes it another test.
const TASKS: &str = r#"[
{"id": "01-cube", "prompt": "Make a 20 mm cube.", "check": {"bodies": 1, "volume": 8000, "bbox_size": [20, 20, 20]}},
{"id": "02-cylinder", "prompt": "Make a cylinder 30 mm in diameter and 50 mm tall.", "check": {"bodies": 1, "volume": 35342.92, "bbox_size": [30, 30, 50]}},
{"id": "03-block", "prompt": "Create a block 60 mm long, 40 mm wide and 10 mm thick.", "check": {"bodies": 1, "volume": 24000, "bbox_size": [10, 40, 60]}},
{"id": "04-sketch-extrude", "prompt": "Sketch a 50 by 30 mm rectangle on the XY plane and extrude it 15 mm.", "check": {"bodies": 1, "volume": 22500, "bbox_size": [15, 30, 50], "feature_kinds": ["Extrude"]}},
{"id": "05-plate-hole", "prompt": "Make a plate 80 x 60 x 10 mm with a 20 mm diameter hole straight through the middle.", "check": {"bodies": 1, "volume": 44858.41, "bbox_size": [10, 60, 80], "note": "80*60*10 - pi*10^2*10"}},
{"id": "06-fillet-box", "prompt": "Round all the edges of the box with a 3 mm radius.", "setup": [{"command": "solid.box", "params": {"length": 40, "width": 40, "height": 40}}], "check": {"bodies": 1, "volume": 63108.53, "tol": 0.003, "feature_kinds": ["Fillet"], "note": "all 12 edges of a 40 mm cube rounded r=3"}},
{"id": "07-move-body", "prompt": "Move the box 50 mm along the X axis.", "setup": [{"command": "solid.box", "params": {"length": 20, "width": 20, "height": 20}}], "check": {"bodies": 1, "volume": 8000, "bbox_min": [50, 0, 0]}},
{"id": "08-rename-body", "prompt": "Rename the body to Bracket.", "setup": [{"command": "solid.box", "params": {"length": 30, "width": 20, "height": 10}}], "check": {"bodies": 1, "volume": 6000, "body_name": "Bracket"}},
{"id": "09-volume-question", "prompt": "What is the volume of the part?", "setup": [{"command": "solid.box", "params": {"length": 30, "width": 20, "height": 10}}], "check": {"bodies": 1, "volume": 6000, "unchanged": true, "reply_number": "6[ ,. ]?000(?![0-9])|6(\\.0+)? ?cm"}},
{"id": "10-two-holes", "prompt": "Make a 100 x 50 x 5 mm plate and drill two 10 mm holes through it, each 15 mm in from the short ends and centred across the width.", "check": {"bodies": 1, "volume": 24214.6, "bbox_size": [5, 50, 100], "note": "100*50*5 - 2*pi*5^2*5"}},
{"id": "11-any-change", "prompt": "can you use the software to make a change to the current shape, any change at all", "setup": [{"command": "solid.box", "params": {"length": 30, "width": 20, "height": 10}}], "check": {"changed": true, "note": "verbatim user prompt from #4846; pass = the design changed"}}
]"#;
/// The agent the test adds. A fixed id replaces it on every run instead of adding another.
const AGENT_ID: &str = "api-bench-assistant";
const AGENT_NAME: &str = "bench";
/// Longest control reply read (the channel's own request limit).
const MAX_REPLY: usize = 16 << 20;
/// Most result data kept for `--json`.
const MAX_RESULTS: usize = 64 << 20;
/// Deadline of the requests that only set up or read (each is answered at once by the app).
const REQUEST_TIMEOUT: Duration = Duration::from_secs(120);
/// How often a running turn is polled.
const POLL: Duration = Duration::from_millis(250);
/// After this long, an idle chat with no reply yet counts as a turn that ended without one.
const START_GRACE: Duration = Duration::from_secs(30);
/// Absolute tolerance of the lowest corner, in millimetres.
const MIN_CORNER_TOL: f64 = 0.05;
/// A JSON-lines connection to the app. Requests are sent once; a lost reply is never retried,
/// so nothing runs twice.
struct Control {
addr: SocketAddr,
token: String,
reader: BufReader<TcpStream>,
id: u64,
}
impl Control {
fn connect(addr: &str, token: Option<&str>) -> Result<Self, String> {
let addr: SocketAddr = addr.parse().map_err(|_| format!("--connect: `{addr}` is not an IP address and port"))?;
if !addr.ip().is_loopback() || addr.port() == 0 {
return Err("--connect requires a loopback address and a nonzero port (the app listens on 127.0.0.1)".into());
}
let token = solidcraft_engine::control_auth::client_token(addr.port(), token)
.ok_or("no control token: pass --token, set SOLIDCRAFT_CONTROL_TOKEN, or run on the app's computer (its token file)")?;
if token.len() > 4096 {
return Err("the control token is longer than 4096 bytes".into());
}
Self::open(addr, token)
}
fn open(addr: SocketAddr, token: String) -> Result<Self, String> {
let stream = TcpStream::connect_timeout(&addr, Duration::from_secs(5)).map_err(|e| format!("connecting to {addr}: {e}"))?;
Ok(Self { addr, token, reader: BufReader::new(stream), id: 0 })
}
/// A new connection to the same app: a request that outlived its deadline keeps the old one
/// busy until it is answered.
fn reopen(&self) -> Result<Self, String> {
Self::open(self.addr, self.token.clone())
}
fn call(&mut self, method: &str, params: Value, deadline: Instant) -> Result<Value, String> {
let left = || deadline.checked_duration_since(Instant::now()).filter(|d| !d.is_zero()).ok_or_else(|| "timeout".to_string());
let io = |e: std::io::Error| match e.kind() {
std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock => "timeout".to_string(),
_ => format!("control channel: {e}"),
};
self.reader.get_ref().set_write_timeout(Some(left()?)).map_err(io)?;
self.id += 1;
// The first request on a connection carries the token; sending it on every one is allowed.
let request = json!({"id": self.id, "method": method, "params": params, "token": self.token});
writeln!(self.reader.get_mut(), "{request}").map_err(io)?;
let mut line = Vec::new();
loop {
self.reader.get_ref().set_read_timeout(Some(left()?)).map_err(io)?;
let bytes = self.reader.fill_buf().map_err(io)?;
if bytes.is_empty() {
return Err("the app closed the control connection".into());
}
let newline = bytes.iter().position(|b| *b == b'\n');
let used = newline.map_or(bytes.len(), |n| n + 1);
if line.len() + used > MAX_REPLY {
return Err("a control reply is larger than 16 MiB".into());
}
line.extend_from_slice(bytes.get(..used).unwrap_or_default());
self.reader.consume(used);
if newline.is_some() {
break;
}
}
let reply: Value = serde_json::from_slice(&line).map_err(|e| format!("control reply: {e}"))?;
if reply["id"].as_u64() != Some(self.id) {
return Err("control reply for another request".into());
}
if reply["ok"] != true {
return Err(format!("{method}: {}", reply["error"].as_str().unwrap_or("failed")));
}
reply.get("result").cloned().ok_or_else(|| format!("{method}: reply without a result"))
}
fn execute(&mut self, command: &str, params: Value) -> Result<Value, String> {
self.call("engine.execute", json!({"command": command, "params": params, "animate": false}), Instant::now() + REQUEST_TIMEOUT)
}
fn chat(&mut self, params: Value) -> Result<Value, String> {
self.call("ui.agent_chat", params, Instant::now() + REQUEST_TIMEOUT)
}
fn inspect(&mut self) -> Result<Value, String> {
let design = self.call("document.inspect", json!({"measure": true}), Instant::now() + REQUEST_TIMEOUT)?;
if design.is_object() { Ok(design) } else { Err("document.inspect returned no design".into()) }
}
}
fn tasks() -> Result<Vec<Value>, String> {
serde_json::from_str(TASKS).map_err(|e| format!("embedded tasks: {e}"))
}
fn triple(value: &Value) -> Option<[f64; 3]> {
match value.as_array()?.as_slice() {
[x, y, z] => {
let n = |v: &Value| v.as_f64().filter(|n| n.is_finite());
Some([n(x)?, n(y)?, n(z)?])
}
_ => None,
}
}
/// What the model said (thoughts left out).
fn reply(items: &[Value]) -> String {
items.iter().filter(|i| i["thought"] != true).filter_map(|i| i["agent"].as_str()).collect::<Vec<_>>().join("\n")
}
/// v1's reply pattern `6[ ,. ]?000(?![0-9])|6(\.0+)? ?cm`: "6000", "6 000", "6,000", "6.000" not
/// followed by a digit, or "6 cm", "6.0cm", … (a narrow no-break space also separates thousands).
fn volume_reply(text: &str) -> bool {
text.match_indices('6').any(|(at, _)| {
let tail = text.get(at + 1..).unwrap_or_default();
let thousands = |t: &str| t.strip_prefix("000").is_some_and(|rest| !rest.starts_with(|c: char| c.is_ascii_digit()));
let separated = tail.strip_prefix([' ', ',', '.', '\u{202f}']).is_some_and(thousands);
let cm = {
let t = tail.strip_prefix(".0").map_or(tail, |t| t.trim_start_matches('0'));
t.strip_prefix(' ').unwrap_or(t).starts_with("cm")
};
thousands(tail) || separated || cm
})
}
/// The design as the user sees it, without what reading or saving changes (path, saved state,
/// revision, selection, undo depth, evaluation timings).
fn content(design: &Value) -> Value {
let mut v = design.clone();
if let Some(o) = v.as_object_mut() {
for key in ["path", "dirty", "revision", "selection", "undo", "redo"] {
o.remove(key);
}
}
for feature in v.get_mut("timeline").and_then(Value::as_array_mut).into_iter().flatten() {
if let Some(o) = feature.as_object_mut() {
o.remove("ms");
o.remove("boolean_diagnostics");
}
}
v
}
/// Whether the design changed: its content, or a step on the undo stack (a colour, say, that the
/// inspection does not list).
fn changed(before: &Value, after: &Value) -> bool {
content(before) != content(after) || after["undo"].as_u64() > before["undo"].as_u64()
}
/// The rules a task's result breaks (none: it passed). `before` and `after` are
/// `document.inspect {measure: true}` before the prompt and after the turn.
pub(super) fn check(rule: &Value, before: &Value, after: &Value, items: &[Value]) -> Vec<String> {
let Some(all) = after["bodies"].as_array() else { return vec!["the design could not be read".into()] };
// Bodies the user sees; a hidden one (a tool body, a hidden original) is not the result.
let bodies: Vec<&Value> = all.iter().filter(|b| b["visible"] != false).collect();
let mut errors = Vec::new();
if let Some(n) = rule["bodies"].as_u64()
&& bodies.len() as u64 != n
{
errors.push(format!("{} bodies, expected {n}", bodies.len()));
}
if let Some(expected) = rule["volume"].as_f64() {
let tol = rule["tol"].as_f64().unwrap_or(0.01);
let volumes: Vec<f64> = bodies.iter().filter_map(|b| b["volume_mm3"].as_f64()).filter(|v| v.is_finite()).collect();
let total: f64 = volumes.iter().sum();
if volumes.len() != bodies.len() || (total - expected).abs() > expected * tol {
errors.push(format!("volume {total:.1} mm³, expected {expected} mm³ ±{}%", tol * 100.0));
}
}
let first = bodies.first().copied().unwrap_or(&Value::Null);
let min = triple(&first["bbox"]["min"]);
if let Some(mut expected) = triple(&rule["bbox_size"]) {
expected.sort_by(f64::total_cmp);
let size = min.zip(triple(&first["bbox"]["max"])).map(|(lo, hi)| {
let mut size = [hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]];
size.sort_by(f64::total_cmp);
size
});
if !size.is_some_and(|s| s.iter().zip(expected).all(|(a, e)| (a - e).abs() <= 0.01 * e.max(1.0))) {
errors.push(format!("size {size:?}, expected {expected:?} in any orientation"));
}
}
if let Some(expected) = triple(&rule["bbox_min"])
&& !min.is_some_and(|m| m.iter().zip(expected).all(|(a, e)| (a - e).abs() <= MIN_CORNER_TOL))
{
errors.push(format!("lowest corner {min:?}, expected {expected:?}"));
}
if let Some(name) = rule["body_name"].as_str()
&& !bodies.iter().any(|b| b["name"] == name)
{
errors.push(format!("no body named {name}"));
}
for kind in rule["feature_kinds"].as_array().into_iter().flatten().filter_map(Value::as_str) {
let live = |f: &&Value| f["suppressed"] != true && f["rolled_back"] != true && f["error"].is_null();
if !after["timeline"].as_array().into_iter().flatten().filter(live).any(|f| f["type"].as_str().is_some_and(|t| t.contains(kind))) {
errors.push(format!("no {kind} in the timeline"));
}
}
if rule["changed"] == true || rule["unchanged"] == true {
let changed = before.is_object() && changed(before, after);
if rule["changed"] == true && !changed {
errors.push("the design did not change".into());
}
if rule["unchanged"] == true && (changed || !before.is_object()) {
errors.push("the design changed".into());
}
}
if rule["reply_number"].is_string() && !volume_reply(&reply(items)) {
errors.push("the reply does not give the volume".into());
}
errors
}
/// A tool call written into the reply as JSON: `{"name": …`, `{"command": …`, `{"function": …`,
/// `{"tool": …`.
fn call_as_text(text: &str) -> bool {
text.match_indices('{').any(|(at, _)| {
let rest = text.get(at + 1..).unwrap_or_default().trim_start();
["\"name\"", "\"command\"", "\"function\"", "\"tool\""]
.iter()
.any(|key| rest.strip_prefix(key).is_some_and(|r| r.trim_start().starts_with(':')))
})
}
/// How a failed task failed, in the issue's words, from the conversation's items.
pub(super) fn classify(items: &[Value], timed_out: bool) -> &'static str {
if timed_out {
return "timeout";
}
let tools: Vec<&Value> = items.iter().filter(|i| i.get("tool").is_some()).collect();
// A failed call, or a batch whose answer reports a failed step (`"ok": false`).
let failed: Vec<String> = tools
.iter()
.map(|t| t["output"].as_str().unwrap_or_default())
.zip(&tools)
.filter(|(output, t)| t["status"] == "Failed" || serde_json::from_str::<Value>(output).is_ok_and(|v| v["ok"] == false))
.map(|(output, _)| output.to_lowercase())
.collect();
let notices: Vec<String> = items.iter().filter_map(|i| i["notice"].as_str()).map(str::to_lowercase).collect();
let answer = reply(items);
if notices.iter().any(|n| n.contains("http 400")) {
return "the endpoint refused tools (HTTP 400)";
}
if notices.iter().any(|n| n.contains("max_turn_requests")) || answer.contains("requests for one message") {
return "hit the request limit";
}
if call_as_text(&answer) {
return "wrote the call as text";
}
if failed.iter().any(|o| o.contains("unknown command") || o.starts_with("unknown tool")) {
return "wrong or unknown command";
}
if failed.iter().any(|o| o.contains("invalid parameters") || o.starts_with("not run:") || o.contains("not valid json")) {
return "wrong arguments";
}
if !failed.is_empty() || notices.iter().any(|n| n.starts_with("http ")) || items.iter().any(|i| i["error"] == true) {
return "gave up after errors";
}
if tools.is_empty() {
return if answer.trim().is_empty() { "an empty reply" } else { "only talked" };
}
"wrong result"
}
/// One run's results in the issue's comment template (`run`: which repetition).
pub(super) fn markdown(model: &str, version: &str, results: &[Value], run: u64) -> String {
let clean = |s: &str| s.replace(['\n', '\r', '|'], " ");
let rows: Vec<&Value> = results.iter().filter(|r| r["repeat"] == run).collect();
let passed = rows.iter().filter(|r| r["passed"] == true).count();
let mut out = format!(
"### {} (<quant>, <download size>)\nRuntime: <Ollama/LM Studio/... version>, context <num_ctx if changed>\nSolidCraft: {}\nResult: {passed}/11\n| # | pass/fail | how it failed |\n|---|---|---|\n",
clean(model),
clean(version)
);
for n in 1..=11 {
match rows.iter().find(|r| r["task"] == n) {
Some(r) if r["passed"] == true => out.push_str(&format!("| {n} | pass | |\n")),
Some(r) => out.push_str(&format!("| {n} | fail | {} |\n", clean(r["failure"].as_str().unwrap_or_default()))),
None => out.push_str(&format!("| {n} | | not run |\n")),
}
}
out.push_str(if rows.len() == 11 {
"Notes: <anything odd, e.g. very slow, needed a retry>\n"
} else {
"Notes: partial run; the tasks marked \"not run\" were left out.\n"
});
out
}
fn write(path: &str, text: &str) -> Result<(), String> {
solidcraft_engine::io::vfs::write(path, text.as_bytes()).map_err(|e| format!("{path}: {e}"))
}
struct Options<'a> {
connect: &'a str,
token: Option<&'a str>,
base_url: &'a str,
model: &'a str,
tasks: Vec<usize>,
repeat: u64,
timeout: Duration,
json: Option<&'a str>,
markdown: Option<&'a str>,
}
fn options(args: &[String]) -> Result<Options<'_>, String> {
const FLAGS: [&str; 9] = ["--connect", "--token", "--base-url", "--model", "--tasks", "--repeat", "--timeout-s", "--json", "--markdown"];
let mut given = std::collections::BTreeMap::new();
let mut args = args.iter();
while let Some(flag) = args.next() {
let Some(flag) = FLAGS.iter().find(|f| **f == flag.as_str()) else { return Err(format!("unknown bench-assistant option: {flag}")) };
let value = args
.next()
.filter(|v| !v.starts_with("--") && !v.is_empty() && v.len() <= 4096)
.ok_or_else(|| format!("{flag} needs a value (at most 4096 bytes)"))?;
if given.insert(*flag, value.as_str()).is_some() {
return Err(format!("{flag} given twice"));
}
}
let required = |key: &str| given.get(key).copied().ok_or_else(|| format!("bench-assistant needs {key}"));
let number = |key: &str, default: u64, max: u64| -> Result<u64, String> {
given
.get(key)
.map_or(Ok(default), |s| s.parse::<u64>().ok().filter(|n| (1..=max).contains(n)).ok_or_else(|| format!("{key} must be 1 to {max}")))
};
let tasks = match given.get("--tasks") {
None => (1..=11).collect(),
Some(list) => {
let mut tasks = Vec::new();
for n in list.split(',') {
let n = n
.trim()
.parse::<usize>()
.ok()
.filter(|n| (1..=11).contains(n))
.ok_or("--tasks takes task numbers 1 to 11, separated by commas")?;
if tasks.contains(&n) {
return Err(format!("--tasks lists task {n} twice"));
}
tasks.push(n);
}
tasks
}
};
Ok(Options {
connect: required("--connect")?,
token: given.get("--token").copied(),
base_url: required("--base-url")?,
model: required("--model")?,
tasks,
repeat: number("--repeat", 1, 100)?,
timeout: Duration::from_secs(number("--timeout-s", 300, 3600)?),
json: given.get("--json").copied(),
markdown: given.get("--markdown").copied(),
})
}
/// Waits for the turn the prompt started to end. Returns the chat's last state and whether the
/// deadline passed first.
fn wait_for_turn(control: &mut Control, mut chat: Value, began: Instant, end: Instant) -> Result<(Value, bool), String> {
let mut seen_busy = false;
loop {
let state = chat["state"].as_str().unwrap_or_default();
let turn = !chat["turn"].is_null();
let queued = chat["pending"].as_array().is_some_and(|p| !p.is_empty());
seen_busy |= state == "busy" || turn;
let idle = !matches!(state, "busy" | "starting") && !turn && !queued;
// A turn can start and end between two polls: then the conversation has its answer.
let answered = chat["items"].as_array().into_iter().flatten().any(|i| i.get("user").is_none());
if idle && (seen_busy || answered || matches!(state, "exited" | "auth" | "off") || began.elapsed() >= START_GRACE) {
return Ok((chat, false));
}
let Some(left) = end.checked_duration_since(Instant::now()).filter(|d| !d.is_zero()) else { return Ok((chat, true)) };
std::thread::sleep(POLL.min(left));
match control.call("ui.agent_chat", json!({}), end) {
Ok(next) => chat = next,
Err(e) if e == "timeout" => return Ok((chat, true)),
Err(e) => return Err(e),
}
}
}
pub fn run(args: &[String]) -> Result<(), String> {
let o = options(args)?;
let tasks = tasks()?;
let mut control = Control::connect(o.connect, o.token)?;
let help = control.call("ui.help", json!({}), Instant::now() + REQUEST_TIMEOUT)?;
let version = help["version"].as_str().filter(|s| !s.is_empty() && s.len() <= 256).ok_or("the app reported no version")?.to_string();
let config = control.chat(json!({
"features": true,
"add_agent": {"id": AGENT_ID, "name": AGENT_NAME, "kind": "api", "base_url": o.base_url, "model": o.model},
}))?;
if !config["agents"].as_array().into_iter().flatten().any(|a| a["id"] == AGENT_ID) {
return Err("the app did not add the benchmark's agent".into());
}
// Put the user's permission setting back afterwards.
let permissions = config["permissions"].as_str().map(str::to_string);
let result = run_tasks(&mut control, &o, &tasks, &version);
let _ = control.chat(json!({"stop": true}));
if let Some(p) = permissions {
let _ = control.chat(json!({"permissions": p}));
}
result
}
fn run_tasks(control: &mut Control, o: &Options, tasks: &[Value], version: &str) -> Result<(), String> {
let mut results: Vec<Value> = Vec::new();
let mut kept = 0;
let mut stdout = std::io::stdout().lock();
let mut say = |line: String| writeln!(stdout, "{line}").map_err(|e| format!("stdout: {e}"));
say(format!("bench-assistant v1: {} on SolidCraft {version}", o.model))?;
say("| run | # | task | pass/fail | how it failed | seconds |\n|---|---|---|---|---|---|".into())?;
for run in 1..=o.repeat {
for &n in &o.tasks {
let task = tasks.get(n - 1).ok_or("missing embedded task")?;
// A fresh design and a fresh chat, with nothing of the last task still running.
control.chat(json!({"stop": true}))?;
control.execute("file.new", json!({"name": "Benchmark"}))?;
for step in task["setup"].as_array().into_iter().flatten() {
control.execute(step["command"].as_str().ok_or("embedded setup without a command")?, step["params"].clone())?;
}
control.chat(json!({"agent": AGENT_ID, "permissions": "all", "new_chat": true, "attach_view": false}))?;
let before = control.inspect()?;
let began = Instant::now();
let end = began + o.timeout;
let (chat, timed_out) = match control.call("ui.agent_chat", json!({"send": task["prompt"]}), end) {
Ok(chat) => wait_for_turn(control, chat, began, end)?,
Err(e) if e == "timeout" => (json!({}), true),
Err(e) => return Err(e),
};
if timed_out {
// The late reply would arrive on the old connection; stop the model on a new one
// before reading the design.
*control = control.reopen()?;
}
// Stop the agent before reading the result, so nothing changes the design meanwhile.
let stopped = control.chat(json!({"stop": true}))?;
let items = stopped["items"].as_array().or(chat["items"].as_array()).cloned().unwrap_or_default();
let after = control.inspect()?;
let failed_rules = check(&task["check"], &before, &after, &items);
let passed = failed_rules.is_empty();
let failure = if passed { "" } else { classify(&items, timed_out) };
let seconds = began.elapsed().as_secs_f64();
say(format!(
"| {run} | {n} | {} | {} | {failure} | {seconds:.0} |",
task["id"].as_str().unwrap_or_default(),
if passed { "pass" } else { "fail" }
))?;
let result = json!({
"task": n, "id": task["id"], "prompt": task["prompt"], "repeat": run, "passed": passed, "failure": failure,
"failed_rules": failed_rules, "timed_out": timed_out, "seconds": seconds, "check": task["check"],
"items": items, "before": before, "after": after,
});
kept += result.to_string().len();
if kept > MAX_RESULTS {
return Err("the results passed 64 MiB; run fewer tasks or repetitions".into());
}
results.push(result);
// Written after every task, so an interrupted run keeps what it measured.
if let Some(path) = o.json {
let all = json!({"test": "v1", "model": o.model, "base_url": o.base_url, "solidcraft": version, "results": results});
write(path, &serde_json::to_string_pretty(&all).map_err(|e| e.to_string())?)?;
}
if let Some(path) = o.markdown {
write(path, &(1..=run).map(|r| markdown(o.model, version, &results, r)).collect::<Vec<_>>().join("\n"))?;
}
}
}
for run in 1..=o.repeat {
let rows: Vec<&Value> = results.iter().filter(|r| r["repeat"] == run).collect();
say(format!("run {run}: {}/{} passed", rows.iter().filter(|r| r["passed"] == true).count(), rows.len()))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn design(bodies: Value, timeline: Value) -> Value {
json!({"name": "Benchmark", "params": [], "timeline": timeline, "bodies": bodies, "sketches": [], "undo": 1, "revision": 3, "dirty": true})
}
fn body(name: &str, volume: f64, min: [f64; 3], max: [f64; 3]) -> Value {
json!({"name": name, "volume_mm3": volume, "bbox": {"min": min, "max": max}, "visible": true})
}
#[test]
fn the_embedded_tasks_are_v1() {
let tasks = tasks().unwrap();
assert_eq!(tasks.len(), 11);
assert_eq!(tasks[0]["prompt"], "Make a 20 mm cube.");
assert_eq!(tasks[10]["prompt"], "can you use the software to make a change to the current shape, any change at all");
assert_eq!(tasks[5]["check"]["tol"], 0.003);
assert_eq!(tasks[6]["check"]["bbox_min"], json!([50, 0, 0]));
let boxes: Vec<_> = tasks.iter().filter(|t| t["setup"].is_array()).map(|t| t["id"].as_str().unwrap()).collect();
assert_eq!(boxes, ["06-fillet-box", "07-move-body", "08-rename-body", "09-volume-question", "11-any-change"]);
}
#[test]
fn pass_rules() {
let tasks = tasks().unwrap();
let rule = |n: usize| tasks[n - 1]["check"].clone();
let empty = design(json!([]), json!([]));
let pass = |n: usize, before: &Value, after: &Value, items: &[Value]| check(&rule(n), before, after, items);
// 3: any orientation, within 1%.
let block = design(json!([body("Body1", 24_100.0, [0.0; 3], [40.0, 10.0, 60.2])]), json!([{"type": "Box"}]));
assert!(pass(3, &empty, &block, &[]).is_empty());
let thick = design(json!([body("Body1", 24_000.0, [0.0; 3], [40.0, 12.0, 60.0])]), json!([]));
assert_eq!(pass(3, &empty, &thick, &[]).len(), 1);
let two = design(json!([body("A", 12_000.0, [0.0; 3], [60.0, 40.0, 5.0]), body("B", 12_000.0, [0.0; 3], [60.0, 40.0, 5.0])]), json!([]));
assert!(!pass(3, &empty, &two, &[]).is_empty());
// A hidden body is not part of the result.
let mut hidden = two.clone();
hidden["bodies"][1]["visible"] = json!(false);
hidden["bodies"][0]["volume_mm3"] = json!(24_000.0);
hidden["bodies"][0]["bbox"]["max"] = json!([60.0, 40.0, 10.0]);
assert!(pass(3, &empty, &hidden, &[]).is_empty());
// 4: the timeline needs a working Extrude.
let plate = |kind: Value| design(json!([body("Body1", 22_500.0, [0.0; 3], [50.0, 30.0, 15.0])]), kind);
assert!(pass(4, &empty, &plate(json!([{"type": "Sketch"}, {"type": "Extrude"}])), &[]).is_empty());
assert!(!pass(4, &empty, &plate(json!([{"type": "Box"}])), &[]).is_empty());
for flag in [json!({"suppressed": true}), json!({"rolled_back": true}), json!({"error": "failed"})] {
let mut feature = json!({"type": "Extrude"});
feature.as_object_mut().unwrap().extend(flag.as_object().unwrap().clone());
assert!(!pass(4, &empty, &plate(json!([feature])), &[]).is_empty(), "{flag}");
}
// 6: the fillet's tolerance is 0.3%.
let start = design(json!([body("Body1", 64_000.0, [0.0; 3], [40.0; 3])]), json!([{"type": "Box"}]));
let filleted = |v: f64| design(json!([body("Body1", v, [0.0; 3], [40.0; 3])]), json!([{"type": "Box"}, {"type": "Fillet"}]));
assert!(pass(6, &start, &filleted(63_108.53 * 1.002), &[]).is_empty());
assert!(!pass(6, &start, &filleted(63_108.53 * 1.004), &[]).is_empty());
// 7: the lowest corner.
let moved = |x: f64| design(json!([body("Body1", 8000.0, [x, 0.0, 0.0], [x + 20.0, 20.0, 20.0])]), json!([]));
assert!(pass(7, &empty, &moved(50.0), &[]).is_empty());
assert!(!pass(7, &empty, &moved(0.0), &[]).is_empty());
// 8: the name.
let named = |name: &str| design(json!([body(name, 6000.0, [0.0; 3], [30.0, 20.0, 10.0])]), json!([]));
assert!(pass(8, &empty, &named("Bracket"), &[]).is_empty());
assert!(!pass(8, &empty, &named("Body1"), &[]).is_empty());
// 9: unchanged, and the reply gives the volume.
let start = named("Body1");
let said = |text: &str| [json!({"user": "What is the volume of the part?"}), json!({"agent": text, "thought": false})];
let mut read = start.clone();
read["revision"] = json!(7);
read["dirty"] = json!(false);
read["selection"] = json!([{"body": "Body1"}]);
assert!(pass(9, &start, &read, &said("The volume is 6000 mm³.")).is_empty());
assert!(!pass(9, &start, &read, &said("About 60 cm³.")).is_empty());
assert!(!pass(9, &start, &named("Bracket"), &said("6 000 mm³")).is_empty());
let mut undone = start.clone();
undone["undo"] = json!(2);
assert!(!pass(9, &start, &undone, &said("6000 mm³")).is_empty());
// 11: any change counts; reading does not.
assert!(pass(11, &start, &named("Bracket"), &[]).is_empty());
assert!(pass(11, &start, &undone, &[]).is_empty());
assert!(!pass(11, &start, &read, &[]).is_empty());
// An unreadable design passes nothing.
assert!(!pass(11, &start, &json!({}), &[]).is_empty());
}
#[test]
fn the_volume_reply() {
for yes in ["6000", "6 000 mm³", "6,000", "6.000", "6\u{202f}000", "6 cm³", "6.00cm3", "is 6000."] {
assert!(volume_reply(yes), "{yes}");
}
for no in ["60000", "6,0001", "60 cm³", "600", "6.01 cm³", ""] {
assert!(!volume_reply(no), "{no}");
}
}
#[test]
fn failure_categories() {
for (items, expected) in [
(
json!([{"user": "Make a 20 mm cube."}, {"agent": "{\"name\": \"execute\", \"arguments\": {}}", "thought": false}]),
"wrote the call as text",
),
(
json!([{"notice": "HTTP 400: registry.ollama.ai/library/gemma3:4b does not support tools", "error": true}]),
"the endpoint refused tools (HTTP 400)",
),
(json!([{"user": "Make a 20 mm cube."}, {"agent": "", "thought": false}]), "an empty reply"),
(json!([{"agent": "Let me think", "thought": true}]), "an empty reply"),
(json!([{"agent": "Sure, here is how to make a cube.", "thought": false}]), "only talked"),
(
json!([{"tool": "1", "status": "Failed", "output": "unknown command `solid.cube` (did you mean `solid.box`?)"}]),
"wrong or unknown command",
),
(json!([{"tool": "1", "status": "Failed", "output": "unknown tool `make_cube`"}]), "wrong or unknown command"),
(json!([{"tool": "1", "status": "Failed", "output": "invalid parameters for `solid.box`: `length` is required"}]), "wrong arguments"),
(json!([{"tool": "1", "status": "Failed", "output": "Not run: the tool arguments were not valid JSON"}]), "wrong arguments"),
(json!([{"tool": "1", "status": "Failed", "output": "no body `Box`"}]), "gave up after errors"),
(
json!([{"tool": "1", "status": "Completed", "output": "{\"ok\": false, \"failed\": {\"error\": \"unknown command `solid.delete`\"}}"}]),
"wrong or unknown command",
),
(
json!([{"tool": "1", "status": "Completed"}, {"agent": "\n(stopped after 24 requests for one message; send another message to go on)"}]),
"hit the request limit",
),
(json!([{"tool": "1", "status": "Completed", "output": "{}"}]), "wrong result"),
] {
let items = items.as_array().unwrap();
assert_eq!(classify(items, false), expected, "{items:?}");
assert_eq!(classify(items, true), "timeout");
}
}
#[test]
fn markdown_is_the_comment_template() {
let rows: Vec<Value> =
(1..=11).map(|n| json!({"task": n, "repeat": 1, "passed": n != 2, "failure": if n == 2 { "an empty reply" } else { "" }})).collect();
let report = markdown("qwen2.5:7b", "0.19.0", &rows, 1);
assert!(report.starts_with(
"### qwen2.5:7b (<quant>, <download size>)\nRuntime: <Ollama/LM Studio/... version>, context <num_ctx if changed>\nSolidCraft: 0.19.0\nResult: 10/11\n| # | pass/fail | how it failed |\n|---|---|---|\n| 1 | pass | |\n| 2 | fail | an empty reply |\n"
));
assert!(report.ends_with("| 11 | pass | |\nNotes: <anything odd, e.g. very slow, needed a retry>\n"));
let partial = markdown("m|x", "v", &rows[..1], 1);
assert!(partial.starts_with("### m x ("));
assert!(partial.contains("Result: 1/11") && partial.contains("| 2 | | not run |") && partial.contains("partial run"));
}
#[test]
fn options_are_checked_before_connecting() {
for extra in [
&["--repeat", "0"][..],
&["--repeat", "101"],
&["--timeout-s", "3601"],
&["--tasks", "0"],
&["--tasks", "12"],
&["--tasks", "1,1"],
&["--model", "again"],
&["--unexpected", "1"],
&["--token"],
] {
let mut args: Vec<String> =
["--connect", "127.0.0.1:9", "--model", "m", "--base-url", "http://127.0.0.1:11434/v1"].iter().map(|s| s.to_string()).collect();
args.extend(extra.iter().map(|s| s.to_string()));
assert!(options(&args).is_err(), "{extra:?}");
}
let args: Vec<String> =
["--connect", "127.0.0.1:9", "--model", "m", "--base-url", "u", "--tasks", "2, 5"].iter().map(|s| s.to_string()).collect();
let o = options(&args).unwrap();
assert_eq!((o.tasks, o.repeat, o.timeout), (vec![2, 5], 1, Duration::from_secs(300)));
assert!(Control::connect("10.0.0.1:9271", Some("t")).is_err());
assert!(Control::connect("localhost", Some("t")).is_err());
}
}
+10 -2
View File
@@ -6,6 +6,9 @@
//! solidcraft-cli script <design.solidcraft> [--out script.json] the design's history as a command script
//! solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only] [--save OUT.solidcraft] [--display LEVEL [--edit NAME EXPR]] [--ops script.json] loading timings as JSON lines
//! solidcraft-cli bench-agent [--scale F] [--save F] [--script-out F] a ~1,300-body design built command by command, timed
//! solidcraft-cli bench-assistant --connect 127.0.0.1:PORT [--token T] --base-url URL --model NAME [--tasks 1,2,5] [--repeat N]
//! [--timeout-s 300] [--json out.json] [--markdown out.md]
//! the standard Assistant model test (#4851) on a running app
//! solidcraft-cli snapshot <script|design> [--in design|part.step] --out shot.png [--width W] [--height H] [--view iso|front|top|…|NAME | --camera JSON] [--no-grid] [--no-axes]
//! [--body NAME:opacity=0.3,color=#rrggbb]... [--tool NAME]... [--section z=27]
//! solidcraft-cli exec <command> [json-params] run one command on an empty design
@@ -24,6 +27,7 @@
#![forbid(unsafe_code)]
mod bench_agent;
mod bench_assistant;
mod crashes;
mod fuzz;
mod logger;
@@ -57,6 +61,7 @@ const USAGE: &str = "usage:
(--ops: after opening, run each command of the script and undo it, timing the command, the
evaluations it caused and the view it leaves: placing and meshing what changed)
solidcraft-cli bench-agent [--scale F] [--save design.solidcraft] [--script-out script.json]
solidcraft-cli bench-assistant --connect 127.0.0.1:PORT [--token T] --base-url URL --model NAME [--tasks 1,2,5] [--repeat N] [--timeout-s 300] [--json out.json] [--markdown out.md]
solidcraft-cli snapshot <script|design> [--in design.solidcraft|part.step] --out shot.png [--width W] [--height H] [--view NAME | --camera JSON] [--no-grid] [--no-axes]
[--body NAME:opacity=0.3,color=#rrggbb]... [--tool NAME]... [--section z=27]
solidcraft-cli snapshot --in <design.solidcraft|part.step> --out shot.png ...
@@ -136,6 +141,8 @@ fn main() -> ExitCode {
Some("bench-open") => cmd_bench_open(&args[1..]),
// A ~1,300-body design built command by command, as an agent builds one over MCP.
Some("bench-agent") => bench_agent::run(&args[1..]),
// The standard Assistant model test (#4851), run against a desktop app over its control channel.
Some("bench-assistant") => bench_assistant::run(&args[1..]),
Some("--version" | "-V") => {
println!("solidcraft-cli {}", env!("CARGO_PKG_VERSION"));
Ok(())
@@ -716,9 +723,10 @@ pub(crate) fn cmd_bench_open(args: &[String]) -> Result<(), String> {
let t = std::time::Instant::now();
let s = Session::opened(doc, saved.as_ref());
drop(read);
// Taken from the file: nothing evaluated (SOLIDCRAFT_NO_MODEL_CACHE evaluates anyway).
// Taken from the file: nothing evaluated (SOLIDCRAFT_NO_MODEL_CACHE evaluates anyway), or
// only the features whose state a thinned file left out.
let adopted = saved.is_some() && s.model.last_recomputed == 0;
println!("{}", json!({"step": "evaluate", "ms": t.elapsed().as_secs_f64() * 1000.0, "adopted": adopted}));
println!("{}", json!({"step": "evaluate", "ms": t.elapsed().as_secs_f64() * 1000.0, "adopted": adopted, "recomputed": s.model.last_recomputed}));
for r in &s.model.results {
println!("{}", json!({"step": "feature", "id": r.id, "name": r.name, "ms": r.ms, "error": r.error}));
}
+82 -1
View File
@@ -7,7 +7,7 @@ use std::sync::Arc;
use serde_json::{Value, json};
use solidcraft_engine::Session;
use solidcraft_engine::doc::{ModelBody, ModelState};
use solidcraft_engine::doc::{ModelBody, ModelState, SavedModel};
fn corpus() -> Vec<PathBuf> {
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/corpus");
@@ -149,6 +149,87 @@ fn imported_assemblies_open_from_their_model_as_evaluated() {
check("imported hinge", s);
}
/// `built` saved with `saved`, a model of it that keeps only some states (as a file without room
/// for all of them does, #3220), opens as the design evaluated again: evaluating just the
/// features whose state the file left out, then after suppressing a feature at, before or after
/// each state it kept, after undoing that, and with the marker moved there and back.
fn check_thinned(name: &str, built: &Session, saved: &SavedModel) {
let design = built.design();
let bytes =
solidcraft_engine::io::write_design_container_with_cache(&design, solidcraft_engine::io::DesignCompression::Fast, None, Some(saved)).unwrap();
assert!(solidcraft_engine::io::read_design_geometry(&bytes).is_some(), "{name}: the file keeps the final state");
let path = temp(&name.replace(' ', "-"));
std::fs::write(&path, &bytes).unwrap();
let mut cached = open(&path);
let mut rebuilt = Session::new(solidcraft_engine::io::read_design(&bytes).unwrap());
let features = saved.records["features"].as_array().unwrap();
let unkept = features.iter().filter(|f| f["skipped"] == false && f.get("state").is_none()).count();
assert!(unkept > 0, "{name}: some states left out");
assert_eq!(cached.model.recomputed, unkept, "{name}: evaluated just the features whose state the file left out");
assert_eq!(model(name, &cached), model(name, &rebuilt), "{name}: opened from the file's model");
let n = cached.doc.features.len();
let mut at: Vec<usize> = features
.iter()
.enumerate()
.filter(|(_, f)| f["skipped"] == false && f.get("state").is_some())
.flat_map(|(k, _)| [k.saturating_sub(1), k, k + 1])
.filter(|i| *i < n)
.collect();
at.dedup();
for i in at {
let edit = json!({"feature": cached.doc.features[i].id, "suppressed": true});
let (a, b) = (cached.execute("timeline.suppress", &edit), rebuilt.execute("timeline.suppress", &edit));
assert_eq!(a.is_ok(), b.is_ok(), "{name}: suppressing feature {i}");
assert_eq!(model(name, &cached), model(name, &rebuilt), "{name}: feature {i} suppressed");
if a.is_ok() {
cached.execute("edit.undo", &json!({})).unwrap();
rebuilt.execute("edit.undo", &json!({})).unwrap();
assert_eq!(model(name, &cached), model(name, &rebuilt), "{name}: suppressing feature {i} undone");
}
for marker in [json!({"position": i}), json!({})] {
cached.execute("timeline.rollback", &marker).unwrap();
rebuilt.execute("timeline.rollback", &marker).unwrap();
assert_eq!(model(name, &cached), model(name, &rebuilt), "{name}: the marker at {marker}, from feature {i}");
}
}
let _ = std::fs::remove_file(&path);
}
/// A model too big for its file keeps the final state, then the slowest features' states as fit
/// (#3220). Both the smallest such model (the final state, and states that add no body to it)
/// and one that keeps more open as the design evaluated again. The placed components' design has
/// a cut whose fingerprint reads where the bodies before it lie, which a state left out does not
/// say until it is evaluated.
#[test]
fn thinned_models_open_as_evaluated() {
let corpus = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/corpus/components-arm.solidcraft");
for sample in ["Sample Plate", "Plastic Enclosure", "components-arm"] {
let built = match solidcraft_engine::sample::samples().into_iter().find(|s| s.name == sample) {
Some(s) => {
let mut built = Session::default();
for c in (s.script)()["commands"].as_array().unwrap() {
built.execute(c["command"].as_str().unwrap(), &c["params"]).unwrap();
}
built
}
None => open(&corpus),
};
let saved = built.model.saved(&built.design()).unwrap();
// Every body a megabyte (the records fit in the room left over): each budget keeps one more
// body's worth of states.
let body = 1 << 20;
let pieces: Vec<Vec<(usize, usize)>> = (0..saved.bodies.len()).map(|i| vec![(i, body)]).collect();
let thinned: Vec<SavedModel> =
(0..=saved.bodies.len()).filter_map(|n| saved.thinned(&pieces, n * body + body - 1).map(|(m, _, _)| m)).collect();
let kept = |m: &SavedModel| m.records["features"].as_array().unwrap().iter().filter(|f| f.get("state").is_some()).count();
let smallest = thinned.first().unwrap();
assert!(smallest.records["states"].as_array().unwrap().len() < saved.records["states"].as_array().unwrap().len(), "{sample}");
let more = thinned.iter().find(|m| kept(m) > kept(smallest) && kept(m) < built.doc.features.len()).unwrap();
check_thinned(&format!("{sample} smallest"), &built, smallest);
check_thinned(&format!("{sample} thinned"), &built, more);
}
}
/// Opened from its model, a design evaluates only from the first feature an edit changes: the
/// saved states before it are kept as they were read (the same states, not evaluated again).
#[test]
+1 -1
View File
@@ -30,5 +30,5 @@ web-sys = { version = "0.3", features = [
"FileSystemHandle", "FileSystemDirectoryHandle", "FileSystemFileHandle", "FileSystemGetFileOptions", "FileSystemWritableFileStream",
"WritableStream", "File", "FileList", "Blob", "BlobPropertyBag", "Url", "HtmlAnchorElement", "HtmlInputElement", "Storage",
"IdbFactory", "IdbDatabase", "IdbObjectStore", "IdbOpenDbRequest", "IdbRequest", "IdbTransaction", "IdbTransactionMode",
"BeforeUnloadEvent", "Event", "EventTarget", "Response", "MediaQueryList", "Gamepad", "Performance",
"BeforeUnloadEvent", "Event", "EventTarget", "KeyboardEvent", "Response", "MediaQueryList", "Gamepad", "Performance",
] }
+3 -1
View File
@@ -499,7 +499,9 @@ fn input_picker(files: &Rc<Files>, intent: Intent) {
let Some(doc) = web_sys::window().and_then(|w| w.document()) else { return };
let Ok(input) = doc.create_element("input").map(|e| e.unchecked_into::<web_sys::HtmlInputElement>()) else { return };
input.set_type("file");
input.set_accept(".solidcraft,.solvecraft,.json,.step,.stp,.3mf,.stl,.obj,.glb,.gltf");
// Everything the browser build imports, from the format registry (no cadmpeg formats here).
let accept = solidcraft_engine::io::formats::open_extensions(false).iter().map(|e| format!(".{e}")).collect::<Vec<_>>().join(",");
input.set_accept(&accept);
// Attached (hidden) while it is open: some browsers ignore a click on a detached input.
let _ = input.set_attribute("style", "display:none");
if let Some(body) = doc.body() {
+43
View File
@@ -17,6 +17,14 @@ mod install;
#[cfg(any(target_arch = "wasm32", test))]
mod space_mouse;
/// The browser switches tabs with Ctrl+Tab and Ctrl+Shift+Tab, so the page leaves those keys to
/// it (#5227); plain Tab and Shift+Tab still move between the app's fields. (Ctrl+PageUp and
/// Ctrl+PageDown already reach the browser.)
#[cfg(any(target_arch = "wasm32", test))]
fn browser_switches_tabs(key: &str, ctrl: bool) -> bool {
ctrl && key == "Tab"
}
#[cfg(target_arch = "wasm32")]
mod web {
use std::rc::Rc;
@@ -84,6 +92,20 @@ mod web {
on_unload.forget();
}
/// eframe cancels the browser's action for every Tab it gets, so the browser couldn't switch
/// tabs while the app has the keyboard. Its tab-switching keys stop here, on the way down to
/// the canvas, before eframe sees them; the browser then acts on them as usual.
fn leave_tab_switching_to_browser(window: &web_sys::Window) {
let on_key = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::KeyboardEvent)>::new(|e: web_sys::KeyboardEvent| {
if crate::browser_switches_tabs(&e.key(), e.ctrl_key()) {
e.stop_propagation();
}
});
let _ = window.add_event_listener_with_callback_and_bool("keydown", on_key.as_ref().unchecked_ref(), true);
// The listener lives as long as the page.
on_key.forget();
}
const JAPANESE_FONT_PATH: &str = "./fonts/BIZUDPGothic-Regular.ttf";
type FontLoadResult = Rc<std::cell::RefCell<Option<Result<Vec<u8>, String>>>>;
@@ -109,6 +131,7 @@ mod web {
/// The language the fonts were last set up for (a CJK UI leads with the CJK face).
font_language: String,
space_mouse: SpaceMouse,
wheel_gestures: solidcraft_ui_egui::WebWheelGestures,
}
impl Shell {
@@ -487,6 +510,10 @@ mod web {
UNSAVED.with(|u| u.set(solidcraft_ui_egui::documents::any_dirty(&self.app)));
}
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
let now = raw.time.unwrap_or_else(|| ctx.input(|i| i.time));
if let Some(delay) = self.wheel_gestures.update(raw, now) {
ctx.request_repaint_after(delay);
}
files::take_drops(&self.files, raw);
// Before the app's own hook, which adds the control channel's events.
self.space_mouse.raw_input_hook(ctx, self.app.preferences.space_mouse_enabled, raw);
@@ -553,6 +580,7 @@ mod web {
wasm_bindgen_futures::spawn_local(async {
let Some(window) = web_sys::window() else { return };
ask_before_unload(&window);
leave_tab_switching_to_browser(&window);
let Some(document) = window.document() else { return };
let Some(canvas) = document.get_element_by_id(CANVAS_ID).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok()) else {
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
@@ -650,6 +678,7 @@ mod web {
cjk_bytes: None,
font_language,
space_mouse: SpaceMouse::default(),
wheel_gestures: solidcraft_ui_egui::WebWheelGestures::default(),
}))
}),
)
@@ -675,3 +704,17 @@ fn main() {
fn main() {
eprintln!("solidcraft-web only runs in the browser: build it with `trunk build --release` in apps/solidcraft-web");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_ctrl_tab_is_left_to_the_browser() {
// Ctrl+Tab and Ctrl+Shift+Tab (the event's key is "Tab" either way) switch browser tabs.
assert!(browser_switches_tabs("Tab", true));
// Tab and Shift+Tab move between the app's fields; Ctrl with other keys stays the app's.
assert!(!browser_switches_tabs("Tab", false));
assert!(!browser_switches_tabs("s", true));
}
}
+2 -1
View File
@@ -70,6 +70,7 @@ windows-sys = { version = "0.61", features = [
] }
[target.'cfg(target_os = "macos")'.dependencies]
muda = { version = "0.21.2", default-features = false }
# The 3D mouse through the 3Dconnexion driver, which keeps the device to itself (src/space_mouse.rs).
solidcraft-3dx-client = { workspace = true }
# The 3D mouse (src/space_mouse.rs), in the shared-device open mode so that a running 3DxWare
@@ -78,7 +79,7 @@ hidapi = { version = "2.6.7", default-features = false, features = ["macos-share
# Files opened from Finder and the Dock (src/open_documents.rs); all already come with eframe and rfd.
objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSNotification", "NSOperation", "NSString", "NSURL"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSNotification", "NSOperation", "NSProcessInfo", "NSString", "NSURL"] }
block2 = "0.6"
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
+91
View File
@@ -0,0 +1,91 @@
//! The graphics device was lost after the window opened: the driver was reset (on Windows a TDR,
//! "Display driver stopped responding and has recovered") or the GPU went away (#4172). A lost
//! device can't draw again, so the window can't show anything more, not even a message. The
//! painter stops drawing (`egui_wgpu::DeviceLost`); the app then closes, keeping unsaved changes
//! for recovery as a forced quit does, and starts again ([`restart`]), and the new start says what
//! happened ([`note`]). A device lost again soon after such a restart starts the next one on the
//! software renderer, which doesn't depend on the graphics driver.
use crate::startup;
/// Set on the start after a lost device: when the device was lost (seconds since 1970).
pub const LOST_ENV: &str = "SOLIDCRAFT_DEVICE_LOST";
/// A loss this recent (seconds) counts for the start after it: its note, and a second loss
/// within it starts on the software renderer. Inherited by later starts, an older stamp is
/// ignored.
const RECENT_SECS: u64 = 600;
/// Seconds since 1970.
pub fn now_secs() -> u64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs())
}
/// Whether `stamp` ([`LOST_ENV`]) names a loss at most [`RECENT_SECS`] before `now`.
pub fn recent(stamp: Option<&str>, now: u64) -> bool {
stamp.and_then(|s| s.trim().parse::<u64>().ok()).is_some_and(|at| at <= now && now - at <= RECENT_SECS)
}
/// Whether the start after a lost device uses the software renderer: when this launch already
/// followed a recent loss (the driver keeps losing it), or ran on it anyway.
pub fn restart_on_software(after_recent_loss: bool, cpu_renderer: bool) -> bool {
after_recent_loss || cpu_renderer
}
/// What the start after a lost device tells the user, `software` when it runs on the software
/// renderer.
pub fn note(software: bool) -> String {
let mut note = String::from(
"The graphics device was reset (the graphics driver stopped responding), so SolidCraft closed and started again. Unsaved changes were kept for recovery.",
);
if software {
note.push_str(" This start uses software rendering, which doesn't depend on the graphics driver.");
}
note
}
/// Start SolidCraft again with the same arguments, the loss stamped at `lost_at`, on the software
/// renderer when `software`.
pub fn restart(software: bool, lost_at: u64) {
let Ok(exe) = std::env::current_exe() else { return };
let mut command = std::process::Command::new(exe);
command.args(std::env::args_os().skip(1)).env(LOST_ENV, lost_at.to_string());
if software {
command.env(startup::RENDERER_ENV, "cpu");
}
#[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 after the device was lost: {e}");
}
}
#[cfg(test)]
mod tests {
/// Only a recent, well-formed stamp counts: a later start that inherited it says nothing.
#[test]
fn only_a_recent_loss_counts() {
let now = 1_000_000;
assert!(super::recent(Some("999990"), now));
assert!(super::recent(Some(" 999400 "), now));
assert!(!super::recent(Some("999399"), now), "older than ten minutes");
assert!(!super::recent(Some("1000001"), now), "from the future");
assert!(!super::recent(Some("soon"), now));
assert!(!super::recent(None, now));
}
/// The first loss starts again on the GPU; a second one soon after, on the software renderer.
#[test]
fn a_second_loss_in_a_row_starts_on_the_software_renderer() {
assert!(!super::restart_on_software(false, false));
assert!(super::restart_on_software(true, false));
assert!(super::restart_on_software(false, true));
assert!(super::note(true).contains("software rendering"));
assert!(!super::note(false).contains("software rendering"));
assert!(super::note(false).contains("kept for recovery"));
}
}
+126 -48
View File
@@ -19,11 +19,14 @@
#[path = "../../../assets/icons/app/shared/source/app_icon.rs"]
mod app_icon;
mod control_server;
mod device_lost;
#[cfg(target_os = "windows")]
mod native_crash;
#[cfg(target_os = "windows")]
mod native_frame;
#[cfg(target_os = "macos")]
mod native_menu;
#[cfg(target_os = "macos")]
mod open_documents;
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
mod space_mouse;
@@ -76,8 +79,12 @@ struct App {
frames: u32,
/// Started over the control channel: the window doesn't ask for the foreground.
take_focus_unwanted: bool,
/// The graphics device: lost, it can't draw again (`App::watch_device`, #4172).
gpu: Option<LostWatch>,
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
space_mouse: space_mouse::SpaceMouse,
#[cfg(target_os = "macos")]
native_menu: Option<native_menu::NativeMenu>,
}
impl eframe::App for App {
@@ -107,6 +114,7 @@ impl eframe::App for App {
}
}
}
self.watch_device();
// 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
@@ -119,6 +127,10 @@ impl eframe::App for App {
for f in if self.solve.file_picker_busy() { Vec::new() } else { open_documents::take() } {
self.solve.open_path_async(&f);
}
#[cfg(target_os = "macos")]
if let Some(menu) = &mut self.native_menu {
menu.tick(&mut self.solve, ctx);
}
self.solve.logic(ctx);
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
if !self.solve.file_picker_busy() {
@@ -129,6 +141,10 @@ impl eframe::App for App {
}
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
#[cfg(target_os = "macos")]
if let Some(menu) = &mut self.native_menu {
menu.raw_input_hook(&mut self.solve, _ctx, raw);
}
// Before the app's own hook, which adds the control channel's events.
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
self.space_mouse.raw_input_hook(self.solve.preferences.space_mouse_enabled, raw);
@@ -147,6 +163,9 @@ impl eframe::App for App {
solidcraft_ui_egui::save_job::tick(&mut self.solve, true);
solidcraft_ui_egui::recovery_ui::close(&mut self.solve);
solidcraft_engine::crash::end_session();
if let Some(lost_at) = self.gpu.as_ref().and_then(|g| g.lost_at) {
device_lost::restart(self.gpu.as_ref().is_some_and(|g| g.restart_on_software), lost_at);
}
// 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);
@@ -166,6 +185,48 @@ impl eframe::App for App {
}
}
/// The window's graphics device, watched for its loss.
struct LostWatch {
lost: eframe::egui_wgpu::DeviceLost,
/// Where the next start runs once this device is lost ([`device_lost::restart_on_software`]).
restart_on_software: bool,
/// When the loss was seen: the app is closing to start again.
lost_at: Option<u64>,
}
impl App {
/// The graphics device was lost (#4172): the window can't draw again, so close, keeping
/// unsaved changes for recovery as a forced quit does (the window can't ask about them), and
/// start again once closed ([`device_lost::restart`]).
fn watch_device(&mut self) {
let Some(gpu) = self.gpu.as_mut().filter(|g| g.lost_at.is_none() && g.lost.is_lost()) else { return };
let reason = gpu.lost.reason().unwrap_or_default();
log::error!("graphics: the device was lost ({reason}); closing to start again");
eprintln!("SolidCraft graphics: the device was lost ({reason}); starting again");
solidcraft_engine::crash::phase(&format!("graphics device lost: {reason}"));
gpu.lost_at = Some(device_lost::now_secs());
self.solve.quit_requested = true;
}
}
/// Linux file dialogs match patterns by case, so each extension goes in as lower and upper
/// case, plus FreeCAD's own mixed casing.
fn case_variants(exts: &[&str]) -> Vec<String> {
let mut all: Vec<String> = Vec::new();
for e in exts {
let mut forms = vec![e.to_ascii_lowercase(), e.to_ascii_uppercase()];
if e.eq_ignore_ascii_case("fcstd") {
forms.push("FCStd".into());
}
for v in forms {
if !all.contains(&v) {
all.push(v);
}
}
}
all
}
fn services(cc: &eframe::CreationContext<'_>) -> Services {
let parent = rfd::AsyncFileDialog::new().set_parent(cc);
let open_parent = parent.clone();
@@ -177,48 +238,14 @@ fn services(cc: &eframe::CreationContext<'_>) -> Services {
let mut dialog = open_parent.clone();
if !first.is_empty() {
let label = first.iter().map(|e| e.to_uppercase()).collect::<Vec<_>>().join(", ");
let both: Vec<String> = first.iter().flat_map(|e| [e.to_lowercase(), e.to_uppercase()]).collect();
dialog = dialog.add_filter(label, &both);
dialog = dialog.add_filter(label, &case_variants(first));
}
let future = dialog
.add_filter(
"Designs, CAD files and meshes",
&[
io::DESIGN_EXT,
io::LEGACY_DESIGN_EXT,
"json",
"step",
"stp",
"STEP",
"STP",
"igs",
"iges",
"IGS",
"IGES",
"FCStd",
"fcstd",
"3dm",
"3DM",
"3mf",
"3MF",
"stl",
"STL",
"obj",
"OBJ",
"glb",
"GLB",
"gltf",
"GLTF",
],
)
.add_filter("SolidCraft design (container or JSON)", &[io::DESIGN_EXT, io::LEGACY_DESIGN_EXT, "json"])
.add_filter("STEP", &["step", "stp", "STEP", "STP"])
.add_filter("IGES", &["igs", "iges", "IGS", "IGES"])
.add_filter("FreeCAD and Rhino", &["FCStd", "fcstd", "3dm", "3DM"])
.add_filter("Mesh (3MF, STL, OBJ, glTF)", &["3mf", "3MF", "stl", "STL", "obj", "OBJ", "glb", "GLB", "gltf", "GLTF"])
.add_filter("Sketch drawings (DXF, SVG)", &["dxf", "DXF", "svg", "SVG"])
.add_filter("All files", &["*"])
.pick_file();
// The groups come from the format registry, so a new format joins this dialog by itself.
dialog = dialog.add_filter("Designs, CAD files and meshes", &case_variants(&io::formats::open_extensions(true)));
for (label, exts) in io::formats::import_filters(true) {
dialog = dialog.add_filter(label, &case_variants(&exts));
}
let future = dialog.add_filter("All files", &["*"]).pick_file();
Box::pin(async move { future.await.map(|p| p.path().to_string_lossy().into_owned()) })
})),
// One kind of file (the agent chat's attachments): only the types it names, as given and in
@@ -483,6 +510,18 @@ fn backend_preference() -> Option<String> {
graphics().and_then(|v| v.get("backend").and_then(serde_json::Value::as_str).map(str::to_string))
}
/// The viewport scene's sample count: the app's anti-aliasing (the window's pass is 1x), clamped
/// by the backend's policy (OpenGL renders 1x, see `effective_msaa_samples`). A count the device
/// cannot render with falls back to the supported default.
fn scene_samples(backend: eframe::wgpu::Backend, samples: u32, supported: &[u32]) -> u32 {
let requested = eframe::egui_wgpu::effective_msaa_samples(backend, samples.max(1));
if requested <= 1 || supported.is_empty() || supported.contains(&requested) {
requested
} else {
supported.iter().copied().filter(|&n| n <= solidcraft_ui_egui::prefs::Prefs::default().msaa).max().unwrap_or(1)
}
}
/// Anti-aliasing samples from Preferences (4 when unset).
fn msaa() -> u16 {
graphics()
@@ -529,6 +568,8 @@ fn app_viewport() -> Result<egui::ViewportBuilder, eframe::Error> {
const APP_ID: &str = solidcraft_engine::io::APP_ID;
fn main() -> eframe::Result {
#[cfg(target_os = "macos")]
objc2_foundation::NSProcessInfo::processInfo().setProcessName(objc2_foundation::ns_string!("SolidCraft"));
mark("main");
adopt_legacy_settings();
let mut safe_mode = startup::env_on(std::env::var(startup::SAFE_MODE_ENV).ok().as_deref());
@@ -579,6 +620,11 @@ fn main() -> eframe::Result {
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 {
notes.push(device_lost::note(cpu_renderer));
}
if safe_mode {
notes.push("Safe mode: default settings (yours are kept for the next normal start), no recovery offer, software rendering.".into());
}
@@ -604,9 +650,11 @@ fn main() -> eframe::Result {
solidcraft_engine::crash::phase("window");
let options = eframe::NativeOptions {
viewport: app_viewport()?,
depth_buffer: depth_bits,
stencil_buffer: STENCIL_BITS,
multisampling: samples,
// The window's pass (egui's) has no depth and one sample: the viewport draws its scene
// offscreen with its own depth and MSAA, then blits it, so the UI pass pays for neither.
depth_buffer: 0,
stencil_buffer: 0,
multisampling: 1,
// Kept with the other settings, not in a folder named after the app id. Safe mode reads
// and writes a scratch copy, leaving the user's settings as they were.
persistence_path: if safe_mode {
@@ -669,10 +717,16 @@ fn main() -> eframe::Result {
app.load_prefs(&p);
}
mark("app state and prefs");
let gpu = cc.wgpu_render_state.as_ref().map(|rs| LostWatch {
lost: rs.device_lost.clone(),
restart_on_software: device_lost::restart_on_software(after_loss, cpu_renderer),
lost_at: None,
});
if let Some(rs) = &cc.wgpu_render_state {
eprintln!("SolidCraft graphics: {:?}, MSAA {}x (requested {}x)", rs.adapter.get_info().backend, rs.msaa_samples, samples);
let scene_samples = scene_samples(rs.adapter.get_info().backend, u32::from(samples), &rs.msaa_supported);
eprintln!("SolidCraft graphics: {:?}, MSAA {scene_samples}x (requested {}x)", rs.adapter.get_info().backend, samples);
solidcraft_engine::crash::phase("viewport pipelines (shader compile)");
app.set_wgpu(rs, depth_bits, STENCIL_BITS, rs.msaa_samples);
app.set_wgpu_split(rs, depth_bits, STENCIL_BITS, scene_samples);
solidcraft_ui_egui::gpu::install_picture_renderer(rs);
// The graphics driver is loaded now; it may have put its own exception filter
// in front of ours.
@@ -682,8 +736,8 @@ fn main() -> eframe::Result {
solidcraft_ui_egui::prefs::check_msaa(&mut app, u32::from(samples));
let info = rs.adapter.get_info();
solidcraft_engine::crash::set_environment(&format!(
"Renderer: {:?} {} ({}), MSAA {}x",
info.backend, info.name, info.driver, rs.msaa_samples
"Renderer: {:?} {} ({}), MSAA {scene_samples}x",
info.backend, info.name, info.driver
));
}
mark("viewport pipelines");
@@ -739,6 +793,18 @@ fn main() -> eframe::Result {
}
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
let space_mouse = space_mouse::SpaceMouse::start(&cc.egui_ctx);
#[cfg(target_os = "macos")]
let native_menu = match native_menu::NativeMenu::new(&app, &cc.egui_ctx) {
Ok(menu) => Some(menu),
Err(error) => {
app.set_status(format!("System menu: {error}"), true);
None
}
};
#[cfg(target_os = "macos")]
{
app.native_file_menu = native_menu.is_some();
}
mark("app created");
solidcraft_engine::crash::phase("first frame");
Ok(Box::new(App {
@@ -746,8 +812,11 @@ fn main() -> eframe::Result {
starting,
frames: 0,
take_focus_unwanted: control_port.is_some(),
gpu,
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
space_mouse,
#[cfg(target_os = "macos")]
native_menu,
}))
}),
);
@@ -909,4 +978,13 @@ mod msaa_tests {
}
}
}
#[test]
fn the_scene_follows_the_backend_policy_too() {
use eframe::wgpu::Backend;
assert_eq!(super::scene_samples(Backend::Gl, 4, &[1, 4]), 1);
assert_eq!(super::scene_samples(Backend::Gl, 4, &[]), 1);
assert_eq!(super::scene_samples(Backend::Vulkan, 4, &[1, 4]), 4);
assert_eq!(super::scene_samples(Backend::Dx12, 8, &[1, 2, 4]), 4);
}
}
+189
View File
@@ -0,0 +1,189 @@
//! macOS system menus, owned and updated on eframe's main thread.
use std::sync::mpsc::{Receiver, sync_channel};
use muda::{
AboutMetadata, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu,
accelerator::{Accelerator, Code, Modifiers},
};
use solidcraft_ui_egui::{SolveApp, toolbar::FileAction};
pub struct NativeMenu {
menu: Menu,
actions: Vec<(MenuItem, FileAction)>,
help_actions: Vec<(MenuItem, &'static str)>,
events: Receiver<MenuEvent>,
recent: Submenu,
recent_paths: Vec<String>,
}
impl NativeMenu {
pub fn new(app: &SolveApp, ctx: &egui::Context) -> muda::Result<Self> {
let menu = Menu::new();
let application = Submenu::new("SolidCraft", true);
let file = Submenu::new(app.tr("ui.file"), true);
let edit = Submenu::new(app.tr_label("Edit"), true);
let recent = Submenu::new(app.tr("ui.open_recent"), true);
let window = Submenu::new("Window", true);
let help = Submenu::new(app.tr("ui.help"), true);
let mut help_actions = Vec::new();
let mut actions = Vec::new();
let mut add = |parent: &Submenu, label: &str, action: FileAction, shortcut: Option<Accelerator>| -> muda::Result<()> {
let item = MenuItem::new(label, Self::action_enabled(&action, app, ctx), shortcut);
parent.append(&item)?;
actions.push((item, action));
Ok(())
};
application.append(&PredefinedMenuItem::about(
Some("About SolidCraft"),
Some(AboutMetadata { name: Some("SolidCraft".into()), version: Some(env!("CARGO_PKG_VERSION").into()), ..Default::default() }),
))?;
application.append(&PredefinedMenuItem::separator())?;
add(
&application,
&format!("{}\u{2026}", app.tr("ui.preferences")),
FileAction::Settings,
Some(Accelerator::new(Modifiers::META, Code::Comma)),
)?;
add(&application, &format!("{}\u{2026}", app.tr("ui.keyboard_shortcuts")), FileAction::Shortcuts, None)?;
application.append(&PredefinedMenuItem::separator())?;
application.append(&PredefinedMenuItem::services(None))?;
application.append(&PredefinedMenuItem::separator())?;
application.append_items(&[
&PredefinedMenuItem::hide(Some("Hide SolidCraft")),
&PredefinedMenuItem::hide_others(None),
&PredefinedMenuItem::show_all(None),
&PredefinedMenuItem::separator(),
])?;
add(&application, &format!("{} SolidCraft", app.tr("ui.quit")), FileAction::Quit, Some(Accelerator::new(Modifiers::META, Code::KeyQ)))?;
add(&file, app.tr("ui.new_design"), FileAction::New, Some(Accelerator::new(Modifiers::META, Code::KeyN)))?;
add(&file, &format!("{}\u{2026}", app.tr("ui.open")), FileAction::Open, Some(Accelerator::new(Modifiers::META, Code::KeyO)))?;
file.append(&recent)?;
file.append(&PredefinedMenuItem::separator())?;
for (label, command) in
[("Insert STEP\u{2026}", "file.insert_step"), ("Insert Mesh\u{2026}", "file.insert_mesh"), ("Insert Design", "component.insert_design")]
{
add(&file, app.tr_label(label), FileAction::Command(command), None)?;
}
file.append(&PredefinedMenuItem::separator())?;
add(&file, app.tr("ui.save"), FileAction::Save, Some(Accelerator::new(Modifiers::META, Code::KeyS)))?;
add(
&file,
&format!("{}\u{2026}", app.tr("ui.save_as")),
FileAction::SaveAs,
Some(Accelerator::new(Modifiers::META | Modifiers::SHIFT, Code::KeyS)),
)?;
add(&file, app.tr_label("Save As JSON\u{2026}"), FileAction::SaveJson, None)?;
file.append(&PredefinedMenuItem::separator())?;
for (label, action) in [
("Export STL\u{2026}", FileAction::ExportMesh("stl")),
("Export 3MF\u{2026}", FileAction::ExportMesh("3mf")),
("Export STEP\u{2026}", FileAction::Export("step")),
("Export Current View as DXF\u{2026}", FileAction::Command("file.export_view_dxf")),
("Export OBJ\u{2026}", FileAction::Export("obj")),
("Export URDF\u{2026}", FileAction::Export("urdf")),
("Export Design as JSON\u{2026}", FileAction::Export("json")),
] {
add(&file, app.tr_label(label), action, None)?;
}
file.append(&PredefinedMenuItem::separator())?;
add(&file, app.tr_label("Design Properties"), FileAction::Command("document.metadata"), None)?;
add(&edit, app.tr("ui.undo"), FileAction::Undo, Some(Accelerator::new(Modifiers::META, Code::KeyZ)))?;
add(&edit, app.tr("ui.redo"), FileAction::Redo, Some(Accelerator::new(Modifiers::META | Modifiers::SHIFT, Code::KeyZ)))?;
add(&window, app.tr_label("Agent Chat"), FileAction::AgentChat, None)?;
window.append(&PredefinedMenuItem::separator())?;
window.append_items(&[
&PredefinedMenuItem::minimize(None),
&PredefinedMenuItem::maximize(None),
&PredefinedMenuItem::fullscreen(None),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::bring_all_to_front(None),
])?;
for (action, label) in [
("docs", "Documentation"),
("shortcuts", "Keyboard Shortcuts"),
("messages", "Messages\u{2026}"),
("report", "Report an Issue\u{2026}"),
("crashes", "Crash Reports\u{2026}"),
] {
let item = MenuItem::new(app.tr_label(label), true, None);
help.append(&item)?;
help_actions.push((item, action));
}
menu.append_items(&[&application, &file, &edit, &window, &help])?;
let (sender, events) = sync_channel(64);
let ctx = ctx.clone();
MenuEvent::set_event_handler(Some(move |event| {
let _ = sender.try_send(event);
ctx.request_repaint();
}));
menu.init_for_nsapp();
let mut native = Self { menu, actions, help_actions, events, recent, recent_paths: Vec::new() };
native.update_recent(app)?;
Ok(native)
}
pub fn raw_input_hook(&mut self, app: &mut SolveApp, ctx: &egui::Context, raw: &mut egui::RawInput) {
for event in self.events.try_iter().take(64) {
if let Some((_, action)) = self.help_actions.iter().find(|(item, _)| item.id() == &event.id) {
solidcraft_ui_egui::help::run(app, ctx, action);
}
if let Some((_, action)) = self.actions.iter().find(|(item, _)| item.id() == &event.id) {
if matches!(action, FileAction::Undo | FileAction::Redo) && ctx.egui_wants_keyboard_input() {
raw.events.push(egui::Event::Key {
key: egui::Key::Z,
physical_key: None,
pressed: true,
repeat: false,
modifiers: egui::Modifiers { command: true, mac_cmd: true, shift: matches!(action, FileAction::Redo), ..Default::default() },
});
} else {
action.run(app);
}
}
}
}
fn action_enabled(action: &FileAction, app: &SolveApp, ctx: &egui::Context) -> bool {
action.enabled(app)
|| (matches!(action, FileAction::Undo | FileAction::Redo) && ctx.egui_wants_keyboard_input() && FileAction::Settings.enabled(app))
}
pub fn tick(&mut self, app: &mut SolveApp, ctx: &egui::Context) {
for (item, action) in &self.actions {
let enabled = Self::action_enabled(action, app, ctx);
if item.is_enabled() != enabled {
item.set_enabled(enabled);
}
}
if !self.recent_paths.iter().eq(app.home.recent.iter().take(32))
&& let Err(error) = self.update_recent(app)
{
app.set_status(format!("Recent designs menu: {error}"), true);
}
}
fn update_recent(&mut self, app: &SolveApp) -> muda::Result<()> {
while self.recent.remove_at(0).is_some() {}
self.actions.retain(|(_, action)| !matches!(action, FileAction::OpenRecent(_) | FileAction::ClearRecent));
self.recent_paths = app.home.recent.iter().take(32).cloned().collect();
for path in &self.recent_paths {
let action = FileAction::OpenRecent(path.clone());
let item = MenuItem::new(path, action.enabled(app), None);
self.recent.append(&item)?;
self.actions.push((item, action));
}
self.recent.append(&PredefinedMenuItem::separator())?;
let clear = MenuItem::new(app.tr("ui.clear_recent"), !self.recent_paths.is_empty(), None);
self.recent.append(&clear)?;
self.actions.push((clear, FileAction::ClearRecent));
Ok(())
}
}
impl Drop for NativeMenu {
fn drop(&mut self) {
MenuEvent::set_event_handler(None::<fn(MenuEvent)>);
self.menu.remove_for_nsapp();
}
}
+33 -5
View File
@@ -284,15 +284,18 @@ fn decode_report(report: &[u8], layout: Layout, reading: &mut Reading, now: Inst
true
}
/// Publishes one input report from the device to the egui thread.
/// Publishes one input report from the device to the egui thread. A report that reads the same as
/// the reading it replaces is no news — a device may repeat its report at rest — so it asks for no
/// frame; while the cap moves, the view asks for the next frame itself.
fn publish(ctx: &Context, shared: &Mutex<Reading>, layout: Layout, report: &[u8]) {
if DRIVER_CONNECTED.load(Ordering::Relaxed) {
return;
}
if let Ok(mut reading) = shared.lock()
&& decode_report(report, layout, &mut reading, Instant::now())
{
ctx.request_repaint();
if let Ok(mut reading) = shared.lock() {
let before = reading.axes;
if decode_report(report, layout, &mut reading, Instant::now()) && reading.axes != before {
ctx.request_repaint();
}
}
}
@@ -886,6 +889,31 @@ mod tests {
assert_eq!(reading.current(now + STALE_AFTER, Axes::default()).translation, [0; 3]);
}
/// A device that repeats its reading at rest asks for no frame; a reading that changes does.
#[test]
fn a_repeated_report_at_rest_asks_for_no_frame() {
let ctx = Context::default();
let shared = Mutex::new(Reading::new(Instant::now()));
let layout = Layout::default();
let frame = || {
let mut out = ctx.run_ui(egui::RawInput::default(), |_| {});
out.textures_delta.clear();
};
let asked = || {
frame();
ctx.repaint_causes().iter().any(|c| c.file.ends_with("space_mouse.rs"))
};
frame();
// Report 1 carries translation (see the split-layout test).
let report = [1, 0x9c, 0xff, 0x2c, 0x01, 0, 0];
publish(&ctx, &shared, layout, &report);
assert!(asked(), "a report that moves the cap asks for a frame");
publish(&ctx, &shared, layout, &report);
assert!(!asked(), "the same reading at rest is no news");
publish(&ctx, &shared, layout, &[1, 0x9d, 0xff, 0x2c, 0x01, 0, 0]);
assert!(asked(), "a changed reading asks for a frame");
}
#[test]
fn a_held_reading_stays_until_the_next_one_and_a_hid_reading_goes_stale() {
let now = Instant::now();
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 629 B

After

Width:  |  Height:  |  Size: 660 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 944 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

+87 -66
View File
@@ -1,88 +1,93 @@
//! Original SolidCraft mark, shared by desktop, packaging and CLI.
//! Original SolidCraft mark, shared by desktop, packaging and CLI: the borderless mark, five
//! faces with transparent gaps between them, drawn from the macOS icon's
//! `macos/source/SolidCraft.icon/Assets/03-solid-blue-cutout.svg`.
type P = (f32, f32);
/// One face in that SVG's units: its outline, and its linear gradient from one point and
/// colour to another.
struct Face {
outline: &'static [P],
from: (P, [u8; 3]),
to: (P, [u8; 3]),
}
const FACES: [Face; 5] = [
// Inner side of the upper step.
Face {
outline: &[(547.883, 282.66), (709.797, 188.211), (709.797, 375.985)],
from: ((619.14, 179.734), [29, 99, 198]),
to: ((694.687, 396.761), [21, 73, 158]),
},
// Top of the lower step.
Face {
outline: &[(727.797, 593.626), (727.797, 407.136), (889.571, 500.381)],
from: ((619.14, 413.244), [69, 159, 255]),
to: ((790.839, 605.547), [35, 121, 231]),
},
// Right face, with the recess.
Face {
outline: &[(530.0, 521.17), (691.797, 427.912), (691.797, 655.928), (907.594, 531.545), (907.594, 740.159), (530.0, 957.8)],
from: ((632.876, 399.508), [27, 102, 207]),
to: ((783.971, 968.175), [18, 69, 150]),
},
// Left face, stepped.
Face {
outline: &[(116.406, 510.769), (116.406, 303.529), (494.0, 521.17), (494.0, 957.8), (314.203, 854.167), (314.203, 624.777)],
from: ((207.063, 303.357), [37, 141, 245]),
to: ((426.837, 968.175), [20, 97, 218]),
},
// Top of the extruded profile.
Face {
outline: &[(134.374, 272.334), (512.0, 53.364), (691.828, 157.015), (476.117, 282.846), (673.752, 396.761), (512.0, 489.993)],
from: ((385.63, 53.364), [81, 173, 255]),
to: ((522.989, 510.769), [43, 131, 241]),
},
];
/// The mark's extent in SVG units: left, top, width, height.
const BOUNDS: (f32, f32, f32, f32) = (116.406, 53.364, 791.188, 904.436);
/// This block is the icon image data
pub fn rgba(size: usize) -> Result<Vec<u8>, String> {
if !(1..=2048).contains(&size) {
return Err("icon size must be 1..=2048".into());
}
type P = (f32, f32);
// Coverage buffer at 4x4 samples per pixel (2x2 for big sizes), averaged down at the end.
let ss = if size > 512 { 2 } else { 4 };
let n = size * ss;
let mut buf = vec![[0u8; 4]; n * n];
let s = n as f32 / 412.217; // SVG units -> samples: the logo fills the height
let ox = (n as f32 - 357.0 * s) / 2.0; // and is centred sideways
// Fill a convex polygon given in SVG units.
let mut fill = |pts: &[P], col: [u8; 3]| {
let v: Vec<P> = pts.iter().map(|p| (ox + p.0 * s, p.1 * s)).collect();
let (bx, by, bw, bh) = BOUNDS;
let s = n as f32 / bh; // SVG units -> samples: the mark fills the height
let (ox, oy) = ((n as f32 - bw * s) / 2.0 - bx * s, -by * s); // and is centred sideways
// Fill each face's outline (even-odd spans per row, so concave outlines work) with its gradient.
for face in &FACES {
let v: Vec<P> = face.outline.iter().map(|p| (ox + p.0 * s, oy + p.1 * s)).collect();
let (((fx, fy), c0), ((tx, ty), c1)) = (face.from, face.to);
let (gx, gy) = (tx - fx, ty - fy);
let g2 = gx * gx + gy * gy;
let (top, bottom) = v.iter().fold((f32::MAX, f32::MIN), |(a, b), p| (a.min(p.1), b.max(p.1)));
let mut xs = Vec::with_capacity(v.len());
for y in ((top - 0.5).ceil() as i32).max(0)..=((bottom - 0.5).floor() as i32).min(n as i32 - 1) {
let cy = y as f32 + 0.5;
let (mut lo, mut hi) = (f32::MAX, f32::MIN);
xs.clear();
for i in 0..v.len() {
let (a, b) = (v[i], v[(i + 1) % v.len()]);
if (a.1 <= cy) != (b.1 <= cy) {
let x = a.0 + (cy - a.1) / (b.1 - a.1) * (b.0 - a.0);
lo = lo.min(x);
hi = hi.max(x);
xs.push(a.0 + (cy - a.1) / (b.1 - a.1) * (b.0 - a.0));
}
}
if lo > hi {
continue;
}
for x in ((lo - 0.5).ceil() as i32).max(0)..=((hi - 0.5).floor() as i32).min(n as i32 - 1) {
buf[y as usize * n + x as usize] = [col[0], col[1], col[2], 255];
xs.sort_by(f32::total_cmp);
for &[from, to] in xs.as_chunks::<2>().0 {
for x in ((from - 0.5).ceil() as i32).max(0)..=((to - 0.5).floor() as i32).min(n as i32 - 1) {
// The sample's place along the gradient, in SVG units.
let (px, py) = ((x as f32 + 0.5 - ox) / s, (cy - oy) / s);
let t = (((px - fx) * gx + (py - fy) * gy) / g2).clamp(0.0, 1.0);
let mix = |k: usize| (f32::from(c0[k]) + (f32::from(c1[k]) - f32::from(c0[k])) * t).round() as u8;
buf[y as usize * n + x as usize] = [mix(0), mix(1), mix(2), 255];
}
}
}
};
// Faces: each is a few overlapping convex pieces (the filler only does convex shapes).
let (light, mid, dark) = ([96, 158, 236], [58, 136, 238], [55, 108, 197]);
fill(&[(3.5, 105.9), (177.0, 5.8), (263.4, 55.6), (263.8, 156.0), (177.0, 406.4)], light);
fill(&[(177.0, 206.1), (263.8, 156.0), (350.5, 206.2), (350.5, 306.3), (177.0, 406.4)], light);
fill(&[(3.5, 105.9), (177.0, 206.1), (177.0, 406.4)], mid);
fill(&[(3.5, 105.9), (3.5, 205.7), (90.3, 256.2), (177.0, 206.1)], mid);
fill(&[(177.0, 206.1), (90.3, 256.2), (90.0, 356.2), (177.0, 406.4)], mid);
fill(&[(264.1, 155.8), (177.0, 206.1), (177.0, 406.4), (263.8, 256.2)], dark);
fill(&[(263.8, 256.2), (350.5, 206.2), (350.5, 306.3), (177.0, 406.4)], dark);
fill(&[(177.0, 206.1), (177.0, 406.4), (350.5, 306.3)], dark);
fill(&[(263.8, 55.9), (263.8, 156.0), (177.1, 105.9)], dark);
// White edge lines, 10 units wide: a rectangle per segment and a disc at each inner corner.
let white = [255, 255, 255];
let mut stroke = |pts: &[P], closed: bool| {
let m = pts.len();
for i in 0..(if closed { m } else { m - 1 }) {
let (a, b) = (pts[i], pts[(i + 1) % m]);
let l = ((b.0 - a.0).powi(2) + (b.1 - a.1).powi(2)).sqrt();
let (nx, ny) = (-(b.1 - a.1) / l * 5.0, (b.0 - a.0) / l * 5.0);
fill(&[(a.0 + nx, a.1 + ny), (b.0 + nx, b.1 + ny), (b.0 - nx, b.1 - ny), (a.0 - nx, a.1 - ny)], white);
}
for c in &pts[if closed { 0 } else { 1 }..if closed { m } else { m - 1 }] {
let disc: Vec<P> = (0..16)
.map(|k| {
let t = k as f32 * std::f32::consts::TAU / 16.0;
(c.0 + 5.0 * t.cos(), c.1 + 5.0 * t.sin())
})
.collect();
fill(&disc, white);
}
};
stroke(&[(263.8, 55.9), (177.1, 105.9), (263.8, 156.0), (263.8, 256.2), (350.6, 206.1)], false);
stroke(&[(5.0, 105.9), (178.5, 206.1), (178.5, 406.4)], false);
stroke(&[(177.0, 206.1), (263.8, 156.0)], false);
stroke(
&[
(178.5, 5.8),
(5.0, 105.9),
(5.0, 206.1),
(91.7, 256.2),
(91.7, 356.4),
(178.5, 406.4),
(352.0, 306.3),
(352.0, 206.1),
(265.2, 156.0),
(265.2, 55.9),
],
true,
);
}
// Average each ss x ss block down to one pixel (straight alpha = covered share).
let mut rgba = Vec::with_capacity(size * size * 4);
for y in 0..size {
@@ -123,4 +128,20 @@ mod tests {
assert!(super::rgba(0).is_err());
assert!(super::rgba(2049).is_err());
}
/// Borderless: no white edge lines, and the gaps between faces are see-through. At 256 px
/// the gap between the left and right faces (SVG x 494..530) is transparent on the row
/// through the middle of the mark, and no opaque pixel is white.
#[test]
fn shared_mark_is_borderless() {
let size = 256;
let pixels = super::rgba(size).expect("valid size");
let px = |x: usize, y: usize| pixels.as_chunks::<4>().0[y * size + x];
let s = size as f32 / 904.436;
let gap_x = ((size as f32 - 791.188 * s) / 2.0 + (512.0 - 116.406) * s) as usize;
let row = ((750.0 - 53.364) * s) as usize;
assert_eq!(px(gap_x, row)[3], 0, "the seam between the left and right faces is a gap");
assert!(px(gap_x - 8, row)[3] == 255 && px(gap_x + 8, row)[3] == 255, "faces on both sides of the gap");
assert!(pixels.as_chunks::<4>().0.iter().all(|p| p[3] == 0 || p[0] < 200), "no white edges");
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 364 KiB

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 44 KiB

+4 -4
View File
@@ -104,10 +104,10 @@ pub(crate) fn brand_mark(p: &egui::Painter, c: Pos2) {
/// The same original mark as a single quiet silhouette, without face seams or outlines.
pub(crate) fn brand_mark_silhouette(p: &egui::Painter, c: Pos2, height: f32, ink: Color32) {
brand_mark_styled(p, c, height, [ink; 4], false);
brand_mark_styled(p, c, height, [ink; 3], false);
}
fn brand_mark_styled(p: &egui::Painter, c: Pos2, height: f32, colours: [Color32; 4], edges: bool) {
fn brand_mark_styled(p: &egui::Painter, c: Pos2, height: f32, colours: [Color32; 3], edges: bool) {
let s = height / 412.217;
let m = |pts: &[(f32, f32)]| -> Vec<egui::Pos2> { pts.iter().map(|&(x, y)| pos2(c.x + (x - 178.5) * s, c.y + (y - 206.1) * s)).collect() };
// egui only fills convex shapes, so each face is a few overlapping convex pieces.
@@ -115,7 +115,7 @@ fn brand_mark_styled(p: &egui::Painter, c: Pos2, height: f32, colours: [Color32;
p.add(egui::Shape::convex_polygon(m(pts), col, Stroke::NONE));
};
// The faces are separated by gaps that show the application bar, not by white lines.
let [light, mid, dark, _] = colours;
let [light, mid, dark] = colours;
let edge_col = crate::theme::Tokens::get().app_bar;
face(light, &[(3.5, 105.9), (177.0, 5.8), (263.4, 55.6), (263.8, 156.0), (177.0, 406.4)]);
face(light, &[(177.0, 206.1), (263.8, 156.0), (350.5, 206.2), (350.5, 306.3), (177.0, 406.4)]);
@@ -129,7 +129,7 @@ fn brand_mark_styled(p: &egui::Painter, c: Pos2, height: f32, colours: [Color32;
if !edges {
return;
}
// White edge lines.
// The seams between the faces and around the mark, in the bar's colour, so they read as gaps.
let edge = Stroke::new(10.0 * s, edge_col);
let line = |pts: &[(f32, f32)]| {
p.add(egui::Shape::line(m(pts), edge));
+3 -1
View File
@@ -1060,7 +1060,7 @@
"search.try": "Zadejte název příkazu, prvek v tomto návrhu nebo parametr. Chcete-li spustit příkaz s hodnotami, zadejte jeho id a objekt JSON: {example}",
"search.valid": "Platné",
"settings.display_quality_note": "Jemnější úrovně kreslí zakřivené plochy a hrany hladčeji, ale síťování trvá déle. Mění se jen zobrazení, nikdy návrh ani exporty.",
"settings.frame_rate_note": "Zobrazuje snímky za sekundu v pravém dolním rohu pohledu. Dokud je zapnuto, pohled se překresluje nepřetržitě.",
"settings.frame_rate_note": "Zobrazuje snímky za sekundu v pravém dolním rohu pohledu.",
"settings.measure_axis_distances": "Zobrazit vzdálenosti X/Y/Z",
"settings.measure_axis_distances_help": "Při otevřeném měření zobrazit kromě celkové vzdálenosti i složky ve směru os.",
"settings.msaa_applies_on_restart": "Vyhlazování se projeví při příštím spuštění SolidCraftu",
@@ -1183,6 +1183,7 @@
"ui.agent_chat_files_left_out": "K jedné zprávě lze připojit nejvýše {max} souborů; vynecháno: {files}",
"ui.agent_chat_files_loading": "Čekání na přečtení souborů",
"ui.agent_chat_files_max": "K jedné zprávě nejvýše {max} souborů",
"ui.agent_chat_flatpak_none_found": "Nebyl nalezen žádný kódovací agent. Sandbox Flatpaku ve výchozím stavu nevidí agenty nainstalované ve vaší domovské složce a nemá přístup k síti. Chcete-li povolit Claude Code a jeho adaptér ACP nainstalované přes nvm nebo do ~/.local/bin, spusťte v terminálu tento příkaz a poté SolidCraft restartujte. Udělí přístup k uvedeným programům a přihlašovacím souborům a také přístup k síti:",
"ui.agent_chat_import_failed": "{name}: import selhal: {error}",
"ui.agent_chat_intro": "Chatujte s kódovacím agentem, kterého již používáte, nebo s modelem na zvoleném endpointu. Pracuje na otevřeném návrhu pomocí nástrojů SolidCraftu: jeho změny vidíte průběžně a každou zprávu lze vrátit jedním krokem. Co smí dělat bez ptaní, nastavíte v Předvolby > AI.",
"ui.agent_chat_key_in_env": "klíč nastaven",
@@ -1502,6 +1503,7 @@
"ui.grid_follows_view_hint": "Boční pohledy zobrazí mřížku na svislé rovině, která je k nim přivrácená, místo na zemi",
"ui.ground_shadow": "Stín na podložce",
"ui.groupSelected": "Seskupit vybrané",
"ui.hatch": "Šrafování",
"ui.heading_add_ins": "DOPLŇKY",
"ui.heading_assemble": "SESTAVA",
"ui.heading_box": "KVÁDR",
+3 -1
View File
@@ -1060,7 +1060,7 @@
"search.try": "Skriv et kommandonavn, en feature i dette design eller en parameter. Du kører en kommando med værdier ved at skrive dens id og et JSON-objekt: {example}",
"search.valid": "Gyldig",
"settings.display_quality_note": "Finere niveauer viser krumme flader og kanter mere jævnt og tager længere tid at opbygge mesh til. Kun modelvisningen ændres, aldrig designet eller eksporten.",
"settings.frame_rate_note": "Viser billeder pr. sekund nederst til højre i modelvisningen. Når dette er slået til, gentegnes visningen løbende.",
"settings.frame_rate_note": "Viser billeder pr. sekund nederst til højre i modelvisningen.",
"settings.measure_axis_distances": "Vis X/Y/Z-afstande",
"settings.measure_axis_distances_help": "Vis afstanden langs X-, Y- og Z-aksen sammen med den samlede afstand i Mål.",
"settings.msaa_applies_on_restart": "Kantudjævning træder i kraft, næste gang SolidCraft starter",
@@ -1183,6 +1183,7 @@
"ui.agent_chat_files_left_out": "Højst {max} filer pr. besked; udeladt: {files}",
"ui.agent_chat_files_loading": "Venter på indlæsning af filerne",
"ui.agent_chat_files_max": "Højst {max} filer i én besked",
"ui.agent_chat_flatpak_none_found": "Ingen kodeagent fundet. Flatpak-sandkassen kan som standard ikke se agenter, der er installeret i din hjemmemappe, og har ingen netværksadgang. For at tillade Claude Code og dets ACP-adapter installeret via nvm eller i ~/.local/bin skal du køre denne kommando i en terminal og derefter genstarte SolidCraft. Den giver adgang til de nævnte programmer og loginfiler samt netværksadgang:",
"ui.agent_chat_import_failed": "{name}: Importen mislykkedes: {error}",
"ui.agent_chat_intro": "Chat med en kodeagent, du allerede bruger, eller en modeltjeneste, du vælger. Den arbejder på det åbne design gennem SolidCrafts værktøjer: Ændringer vises undervejs og fortrydes som ét trin pr. besked. Hvad den må gøre uden at spørge, angives i Indstillinger > AI.",
"ui.agent_chat_key_in_env": "nøgle angivet",
@@ -1502,6 +1503,7 @@
"ui.grid_follows_view_hint": "Sidevisninger viser gitteret på det lodrette plan, der vender mod dem, i stedet for grundplanet",
"ui.ground_shadow": "Skygge på grundplan",
"ui.groupSelected": "Gruppér valgte",
"ui.hatch": "Skravering",
"ui.heading_add_ins": "ADD-INS",
"ui.heading_assemble": "SAMLING",
"ui.heading_box": "BOKS",
+14 -1
View File
@@ -562,8 +562,10 @@
"cmd.component_activate": "Komponente aktivieren",
"cmd.component_break_link": "Verknüpfung trennen",
"cmd.component_capture_position": "Position erfassen",
"cmd.component_copies": "Identische Kopien finden",
"cmd.component_create": "Neue Komponente",
"cmd.component_delete": "Komponente löschen",
"cmd.component_extract": "In Datei extrahieren",
"cmd.component_from_bodies": "Komponenten aus Körpern erstellen",
"cmd.component_insert_design": "Konstruktion einfügen",
"cmd.component_links": "Verknüpfungen auflisten",
@@ -576,6 +578,7 @@
"cmd.component_properties": "Physikalische Eigenschaften der Komponente",
"cmd.component_relink": "Quelle neu verknüpfen",
"cmd.component_rename": "Komponente umbenennen",
"cmd.component_replace_with_link": "Durch Verknüpfung ersetzen",
"cmd.component_revert_position": "Position zurücksetzen",
"cmd.component_unlock": "Inhalt entsperren",
"cmd.component_update_links": "Verknüpfungen aktualisieren",
@@ -1178,7 +1181,7 @@
"search.try": "Geben Sie einen Befehlsnamen, ein Merkmal in diesem Design oder einen Parameter ein. Um einen Befehl mit Werten auszuführen, geben Sie seine ID und ein JSON-Objekt ein: {example}",
"search.valid": "Gültig",
"settings.display_quality_note": "Feinere Stufen zeigen gekrümmte Flächen und Kanten glatter und brauchen länger zum Vernetzen. Nur die Ansicht ändert sich, nie das Design oder Exporte.",
"settings.frame_rate_note": "Zeigt die Bilder pro Sekunde unten rechts in der Ansicht. Solange sie an ist, wird die Ansicht fortlaufend neu gezeichnet.",
"settings.frame_rate_note": "Zeigt die Bilder pro Sekunde unten rechts in der Ansicht.",
"settings.measure_axis_distances": "X/Y/Z-Maße anzeigen",
"settings.measure_axis_distances_help": "Bei geöffneter Messung die Abstände entlang der Weltachsen zusätzlich zum Gesamtabstand anzeigen.",
"settings.msaa_applies_on_restart": "Die Kantenglättung wird beim nächsten Start von SolidCraft übernommen",
@@ -1301,6 +1304,7 @@
"ui.agent_chat_files_left_out": "Höchstens {max} Dateien pro Nachricht; ausgelassen: {files}",
"ui.agent_chat_files_loading": "Warten, bis die Dateien gelesen sind",
"ui.agent_chat_files_max": "Höchstens {max} Dateien pro Nachricht",
"ui.agent_chat_flatpak_none_found": "Kein Coding-Agent gefunden. Die Flatpak-Sandbox sieht standardmäßig keine Agenten in Ihrem Benutzerordner und hat keinen Netzwerkzugriff. Um Claude Code und seinen ACP-Adapter zu erlauben, die über nvm oder in ~/.local/bin installiert sind, führen Sie diesen Befehl in einem Terminal aus und starten Sie SolidCraft dann neu. Er gewährt Zugriff auf die genannten Programme und Anmeldedateien sowie Netzwerkzugriff:",
"ui.agent_chat_import_failed": "{name}: Der Import ist fehlgeschlagen: {error}",
"ui.agent_chat_intro": "Chatten Sie mit einem Coding-Agenten, den Sie schon nutzen, oder einem Modell-Endpunkt Ihrer Wahl. Er arbeitet über die Werkzeuge von SolidCraft am geöffneten Design: Seine Änderungen erscheinen sofort und lassen sich pro Nachricht in einem Schritt rückgängig machen. Was er ohne Nachfrage darf, legen Sie unter Einstellungen > KI fest.",
"ui.agent_chat_key_in_env": "Schlüssel gesetzt",
@@ -1589,6 +1593,9 @@
"ui.export_keymap_tip": "Tasten, S-Box-Favoriten und die Symbolleiste als Datei",
"ui.extend": "Verlängern",
"ui.extent_type": "Ausdehnungstyp",
"ui.extract_component": "In Datei extrahieren…",
"ui.extract_design_changed": "Das Design hat sich beim Wählen einer Datei geändert. Starten Sie In Datei extrahieren erneut.",
"ui.extract_done": "Die Komponente ist jetzt ein eigenes Design und hier verknüpft.",
"ui.extrude": "Extrudieren",
"ui.factor": "Faktor",
"ui.feature_history": "Feature-Verlauf",
@@ -1620,6 +1627,7 @@
"ui.grid_follows_view_hint": "Seitenansichten zeigen das Raster auf der aufrechten Ebene, die ihnen zugewandt ist, statt auf dem Boden",
"ui.ground_shadow": "Bodenschatten",
"ui.groupSelected": "Auswahl gruppieren",
"ui.hatch": "Schraffur",
"ui.heading_add_ins": "ADD-INS",
"ui.heading_assemble": "ZUSAMMENBAU",
"ui.heading_box": "BLOCK",
@@ -1720,6 +1728,10 @@
"ui.length_decimals": "Längen-Dezimalstellen",
"ui.light": "Hell",
"ui.line": "Linie",
"ui.link_copies": "Identische Kopien verknüpfen",
"ui.link_copies_keep": "Behalten",
"ui.link_copies_link": "Verknüpfen",
"ui.link_copies_text": "Diese Komponenten erzeugen dieselben Körper wie {name}. Sollen sie zu Platzierungen seiner Verknüpfung werden?",
"ui.lock": "Sperren",
"ui.loft": "Loft",
"ui.look_at": "Ansehen",
@@ -1843,6 +1855,7 @@
"ui.render": "Rendern",
"ui.renderer": "Renderer",
"ui.repeat": "Wiederholen",
"ui.replace_with_link_done": "Die Kopien sind jetzt Platzierungen des verknüpften Designs.",
"ui.report_a_bug": "Fehler melden",
"ui.reset": "Zurücksetzen",
"ui.reset_all": "Alle zurücksetzen",
+26 -1
View File
@@ -176,6 +176,7 @@
"Export JSON…": "Export JSON…",
"Export design library...": "Export design library...",
"Export hidden edges as dashed lines": "Export hidden edges as dashed lines",
"Exported by SolidWorks, which usually saves Y up": "Exported by SolidWorks, which usually saves Y up",
"Expression": "Expression",
"Extend to meet": "Extend to meet",
"Extents": "Extents",
@@ -609,8 +610,10 @@
"cmd.component_activate": "Activate Component",
"cmd.component_break_link": "Break Link",
"cmd.component_capture_position": "Capture Position",
"cmd.component_copies": "Find Identical Copies",
"cmd.component_create": "New Component",
"cmd.component_delete": "Delete Component",
"cmd.component_extract": "Extract to File",
"cmd.component_from_bodies": "Create Components from Bodies",
"cmd.component_insert_design": "Insert Design",
"cmd.component_links": "List Links",
@@ -623,6 +626,7 @@
"cmd.component_properties": "Component Physical Properties",
"cmd.component_relink": "Relink Source",
"cmd.component_rename": "Rename Component",
"cmd.component_replace_with_link": "Replace with Link",
"cmd.component_revert_position": "Revert Position",
"cmd.component_unlock": "Unlock Contents",
"cmd.component_update_links": "Update Links",
@@ -740,6 +744,7 @@
"cmd.motion_plot": "Plot Motion Study",
"cmd.motion_study": "Motion Study",
"cmd.motion_trace": "Motion Trace",
"cmd.motion_trace_edit": "Edit Motion Trace",
"cmd.motion_trace_keep": "Keep Motion Trace",
"cmd.occurrence_copy": "Paste (Instance)",
"cmd.occurrence_ground": "Ground",
@@ -1353,7 +1358,7 @@
"search.try": "Type a command name, a feature in this design, or a parameter. To run a command with values, type its id and a JSON object: {example}",
"search.valid": "Valid",
"settings.display_quality_note": "Only the display. Designs and exports are not affected.",
"settings.frame_rate_note": "Shows frames per second in the view's bottom-right corner. While it is on, the view redraws continuously.",
"settings.frame_rate_note": "Shows frames per second in the view's bottom-right corner.",
"settings.measure_axis_distances": "Show X/Y/Z distances",
"settings.measure_axis_distances_help": "Show world-axis component dimensions alongside the total distance while Measure is open.",
"settings.msaa_applies_on_restart": "Anti-aliasing applies the next time SolidCraft starts",
@@ -1362,6 +1367,7 @@
"settings.reset_intro": "These settings don't work on this computer and would keep SolidCraft from starting, so they were changed:",
"settings.reset_title": "Settings were changed",
"settings.supersampling_note": "Draws the view at a higher resolution and filters it down: smoother edges, lines and grid on screens below 4K. It costs GPU time: about 2.25× the pixels at 1.5× and 4× at 2×.",
"sheet.no_body": "there is no sheet metal body: start one with Flange > Base, or turn a plate into sheet metal with Convert to Sheet Metal (pick its large flat face)",
"shortcut.json_error": "parameters must be JSON: {error}",
"shortcut.no_command": "no command `{id}`; the text before the JSON must be a command id",
"shortcut.pin": "Pin to the top of the S box",
@@ -1398,6 +1404,9 @@
"thread.designation": "Designation",
"thread.iso_metric": "ISO metric",
"thread.select_size": "Select size",
"timeline.delete_after_marker_one": "Delete 1 Feature After History Marker",
"timeline.delete_after_marker_other": "Delete {count} Features After History Marker",
"timeline.delete_many": "Delete {count} Selected Features",
"timeline.group": "New Feature Folder",
"timeline.move_many": "Move Timeline Items",
"timeline.move_to_group": "Move Features to Folder",
@@ -1476,6 +1485,7 @@
"ui.agent_chat_files_left_out": "At most {max} files go with one message; left out: {files}",
"ui.agent_chat_files_loading": "Waiting for the files to be read",
"ui.agent_chat_files_max": "At most {max} files on one message",
"ui.agent_chat_flatpak_none_found": "No coding agent found. The Flatpak sandbox cannot see agents installed in your home folder and has no network access by default. To allow Claude Code and its ACP adapter installed through nvm or ~/.local/bin, run this command in a terminal, then restart SolidCraft. It grants access to the listed program and sign-in files, plus network access:",
"ui.agent_chat_import_failed": "{name}: the import failed: {error}",
"ui.agent_chat_intro": "Chat with a coding agent you already use, or a model endpoint you choose. It works on the open design through SolidCraft's tools: its changes show as it makes them and undo as one step per message. What it may do without asking is set in Preferences > AI.",
"ui.agent_chat_key_in_env": "key set",
@@ -1548,8 +1558,10 @@
"ui.ai_add": "Add",
"ui.ai_add_agent": "Add agent…",
"ui.ai_add_agent_title": "Add agent",
"ui.ai_add_anthropic": "Anthropic-compatible API",
"ui.ai_add_api": "OpenAI-compatible API",
"ui.ai_add_custom": "Custom command",
"ui.ai_add_endpoint": "API endpoint",
"ui.ai_add_installed": "Installed agent",
"ui.ai_add_installed_note": "An entry of your own for an installed agent, for example with other arguments. Installed agents are listed by themselves.",
"ui.ai_added": "added",
@@ -1578,6 +1590,7 @@
"ui.ai_key_needs_https": "A key is only sent over HTTPS, or to this computer.",
"ui.ai_key_note": "Stored in the system keychain, never in SolidCraft's settings. Leave it empty for a local server.",
"ui.ai_keychain_error": "The system keychain refused: {error}",
"ui.ai_kind": "Kind",
"ui.ai_mode_all": "Allow everything",
"ui.ai_mode_all_note": "Nothing asks first. Crash reports stay off limits and the folder limits still apply.",
"ui.ai_mode_all_warning": "The agent can save, overwrite, export and replace your design without asking. Use this only with an agent you trust, on designs you have saved.",
@@ -1599,6 +1612,7 @@
"ui.ai_panel": "Panel",
"ui.ai_permissions": "Permissions",
"ui.ai_preset": "Preset",
"ui.ai_protocol": "Protocol",
"ui.ai_remove": "Remove",
"ui.ai_request_limit": "stopped after 24 requests for one message; send another message to go on",
"ui.ai_reset": "Reset",
@@ -1609,6 +1623,8 @@
"ui.ai_test": "Test",
"ui.ai_test_acp_ok": "Answers: {agent}",
"ui.ai_test_acp_tip": "Starts the agent briefly and asks it for a session; ends it again",
"ui.ai_test_answered": "Reached: {model} answered",
"ui.ai_test_anthropic_tip": "Sends a short message to the endpoint",
"ui.ai_test_api_tip": "Asks the endpoint for its models",
"ui.ai_test_no_model": "Reached: {count} models, but not {model}",
"ui.ai_test_ok": "Reached: {count} models",
@@ -1766,6 +1782,9 @@
"ui.export_keymap_tip": "Keys, S box favourites and the toolbar, as a file",
"ui.extend": "Extend",
"ui.extent_type": "Extent Type",
"ui.extract_component": "Extract to File…",
"ui.extract_design_changed": "The design changed while choosing a file. Start Extract to File again.",
"ui.extract_done": "The component is now its own design, linked here.",
"ui.extrude": "Extrude",
"ui.factor": "Factor",
"ui.feature_history": "Feature history",
@@ -1797,6 +1816,7 @@
"ui.grid_follows_view_hint": "Side views show the grid on the upright plane facing them instead of the ground",
"ui.ground_shadow": "Ground shadow",
"ui.groupSelected": "Group Selected",
"ui.hatch": "Hatch",
"ui.heading_add_ins": "ADD-INS",
"ui.heading_assemble": "ASSEMBLE",
"ui.heading_box": "BOX",
@@ -1904,6 +1924,10 @@
"ui.length_decimals": "Length decimals",
"ui.light": "Light",
"ui.line": "Line",
"ui.link_copies": "Link Identical Copies",
"ui.link_copies_keep": "Keep",
"ui.link_copies_link": "Link",
"ui.link_copies_text": "These components build the same bodies as {name}. Make them placements of its link?",
"ui.lock": "Lock",
"ui.loft": "Loft",
"ui.look_at": "Look At",
@@ -2027,6 +2051,7 @@
"ui.render": "Render",
"ui.renderer": "Renderer",
"ui.repeat": "Repeat",
"ui.replace_with_link_done": "The copies are now placements of the linked design.",
"ui.report_a_bug": "Report a bug",
"ui.reset": "Reset",
"ui.reset_all": "Reset All",
+3 -1
View File
@@ -1060,7 +1060,7 @@
"search.try": "Escribe el nombre de un comando, una operación de este diseño o un parámetro. Para ejecutar un comando con valores, escribe su id y un objeto JSON: {example}",
"search.valid": "Válido",
"settings.display_quality_note": "Los niveles más finos muestran las caras y aristas curvas con mayor suavidad, pero tardan más en generar la malla. Solo cambia el visor, nunca el diseño ni las exportaciones.",
"settings.frame_rate_note": "Muestra los fotogramas por segundo en la esquina inferior derecha de la vista. Mientras esté activado, la vista se redibuja continuamente.",
"settings.frame_rate_note": "Muestra los fotogramas por segundo en la esquina inferior derecha de la vista.",
"settings.measure_axis_distances": "Mostrar distancias X/Y/Z",
"settings.measure_axis_distances_help": "Mostrar las cotas de cada eje global junto a la distancia total mientras está abierta la herramienta Medir.",
"settings.msaa_applies_on_restart": "El suavizado de bordes se aplicará la próxima vez que se inicie SolidCraft",
@@ -1183,6 +1183,7 @@
"ui.agent_chat_files_left_out": "Se pueden enviar como máximo {max} archivos con un mensaje; omitidos: {files}",
"ui.agent_chat_files_loading": "Esperando a que se lean los archivos",
"ui.agent_chat_files_max": "Como máximo {max} archivos por mensaje",
"ui.agent_chat_flatpak_none_found": "No se ha encontrado ningún agente de programación. De forma predeterminada, el entorno aislado de Flatpak no ve los agentes instalados en tu carpeta personal y no tiene acceso a la red. Para permitir Claude Code y su adaptador ACP instalados con nvm o en ~/.local/bin, ejecuta este comando en un terminal y reinicia SolidCraft. Concede acceso a los programas y archivos de inicio de sesión indicados, además de acceso a la red:",
"ui.agent_chat_import_failed": "{name}: la importación falló: {error}",
"ui.agent_chat_intro": "Habla con un agente de programación que ya utilices o con un servicio de modelos que elijas. Trabaja sobre el diseño abierto mediante las herramientas de SolidCraft: sus cambios se muestran al realizarlos y pueden deshacerse de una vez por mensaje. Lo que puede hacer sin preguntar se configura en Preferencias > IA.",
"ui.agent_chat_key_in_env": "clave configurada",
@@ -1502,6 +1503,7 @@
"ui.grid_follows_view_hint": "Las vistas laterales muestran la cuadrícula en el plano vertical que tienen enfrente, en lugar del suelo",
"ui.ground_shadow": "Sombra en el suelo",
"ui.groupSelected": "Agrupar selección",
"ui.hatch": "Rayado",
"ui.heading_add_ins": "COMPLEMENTOS",
"ui.heading_assemble": "ENSAMBLAR",
"ui.heading_box": "CAJA",
+12 -10
View File
@@ -1044,13 +1044,13 @@
"sample.bracket.name": "Équerre en tôle",
"sample.enclosure.about": "Boîte en coque avec lèvre, bossages de vis, appui et clip",
"sample.enclosure.name": "Boîtier plastique",
"sample.flange.about": "Moyeu et bride avec un réseau circulaire piloté par un paramètre",
"sample.flange.about": "Moyeu et bride avec une répétition circulaire pilotée par un paramètre",
"sample.flange.name": "Bride boulonnée",
"sample.hinge.about": "Deux composants et une liaison pivot, entraînée à 60°",
"sample.hinge.name": "Assemblage de charnière",
"sample.plate.about": "Plaque paramétrique avec congés, perçages et un bossage alésé",
"sample.plate.name": "Plaque d'exemple",
"sample.vents.about": "Rainures en réseau rectangulaire : lignes, colonnes et pas sont des paramètres",
"sample.vents.about": "Rainures en répétition rectangulaire : lignes, colonnes et pas sont des paramètres",
"sample.vents.name": "Plaque d'aération",
"search.command_keys": "Entrée exécuter Maj+Entrée exécuter et garder ouvert Échap fermer",
"search.commands": "Commandes",
@@ -1066,7 +1066,7 @@
"search.try": "Saisissez un nom de commande, une entité de cette conception ou un paramètre. Pour exécuter une commande avec des valeurs, saisissez son id et un objet JSON : {example}",
"search.valid": "Valide",
"settings.display_quality_note": "Les niveaux plus fins affichent plus lisses les faces et arêtes courbes, mais leur maillage prend plus de temps. Seule la vue change, jamais la conception ni les exports.",
"settings.frame_rate_note": "Affiche les images par seconde dans le coin inférieur droit de la vue. Tant qu'elle est activée, la vue est redessinée en continu.",
"settings.frame_rate_note": "Affiche les images par seconde dans le coin inférieur droit de la vue.",
"settings.measure_axis_distances": "Afficher les cotes X/Y/Z",
"settings.measure_axis_distances_help": "Afficher les cotes suivant les axes du repère global en plus de la distance totale pendant la mesure.",
"settings.msaa_applies_on_restart": "L'anticrénelage s'appliquera au prochain démarrage de SolidCraft",
@@ -1189,6 +1189,7 @@
"ui.agent_chat_files_left_out": "Un message ne peut contenir que {max} fichiers au plus ; non joints : {files}",
"ui.agent_chat_files_loading": "Lecture des fichiers en cours",
"ui.agent_chat_files_max": "{max} fichiers au plus par message",
"ui.agent_chat_flatpak_none_found": "Aucun agent de programmation trouvé. Par défaut, le bac à sable Flatpak ne voit pas les agents installés dans votre dossier personnel et n’a pas accès au réseau. Pour autoriser Claude Code et son adaptateur ACP installés avec nvm ou dans ~/.local/bin, exécuter cette commande dans un terminal, puis redémarrer SolidCraft. Elle donne accès aux programmes et fichiers de connexion indiqués, ainsi qu’au réseau :",
"ui.agent_chat_import_failed": "{name} : l'importation a échoué : {error}",
"ui.agent_chat_intro": "Discuter avec un agent de programmation que vous utilisez déjà, ou avec un point d'accès de modèle de votre choix. Il travaille sur la conception ouverte avec les outils de SolidCraft : ses modifications apparaissent au fur et à mesure et s'annulent en une seule étape par message. Ce qu'il peut faire sans demander se règle dans Préférences › IA.",
"ui.agent_chat_key_in_env": "clé définie",
@@ -1373,7 +1374,7 @@
"ui.chamfer_type": "Type de chanfrein",
"ui.change_workspace": "Changer d'espace de travail",
"ui.circle": "Cercle",
"ui.circular_pattern": "Réseau circulaire",
"ui.circular_pattern": "Répétition circulaire",
"ui.clear": "Effacer",
"ui.clear_recent": "Effacer les récents",
"ui.clear_the_selection": "Effacer la sélection",
@@ -1508,11 +1509,12 @@
"ui.grid_follows_view_hint": "Dans les vues de côté, la grille s'affiche sur le plan vertical qui leur fait face plutôt que sur le sol",
"ui.ground_shadow": "Ombre au sol",
"ui.groupSelected": "Grouper la sélection",
"ui.hatch": "Hachures",
"ui.heading_add_ins": "COMPLÉMENTS",
"ui.heading_assemble": "ASSEMBLER",
"ui.heading_box": "BOÎTE",
"ui.heading_chamfer": "CHANFREIN",
"ui.heading_circular_pattern": "RÉSEAU CIRCULAIRE",
"ui.heading_circular_pattern": "RÉPÉTITION CIRCULAIRE",
"ui.heading_coil": "HÉLICE",
"ui.heading_combine": "COMBINER",
"ui.heading_configure": "CONFIGURER",
@@ -1545,14 +1547,14 @@
"ui.heading_offset_plane": "PLAN DÉCALÉ",
"ui.heading_ok": "OK",
"ui.heading_parameters": "PARAMÈTRES",
"ui.heading_pattern": "RÉSEAU",
"ui.heading_pattern_on_path": "RÉSEAU SUR TRAJECTOIRE",
"ui.heading_pattern": "RÉPÉTITION",
"ui.heading_pattern_on_path": "RÉPÉTITION SUR TRAJECTOIRE",
"ui.heading_pipe": "TUYAU",
"ui.heading_plane_at_angle": "PLAN INCLINÉ",
"ui.heading_plastic": "PLASTIQUE",
"ui.heading_position": "POSITION",
"ui.heading_prepare": "PRÉPARER",
"ui.heading_rectangular_pattern": "RÉSEAU RECTANGULAIRE",
"ui.heading_rectangular_pattern": "RÉPÉTITION RECTANGULAIRE",
"ui.heading_revolve": "RÉVOLUTION",
"ui.heading_right": "DROITE",
"ui.heading_save_mesh": "ENREGISTRER COMME MAILLAGE",
@@ -1682,7 +1684,7 @@
"ui.part_size_shared": "Reconstruit la pièce en remplaçant les entités ajoutées. Toutes les occurrences de ce composant sont mises à jour.",
"ui.paste": "Coller",
"ui.path": "Trajectoire",
"ui.pattern_on_path": "Réseau sur trajectoire",
"ui.pattern_on_path": "Répétition sur trajectoire",
"ui.perpendicular": "Perpendicularité",
"ui.perspective_view": "Vue en perspective",
"ui.pinToShortcuts": "Épingler aux raccourcis",
@@ -1719,7 +1721,7 @@
"ui.rail_fit": "Ajustement au rail",
"ui.reachable": "Accessible",
"ui.rectangle": "Rectangle",
"ui.rectangular_pattern": "Réseau rectangulaire",
"ui.rectangular_pattern": "Répétition rectangulaire",
"ui.redo": "Rétablir",
"ui.relink_design_changed": "L’assemblage a changé pendant le choix d’une source. Relancez Relier à nouveau la source.",
"ui.relink_done": "Source reliée à nouveau pour toutes les instances.",
+3 -1
View File
@@ -1060,7 +1060,7 @@
"search.try": "Escribe o nome dunha orde, unha operación deste deseño ou un parámetro. Para executar unha orde con valores, escribe o seu id e un obxecto JSON: {example}",
"search.valid": "Válido",
"settings.display_quality_note": "Os niveis máis finos debuxan as caras e arestas curvas máis suaves e tardan máis en xerar a malla. Só cambia a vista, nunca o deseño nin as exportacións.",
"settings.frame_rate_note": "Mostra as imaxes por segundo na esquina inferior dereita da vista. Mentres está activado, a vista redebúxase continuamente.",
"settings.frame_rate_note": "Mostra as imaxes por segundo na esquina inferior dereita da vista.",
"settings.measure_axis_distances": "Mostrar as distancias en X/Y/Z",
"settings.measure_axis_distances_help": "Mostra as compoñentes da distancia segundo os eixes do mundo xunto á distancia total mentres Medir está aberto.",
"settings.msaa_applies_on_restart": "O antialiasing aplicarase a próxima vez que se inicie SolidCraft",
@@ -1183,6 +1183,7 @@
"ui.agent_chat_files_left_out": "Cunha mensaxe van como máximo {max} ficheiros; deixados fóra: {files}",
"ui.agent_chat_files_loading": "Agardando a que se lean os ficheiros",
"ui.agent_chat_files_max": "Como máximo {max} ficheiros por mensaxe",
"ui.agent_chat_flatpak_none_found": "Non se atopou ningún axente de programación. De forma predeterminada, o illamento de Flatpak non ve os axentes instalados no teu cartafol persoal e non ten acceso á rede. Para permitir Claude Code e o seu adaptador ACP instalados con nvm ou en ~/.local/bin, executa esta orde nun terminal e despois reinicia SolidCraft. Concede acceso aos programas e ficheiros de inicio de sesión indicados, ademais de acceso á rede:",
"ui.agent_chat_import_failed": "{name}: fallou a importación: {error}",
"ui.agent_chat_intro": "Conversa cun axente de programación que xa uses, ou cun punto de acceso de modelo que escollas. Traballa no deseño aberto a través das ferramentas de SolidCraft: os seus cambios vense mentres os fai e desfanse nun paso por mensaxe. O que pode facer sen preguntar defínese en Preferencias > IA.",
"ui.agent_chat_key_in_env": "clave configurada",
@@ -1502,6 +1503,7 @@
"ui.grid_follows_view_hint": "As vistas laterais mostran a grella no plano vertical que ten diante en vez do chan",
"ui.ground_shadow": "Sombra no chan",
"ui.groupSelected": "Agrupar a selección",
"ui.hatch": "Raiado",
"ui.heading_add_ins": "COMPLEMENTOS",
"ui.heading_assemble": "ENSAMBLAR",
"ui.heading_box": "CAIXA",
+3 -1
View File
@@ -1059,7 +1059,7 @@
"search.try": "Írja be egy parancs nevét, a terv egy elemét vagy egy paramétert. Parancs értékekkel való futtatásához írja be az azonosítóját és egy JSON-objektumot: {example}",
"search.valid": "Érvényes",
"settings.display_quality_note": "A finomabb szintek simábban rajzolják az ívelt lapokat és éleket, de tovább tart a hálózás. Csak a nézetablak változik, a terv és az exportok soha.",
"settings.frame_rate_note": "Megjeleníti a másodpercenkénti képkockaszámot a nézet jobb alsó sarkában. Bekapcsolt állapotban a nézet folyamatosan újrarajzolódik.",
"settings.frame_rate_note": "Megjeleníti a másodpercenkénti képkockaszámot a nézet jobb alsó sarkában.",
"settings.measure_axis_distances": "X/Y/Z távolságok megjelenítése",
"settings.measure_axis_distances_help": "A világtengely-összetevők méreteit is megjeleníti a teljes távolság mellett, amíg a Mérés nyitva van.",
"settings.msaa_applies_on_restart": "Az élsimítás a SolidCraft következő indításakor lép érvénybe",
@@ -1182,6 +1182,7 @@
"ui.agent_chat_files_left_out": "Egy üzenethez legfeljebb {max} fájl tartozhat; kihagyva: {files}",
"ui.agent_chat_files_loading": "Várakozás a fájlok beolvasására",
"ui.agent_chat_files_max": "Egy üzenetben legfeljebb {max} fájl lehet",
"ui.agent_chat_flatpak_none_found": "Nem található kódolóügynök. A Flatpak homokozó alapértelmezés szerint nem látja a saját mappájába telepített ügynököket, és nincs hálózati hozzáférése. Az nvm-mel vagy a ~/.local/bin mappába telepített Claude Code és ACP-adaptere engedélyezéséhez futtassa ezt a parancsot egy terminálban, majd indítsa újra a SolidCraftot. Hozzáférést ad a felsorolt programokhoz és bejelentkezési fájlokhoz, valamint a hálózathoz:",
"ui.agent_chat_import_failed": "{name}: az importálás sikertelen: {error}",
"ui.agent_chat_intro": "Csevegjen egy már használt kódolóügynökkel vagy egy választott modellvégponttal. A megnyitott terven a SolidCraft eszközein keresztül dolgozik: a módosításai láthatók közben, és üzenetenként egy lépésben vonhatók vissza. Hogy mit tehet kérdés nélkül, azt a Beállítások > AI menüpontban adhatja meg.",
"ui.agent_chat_key_in_env": "kulcs beállítva",
@@ -1501,6 +1502,7 @@
"ui.grid_follows_view_hint": "Az oldalnézetek a talaj helyett a feléjük néző függőleges síkon mutatják a rácsot",
"ui.ground_shadow": "Talajárnyék",
"ui.groupSelected": "Kijelöltek csoportosítása",
"ui.hatch": "Sraffozás",
"ui.heading_add_ins": "BŐVÍTMÉNYEK",
"ui.heading_assemble": "ÖSSZESZERELÉS",
"ui.heading_box": "DOBOZ",
+3 -1
View File
@@ -1059,7 +1059,7 @@
"search.try": "Ketik nama perintah, fitur di desain ini, atau parameter. Untuk menjalankan perintah dengan nilai, ketik id-nya dan objek JSON: {example}",
"search.valid": "Valid",
"settings.display_quality_note": "Level lebih halus menggambar muka dan tepi lengkung lebih mulus dan butuh waktu meshing lebih lama. Hanya viewport yang berubah, tidak pernah desain atau ekspor.",
"settings.frame_rate_note": "Menampilkan frame per detik di sudut kanan bawah tampilan. Saat aktif, tampilan digambar ulang terus-menerus.",
"settings.frame_rate_note": "Menampilkan frame per detik di sudut kanan bawah tampilan.",
"settings.measure_axis_distances": "Tampilkan jarak X/Y/Z",
"settings.measure_axis_distances_help": "Tampilkan dimensi komponen sumbu dunia di samping jarak total saat Ukur terbuka.",
"settings.msaa_applies_on_restart": "Anti-aliasing berlaku saat SolidCraft dimulai berikutnya",
@@ -1182,6 +1182,7 @@
"ui.agent_chat_files_left_out": "Maksimal {max} file per pesan; tidak disertakan: {files}",
"ui.agent_chat_files_loading": "Menunggu file selesai dibaca",
"ui.agent_chat_files_max": "Maksimal {max} file per pesan",
"ui.agent_chat_flatpak_none_found": "Agen coding tidak ditemukan. Secara bawaan, sandbox Flatpak tidak dapat melihat agen yang terpasang di folder rumah Anda dan tidak memiliki akses jaringan. Untuk mengizinkan Claude Code dan adaptor ACP-nya yang dipasang melalui nvm atau di ~/.local/bin, jalankan perintah ini di terminal, lalu mulai ulang SolidCraft. Perintah ini memberi akses ke program dan berkas masuk yang disebutkan, serta akses jaringan:",
"ui.agent_chat_import_failed": "{name}: impor gagal: {error}",
"ui.agent_chat_intro": "Mengobrol dengan agen coding yang sudah Anda pakai, atau endpoint model pilihan Anda. Agen bekerja pada desain yang terbuka lewat alat SolidCraft: perubahannya tampil saat dibuat dan dapat diurungkan satu langkah per pesan. Yang boleh dilakukannya tanpa bertanya diatur di Preferensi > AI.",
"ui.agent_chat_key_in_env": "key diatur",
@@ -1501,6 +1502,7 @@
"ui.grid_follows_view_hint": "Tampilan samping menampilkan grid pada bidang tegak yang menghadapnya, bukan pada dasar",
"ui.ground_shadow": "Bayangan dasar",
"ui.groupSelected": "Kelompokkan yang Dipilih",
"ui.hatch": "Arsir",
"ui.heading_add_ins": "ADD-IN",
"ui.heading_assemble": "RAKIT",
"ui.heading_box": "KOTAK",
+3 -1
View File
@@ -1063,7 +1063,7 @@
"search.try": "Digita il nome di un comando, una feature di questo progetto o un parametro. Per eseguire un comando con dei valori, digita il suo id e un oggetto JSON: {example}",
"search.valid": "Valido",
"settings.display_quality_note": "I livelli più fini rendono più uniformi le facce curve e gli spigoli e richiedono più tempo per la mesh. Cambia solo la vista, mai il progetto o le esportazioni.",
"settings.frame_rate_note": "Mostra i fotogrammi al secondo nell'angolo inferiore destro della vista. Quando è attivo, la vista viene ridisegnata continuamente.",
"settings.frame_rate_note": "Mostra i fotogrammi al secondo nell'angolo inferiore destro della vista.",
"settings.measure_axis_distances": "Mostra distanze X/Y/Z",
"settings.measure_axis_distances_help": "Mostra le componenti lungo gli assi globali accanto alla distanza totale quando Misura è aperto.",
"settings.msaa_applies_on_restart": "L'antialiasing verrà applicato al prossimo avvio di SolidCraft",
@@ -1186,6 +1186,7 @@
"ui.agent_chat_files_left_out": "Un messaggio può contenere al massimo {max} file; esclusi: {files}",
"ui.agent_chat_files_loading": "In attesa della lettura dei file",
"ui.agent_chat_files_max": "Al massimo {max} file per messaggio",
"ui.agent_chat_flatpak_none_found": "Nessun agente di programmazione trovato. Per impostazione predefinita, la sandbox Flatpak non vede gli agenti installati nella tua cartella home e non ha accesso alla rete. Per consentire Claude Code e il suo adattatore ACP installati con nvm o in ~/.local/bin, esegui questo comando in un terminale, poi riavvia SolidCraft. Concede l’accesso ai programmi e ai file di accesso elencati, oltre all’accesso alla rete:",
"ui.agent_chat_import_failed": "{name}: importazione non riuscita: {error}",
"ui.agent_chat_intro": "Conversa con un agente di programmazione che già usi o con un endpoint di modello a tua scelta. Opera sul progetto aperto tramite gli strumenti di SolidCraft: le modifiche sono visibili mentre vengono eseguite e si annullano in un solo passo per messaggio. Le azioni consentite senza conferma si impostano in Preferenze > IA.",
"ui.agent_chat_key_in_env": "chiave impostata",
@@ -1505,6 +1506,7 @@
"ui.grid_follows_view_hint": "Le viste laterali mostrano la griglia sul piano verticale rivolto verso di esse anziché sul piano di appoggio",
"ui.ground_shadow": "Ombra sul piano di appoggio",
"ui.groupSelected": "Raggruppa selezionati",
"ui.hatch": "Tratteggio",
"ui.heading_add_ins": "COMPONENTI AGGIUNTIVI",
"ui.heading_assemble": "ASSEMBLA",
"ui.heading_box": "PARALLELEPIPEDO",
+26 -1
View File
@@ -176,6 +176,7 @@
"Export JSON…": "JSONを書き出し…",
"Export design library...": "デザインライブラリを書き出す...",
"Export hidden edges as dashed lines": "点線で出力",
"Exported by SolidWorks, which usually saves Y up": "SolidWorks で書き出されたファイルです。通常は Y 軸が上になります",
"Expression": "式",
"Extend to meet": "交わるまで延長",
"Extents": "範囲",
@@ -609,8 +610,10 @@
"cmd.component_activate": "コンポーネントをアクティブ化",
"cmd.component_break_link": "リンクを解除",
"cmd.component_capture_position": "位置をキャプチャ",
"cmd.component_copies": "同一のコピーを検索",
"cmd.component_create": "新規コンポーネント",
"cmd.component_delete": "コンポーネントを削除",
"cmd.component_extract": "ファイルに抽出",
"cmd.component_from_bodies": "ボディからコンポーネントを作成",
"cmd.component_insert_design": "デザインを挿入",
"cmd.component_links": "リンクを一覧表示",
@@ -623,6 +626,7 @@
"cmd.component_properties": "コンポーネントの物理的プロパティ",
"cmd.component_relink": "参照元を再リンク",
"cmd.component_rename": "コンポーネントの名前を変更",
"cmd.component_replace_with_link": "リンクに置き換え",
"cmd.component_revert_position": "位置を元に戻す",
"cmd.component_unlock": "内容のロックを解除",
"cmd.component_update_links": "リンクを更新",
@@ -740,6 +744,7 @@
"cmd.motion_plot": "モーション スタディのグラフ",
"cmd.motion_study": "モーション スタディ",
"cmd.motion_trace": "モーション トレース",
"cmd.motion_trace_edit": "モーション トレースを編集",
"cmd.motion_trace_keep": "モーション トレースを保持",
"cmd.occurrence_copy": "貼り付け(インスタンス)",
"cmd.occurrence_ground": "空間固定",
@@ -1353,7 +1358,7 @@
"search.try": "コマンド名、このデザインのフィーチャー、またはパラメーターを入力します。値を指定して実行するには、IDとJSONオブジェクトを入力します:{example}",
"search.valid": "有効",
"settings.display_quality_note": "表示だけに影響します。設計や書き出しは変わりません。",
"settings.frame_rate_note": "ビューの右下に 1 秒あたりのフレーム数を表示します。オンの間、ビューは常に再描画されます。",
"settings.frame_rate_note": "ビューの右下に 1 秒あたりのフレーム数を表示します。",
"settings.measure_axis_distances": "計測のX・Y・Z距離を表示",
"settings.measure_axis_distances_help": "計測ダイアログを開いている間、2点間距離に加えてワールドX・Y・Z軸方向の寸法線と距離を表示します。",
"settings.msaa_applies_on_restart": "アンチエイリアスは次回 SolidCraft を起動したときに適用されます",
@@ -1362,6 +1367,7 @@
"settings.reset_intro": "次の設定はこのコンピューターでは使用できず、SolidCraft が起動できなくなるため変更されました:",
"settings.reset_title": "設定が変更されました",
"settings.supersampling_note": "ビューを高い解像度で描画してから縮小します。4K 未満の画面でエッジ、線、グリッドが滑らかになります。GPU の負荷が増えます(1.5× で約 2.25 倍、2× で 4 倍のピクセル)。",
"sheet.no_body": "板金ボディがありません。フランジ > Base で作成するか、板金に変換でプレートを板金に変換してください(広い平面を選択)。",
"shortcut.json_error": "パラメータはJSON形式で指定してください: {error}",
"shortcut.no_command": "コマンド`{id}`がありません。JSONの前のテキストはコマンドIDである必要があります",
"shortcut.pin": "Sボックスの先頭にピン留め",
@@ -1398,6 +1404,9 @@
"thread.designation": "ねじの呼び",
"thread.iso_metric": "ISOメートルねじ",
"thread.select_size": "サイズを選択",
"timeline.delete_after_marker_one": "履歴マーカー以降の 1 個のフィーチャーを削除",
"timeline.delete_after_marker_other": "履歴マーカー以降の {count} 個のフィーチャーを削除",
"timeline.delete_many": "選択した {count} 個のフィーチャーを削除",
"timeline.group": "フィーチャーフォルダを新規作成",
"timeline.move_many": "タイムライン項目を移動",
"timeline.move_to_group": "フィーチャーをフォルダへ移動",
@@ -1476,6 +1485,7 @@
"ui.agent_chat_files_left_out": "1 つのメッセージで送れるファイルは {max} 個までです。送らなかったファイル: {files}",
"ui.agent_chat_files_loading": "ファイルの読み込みを待っています",
"ui.agent_chat_files_max": "1 つのメッセージに添付できるのは {max} 個までです",
"ui.agent_chat_flatpak_none_found": "コーディングエージェントが見つかりません。Flatpak のサンドボックスは、既定ではホームフォルダー内のエージェントにアクセスできず、ネットワークにも接続できません。nvm または ~/.local/bin にインストールした Claude Code と ACP アダプターを許可するには、端末で次のコマンドを実行し、SolidCraft を再起動してください。指定したプログラムとログインファイルへのアクセス、およびネットワーク接続を許可します:",
"ui.agent_chat_import_failed": "{name}: インポートに失敗しました: {error}",
"ui.agent_chat_intro": "普段使っているコーディングエージェント、または選んだモデルのエンドポイントとチャットできます。SolidCraft のツールで開いているデザインを操作し、変更はその場で表示され、メッセージごとに1回で元に戻せます。確認なしで許可する範囲は「環境設定 > AI」で設定します。",
"ui.agent_chat_key_in_env": "キー設定済み",
@@ -1548,8 +1558,10 @@
"ui.ai_add": "追加",
"ui.ai_add_agent": "エージェントを追加…",
"ui.ai_add_agent_title": "エージェントを追加",
"ui.ai_add_anthropic": "Anthropic 互換 API",
"ui.ai_add_api": "OpenAI 互換 API",
"ui.ai_add_custom": "カスタムコマンド",
"ui.ai_add_endpoint": "API エンドポイント",
"ui.ai_add_installed": "インストール済みエージェント",
"ui.ai_add_installed_note": "インストール済みエージェントの独自エントリ(例: 別の引数で起動)。インストール済みのエージェントは自動で一覧に表示されます。",
"ui.ai_added": "追加済み",
@@ -1578,6 +1590,7 @@
"ui.ai_key_needs_https": "キーは HTTPS 経由、またはこのコンピューター宛てにのみ送信されます。",
"ui.ai_key_note": "システムのキーチェーンに保存され、SolidCraft の設定には保存されません。ローカルサーバーの場合は空欄のままにします。",
"ui.ai_keychain_error": "システムのキーチェーンが拒否しました: {error}",
"ui.ai_kind": "種類",
"ui.ai_mode_all": "すべて許可",
"ui.ai_mode_all_note": "確認なしで実行します。クラッシュレポートは引き続き対象外で、フォルダー制限も有効です。",
"ui.ai_mode_all_warning": "エージェントは確認なしでデザインの保存・上書き・エクスポート・置き換えができます。信頼できるエージェントで、保存済みのデザインに対してのみ使ってください。",
@@ -1599,6 +1612,7 @@
"ui.ai_panel": "パネル",
"ui.ai_permissions": "権限",
"ui.ai_preset": "プリセット",
"ui.ai_protocol": "プロトコル",
"ui.ai_remove": "削除",
"ui.ai_request_limit": "1メッセージあたり24リクエストで停止しました。続けるには、もう一度メッセージを送ってください",
"ui.ai_reset": "リセット",
@@ -1609,6 +1623,8 @@
"ui.ai_test": "テスト",
"ui.ai_test_acp_ok": "応答あり: {agent}",
"ui.ai_test_acp_tip": "エージェントを短時間起動してセッションを要求し、終了します",
"ui.ai_test_answered": "接続成功: {model} が応答しました",
"ui.ai_test_anthropic_tip": "エンドポイントに短いメッセージを送信します",
"ui.ai_test_api_tip": "エンドポイントにモデル一覧を問い合わせます",
"ui.ai_test_no_model": "接続成功: {count} 個のモデル({model} はありません)",
"ui.ai_test_ok": "接続成功: {count} 個のモデル",
@@ -1766,6 +1782,9 @@
"ui.export_keymap_tip": "ショートカット、Sボックスのお気に入り、ツールバー設定をファイルに保存",
"ui.extend": "延長",
"ui.extent_type": "範囲タイプ",
"ui.extract_component": "ファイルに抽出…",
"ui.extract_design_changed": "ファイルの選択中にデザインが変更されました。ファイルへの抽出をやり直してください。",
"ui.extract_done": "コンポーネントは独立したデザインになり、ここにリンクされました。",
"ui.extrude": "押し出し",
"ui.factor": "倍率",
"ui.feature_history": "フィーチャー履歴",
@@ -1797,6 +1816,7 @@
"ui.grid_follows_view_hint": "側面ビューでは、グリッドを地面ではなく、そのビューに向いた垂直平面に表示します",
"ui.ground_shadow": "地面の影",
"ui.groupSelected": "選択をグループ化",
"ui.hatch": "ハッチング",
"ui.heading_add_ins": "アドイン",
"ui.heading_assemble": "アセンブリ",
"ui.heading_box": "直方体",
@@ -1904,6 +1924,10 @@
"ui.length_decimals": "長さの小数点以下の桁数",
"ui.light": "ライト",
"ui.line": "線分",
"ui.link_copies": "同一のコピーをリンク",
"ui.link_copies_keep": "そのまま",
"ui.link_copies_link": "リンク",
"ui.link_copies_text": "これらのコンポーネントは {name} と同じボディを作成します。そのリンクの配置にしますか?",
"ui.lock": "ロック",
"ui.loft": "ロフト",
"ui.look_at": "正面視",
@@ -2027,6 +2051,7 @@
"ui.render": "レンダリング",
"ui.renderer": "レンダラー",
"ui.repeat": "繰り返し",
"ui.replace_with_link_done": "コピーはリンクされたデザインの配置になりました。",
"ui.report_a_bug": "不具合を報告",
"ui.reset": "リセット",
"ui.reset_all": "すべてリセット",
+3 -1
View File
@@ -1064,7 +1064,7 @@
"search.try": "명령 이름, 이 디자인의 피처 또는 매개변수를 입력하세요. 값과 함께 명령을 실행하려면 명령 ID와 JSON 객체를 입력하세요: {example}",
"search.valid": "올바름",
"settings.display_quality_note": "세밀할수록 곡면과 모서리가 더 매끄럽게 표시되지만 메시 생성 시간이 늘어납니다. 뷰포트만 바뀌며 설계나 내보내기는 바뀌지 않습니다.",
"settings.frame_rate_note": "뷰의 오른쪽 아래에 초당 프레임 수를 표시합니다. 켜져 있는 동안 뷰를 계속 다시 그립니다.",
"settings.frame_rate_note": "뷰의 오른쪽 아래에 초당 프레임 수를 표시합니다.",
"settings.measure_axis_distances": "X/Y/Z 거리 표시",
"settings.measure_axis_distances_help": "측정 대화상자가 열려 있는 동안 전체 거리와 함께 월드 축 방향 치수와 거리를 표시합니다.",
"settings.msaa_applies_on_restart": "안티앨리어싱은 다음에 SolidCraft를 시작할 때 적용됩니다",
@@ -1187,6 +1187,7 @@
"ui.agent_chat_files_left_out": "메시지 하나에는 파일을 최대 {max}개까지 보낼 수 있습니다. 제외됨: {files}",
"ui.agent_chat_files_loading": "파일을 읽는 동안 기다리는 중",
"ui.agent_chat_files_max": "메시지 하나에 파일은 최대 {max}개",
"ui.agent_chat_flatpak_none_found": "코딩 에이전트를 찾지 못했습니다. Flatpak 샌드박스는 기본적으로 홈 폴더에 설치된 에이전트를 볼 수 없고 네트워크에도 접근할 수 없습니다. nvm 또는 ~/.local/bin에 설치한 Claude Code와 ACP 어댑터를 허용하려면 터미널에서 다음 명령을 실행한 뒤 SolidCraft를 다시 시작하세요. 나열된 프로그램과 로그인 파일에 대한 접근 권한 및 네트워크 접근 권한을 부여합니다:",
"ui.agent_chat_import_failed": "{name}: 가져오기 실패: {error}",
"ui.agent_chat_intro": "이미 사용 중인 코딩 에이전트나 선택한 모델 엔드포인트와 채팅하세요. SolidCraft 도구로 열린 디자인을 다루며, 변경 사항은 바로 보이고 메시지마다 한 번에 실행 취소할 수 있습니다. 묻지 않고 할 수 있는 일은 환경 설정 > AI에서 정합니다.",
"ui.agent_chat_key_in_env": "키 설정됨",
@@ -1506,6 +1507,7 @@
"ui.grid_follows_view_hint": "측면 뷰에서는 지면 대신 마주 보는 수직 평면에 격자를 표시합니다",
"ui.ground_shadow": "바닥 그림자",
"ui.groupSelected": "선택 항목 그룹화",
"ui.hatch": "해칭",
"ui.heading_add_ins": "애드인",
"ui.heading_assemble": "조립",
"ui.heading_box": "박스",
+3 -1
View File
@@ -1060,7 +1060,7 @@
"search.try": "Taip nama arahan, ciri dalam reka bentuk ini, atau parameter. Untuk menjalankan arahan dengan nilai, taip ID-nya dan objek JSON: {example}",
"search.valid": "Sah",
"settings.display_quality_note": "Tahap halus melicinkan muka dan tepi melengkung, tetapi mesh lebih lambat disiapkan. Hanya paparan berubah, bukan reka bentuk atau eksport.",
"settings.frame_rate_note": "Papar bingkai sesaat di kanan bawah. Jika diaktifkan, pandangan dilukis semula tanpa henti.",
"settings.frame_rate_note": "Papar bingkai sesaat di kanan bawah.",
"settings.measure_axis_distances": "Papar jarak X/Y/Z",
"settings.measure_axis_distances_help": "Papar jarak paksi dunia X/Y/Z bersama jarak keseluruhan semasa Ukur dibuka.",
"settings.msaa_applies_on_restart": "Antialias digunakan apabila SolidCraft dimulakan semula",
@@ -1183,6 +1183,7 @@
"ui.agent_chat_files_left_out": "Paling banyak {max} fail bersama satu mesej; ditinggalkan: {files}",
"ui.agent_chat_files_loading": "Menunggu fail dibaca",
"ui.agent_chat_files_max": "Paling banyak {max} fail pada satu mesej",
"ui.agent_chat_flatpak_none_found": "Tiada ejen pengekodan. Secara lalai, kotak pasir Flatpak tidak dapat melihat ejen yang dipasang dalam folder rumah anda dan tiada akses rangkaian. Untuk membenarkan Claude Code dan penyesuai ACP-nya yang dipasang melalui nvm atau dalam ~/.local/bin, jalankan arahan ini dalam terminal, kemudian mulakan semula SolidCraft. Ia memberi akses kepada program dan fail log masuk yang disenaraikan, serta akses rangkaian:",
"ui.agent_chat_import_failed": "{name}: import gagal: {error}",
"ui.agent_chat_intro": "Sembang dengan ejen pengekodan biasa anda atau titik akhir model pilihan. Ejen mengubah reka bentuk melalui alat SolidCraft; perubahan dipaparkan terus dan boleh dibatalkan sekali bagi setiap mesej. Tetapkan kebenaran di Tetapan > AI.",
"ui.agent_chat_key_in_env": "kunci tersedia",
@@ -1502,6 +1503,7 @@
"ui.grid_follows_view_hint": "Pandangan sisi guna grid satah tegak yang menghadapnya, bukan satah tanah",
"ui.ground_shadow": "Bayang tanah",
"ui.groupSelected": "Kumpulkan Pilihan",
"ui.hatch": "Lorek",
"ui.heading_add_ins": "TAMBAHAN",
"ui.heading_assemble": "PASANG",
"ui.heading_box": "KOTAK",
+3 -1
View File
@@ -1059,7 +1059,7 @@
"search.try": "Typ een opdrachtnaam, een kenmerk in dit ontwerp of een parameter. Om een opdracht met waarden uit te voeren, typt u de id en een JSON-object: {example}",
"search.valid": "Geldig",
"settings.display_quality_note": "Fijnere niveaus tekenen gebogen vlakken en randen vloeiender en kosten meer tijd om te meshen. Alleen de viewport verandert, nooit het ontwerp of de exports.",
"settings.frame_rate_note": "Toont frames per seconde in de rechteronderhoek van de weergave. Zolang dit aan staat, wordt de weergave continu opnieuw getekend.",
"settings.frame_rate_note": "Toont frames per seconde in de rechteronderhoek van de weergave.",
"settings.measure_axis_distances": "X/Y/Z-afstanden tonen",
"settings.measure_axis_distances_help": "Toon de componentmaten langs de wereldassen naast de totale afstand terwijl Meten open is.",
"settings.msaa_applies_on_restart": "Anti-aliasing wordt toegepast bij de volgende start van SolidCraft",
@@ -1182,6 +1182,7 @@
"ui.agent_chat_files_left_out": "Maximaal {max} bestanden per bericht; weggelaten: {files}",
"ui.agent_chat_files_loading": "Wachten tot de bestanden zijn gelezen",
"ui.agent_chat_files_max": "Maximaal {max} bestanden per bericht",
"ui.agent_chat_flatpak_none_found": "Geen codeeragent gevonden. De Flatpak-sandbox ziet standaard geen agents die in je persoonlijke map zijn geïnstalleerd en heeft geen netwerktoegang. Voer dit commando uit in een terminal en start SolidCraft daarna opnieuw om Claude Code en de ACP-adapter toe te staan die via nvm of in ~/.local/bin zijn geïnstalleerd. Het geeft toegang tot de genoemde programma’s en aanmeldbestanden, plus netwerktoegang:",
"ui.agent_chat_import_failed": "{name}: importeren mislukt: {error}",
"ui.agent_chat_intro": "Chat met een codeeragent die u al gebruikt, of met een modeleindpunt naar keuze. De agent werkt aan het geopende ontwerp via de tools van SolidCraft: de wijzigingen verschijnen terwijl ze worden gemaakt en worden per bericht als één stap ongedaan gemaakt. Wat hij zonder te vragen mag doen, stelt u in via Voorkeuren > AI.",
"ui.agent_chat_key_in_env": "sleutel ingesteld",
@@ -1501,6 +1502,7 @@
"ui.grid_follows_view_hint": "Zijaanzichten tonen het raster op het rechtopstaande vlak dat naar hen toe gericht is in plaats van op de grond",
"ui.ground_shadow": "Grondschaduw",
"ui.groupSelected": "Selectie groeperen",
"ui.hatch": "Arcering",
"ui.heading_add_ins": "INVOEGMODULES",
"ui.heading_assemble": "SAMENSTELLEN",
"ui.heading_box": "KUB",
+219 -1
View File
@@ -28,6 +28,7 @@
"Also deletes": "Usuwa również",
"Angle 2": "Kąt 2",
"Animate": "Animuj",
"App/system information and command settings are included. Review private details before sharing.": "Dołączone są informacje o aplikacji i systemie oraz ustawienia poleceń. Przed udostępnieniem sprawdź, czy nie ma tam prywatnych danych.",
"Appearance name": "Nazwa wyglądu",
"Applied Radius": "Zastosowany promień",
"Apply To": "Zastosuj do",
@@ -83,6 +84,7 @@
"Contact": "Kontakt",
"Contact Sets": "Zestawy kontaktów",
"Contact sets": "Zestawy kontaktów",
"Continue on GitHub": "Kontynuuj na GitHubie",
"Continuity": "Ciągłość",
"Copy Error Message": "Kopiuj komunikat błędu",
"Copy Warning Message": "Kopiuj komunikat ostrzeżenia",
@@ -102,6 +104,7 @@
"Curv. Comb": "Grzebień krzyw.",
"Curvature (G2)": "Krzywizna (G2)",
"Curvature change (G3)": "Zmiana krzywizny (G3)",
"Custom": "Niestandardowy",
"Customize Toolbar": "Dostosuj pasek narzędzi",
"Delete": "Usuń",
"Delete all": "Usuń wszystko",
@@ -110,6 +113,8 @@
"Delete the set": "Usuń zestaw",
"Density": "Gęstość",
"Dependents": "Elementy zależne",
"Describe the problem you saw": "Opisz zauważony problem",
"Describe what happened before continuing.": "Zanim przejdziesz dalej, opisz, co się stało.",
"Designs you open or save appear here.": "Tutaj pojawiają się otwierane lub zapisywane projekty.",
"Determined by To Object. Choose Angle to edit the angular extent.": "Wynika z opcji Do obiektu. Wybierz Kąt, aby edytować zakres kątowy.",
"Direction": "Kierunek",
@@ -127,6 +132,7 @@
"Enabled": "Włączone",
"End bulge": "Wybrzuszenie końca",
"End continuity": "Ciągłość końca",
"Expected result (optional)": "Oczekiwany wynik (opcjonalnie)",
"Explode": "Rozstrzel",
"Explode Text": "Rozbij tekst",
"Export Animation…": "Eksportuj animację…",
@@ -168,6 +174,7 @@
"Height": "Wysokość",
"Hidden edges": "Krawędzie niewidoczne",
"History layout": "Układ historii",
"History names": "Nazwy w historii",
"Holes": "Otwory",
"Holes (edges on them)": "Otwory (krawędzie na nich)",
"Home view": "Widok główny",
@@ -234,6 +241,7 @@
"No matching appearance presets.": "Brak pasujących ustawień wyglądu.",
"No matching appearances": "Brak pasujących wyglądów",
"No visible bodies or sketches to export.": "Brak widocznych brył lub szkiców do wyeksportowania.",
"Nominal diameter": "Średnica nominalna",
"None": "Brak",
"Not available yet": "Jeszcze niedostępne",
"Notch": "Wycięcie",
@@ -242,7 +250,10 @@
"Open GitHub draft": "Otwórz szkic zgłoszenia na GitHubie",
"Open local libraries in the desktop app. You can export this design's library here.": "Otwórz biblioteki lokalne w aplikacji komputerowej. Tutaj możesz wyeksportować bibliotekę tego projektu.",
"Open-source parametric 3D CAD in Rust. Licensed MIT OR Apache-2.0.": "Otwartoźródłowy parametryczny CAD 3D w języku Rust. Licencja MIT OR Apache-2.0.",
"Opens your browser and copies the report text for you to paste into GitHub.": "Otwiera przeglądarkę internetową i kopiuje treść raportu, którą wkleisz na GitHubie.",
"Opens your browser. Review and submit your report on GitHub.": "Otwiera przeglądarkę internetową. Sprawdź raport i wyślij go na GitHubie.",
"Operator": "Operator",
"Orbit (right drag)": "Obracanie widoku (przeciąganie prawym przyciskiem)",
"Other side": "Druga strona",
"Other surface (mutual trim)": "Druga powierzchnia (wzajemne przycięcie)",
"Outside": "Na zewnątrz",
@@ -257,6 +268,7 @@
"Pick the tool bodies": "Wybierz bryły narzędziowe",
"Picked": "Wybrane",
"Picked point": "Wybrany punkt",
"Pitch": "Skok",
"Place at the origin instead": "Zamiast tego umieść w początku układu",
"Plastic Rules": "Reguły tworzyw",
"Playback": "Odtwarzanie",
@@ -290,6 +302,8 @@
"Remove the track": "Usuń ścieżkę",
"Remove this point": "Usuń ten punkt",
"Report a bug — review a screenshot and open a GitHub draft": "Zgłoś błąd — sprawdź zrzut ekranu i otwórz szkic zgłoszenia na GitHubie",
"Report text copied. Paste it into GitHub and submit your report there.": "Skopiowano treść raportu. Wklej ją na GitHubie i tam wyślij raport.",
"Reports are public. A GitHub account is required.": "Raporty są publiczne. Wymagane jest konto GitHub.",
"Reset Toolbar": "Przywróć pasek narzędzi",
"Resize Part": "Zmień rozmiar części",
"Restore View": "Przywróć widok",
@@ -304,6 +318,7 @@
"Revert Pos.": "Przywróć poł.",
"Revert Position": "Przywróć położenie",
"Review the report and screenshot before opening GitHub.": "Przed otwarciem GitHuba sprawdź raport i zrzut ekranu.",
"Review your report on GitHub and click Submit new issue to publish it.": "Sprawdź raport na GitHubie i kliknij Submit new issue, aby go opublikować.",
"Rim Edge": "Krawędź obrzeża",
"Roll History Marker Here": "Przesuń tutaj znacznik historii",
"Round": "Okrągłe",
@@ -314,10 +329,12 @@
"SURFACE QUALITY": "JAKOŚĆ POWIERZCHNI",
"Save Rule": "Zapisz regułę",
"Save View": "Zapisz widok",
"Save screenshot…": "Zapisz zrzut ekranu…",
"Save the study as an animated GIF, seen as the viewport shows it": "Zapisz badanie jako animowany GIF, tak jak widać je w widoku",
"Saved preview. Geometry is being checked before editing.": "Zapisany podgląd. Geometria jest sprawdzana przed edycją.",
"Scale factor": "Współczynnik skali",
"Scene": "Scena",
"Screenshot (optional)": "Zrzut ekranu (opcjonalnie)",
"Search": "Szukaj",
"Search commands": "Szukaj poleceń",
"Search this library": "Przeszukaj tę bibliotekę",
@@ -352,17 +369,21 @@
"Start continuity": "Ciągłość początku",
"Step": "Krok",
"Steps": "Kroki",
"Steps to reproduce (optional)": "Kroki do odtworzenia (opcjonalnie)",
"Stitch the kept pieces": "Zszyj zachowane części",
"Stitch to both bodies": "Zszyj z obiema bryłami",
"Study": "Badanie",
"Style": "Styl",
"Summary": "Podsumowanie",
"Suppressed": "Wygaszone",
"Surface": "Powierzchnia",
"Swap Ends": "Zamień końce",
"TRIM": "PRZYCINANIE",
"Tangent (G1)": "Styczność (G1)",
"Tangent Chain": "Łańcuch styczny",
"Tap drill diameter": "Średnica wiertła pod gwint",
"Tear": "Rozdarcie",
"Technical details": "Szczegóły techniczne",
"The dashed sketch is a saved plane guide. Select a new face to repair the attachment.": "Przerywany szkic to zapisana płaszczyzna pomocnicza. Wybierz nową ścianę, aby naprawić zaczepienie.",
"The saved author occurrence is missing. Select a valid author placement before rebinding.": "Brak zapisanego wystąpienia autora. Przed ponownym powiązaniem wybierz prawidłowe położenie autora.",
"Then": "Wtedy",
@@ -374,6 +395,7 @@
"This child is shared. Deleting it removes it from every copy of its parent.": "Ten element podrzędny jest współdzielony. Usunięcie go usuwa go z każdej kopii elementu nadrzędnego.",
"This child is shared. Position changes apply to every copy of its parent.": "Ten element podrzędny jest współdzielony. Zmiany położenia dotyczą każdej kopii elementu nadrzędnego.",
"This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Ta ściana nie zapewnia trwałego zaczepienia. Wybierz inną ścianę lub jawnie użyj stałej płaszczyzny.",
"This image is not attached to GitHub yet. Copy or save it, then attach it on GitHub.": "Ten obraz nie jest jeszcze dołączony na GitHubie. Skopiuj go lub zapisz, a potem dołącz na GitHubie.",
"To Offset": "Do odsunięcia",
"To edges": "Do krawędzi",
"Tolerance": "Tolerancja",
@@ -399,6 +421,9 @@
"Wall": "Ścianka",
"Wall Location": "Położenie ścianki",
"Wall Thickness": "Grubość ścianki",
"What did you do just before the problem? A short list is enough": "Co robiłeś tuż przed wystąpieniem problemu? Wystarczy krótka lista",
"What did you expect to see?": "Czego się spodziewałeś?",
"What happened?": "Co się stało?",
"When": "Gdy",
"Width": "Szerokość",
"Will fail": "Nie powiedzie się",
@@ -409,6 +434,29 @@
"Z angle": "Kąt Z",
"Z distance": "Odległość Z",
"Zebra": "Zebra",
"ai.prefs.availability": "Sterowanie asystentem",
"ai.prefs.background_commands": "Uruchamiaj polecenia agenta w tle",
"ai.prefs.follow_camera": "Obracaj kamerę do szkiców",
"ai.prefs.follow_camera_off": "Automatyczne podążanie kamery jest wyłączone.",
"ai.prefs.follow_camera_on": "Kamera obraca się przodem do szkiców tworzonych przez agenta.",
"ai.prefs.intro": "Agenty, uprawnienia i sposób pokazywania ich pracy.",
"ai.prefs.make_default": "Używaj domyślnie",
"ai.prefs.no_agents": "Brak agentów. Dodaj agenta lub odśwież, aby wyszukać zainstalowane agenty.",
"ai.prefs.no_saved_approvals": "Brak zapisanych zgód.",
"ai.prefs.override_all_warning": "Ten agent może zapisywać, nadpisywać, eksportować i zastępować projekt bez pytania.",
"ai.prefs.permission_all": "Zezwalaj na wszystko",
"ai.prefs.permission_all_summary": "Dostępne narzędzia SolidCraft działają bez pytania.",
"ai.prefs.permission_ask": "Pytaj przed zmianami",
"ai.prefs.permission_ask_summary": "Zatwierdzaj każdą zmianę projektu i każdą operację na plikach.",
"ai.prefs.permission_edits": "Zezwalaj na edycję modelu",
"ai.prefs.permission_edits_summary": "Edycja modelu działa bez pytania; operacje na plikach i nieodwracalne nadal wymagają pytania.",
"ai.prefs.permission_scope": "Dotyczy tylko narzędzi SolidCraft. Powłoka agenta i edycja plików podlegają jego własnym uprawnieniom.",
"ai.prefs.pointer_preview_hidden": "Wskaźnik ukryty",
"ai.prefs.pointer_preview_speed": "Wskaźnik agenta · {speed}",
"ai.prefs.pointer_speed": "Szybkość ruchu",
"ai.prefs.privacy_note": "Rozmowę otrzymuje tylko wybrany przez Ciebie agent lub punkt końcowy. Klucze punktów końcowych są przechowywane w systemowym pęku kluczy.",
"ai.prefs.show_pointer": "Pokaż wskaźnik agenta",
"ai.prefs.turn_limit_hint": "Maksymalna liczba wywołań narzędzi zmieniających projekt, które asystent może wykonać dla jednej wiadomości.",
"angle, height": "kąt, wysokość",
"appearance.dielectric_ior": "Współczynnik załamania dielektryka (IOR)",
"appearance.metallic": "Metaliczność",
@@ -759,6 +807,7 @@
"cmd.view_graphics_preset": "Ustawienie graficzne",
"cmd.view_list": "Lista widoków nazwanych",
"cmd.view_navigate": "Nawiguj widokiem",
"cmd.view_normal_to": "Prostopadle do zaznaczenia",
"cmd.view_rename": "Zmień nazwę widoku nazwanego",
"cmd.view_save": "Nowy widok nazwany",
"color.copy": "Kliknij, aby skopiować wartości koloru",
@@ -856,6 +905,12 @@
"help.licences": "Licencje",
"help.mit": "Licencja MIT",
"help.notice": "NOTICE",
"help.solid.chamfer": "Ścina zaznaczone krawędzie płaską fazą o zadanej odległości. Służy do łamania ostrych krawędzi lub ułatwienia wprowadzenia kołka czy śruby.",
"help.solid.extrude": "Wyciąga profil szkicu w 3D na zadaną odległość, dodając lub usuwając materiał. Służy do zamiany płaskiego obrysu w blok, występ lub kieszeń.",
"help.solid.fillet": "Zaokrągla zaznaczone krawędzie gładkim łukiem o zadanym promieniu. Służy do złagodzenia ostrych krawędzi dla wytrzymałości, wygody lub wyglądu.",
"help.solid.hole": "Wierci okrągły otwór o zadanej średnicy w wybranym punkcie ściany. Służy do otworów przelotowych pod śruby, kołki i innych prostych okrągłych otworów.",
"help.solid.revolve": "Obraca profil szkicu wokół osi, tworząc okrągłą bryłę. Służy do wałków, gałek, misek i wszystkich części okrągłych względem osi.",
"help.solid.shell": "Wydrąża bryłę, pozostawiając ścianki o zadanej grubości, i może otworzyć wybrane ściany. Służy do pudełek, pokryw i innych części cienkościennych.",
"help.third_party": "Licencje stron trzecich",
"home.back": "Wróć do {name}",
"home.crashes": "Raporty awarii",
@@ -965,6 +1020,8 @@
"motion.to_end": "Na koniec",
"motion.to_start": "Na początek",
"motion.track_name": "Ścieżka {number}",
"nav.free_orbit.help": "Mysz i mysz 3D obracają widok przez oba bieguny i zachowują przechylenie. Przyciąganie do osi z Shift jest wyłączone.",
"nav.free_orbit.label": "Swobodne obracanie",
"nav.touchpad.help": "Przytrzymaj Shift i poruszaj wskaźnikiem, aby przesuwać; przytrzymaj Alt/Option i poruszaj nim, aby obracać widok. Przycisk myszy nie jest potrzebny. Przewijanie w pionie powiększa.",
"nav.touchpad.label": "Touchpad",
"nav.touchpad.orbit": "Obracanie widoku (przytrzymaj Alt/Option i poruszaj; bez przycisku myszy)",
@@ -987,8 +1044,130 @@
"pattern.flip_direction": "Odwróć kierunek",
"pick a hole to seat it, or OK places it at the origin": "wybierz otwór, aby go osadzić, lub OK umieszcza go w początku układu",
"pick one or two vertices, edges or faces": "wybierz jeden lub dwa wierzchołki, krawędzie lub ściany",
"prefs.add_item": "Dodaj element",
"prefs.alt_frees_manipulators": "Alt / Option zwalnia również strzałki manipulatora",
"prefs.anti_aliasing_note": "Wygładza poszarpane krawędzie.",
"prefs.applies_on_restart": "Działa po ponownym uruchomieniu",
"prefs.assistant": "Asystent",
"prefs.axes": "Osie",
"prefs.axis": "Oś",
"prefs.bottom": "Dół",
"prefs.browser": "Przeglądarka",
"prefs.built_in": "Wbudowana",
"prefs.button_left": "Przeciąganie lewym",
"prefs.button_middle": "Przeciąganie środkowym",
"prefs.button_right": "Przeciąganie prawym",
"prefs.choose_font": "Wybierz plik czcionki…",
"prefs.click": "Kliknięcie",
"prefs.contact_shading": "Miękkie cieniowanie kontaktowe",
"prefs.contact_shading_note": "Miękkie cieniowanie tam, gdzie części są blisko siebie.",
"prefs.context_menu": "Menu kontekstowe",
"prefs.context_menu_ring": "Menu kontekstowe · przytrzymaj, aby otworzyć menu promieniste",
"prefs.cpu": "CPU (programowo)",
"prefs.crash_reports": "Otwórz raporty o awariach…",
"prefs.crash_reports_note": "Raporty są przechowywane na tym komputerze, razem z projektem i poleceniami, które odtwarzają problem.",
"prefs.ctrl_cmd": "Ctrl / Cmd",
"prefs.curved_surfaces": "Powierzchnie zakrzywione",
"prefs.curves": "Powierzchnie zakrzywione",
"prefs.dead_zone_note": "Zakreskowany pas to strefa martwa: ruch wewnątrz niej jest pomijany.",
"prefs.default": "domyślnie",
"prefs.design": "Ustawienia domyślne projektu",
"prefs.design_note": "Ustawienia, z którymi startują nowe projekty. Otwarte projekty zachowują własne.",
"prefs.devices": "Mysz 3D",
"prefs.display": "Wyświetlanie",
"prefs.edges": "Krawędzie i cieniowanie",
"prefs.extend": "Rozszerz",
"prefs.extra_navigation": "Ctrl+przeciąganie lewym na pustym miejscu obraca widok; przytrzymaj Spację, aby przesuwać.",
"prefs.follows_os": "Zgodnie z systemem",
"prefs.font": "Czcionka",
"prefs.font_fallback": "Znaki, których brakuje w wybranej czcionce, są brane z czcionki wbudowanej, więc żadna etykieta nie pokaże pustego prostokąta.",
"prefs.font_sample": "Zażółć gęślą jaźń · Ø12.50 mm · 45.0° · 0123456789",
"prefs.font_unreadable": "Nie można odczytać wybranej czcionki.",
"prefs.general": "Ogólne",
"prefs.general_note": "Język, zapisywanie i aktualizacje.",
"prefs.gesture_orbit": "Obracanie widoku",
"prefs.gesture_pan": "Przesuwanie",
"prefs.gesture_zoom": "Powiększanie",
"prefs.graphics": "Grafika",
"prefs.graphics_note": "Wybierz między szybkością a gładkością. Każda opcja pokazuje swój efekt na przykładzie.",
"prefs.graphics_presets_note": "Ustawienia wstępne zmieniają razem poniższe opcje.",
"prefs.history": "Historia",
"prefs.hold_stop_snapping": "Przytrzymaj, aby wyłączyć przyciąganie",
"prefs.icons_only": "Tylko ikony",
"prefs.imported_file": "Zaimportowany plik",
"prefs.input": "Wprowadzanie",
"prefs.interface": "Interfejs",
"prefs.interface_language": "Język interfejsu",
"prefs.interface_note": "Wygląd SolidCraft i rozmieszczenie jego elementów.",
"prefs.interval_minutes": "{n} min",
"prefs.labels": "Etykiety",
"prefs.layout": "Układ",
"prefs.layout_note": "Kompaktowy dokuje panele do krawędzi okna. Przezroczystość przeglądarki jest dostępna w układzie Pływający. Stronę asystenta zmienisz na stronie Asystent.",
"prefs.left": "Lewo",
"prefs.left_click": "Kliknięcie lewym",
"prefs.live_input": "Bieżące wejście",
"prefs.live_preview": "Podgląd na żywo",
"prefs.minutes": "minut",
"prefs.minutes_suffix": " min",
"prefs.mouse": "Mysz i gładzik",
"prefs.mouse_heading": "Mysz",
"prefs.mouse_mapping": "Przypisanie przycisków myszy",
"prefs.navigation": "Nawigacja",
"prefs.navigation_note": "Jak mysz, gładzik i mysz 3D poruszają widokiem.",
"prefs.not_on_gpu": "niedostępne na tym GPU",
"prefs.on": "Włączone",
"prefs.opaque": "Nieprzezroczysta",
"prefs.orbit_snap": "Przytrzymaj Shift podczas obracania widoku, aby przyciągnąć do osi.",
"prefs.orientation": "Orientacja",
"prefs.overall_speed": "Szybkość ogólna",
"prefs.preview_choices": "Podgląd wybranych opcji",
"prefs.quality_default": "domyślnie",
"prefs.quality_fastest": "najszybciej",
"prefs.quality_slowest": "najwolniej",
"prefs.quality_smoother": "gładziej",
"prefs.reports_saved": "Zapisane: {n}",
"prefs.right_click": "Kliknięcie prawym",
"prefs.saving": "Autozapis i odzyskiwanie",
"prefs.scale_note": "Także Ctrl + / Ctrl − w oknie. Przyciski mają 28 px przy 100%.",
"prefs.scale_text": "Skala i tekst",
"prefs.select": "Zaznacz",
"prefs.select_one_item": "Zaznacz jeden element",
"prefs.selecting": "Zaznaczanie",
"prefs.shift": "Shift",
"prefs.shift_add_ctrl_toggle": "Shift tylko dodaje; Ctrl / Cmd przełącza",
"prefs.shift_add_note": "Włączone: Shift dodaje, Ctrl / Cmd przełącza. Wyłączone (domyślnie): oba przełączają.",
"prefs.shortcuts": "Skróty",
"prefs.sketch_selection": "Szkic i zaznaczanie",
"prefs.sketch_selection_heading": "Szkic i zaznaczanie",
"prefs.sketch_selection_note": "Jak działa przyciąganie w szkicach i jak kliknięcia zaznaczają.",
"prefs.sketching": "Szkicowanie",
"prefs.snap_drawing_note": "Podczas rysowania lub przeciągania punktu szkicu.",
"prefs.source": "Źródło",
"prefs.speed": "Szybkość",
"prefs.stand_up_note": "Gdy plik STEP lub IGES otworzy się z osią Z w górę, proponuj obrót o ćwierć obrotu wokół osi X.",
"prefs.status_done": "Gotowe",
"prefs.status_error": "Błąd",
"prefs.status_selected": "Zaznaczone",
"prefs.status_warning": "Ostrzeżenie",
"prefs.supersampling_brief": "Rysuje widok w większym rozmiarze i go zmniejsza: bardzo ostro, ale bardziej obciąża.",
"prefs.tab_accent_note": "Kolor akcentu podąża za aktywną kartą",
"prefs.theme_file": "plik motywu",
"prefs.timeline_half_hour": "−30 min",
"prefs.timeline_now": "teraz",
"prefs.timeline_quarter_hour": "−15",
"prefs.toggle_item": "Dodaj element lub usuń go ponownie",
"prefs.toolbar": "Pasek narzędzi",
"prefs.translucent": "Półprzezroczysta",
"prefs.under_browser": "Pod przeglądarką",
"prefs.under_cursor": "Pod kursorem",
"prefs.units_precision": "Jednostki i dokładność",
"prefs.updates": "Aktualizacje",
"prefs.view": "Zachowanie widoku",
"prefs.viewport": "Widok",
"prefs.wheel": "Kółko",
"prefs.wheel_trackpad": "Kółko i gładzik",
"prefs.workspace": "Środowisko pracy",
"prefs.zoom_toward_cursor": "Powiększanie w stronę kursora",
"properties.title": "Właściwości: {name}",
"recovery.days_ago": "{count} dni temu",
"recovery.detail": "{file} · elementy: {features} · autozapis {when}",
@@ -1060,7 +1239,7 @@
"search.try": "Wpisz nazwę polecenia, element tego projektu lub parametr. Aby uruchomić polecenie z wartościami, wpisz jego identyfikator i obiekt JSON: {example}",
"search.valid": "Prawidłowe",
"settings.display_quality_note": "Wyższe poziomy rysują zakrzywione ściany i krawędzie gładziej, ale siatka powstaje dłużej. Zmienia się tylko widok, nigdy projekt ani eksport.",
"settings.frame_rate_note": "Pokazuje liczbę klatek na sekundę w prawym dolnym rogu widoku. Gdy jest włączone, widok odświeża się bez przerwy.",
"settings.frame_rate_note": "Pokazuje liczbę klatek na sekundę w prawym dolnym rogu widoku.",
"settings.measure_axis_distances": "Pokaż odległości X/Y/Z",
"settings.measure_axis_distances_help": "Gdy pomiar jest otwarty, pokazuj składowe wzdłuż osi globalnych obok odległości całkowitej.",
"settings.msaa_applies_on_restart": "Wygładzanie krawędzi zadziała po ponownym uruchomieniu SolidCraft",
@@ -1105,6 +1284,9 @@
"thread.designation": "Oznaczenie",
"thread.iso_metric": "Metryczny ISO",
"thread.select_size": "Wybierz rozmiar",
"timeline.delete_after_marker_one": "Usuń 1 funkcję za znacznikiem historii",
"timeline.delete_after_marker_other": "Usuń funkcje za znacznikiem historii: {count}",
"timeline.delete_many": "Usuń zaznaczone funkcje: {count}",
"timeline.group": "Nowy folder funkcji",
"timeline.move_many": "Przenieś elementy osi czasu",
"timeline.move_to_group": "Przenieś funkcje do folderu",
@@ -1183,6 +1365,7 @@
"ui.agent_chat_files_left_out": "Do jednej wiadomości można dołączyć najwyżej {max} plików; pominięto: {files}",
"ui.agent_chat_files_loading": "Oczekiwanie na odczytanie plików",
"ui.agent_chat_files_max": "Najwyżej {max} plików w jednej wiadomości",
"ui.agent_chat_flatpak_none_found": "Nie znaleziono agenta programistycznego. Piaskownica Flatpak domyślnie nie widzi agentów zainstalowanych w folderze domowym i nie ma dostępu do sieci. Aby zezwolić na Claude Code i jego adapter ACP zainstalowane przez nvm lub w ~/.local/bin, uruchom to polecenie w terminalu, a potem uruchom ponownie SolidCraft. Daje ono dostęp do wymienionych programów i plików logowania oraz dostęp do sieci:",
"ui.agent_chat_import_failed": "{name}: import się nie powiódł: {error}",
"ui.agent_chat_intro": "Rozmawiaj z agentem programistycznym, którego już używasz, lub z wybranym punktem końcowym modelu. Pracuje on na otwartym projekcie za pomocą narzędzi SolidCraft: jego zmiany są widoczne na bieżąco, a cofa się je jednym krokiem na wiadomość. To, co może robić bez pytania, ustawisz w Preferencje > AI.",
"ui.agent_chat_key_in_env": "klucz ustawiony",
@@ -1250,6 +1433,8 @@
"ui.agent_chat_waiting": "Czeka na Ciebie",
"ui.agent_cursor": "Kursor agenta",
"ui.agent_cursor_speed": "Szybkość kursora agenta",
"ui.agent_off_thread": "Zachowaj responsywność okna, gdy agenty wykonują polecenia",
"ui.agent_off_thread_note": "Wolne polecenia agenta (z panelu AI lub klienta MCP) są liczone obok okna: możesz dalej obracać i oglądać widok oraz zatrzymać polecenie przyciskiem w widoku. Wyłączone: każde działa w wątku okna, a okno na nie czeka.",
"ui.ai_add": "Dodaj",
"ui.ai_add_agent": "Dodaj agenta…",
"ui.ai_add_agent_title": "Dodaj agenta",
@@ -1502,6 +1687,7 @@
"ui.grid_follows_view_hint": "Widoki boczne pokazują siatkę na pionowej płaszczyźnie zwróconej do nich zamiast na podłożu",
"ui.ground_shadow": "Cień na podłożu",
"ui.groupSelected": "Grupuj zaznaczone",
"ui.hatch": "Kreskowanie",
"ui.heading_add_ins": "DODATKI",
"ui.heading_assemble": "ZŁOŻENIE",
"ui.heading_box": "PROSTOPADŁOŚCIAN",
@@ -1577,10 +1763,17 @@
"ui.hideProfile": "Ukryj profil",
"ui.history_back": "Cofnij o jeden krok",
"ui.history_below_browser": "Pod przeglądarką",
"ui.history_edge_hint": "kliknij dwukrotnie krawędź",
"ui.history_first": "Przejdź do początku",
"ui.history_forward": "Przejdź o jeden krok do przodu",
"ui.history_hide_names": "Ukryj nazwy",
"ui.history_last": "Przejdź do końca",
"ui.history_marker_tip": "Znacznik historii: przeciągnij, aby cofnąć model; nowe funkcje trafiają tutaj",
"ui.history_names": "Nazwy",
"ui.history_play": "Odtwórz do końca",
"ui.history_show_names": "Pokaż nazwy",
"ui.history_title": "Historia",
"ui.history_toggle_names": "Pokaż lub ukryj nazwy funkcji",
"ui.hole": "Otwór",
"ui.hole_tap_type": "Typ gwintu otworu",
"ui.hole_type": "Typ otworu",
@@ -1792,6 +1985,7 @@
"ui.smooth_wheel_zoom": "Płynne powiększanie kółkiem",
"ui.smooth_wheel_zoom_tip": "Wyłącz, aby każdy ruch kółka działał od razu, bez wygładzania",
"ui.snap": "Przyciąganie",
"ui.space_mouse_axes_reassigned": "Osie są przypisane inaczej w Preferencjach i to przypisanie nadal działa niezależnie od tej kalibracji.",
"ui.space_mouse_calibrate": "Kalibruj…",
"ui.space_mouse_calibrate_tip": "Przesuwaj nakładkę zgodnie z poleceniami okna, aby SolidCraft nauczył się kierunku i zakresu każdej osi tej myszy 3D. Zapisywane dla tej myszy 3D.",
"ui.space_mouse_calibrate_title": "Kalibracja myszy 3D",
@@ -1801,12 +1995,26 @@
"ui.space_mouse_calibration_no_response": "Jedna oś prawie się nie poruszyła, więc nie można jej skalibrować. Zacznij od nowa i za każdym razem wychylaj nakładkę mocniej.",
"ui.space_mouse_dead_zone": "Strefa martwa",
"ui.space_mouse_dead_zone_tip": "O ile trzeba wychylić nakładkę, zanim widok się poruszy, jako część pełnego wychylenia. Zwiększ, jeśli widok dryfuje przy nieruszanej nakładce.",
"ui.space_mouse_driven_by": "Który skalibrowany ruch urządzenia steruje tą osią. Wybranie ruchu używanego już przez inną oś zamienia je miejscami.",
"ui.space_mouse_driven_by_label": "sterowana przez",
"ui.space_mouse_driver": "Sterownik myszy 3D",
"ui.space_mouse_driver_auto": "Automatycznie",
"ui.space_mouse_driver_direct": "Bezpośrednio (hidraw)",
"ui.space_mouse_driver_only_direct": "Bezpośredni odczyt urządzenia; spacenavd nie działa",
"ui.space_mouse_driver_only_spacenavd": "Odczyt przez spacenavd, jedyny dostępny sposób",
"ui.space_mouse_forget": "Zapomnij kalibrację",
"ui.space_mouse_hint": "Przesuwaj i obracaj widok jednocześnie; w szkicach działa tylko przesuwanie i powiększanie.",
"ui.space_mouse_motion_rotation_x": "Obrót X",
"ui.space_mouse_motion_rotation_y": "Obrót Y",
"ui.space_mouse_motion_rotation_z": "Obrót Z",
"ui.space_mouse_motion_translation_x": "Przesunięcie X",
"ui.space_mouse_motion_translation_y": "Przesunięcie Y",
"ui.space_mouse_motion_translation_z": "Przesunięcie Z",
"ui.space_mouse_navigate": "Nawiguj za pomocą SpaceNavigator / SpaceMouse",
"ui.space_mouse_none": "Nie znaleziono myszy 3D",
"ui.space_mouse_not_calibrated": "Nieskalibrowano",
"ui.space_mouse_reading": "Odczyt",
"ui.space_mouse_reset_axes": "Resetuj przypisanie osi",
"ui.space_mouse_step_lift": "Unieś nakładkę, przytrzymaj, a następnie puść.",
"ui.space_mouse_step_press": "Wciśnij nakładkę w dół, przytrzymaj, a następnie puść.",
"ui.space_mouse_step_pull": "Pociągnij nakładkę do siebie, przytrzymaj, a następnie puść.",
@@ -1820,6 +2028,16 @@
"ui.space_mouse_step_tilt_right": "Przechyl nakładkę w prawo (prawa krawędź w dół), przytrzymaj, a następnie puść.",
"ui.space_mouse_step_twist_clockwise": "Obróć nakładkę zgodnie z ruchem wskazówek zegara, przytrzymaj, a następnie puść.",
"ui.space_mouse_step_twist_counterclockwise": "Obróć nakładkę przeciwnie do ruchu wskazówek zegara, przytrzymaj, a następnie puść.",
"ui.space_mouse_view_axis": "Oś widoku",
"ui.space_mouse_view_off": "Wyłączona",
"ui.space_mouse_view_off_tip": "Profil sterownika myszy 3D wyłącza tę oś, więc jej ruch nie porusza widokiem. Wybranie tutaj ruchu innej osi zamienia je miejscami, a ten ruch przestaje poruszać widokiem.",
"ui.space_mouse_view_orbit_sideways": "Obracanie w lewo / w prawo",
"ui.space_mouse_view_orbit_up_down": "Obracanie w górę / w dół",
"ui.space_mouse_view_pan_sideways": "Przesuwanie w lewo / w prawo",
"ui.space_mouse_view_pan_up_down": "Przesuwanie w górę / w dół",
"ui.space_mouse_view_roll": "Przechylenie",
"ui.space_mouse_view_unmapped": "Oś {n}",
"ui.space_mouse_view_zoom": "Powiększanie",
"ui.spacing": "Odstęp",
"ui.spacing_2": "Odstęp 2",
"ui.sphere": "Sfera",
+3 -1
View File
@@ -1060,7 +1060,7 @@
"search.try": "Digite o nome de um comando, um recurso deste projeto ou um parâmetro. Para executar um comando com valores, digite seu id e um objeto JSON: {example}",
"search.valid": "Válido",
"settings.display_quality_note": "Níveis mais finos desenham faces e arestas curvas mais suaves e demoram mais para gerar a malha. Só a janela de visualização muda, nunca o projeto ou as exportações.",
"settings.frame_rate_note": "Mostra quadros por segundo no canto inferior direito da vista. Enquanto estiver ativo, a vista é redesenhada continuamente.",
"settings.frame_rate_note": "Mostra quadros por segundo no canto inferior direito da vista.",
"settings.measure_axis_distances": "Mostrar distâncias X/Y/Z",
"settings.measure_axis_distances_help": "Mostrar as componentes nos eixos do mundo junto com a distância total enquanto Medir está aberto.",
"settings.msaa_applies_on_restart": "O antisserrilhamento vale na próxima vez que o SolidCraft iniciar",
@@ -1183,6 +1183,7 @@
"ui.agent_chat_files_left_out": "No máximo {max} arquivos acompanham uma mensagem; deixados de fora: {files}",
"ui.agent_chat_files_loading": "Aguardando a leitura dos arquivos",
"ui.agent_chat_files_max": "No máximo {max} arquivos por mensagem",
"ui.agent_chat_flatpak_none_found": "Nenhum agente de programação encontrado. Por padrão, a sandbox do Flatpak não vê agentes instalados na sua pasta pessoal e não tem acesso à rede. Para permitir o Claude Code e o adaptador ACP dele instalados via nvm ou em ~/.local/bin, execute este comando em um terminal e reinicie o SolidCraft. Ele concede acesso aos programas e arquivos de login listados, além de acesso à rede:",
"ui.agent_chat_import_failed": "{name}: falha na importação: {error}",
"ui.agent_chat_intro": "Converse com um agente de programação que você já usa, ou com um endpoint de modelo que você escolher. Ele trabalha no projeto aberto pelas ferramentas do SolidCraft: as alterações aparecem conforme são feitas e se desfazem em um passo por mensagem. O que ele pode fazer sem perguntar é definido em Preferências > IA.",
"ui.agent_chat_key_in_env": "chave definida",
@@ -1502,6 +1503,7 @@
"ui.grid_follows_view_hint": "As vistas laterais mostram a grade no plano vertical voltado para elas, em vez do solo",
"ui.ground_shadow": "Sombra no chão",
"ui.groupSelected": "Agrupar seleção",
"ui.hatch": "Hachura",
"ui.heading_add_ins": "COMPLEMENTOS",
"ui.heading_assemble": "MONTAR",
"ui.heading_box": "CAIXA",
+3 -1
View File
@@ -1059,7 +1059,7 @@
"search.try": "Tastați numele unei comenzi, o operație din acest proiect sau un parametru. Pentru a executa o comandă cu valori, tastați id-ul ei și un obiect JSON: {example}",
"search.valid": "Valid",
"settings.display_quality_note": "Nivelurile mai fine desenează fețele și muchiile curbe mai neted și durează mai mult la generarea plasei. Se schimbă doar fereastra de vizualizare, niciodată proiectul sau exporturile.",
"settings.frame_rate_note": "Afișează cadrele pe secundă în colțul din dreapta jos al vizualizării. Cât timp este activat, vizualizarea se redesenează continuu.",
"settings.frame_rate_note": "Afișează cadrele pe secundă în colțul din dreapta jos al vizualizării.",
"settings.measure_axis_distances": "Afișează distanțele X/Y/Z",
"settings.measure_axis_distances_help": "Afișează cotele pe axele globale alături de distanța totală cât timp Măsurare este deschis.",
"settings.msaa_applies_on_restart": "Anti-aliasing se aplică la următoarea pornire a SolidCraft",
@@ -1182,6 +1182,7 @@
"ui.agent_chat_files_left_out": "Cel mult {max} fișiere merg cu un mesaj; omise: {files}",
"ui.agent_chat_files_loading": "Se așteaptă citirea fișierelor",
"ui.agent_chat_files_max": "Cel mult {max} fișiere pe un mesaj",
"ui.agent_chat_flatpak_none_found": "Nu s-a găsit niciun agent de programare. Implicit, sandboxul Flatpak nu vede agenții instalați în dosarul personal și nu are acces la rețea. Pentru a permite Claude Code și adaptorul său ACP instalate prin nvm sau în ~/.local/bin, rulați această comandă într-un terminal, apoi reporniți SolidCraft. Ea acordă acces la programele și fișierele de autentificare enumerate, precum și acces la rețea:",
"ui.agent_chat_import_failed": "{name}: importul a eșuat: {error}",
"ui.agent_chat_intro": "Conversați cu un agent de programare pe care îl folosiți deja sau cu un endpoint de model ales de dumneavoastră. Lucrează la proiectul deschis prin instrumentele SolidCraft: modificările lui apar pe măsură ce le face și se anulează ca un singur pas per mesaj. Ce poate face fără să întrebe se stabilește în Preferințe > AI.",
"ui.agent_chat_key_in_env": "cheie setată",
@@ -1501,6 +1502,7 @@
"ui.grid_follows_view_hint": "Vederile laterale afișează grila pe planul vertical orientat spre ele, în loc de sol",
"ui.ground_shadow": "Umbra la sol",
"ui.groupSelected": "Grupează selecția",
"ui.hatch": "Hașură",
"ui.heading_add_ins": "EXTENSII",
"ui.heading_assemble": "ASAMBLARE",
"ui.heading_box": "CUTIE",
+3 -1
View File
@@ -1059,7 +1059,7 @@
"search.try": "Введите название команды, операцию этого дизайна или параметр. Чтобы выполнить команду со значениями, введите её id и объект JSON: {example}",
"search.valid": "Допустимо",
"settings.display_quality_note": "Более высокие уровни рисуют криволинейные грани и рёбра глаже, но сетка строится дольше. Меняется только видовой экран, но не дизайн и не экспорт.",
"settings.frame_rate_note": "Показывает кадры в секунду в правом нижнем углу вида. Пока включено, вид перерисовывается непрерывно.",
"settings.frame_rate_note": "Показывает кадры в секунду в правом нижнем углу вида.",
"settings.measure_axis_distances": "Расстояния X/Y/Z",
"settings.measure_axis_distances_help": "Показывать размеры вдоль мировых осей вместе с общим расстоянием, пока открыто окно измерения.",
"settings.msaa_applies_on_restart": "Сглаживание вступит в силу при следующем запуске SolidCraft",
@@ -1182,6 +1182,7 @@
"ui.agent_chat_files_left_out": "К одному сообщению можно приложить не более {max} файлов; не добавлены: {files}",
"ui.agent_chat_files_loading": "Ожидание чтения файлов",
"ui.agent_chat_files_max": "Не более {max} файлов в одном сообщении",
"ui.agent_chat_flatpak_none_found": "Кодирующий агент не найден. По умолчанию песочница Flatpak не видит агентов, установленных в домашней папке, и не имеет доступа к сети. Чтобы разрешить Claude Code и его адаптер ACP, установленные через nvm или в ~/.local/bin, выполните эту команду в терминале и перезапустите SolidCraft. Она даёт доступ к перечисленным программам и файлам входа, а также доступ к сети:",
"ui.agent_chat_import_failed": "{name}: не удалось импортировать: {error}",
"ui.agent_chat_intro": "Общайтесь с уже используемым кодирующим агентом или выбранной конечной точкой модели. Он работает с открытым дизайном через инструменты SolidCraft: изменения видны по мере выполнения и отменяются одним шагом на сообщение. Что ему разрешено без запроса, задаётся в Настройки > ИИ.",
"ui.agent_chat_key_in_env": "ключ задан",
@@ -1501,6 +1502,7 @@
"ui.grid_follows_view_hint": "На боковых видах сетка показывается на вертикальной плоскости, обращённой к вам, а не на земле",
"ui.ground_shadow": "Тень на земле",
"ui.groupSelected": "Группировать выбранное",
"ui.hatch": "Штриховка",
"ui.heading_add_ins": "НАДСТРОЙКИ",
"ui.heading_assemble": "СБОРКА",
"ui.heading_box": "БОКС",
+3 -1
View File
@@ -1059,7 +1059,7 @@
"search.try": "Skriv ett kommandonamn, en funktion i denna konstruktion eller en parameter. För att köra ett kommando med värden, skriv dess id och ett JSON-objekt: {example}",
"search.valid": "Giltig",
"settings.display_quality_note": "Finare nivåer ritar krökta ytor och kanter jämnare och tar längre tid att mesha. Endast vyporten ändras, aldrig konstruktionen eller exporter.",
"settings.frame_rate_note": "Visar bildrutor per sekund i vyns nedre högra hörn. Medan det är på ritas vyn om kontinuerligt.",
"settings.frame_rate_note": "Visar bildrutor per sekund i vyns nedre högra hörn.",
"settings.measure_axis_distances": "Visa X/Y/Z-avstånd",
"settings.measure_axis_distances_help": "Visa världsaxelns komponentmått bredvid det totala avståndet medan Mät är öppet.",
"settings.msaa_applies_on_restart": "Kantutjämning tillämpas nästa gång SolidCraft startar",
@@ -1182,6 +1182,7 @@
"ui.agent_chat_files_left_out": "Högst {max} filer följer med ett meddelande; utelämnade: {files}",
"ui.agent_chat_files_loading": "Väntar på att filerna ska läsas",
"ui.agent_chat_files_max": "Högst {max} filer i ett meddelande",
"ui.agent_chat_flatpak_none_found": "Ingen kodningsagent hittades. Flatpak-sandlådan ser som standard inte agenter som är installerade i din hemmapp och har ingen nätverksåtkomst. För att tillåta Claude Code och dess ACP-adapter installerade via nvm eller i ~/.local/bin kör du det här kommandot i en terminal och startar sedan om SolidCraft. Det ger åtkomst till de angivna programmen och inloggningsfilerna samt nätverksåtkomst:",
"ui.agent_chat_import_failed": "{name}: importen misslyckades: {error}",
"ui.agent_chat_intro": "Chatta med en kodningsagent du redan använder, eller en modellslutpunkt du väljer. Den arbetar med den öppna konstruktionen via SolidCrafts verktyg: dess ändringar syns medan den gör dem och ångras som ett steg per meddelande. Vad den får göra utan att fråga ställs in under Inställningar > AI.",
"ui.agent_chat_key_in_env": "nyckel angiven",
@@ -1501,6 +1502,7 @@
"ui.grid_follows_view_hint": "Sidovyer visar rutnätet på det upprätta planet som vetter mot dem istället för marken",
"ui.ground_shadow": "Markskugga",
"ui.groupSelected": "Gruppera markerade",
"ui.hatch": "Skraffering",
"ui.heading_add_ins": "TILLÄGG",
"ui.heading_assemble": "MONTERA",
"ui.heading_box": "LÅDA",
+3 -1
View File
@@ -1059,7 +1059,7 @@
"search.try": "Bir komut adı, bu tasarımdaki bir özellik veya bir parametre yazın. Bir komutu değerlerle çalıştırmak için kimliğini ve bir JSON nesnesi yazın: {example}",
"search.valid": "Geçerli",
"settings.display_quality_note": "Daha ince seviyeler eğri yüzeyleri ve kenarları daha pürüzsüz gösterir, ancak ağ oluşturma daha uzun sürer. Yalnızca görünüm değişir; tasarım ve dışa aktarımlar asla değişmez.",
"settings.frame_rate_note": "Görünümün sağ alt köşesinde saniyedeki kare sayısını gösterir. Açıkken görünüm sürekli yeniden çizilir.",
"settings.frame_rate_note": "Görünümün sağ alt köşesinde saniyedeki kare sayısını gösterir.",
"settings.measure_axis_distances": "X/Y/Z mesafelerini göster",
"settings.measure_axis_distances_help": "Ölç açıkken toplam mesafenin yanında dünya ekseni bileşen ölçülerini gösterir.",
"settings.msaa_applies_on_restart": "Kenar yumuşatma, SolidCraft bir sonraki başlatıldığında uygulanır",
@@ -1182,6 +1182,7 @@
"ui.agent_chat_files_left_out": "Bir iletiyle en fazla {max} dosya gider; dışarıda kalanlar: {files}",
"ui.agent_chat_files_loading": "Dosyaların okunması bekleniyor",
"ui.agent_chat_files_max": "Bir iletide en fazla {max} dosya olabilir",
"ui.agent_chat_flatpak_none_found": "Kodlama ajanı bulunamadı. Flatpak korumalı alanı varsayılan olarak ana klasörünüze yüklenen ajanları göremez ve ağ erişimi yoktur. nvm ile veya ~/.local/bin içine yüklenen Claude Code'a ve ACP bağdaştırıcısına izin vermek için bu komutu bir terminalde çalıştırın, ardından SolidCraft'ı yeniden başlatın. Komut, listelenen programlara ve oturum açma dosyalarına erişimin yanı sıra ağ erişimi de verir:",
"ui.agent_chat_import_failed": "{name}: içe aktarma başarısız oldu: {error}",
"ui.agent_chat_intro": "Zaten kullandığınız bir kodlama ajanıyla veya seçtiğiniz bir model uç noktasıyla sohbet edin. Ajan, SolidCraft'ın araçlarıyla açık tasarım üzerinde çalışır: Değişiklikleri hemen görünür ve mesaj başına tek adımda geri alınabilir. Sormadan neleri yapabileceğini Ayarlar > AI altında belirlersiniz.",
"ui.agent_chat_key_in_env": "anahtar ayarlı",
@@ -1501,6 +1502,7 @@
"ui.grid_follows_view_hint": "Yan görünümlerde ızgara, zemin yerine onlara bakan dik düzlemde gösterilir",
"ui.ground_shadow": "Zemin gölgesi",
"ui.groupSelected": "Seçimi grupla",
"ui.hatch": "Tarama",
"ui.heading_add_ins": "EKLENTİLER",
"ui.heading_assemble": "MONTAJ",
"ui.heading_box": "KUTU",
+3 -1
View File
@@ -1060,7 +1060,7 @@
"search.try": "Введіть назву команди, елемент цього проєкту або параметр. Щоб виконати команду зі значеннями, введіть її id та об'єкт JSON: {example}",
"search.valid": "Дійсне",
"settings.display_quality_note": "Дрібніші рівні малюють криві грані й ребра плавнішими, але тріангуляція триває довше. Змінюється лише вікно перегляду, ніколи не проєкт чи експорт.",
"settings.frame_rate_note": "Показує кадри за секунду в правому нижньому куті виду. Поки це ввімкнено, вид перемальовується безперервно.",
"settings.frame_rate_note": "Показує кадри за секунду в правому нижньому куті виду.",
"settings.measure_axis_distances": "Відстані X/Y/Z",
"settings.measure_axis_distances_help": "Показувати розміри вздовж світових осей разом із загальною відстанню, поки відкрите вікно вимірювання.",
"settings.msaa_applies_on_restart": "Згладжування застосується під час наступного запуску SolidCraft",
@@ -1183,6 +1183,7 @@
"ui.agent_chat_files_left_out": "З одним повідомленням надсилається щонайбільше {max} файлів; пропущено: {files}",
"ui.agent_chat_files_loading": "Очікування читання файлів",
"ui.agent_chat_files_max": "Щонайбільше {max} файлів на одне повідомлення",
"ui.agent_chat_flatpak_none_found": "Агента для програмування не знайдено. Пісочниця Flatpak типово не бачить агентів, установлених у домашній теці, і не має доступу до мережі. Щоб дозволити Claude Code і його адаптер ACP, установлені через nvm або в ~/.local/bin, виконайте цю команду в терміналі, а потім перезапустіть SolidCraft. Вона надає доступ до перелічених програм і файлів входу, а також доступ до мережі:",
"ui.agent_chat_import_failed": "{name}: не вдалося імпортувати: {error}",
"ui.agent_chat_intro": "Спілкуйтеся з агентом для програмування, яким уже користуєтеся, або з обраною вами модельною кінцевою точкою. Він працює з відкритим проєктом через інструменти SolidCraft: його зміни з'являються в міру виконання й скасовуються одним кроком на повідомлення. Що він може робити без запиту, задано в Налаштування > ШІ.",
"ui.agent_chat_key_in_env": "ключ задано",
@@ -1502,6 +1503,7 @@
"ui.grid_follows_view_hint": "Бічні види показують сітку на вертикальній площині, повернутій до них, а не на землі",
"ui.ground_shadow": "Тінь на землі",
"ui.groupSelected": "Згрупувати виділене",
"ui.hatch": "Штрихування",
"ui.heading_add_ins": "НАДБУДОВИ",
"ui.heading_assemble": "СКЛАДАННЯ",
"ui.heading_box": "КУБ",
+3 -1
View File
@@ -1060,7 +1060,7 @@
"search.try": "Nhập tên lệnh, một tính năng trong thiết kế này hoặc một tham số. Để chạy lệnh kèm giá trị, hãy nhập mã lệnh và một đối tượng JSON: {example}",
"search.valid": "Hợp lệ",
"settings.display_quality_note": "Mức độ chi tiết cao hơn giúp hiển thị các mặt cong và cạnh mượt mà hơn nhưng tốn nhiều thời gian tạo lưới hơn. Thiết lập này chỉ thay đổi vùng nhìn, không ảnh hưởng đến thiết kế hay dữ liệu xuất tệp.",
"settings.frame_rate_note": "Hiển thị số khung hình trên giây (FPS) ở góc dưới bên phải khung nhìn. Khi bật, khung nhìn sẽ vẽ lại liên tục.",
"settings.frame_rate_note": "Hiển thị số khung hình trên giây (FPS) ở góc dưới bên phải khung nhìn.",
"settings.measure_axis_distances": "Hiển thị khoảng cách theo trục X/Y/Z",
"settings.measure_axis_distances_help": "Hiển thị kích thước thành phần theo các trục tọa độ cùng với khoảng cách tổng thể khi mở công cụ Đo lường.",
"settings.msaa_applies_on_restart": "Khử răng cưa sẽ áp dụng vào lần khởi động tiếp theo của SolidCraft",
@@ -1183,6 +1183,7 @@
"ui.agent_chat_files_left_out": "Tối đa {max} tệp đính kèm trong một tin nhắn; đã bỏ qua: {files}",
"ui.agent_chat_files_loading": "Đang chờ đọc các tệp đính kèm",
"ui.agent_chat_files_max": "Tối đa {max} tệp trong một tin nhắn",
"ui.agent_chat_flatpak_none_found": "Không tìm thấy tác tử lập trình nào. Theo mặc định, hộp cát Flatpak không thấy các tác tử được cài trong thư mục nhà của bạn và không có quyền truy cập mạng. Để cho phép Claude Code và bộ điều hợp ACP của nó được cài qua nvm hoặc trong ~/.local/bin, hãy chạy lệnh này trong terminal, rồi khởi động lại SolidCraft. Lệnh cấp quyền truy cập các chương trình và tệp đăng nhập được liệt kê, cùng quyền truy cập mạng:",
"ui.agent_chat_import_failed": "{name}: nhập tệp thất bại: {error}",
"ui.agent_chat_intro": "Trò chuyện với tác tử lập trình (AI agent) bạn đang dùng hoặc một điểm cuối mô hình bạn chọn. AI thao tác trên thiết kế đang mở thông qua các công cụ của SolidCraft: các thay đổi hiển thị ngay khi thực hiện và có thể hoàn tác theo từng bước cho mỗi tin nhắn. Các thao tác AI được phép thực hiện mà không cần hỏi được đặt trong Tùy chọn cài đặt > AI.",
"ui.agent_chat_key_in_env": "đã đặt khóa",
@@ -1502,6 +1503,7 @@
"ui.grid_follows_view_hint": "Các góc nhìn cạnh hiển thị lưới trên mặt phẳng thẳng đứng hướng về phía camera thay vì trên mặt sàn",
"ui.ground_shadow": "Bóng đổ mặt sàn",
"ui.groupSelected": "Gộp nhóm vùng chọn",
"ui.hatch": "Gạch mặt cắt",
"ui.heading_add_ins": "TIỆN ÍCH MỞ RỘNG",
"ui.heading_assemble": "LẮP RÁP",
"ui.heading_box": "KHỐI HỘP",
+3 -1
View File
@@ -1059,7 +1059,7 @@
"search.try": "輸入命令名稱、此設計中的特徵或參數。要帶值執行命令,請輸入其ID和JSON物件:{example}",
"search.valid": "有效",
"settings.display_quality_note": "等級越精細,曲面和邊顯示越平滑,但網格化耗時更長。只影響檢視區,不影響設計或匯出。",
"settings.frame_rate_note": "在檢視右下角顯示每秒影格數。開啟時檢視會持續重繪。",
"settings.frame_rate_note": "在檢視右下角顯示每秒影格數。",
"settings.measure_axis_distances": "顯示 X/Y/Z 距離",
"settings.measure_axis_distances_help": "開啟測量時,在總距離之外顯示沿世界座標軸方向的分量尺寸。",
"settings.msaa_applies_on_restart": "抗鋸齒將在下次啟動 SolidCraft 時生效",
@@ -1182,6 +1182,7 @@
"ui.agent_chat_files_left_out": "一條訊息最多傳送 {max} 個檔案;未傳送:{files}",
"ui.agent_chat_files_loading": "正在等待檔案讀取完成",
"ui.agent_chat_files_max": "一條訊息最多附加 {max} 個檔案",
"ui.agent_chat_flatpak_none_found": "未找到程式設計代理。預設情況下,Flatpak 沙箱無法存取主資料夾中安裝的代理,也無法存取網路。若要允許透過 nvm 或在 ~/.local/bin 中安裝的 Claude Code 及其 ACP 轉接器,請在終端機執行以下命令,然後重新啟動 SolidCraft。此命令會授予對所列程式和登入檔案的存取權限,以及網路存取權限:",
"ui.agent_chat_import_failed": "{name}:匯入失敗:{error}",
"ui.agent_chat_intro": "與您已在使用的程式設計代理或您選擇的模型端點聊天。它透過 SolidCraft 的工具處理開啟的設計:變更即時顯示,每條訊息的變更可一步復原。無需詢問即可執行的範圍在「偏好設定 > AI」中設定。",
"ui.agent_chat_key_in_env": "已設定金鑰",
@@ -1501,6 +1502,7 @@
"ui.grid_follows_view_hint": "在側檢視中,將網格顯示在朝向該檢視的豎直平面上,而不是地面上",
"ui.ground_shadow": "地面陰影",
"ui.groupSelected": "群組所選",
"ui.hatch": "剖面線",
"ui.heading_add_ins": "增益集",
"ui.heading_assemble": "組裝",
"ui.heading_box": "長方體",
+26 -1
View File
@@ -176,6 +176,7 @@
"Export JSON…": "导出 JSON…",
"Export design library...": "导出设计库...",
"Export hidden edges as dashed lines": "以虚线导出隐藏线",
"Exported by SolidWorks, which usually saves Y up": "由 SolidWorks 导出,通常以 Y 轴向上保存",
"Expression": "表达式",
"Extend to meet": "延伸至相交",
"Extents": "范围",
@@ -609,8 +610,10 @@
"cmd.component_activate": "激活组件",
"cmd.component_break_link": "断开链接",
"cmd.component_capture_position": "捕获位置",
"cmd.component_copies": "查找相同副本",
"cmd.component_create": "新建组件",
"cmd.component_delete": "删除组件",
"cmd.component_extract": "提取到文件",
"cmd.component_from_bodies": "由实体创建组件",
"cmd.component_insert_design": "插入设计",
"cmd.component_links": "列出链接",
@@ -623,6 +626,7 @@
"cmd.component_properties": "组件物理属性",
"cmd.component_relink": "重新链接源文件",
"cmd.component_rename": "重命名组件",
"cmd.component_replace_with_link": "替换为链接",
"cmd.component_revert_position": "恢复位置",
"cmd.component_unlock": "解锁内容",
"cmd.component_update_links": "更新链接",
@@ -740,6 +744,7 @@
"cmd.motion_plot": "运动研究图表",
"cmd.motion_study": "运动研究",
"cmd.motion_trace": "运动轨迹",
"cmd.motion_trace_edit": "编辑运动轨迹",
"cmd.motion_trace_keep": "保留运动轨迹",
"cmd.occurrence_copy": "粘贴(实例)",
"cmd.occurrence_ground": "固定",
@@ -1353,7 +1358,7 @@
"search.try": "输入命令名称、此设计中的特征或参数。要带值运行命令,请输入其ID和JSON对象:{example}",
"search.valid": "有效",
"settings.display_quality_note": "仅影响显示,不影响设计和导出。",
"settings.frame_rate_note": "在视图右下角显示每秒帧数。开启时视图会持续重绘。",
"settings.frame_rate_note": "在视图右下角显示每秒帧数。",
"settings.measure_axis_distances": "显示 X/Y/Z 距离",
"settings.measure_axis_distances_help": "测量对话框打开时,在总距离之外显示沿世界坐标轴方向的尺寸线和距离。",
"settings.msaa_applies_on_restart": "抗锯齿将在下次启动 SolidCraft 时生效",
@@ -1362,6 +1367,7 @@
"settings.reset_intro": "这些设置在此计算机上无法正常工作,会导致 SolidCraft 无法启动,因此已更改:",
"settings.reset_title": "设置已更改",
"settings.supersampling_note": "以更高分辨率绘制视图再缩小:在低于 4K 的屏幕上边、线和网格更平滑。会消耗 GPU 时间:1.5× 时约 2.25 倍像素,2× 时 4 倍。",
"sheet.no_body": "没有钣金实体:请使用法兰 > Base 创建,或使用转换为钣金将板材转换为钣金(选择其较大的平面)。",
"shortcut.json_error": "参数必须采用 JSON 格式:{error}",
"shortcut.no_command": "没有命令 `{id}`;JSON 前的文本必须是命令 ID",
"shortcut.pin": "固定到 S 框顶部",
@@ -1398,6 +1404,9 @@
"thread.designation": "螺纹标记",
"thread.iso_metric": "ISO 公制",
"thread.select_size": "选择尺寸",
"timeline.delete_after_marker_one": "删除历史标记之后的 1 个特征",
"timeline.delete_after_marker_other": "删除历史标记之后的 {count} 个特征",
"timeline.delete_many": "删除所选的 {count} 个特征",
"timeline.group": "新建特征文件夹",
"timeline.move_many": "移动时间线项目",
"timeline.move_to_group": "将特征移至文件夹",
@@ -1476,6 +1485,7 @@
"ui.agent_chat_files_left_out": "一条消息最多发送 {max} 个文件;未发送:{files}",
"ui.agent_chat_files_loading": "正在等待文件读取完成",
"ui.agent_chat_files_max": "一条消息最多附加 {max} 个文件",
"ui.agent_chat_flatpak_none_found": "未找到编程代理。默认情况下,Flatpak 沙箱无法访问主目录中安装的代理,也无法访问网络。要允许使用通过 nvm 或 ~/.local/bin 安装的 Claude Code 及其 ACP 适配器,请在终端运行以下命令,然后重新启动 SolidCraft。此命令授予对所列程序和登录文件的访问权限以及网络访问权限:",
"ui.agent_chat_import_failed": "{name}:导入失败:{error}",
"ui.agent_chat_intro": "与你已在使用的编程智能体或你选择的模型端点聊天。它通过 SolidCraft 的工具处理打开的设计:更改即时显示,每条消息的更改可一步撤销。无需询问即可执行的范围在“首选项 > AI”中设置。",
"ui.agent_chat_key_in_env": "已设置密钥",
@@ -1548,8 +1558,10 @@
"ui.ai_add": "添加",
"ui.ai_add_agent": "添加智能体…",
"ui.ai_add_agent_title": "添加智能体",
"ui.ai_add_anthropic": "Anthropic 兼容 API",
"ui.ai_add_api": "OpenAI 兼容 API",
"ui.ai_add_custom": "自定义命令",
"ui.ai_add_endpoint": "API 端点",
"ui.ai_add_installed": "已安装的智能体",
"ui.ai_add_installed_note": "为已安装的智能体添加一个自己的条目,例如使用其他参数。已安装的智能体会自动列出。",
"ui.ai_added": "已添加",
@@ -1578,6 +1590,7 @@
"ui.ai_key_needs_https": "密钥只会通过 HTTPS 发送,或发送到本机。",
"ui.ai_key_note": "保存在系统钥匙串中,绝不保存在 SolidCraft 的设置里。本地服务器可留空。",
"ui.ai_keychain_error": "系统钥匙串拒绝:{error}",
"ui.ai_kind": "类型",
"ui.ai_mode_all": "全部允许",
"ui.ai_mode_all_note": "不再询问。崩溃报告仍不可访问,文件夹限制仍然有效。",
"ui.ai_mode_all_warning": "智能体可以不经询问保存、覆盖、导出和替换你的设计。只在你信任的智能体和已保存的设计上使用。",
@@ -1599,6 +1612,7 @@
"ui.ai_panel": "面板",
"ui.ai_permissions": "权限",
"ui.ai_preset": "预设",
"ui.ai_protocol": "协议",
"ui.ai_remove": "移除",
"ui.ai_request_limit": "每条消息 24 次请求后已停止;再发送一条消息即可继续",
"ui.ai_reset": "重置",
@@ -1609,6 +1623,8 @@
"ui.ai_test": "测试",
"ui.ai_test_acp_ok": "已响应:{agent}",
"ui.ai_test_acp_tip": "短暂启动智能体并请求一个会话,然后结束它",
"ui.ai_test_answered": "已连接:{model} 已响应",
"ui.ai_test_anthropic_tip": "向端点发送一条简短消息",
"ui.ai_test_api_tip": "向端点查询其模型列表",
"ui.ai_test_no_model": "已连接:{count} 个模型,但没有 {model}",
"ui.ai_test_ok": "已连接:{count} 个模型",
@@ -1766,6 +1782,9 @@
"ui.export_keymap_tip": "将按键、S 框收藏和工具栏导出为文件",
"ui.extend": "延伸",
"ui.extent_type": "延伸类型",
"ui.extract_component": "提取到文件…",
"ui.extract_design_changed": "选择文件时设计已更改。请重新开始提取到文件。",
"ui.extract_done": "该组件现在是独立的设计,并已在此处链接。",
"ui.extrude": "拉伸",
"ui.factor": "系数",
"ui.feature_history": "特征历史",
@@ -1797,6 +1816,7 @@
"ui.grid_follows_view_hint": "在侧视图中,将网格显示在朝向该视图的竖直平面上,而不是地面上",
"ui.ground_shadow": "地面阴影",
"ui.groupSelected": "将所选编组",
"ui.hatch": "剖面线",
"ui.heading_add_ins": "加载项",
"ui.heading_assemble": "装配",
"ui.heading_box": "长方体",
@@ -1904,6 +1924,10 @@
"ui.length_decimals": "长度小数位",
"ui.light": "浅色",
"ui.line": "直线",
"ui.link_copies": "链接相同副本",
"ui.link_copies_keep": "保留",
"ui.link_copies_link": "链接",
"ui.link_copies_text": "这些组件生成的实体与 {name} 相同。要将它们改为其链接的放置吗?",
"ui.lock": "锁定",
"ui.loft": "放样",
"ui.look_at": "朝向",
@@ -2027,6 +2051,7 @@
"ui.render": "渲染",
"ui.renderer": "渲染器",
"ui.repeat": "重复",
"ui.replace_with_link_done": "这些副本现在是链接设计的放置。",
"ui.report_a_bug": "报告缺陷",
"ui.reset": "重置",
"ui.reset_all": "全部重置",
+6 -1
View File
@@ -1,9 +1,14 @@
//! Built-in physical materials: densities in g/cm³ and sRGB colours.
/// The default material's name: what a body without a usable material of its own is made of.
/// Assigning it clears a body's assignment.
pub const DEFAULT_MATERIAL: &str = "Default";
/// Density of the default material (g/cm³).
pub const DEFAULT_DENSITY: f64 = 1.29;
/// Physical materials: name and density (g/cm³).
pub const MATERIALS: [(&str, f64); 14] = [
("Default", DEFAULT_DENSITY),
(DEFAULT_MATERIAL, DEFAULT_DENSITY),
("Steel", 7.85),
("Stainless Steel", 8.0),
("Aluminum", 2.70),
+27 -3
View File
@@ -16,6 +16,8 @@ struct U {
ana_c: array<vec4<f32>, 8>,
ana_f: vec4<f32>, // x = 1: stripe the negative draft and blocked faces
depth: vec4<f32>, // x = 1: reversed depth (1 near, 0 far); y, z: the faces' push (`FACE_BIAS`)
tint: vec4<f32>, // xyz added to a whole hovered body's colour before lighting
sel: vec4<f32>, // xyz a whole selected body's colour before lighting
};
fn reversed() -> bool {
@@ -253,7 +255,9 @@ fn solid(i: TOut, front: bool) -> vec4<f32> {
if (clipped(i.wp)) { discard; }
if (!front && length(u.clip.xyz) > 0.5) {
// The cut face in its body's colour ("from component" colouring), tinted toward the
// cap colour so it reads as a cut, hatched at 45 degrees on screen.
// cap colour so it reads as a cut, hatched at 45 degrees on screen; with the hatch off
// (`u.cap.a` 0), plain in the body's own colour.
if (u.cap.a < 0.5) { return out_color(vec4<f32>(i.c.rgb, 1.0)); }
let base = mix(i.c.rgb, u.cap.rgb, 0.35);
let h = fract((i.pos.x + i.pos.y) / 9.0);
return out_color(vec4<f32>(select(base, base * 0.6, h < 0.22), 1.0));
@@ -422,6 +426,20 @@ fn fs_tri(i: TOut) -> @location(0) vec4<f32> {
return shade(i);
}
/// A whole hovered body, lit over the model from its own plan triangles in a lifted colour.
@fragment
fn fs_hl_hover(i: TOut) -> @location(0) vec4<f32> {
if (clipped(i.wp)) { discard; }
return lit_color(i, 1.0, vec4<f32>(i.c.rgb + u.tint.rgb, i.c.a));
}
/// A whole selected body, lit over the model from its own plan triangles in the selection colour.
@fragment
fn fs_hl_selected(i: TOut) -> @location(0) vec4<f32> {
if (clipped(i.wp)) { discard; }
return lit_color(i, 1.0, vec4<f32>(u.sel.rgb, i.c.a));
}
/// Reference planes: shaded like `fs_tri`, but never cut by the section plane.
@fragment
fn fs_tri_all(i: TOut) -> @location(0) vec4<f32> {
@@ -433,9 +451,15 @@ fn shade(i: TOut) -> vec4<f32> {
}
fn lit(i: TOut, visibility: f32) -> vec4<f32> {
return lit_color(i, visibility, i.c);
}
/// [`lit`] in a colour of its own: a whole highlighted body is lit in its colour lifted by the
/// hover amount, or in the selection colour, from the model's own plan triangles.
fn lit_color(i: TOut, visibility: f32, base: vec4<f32>) -> vec4<f32> {
// A zero normal marks flat colour (selection fills, translucent planes).
if (length(i.n) < 0.5) {
return out_color(i.c);
return out_color(base);
}
let v = normalize(u.back.xyz);
var n = normalize(i.n);
@@ -446,7 +470,7 @@ fn lit(i: TOut, visibility: f32) -> vec4<f32> {
let h = normalize(key + v);
let spec = pow(max(dot(n, h), 0.0), 40.0) * 0.25;
let k = 0.42 + 0.38 * diff + 0.25 * fill - (0.42 + 0.25 * fill) * (1.0 - visibility);
return out_color(vec4<f32>(min(i.c.rgb * k + vec3<f32>(spec), vec3<f32>(1.0)), i.c.a));
return out_color(vec4<f32>(min(base.rgb * k + vec3<f32>(spec), vec3<f32>(1.0)), base.a));
}
// Lines, decals and ghosts are drawn at their own depth: opaque faces are pushed back by their
+1 -1
View File
@@ -230,7 +230,7 @@ impl Tokens {
],
analysis: AnalysisColors::DEFAULT,
icons: IconColors { highlight: Color32::WHITE, agent: Color32::from_rgb(168, 85, 247), marker_halo: Color32::from_black_alpha(150) },
logo: [Color32::from_rgb(96, 158, 236), Color32::from_rgb(58, 136, 238), Color32::from_rgb(55, 108, 197), Color32::WHITE],
logo: [Color32::from_rgb(96, 158, 236), Color32::from_rgb(58, 136, 238), Color32::from_rgb(55, 108, 197)],
}
}
pub const fn dark() -> Tokens {
+1 -2
View File
@@ -126,8 +126,7 @@
"logo": [
"#ffad38",
"#eda134",
"#855a1d",
"#ffad38"
"#855a1d"
],
"manipulator": "#ffad38",
"mesh_accent": "#d6922f",
+1 -2
View File
@@ -126,8 +126,7 @@
"logo": [
"#d8f4ff",
"#8edcf5",
"#4386ac",
"#eef8ff"
"#4386ac"
],
"manipulator": "#66ddff",
"mesh_accent": "#78c6e8",
@@ -126,8 +126,7 @@
"logo": [
"#cba6f7",
"#477dc8",
"#586382",
"#cdd6f4"
"#586382"
],
"manipulator": "#cba6f7",
"mesh_accent": "#cba6f7",
+1 -2
View File
@@ -126,8 +126,7 @@
"logo": [
"#85b9ef",
"#4096e9",
"#2b71be",
"#ffffff"
"#2b71be"
],
"manipulator": "#007aff",
"mesh_accent": "#927300",
+1 -2
View File
@@ -126,8 +126,7 @@
"logo": [
"#4aa3d8",
"#2878a9",
"#68717d",
"#e0e2e5"
"#68717d"
],
"manipulator": "#4aa3d8",
"mesh_accent": "#4aa3d8",
+1 -2
View File
@@ -126,8 +126,7 @@
"logo": [
"#b3ffc4",
"#79ed95",
"#34854b",
"#d8ffdf"
"#34854b"
],
"manipulator": "#4dff79",
"mesh_accent": "#59d67b",
+1 -2
View File
@@ -126,8 +126,7 @@
"logo": [
"#00bfff",
"#0070d0",
"#555555",
"#ffffff"
"#555555"
],
"manipulator": "#00bfff",
"mesh_accent": "#00bfff",
+1 -2
View File
@@ -126,8 +126,7 @@
"logo": [
"#88c0d0",
"#5e81ac",
"#657991",
"#eceff4"
"#657991"
],
"manipulator": "#88c0d0",
"mesh_accent": "#88c0d0",
+1 -2
View File
@@ -126,8 +126,7 @@
"logo": [
"#bd93f9",
"#8456b8",
"#656581",
"#f8f8f2"
"#656581"
],
"manipulator": "#bd93f9",
"mesh_accent": "#bd93f9",
+1 -2
View File
@@ -126,8 +126,7 @@
"logo": [
"#61afef",
"#56b6c2",
"#c678dd",
"#abb2bf"
"#c678dd"
],
"manipulator": "#61afef",
"mesh_accent": "#857100",
+1 -2
View File
@@ -126,8 +126,7 @@
"logo": [
"#ccccff",
"#9999cc",
"#666699",
"#ffffff"
"#666699"
],
"manipulator": "#555599",
"mesh_accent": "#666699",
+1 -2
View File
@@ -126,8 +126,7 @@
"logo": [
"#268bd2",
"#346e88",
"#b5b6a0",
"#586e75"
"#b5b6a0"
],
"manipulator": "#268bd2",
"mesh_accent": "#268bd2",
+1 -2
View File
@@ -126,8 +126,7 @@
"logo": [
"#ffffff",
"#c0c0c0",
"#808080",
"#ffffff"
"#808080"
],
"manipulator": "#000080",
"mesh_accent": "#000080",
+1 -2
View File
@@ -126,8 +126,7 @@
"logo": [
"#7aa2f7",
"#4775ce",
"#4f6089",
"#c0caf5"
"#4f6089"
],
"manipulator": "#7aa2f7",
"mesh_accent": "#7aa2f7",
+3 -3
View File
@@ -4,7 +4,7 @@
use serde::{Deserialize, Serialize};
use crate::{DocError, Document, Result};
use crate::{DEFAULT_MATERIAL, DocError, Document, Result};
#[path = "config_rules.rs"]
mod rules;
@@ -96,7 +96,7 @@ impl Document {
/// returning None for the default material.
pub fn material_cell(&self, value: &str) -> Result<Option<String>> {
let name = self.material_name(value).ok_or_else(|| DocError::Invalid(format!("unknown configuration material `{value}`")))?;
Ok((name != "Default").then_some(name))
Ok((name != DEFAULT_MATERIAL).then_some(name))
}
/// Freeze design-unit-sensitive configuration literals when changing display units.
@@ -140,7 +140,7 @@ impl Document {
}
}),
Column::Suppress(id) => self.feature(*id).map(|f| f.suppressed.to_string()),
Column::Material(body) => Some(self.materials.get(body).cloned().unwrap_or_else(|| "Default".into())),
Column::Material(body) => Some(self.materials.get(body).cloned().unwrap_or_else(|| DEFAULT_MATERIAL.into())),
Column::Appearance(body) => Some(self.appearances.bodies.get(body).and_then(|look| serde_json::to_string(look).ok()).unwrap_or_default()),
Column::ComponentAppearance(id) => {
Some(self.appearances.components.get(id).and_then(|look| serde_json::to_string(look).ok()).unwrap_or_default())
+49 -4
View File
@@ -2419,6 +2419,12 @@ pub struct Document {
pub marker: Option<usize>,
#[serde(default)]
pub next_id: u64,
/// Body names the design used and must not use again: a name a `body.rename` freed (#4957).
/// The design's own counter starts over at every evaluation, so without these a design that
/// is evaluated again after a rename hands the freed name to the next body, and a script
/// that names a body finds another one.
#[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")]
pub reserved_body_names: std::collections::BTreeSet<String>,
/// Components below the root, as a tree (by parent).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub components: Vec<Component>,
@@ -2665,6 +2671,7 @@ impl Document {
features: Vec::new(),
marker: None,
next_id: 1,
reserved_body_names: Default::default(),
components: Vec::new(),
occurrences: Vec::new(),
assembly: Default::default(),
@@ -2692,7 +2699,14 @@ impl Document {
/// Rename body `old` to `new` where the design keeps things by body name outside its
/// features: materials, appearances, configuration columns, offsets, components and the
/// browser's groups and order. (The features are the rename command's to change.)
///
/// A name the design numbered (`Body1`) is not used again after the rename: the design's
/// counter starts over at every evaluation, so without this a design that is evaluated
/// again hands the freed name to the next body (#4957).
pub fn rename_body_keys(&mut self, old: &str, new: &str) {
if old.strip_prefix("Body").and_then(|n| n.parse::<usize>().ok()).is_some() {
self.reserved_body_names.insert(old.to_string());
}
if let Some(m) = self.materials.remove(old) {
self.materials.insert(new.to_string(), m);
}
@@ -2842,9 +2856,21 @@ impl Document {
st
}
/// Density of a body's material (g/cm³).
/// The material a body's mass is computed with, as the library or this design spells it:
/// its assigned material when that is known and has a usable density, else
/// [`DEFAULT_MATERIAL`]. The assignment as stored (`materials`) is left as it is.
pub fn body_material(&self, body: &str) -> String {
self.materials
.get(body)
.and_then(|m| self.material_name(m))
.filter(|m| self.material_density(m).is_some())
.unwrap_or_else(|| DEFAULT_MATERIAL.into())
}
/// Density of a body's material (g/cm³): its [`Document::body_material`]'s, so the name a
/// body reads and the mass it has never disagree.
pub fn density(&self, body: &str) -> f64 {
self.materials.get(body).and_then(|m| self.material_density(m)).unwrap_or(DEFAULT_DENSITY)
self.material_density(&self.body_material(body)).unwrap_or(DEFAULT_DENSITY)
}
/// A material defined in this design, by name (case-insensitive).
@@ -3452,6 +3478,24 @@ impl Document {
Ok(())
}
/// Resolve circular Quantity expressions before solving. Rebuilding the copies here also
/// covers cold document evaluation and configuration changes, without an engine dependency.
pub fn apply_pattern_quantities(&self, vals: &std::collections::BTreeMap<String, expr::Value>, sk: &mut Sketch) -> Result<()> {
let counts: Vec<_> = sk
.patterns
.iter()
.filter_map(|p| p.count_expression.as_ref().map(|e| (p.id.clone(), e)))
.map(|(id, e)| {
let n = crate::eval::val(vals, e, Kind::Unitless)?;
Ok((id, solidcraft_sketch::SketchPattern::circular_quantity(n)?))
})
.collect::<Result<_>>()?;
for (id, count) in counts {
sk.resize_circular_pattern(&id, count)?;
}
Ok(())
}
/// Move the points placed by parameters to where their expressions come to (in place). Such
/// a point is fixed, so the solve keeps it there.
pub fn apply_point_values(&self, vals: &std::collections::BTreeMap<String, expr::Value>, sk: &mut Sketch) -> Result<()> {
@@ -3538,6 +3582,7 @@ impl Document {
moved_from: Option<&Sketch>,
analysis: solidcraft_sketch::Analysis,
) -> Result<solidcraft_sketch::SolveReport> {
self.apply_pattern_quantities(vals, sk)?;
self.apply_dimension_values(vals, sk)?;
let was = |sk: &Sketch| moved_from.and_then(|from| sk.positions_in(from));
let offsets = !sk.offsets.is_empty() || !sk.freeform_offsets.is_empty();
@@ -3558,10 +3603,10 @@ impl Document {
}
}
/// Density of the default material (g/cm³), as the default.
/// The built-in material library: (name, density), and each material's colour.
#[path = "../../../assets/materials/library.rs"]
mod materials;
pub use materials::{DEFAULT_DENSITY, MATERIALS, material_color};
pub use materials::{DEFAULT_DENSITY, DEFAULT_MATERIAL, MATERIALS, material_color};
/// A physical material defined in a design (`material.create`): its name, its density and,
/// optionally, the look it gives bodies without an appearance of their own.
+122 -9
View File
@@ -17,6 +17,8 @@ use crate::expr::{self, Kind, Value};
use crate::lineage::Traced;
use crate::{AxisRef, DocError, Document, Feature, FeatureKind, HoleKind, Operation, PlaneRef, ProfileSel, Result};
type JointFaces = (Body, kernel::Result<Arc<[kernel::FaceInfo]>>);
/// A body in the evaluated model.
#[derive(Clone, Debug)]
pub struct ModelBody {
@@ -31,6 +33,9 @@ pub struct ModelBody {
// Timeline snapshots are cloned before meshes are requested. Share the cell itself so
// meshing any snapshot also warms every unchanged copy of this body.
mesh: Arc<OnceLock<Arc<Mesh>>>,
/// Joint face measurements use their own tolerance, independent of display quality.
/// Shared by snapshots of this geometry; the body identity guards direct replacement.
joint_faces: Arc<OnceLock<JointFaces>>,
/// Lazy canonical reference triangles for a proper rigid occurrence placement.
mesh_placement: Option<Arc<placed_mesh::PlacedMesh>>,
/// The display plan (none when the body has no plan: a mesh body, or one the planner
@@ -71,6 +76,7 @@ impl ModelBody {
body,
feature,
mesh: Arc::new(OnceLock::new()),
joint_faces: Arc::new(OnceLock::new()),
mesh_placement: None,
plan: Arc::new(Default::default()),
plan_mesh: Arc::new(Default::default()),
@@ -86,6 +92,19 @@ impl ModelBody {
}
}
/// Face measurements for joint origins, at their original chord tolerance. Pose changes
/// and scratch copies share them; a geometry edit makes a new body and cache.
pub(crate) fn joint_faces(&self) -> Result<Arc<[kernel::FaceInfo]>> {
let measure = || {
#[cfg(test)]
cost_tests::count(|c| c.joint_faces += 1);
let tol = (self.body.size() * 2e-3).max(1e-3);
self.body.faces(tol).map(Arc::from)
};
let (body, faces) = self.joint_faces.get_or_init(|| (self.body.clone(), measure()));
if body.same(&self.body) { faces.clone().map_err(Into::into) } else { measure().map_err(Into::into) }
}
/// A box around the body without meshing it ([`Body::outline_bounds`]), worked out once.
pub fn outline_bounds(&self) -> solidcraft_geom::Aabb3 {
match self.outline.get_or_init(|| (self.body.clone(), self.body.outline_bounds())) {
@@ -120,6 +139,9 @@ impl ModelBody {
// finding their surface, picks naming their face, sketch planes), and a different
// triangulation moved those and changed what designs build. A GPU viewport draws the
// plan ([`ModelBody::display_plan`]).
if let Some(mesh) = self.mesh.get() {
return mesh.clone();
}
match self.mesh_placement.as_deref() {
Some(placed) => self.mesh.get_or_init(|| placed.mesh()).clone(),
None => placed_mesh::reference_mesh(&self.body, &self.mesh, self.display_tol()),
@@ -694,6 +716,10 @@ pub struct ModelState {
/// Shared between states: a feature that does not change a sketch keeps it as it was.
pub sketches: Vec<Arc<SolvedSketch>>,
pub body_counter: usize,
/// Body names the design used and must not use again (a name a `body.rename` freed, #4957).
/// The counter starts over at every evaluation, so without these a design that is evaluated
/// again after a rename hands the freed name to the next body.
pub reserved_body_names: std::collections::BTreeSet<String>,
/// Sheet metal bodies (by body name) and their flat patterns.
pub sheets: Vec<crate::sheet::SheetBody>,
/// Construction planes, axes and points as evaluated (by feature).
@@ -729,6 +755,9 @@ impl ModelState {
self.sketch_keys.get(&s.feature).hash(&mut h);
}
self.body_counter.hash(&mut h);
for n in &self.reserved_body_names {
n.hash(&mut h);
}
for (feature, points) in &self.blend_edges {
feature.hash(&mut h);
for p in points {
@@ -994,6 +1023,11 @@ pub struct FeatureResult {
pub stale: bool,
#[serde(skip)]
fingerprint: u64,
/// Taken from a saved model that did not keep this feature's state (#3220): `state` only
/// stands in for it until [`Model::evaluate`] evaluates the feature again, which
/// [`Model::adopt`] does before the model is used.
#[serde(skip)]
unkept: bool,
#[serde(skip)]
pub state: Arc<ModelState>,
}
@@ -1739,7 +1773,8 @@ impl Model {
let (vals, perr) = evaluation_values(doc);
self.param_errors = perr;
let mut prev_fp = 0u64;
let mut state = self.empty.clone();
// The names the design must not use again travel with the state (#4957).
let mut state = std::sync::Arc::new(ModelState { reserved_body_names: doc.reserved_body_names.clone(), ..(*self.empty).clone() });
let mut out: Vec<FeatureResult> = Vec::with_capacity(doc.features.len());
let mut reuse = !self.no_cache;
let mut recomputed = 0;
@@ -1762,11 +1797,13 @@ impl Model {
let cancelled = watched.step(i);
let fp = chain_fingerprint(doc, &vals, f, prev_fp, rolled_back, &self.step_digests, Some(&state));
prev_fp = fp;
let current = |old: &&FeatureResult| old.id == f.id && old.fingerprint == fp && !old.stale;
// A state the saved model did not keep is evaluated again from the one before it; the
// results after it that the model did keep still stand (#3220).
let unkept = reuse && self.results.get(i).filter(current).is_some_and(|old| old.unkept);
if reuse
&& let Some(old) = self.results.get(i)
&& old.id == f.id
&& old.fingerprint == fp
&& !old.stale
&& !unkept
&& let Some(old) = self.results.get(i).filter(current)
{
if !old.skipped {
state = old.state.clone();
@@ -1774,7 +1811,7 @@ impl Model {
out.push(old.clone());
continue;
}
reuse = false;
reuse = unkept && !cancelled;
if f.suppressed || rolled_back || cancelled {
out.push(FeatureResult {
id: f.id,
@@ -1786,6 +1823,7 @@ impl Model {
skipped: true,
stale: false,
fingerprint: fp,
unkept: false,
state: state.clone(),
});
continue;
@@ -1803,6 +1841,8 @@ impl Model {
if c.error.is_none() {
state = c.state.clone();
}
// A kept result after a failed one would not be this evaluation's.
reuse &= c.error.is_none();
out.push(FeatureResult {
id: f.id,
name: f.name.clone(),
@@ -1813,6 +1853,7 @@ impl Model {
skipped: false,
stale: false,
fingerprint: fp,
unkept: false,
state: state.clone(),
});
continue;
@@ -1823,12 +1864,16 @@ impl Model {
if r.error.is_none() {
state = r.state.clone();
}
out.push(FeatureResult { state: state.clone(), ..r.clone() });
reuse &= r.error.is_none();
out.push(FeatureResult { state: state.clone(), unkept: false, ..r.clone() });
continue;
}
recomputed += 1;
let t0 = now();
let mut next = (*state).clone();
// The names the design must not use again travel with the state (#4957): a state
// taken from an earlier evaluation may have been made before a rename freed one.
next.reserved_body_names = doc.reserved_body_names.clone();
for b in &mut next.bodies {
b.made = crate::lineage::Made::Before;
}
@@ -1952,6 +1997,7 @@ impl Model {
m.put(key, e);
}
}
reuse &= error.is_none();
out.push(FeatureResult {
id: f.id,
name: f.name.clone(),
@@ -1962,6 +2008,7 @@ impl Model {
skipped: false,
stale: false,
fingerprint: fp,
unkept: false,
state: state.clone(),
});
}
@@ -2006,6 +2053,8 @@ impl Model {
b.name = new.to_string();
b.names = b.names.renamed();
}
// The names the design must not use again travel with the state (#4957).
st.reserved_body_names = doc.reserved_body_names.clone();
Arc::new(st)
})
.clone();
@@ -2121,8 +2170,10 @@ pub fn select_profiles(ss: &SolvedSketch, sel: &ProfileSel) -> Result<Vec<Region
solidcraft_sketch::without_text_counters(&ss.sketch, chosen).into_iter().map(|p| p.region).collect()
}
ProfileSel::Indices { indices } => {
// A design file may list a profile more than once; it is still one profile.
let mut seen = std::collections::BTreeSet::new();
let mut v = Vec::new();
for i in indices {
for i in indices.iter().filter(|i| seen.insert(**i)) {
v.push(ps.get(*i).ok_or_else(|| DocError::Unknown(format!("profile {i} of {}", ss.name)))?.region.clone());
}
v
@@ -2188,7 +2239,8 @@ fn new_name(state: &mut ModelState, f: &Feature, k: usize) -> String {
loop {
state.body_counter += 1;
let n = format!("Body{}", state.body_counter);
if state.body(&n).is_none() {
// A name a rename freed is not handed out again (#4957).
if state.body(&n).is_none() && !state.reserved_body_names.contains(&n) {
return n;
}
}
@@ -2375,6 +2427,33 @@ fn clear_tools_as_one(tools: Vec<Traced>) -> Vec<Traced> {
}
}
/// An intersect never deletes a body by accident (#5295): it is refused when nothing of a named
/// target would remain, when nothing of any target would remain, or when the tools meet a target
/// and the boolean still came out empty. Only an unnamed target the tools miss altogether is
/// removed, as an intersect with every body does (#5160).
pub(crate) fn empty_intersection(bodies: &[&str]) -> DocError {
let body = match bodies {
[one] => format!("`{one}`"),
[first, rest @ ..] => format!("`{first}` and {} more", rest.len()),
[] => "the target body".to_string(),
};
DocError::Invalid(format!("the intersection is empty: nothing of {body} would remain; check that the profile overlaps the body"))
}
/// The bodies a feature's boolean takes part in, and whether they were named rather than taken
/// by default (every body, or the feature's component).
pub(crate) fn boolean_scope(state: &ModelState, f: &Feature, targets: &[String]) -> (bool, Vec<String>) {
let effective = if targets.is_empty() { &f.participants } else { targets };
let all = effective.len() == 1 && effective.first().is_some_and(|t| t == "*");
let names = state
.bodies
.iter()
.filter(|b| all || if effective.is_empty() { b.component == f.component || b.feature == f.id } else { effective.contains(&b.name) })
.map(|b| b.name.clone())
.collect();
(!all && !effective.is_empty(), names)
}
/// [`apply_op`] with tools whose lineage is known: the bodies it makes or changes keep theirs.
fn apply_traced(state: &mut ModelState, f: &Feature, tools: Vec<Traced>, op: Operation, targets: &[String]) -> Result<()> {
let tools = if op == Operation::Intersect { intersection_tool_union(tools)? } else { tools };
@@ -2382,6 +2461,7 @@ fn apply_traced(state: &mut ModelState, f: &Feature, tools: Vec<Traced>, op: Ope
// of its component (and what it made itself): other components are left alone.
let targets: &[String] = if targets.is_empty() { &f.participants } else { targets };
let all = targets.len() == 1 && targets.first().is_some_and(|t| t == "*");
let named = !all && !targets.is_empty();
let target_idx: Vec<usize> = if all {
(0..state.bodies.len()).collect()
} else if targets.is_empty() {
@@ -2516,6 +2596,24 @@ fn apply_traced(state: &mut ModelState, f: &Feature, tools: Vec<Traced>, op: Ope
None => None,
};
}
if op == Operation::Intersect && acc.as_ref().is_none_or(|b| b.body.face_count() == 0) {
let Some(mb) = state.bodies.get(ti) else { continue };
// A tool that meets the body yet leaves nothing is a failed boolean, not an empty result.
let mut met = false;
if !named && !mb.body.is_mesh() {
for t in &tools {
if mb.body.meets(&tool_in(t, into)?.body)? {
met = true;
break;
}
}
}
if named || met {
return Err(empty_intersection(&[mb.name.as_str()]));
}
remove.push(ti);
continue;
}
match acc {
Some(b) => {
if let Some(mb) = state.bodies.get_mut(ti) {
@@ -2525,6 +2623,10 @@ fn apply_traced(state: &mut ModelState, f: &Feature, tools: Vec<Traced>, op: Ope
None => remove.push(ti),
}
}
if op == Operation::Intersect && target_idx.iter().all(|i| remove.contains(i)) {
let names: Vec<&str> = target_idx.iter().filter_map(|i| state.bodies.get(*i)).map(|b| b.name.as_str()).collect();
return Err(empty_intersection(&names));
}
let mut i = 0;
state.bodies.retain(|_| {
let keep = !remove.contains(&i);
@@ -5016,6 +5118,7 @@ fn feature_op(f: &Feature) -> (Operation, Vec<String>) {
FeatureKind::Pipe { operation, targets, .. } => (*operation, crate::plain(targets)),
FeatureKind::Loft { operation, targets, .. } | FeatureKind::Sweep { operation, targets, .. } => (*operation, crate::plain(targets)),
FeatureKind::Coil { operation, targets, .. } => (*operation, crate::plain(targets)),
FeatureKind::Rib { .. } => (Operation::Join, Vec::new()),
_ => (Operation::NewBody, Vec::new()),
}
}
@@ -5375,6 +5478,16 @@ fn replay(
);
}
}
} else if let FeatureKind::Rib { .. } = &src.kind {
// A rib is built again at each copy of its sketch, against the bodies there (#4593):
// its walls run until they meet the body, which may not lie the same way there.
for (k, m) in mats.iter().enumerate() {
for body in more::rib_tools(vals, st, src, Some(m))? {
// Each copy's faces are named as the rib's, with the instance.
let t = Traced::unnamed(body);
copies.push(Traced { lineage: t.lineage.map(|l| l.copied(src.id, k + 1)), body: t.body });
}
}
} else {
// Each copy's faces are named as the source feature's, with the instance.
let tools = feature_tools_with_constructor(doc, vals, src, st, &mut None)?;
+100 -2
View File
@@ -13,15 +13,16 @@ use solidcraft_sketch::Sketch;
use crate::*;
/// Work counted on this thread: bodies moved into a component's frame, STEP texts digested.
/// Work counted on this thread: bodies moved, STEP texts digested, joint faces measured.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub(super) struct Counts {
pub moved: usize,
pub digested: usize,
pub joint_faces: usize,
}
thread_local! {
static COUNTS: Cell<Counts> = const { Cell::new(Counts { moved: 0, digested: 0 }) };
static COUNTS: Cell<Counts> = const { Cell::new(Counts { moved: 0, digested: 0, joint_faces: 0 }) };
}
pub(super) fn count(f: impl FnOnce(&mut Counts)) {
@@ -39,6 +40,103 @@ fn counted(f: impl FnOnce()) -> Counts {
COUNTS.with(Cell::get)
}
#[test]
fn joint_face_measurements_are_shared_across_motion_samples_and_snap_kinds() {
use crate::joints::{Joint, JointKind, JointOrigin, Snap, XRef};
let mut doc = Document::new("joint face work");
let part = doc.add_component(Some("Crank"), 0).unwrap();
let occurrence = doc.occurrences.iter().find(|o| o.component == part).unwrap().id;
let id = doc
.add_feature(
FeatureKind::Cylinder {
base: point_expr(Vec3::ZERO),
axis: Dir(Vec3::Z),
radius: "2".into(),
height: "10".into(),
operation: Operation::NewBody,
},
None,
)
.unwrap();
doc.feature_mut(id).unwrap().component = part;
let mut model = Model::new();
model.evaluate(&doc);
let state = model.state();
let pick = Vec3::new(0.0, 0.0, 10.0);
let face = joints::face_name_at(&doc, &state, part, pick, false).unwrap();
doc.assembly.joints.push(Joint {
id: 100,
name: "Pivot".into(),
kind: JointKind::Revolute,
a: JointOrigin { occurrence: 0, snap: Snap::Frame { origin: pick, z: Vec3::Z, x: Some(Vec3::X) } },
b: JointOrigin { occurrence, snap: Snap::FaceCenter { pick, face: None, x: Some(XRef::Dir(Vec3::X)) } },
values: vec![0.0],
limits: Vec::new(),
flip: true,
offset: 0.0,
angle: 0.0,
suppressed: false,
exprs: Default::default(),
});
// One body supplies both old nearest-face snaps and persistent named-face snaps.
// A scratch model shares its measurements, even though each sample changes the pose.
let snapshot = model.clone();
let work = counted(|| {
for name in [None, Some(face)] {
doc.assembly.joints[0].b.snap = Snap::FaceCenter { pick, face: name, x: Some(XRef::Dir(Vec3::X)) };
for k in 0..145 {
let angle = (k as f64 * 2.5).to_radians();
doc.assembly.joints[0].values[0] = angle;
let solution = joints::solve(&doc, &snapshot.state());
assert!(solution.errors.is_empty() && solution.conflicts.is_empty(), "{solution:?}");
let point = apply_point(&solution.world[&occurrence], Vec3::new(5.0, 0.0, 10.0));
assert!(point.dist(Vec3::new(5.0 * angle.cos(), 5.0 * angle.sin(), 10.0)) < 1e-9);
doc.apply_joint_solution(&solution);
}
}
});
assert_eq!(work.joint_faces, 1, "pose-only samples must not remeasure unchanged faces: {work:?}");
// Editing geometry creates a fresh measurement without changing a retained snapshot.
let FeatureKind::Cylinder { height, .. } = &mut doc.feature_mut(id).unwrap().kind else { panic!("cylinder") };
*height = "12".into();
model.evaluate(&doc);
let origin = &doc.assembly.joints[0].b;
let work = counted(|| {
let changed = joints::resolve_origin(&doc, &model.state(), origin).unwrap();
let retained = joints::resolve_origin(&doc, &snapshot.state(), origin).unwrap();
assert!((changed[3][2] - 12.0).abs() < 1e-9);
assert!((retained[3][2] - 10.0).abs() < 1e-9);
});
assert_eq!(work.joint_faces, 1, "only the edited body must be measured: {work:?}");
}
#[test]
fn joint_face_measurements_never_follow_a_replaced_body_or_stale_offset() {
use crate::joints::{JointOrigin, Snap};
let mut doc = Document::new("replaced joint geometry");
let mut state = ModelState::default();
state.bodies.push(ModelBody::new("Pin".into(), kernel::cylinder(Vec3::ZERO, Vec3::Z, 2.0, 10.0).unwrap(), 0));
let origin = JointOrigin { occurrence: 0, snap: Snap::FaceCenter { pick: Vec3::new(0.0, 0.0, 10.0), face: None, x: None } };
let work = counted(|| {
joints::resolve_origin(&doc, &state, &origin).unwrap();
});
assert_eq!(work.joint_faces, 1);
let retained = state.clone();
// Body is public; a clone's old measurement must never mask a direct replacement.
state.bodies[0].body = kernel::cylinder(Vec3::ZERO, Vec3::Z, 2.0, 12.0).unwrap();
let changed = joints::resolve_origin(&doc, &state, &origin).unwrap();
assert!((changed[3][2] - 12.0).abs() < 1e-9);
let work = counted(|| {
let old = joints::resolve_origin(&doc, &retained, &origin).unwrap();
assert!((old[3][2] - 10.0).abs() < 1e-9);
doc.body_offsets.insert("Pin".into(), rigid(Vec3::new(0.0, 0.0, 1.0), Vec3::ZERO, Vec3::Z, 0.0));
let moved = joints::resolve_origin(&doc, &retained, &origin).unwrap();
assert!((moved[3][2] - 11.0).abs() < 1e-9);
});
assert_eq!(work.joint_faces, 0, "offsets are applied after the shared face measurements");
}
/// `parts` imported files of two boxes each, every one in its own component placed away from
/// the origin (as inserted parts are).
fn placed_imports(parts: usize) -> Document {
+89 -41
View File
@@ -69,27 +69,8 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
}
apply_op(st, f, tools, if *deboss { Operation::Cut } else { Operation::Join }, &crate::plain(targets))
}
FeatureKind::Rib { sketch, curves, thickness, depth, flip, web, normal } => {
let ss = st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?.clone();
let t = val(vals, thickness, Kind::Length)?;
if !(t > 0.0) {
return Err(DocError::Invalid("the rib thickness must be positive".into()));
}
let depth = match depth {
Some(e) => Some(val(vals, e, Kind::Length)?)
.filter(|d| *d > 0.0)
.ok_or_else(|| DocError::Invalid("the rib depth must be positive".into()))
.map(Some)?,
None => None,
};
let groups: Vec<Vec<String>> = if *web { curves.iter().map(|c| vec![c.clone()]).collect() } else { vec![curves.clone()] };
if groups.is_empty() || curves.is_empty() || curves.len() > 1000 {
return Err(DocError::Invalid("a rib needs 1…1000 sketch curves".into()));
}
let mut tools = Vec::new();
for g in &groups {
tools.push(if *normal { rib_tool_normal(st, &ss, g, t, depth, *flip)? } else { rib_tool(st, &ss, g, t, depth, *flip)? });
}
FeatureKind::Rib { .. } => {
let tools = rib_tools(vals, st, f, None)?;
apply_op(st, f, tools, Operation::Join, &[])
}
FeatureKind::ReplaceFace { faces, target, body } => {
@@ -176,6 +157,41 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
}
}
/// The walls of rib `f` (one per curve for a web), each run until it meets the bodies. `at`
/// moves the rib's sketch first: a pattern's or mirror's copy of the rib is built again where
/// the copy is, against the bodies there (#4593), as a hole is drilled again at each copy.
pub(super) fn rib_tools(vals: &BTreeMap<String, Value>, st: &ModelState, f: &Feature, at: Option<&crate::Mat>) -> Result<Vec<Body>> {
let FeatureKind::Rib { sketch, curves, thickness, depth, flip, web, normal } = &f.kind else {
return Err(DocError::Invalid(format!("{} is not a rib", f.name)));
};
let mut ss = st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?.clone();
if let Some(m) = at {
let p = ss.plane;
ss.plane = Plane::new(apply_point(m, p.origin), crate::apply_vector(m, p.x), crate::apply_vector(m, p.y))
.ok_or_else(|| DocError::Invalid("the rib's sketch plane at the copy".into()))?;
}
let t = val(vals, thickness, Kind::Length)?;
if !(t > 0.0) {
return Err(DocError::Invalid("the rib thickness must be positive".into()));
}
let depth = match depth {
Some(e) => Some(val(vals, e, Kind::Length)?)
.filter(|d| *d > 0.0)
.ok_or_else(|| DocError::Invalid("the rib depth must be positive".into()))
.map(Some)?,
None => None,
};
let groups: Vec<Vec<String>> = if *web { curves.iter().map(|c| vec![c.clone()]).collect() } else { vec![curves.clone()] };
if groups.is_empty() || curves.is_empty() || curves.len() > 1000 {
return Err(DocError::Invalid("a rib needs 1…1000 sketch curves".into()));
}
let mut tools = Vec::new();
for g in &groups {
tools.push(if *normal { rib_tool_normal(st, &ss, g, t, depth, *flip)? } else { rib_tool(st, &ss, g, t, depth, *flip)? });
}
Ok(tools)
}
/// Unit outward normal of the planar face through `p`: of the planar faces whose plane runs
/// through it, the one nearest it. A face elsewhere in such a plane (the wall of a notch, the end
/// of a rib, in line with a rim) is no pick of `p`, though its plane runs through it as exactly.
@@ -728,7 +744,9 @@ pub(super) fn sheet_eval(
None => p.0,
});
}
let (mut sh, notes) = sheet::convert_body(&mb, &r.name, &rv, face.0, &picks)?;
let placement = doc.component_transform(doc.model_body_component(&mb));
let body_to_world = doc.model_body_offset(&mb).map(|offset| crate::mat_mul(&placement, offset)).unwrap_or(placement);
let (mut sh, notes) = sheet::convert_body(&mb, &r.name, &rv, face.0, &picks, &body_to_world)?;
for note in notes {
append_warning(warning, note);
}
@@ -1327,6 +1345,9 @@ fn sheet_prism_cut(
} else {
let material = sh.flat_material()?;
if material.is_empty() {
if operation == Operation::Intersect && super::boolean_scope(st, f, targets).0 {
return Err(super::empty_intersection(&[sh.body.as_str()]));
}
// Empty exact material removes geometry and metadata together. Budget or
// topology errors propagate; they are never mistaken for an empty blank.
plans.push((sh.body.clone(), None));
@@ -1338,6 +1359,14 @@ fn sheet_prism_cut(
}
}
}
if operation == Operation::Intersect {
// Flat material is exact: an empty one is a true miss, removed only while some target remains.
let (_, scope) = super::boolean_scope(st, f, targets);
let emptied: Vec<&str> = plans.iter().filter(|(_, r)| r.is_none()).map(|(name, _)| name.as_str()).collect();
if !emptied.is_empty() && scope.iter().all(|b| emptied.contains(&b.as_str())) {
return Err(super::empty_intersection(&emptied));
}
}
let handled = plans.iter().map(|(name, _)| name.clone()).collect();
if !notes.is_empty() {
append_warning(warning, notes.join("; "));
@@ -1714,6 +1743,21 @@ fn offset_left(p: &[Vec2], d: f64) -> Vec<Vec2> {
.collect()
}
/// The smallest size a plastic feature's lengths may have, in millimetres. Thinner walls and
/// shorter arms than this are below what the solid booleans can resolve.
const PLASTIC_MIN_SIZE: f64 = 0.01;
/// `v`, the feature's `what`, when it is at least [`PLASTIC_MIN_SIZE`].
fn plastic_size(feature: &str, what: &str, v: f64) -> Result<f64> {
if v.is_finite() && v >= PLASTIC_MIN_SIZE {
Ok(v)
} else {
// Tiny values in exponent form: 1e-320 written out is hundreds of zeros.
let got = if v != 0.0 && v.abs() < 1e-3 { format!("{v:e}") } else { format!("{v}") };
Err(DocError::Invalid(format!("the {feature}'s {what} must be at least {PLASTIC_MIN_SIZE} mm (got {got} mm)")))
}
}
pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState) -> Result<()> {
let len = |e: &str| val(vals, e, Kind::Length);
// The plastic rule of the body a feature goes on gives its defaults (draft, clearance).
@@ -1738,10 +1782,8 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
body,
} => {
let target = body_under(st, body, position.0)?;
let (r0, h) = (len(diameter)? / 2.0, len(height)?);
if !(r0 > 0.0 && h > 0.0) {
return Err(DocError::Invalid("the boss diameter and height must be positive".into()));
}
let r0 = plastic_size("boss", "diameter", len(diameter)?)? / 2.0;
let h = plastic_size("boss", "height", len(height)?)?;
let dr = match draft {
Some(e) => val(vals, e, Kind::Angle)?,
None => rule(&target)?.map_or(0.0, |r| r.draft),
@@ -1784,8 +1826,10 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
None => 0.0,
};
let hr = h - off;
if n > 0.0 && !(t > 0.0 && l > 0.0 && hr > 0.0) {
return Err(DocError::Invalid("ribs need a positive thickness, length and height".into()));
if n > 0.0 {
plastic_size("boss", "rib_thickness", t)?;
plastic_size("boss", "rib_length", l)?;
plastic_size("boss", "rib height (height less rib_offset)", hr)?;
}
for k in 0..n as usize {
let a = std::f64::consts::TAU * k as f64 / n;
@@ -1816,9 +1860,9 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
}
}
if let Some(hd) = hole_diameter {
let rh = len(hd)? / 2.0;
let rh = plastic_size("boss", "hole_diameter", len(hd)?)? / 2.0;
let depth = match hole_depth {
Some(e) => len(e)?,
Some(e) => plastic_size("boss", "hole_depth", len(e)?)?,
None => h,
};
if !(rh > 0.0 && rh < r_top && depth > 0.0) {
@@ -1835,14 +1879,15 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
let target = body_under(st, body, face.0)?;
let mb = st.body(&target).cloned().ok_or_else(|| DocError::Invalid("body".into()))?;
let n = planar_normal_at(&mb.body, face.0).ok_or_else(|| DocError::Invalid("pick the planar rim face".into()))?;
let (w, h) = (len(width)?, len(height)?);
let what = if *groove { "groove" } else { "lip" };
let (w, h) = (plastic_size(what, "width", len(width)?)?, plastic_size(what, "height", len(height)?)?);
let g = match gap {
Some(e) => len(e)?,
None if *groove => rule(&target)?.map_or(0.0, |r| r.clearance),
None => 0.0,
};
if !(w > 0.0 && h > 0.0 && g >= 0.0) {
return Err(DocError::Invalid("the lip width and height must be positive".into()));
if !(g >= 0.0) {
return Err(DocError::Invalid("the groove's gap must not be negative".into()));
}
let plane = Plane::from_normal(face.0, n).ok_or_else(|| DocError::Invalid("rim plane".into()))?;
let tol = (mb.body.size() * 1e-3).max(1e-3);
@@ -1920,9 +1965,11 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
}
FeatureKind::SnapFit { position, direction, hook, length, thickness, width, catch_depth, catch_length, body } => {
let target = body_under(st, body, position.0)?;
let (l, t, w, cd, cl) = (len(length)?, len(thickness)?, len(width)?, len(catch_depth)?, len(catch_length)?);
if !(l > 0.0 && t > 0.0 && w > 0.0 && cd > 0.0 && cl > 0.0 && cl < l) {
return Err(DocError::Invalid("the snap fit's sizes must be positive and the catch shorter than the arm".into()));
let size = |what: &str, e: &str| plastic_size("snap fit", what, len(e)?);
let (l, t, w) = (size("length", length)?, size("thickness", thickness)?, size("width", width)?);
let (cd, cl) = (size("catch_depth", catch_depth)?, size("catch_length", catch_length)?);
if !(cl < l) {
return Err(DocError::Invalid("the snap fit's catch must be shorter than its arm".into()));
}
let (x, y, z) = local_frame(direction.0, Some(hook.0))?;
let delta = (l * 0.05).clamp(1e-3, 0.5);
@@ -1943,15 +1990,16 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
}
FeatureKind::Rest { position, direction, along, width, length, height, draft, thickness, body } => {
let target = body_under(st, body, position.0)?;
let (w, h) = (len(width)?, len(height)?);
let l = length.as_deref().map(len).transpose()?;
let size = |what: &str, e: &str| plastic_size("rest", what, len(e)?);
let (w, h) = (size("width", width)?, size("height", height)?);
let l = length.as_deref().map(|e| size("length", e)).transpose()?;
let dr = match draft {
Some(e) => val(vals, e, Kind::Angle)?,
None => rule(&target)?.map_or(0.0, |r| r.draft),
};
let t = thickness.as_deref().map(len).transpose()?;
if !(w > 0.0 && h > 0.0 && l.is_none_or(|l| l > 0.0) && t.is_none_or(|t| t > 0.0 && 2.0 * t < w.min(l.unwrap_or(w)))) {
return Err(DocError::Invalid("the rest's sizes must be positive and its wall thinner than half its width".into()));
let t = thickness.as_deref().map(|e| size("thickness", e)).transpose()?;
if t.is_some_and(|t| 2.0 * t >= w.min(l.unwrap_or(w))) {
return Err(DocError::Invalid("the rest's wall must be thinner than half its width".into()));
}
if !(dr.abs() < 1.0) {
return Err(DocError::Invalid("the rest's draft must be under 57°".into()));
+10 -4
View File
@@ -341,6 +341,12 @@ fn lex_spans(s: &str) -> Result<Vec<(Tok, usize, usize)>, DocError> {
let mut out = Vec::new();
// Parentheses open at this point: a comma outside them can only be a decimal comma.
let mut depth = 0usize;
// A single leading `=` marks the text as a formula (`=d3`, #4681) and is not part of it.
if let Some(k) = cs.iter().position(|(_, c)| !c.is_whitespace())
&& cs.get(k).is_some_and(|(_, c)| *c == '=')
{
i = k + 1;
}
while let Some(&(_, c)) = cs.get(i) {
if c.is_whitespace() {
i += 1;
@@ -564,7 +570,9 @@ fn is_name_shaped(s: &str) -> bool {
pub fn definition(s: &str) -> Option<(&str, &str)> {
let (name, rhs) = s.split_once('=')?;
let name = name.trim();
is_name_shaped(name).then(|| (name, rhs.trim()))
let rhs = rhs.trim();
// `w == 3` is not a definition: a second `=` would read as a formula marker (#4681).
(is_name_shaped(name) && !rhs.starts_with('=')).then_some((name, rhs))
}
/// The length unit of a design with these units: `mm` unless they name another length unit.
@@ -1252,8 +1260,6 @@ mod tests {
("szerokość = 20", "szerokość", "20"),
// Reserved names split too: the parameter check names the reason.
("mm = 5", "mm", "5"),
// A second `=` stays in the expression, which then fails to evaluate.
("w == 3", "w", "= 3"),
("w = a = 3", "w", "a = 3"),
// An empty expression is the caller's error to report.
("w =", "w", ""),
@@ -1262,7 +1268,7 @@ mod tests {
for (text, name, rhs) in splits {
assert_eq!(definition(text), Some((name, rhs)), "{text:?}");
}
for text in ["20", "20 mm", "width / 2", "= 20", "=", "", "2w = 3", "a b = 3", "a-b = 3", "w.x = 3", "(w) = 3", "-w = 3"] {
for text in ["20", "20 mm", "width / 2", "= 20", "=", "", "2w = 3", "a b = 3", "a-b = 3", "w.x = 3", "(w) = 3", "-w = 3", "w == 3"] {
assert_eq!(definition(text), None, "{text:?}");
}
}
+43
View File
@@ -578,6 +578,7 @@ pub fn read(s: &str) -> Result<Document> {
validate_body_transfers(&v)?;
FORMATS.upgrade(&mut v, version, applied.as_deref())?;
let mut d: Document = serde_json::from_value(v).map_err(|e| DocError::Invalid(format!("design file: {e}")))?;
check_typed_numbers(&d)?;
let mut legacy_by_feature = std::collections::BTreeMap::<usize, Vec<usize>>::new();
for (feature, wire) in legacy_wires {
legacy_by_feature.entry(feature).or_default().push(wire);
@@ -1046,6 +1047,16 @@ fn check_design_numbers(v: &Value) -> Result<()> {
}
}
/// The range check again, on the typed design: an integer such as `9223372036854775808` passes
/// [`check_numbers`] in the file (ids and counters are integers and may be large), but once it
/// lands in a coordinate or size it is a float, and floats are written as floats. Integer fields
/// (ids, counters, indexes) stay integers and are never limited, so the field's type decides
/// what is checked, not its name.
fn check_typed_numbers(d: &Document) -> Result<()> {
let typed = serde_json::to_value(d).map_err(|e| DocError::Invalid(format!("design file: {e}")))?;
check_design_numbers(&typed)
}
/// Every non-integer number in the file is finite and within ±`MAX_REAL` (a damaged or hostile
/// file is refused instead of overflowing the geometry later).
fn check_numbers(v: &Value, depth: usize) -> Result<()> {
@@ -2095,6 +2106,38 @@ mod tests {
}
}
/// An integer-valued coordinate is range-checked like a float one, while ids and counters
/// may still be large integers (solidcraft#5021).
#[test]
fn integer_valued_coordinates_are_range_checked_and_large_ids_still_load() {
let mut sketch = solidcraft_sketch::Sketch::new();
sketch.add_point(solidcraft_geom::Vec2::new(40.0, 0.0), None).unwrap();
let mut d = Document::new("range");
d.add_feature(crate::FeatureKind::Sketch { plane: crate::PlaneRef::Origin { name: "XY".into() }, sketch }, None).unwrap();
let last = |v: &Value| v["features"][0]["sketch"]["points"].as_array().map_or(0, |a| a.len() - 1);
for huge in ["9223372036854775808", "4294967295", "2000000000", "-2000000000"] {
let json = edited(&d, |v| {
let i = last(v);
v["features"][0]["sketch"]["points"][i]["pos"][1] = serde_json::from_str(huge).unwrap();
});
let error = read(&json).unwrap_err().to_string();
assert!(error.contains("damaged design") && error.contains("out of range"), "{huge}: {error}");
}
// In range, an integer-valued coordinate is fine.
let json = edited(&d, |v| {
let i = last(v);
v["features"][0]["sketch"]["points"][i]["pos"][1] = 1_000_000_000u64.into();
});
assert!(read(&json).is_ok());
// Ids and counters are integers and may be past the coordinate limit.
let json = edited(&d, |v| {
v["features"][0]["id"] = 5_000_000_000u64.into();
v["next_id"] = 5_000_000_001u64.into();
});
let back = read(&json).unwrap();
assert_eq!(back.features[0].id, 5_000_000_000);
}
#[test]
fn exhausted_id_space_is_an_error_not_a_panic() {
let d = two_components_one_occurrence();
@@ -0,0 +1,4 @@
//! Associative motion traces retain their occurrence path, point reference, joint bounds or
//! study identity, sample count and assembly branch seeds, alongside the generated sketch.
//! Older designs default to no associations: their saved curves remain fixed. Readers from
//! the last release refuse the newer format number instead of silently dropping associations.
@@ -0,0 +1,5 @@
//! The design saves the body names a `body.rename` freed (`Body1` after the body it named was
//! renamed), so a name the design used is not used again.
//!
//! A design saved before this has no such names: it keeps the names it has, and the next body
//! it makes may take a name an earlier rename freed.
@@ -0,0 +1,3 @@
//! Circular sketch patterns may retain a Quantity expression alongside their evaluated count.
//! Older designs have no expression and keep their fixed counts unchanged. The previous
//! release must request an update rather than discard the expression on save.
+291 -40
View File
@@ -572,6 +572,8 @@ pub struct Assembly {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub studies: Vec<crate::motion::MotionStudy>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub traces: Vec<crate::motion::MotionTrace>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub exploded: Vec<crate::motion::ExplodedView>,
}
@@ -597,6 +599,15 @@ impl Assembly {
}
}
}
for trace in &self.traces {
if let crate::motion::TraceMotion::Joint { joint, from, to, .. } = &trace.motion {
for (label, value) in [("from", from), ("to", to)] {
if let serde_json::Value::String(e) = value {
out.push((*joint, format!("trace {} {label}", trace.sketch), e.as_str()));
}
}
}
}
out
}
@@ -616,6 +627,15 @@ impl Assembly {
}
}
}
for trace in &mut self.traces {
if let crate::motion::TraceMotion::Joint { from, to, .. } = &mut trace.motion {
for value in [from, to] {
if let serde_json::Value::String(e) = value {
out.push(e);
}
}
}
}
out
}
@@ -627,6 +647,7 @@ impl Assembly {
&& !self.contact_enabled
&& !self.contact_all
&& self.studies.is_empty()
&& self.traces.is_empty()
&& self.exploded.is_empty()
}
}
@@ -731,8 +752,7 @@ fn nearest_plane(doc: &Document, st: &ModelState, o: &JointOrigin, pick: Vec3) -
let mut best: Option<(f64, Vec3, Vec3)> = None;
for b in component_bodies(doc, st, comp) {
let offset = doc.model_body_offset(b);
let tol = (b.body.size() * 2e-3).max(1e-3);
for f in b.body.faces(tol)? {
for f in b.joint_faces()?.iter() {
let Some(n) = f.plane_normal else { continue };
let (c, n) = match offset {
Some(m) => (apply_point(m, f.centroid), apply_vector(m, n)),
@@ -836,6 +856,15 @@ pub fn clamp(j: &Joint, q: &mut [f64]) {
}
}
/// Work requested after placing occurrences and checking their joint constraints.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Analysis {
/// Placement, loop closure, conflicts and errors only; `Solution::dof` is empty.
Placement,
/// Also count each occurrence's remaining motion, including nearby singular poses.
DegreesOfFreedom,
}
/// The result of placing occurrences by their joints.
#[derive(Clone, Debug, Default, Serialize)]
pub struct Solution {
@@ -852,7 +881,7 @@ pub struct Solution {
/// Remaining degrees of freedom per occurrence (6 = free, 0 = fixed): how many independent
/// values still move it, from the fixed occurrences, with loops kept closed and a value a
/// motion link sets moving with the values its link chain starts from (a driven gear has its
/// driving gear's one, a screw's travel its turn's).
/// driving gear's one, a screw's travel its turn's). Empty for [`Analysis::Placement`].
pub dof: BTreeMap<u64, usize>,
/// Occurrences fixed in place (grounded, or the root's).
pub fixed: Vec<u64>,
@@ -943,22 +972,36 @@ fn flip_of(j: &Joint) -> Mat {
if !j.flip { mat_mul(&m, &[[1.0, 0.0, 0.0, 0.0], [0.0, -1.0, 0.0, 0.0], [0.0, 0.0, -1.0, 0.0], [0.0, 0.0, 0.0, 1.0]]) } else { m }
}
/// Component-space frames stay fixed throughout one solve. Cache their inverses without
/// regrouping products: reversed joints still invert the value-dependent link as before.
struct JointFrames {
a: Mat,
b: Mat,
inverse_a: Option<Mat>,
inverse_b: Option<Mat>,
}
impl JointFrames {
fn new(a: Mat, b: Mat) -> Self {
Self { a, b, inverse_a: mat_inverse(&a), inverse_b: mat_inverse(&b) }
}
fn through(&self, joint: &Joint, values: &[f64], from_a: bool, t: &Mat) -> Option<Mat> {
let link = mat_mul(&link_of(joint, values), self.inverse_b.as_ref()?);
if from_a { Some(mat_mul(&mat_mul(t, &self.a), &link)) } else { Some(mat_mul(&mat_mul(t, &mat_inverse(&link)?), self.inverse_a.as_ref()?)) }
}
}
/// B's world transform from A's (or A's from B's, going the other way) for the joint's values.
fn through(j: &Joint, (fa, fb): &(Mat, Mat), q: &[f64], from_a: bool, t: &Mat) -> Option<Mat> {
let link = mat_mul(&link_of(j, q), &mat_inverse(fb)?);
if from_a {
Some(mat_mul(&mat_mul(t, fa), &link))
} else {
// T_A = T_B · link⁻¹ · F_a⁻¹
Some(mat_mul(&mat_mul(t, &mat_inverse(&link)?), &mat_inverse(fa)?))
}
JointFrames::new(*fa, *fb).through(j, q, from_a, t)
}
/// The spanning tree the solver places occurrences along, out from the fixed ones.
struct Tree<'a> {
joints: BTreeMap<u64, &'a Joint>,
/// Joint frames in component coordinates (A's, B's).
frames: BTreeMap<u64, (Mat, Mat)>,
frames: BTreeMap<u64, JointFrames>,
fixed: BTreeMap<u64, Mat>,
/// Placed occurrence → (the occurrence it is placed from, the joint, from the joint's A side).
parent: BTreeMap<u64, (u64, u64, bool)>,
@@ -968,8 +1011,10 @@ struct Tree<'a> {
impl Tree<'_> {
fn step(&self, joint: u64, from_a: bool, t: &Mat, vals: &Values) -> Option<Mat> {
#[cfg(test)]
tests::PLACEMENT_STEPS.with(|steps| steps.set(steps.get() + 1));
let q = vals.get(&joint).map(Vec::as_slice).unwrap_or(&[]);
through(self.joints.get(&joint)?, self.frames.get(&joint)?, q, from_a, t)
self.frames.get(&joint)?.through(self.joints.get(&joint)?, q, from_a, t)
}
/// Where every placed occurrence is for these values.
@@ -1008,6 +1053,75 @@ impl Tree<'_> {
}
}
/// Storage shared by one residual callback's evaluations, scoped to its immutable tree.
/// Keep map nodes and path capacity, but invalidate every placement before reading new values.
struct PlacementWorkspace<'tree, 'doc> {
tree: &'tree Tree<'doc>,
placed: BTreeMap<u64, Option<Mat>>,
path: Vec<(u64, u64, bool)>,
}
impl<'tree, 'doc> PlacementWorkspace<'tree, 'doc> {
fn new(tree: &'tree Tree<'doc>) -> Self {
Self { tree, placed: BTreeMap::new(), path: Vec::new() }
}
fn at<'values>(&'values mut self, values: &'values Values) -> Placements<'values, 'tree, 'doc> {
for placed in self.placed.values_mut() {
*placed = None;
}
Placements { workspace: self, values }
}
}
/// Only this guard can read placements. Its values stay immutable until it is dropped;
/// obtaining the next guard clears validity while retaining the allocated storage.
struct Placements<'values, 'tree, 'doc> {
workspace: &'values mut PlacementWorkspace<'tree, 'doc>,
values: &'values Values,
}
impl Placements<'_, '_, '_> {
fn world(&mut self, mut occurrence: u64) -> Option<Mat> {
self.workspace.path.clear();
let mut t = loop {
if let Some(t) = self.workspace.placed.get(&occurrence).and_then(Option::as_ref).or_else(|| self.workspace.tree.fixed.get(&occurrence)) {
break *t;
}
if self.workspace.path.len() >= self.workspace.tree.order.len() {
return None;
}
let &(from, joint, from_a) = self.workspace.tree.parent.get(&occurrence)?;
self.workspace.path.push((occurrence, joint, from_a));
occurrence = from;
};
while let Some((occurrence, joint, from_a)) = self.workspace.path.pop() {
t = self.workspace.tree.step(joint, from_a, &t, self.values)?;
#[cfg(test)]
if !self.workspace.placed.contains_key(&occurrence) {
tests::PLACEMENT_INSERTS.with(|inserts| inserts.set(inserts.get() + 1));
}
self.workspace.placed.insert(occurrence, Some(t));
}
Some(t)
}
fn closure_gaps(&mut self, closing: &[u64], scale: f64, out: &mut Vec<f64>) -> Option<()> {
for id in closing {
let (joint, frames) = (self.workspace.tree.joints.get(id)?, self.workspace.tree.frames.get(id)?);
let tb = self.world(joint.b.occurrence)?;
let ta = self.world(joint.a.occurrence)?;
let want = self.workspace.tree.step(*id, true, &ta, self.values)?;
for p in [Vec3::ZERO, Vec3::X * scale, Vec3::Y * scale] {
let at = apply_point(&frames.b, p);
let d = apply_point(&want, at) - apply_point(&tb, at);
out.extend([d.x, d.y, d.z]);
}
}
Some(())
}
}
/// Values the loop solver moves at most in one group of loops, and loop-closing joints it takes
/// on at most: larger ones are only checked.
const MAX_LOOP_VALUES: usize = 64;
@@ -1212,8 +1326,8 @@ fn loop_scale(tree: &Tree, around: &BTreeSet<u64>, vals: &Values) -> f64 {
let joints: Aabb3 = around
.iter()
.filter_map(|id| {
let (j, (fa, _)) = (tree.joints.get(id)?, tree.frames.get(id)?);
Some(apply_point(&mat_mul(&tree.world(j.a.occurrence, vals)?, fa), Vec3::ZERO))
let (j, frames) = (tree.joints.get(id)?, tree.frames.get(id)?);
Some(apply_point(&mat_mul(&tree.world(j.a.occurrence, vals)?, &frames.a), Vec3::ZERO))
})
.collect();
if joints.is_empty() { 1.0 } else { joints.diagonal().clamp(1.0, 1e6) }
@@ -1221,18 +1335,8 @@ fn loop_scale(tree: &Tree, around: &BTreeSet<u64>, vals: &Values) -> f64 {
/// How far each closing joint is from meeting: three points of B's joint frame, `scale` apart,
/// where the joint wants them against where the tree puts them (appended to `out`).
fn closure_gaps(tree: &Tree, closing: &[u64], vals: &Values, scale: f64, out: &mut Vec<f64>) -> Option<()> {
for id in closing {
let (j, (_, fb)) = (tree.joints.get(id)?, tree.frames.get(id)?);
let tb = tree.world(j.b.occurrence, vals)?;
let want = tree.step(*id, true, &tree.world(j.a.occurrence, vals)?, vals)?;
for p in [Vec3::ZERO, Vec3::X * scale, Vec3::Y * scale] {
let at = apply_point(fb, p);
let d = apply_point(&want, at) - apply_point(&tb, at);
out.extend([d.x, d.y, d.z]);
}
}
Some(())
fn closure_gaps(workspace: &mut PlacementWorkspace, closing: &[u64], vals: &Values, scale: f64, out: &mut Vec<f64>) -> Option<()> {
workspace.at(vals).closure_gaps(closing, scale, out)
}
/// How far a closing joint may be off before it is a conflict: its frames apart by more than
@@ -1294,6 +1398,7 @@ fn nearby_pose(tree: &Tree, groups: &[Group], asm: &Assembly, linked: &BTreeSet<
(v + d * if k % 2 == 0 { w } else { -w }).max(*lo).min(*hi)
})
.collect();
let mut workspace = PlacementWorkspace::new(tree);
let mut work = near.clone();
let mut gaps = |x: &[f64]| -> Option<Vec<f64>> {
spent += 1;
@@ -1307,7 +1412,7 @@ fn nearby_pose(tree: &Tree, groups: &[Group], asm: &Assembly, linked: &BTreeSet<
}
apply_links(asm, &mut work);
let mut out = Vec::with_capacity(closing.len() * 9);
closure_gaps(tree, closing, &work, scale, &mut out)?;
closure_gaps(&mut workspace, closing, &work, scale, &mut out)?;
out.iter().all(|v| v.is_finite()).then_some(out)
};
if close_within(&mut gaps, &mut x, &bounds, (1e-10 * scale).min(1e-7), loose_gap(scale)).is_some()
@@ -1422,6 +1527,7 @@ fn loop_dof(tree: &Tree, closing: &[u64], asm: &Assembly, vals: &Values, dof: &m
let (n, gaps) = (values.len(), closers.len() * 9);
// How many ways the occurrence moves at a pose, with the loops kept closed.
let mut free_at = |at: &Values| -> Option<usize> {
let mut workspace = PlacementWorkspace::new(tree);
let mut work = at.clone();
// The loops' gaps, then three points of the occurrence `scale` apart.
let mut f = |x: &[f64]| -> Option<Vec<f64>> {
@@ -1433,8 +1539,9 @@ fn loop_dof(tree: &Tree, closing: &[u64], asm: &Assembly, vals: &Values, dof: &m
}
apply_links(asm, &mut work);
let mut out = Vec::with_capacity(closers.len() * 9 + 9);
closure_gaps(tree, &closers, &work, scale, &mut out)?;
let t = tree.world(*occ, &work)?;
let mut placed = workspace.at(&work);
placed.closure_gaps(&closers, scale, &mut out)?;
let t = placed.world(*occ)?;
for p in [Vec3::ZERO, Vec3::X * scale, Vec3::Y * scale] {
let q = apply_point(&t, p);
out.extend([q.x, q.y, q.z]);
@@ -1530,6 +1637,7 @@ fn close_group(
if !moves.is_empty() {
first = first.min(1.0 / stride(&effective(&x0, 0.0), &effective(&x0, 1.0)));
}
let mut workspace = PlacementWorkspace::new(tree);
let mut work = vals.clone();
// How far each closing joint is from meeting with the held values `t` of the way along:
// three points of B's joint frame, where the joint wants them against where the tree puts them.
@@ -1540,7 +1648,7 @@ fn close_group(
}
place(&mut work, x, t);
let mut out = Vec::with_capacity(closing.len() * 9);
closure_gaps(tree, closing, &work, scale, &mut out)?;
closure_gaps(&mut workspace, closing, &work, scale, &mut out)?;
out.iter().all(|v| v.is_finite()).then_some(out)
};
// Already closed, with no held value moving. (A moving one is followed all the way: a
@@ -1690,6 +1798,13 @@ pub fn solve(doc: &Document, st: &ModelState) -> Solution {
/// of the other joints around the loop. The `held` values (the ones being driven) keep theirs,
/// moving there from the values given (where the loops last closed) so the others follow them.
pub fn solve_holding(doc: &Document, st: &ModelState, held: &Held) -> Solution {
solve_holding_with_analysis(doc, st, held, Analysis::DegreesOfFreedom)
}
/// Place occurrences and check constraints with the requested diagnostic work. Placement-only
/// solves still close and check every loop, respect held values and limits, and report errors;
/// they leave `Solution::dof` empty rather than returning the spanning tree's approximate count.
pub fn solve_holding_with_analysis(doc: &Document, st: &ModelState, held: &Held, analysis: Analysis) -> Solution {
let asm = &doc.assembly;
let mut sol = Solution::default();
let mut vals = joint_values(asm);
@@ -1704,9 +1819,9 @@ pub fn solve_holding(doc: &Document, st: &ModelState, held: &Held) -> Solution {
for id in &closing {
let Some(j) = tree.joints.get(id) else { continue };
let (Some(ta), Some(tb)) = (placed.get(&j.a.occurrence), placed.get(&j.b.occurrence)) else { continue };
let (Some(want), Some((_, fb))) = (tree.step(*id, true, ta, &vals), tree.frames.get(id)) else { continue };
let (Some(want), Some(frames)) = (tree.step(*id, true, ta, &vals), tree.frames.get(id)) else { continue };
// Measured at the joint, where the loop solver closes it.
let (dt, da) = mismatch(&mat_mul(&want, fb), &mat_mul(tb, fb));
let (dt, da) = mismatch(&mat_mul(&want, &frames.b), &mat_mul(tb, &frames.b));
if dt > CONFLICT_MM || da > CONFLICT_RAD {
sol.conflicts.push((
j.id,
@@ -1719,11 +1834,15 @@ pub fn solve_holding(doc: &Document, st: &ModelState, held: &Held) -> Solution {
sol.conflicts.push((j.id, j.name.clone(), "the loop can't stay closed on the way to these values".into()));
}
}
if !closing.is_empty() {
loop_dof(&tree, &closing, asm, &vals, &mut dof);
}
for o in &doc.occurrences {
dof.entry(o.id).or_insert(6);
if analysis == Analysis::DegreesOfFreedom {
if !closing.is_empty() {
loop_dof(&tree, &closing, asm, &vals, &mut dof);
}
for o in &doc.occurrences {
dof.entry(o.id).or_insert(6);
}
} else {
dof.clear();
}
placed.remove(&0);
sol.world = placed;
@@ -1749,7 +1868,7 @@ fn build<'a>(doc: &'a Document, st: &ModelState, vals: &Values, sol: &mut Soluti
let joints: Vec<&Joint> = asm.joints.iter().filter(|j| !j.suppressed).take(MAX_JOINTS).collect();
// Joint frames in component coordinates (errors leave the joint out). An origin several joints
// share (a panel face every part sits on) is found once.
let mut frames: BTreeMap<u64, (Mat, Mat)> = BTreeMap::new();
let mut frames: BTreeMap<u64, JointFrames> = BTreeMap::new();
let mut found: BTreeMap<(u64, String), std::result::Result<Mat, String>> = BTreeMap::new();
let mut resolve = |o: &JointOrigin| -> Result<Mat> {
let Ok(comp) = component_of(doc, o.occurrence) else { return resolve_origin(doc, st, o) };
@@ -1770,7 +1889,7 @@ fn build<'a>(doc: &'a Document, st: &ModelState, vals: &Values, sol: &mut Soluti
}
match (resolve(&j.a), resolve(&j.b)) {
(Ok(fa), Ok(fb)) => {
frames.insert(j.id, (fa, fb));
frames.insert(j.id, JointFrames::new(fa, fb));
}
(Err(e), _) | (_, Err(e)) => sol.errors.push((j.id, j.name.clone(), e.to_string())),
}
@@ -1953,6 +2072,138 @@ pub fn frame_of(o: Vec3, z: Vec3, x: Option<Vec3>) -> Option<Mat> {
mod tests {
use super::*;
thread_local! {
pub(super) static PLACEMENT_STEPS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
pub(super) static PLACEMENT_INSERTS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
pub(super) fn placement_tree(asm: &Assembly) -> Tree<'_> {
Tree {
joints: asm.joints.iter().map(|j| (j.id, j)).collect(),
frames: asm.joints.iter().map(|j| (j.id, JointFrames::new(mat_mul(&shift(3.0, 4.0, 5.0), &rot_y(0.3)), rot_x(-0.2)))).collect(),
fixed: BTreeMap::from([(0, IDENTITY)]),
parent: BTreeMap::from([(1, (0, 1, true)), (2, (1, 2, false)), (3, (2, 3, true)), (4, (1, 4, true)), (99, (0, 5, true))]),
order: vec![1, 2, 3, 4, 99],
}
}
/// Shared ancestors are evaluated once, without visiting an unrelated assembly branch.
/// The same arithmetic must be used after values change and on a reversed tree edge.
#[test]
fn residual_placements_reuse_ancestors_only_for_the_current_values() {
let asm = chain(6, 0.2);
let tree = placement_tree(&asm);
let mut values = joint_values(&asm);
let expected: BTreeMap<u64, Mat> = [1, 2, 3, 4].into_iter().map(|id| (id, tree.world(id, &values).unwrap())).collect();
let mut workspace = PlacementWorkspace::new(&tree);
PLACEMENT_STEPS.with(|steps| steps.set(0));
{
let mut placed = workspace.at(&values);
for id in [3, 4, 3, 2, 4] {
assert_eq!(placed.world(id), expected.get(&id).copied());
}
assert_eq!(placed.world(0), Some(IDENTITY));
assert_eq!(placed.world(1234), None);
}
assert_eq!(PLACEMENT_STEPS.with(|steps| steps.get()), 4, "one step per needed occurrence; no unrelated branch");
values.get_mut(&1).unwrap()[0] += 0.1;
apply_links(&asm, &mut values);
let changed = tree.world(3, &values).unwrap();
assert_ne!(Some(&changed), expected.get(&3));
let mut placed = workspace.at(&values);
assert_eq!(placed.world(3), Some(changed), "a new residual evaluation cannot reuse the previous values' placements");
}
/// Cached static inverses retain the original multiplication order for every joint kind,
/// flip and traversal direction. A non-invertible frame remains a failed step.
#[test]
fn prepared_joint_frames_match_fresh_inverses() {
fn reference(j: &Joint, fa: &Mat, fb: &Mat, q: &[f64], from_a: bool, t: &Mat) -> Option<Mat> {
let link = mat_mul(&link_of(j, q), &mat_inverse(fb)?);
if from_a { Some(mat_mul(&mat_mul(t, fa), &link)) } else { Some(mat_mul(&mat_mul(t, &mat_inverse(&link)?), &mat_inverse(fa)?)) }
}
let mut joint = chain(1, 0.0).joints.remove(0);
let fa = mat_mul(&shift(3.0, -7.0, 11.0), &rot_y(0.3));
let fb = mat_mul(&shift(-2.0, 5.0, 13.0), &rot_x(-0.2));
let t = mat_mul(&shift(17.0, 19.0, -23.0), &rot_z(0.7));
let singular = [[0.0; 4]; 4];
for (a, b) in [(fa, fb), (singular, fb), (fa, singular)] {
let frames = JointFrames::new(a, b);
for kind in [
JointKind::Rigid,
JointKind::Revolute,
JointKind::Slider,
JointKind::Cylindrical,
JointKind::PinSlot,
JointKind::Planar,
JointKind::Ball,
JointKind::Parallel,
JointKind::Perpendicular,
JointKind::Angle,
JointKind::PointOnPlane,
JointKind::PointOnLine,
JointKind::LineOnPlane,
] {
joint.kind = kind;
joint.offset = 2.7;
joint.angle = -0.41;
for flip in [false, true] {
joint.flip = flip;
for angle in [0.0, 1e-6, -0.37, std::f64::consts::PI] {
let q: Vec<f64> = (0..kind.dofs().len()).map(|i| angle * (i + 1) as f64).collect();
for forward in [false, true] {
assert_eq!(
frames.through(&joint, &q, forward, &t),
reference(&joint, &a, &b, &q, forward, &t),
"{kind:?}, {flip}, {angle}, {forward}"
);
}
}
}
}
}
}
/// The loop solver's residual entry point reuses shared ancestors for each perturbation;
/// row order, duplicate rows, append semantics and missing-origin failure stay unchanged.
#[test]
fn closure_residuals_reuse_shared_ancestors_for_each_perturbation() {
let mut asm = chain(6, 0.2);
asm.joints[5].a.occurrence = 3;
asm.joints[5].b.occurrence = 4;
let tree = placement_tree(&asm);
let mut workspace = PlacementWorkspace::new(&tree);
let mut cold = true;
for start in [0.2, 0.2 + 1e-6, 0.2 - 1e-6, 1.3] {
let mut values = joint_values(&asm);
values.get_mut(&1).unwrap()[0] = start;
apply_links(&asm, &mut values);
let mut expected = vec![42.0];
for id in [6, 6] {
let joint = tree.joints.get(&id).unwrap();
let frames = tree.frames.get(&id).unwrap();
let a = tree.world(joint.a.occurrence, &values).unwrap();
let b = tree.world(joint.b.occurrence, &values).unwrap();
let want = through(joint, &(frames.a, frames.b), values.get(&id).unwrap(), true, &a).unwrap();
for point in [Vec3::ZERO, Vec3::X * 37.0, Vec3::Y * 37.0] {
let at = apply_point(&frames.b, point);
let d = apply_point(&want, at) - apply_point(&b, at);
expected.extend([d.x, d.y, d.z]);
}
}
PLACEMENT_STEPS.with(|steps| steps.set(0));
PLACEMENT_INSERTS.with(|inserts| inserts.set(0));
let mut actual = vec![42.0];
assert_eq!(closure_gaps(&mut workspace, &[6, 6], &values, 37.0, &mut actual), Some(()));
assert_eq!(actual, expected);
assert_eq!(PLACEMENT_INSERTS.with(|inserts| inserts.get()), if cold { 4 } else { 0 }, "reuse cache storage between perturbations");
cold = false;
assert_eq!(PLACEMENT_STEPS.with(|steps| steps.get()), 6, "four tree placements and two closing-joint steps");
assert_eq!(closure_gaps(&mut workspace, &[u64::MAX], &values, 37.0, &mut actual), None);
assert_eq!(actual, expected, "an unresolved joint does not insert a zero residual");
}
}
fn close(a: &Mat, b: &Mat) -> bool {
let (t, r) = mismatch(a, b);
t < 1e-9 && r < 1e-9
@@ -1960,7 +2211,7 @@ mod tests {
/// Revolute joints 1…n (joint 1 at `start`, the others at 0) linked 1 → 2 → … → n with
/// ratio 2, the links stored from the end of the chain; joint 3 limited to ±50.
fn chain(n: u64, start: f64) -> Assembly {
pub(super) fn chain(n: u64, start: f64) -> Assembly {
let origin = JointOrigin { occurrence: 0, snap: Snap::Point { point: Vec3::ZERO, z: None, x: None } };
let joints = (1..=n)
.map(|id| Joint {
+1 -2
View File
@@ -64,8 +64,7 @@ impl NamedFace<'_> {
/// The face's centre and outward normal (the component's coordinates), when it is planar.
pub fn plane(&self) -> Result<Option<(Vec3, Vec3)>> {
let tol = (self.body.body.size() * 2e-3).max(1e-3);
let faces = self.body.body.faces(tol)?;
let faces = self.body.joint_faces()?;
Ok(faces.get(self.face).and_then(|f| f.plane_normal.map(|n| (apply_point(&self.offset, f.centroid), apply_vector(&self.offset, n)))))
}
}
+80 -14
View File
@@ -9,8 +9,8 @@ use solidcraft_geom::Vec3;
use solidcraft_sketch::linalg;
use super::{
Assembly, Held, MAX_LOOP_VALUES, MAX_LOOP_WORK, MAX_LOOPS, Solution, Tree, Values, apply_links, build, close_within, closure_gaps, jacobian,
joint_values, largest, least_squares, limit_bounds, link_chains, loop_groups, loop_scale, loose_gap, unknowns,
Assembly, Held, MAX_LOOP_VALUES, MAX_LOOP_WORK, MAX_LOOPS, Solution, Tree, Values, apply_links, build, close_within, jacobian, joint_values,
largest, least_squares, limit_bounds, link_chains, loop_groups, loop_scale, loose_gap, unknowns,
};
use crate::{Document, ModelState, apply_point, apply_vector, mat_mul};
@@ -133,8 +133,8 @@ pub fn reach(doc: &Document, st: &ModelState, r: &Reach) -> Result<Reached, Stri
let lever = path
.iter()
.filter_map(|id| {
let (j, (fa, _)) = (tree.joints.get(id)?, tree.frames.get(id)?);
Some(apply_point(&mat_mul(&tree.world(j.a.occurrence, &vals)?, fa), Vec3::ZERO).dist(point?))
let (j, frames) = (tree.joints.get(id)?, tree.frames.get(id)?);
Some(apply_point(&mat_mul(&tree.world(j.a.occurrence, &vals)?, &frames.a), Vec3::ZERO).dist(point?))
})
.fold(0.0, f64::max);
let span = point.map_or(scale, |p| scale.max(p.dist(r.to)).max(lever));
@@ -155,7 +155,8 @@ pub fn reach(doc: &Document, st: &ModelState, r: &Reach) -> Result<Reached, Stri
let mt = if r.axis.is_some() { 6 } else { 3 };
let spent = Cell::new(0usize);
let mut work = vals.clone();
let mut f = |x: &[f64]| misses(tree, asm, r, scale, &vars, &closers, x, &mut work, &spent);
let mut workspace = super::PlacementWorkspace::new(tree);
let mut f = |x: &[f64]| misses(&mut workspace, asm, r, scale, &vars, &closers, x, &mut work, &spent);
let mut x = x0.clone();
let miss = |res: &[f64]| res.iter().take(mt).map(|v| v * v).sum::<f64>().sqrt();
let before = f(&x0).map_or(f64::INFINITY, |r| miss(&r));
@@ -216,7 +217,8 @@ pub fn reach(doc: &Document, st: &ModelState, r: &Reach) -> Result<Reached, Stri
if let Some(first) = closers.first() {
let k = drive.len();
let mut alone = vals.clone();
let mut g = |x: &[f64]| misses(tree, asm, r, scale, &drive, &[], x, &mut alone, &spent);
let mut alone_workspace = super::PlacementWorkspace::new(tree);
let mut g = |x: &[f64]| misses(&mut alone_workspace, asm, r, scale, &drive, &[], x, &mut alone, &spent);
let (mut xo, bo, co) = (x0.get(..k).unwrap_or_default().to_vec(), bounds.get(..k).unwrap_or_default(), cap.get(..k).unwrap_or_default());
// The loops left open, nothing to pace.
if let Some(ro) = approach(&mut g, &|_, _| 0.0, mt, &mut xo, bo, co, tol, loose_gap(scale))
@@ -249,7 +251,7 @@ pub fn reach(doc: &Document, st: &ModelState, r: &Reach) -> Result<Reached, Stri
/// The target's misses with the values `vars` at `x`: the point's (mm), then the axis's (as mm
/// across the chain's `scale`), then the gaps of the loops `closers`, weighted.
fn misses(
tree: &Tree,
workspace: &mut super::PlacementWorkspace,
asm: &Assembly,
r: &Reach,
scale: f64,
@@ -269,9 +271,18 @@ fn misses(
}
}
apply_links(asm, work);
let mut out = target(tree, r, scale, work)?;
let n = out.len();
closure_gaps(tree, closers, work, scale, &mut out)?;
let tree = workspace.tree;
// An open chain has only one target placement to read. Loops share their ancestors with
// that target, but a single placement needs no residual-local map.
let mut out = if closers.is_empty() {
target(tree, r, scale, work)?
} else {
let mut placed = workspace.at(work);
let mut out = target_at(&placed.world(r.occurrence)?, r, scale);
placed.closure_gaps(closers, scale, &mut out)?;
out
};
let n = if r.axis.is_some() { 6 } else { 3 };
for v in out.iter_mut().skip(n) {
*v *= LOOP_WEIGHT;
}
@@ -280,14 +291,17 @@ fn misses(
/// The target's misses for these values: the point's (mm), then the axis's (as mm across `scale`).
fn target(tree: &Tree, r: &Reach, scale: f64, vals: &Values) -> Option<Vec<f64>> {
let t = tree.world(r.occurrence, vals)?;
let d = apply_point(&t, r.point) - r.to;
Some(target_at(&tree.world(r.occurrence, vals)?, r, scale))
}
fn target_at(t: &crate::Mat, r: &Reach, scale: f64) -> Vec<f64> {
let d = apply_point(t, r.point) - r.to;
let mut out = vec![d.x, d.y, d.z];
if let Some((a, z)) = r.axis {
let w = (apply_vector(&t, a) - z) * scale;
let w = (apply_vector(t, a) - z) * scale;
out.extend([w.x, w.y, w.z]);
}
Some(out)
out
}
/// How many steps apart the values `vars` at `a` and at `b` put the `paced` values (each with its
@@ -447,3 +461,55 @@ fn approach(
}
Some(r)
}
#[cfg(test)]
mod tests {
use super::super::tests::{PLACEMENT_INSERTS, PLACEMENT_STEPS, chain, placement_tree};
use super::*;
/// Exercise the IK callback itself: removing sharing at this call site must fail even if
/// the reusable placement helper still passes its own tests.
#[test]
fn ik_residual_reuses_target_and_loop_ancestors_at_the_call_site() {
let mut asm = chain(6, 0.2);
asm.joints[5].a.occurrence = 3;
asm.joints[5].b.occurrence = 4;
let tree = placement_tree(&asm);
let base = joint_values(&asm);
let mut workspace = super::super::PlacementWorkspace::new(&tree);
let mut cold = true;
for axis in [None, Some((Vec3::Y, Vec3::Z))] {
let r = Reach { occurrence: 3, point: Vec3::new(1.0, 2.0, 3.0), to: Vec3::new(7.0, 11.0, 13.0), axis, joints: None };
for x in [0.2, 0.2 + 1e-6, 0.2 - 1e-6, 1.3] {
let mut reference = base.clone();
reference.get_mut(&1).unwrap()[0] = x;
apply_links(&asm, &mut reference);
let mut expected = target(&tree, &r, 37.0, &reference).unwrap();
let n = expected.len();
super::super::closure_gaps(&mut super::super::PlacementWorkspace::new(&tree), &[6], &reference, 37.0, &mut expected).unwrap();
for v in expected.iter_mut().skip(n) {
*v *= LOOP_WEIGHT;
}
let mut work = base.clone();
let spent = Cell::new(0);
PLACEMENT_STEPS.with(|steps| steps.set(0));
PLACEMENT_INSERTS.with(|inserts| inserts.set(0));
let actual = misses(&mut workspace, &asm, &r, 37.0, &[(1, 0)], &[6], &[x], &mut work, &spent);
assert_eq!(actual, Some(expected));
assert_eq!(
PLACEMENT_INSERTS.with(|inserts| inserts.get()),
if cold { 4 } else { 0 },
"IK must retain cache storage across evaluations"
);
cold = false;
assert_eq!(spent.get(), 1, "reuse must not skip a finite-difference evaluation or bypass its work cap");
assert_eq!(PLACEMENT_STEPS.with(|steps| steps.get()), 5, "four tree placements shared by the target and loop, plus the closing step");
}
let mut work = base.clone();
let spent = Cell::new(MAX_LOOP_WORK);
PLACEMENT_STEPS.with(|steps| steps.set(0));
assert!(misses(&mut workspace, &asm, &r, 37.0, &[(1, 0)], &[6], &[0.2], &mut work, &spent).is_none());
assert_eq!(PLACEMENT_STEPS.with(|steps| steps.get()), 0);
}
}
}
+209 -22
View File
@@ -6,7 +6,8 @@
//! hold (one that later features pass through unchanged) is recorded once, as are its face names
//! and the solved sketches the states share. Face names are kept as worked out, not as the
//! history they were worked out from: a model taken from a file names every face as the model
//! that was saved did.
//! that was saved did. A file without room for every state keeps fewer
//! ([`SavedModel::thinned`]); opening evaluates the features whose state it left out again.
use super::*;
use crate::history_names::{FaceInfo, Names};
@@ -181,6 +182,7 @@ impl Writer {
let sketches = st.sketches.iter().map(|s| self.sketch(s)).collect::<Option<Vec<_>>>()?;
let r = json!({
"bodies": runs(&bodies), "sketches": runs(&sketches), "body_counter": st.body_counter,
"reserved_body_names": st.reserved_body_names.iter().collect::<Vec<_>>(),
"sheets": serde_json::to_value(&st.sheets).ok()?, "construct": serde_json::to_value(&st.construct).ok()?,
"threads": serde_json::to_value(&st.threads).ok()?,
"sketch_keys": st.sketch_keys.iter().map(|(f, k)| json!([f, hex(*k)])).collect::<Vec<_>>(),
@@ -320,6 +322,11 @@ impl SavedModel {
bodies: ids("bodies", model_bodies.len())?.into_iter().filter_map(|i| model_bodies.get(i).cloned()).collect(),
sketches: ids("sketches", sketches.len())?.into_iter().filter_map(|i| sketches.get(i).cloned()).collect(),
body_counter: index(s.get("body_counter"), usize::MAX)?,
reserved_body_names: s
.get("reserved_body_names")
.and_then(Json::as_array)
.map(|a| a.iter().filter_map(|n| n.as_str().map(str::to_string)).collect())
.unwrap_or_default(),
sheets: serde_json::from_value(field("sheets")?).map_err(invalid)?,
construct: serde_json::from_value(field("construct")?).map_err(invalid)?,
threads: serde_json::from_value(field("threads")?).map_err(invalid)?,
@@ -353,12 +360,163 @@ impl SavedModel {
None => Ok(Arc::new(ModelState::default())),
}
}
/// This model with fewer states, for a design file without room for all of them (#3220): the
/// final state (what the design shows, kept whatever it costs), then the others, the slowest
/// feature's first (the ones opening would take longest to evaluate again; of equal ones the
/// later first), each while what it adds still fits in `budget` bytes: the bodies no state kept
/// so far holds, and the records of the state, those bodies, their names and sketches.
/// `pieces[i]` is what keeping body `i` stores, as ids and bytes: a piece that several bodies
/// or states need counts once. A feature whose state is left out keeps none, and opening
/// evaluates it again ([`Model::adopt`]). With the model, the index here of each body it keeps,
/// and the bytes it counted: over `budget` when the final state alone is. `None` only for
/// records it cannot read.
pub fn thinned(&self, pieces: &[Vec<(usize, usize)>], budget: usize) -> Option<(SavedModel, Vec<usize>, usize)> {
let list = |key: &str| self.records.get(key).and_then(Json::as_array);
let (features, states, model_bodies) = (list("features")?, list("states")?, list("model_bodies")?);
let (names, sketches) = (list("names")?, list("sketches")?);
let at = |v: Option<&Json>, len: usize| v.and_then(Json::as_u64).and_then(|i| usize::try_from(i).ok()).filter(|i| *i < len);
// The indices a state's runs list.
let held = |s: &Json, key: &str, len: usize| -> Option<Vec<usize>> {
let mut out = Vec::new();
for run in s.get(key)?.as_array()? {
let first = at(run.get(0), len)?;
out.extend(first..first.checked_add(at(run.get(1), len.saturating_add(1))?).filter(|end| *end <= len)?);
}
Some(out)
};
let state_bodies = states.iter().map(|s| held(s, "bodies", model_bodies.len())).collect::<Option<Vec<_>>>()?;
let state_sketches = states.iter().map(|s| held(s, "sketches", sketches.len())).collect::<Option<Vec<_>>>()?;
let body_of = model_bodies.iter().map(|r| at(r.get("body"), self.bodies.len())).collect::<Option<Vec<_>>>()?;
let names_of = model_bodies.iter().map(|r| at(r.get("names"), names.len())).collect::<Option<Vec<_>>>()?;
// What each record takes in the file.
let bytes = |list: &[Json]| list.iter().map(|r| serde_json::to_vec(r).map_or(usize::MAX, |b| b.len().saturating_add(1))).collect::<Vec<_>>();
let (state_bytes, body_bytes) = (bytes(states.as_slice()), bytes(model_bodies.as_slice()));
let (name_bytes, sketch_bytes) = (bytes(names.as_slice()), bytes(sketches.as_slice()));
// What each state's feature took to evaluate, and the final state.
let mut ms = vec![0.0f64; states.len()];
let mut last = None;
for f in features.iter().filter(|f| f.get("skipped").and_then(Json::as_bool) == Some(false)) {
let s = at(f.get("state"), states.len())?;
if let Some(t) = ms.get_mut(s) {
*t += f.get("ms").and_then(Json::as_f64).filter(|t| t.is_finite() && *t > 0.0).unwrap_or(0.0);
}
last = Some(s);
}
let last = last?;
let time = |s: &usize| ms.get(*s).copied().unwrap_or(0.0);
let mut order: Vec<usize> = (0..states.len()).filter(|s| *s != last).collect();
order.sort_by(|a, b| time(b).total_cmp(&time(a)).then(b.cmp(a)));
let mut keep = vec![false; states.len()];
// The records and pieces kept so far, and their bytes.
let mut kept_bodies = vec![false; model_bodies.len()];
let (mut names_used, mut sketches_used) = (vec![false; names.len()], vec![false; sketches.len()]);
let mut stored = std::collections::HashSet::new();
let mut used = 0usize;
for s in std::iter::once(last).chain(order) {
let (mut adds, mut new_names, mut new_sketches) = (HashMap::new(), std::collections::HashSet::new(), std::collections::HashSet::new());
let mut records = *state_bytes.get(s)?;
for &m in state_bodies.get(s)? {
if kept_bodies.get(m) == Some(&false) {
records = records.saturating_add(*body_bytes.get(m)?);
let n = *names_of.get(m)?;
if names_used.get(n) == Some(&false) && new_names.insert(n) {
records = records.saturating_add(*name_bytes.get(n)?);
}
for &(piece, bytes) in pieces.get(*body_of.get(m)?)? {
if !stored.contains(&piece) {
adds.insert(piece, bytes);
}
}
}
}
for &k in state_sketches.get(s)? {
if sketches_used.get(k) == Some(&false) && new_sketches.insert(k) {
records = records.saturating_add(*sketch_bytes.get(k)?);
}
}
let total = adds.values().fold(used.saturating_add(records), |t, b| t.saturating_add(*b));
if total > budget && s != last {
continue;
}
used = total;
stored.extend(adds.into_keys());
for &m in state_bodies.get(s)? {
*kept_bodies.get_mut(m)? = true;
}
for n in new_names {
*names_used.get_mut(n)? = true;
}
for k in new_sketches {
*sketches_used.get_mut(k)? = true;
}
*keep.get_mut(s)? = true;
}
// What the states kept refer to, numbered again in the order it had.
let mut kernel_used = vec![false; self.bodies.len()];
for m in (0..model_bodies.len()).filter(|m| kept_bodies.get(*m) == Some(&true)) {
*kernel_used.get_mut(*body_of.get(m)?)? = true;
}
let renumber = |used: &[bool]| -> Vec<Option<usize>> {
let mut next = 0;
used.iter()
.map(|&u| {
u.then(|| {
next += 1;
next - 1
})
})
.collect()
};
let (new_body, new_kernel, new_names) = (renumber(&kept_bodies), renumber(&kernel_used), renumber(&names_used));
let (new_sketch, new_state) = (renumber(&sketches_used), renumber(&keep));
let new = |map: &[Option<usize>], i: usize| map.get(i).copied().flatten();
let kept_of = |list: &[Json], used: &[bool]| list.iter().zip(used).filter(|(_, u)| **u).map(|(v, _)| v.clone()).collect::<Vec<_>>();
let mut out_states = Vec::new();
for (s, st) in states.iter().enumerate().filter(|(s, _)| keep.get(*s) == Some(&true)) {
let bodies = state_bodies.get(s)?.iter().map(|m| new(&new_body, *m)).collect::<Option<Vec<_>>>()?;
let sketches = state_sketches.get(s)?.iter().map(|i| new(&new_sketch, *i)).collect::<Option<Vec<_>>>()?;
let mut st = st.clone();
let o = st.as_object_mut()?;
o.insert("bodies".into(), json!(runs(&bodies)));
o.insert("sketches".into(), json!(runs(&sketches)));
out_states.push(st);
}
let mut out_bodies = Vec::new();
for (m, r) in model_bodies.iter().enumerate().filter(|(m, _)| kept_bodies.get(*m) == Some(&true)) {
let (body, named) = (new(&new_kernel, *body_of.get(m)?)?, new(&new_names, at(r.get("names"), names.len())?)?);
let mut r = r.clone();
let o = r.as_object_mut()?;
o.insert("body".into(), json!(body));
o.insert("names".into(), json!(named));
out_bodies.push(r);
}
let mut out_features = Vec::with_capacity(features.len());
for f in features {
let state = new(&new_state, at(f.get("state"), states.len())?);
let mut f = f.clone();
let o = f.as_object_mut()?;
match state {
Some(s) => o.insert("state".into(), json!(s)),
None => o.remove("state"),
};
out_features.push(f);
}
let records = json!({
"features": out_features, "states": out_states, "model_bodies": out_bodies, "names": kept_of(names.as_slice(), &names_used),
"sketches": kept_of(sketches.as_slice(), &sketches_used),
});
let kept: Vec<usize> = (0..self.bodies.len()).filter(|i| kernel_used.get(*i) == Some(&true)).collect();
let bodies = kept.iter().filter_map(|i| self.bodies.get(*i).cloned()).collect();
Some((SavedModel { records, bodies }, kept, used))
}
}
impl Model {
/// The model a design file kept for `doc` ([`Model::saved`]), taken as `doc`'s evaluation:
/// every feature's result as saved, nothing evaluated. Refused (an error) unless every
/// feature's fingerprint is the one `doc` gives it now, and every record is well formed.
/// every feature's result as saved, nothing evaluated but the features whose state the file
/// did not keep ([`SavedModel::thinned`]). Refused (an error) unless every feature's
/// fingerprint is the one `doc` gives it now, and every record is well formed.
pub fn adopt(doc: &Document, saved: &SavedModel) -> Result<Model> {
let states = saved.states()?;
let records = &saved.records;
@@ -375,16 +533,24 @@ impl Model {
let step_digests = StepDigests::of(doc, &StepDigests::default());
let marker = doc.marker.unwrap_or(usize::MAX);
let ctx = context_digest(doc, &vals);
let mut cache = EvalCache::default();
let mut before = Arc::new(ModelState::default());
// Whether `before` is the state before the feature: one the file left out only stands in.
let mut exact = true;
let mut prev = 0;
let mut results = Vec::with_capacity(features.len());
for (i, (f, r)) in doc.features.iter().zip(features).enumerate() {
prev = chain_fingerprint(doc, &vals, f, prev, i >= marker, &step_digests, Some(&before));
if r.get("id").and_then(Json::as_u64) != Some(f.id) || unhex(r.get("fingerprint").unwrap_or(&Json::Null))? != prev {
let saved = unhex(r.get("fingerprint").unwrap_or(&Json::Null))?;
// A fingerprint reads the state before its feature (where the bodies it takes lie).
// After a state the file left out, the saved one is taken: `evaluate` works that state
// out, checks the fingerprints after it, and evaluates again from one that differs.
prev = if exact { chain_fingerprint(doc, &vals, f, prev, i >= marker, &step_digests, Some(&before)) } else { saved };
if r.get("id").and_then(Json::as_u64) != Some(f.id) || saved != prev {
return Err(bad("saved model: the document changed"));
}
let ms = r.get("ms").and_then(Json::as_f64).filter(|ms| ms.is_finite()).ok_or_else(|| bad("saved model: time"))?;
// A file without room for every state keeps some features without one (#3220).
let state =
r.get("state").map(|s| states.get(index(Some(s), states.len())?).cloned().ok_or_else(|| bad("saved model: state"))).transpose()?;
let result = FeatureResult {
id: f.id,
name: f.name.clone(),
@@ -398,27 +564,48 @@ impl Model {
skipped: r.get("skipped").and_then(Json::as_bool).ok_or_else(|| bad("saved model: skipped"))?,
stale: false,
fingerprint: prev,
state: states.get(index(r.get("state"), states.len())?).cloned().ok_or_else(|| bad("saved model: state"))?,
unkept: state.is_none(),
state: state.unwrap_or_else(|| before.clone()),
};
// Each result where an evaluation would have put it, so an edit undone (or set back)
// finds it again rather than evaluating the feature anew.
if !result.skipped {
let local = fingerprint(0, f, &vals, false, &step_digests) ^ placement_digest(doc, f, Some(&before));
cache.put(
cache_key(f, prev, local, ctx, &before),
CachedEval {
error: None,
warning: result.warning.clone(),
boolean_diagnostics: result.boolean_diagnostics.clone(),
ms,
bytes: result.state.retained_bytes(),
state: result.state.clone(),
},
);
exact = !result.unkept;
before = result.state.clone();
}
results.push(result);
}
Ok(Model { results, param_errors, step_digests, cache: Arc::new(std::sync::Mutex::new(cache)), ..Model::default() })
let mut model = Model { results, param_errors, step_digests, ..Model::default() };
let unkept = model.results.iter().filter(|r| r.unkept).count();
if unkept > 0 {
// The features whose state the file did not keep, each evaluated again from the state
// before it: nothing else in the model is missing then.
model.evaluate(doc);
log::info!(
"the saved model kept the states of {} of {} features: {} evaluated again",
model.results.len().saturating_sub(unkept),
model.results.len(),
model.last_recomputed
);
}
// Each result where an evaluation would have put it, so an edit undone (or set back)
// finds it again rather than evaluating the feature anew.
let mut before = Arc::new(ModelState::default());
if let Ok(mut cache) = model.cache.lock() {
for (f, r) in doc.features.iter().zip(&model.results).filter(|(_, r)| !r.skipped && r.error.is_none()) {
let local = fingerprint(0, f, &vals, false, &model.step_digests) ^ placement_digest(doc, f, Some(&before));
cache.put(
cache_key(f, r.fingerprint, local, ctx, &before),
CachedEval {
error: None,
warning: r.warning.clone(),
boolean_diagnostics: r.boolean_diagnostics.clone(),
ms: r.ms,
bytes: r.state.retained_bytes(),
state: r.state.clone(),
},
);
before = r.state.clone();
}
}
Ok(model)
}
}
+23
View File
@@ -5,6 +5,29 @@
use serde::{Deserialize, Serialize};
use solidcraft_geom::Vec3;
/// The motion retained by an associative trace. Joint bounds keep their expressions, in
/// command units (degrees or millimetres); studies are referenced by their stable identity.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum TraceMotion {
Joint { joint: u64, index: usize, from: serde_json::Value, to: serde_json::Value },
Study { study: u64 },
}
/// A generated root sketch curve and the inputs needed to regenerate it. The seed values
/// select the original assembly branch: playing or dragging the mechanism does not redefine
/// its trace. Older saved curves have no entry here and remain ordinary fixed sketch curves.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct MotionTrace {
pub sketch: u64,
pub wire: String,
pub occurrence: Vec<u64>,
pub point: crate::joints::JointOrigin,
pub motion: TraceMotion,
pub samples: Option<usize>,
pub seeds: Vec<(u64, Vec<f64>)>,
}
/// Occurrences whose bodies may touch but not pass through each other while joints move.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ContactSet {
+2 -7
View File
@@ -266,10 +266,7 @@ pub fn locate_face(st: &ModelState, name: &str, p: Vec3) -> Option<FaceHit> {
// Every body's names are read below: work out the ones not known yet on all cores first
// (each body's names are its own, whichever thread works them out).
if legacy_name(name).is_none() {
let bodies: Vec<&ModelBody> = st.bodies.iter().filter(|b| !b.body.is_mesh()).collect();
solidcraft_kernel::for_each_on_threads(&bodies, 16, |b| {
let _ = naming::names(b);
});
naming::warm_names(st.bodies.iter().filter(|b| !b.body.is_mesh()));
}
locate_in(st.bodies.iter().enumerate().filter(|(_, b)| !b.body.is_mesh()), name, p)
}
@@ -445,9 +442,7 @@ pub fn locate_one(st: &ModelState, body: Option<&str>, name: &str) -> Result<Fac
st.bodies.iter().enumerate().filter(|(_, b)| !b.body.is_mesh() && body.is_none_or(|n| b.name == n)).collect();
// As [`locate_face`]: the names of several bodies are worked out on all cores first.
if bodies.len() > 1 && legacy_name(name).is_none() {
solidcraft_kernel::for_each_on_threads(&bodies, 16, |(_, b)| {
let _ = naming::names(b);
});
naming::warm_names(bodies.iter().map(|(_, b)| *b));
}
let hit = locate_in(bodies.into_iter(), name, Vec3::ZERO).ok_or_else(|| format!("no face is named `{name}` (`model.faces` lists the names)"))?;
if hit.split {
+96
View File
@@ -229,6 +229,23 @@ pub fn face_name_exact(b: &ModelBody, index: usize) -> bool {
names(b).verified(index)
}
/// Prepare only names not yet known before a lookup reads several bodies. A ready cell is
/// shared by snapshots and poses: dispatching threads just to read it costs more than a lookup.
pub(crate) fn warm_names<'a>(bodies: impl Iterator<Item = &'a ModelBody>) {
let bodies: Vec<_> = bodies.filter(|b| b.names.names.get().is_none()).collect();
#[cfg(test)]
tests::record_warm_jobs(bodies.len());
match bodies.as_slice() {
[] => {}
[body] => {
let _ = names(body);
}
_ => solidcraft_kernel::for_each_on_threads(&bodies, 16, |b| {
let _ = names(b);
}),
}
}
/// A body's names, with what finding them again needs: from the history of the feature that
/// made it where it reports one, else from geometry (unverified).
pub(crate) fn names(b: &ModelBody) -> Arc<Names> {
@@ -872,6 +889,85 @@ pub fn face_on_body(body: &ModelBody, name: &str, p: Vec3) -> Option<(usize, Vec
#[cfg(test)]
mod tests {
thread_local! {
static WARM_JOBS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
pub(super) fn record_warm_jobs(n: usize) {
WARM_JOBS.with(|jobs| jobs.set(jobs.get() + n));
}
fn take_warm_jobs() -> usize {
WARM_JOBS.with(|jobs| jobs.replace(0))
}
fn lookup_bodies() -> ModelState {
let body = solidcraft_kernel::box_solid(Vec3::ZERO, Vec3::new(4., 2., 1.)).unwrap();
let mut st = ModelState::default();
st.bodies = (0..13).map(|i| ModelBody::new(format!("Part{i}"), body.clone(), i + 1)).collect();
st
}
fn same_hit(a: &FaceHit, b: &FaceHit) {
assert_eq!((a.body, a.face, a.point), (b.body, b.face, b.point));
assert_eq!((a.split, a.merged, a.verified, a.on_point, a.legacy), (b.split, b.merged, b.verified, b.on_point, b.legacy));
}
#[test]
fn cached_face_lookups_schedule_no_name_warmup() {
let st = lookup_bodies();
for b in &st.bodies {
let _ = face_names(b);
}
let name = face_names(&st.bodies[0])[0].clone();
let picked = locate_face(&st, &name, Vec3::ZERO).unwrap();
let unique = locate_one(&st, None, &name).unwrap();
take_warm_jobs();
// The two public lookup paths used by picked origins and named-face commands.
for _ in 0..145 {
same_hit(&locate_face(&st, &name, Vec3::ZERO).unwrap(), &picked);
same_hit(&locate_one(&st, None, &name).unwrap(), &unique);
}
assert!(locate_face(&st, "absent:face:0", Vec3::ZERO).is_none());
assert!(locate_one(&st, None, "absent:face:0").is_err());
assert_eq!(take_warm_jobs(), 0, "cached names must not dispatch preparation work");
}
#[test]
fn face_lookup_warms_only_cold_and_invalidated_names() {
// Exercise both entry points as the first reader of a partly named model.
for picked in [false, true] {
let mut st = lookup_bodies();
let name = face_names(&st.bodies[0])[0].clone();
let lookup = |st: &ModelState, name: &str| {
if picked { locate_face(st, name, Vec3::ZERO).unwrap() } else { locate_one(st, None, name).unwrap() }
};
take_warm_jobs();
let hit = lookup(&st, &name);
assert_eq!(take_warm_jobs(), 12, "only the other bodies need names");
let snapshot = st.clone();
same_hit(&lookup(&snapshot, &name), &hit);
assert_eq!(take_warm_jobs(), 0, "snapshots share ready names");
st.bodies[0].name = "Renamed".into();
st.bodies[0].names = st.bodies[0].names.renamed();
let renamed = lookup(&st, "Renamed:face:0");
same_hit(&renamed, &hit);
assert_eq!(take_warm_jobs(), 1, "a rename invalidates only its own names");
assert!(locate_face(&st, &name, Vec3::ZERO).is_none());
same_hit(&lookup(&snapshot, &name), &hit);
assert_eq!(take_warm_jobs(), 0, "the retained snapshot stays ready");
let source = st.bodies[1].clone();
st.bodies[1].names = NameCell::same(&source);
same_hit(&lookup(&st, "Renamed:face:0"), &renamed);
assert_eq!(take_warm_jobs(), 1, "a lazy moved copy is prepared once");
assert!(Arc::ptr_eq(&names(&st.bodies[1]), &names(&source)));
same_hit(&lookup(&st, "Renamed:face:0"), &renamed);
assert_eq!(take_warm_jobs(), 0);
}
}
/// Pieces lying almost level along the sort axis must keep the order they arrived in, whatever
/// the low bits of their centres do. The centre is the mean of a tessellated boundary, so those
/// bits depend on where the loop's polyline starts and can differ between processes — which is
+16
View File
@@ -76,6 +76,9 @@ impl FeatureKind {
ExprRole::Placement => placement.push(e.as_str()),
});
inputs.extend(placement);
if let FeatureKind::Sketch { sketch, .. } = self {
inputs.extend(sketch.patterns.iter().filter_map(|p| p.count_expression.as_deref()));
}
inputs
}
}
@@ -694,6 +697,11 @@ impl Document {
out.push(ParamUser::Dimension { feature: f.id, name: f.name.clone(), constraint: c.id.clone() });
}
}
for pattern in &sketch.patterns {
if pattern.count_expression.as_ref().is_some_and(|e| expr::references(e).iter().any(|r| r == name)) {
out.push(ParamUser::Feature { feature: f.id, name: f.name.clone(), input: format!("{} Quantity", pattern.id) });
}
}
for (what, e) in sketch.text_exprs() {
if expr::references(e).iter().any(|r| r == name) {
out.push(ParamUser::Feature { feature: f.id, name: f.name.clone(), input: what.to_string() });
@@ -783,6 +791,13 @@ impl Document {
n += 1;
}
}
for e in sketch.patterns.iter_mut().filter_map(|p| p.count_expression.as_mut()) {
let r = expr::rename_reference(e, old, new);
if r != *e {
*e = r;
n += 1;
}
}
for e in sketch.text_exprs_mut() {
let r = expr::rename_reference(e, old, new);
if r != *e {
@@ -853,6 +868,7 @@ impl Document {
if let FeatureKind::Sketch { sketch, .. } = &f.kind {
names.extend(sketch.constraints.iter().filter_map(|c| c.param.clone()));
names.extend(sketch.text_exprs().into_iter().flat_map(|(_, e)| expr::references(e)));
names.extend(sketch.patterns.iter().filter_map(|p| p.count_expression.as_deref()).flat_map(expr::references));
}
out.extend(names.into_iter().map(|n| (format!("Feature:{}", f.name), n)));
}
+36 -5
View File
@@ -330,10 +330,32 @@ struct Chamfer {
fn chamfer_at(mb: &ModelBody, mesh: &Mesh, p: Vec3, tol: f64, near: f64) -> Option<Chamfer> {
let b = &mb.body;
let (d, f) = crate::appearance::nearest_face(mb, p)?;
if d <= near
&& let Some(c) = bend_cylinder(b, mesh, f, tol)
{
return Some(c);
if d <= near {
if let Some(c) = bend_cylinder(b, mesh, f, tol) {
return Some(c);
}
// A tangent edge belongs to both the cylinder and a planar panel. The nearest
// face can be either: resolve the cylinder from the edge's incident faces too.
let edge = mesh
.edges
.iter()
.zip(&mesh.edge_faces)
.map(|(line, faces)| {
let distance = line
.windows(2)
.filter_map(|w| match w {
[a, b] => Some(p.dist_to_segment(*a, *b)),
_ => None,
})
.fold(f64::INFINITY, f64::min);
(distance, faces)
})
.min_by(|a, b| a.0.total_cmp(&b.0));
if let Some((_, faces)) = edge.filter(|(distance, _)| *distance <= near)
&& let Some(c) = faces.iter().find_map(|g| bend_cylinder(b, mesh, *g as usize, tol))
{
return Some(c);
}
}
if d > tol {
return None;
@@ -672,8 +694,17 @@ pub fn bend_edge_names(st: &crate::ModelState, body: &str, pts: &[Vec3]) -> Vec<
/// Convert body `mb` to a sheet under the rule `rule` with values `rv`: `face` is a point on the
/// fixed face and `bends` are points on the sharp edges that become bends, in the middle of
/// chamfers or on bend cylinders that do (the body's coordinates, mm). See the module docs.
/// `body_to_world` places diagnostic points in world coordinates. Geometry and picks stay local.
/// Returns the sheet and a note for every corner or end of a bend not finished as the rule says.
pub fn convert_body(mb: &ModelBody, rule: &str, rv: &RuleValues, face: Vec3, bends: &[Vec3]) -> Result<(SheetBody, Vec<String>)> {
pub fn convert_body(
mb: &ModelBody,
rule: &str,
rv: &RuleValues,
face: Vec3,
bends: &[Vec3],
body_to_world: &crate::Mat,
) -> Result<(SheetBody, Vec<String>)> {
let at = |p| at(apply_point(body_to_world, p));
if bends.is_empty() || bends.len() > MAX_BENDS {
return err(format!("pick 1…{MAX_BENDS} bend edges"));
}
+39
View File
@@ -251,6 +251,45 @@ fn params_and_serialization() {
assert!(doc.param("d1").is_none(), "unused model parameters are pruned");
}
/// #4783: a body reads the material its mass is computed with, as the library or the design
/// spells it. An unknown name, or a design material with an unusable density, reads as the
/// default material it weighs. `density` agrees in every case, and a configuration keeps the
/// assignment as stored.
#[test]
fn a_body_reads_the_material_its_mass_is_computed_with() {
let mut doc = Document::new("materials");
let usable = [("Walnut", 0.65), ("Mote", f64::MIN_POSITIVE), ("Limit", MAX_DENSITY)];
let unusable = [("Broken", -3.0), ("Weightless", 0.0), ("Beyond", MAX_DENSITY.next_up())];
for (name, density) in usable.into_iter().chain(unusable) {
doc.user_materials.push(UserMaterial { name: name.into(), density, appearance: None });
}
// (body, its assignment as stored, the material it reads and weighs, that material's density)
let cases = [
("Known", Some("Steel"), "Steel", 7.85),
("Miscased", Some("steel"), "Steel", 7.85),
("Own", Some(" walnut "), "Walnut", 0.65),
("Lightest", Some("Mote"), "Mote", f64::MIN_POSITIVE),
("At the limit", Some("Limit"), "Limit", MAX_DENSITY),
("Unknown", Some("Unobtainium"), DEFAULT_MATERIAL, DEFAULT_DENSITY),
("Unusable", Some("Broken"), DEFAULT_MATERIAL, DEFAULT_DENSITY),
("Zero density", Some("Weightless"), DEFAULT_MATERIAL, DEFAULT_DENSITY),
("Over the limit", Some("Beyond"), DEFAULT_MATERIAL, DEFAULT_DENSITY),
("Unassigned", None, DEFAULT_MATERIAL, DEFAULT_DENSITY),
];
for (body, stored, ..) in cases {
if let Some(material) = stored {
doc.materials.insert(body.into(), material.into());
}
}
for (body, stored, material, density) in cases {
assert_eq!(doc.body_material(body), material, "{body}");
assert_eq!(doc.density(body), density, "{body}");
assert_eq!(doc.material_density(material), Some(density), "{body}: the density is its material's");
let cell = doc.config_current(&crate::config::Column::Material(body.into()));
assert_eq!(cell.as_deref(), Some(stored.unwrap_or(DEFAULT_MATERIAL)), "{body}: a configuration keeps the assignment as stored");
}
}
#[test]
fn primitives_and_combine() {
let mut doc = Document::new("p");
@@ -1223,3 +1223,78 @@ fn extrude_cut_into_a_rounded_block_top() {
let v = before - volume(&cut, "Body1");
assert!((v - taken).abs() < 0.05, "took {v}, expected {taken}");
}
#[test]
fn empty_intersection_is_refused_without_changing_the_design() -> crate::Result<()> {
for command in ["solid.extrude", "solid.box", "solid.combine"] {
for x in [5, 20] {
let mut s = Session::default();
s.execute("solid.box", &json!({"length":10,"width":10,"height":10,"body_name":"Base"}))?;
s.execute("solid.box", &json!({"corner":[x,0,0],"length":10,"width":10,"height":10,"body_name":"Tool","operation":"new"}))?;
s.execute("sketch.create", &json!({"plane":"XY"}))?;
s.execute("sketch.rectangle.two_point", &json!({"p0":[x,0],"p1":[x+10,10]}))?;
s.execute("sketch.finish", &json!({}))?;
let before = s.doc.to_json();
let params = match command {
"solid.combine" => json!({"target":"Base","tools":["Tool"],"operation":"intersect"}),
"solid.box" => json!({"corner":[x,0,0],"length":10,"width":10,"height":10,"operation":"intersect","targets":["Base"]}),
_ => json!({"distance":10,"operation":"intersect","targets":["Base"]}),
};
let result = s.execute(command, &params);
if x == 20 {
let error = result.err().ok_or_else(|| crate::EngineError::Other(format!("{command} accepted an empty intersection")))?;
assert!(
error.to_string().contains("the intersection is empty: nothing of `Base` would remain; check that the profile overlaps the body"),
"{error}"
);
assert_eq!(s.doc.to_json(), before);
assert_eq!(s.model.state().bodies.len(), 2);
assert!((volume(&s, "Base") - 1000.0).abs() < 1e-6);
assert!((volume(&s, "Tool") - 1000.0).abs() < 1e-6);
} else {
result?;
assert!((volume(&s, "Base") - 500.0).abs() < 1e-6);
}
}
}
Ok(())
}
#[test]
fn empty_intersection_in_a_saved_design_fails_and_keeps_the_target() -> crate::Result<()> {
let mut s = Session::default();
s.execute("solid.box", &json!({"length":10,"width":10,"height":10,"body_name":"Base"}))?;
s.execute("solid.box", &json!({"length":10,"width":10,"height":40,"body_name":"Tall","operation":"new"}))?;
s.execute("sketch.create", &json!({"plane":"XY","offset":5}))?;
s.execute("sketch.rectangle.two_point", &json!({"p0":[0,0],"p1":[10,10]}))?;
s.execute("sketch.finish", &json!({}))?;
let result = s.execute("solid.extrude", &json!({"distance":10,"operation":"intersect","targets":["Tall","Base"]}))?;
let id = result["feature"].as_u64().ok_or_else(|| crate::EngineError::Other("missing feature id".into()))?;
assert!((volume(&s, "Base") - 500.0).abs() < 1e-6);
let mut doc = solidcraft_doc::Document::from_json(&s.doc.to_json())?;
if let Some(f) = doc.feature_mut(id)
&& let solidcraft_doc::FeatureKind::Extrude { extent, .. } = &mut f.kind
{
extent.start_offset = Some("20 mm".into());
}
let reopened = Session::new(solidcraft_doc::Document::from_json(&doc.to_json())?);
assert!(reopened.model.result(id).and_then(|r| r.error.as_ref()).is_some_and(|e| e.contains("the intersection is empty")));
assert_eq!(reopened.model.state().bodies.len(), 2);
assert!((volume(&reopened, "Tall") - 4000.0).abs() < 1e-6);
assert!((volume(&reopened, "Base") - 1000.0).abs() < 1e-6);
Ok(())
}
#[test]
fn intersect_with_every_body_still_drops_the_ones_it_misses() -> crate::Result<()> {
let mut s = Session::default();
s.execute("solid.box", &json!({"length":10,"width":10,"height":10,"body_name":"Near"}))?;
s.execute("solid.box", &json!({"corner":[30,0,0],"length":10,"width":10,"height":10,"body_name":"Far","operation":"new"}))?;
s.execute("sketch.create", &json!({"plane":"XY"}))?;
s.execute("sketch.rectangle.two_point", &json!({"p0":[0,0],"p1":[5,10]}))?;
s.execute("sketch.finish", &json!({}))?;
s.execute("solid.extrude", &json!({"distance":10,"operation":"intersect"}))?;
assert_eq!(s.model.state().bodies.len(), 1);
assert!((volume(&s, "Near") - 500.0).abs() < 1e-6);
Ok(())
}
+93 -5
View File
@@ -34,7 +34,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("view.delete", "Delete Named View", view_delete).icon("delete").params("view: name"),
CommandSpec::new("view.list", "List Named Views", view_list).icon("list").noundo().doc_only(),
CommandSpec::new("view.apply", "Restore Named View", view_apply).icon("camera").noundo().params(
"view?: front|back|top|bottom|left|right|iso|home (frames the model) | a named view's name; or camera?: {target: [x,y,z], yaw, pitch, distance, fov?} (radians, mm) → camera. The viewport (and the MCP screenshot) moves there. View state: not saved, not an undo step",
"view?: front|back|top|bottom|left|right|iso|home (frames the model) | a named view's name; or camera?: {target: [x,y,z], yaw, pitch, distance, fov?, roll?} (radians, mm; roll defaults to 0, must be finite and is clamped to ±π; up is refused: the up axis comes from the design settings) → camera. The viewport (and the MCP screenshot) moves there. View state: not saved, not an undo step",
),
CommandSpec::new("browser.order", "Order Browser Items", order).icon("reorder").params("folder, component?, order: [keys] (the folder's items in display order)"),
CommandSpec::new("browser.visibility", "Show/Hide", visibility).icon("eye").noundo().params(
@@ -453,8 +453,17 @@ fn view_apply(s: &mut Session, p: &Value) -> Result<Value> {
(Some(_), Some(_)) => return Err(bad(cmd, "give `view` or `camera`, not both")),
(None, None) => return Err(bad(cmd, "`view` (a name) or `camera` is required")),
(None, Some(c)) => {
if c.get("up").is_some() {
return Err(bad(cmd, "camera `up` is not supported: the up axis comes from the design settings"));
}
let roll = match c.get("roll") {
Some(value) => {
value.as_f64().filter(|r| r.is_finite()).ok_or_else(|| bad(cmd, "camera `roll` must be a finite number in radians"))?
}
None => 0.0,
};
let v: solidcraft_doc::NamedView = serde_json::from_value(c.clone()).map_err(|e| bad(cmd, format!("bad camera: {e}")))?;
camera_of(&v)
crate::render::Camera { roll: roll.clamp(-std::f64::consts::PI, std::f64::consts::PI), ..camera_of(&v) }
}
// Standard view names come first, as in the browser's Named Views folder.
(Some(name), None) => match crate::render::StandardView::parse(name) {
@@ -548,8 +557,10 @@ fn rename_body(s: &mut Session, p: &Value) -> Result<Value> {
// includes the siblings whose stored name is shared: the solids of a STEP file with one label
// are stored as `X`, `X`, `X` and built as `X`, `X (2)`, `X (3)`, so the stored names are set
// to the numbered ones, or `X (3)` would turn into `X (2)` once the second solid is renamed.
// Read the feature's own result so siblings removed by later features keep their names too.
let fid = b.feature;
let siblings: Vec<String> = st.bodies.iter().filter(|x| x.feature == fid).map(|x| x.name.clone()).collect();
let made = s.model.result(fid).map_or(&st.bodies, |r| &r.state.bodies);
let siblings: Vec<String> = made.iter().filter(|x| x.feature == fid).map(|x| x.name.clone()).collect();
let k = siblings.iter().position(|n| *n == old).unwrap_or(0);
// A body numbered by the counter (`BodyN`) gives its number back once it has a name of its
// own, and every counted body after it would move down one (`Body3` becoming `Body1`) and
@@ -777,6 +788,30 @@ mod tests {
assert_eq!(names(&s)[1], b);
}
#[test]
fn renaming_a_body_keeps_a_later_sibling_removal() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
for x in [0, 20, 40] {
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [x, 0], "p1": [x + 10, 10]}));
}
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 5, "operation": "new", "name": "Extrude1"}));
assert_eq!(names(&s), ["Body1", "Body2", "Body3"]);
run(&mut s, "solid.remove", json!({"bodies": ["Body3"]}));
assert_eq!(names(&s), ["Body1", "Body2"]);
run(&mut s, "body.rename", json!({"body": "Body1", "name": "Ext"}));
assert_eq!(s.doc.find_feature("Extrude1").unwrap().body_names, ["Ext", "Body2", "Body3"]);
assert_eq!(names(&s), ["Ext", "Body2"]);
assert!(s.model.results.iter().all(|r| r.error.is_none()), "the remove still finds Body3");
let mut fresh = Session::new((*s.doc).clone());
fresh.refresh();
assert_eq!(names(&fresh), ["Ext", "Body2"]);
assert!(fresh.model.results.iter().all(|r| r.error.is_none()), "the remove also works after a full evaluation");
}
/// #3510: each piece of a split is renamed on its own. Renaming the split body leaves the
/// other piece, and the features that use it, as they were.
#[test]
@@ -797,6 +832,28 @@ mod tests {
assert_eq!(names(&s), vec!["Upper", "Top (1)"]);
}
/// Renaming one half of a split leaves the other half's name, also once a later feature
/// joins that other half away.
#[test]
fn renaming_one_half_of_a_split_leaves_the_other() {
let ok = |s: &Session| s.model.results.iter().all(|r| r.error.is_none());
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [0, 0, -5]}));
run(&mut s, "solid.split_body", json!({"body": "Body1", "plane": "XY"}));
assert_eq!(names(&s), ["Body1", "Body1 (1)"]);
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [10, 0, -5], "operation": "new", "body_name": "Tab"}));
run(&mut s, "solid.combine", json!({"target": "Tab", "tools": ["Body1 (1)"], "operation": "join"}));
assert_eq!(names(&s), ["Body1", "Tab"]);
run(&mut s, "body.rename", json!({"body": "Body1", "name": "Upper"}));
assert_eq!(names(&s), ["Upper", "Tab"]);
assert!(ok(&s), "the join still finds the other half");
let mut fresh = Session::new((*s.doc).clone());
fresh.refresh();
assert_eq!(names(&fresh), ["Upper", "Tab"]);
assert!(ok(&fresh), "the join also works after a full evaluation");
}
/// #1108: a primitive's `targets` are its participants, kept beside its kind; a rename
/// follows them, as it follows the names inside the kind (a combine's target and tools).
#[test]
@@ -985,8 +1042,14 @@ mod tests {
run(&mut s, "view.apply", json!({"view": "top"}));
let c = s.camera.expect("a camera for the top view");
assert!((c.pitch - std::f64::consts::FRAC_PI_2).abs() < 1e-3 && (c.target.x - 20.0).abs() < 1e-6, "{c:?}");
run(&mut s, "view.apply", json!({"camera": {"target": [0, 0, 0], "yaw": 1, "pitch": 0.2, "distance": 80, "fov": 0.5}}));
assert_eq!(s.camera.map(|c| (c.yaw, c.distance, c.fov)), Some((1.0, 80.0, 0.5)));
let r = run(&mut s, "view.apply", json!({"camera": {"target": [0, 0, 0], "yaw": 1, "pitch": 0.2, "distance": 80, "fov": 0.5, "roll": 1.0}}));
assert_eq!(s.camera.map(|c| (c.yaw, c.distance, c.fov, c.roll)), Some((1.0, 80.0, 0.5, 1.0)));
assert_eq!(r["camera"]["roll"], 1.0);
for (roll, expected) in [(f64::MAX, std::f64::consts::PI), (-f64::MAX, -std::f64::consts::PI)] {
let r = run(&mut s, "view.apply", json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 0, "distance": 80, "roll": roll}}));
assert_eq!(r["camera"]["roll"], expected);
assert_eq!(s.camera.map(|c| c.roll), Some(expected));
}
assert_eq!(s.undo.len(), undo, "view state, not an undo step");
for p in [
json!({}),
@@ -999,6 +1062,31 @@ mod tests {
}
}
#[test]
fn applying_a_camera_rejects_up() {
let mut s = Session::default();
run(&mut s, "view.apply", json!({"view": "front"}));
let before = serde_json::to_value(s.camera).unwrap();
let err = s.execute("view.apply", &json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 0, "distance": 80, "up": "y"}})).unwrap_err();
let message = err.to_string();
assert!(message.contains("up") && message.contains("design") && message.contains("settings"), "{message}");
assert_eq!(serde_json::to_value(s.camera).unwrap(), before);
}
#[test]
fn applying_a_camera_rejects_invalid_roll() {
let mut s = Session::default();
run(&mut s, "view.apply", json!({"view": "front"}));
let before = serde_json::to_value(s.camera).unwrap();
// Non-finite floating-point values serialize as null in JSON.
for roll in [json!(f64::NAN), json!(f64::INFINITY), json!(f64::NEG_INFINITY), json!("NaN"), json!(true)] {
let err =
s.execute("view.apply", &json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 0, "distance": 80, "roll": roll}})).unwrap_err();
assert!(err.to_string().contains("roll"), "{err}");
assert_eq!(serde_json::to_value(s.camera).unwrap(), before);
}
}
#[test]
fn named_views_are_saved_renamed_and_deleted() {
let mut s = Session::default();
+6
View File
@@ -109,6 +109,12 @@ pub(super) fn valid_position(m: &Mat) -> bool {
pub(super) fn new_component(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "component.create";
if let Some(params) = p.as_object()
&& let Some(key) = params.keys().find(|key| !matches!(key.as_str(), "name" | "parent" | "parent_path" | "activate"))
{
let hint = if params.contains_key("bodies") { "; use component.move_bodies or component.from_bodies to move bodies" } else { "" };
return Err(bad(cmd, format!("unknown parameter `{key}`; accepted parameters: name, parent, parent_path, activate{hint}")));
}
let parent = if p.get("parent").is_some() { component(s, p, "parent", cmd)? } else { s.active_component };
let parent_path = if let Some(value) = p.get("parent_path") {
let path = value
+1 -1
View File
@@ -99,7 +99,7 @@ fn unique(base: &str, used: &mut BTreeSet<String>) -> String {
/// name is built as stored, or as `stored (n)` when a body of that name exists already: the solids
/// of a STEP file that share a label are stored as `X`, `X`, `X` and built as `X`, `X (2)`, `X (3)`,
/// and a second import of a file builds `Aa (2)` for the stored `Aa`.
fn named_by(stored: &[String], built: &[String]) -> bool {
pub(super) fn named_by(stored: &[String], built: &[String]) -> bool {
!stored.is_empty()
&& stored.len() == built.len()
&& stored.iter().zip(built).all(|(stored, built)| {
File diff suppressed because it is too large Load Diff
+23 -3
View File
@@ -15,6 +15,9 @@ use super::CommandSpec;
use crate::params::{bad, str_, vec3};
use crate::{EngineError, Result, Session};
#[path = "component_extract.rs"]
mod extract;
#[cfg(test)]
#[path = "component_relink_tests.rs"]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
@@ -35,6 +38,19 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("component.break_link", "Break Link", break_link)
.icon("break")
.params("component: id or name of a linked component — keeps its bodies as they are and forgets the design file"),
CommandSpec::new("component.extract", "Extract to File", extract::extract).icon("export").params(
"component: id or name of a component that is not linked; path: the .solidcraft design to write → its history, subcomponents and their joints \
move to that design, and the component becomes linked to it (as component.insert_design makes it); returns path, file, feature, bodies, \
notices (what stays behind), warnings and copies (components that build the same bodies: see component.replace_with_link)",
),
CommandSpec::new("component.copies", "Find Identical Copies", extract::copies).icon("inspect").noundo().params(
"component: id or name of a linked component → copies: [{component, name}], the components not linked whose own history builds the same bodies",
),
CommandSpec::new("component.replace_with_link", "Replace with Link", extract::replace_with_link).icon("chain").params(
"component or components: [ids or names] of components that are not linked; with: a linked component, or path: a .solidcraft design \
(its link here, else a new linked component) → each component's history goes and its placements become placements of the linked \
component; refused when its bodies are not the same as the link's; returns component, name, replaced and warnings",
),
CommandSpec::new("component.links", "List Links", list_links)
.icon("chain")
.noundo()
@@ -112,8 +128,12 @@ fn linked_bodies(file: &Path, own: Option<&str>) -> Result<(String, solidcraft_i
}
let bytes = solidcraft_io::vfs::read(&shown, solidcraft_io::MAX_DESIGN_BYTES).map_err(|e| EngineError::Other(format!("{shown}: {e}")))?;
let doc = solidcraft_io::read_design(&bytes).map_err(|e| EngineError::Other(format!("{shown}: {e}")))?;
let name = doc.name.clone();
let part = Session::new(doc);
session_bodies(&Session::new(doc), &shown)
}
/// The solid bodies of `part`, the design saved as `shown`, as an Import feature.
fn session_bodies(part: &Session, shown: &str) -> Result<(String, solidcraft_io::StepFeature)> {
let name = part.doc.name.clone();
if let Some(e) = part.model.results.iter().find_map(|r| r.error.clone()) {
return Err(EngineError::Other(format!("{shown}: the design has a feature that fails ({e}); fix it before linking it")));
}
@@ -124,7 +144,7 @@ fn linked_bodies(file: &Path, own: Option<&str>) -> Result<(String, solidcraft_i
}
let step = solidcraft_io::export(&part.doc.painted(&world), &solids, solidcraft_io::Format::Step, &name)
.map_err(|e| EngineError::Other(format!("{shown}: {e}")))?;
let mut feature = solidcraft_io::step_import_feature(&step, &shown).map_err(|e| EngineError::Other(format!("{shown}: {e}")))?;
let mut feature = solidcraft_io::step_import_feature(&step, shown).map_err(|e| EngineError::Other(format!("{shown}: {e}")))?;
if world.bodies.len() > solids.len() {
feature.warnings.push(format!("{} mesh bodies of {shown} are not linked", world.bodies.len() - solids.len()));
}
@@ -130,7 +130,7 @@ fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool,
edges += measured.merged.edges;
vertices += measured.merged.vertices;
bodies.push(json!({"name": body.name, "component": component, "path": path,
"material": doc.materials.get(&body.name).map(String::as_str).unwrap_or("Default"),
"material": doc.body_material(&body.name),
"density_g_cm3": density, "mass_g": body_mass, "volume_mm3": measured.volume,
"area_mm2": measured.area, "center_of_mass": measured.centroid,
"bbox": {"min": measured.bbox.min, "max": measured.bbox.max}}));
@@ -256,7 +256,8 @@ fn user_defined_material_survives_save_and_reopen() {
/// #532: designs from before user materials open unchanged: built-in assignments keep their
/// densities and a design without its own materials saves no `user_materials`. A damaged
/// file's unusable density falls back to the default instead of producing nonsense masses.
/// file's unusable density falls back to the default instead of producing nonsense masses, and
/// the body reads as the default material.
#[test]
fn designs_without_user_materials_open_unchanged() {
let (dir, path) = temp_design("legacy");
@@ -278,7 +279,17 @@ fn designs_without_user_materials_open_unchanged() {
std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
run(&mut s, "doc.open", json!({"path": path}));
close(&mass(&mut s), solidcraft_doc::DEFAULT_DENSITY);
// #4783: it reads as the material it weighs; the file's assignment is kept as it is.
let props = run(&mut s, "component.properties", json!({"component": "root"}));
assert_eq!(props["bodies"][0]["material"], "Default", "{props}");
let measured = run(&mut s, "inspect.measure", json!({}));
assert_eq!(measured["bodies"][0]["material"], "Default", "{measured}");
close(&measured["bodies"][0]["density_g_cm3"], solidcraft_doc::DEFAULT_DENSITY);
assert_eq!(s.doc.materials.get("Plate").map(String::as_str), Some("Broken"));
assert_eq!(s.doc.material_look("Broken").map(|l| l.opacity), Some(1.0));
// Assigning the default material, in any case, clears the damaged assignment.
run(&mut s, "material.assign", json!({"bodies": ["Plate"], "material": "default"}));
assert!(!s.doc.materials.contains_key("Plate"));
let _ = std::fs::remove_dir_all(&dir);
}
@@ -292,6 +303,10 @@ fn configurations_switch_to_a_user_defined_material() {
run(&mut s, "config.row", json!({"name": "Walnut", "values": {"material:Slat": "walnut"}}));
run(&mut s, "config.activate", json!({"row": "Walnut"}));
close(&mass(&mut s), 0.65);
// A row of the default material, in any case, clears the assignment.
run(&mut s, "config.row", json!({"name": "Plain", "values": {"material:Slat": "default"}}));
run(&mut s, "config.activate", json!({"row": "Plain"}));
assert!(!s.doc.materials.contains_key("Slat"));
run(&mut s, "material.edit", json!({"material": "Walnut", "name": "Black Walnut"}));
run(&mut s, "config.activate", json!({"row": "Default"}));
run(&mut s, "config.activate", json!({"row": "Walnut"}));
+2 -1
View File
@@ -2,6 +2,7 @@
//! suppressions) and switching between its rows.
use serde_json::{Value, json};
use solidcraft_doc::DEFAULT_MATERIAL;
use solidcraft_doc::config::{Column, ConfigRow, MAX_COLUMNS, MAX_ROWS};
use super::CommandSpec;
@@ -265,7 +266,7 @@ fn row(s: &mut Session, p: &Value) -> Result<Value> {
.ok_or_else(|| bad(cmd, format!("`{k}` is not a column (config.column adds one)")))?;
match s.doc.configs.columns.get(col) {
Some(Column::Material(_)) => {
text = s.doc.material_cell(&text).map_err(|e| bad(cmd, e.to_string()))?.unwrap_or_else(|| "Default".into());
text = s.doc.material_cell(&text).map_err(|e| bad(cmd, e.to_string()))?.unwrap_or_else(|| DEFAULT_MATERIAL.into());
}
Some(Column::Appearance(_) | Column::ComponentAppearance(_) | Column::FaceAppearance(_)) => {
text = match solidcraft_doc::config::appearance_cell(&text).map_err(|e| bad(cmd, e.to_string()))? {
+55 -8
View File
@@ -39,13 +39,13 @@ pub static COMMANDS: &[CommandSpec] = &[
.doc_only()
.params("features?: [id|name], groups?: [ids], group?: id (omit or null to move to the timeline root)"),
CommandSpec::new("timeline.move_many", "Move Timeline Items", move_many).icon("reorder").params(
"features?: [id|name], groups?: [folder ids], group?: folder id, position: insertion slot; atomically changes folder membership and moves all selected contents as one block",
"features?: [id|name], groups?: [folder ids], group?: folder id, position: insertion slot, active?: bool (true = the moved contents end up before the history marker, false = behind it; other features keep their state); atomically changes folder membership and moves all selected contents as one block",
),
CommandSpec::new("timeline.rollback", "Move Timeline Marker", rollback).icon("rollback").params("position: number of features to keep active, or \"end\" (omit = end)"),
CommandSpec::new("timeline.roll_to", "Roll History Marker", rollback).icon("rollback")
CommandSpec::new("timeline.roll_to", "Roll History Marker", roll_to).icon("rollback")
.params("position: number of features to keep active, or \"end\" (omit = end) | feature: id|name (the marker goes right after it)"),
CommandSpec::new("timeline.reorder", "Reorder Feature", reorder).icon("reorder")
.params("feature: id|name, position: its new index; refused when a feature that worked would fail"),
.params("feature: id|name, position: its new index, active?: bool (true = it ends up before the history marker, false = behind it; other features keep their state); refused when a feature that worked would fail"),
CommandSpec::new("timeline.reorder_many", "Reorder Features", reorder_many).icon("reorder").params(
"features: [id|name], position: insertion slot in the current history; selected features keep their relative order; refused when a feature that worked would fail",
),
@@ -491,7 +491,9 @@ fn move_many(s: &mut Session, p: &Value) -> Result<Value> {
}
sort_group_features(&mut next);
let slot = num(p, "position").map(|x| x.max(0.0) as usize).ok_or_else(|| bad(cmd, "`position` must be a number"))?;
let (next, ordered) = reorder_features(s, next, &moving, slot, cmd)?;
let active = active_param(p, cmd)?;
let (mut next, ordered) = reorder_features(s, next, &moving, slot, cmd)?;
place_marker(s, &mut next, &moving, active, cmd)?;
if next != *s.doc {
*s.doc_mut() = next;
}
@@ -499,10 +501,19 @@ fn move_many(s: &mut Session, p: &Value) -> Result<Value> {
}
fn rollback(s: &mut Session, p: &Value) -> Result<Value> {
move_marker(s, p, "timeline.rollback")
}
fn roll_to(s: &mut Session, p: &Value) -> Result<Value> {
move_marker(s, p, "timeline.roll_to")
}
/// `timeline.rollback` and `timeline.roll_to`, refusing in the name of the one asked for (#4712).
fn move_marker(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
let n = s.doc.features.len();
let pos = match p.get("feature") {
Some(f) => {
let id = feature_id(s, Some(f), "timeline.roll_to")?;
let id = feature_id(s, Some(f), cmd)?;
s.doc.feature_index(id).map(|i| i + 1)
}
// A number of features, or "end" (as omitting it); anything else is refused rather than
@@ -513,7 +524,13 @@ fn rollback(s: &mut Session, p: &Value) -> Result<Value> {
Some(v) => {
let x = v.as_f64().or_else(|| v.as_str().and_then(|t| t.trim().parse::<f64>().ok())).and_then(crate::params::finite);
let why = "`position` must be a number of features to keep active, or \"end\"";
Some(x.map(|x| x.max(0.0) as usize).ok_or_else(|| bad("timeline.roll_to", why))?)
let x = x.ok_or_else(|| bad(cmd, why))?;
// A negative position was taken as 0, rolling the whole timeline back: an agent
// meaning "one step back" by -1 lost every body without a word (#4712).
if x < 0.0 {
return Err(bad(cmd, format!("`position` {x} is negative: it counts the features to keep active, 0 to {n} (or \"end\")")));
}
Some(x as usize)
}
},
};
@@ -1057,7 +1074,8 @@ fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
let n = s.doc.features.len();
let to = num(p, "position").map(|x| x.max(0.0) as usize).ok_or_else(|| bad(cmd, "`position` must be a number"))?.min(n.saturating_sub(1));
let from = s.doc.feature_index(id).ok_or_else(|| bad(cmd, "feature"))?;
if from == to {
let active = active_param(p, cmd)?;
if from == to && active.is_none() {
return Ok(json!({"feature": id, "position": to}));
}
let mut next = (*s.doc).clone();
@@ -1074,10 +1092,39 @@ fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
let list: Vec<String> = broken.iter().map(|(n, e)| format!("{n} ({e})")).collect();
return Err(bad(cmd, format!("moving {name} there breaks {}", list.join("; "))));
}
*s.doc_mut() = next;
place_marker(s, &mut next, &[id], active, cmd)?;
if next != *s.doc {
*s.doc_mut() = next;
}
Ok(json!({"feature": id, "position": to}))
}
/// The optional `active` of a move: whether the moved features end up before the history marker.
fn active_param(p: &Value, cmd: &str) -> Result<Option<bool>> {
match p.get("active") {
None | Some(Value::Null) => Ok(None),
Some(Value::Bool(active)) => Ok(Some(*active)),
Some(_) => Err(bad(cmd, "`active` must be true or false")),
}
}
/// With `active` given, put the history marker of the reordered `next` so the `moving` features
/// are active (before it) or rolled back (behind it) while every other feature keeps its state, in
/// the same step as the move. Refused when `position` puts them on the other side of it.
fn place_marker(s: &Session, next: &mut solidcraft_doc::Document, moving: &[u64], active: Option<bool>, cmd: &str) -> Result<()> {
let Some(active) = active else { return Ok(()) };
let n = s.doc.features.len();
let was_active: std::collections::BTreeSet<u64> = s.doc.features.iter().take(s.doc.marker.unwrap_or(n)).map(|feature| feature.id).collect();
let stays_active = |id: u64| if moving.contains(&id) { active } else { was_active.contains(&id) };
let count = next.features.iter().filter(|feature| stays_active(feature.id)).count();
if !next.features.iter().take(count).all(|feature| stays_active(feature.id)) {
let side = if active { "before" } else { "behind" };
return Err(bad(cmd, format!("`position` is not {side} the history marker, as `active: {active}` asks")));
}
next.marker = (count < next.features.len()).then_some(count);
Ok(())
}
fn reorder_many(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "timeline.reorder_many";
let ids = group_features(s, p, true, cmd)?;

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