Compare commits

..

78 Commits

Author SHA1 Message Date
Brennen Herbruck 5774d27c2f Mesher: flat faces are their boundary alone, not a grid capped at a fifth of the body
A planar face is exact for any triangulation of its boundary, so it needs no interior
cells. The planner capped every face, flat ones included, at a fifth of the body's
extent, so a 20 x 10 x 5 box came out as 124 triangles instead of 12.

A flat face (a plane, or a spline lying in one) now asks for no size. An edge between
flat faces is one segment; an edge beside a curved face still takes the curved face's
size, so both faces use the same nodes and the mesh stays watertight. Curved faces keep
the cap, the aspect limit and the grid limits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 23:04:49 -05:00
bherbruck 1e6b1ec20f STEP import: a surface model of open shells, some closed, beside a turned sheet imports again (#13226)
A SolidWorks 2011 vendor part (a SHELL_BASED_SURFACE_MODEL of three OPEN_SHELLs, two of
them closed, the third using faces against their surfaces) came in as nothing on the SMLib
kernel: "STEP import: no bodies (... not supported yet: SMLib renumbered the solid's faces)".
SMLib's export numbers such a body's edges and vertices otherwise than the reader, so the
face map was refused even though the faces were in the reader's order. Where the full
numbering does not match, the faces are now taken in the record's order when each one is
checked to be the record's face (same surface kind, loops, and corners).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 01:55:43 +00:00
PeppaWutz 4d96c780e1 Agents and scripts: the sketch projection commands describe their exact parameters and reply (#12505) (#12598)
* Agents and scripts: the sketch projection commands describe their exact parameters and reply (#12505)

sketch.project, intersect, include_3d, project_to_surface,
intersection_curve, spun_profile, isoparametric_curve,
fit_curves_to_section, break_link and auto_project now publish typed input
and output schemas. Each parameter is read the way the handler reads it
today and refused with the handler's own message, and the replies are built
from the same types the output schemas describe, so the JSON they return
does not change. Their lines leave unconverted.txt.

The shape check of a reference is split out of the handler's reference
reader so the contract can use it without changing what it accepts.

Part of #2829.

* Sort the schema module list for rustfmt

Made with Claude Opus 5.5 in Claude Code (Ultra plan, default effort, fast mode off, Ultracode off).
2026-11-08 01:45:28 +00:00
github-actions[bot] 52afe212a8 Regenerate generated files and fold pending files [skip ci] 2026-11-08 01:37:34 +00:00
Daniel Dimitrov 0bb09d0b13 Hole: an option to put the holes one Hole command makes into a History folder (#12492) (#12493)
* Hole: an option to put the holes one Hole command makes into a folder (#12492)

Preferences > Tools > Hole > Group holes made together (hole_group, off by
default). With it on, when one Hole OK makes two or more holes, they go
into a feature folder named "Holes" in History, closed at first; opened,
each hole is edited on its own as before. A lone hole and an edit make no
folder, and holes added inside a folder get their folder there.

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

* Hole: a new Holes folder starts folded through the History's fold state

Since the History keeps each design's folds (#8886), fold the new folder
with timeline_folders::set_open instead of the browser's collapsed set.

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

* Hole: add several placements on one face

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 00:59:31 +00:00
PaddleStroke 11abdba1ba Export test: a box exports as its grid mesh (124 triangles), not 12 (#12781)
* Export test: count the box's grid mesh, not 12 triangles

each_format_has_an_export_command_of_its_own asserted a 20 x 10 x 5 box
exports as 12 triangles (an 684-byte STL). The mesher lays a planar face
out as a regular grid with sides at most a fifth of the body's diagonal
(kernel test a_box_is_a_regular_grid_on_every_face), so the box is 124
triangles and the STL 6284 bytes, on Linux and Windows alike. The test now
takes the count from the body's export mesh and checks the STL header,
the file size and the OBJ's faces against it; its line leaves
tests/KNOWN_FAILING_TESTS.

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

* Export test: expect the box's grid count, worked out from the mesher's rule

Review: a count taken from export_mesh would follow an export that drops
triangles. The test now expects 124 from the grid rule (cells at most a
fifth of the diagonal: 5 x 3 x 2 along 20 x 10 x 5).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 00:35:56 +00:00
PeppaWutz 7c9c1699de Agents and scripts: Inspect Document, Measure, Inspect Sketch, the command list and the material commands describe their exact parameters and reply (#12498) (#12591)
document.inspect, engine.commands, inspect.measure, material.assign,
material.list and sketch.inspect now publish typed input and output schemas.
Each parameter is read the way its handler reads it today, and is refused with
the handler's own message; inspect.measure reads only `items` when it is given
and `bodies` and `names_only` otherwise. The replies are built from the same
types the output schemas describe, so the JSON they return does not change.
Their lines leave unconverted.txt.

Part of #2829.
2026-11-08 00:04:33 +00:00
PeppaWutz cf9ae4d25e Agents and scripts: Auto Constrain, the constrainer, snapping and the curvature and polygon constraints describe their exact parameters and reply (#12506) (#12599)
* Agents and scripts: Auto Constrain, the constrainer, snapping and the curvature and polygon constraints describe their exact parameters and reply (#12506)

sketch.auto_constrain, sketch.auto_constrain_from_datum,
sketch.finish_auto_constrain, sketch.constrainer, sketch.constraint.curvature,
sketch.constraint.polygon, sketch.toggle_driven, sketch.glyphs and sketch.snap
now publish typed input and output schemas, so agents and scripts can read
exactly which parameters they take and what they return, and the warn and
strict parameter checks cover them.

Each parameter is read the way its handler reads it today, with the same error
messages. The replies are built from typed values and serialize to the same
JSON as before. Their lines leave unconverted.txt. Part of #2829.

* Drop constraint_result, which no command uses any more

Made with Claude Opus 5.5 in Claude Code (Ultra plan, default effort, fast mode off, Ultracode off).

* Check the coincident snap's point id in the reply test

Made with Claude Opus 5.5 in Claude Code (Ultra plan, default effort, fast mode off, Ultracode off).
2026-11-08 00:04:16 +00:00
PeppaWutz 1f29f432ee Agents and scripts: the 3D sketch commands describe their exact parameters and reply (#12507) (#12600)
sketch.line3d, sketch.spline3d, sketch.point3d and sketch.move3d now
publish typed input and output schemas. Each parameter is read the way
the handler reads it today and refused with the handler's own message,
and the replies are built from the same types the output schemas
describe, so the JSON they return does not change. Their lines leave
unconverted.txt.

Part of #2829.
2026-11-08 00:04:04 +00:00
PeppaWutz dc679cae34 Agents and scripts: the parameter commands and Evaluate Expression describe their exact parameters and reply (#12497) (#12590)
parameters.change, list, users, graph, export, import, complete and
expr.evaluate now publish typed input and output schemas. Each parameter is
read as its handler reads it today, with the same error messages; keys a
handler only reads in some forms (changes, delete, favorites, text, a text
unit) are checked only in those forms. Each reply is built from the typed value
and serializes to the same JSON as before. Their lines leave unconverted.txt.
2026-11-07 22:46:30 +00:00
PeppaWutz dbbfa437a5 Agents and scripts: the tangent arcs and circles, slots, splines, conics, ellipses and sketch text describe their exact parameters and reply (#12550) (#12604)
sketch.arc.tangent, sketch.blend_curve, sketch.centerline,
sketch.circle.two_tangent, sketch.circle.three_tangent, sketch.conic,
sketch.edit_text, sketch.ellipse, sketch.infinite, sketch.line.midpoint,
the three slot commands that were missing, the three spline commands
and sketch.text now publish typed input and output schemas.

Each parameter is read the way its handler reads it today and refused
with the handler's own message. Keys a handler reads only in some cases
(diameter without a radius, end without a sweep, minor without a minor
radius, the keys of one text outside edits) are checked only in those
cases. The replies are built from the same types the output schemas
describe, so the JSON they return does not change. Their lines leave
unconverted.txt.

Part of #2829.
2026-11-07 22:46:03 +00:00
bherbruck ba43d2798f Docs: RFCs 0001 (Craft bridge) and 0002 (plugins) (#13194)
* docs: RFC 0001, the Craft bridge between SolidCraft, CamCraft and PCBCraft

Starts docs/rfcs/ for cross-app proposals. RFC 0001 is a draft: one
.craftx exchange file with stable face names, an open-in launch, file
watching for live links, and a phased plan starting with Send to CamCraft.

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

* docs: RFC 0002, plugins

Turns the design in #1221 and docs/plugins.md into an RFC. docs/plugins.md
becomes a stub pointing to it; the two links to it are updated.

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

* docs: keep the RFC change docs-only

docs/plugins.md stays as a pointer to the RFC, so the code comment needs no edit.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 23:05:23 +00:00
MarkusRe 6a1fe74683 Sketch Tangent: a line's end point and a curve make a tangent joint (#12092) (#12377)
* claim: #12092

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

* Sketch Tangent: a line's end point and a curve make a tangent joint (#12092)

Tangent took two curves only and refused a picked end point with
"unknown sketch curve l1.end". It now takes a line's or an arc's end
point with a curve: the end lies on the curve (point on curve) and the
two curves are tangent there, in one undo step. Two end points make the
ends coincident and the curves tangent. Only the picked end moves, so
the line turns about its other end and gets longer or shorter; the
other curve stays.

Parallel, Perpendicular, Equal and Collinear take no point, so a click
on a line's end point now picks the line for them.

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

* Sketch Tangent: at a joint, add only the tangent (#12092)

When the picked end already lies on the other curve (it is that
curve's end, or a coincident joins them), Tangent added a redundant
point-on-curve, and a coincident given in the other order came back as
the existing constraint but was reported as new. Now such a constraint
counts as held and is not added. The end can't leave the other curve
at a joint, so the curve it ends stays and the other curve moves.

Raised in review by CodeRabbit.

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

* Sketch Tangent: x and y name the axes before a point does (#12092)

Tangent looks for an end point only when an operand names no curve and
no sketch axis. It checked for an existing axis line, so before that
line was created a point called x or y took the axis's place. Now x and
y always name the axes, as for the other constraints that take a line;
a curve with that id still wins.

Raised in review by CodeRabbit.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 22:08:10 +00:00
github-actions[bot] a9ff55ef27 Regenerate generated files and fold pending files [skip ci] 2026-11-07 22:31:45 +00:00
github-actions[bot] 3081630f92 Regenerate generated files and fold pending files [skip ci] 2026-11-07 22:17:47 +00:00
Daniel Dimitrov 323defa525 History: an option for a folder of one kind of feature to show that kind's icon (#12514) (#12515)
* History: an option for a folder of one kind of feature to show its icon (#12514)

Preferences > Layout > Folder icon from its features
(history_folder_kind_icon, off by default). With it on, a History folder
whose features (including those in folders inside it) all have the same
icon, and that holds no joints, shows that icon instead of the folder
icon, keeping its count; in both the History bar and the side History.

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

* Preferences: Folder icon from its features in a row of its own

One more box in the row of History and browser options widened the
Layout page past its edge at narrow windows, pushing the navigation
bar's segments outside it
(navigation_bar_appearance_segments_stay_inside_the_page_at_narrow_widths).
Also give the new messages in Korean.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 20:39:38 +00:00
PeppaWutz 5eaf8c4181 Agents and scripts: the named view commands and Redefine Sketch describe their exact parameters and reply (#12499) (#12592)
view.save, view.apply, view.rename, view.delete, view.list and
sketch.redefine now publish typed input and output schemas. Each
parameter is read the way the handler reads it today and refused with
the handler's own message, and the replies are built from the same
types the output schemas describe, so the JSON they return does not
change. view.apply only looks at the camera when no view name is given,
as the handler does. Their lines leave unconverted.txt.

The camera checks of view.save and view.apply, and the note that
sketch.redefine adds for a world plane in a moved component, are split
out of the handlers so the contracts use the same code.

Part of #2829.
2026-11-07 19:58:45 +00:00
PeppaWutz b464c48506 Agents and scripts: Frame Member and the profile library commands describe their exact parameters and reply (#12500) (#12593)
frame.member, frame.profile.insert, frame.profile.list and
frame.profile.properties now publish typed input and output schemas. Each
parameter is read the way its handler reads it today and refused with the
handler's own message, and frame.member reads the profile-sketch keys only
when the handler does. The replies are built from the same types the output
schemas describe, so the JSON they return does not change. Their lines leave
unconverted.txt.

Part of #2829.
2026-11-07 19:58:36 +00:00
PeppaWutz 7a2f2949a7 Agents and scripts: Copy and Paste Features, Add Recorded Feature and Export Command Script describe their exact parameters and reply (#12502) (#12595)
timeline.copy, timeline.paste, timeline.add and timeline.export_script now
publish typed input and output schemas, so agents and scripts can read
exactly which parameters they take and what they return, and the warn and
strict parameter checks cover them.

Each parameter is read the way the handler reads it today, with the same
error messages, and the replies are built from typed values that serialize
to the same JSON as before. Their lines leave unconverted.txt. Part of #2829.
2026-11-07 19:58:27 +00:00
PeppaWutz fcd4ccd09a Agents and scripts: the component commands describe their exact parameters and reply (#12504) (#12597)
component.activate, create, delete, from_bodies, list, properties, rename
and unlock now publish typed input and output schemas. Each parameter is
read the way its handler reads it today and refused with the handler's own
message, and each reply is built from the typed value and serializes to the
same JSON as before. Their lines leave unconverted.txt. Part of #2829.
2026-11-07 19:58:18 +00:00
PeppaWutz beaa43cb40 Agents and scripts: Create, Delete, List, Inspect, Limits, Drive, Solve and As-Built Joint describe their exact parameters and reply (#12503) (#12596)
* Agents and scripts: Create, Delete, List, Inspect, Limits, Drive, Solve and As-Built Joint describe their exact parameters and reply (#12503)

joint.as_built, joint.create, joint.delete, joint.drive, joint.inspect,
joint.limits, joint.list and joint.solve now publish typed input and output
schemas. Each parameter is read as its handler reads it today, with the same
error messages, in the same modes, and each reply is built from the typed
value and serializes to the same JSON as before. Their lines leave
unconverted.txt. Part of #2829.

* Joints: a joint origin's points take {x, y, z} in the schema too, as the handler reads them
2026-11-07 19:41:10 +00:00
PeppaWutz 6085423d0d Agents and scripts: the exploded view commands describe their exact parameters and reply (#12501) (#12594)
explode.create, explode.delete, explode.list and explode.show now publish
typed input and output schemas. Each parameter is read as its handler reads
it today, with the same error messages (scale is only read when moves is
not given, and show treats a missing or null view as "none"), and each reply
is built from the typed value and serializes to the same JSON as before.
Their lines leave unconverted.txt.
2026-11-07 19:23:14 +00:00
Loohi 4425b87d2b Typed schemas for Line, Point, Circle and Arc (#12953) (#12955)
* claim: #12953

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

* Typed schemas for Line, Point, Circle and Arc (#12953)

sketch.line, sketch.point, sketch.circle.center, sketch.circle.two_point,
sketch.circle.three_point, sketch.arc.three_point and sketch.arc.center_point
get typed input contracts and output schemas in schema/sketch_shapes.rs, and
leave unconverted.txt. The replies keep the same JSON.

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

* Blend test: give the chord line its id with `ids`

sketch.line takes `ids`; the `id` it was given was ignored, which the
parameter audit flags as a call strict would refuse.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 18:46:23 +00:00
filippecha 59d032659f Czech: translate the 748 missing UI strings, so the Czech interface has no English left (#12961)
* Czech: translate the 748 missing UI strings and fix 3 untranslated ones

The Czech catalog now covers every English message. New translations
go in assets/locales/pending/czech-complete.json; three messages that
were left in English in cs.json (Zoom, Polygon, Start) are translated
in place.

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

* Czech: name the render methods like their labels (Trasování paprsků CPU, Trasování cest GPU) and fix the direction of the graphics page subtitle

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 18:13:31 +00:00
Albert Shambazov 395679015f IO: sketch.export_dxf - units, layers and joined LWPOLYLINE loops (#12725) (#12726)
Opt-in options for sketch.export_dxf: units (scale + $INSUNITS), layer /
layers:"roles" (OUTLINE / CUT), join:true (closed LWPOLYLINE with arc
bulges, R2000). Default output stays byte-identical R12.

Co-authored-by: Albert Shambazov <319170918+shambazovalbert-art@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:48:25 +00:00
PaddleStroke 30be21a224 Browser: list the features that built each body under it, as an option (#11945) (#12709)
* claim: #11945

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

* wip: body features data, model.body_features and the Browser's Features under bodies (#11945)

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

* wip: fmt; engine body features test passes (#11945)

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

* kernel-cases: model.body_features is a listing with no kernel case

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

* Browser: a feature listed under two bodies is a Shift-click range end at each of its rows (#11945)

Each feature row under a body takes its own row-order key, <body row>/s:<id>, which still selects the feature, so a Shift-click on its second row no longer starts the range at its first row under another body.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:46:29 +00:00
bherbruck cd1685035a Release 0.40.0-rc.3 (#13144)
* Release 0.40.0-rc.3

Bumps the version to 0.40.0-rc.3 and folds the 15 pending format changes into format 51.

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

* Doc test: check the saved texture resource by its key, not the pending format name

Folding embedded-surface-textures into format 51 stops the design naming it.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 20:32:47 +00:00
bherbruck f472ee1a92 Start generated release notes at the newest published release (#13114)
* Start release notes at the newest published release

The nightly release job has failed since Nov 4 with HTTP 422 "body is too long": generated notes
started at the Latest stable release, v0.30.1, and grew past 125,000 characters. Start them at the
newest published release instead, pre-releases included. Let a refused Snap Store upload stop
failing the job, after the release is already staged.

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

* Pick the start tag by publication time; leave the Snap Store step as it was

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 20:03:17 +00:00
github-actions[bot] 0160e67ea1 Regenerate generated files and fold pending files [skip ci] 2026-11-07 20:15:34 +00:00
Steve Jason c29f396f72 Expressions: floor and ceil of 3 in / 1 in are 3, not 2 (#12489)
* Expressions: floor, ceil and trunc ignore float noise from unit conversion

* Expressions: snap only a few ulps to a whole number in floor and ceil

* Expressions: never snap a tiny value to zero in floor and ceil
2026-11-07 17:38:56 +00:00
Daniel Dimitrov 46edecca67 History: Edit Holes on a folder edits all its holes at once (#12520) (#12521)
A History folder that holds only holes (with the folders inside it) gets
Edit Holes in its right-click menu, in the History bar and the side
History, and over the control channel as ui.editFolder. It opens one
Hole dialog filled in from the first hole, with nothing to pick; OK
redefines each hole with the fields changed there, and each hole keeps
its own place and everything not changed.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:38:15 +00:00
Igor Bronovskyi a36c1743dc Ukrainian: translate 523 missing messages and four toolbar labels (#12566)
Fill the gaps in the Ukrainian catalog with a pending locale file, and translate the Appearance, Camera, Environment and Lighting toolbar labels.
2026-11-07 17:38:05 +00:00
PaddleStroke 7b3ca00054 Measure: two flat faces always show the distance between them (#11833) (#12708)
* claim: #11833

* wip: Measure: exact plane-to-plane distance with real ends (#11833)

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

* Measure: datum planes' meeting point from the cross product, exact near parallel

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

* Measure: the 1 and 2 end numbers never cover each other or the distance (#11833)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:37:16 +00:00
PaddleStroke 91a06a546e Sketch: replace a sketch's contents with another sketch, from this design or another file (#11125) (#12712)
* claim: #11125

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

* wip: sketch.replace command, tests and Browser menu entry (#11125)

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

* wip: menu test, formatting (#11125)

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

* kernel-cases: sketch.replace is 2D sketch only

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

* Replace Sketch: new ids never reuse the old contents' ids; a tool's picks on the replaced sketch are dropped

A reference to an old curve (a profile picked by its curves, a sweep path) now finds nothing and
the feature reports it, instead of quietly binding to a new curve that got the same id. Replacing
the sketch being edited clears the active tool's picks.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:36:45 +00:00
PaddleStroke fd02123b3e Sketch: a sketch that lost a projected edge opens for editing with its curves and the lost link marked (#12085) (#12714)
* claim: #12085

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

* Sketch: a sketch that lost a projected edge by name opens for editing with its curves and the lost link marked (#12085)

The sketch still fails and features built from it still fail; while it is
edited, the model holds it built with the lost named link marked lost, in the
final state only, so it can be repaired from inside.

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

* Sketch: a failed sketch held for its editor is built again on every evaluation (#12085)

When the sketch and every feature after it fail, the results end in the
same state as before an edit, so the held sketch kept its old geometry.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:36:35 +00:00
Mek 13ab728d07 Browser: Edit Feature from a component's Features folder gives the active component back (#12720) (#12723)
* claim: #12720

* Browser: Edit Feature from a component's Features folder gives the active component back (#12720)

A double-click on a feature row inside a placed component activates that
component and opens Edit Feature, so the edit happens in its placement.
OK and Cancel only closed the editor: the component stayed active, every
other body stayed faded and the History bar stayed filtered on it.

The double-click now remembers the component and placement active before
it, and the end of that feature's editor, however it ends, makes that one
active again, as leaving a sketch opened the same way does (#8127). A
component activated while the editor is open stays active.

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

---------

Co-authored-by: Mek <mf@linescode.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-07 17:36:25 +00:00
PaddleStroke 4a07865288 Brazilian Portuguese: translate the 3720 messages that still showed in English (#11760) (#12697)
* claim: #11760

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

* i18n(pt-BR): translate 1594 missing messages (labels, commands, ui)

Refs #11760

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

* i18n(pt-BR): translate 405 missing preference messages

Refs #11760

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

* i18n(pt-BR): translate 309 missing command help messages

Refs #11760

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

* wip: i18n(pt-BR): translate 600 of 1412 missing render messages

Refs #11760

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

* wip: i18n(pt-BR): translate 1000 of 1412 missing render messages

Refs #11760

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

* i18n(pt-BR): translate the remaining missing render messages

Refs #11760

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:27:34 +00:00
CamWight b73d6e6e6f Render: the crate's tests compile without the raytrace-cpu feature (#12535) (#12542)
The test helper mapped() in job/host_tests.rs reads a texture's encoded
bytes through TextureMap::source() and TextureSource::encoded_weak(), which
exist only with raytrace-cpu, but it wasn't gated, so the render crate's
tests failed to compile when it was checked on its own. The helper and the
two registered-executor tests that call it now take the cfg the helper's
other callers and its sibling cpu() already have. Workspace builds, which
turn the feature on, run all of them as before.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:27:26 +00:00
Cyryl Sochacki 3b2ca62c86 i18n(pl): translate the render workspace and 1290 missing UI messages (#12734)
* i18n(pl): translate the render workspace (1412 messages)

Every render.* message of en.json: output, lighting, environment, stage,
studios, appearance editor, lens effects, aging, shots and the render
browser. Added as one pending locale file.

* i18n(pl): translate 1290 missing UI messages

Everything else en.json gained since the last catch-up: Preferences,
tool help texts, panels, construction lines, fillet options, import
report, personal sheet metal rules, joint limits and recovery. With the
render file, pl covers every message of en.json.

* i18n(pl): "Of" picks a joint's motion, not an object

The nested Of row in the motion study and motion trace dialogs chooses
which of a joint's motions (turn or slide) the track drives.
2026-11-07 17:27:14 +00:00
ashxddaga 24c7bb7d70 fix(doc): permit circular edge centered on axis in axis_point plane construction (#12851) (#12882)
`cmd.construct.plane.axis_point` failed with "the point is on the axis"
when the user selected a circular edge whose center lies on the construction
axis (e.g. rim of a cylinder, revolve, or torus). This occurred because
`point()` resolved `GeoRef::Edge` to its center, which is collinear with
the axis of revolution.

Resolve the edge pick to the nearest point on the edge polyline, projecting
onto the exact circle geometry via `kernel::edge_circle` when available,
enabling unique planes through the axis at any picked azimuth around the rim.

Changes a default: no
Fixes #12851
2026-11-07 17:26:07 +00:00
bherbruck 670636ecd6 Update the solidcore kernel to main a445bdaac6 for rc.3 (#13015)
* Update the solidcore kernel to main 99ed57c4d4 for rc.3

The pin and its API changes, nine retired flags dropped, chamfers through
prepare_chamfers_along at the sizes asked, the whole-body section join, the
test and list updates this pin needs.

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

* Format crates/smlib/src/core/blend.rs

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

---------

Co-authored-by: bherbruck <bherbruck1@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 19:06:44 +00:00
github-actions[bot] 3623466acf Regenerate generated files and fold pending files [skip ci] 2026-11-07 19:19:41 +00:00
dudedagger42 ecbd05ef5e STEP import: an edge on a CURVE_REPLICA reads as its parent moved (#10755) (#12915)
* STEP import: tests for an edge or curve on a CURVE_REPLICA (#10755)

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

* STEP import: read a CURVE_REPLICA as its parent moved by the transformation (#10755)

An edge whose curve is a CURVE_REPLICA (FreeCAD writes one for a repeated
edge) was refused as an unsupported curve, leaving the body open. The reader
now keeps the parent and the CARTESIAN_TRANSFORMATION_OPERATOR_3D; edges use
the parent moved and scaled as the same kind of curve; the writer round-trips
it. A mirroring operator and a replica as a swept profile stay unsupported.

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

* STEP import: read CARTESIAN_TRANSFORMATION_OPERATOR_3D with its seven parameters (#10755)

The entity has a name and a description before its axes, origin and scale.

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 17:22:00 +00:00
MarkusRe c1bd94e937 Sketch Intersect: a cut through small rounds stays one closed profile (#12722)
* Sketch Intersect: test that a round refitted to the exact section still meets its neighbours

A failing test first. The section fit re-fits each arc to the exact crossings and moves only the
arc's own ends onto the new circle, so the lines beside it no longer meet it and the sketch finds
no closed profile (tangency-labs/solidcore#2908, point 1). `refine_rounds` wraps the per-curve
refit unchanged, so the test can call it.

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

* Sketch Intersect: a round refitted to the exact section keeps meeting the lines beside it

The section by a plane fits lines and arcs to the mesh crossings, then re-fits each arc to the
crossings that lie exactly on the surface. That refit moved only the arc's own ends onto its new
circle, so a wall beside an R2 round ended up to 1.2e-3 mm away from it, and the sketch, which joins
ends only within 1e-7, found no closed profile. A line meeting an arc's end now moves with it; an
arc that meets another curve there keeps its fit.

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

* Sketch Intersect: the refit test's crossings step by pi/16 with a constant that exists

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

* Sketch Intersect: the refit test names the distance a profile joins ends within (1e-4)

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

* Sketch Intersect: test that a round beside another arc is refitted when their joint stays put

A failing test first, for CodeRabbit's point on #12722: the fix kept the first fit of every arc
that meets another arc, even where the refit leaves their joint where it was. Before the fix such
a round was refitted, and that more exact result should stay.

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

* Sketch Intersect: a round beside another arc is refitted unless that tears their joint

The refit now runs for every arc. Lines that met its ends move with them, as before. Where an end
meets another arc, the refit stands when that end moves no further than the fit's join distance
(1e-7 of the size). Otherwise the arc keeps its first fit. So a round that was refitted before
tangency-labs/solidcore#2908 still is, unless the refit would open the outline.

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

* Sketch Intersect: test that a round beside another arc is refitted while its joint moves less than a profile joins across

A failing test first, for CodeRabbit's follow-up on #12722: with the joint moving 1e-5 to 9e-5 mm
the refit closed the profile before the fix, but the fix kept the first fit, because it guarded
the joint with the fit's join distance (1.4e-6 mm here) instead of the profile's 1e-4 mm.

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

* Sketch Intersect: a round beside another arc keeps its refit while their joint stays within a profile's join distance

The guard on a joint between two arcs now asks whether the refitted end stays within
ENDPOINT_JOIN_TOL (1e-4 mm, the distance a profile joins ends across, now public in
solidcraft-sketch) of where the other arc ends now, which may itself be refitted. Before, it asked
whether the end moved more than the fit's join distance (1e-7 of the size), which kept back refits
the profile would have closed across.

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

* Sketch Intersect: test that two arcs refitted the same way keep their joint

A failing test first, for CodeRabbit's next point on #12722: when a round and the arc beside it
both miss their circles by the same shift, both refits move their joint to the same point. Before
the fix both were refitted. bd7db2626 compares each refit with the other arc as it was fitted, so
past 1e-4 it keeps both first fits.

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

* Sketch Intersect: two arcs refitted the same way keep their refits and their joint

refine_rounds now refits every arc first, then takes refits back where two arcs met as fitted and
their ends, as they would now stand, are further apart than a profile joins ends across. It
repeats until no joint tears. So two arcs whose refits move their joint the same way both keep
them, and an arc is only kept as fitted where a refit would open the outline.

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

* Sketch: ENDPOINT_JOIN_TOL's comment says it bounds the search for joined ends, not that ends within it always join

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

* Sketch Intersect: the two-arc refit test checks that the whole outline stays closed

At e4c2545f8 the lines after an arc lost their move to the refitted arc's end, which the test
missed because it only looked at the joint between the arcs.

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

* Sketch Intersect: lines after a refitted arc keep their move to its ends

refine_rounds re-set every non-arc piece from its fit after the arcs were done, which undid the
move of each line that comes after the arc it meets. Only circles are refitted there now.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:19:22 +00:00
PaddleStroke 28fed4d6dd Sketch: a projected face follows its body when the body is resized (#12086) (#12732)
* claim: #12086

* wip: a projected face tells a thin arm's end corners apart by the sense of their lines (#12086)

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

* Sketch: lines leaving a projected face's corner match one to one (#12086 review)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:19:06 +00:00
PaddleStroke be648ef742 Sketch: a spline ending on another spline between its fit points closes a selectable profile (#12324) (#12774)
* claim: #12324

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

* Sketch: a curve ending on a spline between its samples closes the regions it bounds (#12324)

Free-form curves take part in profile detection as polylines with a 1e-3
chord tolerance, while curve ends are joined within 1e-5. A spline (or any
curve) ending on a spline between two samples, on the side away from the
chord, ended short of the polyline and was pruned as dangling, so the
regions it closes were not selectable. The polyline now passes through every
curve end lying on the exact segment.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:17:52 +00:00
DuBearte a8d775c12a Home: a tile keeps its title in view when its caption wraps (#7483) (#7716)
A tile on the Home page centres its title and a caption of up to two
rows. When the two are taller than the tile (a system font with tall
lines, such as the Chinese font on macOS), centring put the title's top
above the tile, where it was clipped. They now start at the top of the
text area instead, so the caption's end is clipped rather than the
title.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:17:38 +00:00
rlartot e02e1d40b5 Extrude: a profile still found after a sketch line cuts off a sliver or splits an offset curve (#9368) (#9563)
* claim: #9368

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

* Extrude: a profile still found after a sketch line cuts off a sliver or splits an offset curve (#9368)

A picked profile of lines, arcs and circles no longer fails when a line ending on
a circle gives it more arcs, and an analytic offset's loop that a line now cuts
falls back to its pieces (step 1 only). Free curves and free-form offsets keep
their exact meaning.

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 17:17:20 +00:00
PaddleStroke 136b62fa47 Dynamics: joint springs, motors and motion links (refs #9719) (#9768)
* claim: joint springs, motors and motion links (refs #9719)

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

* Dynamics: joint springs, motors and motion links (refs #9719)

Joint spring-dampers and motors act along joint values (revolute, slider, cylindrical and planar) as generalized forces; drives make a joint value follow a time profile (constant, linear, cycloidal ramp, harmonic, table) as a rheonomic constraint and report the effort it takes; motion links tie two values linearly (gear, rack and pinion, screw). Values they use are tracked in the state so angles count whole turns. The energy now carries the work put in (motors, drives, loads) and the energy dissipated, so total + dissipated - input stays constant. Integrators pass time to the stages (Dormand-Prince nodes).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:17:10 +00:00
phanoob a2c4a1267b Measure: the area of a flat face with holes comes from its exact edges (#12373)
* claim: #12372

* Measure: area of a flat face from its exact edges, less its holes

A flat face's area was taken from the display mesh. The mesh's polygon for a
round hole runs inside the circle, so the holes came out too small and the face
too big (2293.875 mm² instead of 2293.186 mm² for a 60 × 40 plate with holes of
radius 3 and 5).

The area of a flat face is now worked out along the exact edges of each loop
(Green's theorem): a line exactly, a circle or an arc in closed form, any other
curve with Gauss quadrature. The face's area is its outer loop's area less the
areas of its holes. When an edge does not lie in the face's plane, Measure falls
back to the mesh as before.

Fixes #12372

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

* Measure: a flat face's straight edges must lie in its plane; test an elliptic hole

A straight edge whose ends are off the face's plane now sends Measure back to
the mesh, as an off-plane curve already did.

A new test cuts an elliptic hole through a plate. Its edge is a spline, so it
goes through the Gauss quadrature path, on both faces. Without that path the
face read 2024.696 mm² from the mesh; it now reads πab within 10⁻² mm² (the
kernel's spline for the ellipse encloses 2·10⁻³ mm² more than πab).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:16:24 +00:00
PaddleStroke 735688dbc5 Render tests: two CPU raytracing tests skip when the crate is tested without raytrace-cpu (#12780)
* Render tests: skip two CPU raytracing tests when raytrace-cpu is not compiled in

cpu_atmosphere_pointer_fields_preserve_saved_camera_and_roundtrip_with_one_undo_each
and live_image_imported_mesh_keeps_selected_and_hovered_face_and_body_feedback
need CPU raytracing, which solidcraft-ui-egui only gets through feature
unification with the apps in a workspace build. Run on their own
(cargo test -p solidcraft-ui-egui) they failed. They now skip the same
way as their neighbours (live_cpu_fixture and the other CPU tests).

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

* Render tests: the CPU Atmosphere pointer-field test is native only, like its neighbours

On wasm the platform CPU host is the cooperative one, which never accepts
CPU raytracing, so the test could only ever skip there.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:16:16 +00:00
Daniel Dimitrov 0debd9759f Hole: an option to place several Single holes on one face (#12480) (#12481)
Preferences > Tools > Hole > Several holes on one face (hole_several, off
by default). With it on, in Single placement a pick elsewhere on a face
that already has a hole adds another hole, and a pick on a hole takes that
one out; each hole keeps its own drag handle, and OK makes one Hole
feature per hole. A round face split at its seam still gives one hole per
pick. Off, a face takes one Single hole as before.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:15:47 +00:00
austin62jake 2c6230eafc Agents and scripts: Render Settings and Save Render Settings describe their exact parameters and reply (#12783) (#12784)
* claim: #12783

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

* Render settings: input and output schemas for Render Settings and Save Render Settings (#12783)

Typed contracts and reply types for render.settings.get and
render.settings.set in a new schema/render_settings.rs, wired with
checked!, .contract and .output; the two leave schema/unconverted.txt.
The handlers build their replies from the new types through typed_reply;
the JSON is the same.

SETUP_FIELDS lists RenderSetup's 15 fields once. Save Render Settings'
schema and HDR capture's schema both publish it, and patched_settings
takes its bound from its length, so they can't drift: HDR capture's
schema had kept 13 as its bound and listed 14 fields without `coats`,
refusing patches the handler takes. A test checks the list against the
fields serde names for RenderSetup.

`name` reads with the setup name reader Apply Render Camera's contract
uses (now visible to the module beside it). Save Render Settings' `name`
and `set` are optional in the type and required in the schema, so a call
without them keeps serde's "missing field"; `null` and wrong types are
refused with the handler's message, and their published `null` default
is dropped (drop_default, also made visible).

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

* Save Render Settings: the check refuses a call without name or set

`name` and `set` were optional in SetParams, so the check passed a call
without them and the handler refused it with serde's "missing field".
They are required fields now: the check refuses the call itself, with
"`name` is required" or "`set` is required", and the published schema
lists both as required (no `default: null`, so drop_default is private
again in render_camera.rs).

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-07 17:15:36 +00:00
Homero Thompson 850509120b Thread: a modelled thread on one hole breaks the next hole (#11367) (#12491)
A modelled thread used to cut the whole body as facets: the Thread feature
swept the groove as a mesh and cut it with faceted_boolean at a tolerance of
a two-hundredth of the pitch, and faceted_boolean faceted the body operand
too. Every other cylinder in the body came out of that cut as a stack of
small planar faces, so the next hole's wall was no longer a cylinder and
thread_on refused it with "no cylindrical face there to thread".

The cut is exact now (the groove is an SMLib screw sweep cut by the exact
B-rep boolean), and faceted_boolean no longer facets the body. Both came
with the SMLib kernel merge, 34 minutes after the issue was filed, and the
reporter and the maintainer both reproduced on 0.30.1, which predates it.

This pins the behaviour down with tests, at the two layers the bug was
reported through: the model layer (the other hole's wall is still a cylinder
to thread) and the CLI (a modelled M6 thread in one hole of a block with two
holes, then a thread in the second, at gaps of 40, 20, 12 and 8 mm).

Fixes #11367

Co-authored-by: vdirienzo <vdirienzo@users.noreply.github.com>
2026-11-07 17:15:31 +00:00
PaddleStroke 72d2beff71 UI tests: clear each frame's texture changes so they pass with debug assertions (#12779)
Six ui-egui tests dropped an egui frame output without clearing its
texture changes. epaint debug-asserts that a dropped TexturesDelta is
empty, so a plain `cargo test -p solidcraft-ui-egui` (debug assertions
on) failed them; CI's `ci` profile turns debug assertions off and never
saw it. Each test now clears the texture changes right after the frame,
as the rest of the crate's tests do.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:15:24 +00:00
Homero Thompson 5395387dad Split Body: a body of two separate pieces splits through both (#6738) (#12511)
A body of two pieces joined apart could not be split by a plane through
both of them: the split stopped with "the tool does not split the body".

The split keeps only the non-empty halves the kernel's half-space cut
gives it, and refuses when fewer than two come back. For a body in two
pieces the half-space cut of one piece came back empty, so fewer than two
halves came back and the split was refused.

The behaviour is already right in the tree: the SMLib kernel merge
ea772a043 (#11351) brought it. This commit pins it down with tests, at the
two layers the bug was reported through, so it cannot come back:

* crates/doc/src/tests.rs - a_split_of_a_body_of_two_pieces_gives_the_halves_of_both
* crates/engine/src/tests.rs - split_body_of_a_body_of_two_pieces_splits_both

The engine test is the reporter's and the maintainer's scenario: one
sketch with two squares 20 mm across and 30 mm apart, both extruded 10 mm
as New Body, then Combine (join), then a plane through the middle of both
squares.

Fixes #6738

Changes a default: no
2026-11-07 17:15:11 +00:00
Daniel 0e83a0e494 Refusals: five windows drop a refused action's message once its inputs change (#12576)
* Parameters: a refused new parameter's message goes once the draft is corrected

In Parameters, adding a parameter whose name is taken, or whose expression does not evaluate, showed the reason in the window's banner. The banner stayed after the name or the expression was corrected, until the next Enter, beside an expression cell that already said the draft was fine.

The add row now keeps the draft it was refused with (name, unit, expression, comment), the design document the refusal was about and the message, as a command window does for a refused OK (#641). Each frame the window drops the message once the draft differs or the document is another one; a selection or display change keeps the document and so keeps the message. The refusal lives apart from the row edits' messages: the banner shows the last action's message if there is one, else the refusal. Discarding the row takes the message with it. The add row's bin button publishes the scenario handle param:new:delete.

Eight asserts read the banner through ParamsDialog::banner() instead of the error field. Regression tests: correcting_a_refused_new_parameter_clears_its_error and seven more in params_dialog.rs cover the corrected draft, a discarded row, a refusal on leaving the row, a design change, a row's own message beside the refusal, a selection, a unit or comment edit, and the banner's order.

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

* Design Properties: a refused OK's message goes once the fields change

In File, Design Properties, a value the design refuses (a part number with a control character, for example) keeps the window open with the reason. The message stayed after the field was corrected, until the next OK.

The window now keeps a snapshot of the fields at the refused OK, as a command dialog does for its inputs (#641). Each frame it compares the fields with the snapshot; once they differ, the message goes, also on an edit to another field, and the next OK checks again. The regression test the_window_edits_properties_in_one_undo_step_and_keeps_other_tools_data covers the corrected field, an edit to another field and the repeated refusal.

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

* BOM panel: a refused Apply's message goes once the row or its fields change

In the Bill of Materials window, edit a part number to one the design refuses and click Apply: the message shows. Correct the field, or pick another row: the message stayed until the next Apply. BomPanel::apply set the error on a refusal and nothing cleared it while the window was open.

The panel now keeps the design revision, the row and the fields Apply ran with and drops the message as soon as any of them differs, as a command dialog's refused OK does (#641); the next Apply checks again. The revision is part of the snapshot because the row index counts the design's rows, which an undo or a part added elsewhere can change under the message. The regression test a_refused_apply_keeps_its_message_until_the_row_or_its_fields_change covers the corrected field, another field, another row, an undo and the repeated refusal.

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

* Material editor: a refused Save's message goes once the draft changes

New material, name it Steel (a built-in), Save: refused with the reason. Rename it: the message stayed until the next Save. The editor set the error when save was refused and cleared it only on a successful save, Discard or a new draft.

The editor now keeps the design revision and the command Save ran with and drops the message as soon as the draft would run another, or the design changes under it, as a command dialog's refused OK does (#641); the next Save checks again. A Save refused while the draft fails its own validation (not reachable with a mouse, Save is disabled then) is kept the same way. The regression tests physical_material_refused_save_keeps_its_message_until_the_draft_changes and physical_material_refused_save_goes_when_the_design_changes in manage_dialogs_tests.rs cover the rename, an undo and the repeated refusal.

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

* Add agent: a refused Add's message goes once the fields change

Add agent, custom, click Add with the command empty: the dialog asks for a command. Type the command: the message stayed until Add was clicked again, the same for a missing model, a key without https or a bad URL. The dialog cleared the message only on a preset, kind or model change.

The dialog now keeps what the fields said when Add was refused and drops the message as soon as any of them changes, as a command dialog's refused OK does (#641); the next Add checks again. The snapshot holds whether a key is typed, not the key itself. The regression test a_refused_add_keeps_its_message_until_the_fields_change covers a program without a command and an endpoint without a model.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-07 17:14:18 +00:00
Daniel 9387a1b93c Web app: restore and recover a large design without freezing the page, with progress and Cancel (#12578)
* Web app: test that a staged restore counts only once its file is in

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

* Web app: restore and recover a large design in stages, with progress and Cancel

Restore and Open saved version in "Restore unsaved design?" and Recover in "Recover unsaved work?" opened their design with open_path, which reads and evaluates it on the page's only thread before returning. They now open through open_path_answered, a few features a frame, and what follows the open (the design gets back its file and name and is marked unsaved, the kept copy or the recovery goes) waits for the answer that it is in. Neither window comes back while the open runs.

SolveApp::open_path is now pub(crate), with a line saying it blocks; the one test outside the crate that used it opens with open_path_async and waits for the load.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:14:07 +00:00
Daniel 304aba2a49 Paste and Move Sketches: geometry pasted while an exploded view is shown stays put when the view closes (#12580)
* Tests: paste and move sketches while an exploded view is shown save what they save without it

Red first: the four commands work the saved geometry out from the exploded placements.

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

* Paste and Move Sketches: leave an exploded view shown out of the saved geometry

timeline.paste (within a design and from another design), component.paste_from_design and
component.move_sketches worked the pasted or moved geometry out from placed_document(), which
includes the placements an exploded view shows. Use design_document(), as component.transfer does.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:13:57 +00:00
Daniel b9270a6884 Section Analysis: a construction plane in a moved component cuts where it is drawn (#12582)
* Section Analysis: a construction plane in a moved component cuts where it is drawn

Make or insert a component away from the origin (or move it), activate it,
add a construction plane, then Section Analysis on that plane. The cut ran
where the plane would be if the component sat at the origin, while the
viewport drew the plane where the component stands, and the reply's origin
said the unplaced position. `inspect.section` read the plane from the
component frame; it now reads it from the placed model, like every other
world reply.

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

* Section Analysis: the placed-plane test turns the component too

A translation alone cannot tell a placed normal from an unplaced one.
The test now turns the component 90 degrees about X as well and takes
its expectation from the placed box as inspect.measure answers it: the
cut halves the box along Y and the normal lies along Y.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-07 17:13:46 +00:00
Daniel ea46e07300 Body names: a placed copy's name is taken by the rule, repair and configuration column commands (#12584)
`inspect.measure` lists a further placement's body as `KB (K:2)`, and
`appearance.assign` and `body.rename` take that name for the body the
placement shows. Three commands did not: `plastic.assign_rule`,
`body.repair` and a configuration column by body (`config.column` with
`material` or `appearance`) each answered "no body" for it. They now
resolve the name the same way (`own_body_name`), so an agent can act on
the name it was given. These commands take no picks, so the name is
mapped in the handler without placing the model.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-07 17:13:35 +00:00
Kai Evans eaf014eb54 Joint: a pick's snap is measured once, not every frame (#12864)
With a face picked, the Joint dialog's frames took 462 ms on a
1,956-face part. With both sides picked, they took 593 ms. snap_of
read a face's centre with Body::faces, which meshes the whole body.
A round face meshed it again in cylinder_face_at. The dialog's
commands and the picked glyphs called it every frame. The hover glyph
called it on each new hovered item.

- snap_of measures a face with ModelBody::joint_faces (now pub). Joint
  origins already use that cache, at the same tolerance.
- SolveApp keeps each pick's snap (picked_snap). It holds while the
  world state, design and open sketch keep the same Arcs. Any change
  starts a fresh memo. The dialog's commands, the picked glyphs and
  the hover glyph read it. It keeps the 16 latest, so hovering across
  a part stays bounded.
- A world rebuild that reuses an unchanged placement shares its face
  measurements too. Moving 1 part re-measures that part alone.

In the app, 10 s pointer sweeps give these median frames. With 1
pick: 462 ms before, 1.9 ms after. With 2 picks: 593 ms before,
1.9 ms after.

Test: a_picked_snap_is_measured_once_not_every_frame picks a hole wall
and a face. It then runs 3 frames plus the OK and preview commands.
Without the memo they tessellate for 26.7-27.2 ms in 4 runs. Without
the memo and the joint_faces read, 42.2-44.2 ms in 3 runs. With both,
0 ms. The memo matches a fresh measurement within 1e-9, and follows
a moved part.

cargo xtask ci: 13,644 passed, 36 failed, 67 ignored. 35 of the
failures also fail alone on upstream/main 871014f95. The other,
scale_invariant, is a random proptest case. It passes 3 of 3 runs
alone on both.
2026-11-07 17:13:26 +00:00
Kai Evans 1c03f2be06 Eval: To / From Object reads its face's part, not every part's placement (#12866)
Creating a joint rebuilt every feature after an Extrude From a face.
So did moving a part. That held even when the face's part stayed put.
placement_digest set reads.all for an Extrude with a From or To face.
It did the same for a Revolve To. Their cache key then took every
part's placement. Moving a third part changed that key. The chain
fingerprint then rebuilt every feature after it (#6183).

- placement_digest reads the bodies the stored face names find
  (PlacementReads::object_faces). Each comes with its own placement,
  as a combine reads its target. A join or cut also reads its
  targets, as other drawn tools do.
- It falls back to every placement when a name is empty or missing.
  It does the same when the name finds no body, or more than one. It
  also falls back when there is no input state. The face is then
  found by its point, on whatever body lies there.
- name_lookup::face_body finds the one body holding a stored name's
  face. It matches names exactly, as locate_face does.

No miss record is needed. with_named_face_points moves the point onto
the named face first. It fails the feature with "reselect it" when no
one face has the name. object_face's point search with a stored name
doesn't run.

Tests, in eval_cost_tests beside the cross-part cut test:
an_extrude_from_its_own_part_keeps_its_result_when_another_part_moves
and a_revolve_to_its_own_part_keeps_its_result_when_another_part_moves.
Moving the other part recomputes 1 feature on main, 0 here. Moving the
plate still recomputes 1, and the result follows it (#928).
an_extrude_from_a_face_without_a_name_extrudes_again_when_any_part_moves
holds the fallback.

The test part has 1,956 faces and 38 features after the extrude.
joint.create took 26.9 s before and takes 80-84 ms after.
occurrence.move of the third part took 26.7 s before, 0.1 ms after.

cargo xtask ci: fmt, clippy and the wasm check pass. 13,647 passed,
35 failed, 67 ignored. The 35 fail alone on upstream/main 871014f95 too.
2026-11-07 17:13:23 +00:00
austin62jake 3dc258c203 Agents and scripts: Interference describes its exact parameters and reply (#12875)
* Claim #12874: Interference schemas

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

* Interference: input and output schemas for inspect.interference

`InterferenceParams` gives the check its keys, `bodies` as a name list with
the handler's messages and `budget_s` bounded as the handler bounds it. The
reply is `InterferenceResult`, with the keys it had before. The command
leaves schema/unconverted.txt.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-07 17:12:50 +00:00
CaRLymx 565ba4a37e Catalan (ca): catch up with en.json (2026-11-07) (#12793)
* i18n(ca): catch-up amb en.json del 2026-11-07 (432 noves, 24 canviades)

Catch-up with `en.json` at `871014f95`.

* i18n(ca): el títol REST és Recolzament, com l'ordre i l'ajuda

---------

Co-authored-by: carlymx <24634598+carlymx@users.noreply.github.com>
2026-11-07 17:12:25 +00:00
Homero Thompson 1c1018aa6e Crash: a clean exit is reported as an unclean exit (#12842) (#12891)
* Crash: a clean exit is reported as an unclean exit

Task: https://github.com/tangency-labs/solidcraft/issues/12842

* Crash: do not report a clean exit as an unclean exit

Task: https://github.com/tangency-labs/solidcraft/issues/12842
2026-11-07 17:12:01 +00:00
PeppaWutz 1bd05a2c51 Agents and scripts: the timeline editing commands describe their exact parameters and reply (#12547) (#12602)
timeline.compute_all, delete, dependents, edit, inspect, rename, rollback,
roll_to and suppress now publish typed input and output schemas. Each
parameter is read as its handler reads it today, with the same error
messages, and a key a handler reads only in some cases (position when a
feature is given, feature when a group is given) is read only then. Each
reply is built from the typed value and serializes to the same JSON as
before. Their lines leave unconverted.txt.

Part of #2829.
2026-11-07 17:11:21 +00:00
ABDULLAH 5d8cd60f70 Arabic: tanween spelling in render help, sketch term in three preferences, verb form in the panel page reset hint (#12888) 2026-11-07 17:11:17 +00:00
PeppaWutz 046eba5ba3 Agents and scripts: the geometric sketch constraints describe their exact parameters and reply (#12549) (#12603)
sketch.constraint.coincident, collinear, concentric, equal, fix,
horizontal_vertical, midpoint, parallel, perpendicular, symmetry and tangent
now publish typed input and output schemas. Each key is read the way its
handler reads it today, in the same cases (for example `line` is not looked
at when Horizontal/Vertical gets `points`, and `a` and `b` are not looked at
when Equal gets `curves`), and is refused with the handler's own message. The
replies are built from the same types the output schemas describe, so the
JSON they return does not change. Their lines leave unconverted.txt.

Part of #2829.
2026-11-07 17:11:09 +00:00
austin62jake 96a6629ed1 Combine: input and output schemas (part of #2829) (#12918)
* Claim #12917: Combine schemas

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

* Combine: input and output schemas for solid.combine

solid.combine gets an input contract (CombineParams) and an output schema
(CombineResult: the feature result plus the new component's id). Each key
reads with the handler's messages, so strict mode keeps today's errors.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-07 17:10:56 +00:00
PeppaWutz 203ce2672b Agents and scripts: the sketch modify commands describe their exact parameters and reply (#12551) (#12605)
sketch.break, sketch.chamfer.distance_angle, sketch.chamfer.equal_distance,
sketch.chamfer.two_distance, sketch.extend, sketch.fillet, sketch.mirror,
sketch.move, sketch.offset, sketch.offset.break, sketch.pattern.circular,
sketch.pattern.rectangular, sketch.scale, sketch.trim and sketch.trim.path
now publish typed input and output schemas. Each parameter is read the way
the handler reads it today, with the same error messages, and keys a handler
only reads in some forms are only checked in those forms. The replies are
built from typed values and serialize to the same JSON as before. Their
lines leave unconverted.txt.

Part of #2829.
2026-11-07 17:10:48 +00:00
PeppaWutz 43c14bdbdf Agents and scripts: the render library apply and prepare commands describe their exact parameters and reply (#12552) (#12619)
The six render library commands (appearance, environment, physical,
surface and studio apply and prepare) now have typed input and output
schemas, so an agent can see exactly which parameters they take and what
they reply with. The commands behave as before; only what they report
about themselves is new.
2026-11-07 17:10:29 +00:00
austin62jake 6b5b49b285 Agents and scripts: the plastic features Boss, Lip, Snap Fit and Rest describe their exact parameters and reply (#12622) (#12623)
* claim: #12622

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

* Plastic features: input and output schemas for Boss, Lip, Snap Fit and Rest (#12622)

Typed contracts for plastic.boss, plastic.lip, plastic.snap_fit and
plastic.rest in schema/plastic.rs, wired with checked!, .contract and
.output; the four leave schema/unconverted.txt. Each reply is the
feature commands' FeatureResult.

Each key reads as its handler reads it, with its message: points and
directions through a small plastic_vector! type (a direction must not be
zero), sizes and angles as LengthExpr, AngleExpr and NumberExpr, and the
common feature keys through FeatureCommon. `touching` is declared because
the features join through the shared Boolean step. Lip's `type` and
`side` are new keyword_param! types in schema/types.rs.

The contracts refuse optional keys of the wrong type that the handlers
read as left out today (a size that isn't a number or text, a `body`
that isn't text, an `outside` or `new_body` that isn't true or false).

Tests in schema/plastic_tests.rs: the keys, 33 calls that keep their
message in strict (and an unknown `touching`, which strict gives after
the command's name), the 13 stricter keys, Boss's schema against its
handler, and each feature's reply against FeatureResult.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-07 17:10:20 +00:00
PeppaWutz 3d49d0cb0b Agents and scripts: Part Properties and Change Part Properties describe their exact parameters and reply (#12609) (#12634)
* Agents and scripts: Part Properties and Change Part Properties describe their exact parameters and reply (#12609)

component.metadata and component.metadata.set now publish typed input and
output schemas. Each parameter is read the way the handler reads it today and
refused with the handler's own message, and the replies are built from the
same types the output schemas describe, so the JSON they return does not
change. Their lines leave unconverted.txt.

Part of #2829.

Made with Claude Sonnet 5.5 (medium effort, fast mode off) through the Claude API, with Claude Opus 5.5 as advisor.

* Change Part Properties: say plainly how `values` reads each key

Made with Claude Opus 5.5 in Claude Code (Ultra plan, default effort, fast mode off, Ultracode off).
2026-11-07 17:10:05 +00:00
PeppaWutz 9ecf28834f Agents and scripts: Delete describes its exact parameters and reply (#12610) (#12635)
selection.delete now publishes typed input and output schemas. Each
parameter is read the way the handler reads it today and refused with the
handler's own message, and the reply is built from the same type the output
schema describes, so the JSON it returns does not change. Its line leaves
unconverted.txt.

Part of #2829.

Made with Claude Sonnet 5.5 (medium effort, fast mode off) through the Claude API, with Claude Opus 5.5 as advisor.
2026-11-07 17:09:55 +00:00
PeppaWutz 2e4b728fca Agents and scripts: Inspect Silhouette and Export View DXF describe their exact parameters and reply (#12611) (#12636)
* Agents and scripts: Inspect Silhouette and Export View DXF describe their exact parameters and reply (#12611)

inspect.silhouette and file.export_view_dxf now publish typed input and
output schemas. Each parameter is read the way the handler reads it today
and refused with the handler's own message, and the replies are built from
the same types the output schemas describe, so the JSON they return does
not change. Their lines leave unconverted.txt.

Part of #2829.

Made with Claude Sonnet 5.5 (medium effort, fast mode off) through the Claude API, with Claude Opus 5.5 as advisor.

* Export View DXF: read the triangle count through the meshes module

The typed reply read `meshes.triangles` in `view_dxf.rs`, which
`cargo xtask mesh-decisions` takes for a model decision on a mesh. The
count is only reported in the reply's stats, so `view_dxf_meshes.rs`,
the module that builds the drawing's display meshes, now hands it out.

Made with Claude Opus 5.5 in Claude Code (Ultra plan, default effort, fast mode off, Ultracode off).
2026-11-07 17:09:46 +00:00
thocsmith 5524224222 Motion Study: Play runs on by itself at 1× and slower speeds, without moving the mouse (#12620) (#12638)
* claim: #12620

* Motion Study: Play runs on by itself at every speed (#12620)

The Step slider was given the playhead itself. It is an integer slider,
and egui rounds such a slider's value every frame, so playback's gain of
0.4 of a step a frame at 1x went back to 0: Play only moved when a mouse
move made a long frame. The slider now gets a rounded copy and writes
back only when it is moved.

advance() also passes the end once the step shown would be after the
last one, so a loop no longer shows "11 / 10" before starting over.

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

---------

Co-authored-by: thocsmith <193096300+thocsmith@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:09:38 +00:00
austin62jake 2c4e3fe4d6 Assign Plastic Rule: the schema's bodies is a name or a non-empty list, not null (#12652)
The field is `Option<NameList>`, so its published schema allowed `null`
(from the `Option`) and an empty list (from `NameList`), both of which
the handler refuses ("`bodies` must list bodies"). It now has its own
schema: a name, or a list of 1 to 10 000 names. The reader is unchanged,
so strict still gives the handler's message for those calls. The schema
test adds both cases.

This is 9dfa1ade2 from #12314, pushed after review; the merge queue
merged #12314 from the commit before it, so main never got it.

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-07 17:09:27 +00:00
260 changed files with 41883 additions and 2922 deletions
+13 -2
View File
@@ -725,10 +725,16 @@ jobs:
run: |
tag="v$VERSION"
sha256sum --check SHA256SUMS.txt
# Generated notes start at the newest published release, pre-releases included. Left
# to GitHub they start at the Latest stable one, and the list outgrows the 125,000
# characters a release body may have.
start=()
prev="$(gh release list --exclude-drafts --limit 30 --json tagName,publishedAt --jq 'sort_by(.publishedAt) | last | .tagName // empty')"
if [ -n "$prev" ]; then start=(--notes-start-tag "$prev"); fi
# Stage all packages before publishing. Published tags are never replaced.
gh release create "$tag" --draft --prerelease --latest=false \
--title "SolidCraft v$VERSION (nightly)" --target "$GITHUB_SHA" \
--notes-file ../notes.md --generate-notes -- *
--notes-file ../notes.md --generate-notes "${start[@]}" -- *
gh release edit "$tag" --draft=false --prerelease --latest=false
echo "### Published nightly: $tag (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
- name: Create or update the draft release
@@ -760,8 +766,13 @@ jobs:
--target "$GITHUB_SHA" --prerelease="$PRERELEASE"
gh release upload "$tag" --clobber -- *
else
# As for nightlies: start the generated notes at the newest published release.
start=()
prev="$(gh release list --exclude-drafts --limit 30 --json tagName,publishedAt --jq 'sort_by(.publishedAt) | last | .tagName // empty')"
if [ -n "$prev" ]; then start=(--notes-start-tag "$prev"); fi
gh release create "$tag" --draft --title "SolidCraft v$VERSION" \
--target "$GITHUB_SHA" --notes-file ../notes.md --generate-notes "${pre[@]}" -- *
--target "$GITHUB_SHA" --notes-file ../notes.md --generate-notes "${start[@]}" \
"${pre[@]}" -- *
fi
{
echo "Draft **SolidCraft v$VERSION** at \`${GITHUB_SHA::9}\`:"
+1
View File
@@ -842,6 +842,7 @@ Original SolidCraft drawings, Open for owner review; no runtime assignments.
| crates/sketch/assets/fonts/liberation-sans/LiberationSans-Regular.ttf | Red Hat, Inc. (Liberation Fonts 2.x; digitized data Google) | https://github.com/liberationfonts (Debian fonts-liberation), unmodified; licence text in crates/sketch/assets/fonts/liberation-sans/OFL.txt; used for sketch Text outlines | SIL OFL 1.1 |
| crates/sketch/assets/fonts/liberation-sans/OFL.txt | Red Hat, Inc.; Liberation Fonts contributors | Unmodified accompanying font licence | SIL OFL 1.1 |
| assets/locales/pending/drag-to-zoom.json | SolidCraft contributors | Original drag-to-zoom labels and translations | MIT OR Apache-2.0 |
| `assets/locales/pending/hole-group-folder.json` | SolidCraft contributors | Original Hole grouping preference labels and translations | MIT OR Apache-2.0 |
## Code adapted from sibling projects
Generated
+37 -202
View File
@@ -125,21 +125,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher 0.4.4",
"cipher",
"cpufeatures 0.2.17",
]
[[package]]
name = "aes"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fc36d7440d015b45468ba08a21e80e056ebbd014c611c6298bb7503e04bd1"
dependencies = [
"cipher 0.5.2",
"cpubits",
"cpufeatures 0.3.1",
]
[[package]]
name = "ahash"
version = "0.8.12"
@@ -154,21 +143,6 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "alloc-no-stdlib"
version = "2.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
[[package]]
name = "alloc-stdlib"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195"
dependencies = [
"alloc-no-stdlib",
]
[[package]]
name = "allocator-api2"
version = "0.2.21"
@@ -511,15 +485,6 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-padding"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b"
dependencies = [
"hybrid-array",
]
[[package]]
name = "block2"
version = "0.5.1"
@@ -551,16 +516,6 @@ dependencies = [
"piper",
]
[[package]]
name = "brotli-decompressor"
version = "5.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
]
[[package]]
name = "bumpalo"
version = "3.20.3"
@@ -748,16 +703,7 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
dependencies = [
"cipher 0.4.4",
]
[[package]]
name = "cbc"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896"
dependencies = [
"cipher 0.5.2",
"cipher",
]
[[package]]
@@ -822,17 +768,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common 0.1.7",
"inout 0.1.4",
]
[[package]]
name = "cipher"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
dependencies = [
"crypto-common 0.2.2",
"inout 0.2.6",
"inout",
]
[[package]]
@@ -951,12 +887,6 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
[[package]]
name = "cpubits"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -1066,9 +996,9 @@ version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6"
dependencies = [
"aes 0.8.4",
"block-padding 0.3.3",
"cbc 0.1.2",
"aes",
"block-padding",
"cbc",
"dbus",
"fastrand",
"hkdf",
@@ -1165,15 +1095,6 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "ecb"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26f2a8b3e564eba0877223dc343703ad0385794e882e6d13f3a4dd5c6b1f41ac"
dependencies = [
"cipher 0.5.2",
]
[[package]]
name = "ecolor"
version = "0.37.0"
@@ -1704,7 +1625,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
dependencies = [
"color_quant",
"weezl 0.1.12",
"weezl",
]
[[package]]
@@ -2147,20 +2068,10 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"block-padding 0.3.3",
"block-padding",
"generic-array",
]
[[package]]
name = "inout"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a580c13f0ffc335ef42dd3f6b1a41b726051c5e1656d9a3aa6fddb3c5dfbbf9"
dependencies = [
"block-padding 0.4.2",
"hybrid-array",
]
[[package]]
name = "itertools"
version = "0.15.0"
@@ -2405,43 +2316,6 @@ version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "lopdf"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfffda0fe1ab0157e1a13c14bebd3f28671f2fccb7922f0722ec53926e6922d3"
dependencies = [
"aes 0.9.4",
"bitflags 2.13.2",
"brotli-decompressor",
"cbc 0.2.1",
"ecb",
"encoding_rs",
"flate2",
"getrandom 0.4.3",
"indexmap",
"itoa",
"log",
"md-5",
"nom",
"rand 0.10.3",
"rangemap",
"sha2 0.11.1",
"stringprep",
"thiserror 2.0.21",
"weezl 0.2.1",
]
[[package]]
name = "md-5"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98"
dependencies = [
"cfg-if",
"digest 0.11.3",
]
[[package]]
name = "memchr"
version = "2.8.3"
@@ -3437,7 +3311,6 @@ version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
dependencies = [
"chacha20",
"getrandom 0.4.3",
"rand_core 0.10.1",
]
@@ -3482,12 +3355,6 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08"
[[package]]
name = "rangemap"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d"
[[package]]
name = "raw-window-handle"
version = "0.6.2"
@@ -3863,8 +3730,8 @@ version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4"
dependencies = [
"aes 0.8.4",
"cbc 0.1.2",
"aes",
"cbc",
"futures-util",
"generic-array",
"hkdf",
@@ -4184,15 +4051,16 @@ dependencies = [
[[package]]
name = "solidcore"
version = "1.0.1"
source = "git+https://github.com/tangency-labs/solidcore?rev=8ddce901671f1cebd9214a31d21895d725e886c8#8ddce901671f1cebd9214a31d21895d725e886c8"
source = "git+https://github.com/tangency-labs/solidcore?rev=a445bdaac60a980b0e9d1dba3ee28eb7ada44f50#a445bdaac60a980b0e9d1dba3ee28eb7ada44f50"
dependencies = [
"serde",
"thiserror 2.0.21",
]
[[package]]
name = "solidcore-record"
version = "0.1.0"
source = "git+https://github.com/tangency-labs/solidcore?rev=f5e8e9a29e0c9b7cf6c4040a5bc8878382b3c62f#f5e8e9a29e0c9b7cf6c4040a5bc8878382b3c62f"
source = "git+https://github.com/tangency-labs/solidcore?rev=a445bdaac60a980b0e9d1dba3ee28eb7ada44f50#a445bdaac60a980b0e9d1dba3ee28eb7ada44f50"
dependencies = [
"serde",
"serde_json",
@@ -4201,7 +4069,7 @@ dependencies = [
[[package]]
name = "solidcraft"
version = "0.40.0-rc.2"
version = "0.40.0-rc.3"
dependencies = [
"block2 0.6.2",
"eframe",
@@ -4239,11 +4107,11 @@ dependencies = [
[[package]]
name = "solidcraft-3dx-client"
version = "0.40.0-rc.2"
version = "0.40.0-rc.3"
[[package]]
name = "solidcraft-cli"
version = "0.40.0-rc.2"
version = "0.40.0-rc.3"
dependencies = [
"image",
"log",
@@ -4257,7 +4125,7 @@ dependencies = [
[[package]]
name = "solidcraft-derive"
version = "0.40.0-rc.2"
version = "0.40.0-rc.3"
dependencies = [
"proc-macro2",
"quote",
@@ -4266,7 +4134,7 @@ dependencies = [
[[package]]
name = "solidcraft-doc"
version = "0.40.0-rc.2"
version = "0.40.0-rc.3"
dependencies = [
"log",
"serde",
@@ -4285,7 +4153,7 @@ dependencies = [
[[package]]
name = "solidcraft-dynamics"
version = "0.40.0-rc.2"
version = "0.40.0-rc.3"
dependencies = [
"serde",
"serde_json",
@@ -4296,7 +4164,7 @@ dependencies = [
[[package]]
name = "solidcraft-engine"
version = "0.40.0-rc.2"
version = "0.40.0-rc.3"
dependencies = [
"image",
"log",
@@ -4319,7 +4187,7 @@ dependencies = [
[[package]]
name = "solidcraft-geom"
version = "0.40.0-rc.2"
version = "0.40.0-rc.3"
dependencies = [
"serde",
"serde_json",
@@ -4329,7 +4197,7 @@ dependencies = [
[[package]]
name = "solidcraft-gpu"
version = "0.40.0-rc.2"
version = "0.40.0-rc.3"
dependencies = [
"image",
"log",
@@ -4350,7 +4218,7 @@ dependencies = [
[[package]]
name = "solidcraft-io"
version = "0.40.0-rc.2"
version = "0.40.0-rc.3"
dependencies = [
"cadmpeg-core",
"cadmpeg-ir",
@@ -4377,7 +4245,7 @@ dependencies = [
[[package]]
name = "solidcraft-kernel"
version = "0.40.0-rc.2"
version = "0.40.0-rc.3"
dependencies = [
"log",
"proptest",
@@ -4396,7 +4264,7 @@ dependencies = [
[[package]]
name = "solidcraft-mcp"
version = "0.40.0-rc.2"
version = "0.40.0-rc.3"
dependencies = [
"image",
"serde_json",
@@ -4406,7 +4274,7 @@ dependencies = [
[[package]]
name = "solidcraft-profile"
version = "0.40.0-rc.2"
version = "0.40.0-rc.3"
dependencies = [
"serde_json",
"web-time",
@@ -4414,7 +4282,7 @@ dependencies = [
[[package]]
name = "solidcraft-render"
version = "0.40.0-rc.2"
version = "0.40.0-rc.3"
dependencies = [
"flate2",
"image",
@@ -4427,12 +4295,12 @@ dependencies = [
"solidcore-record",
"solidcraft-geom",
"web-time",
"weezl 0.1.12",
"weezl",
]
[[package]]
name = "solidcraft-sketch"
version = "0.40.0-rc.2"
version = "0.40.0-rc.3"
dependencies = [
"harfrust",
"serde",
@@ -4446,7 +4314,7 @@ dependencies = [
[[package]]
name = "solidcraft-smlib"
version = "0.40.0-rc.2"
version = "0.40.0-rc.3"
dependencies = [
"flate2",
"log",
@@ -4459,14 +4327,14 @@ dependencies = [
[[package]]
name = "solidcraft-space-mouse"
version = "0.40.0-rc.2"
version = "0.40.0-rc.3"
dependencies = [
"serde",
]
[[package]]
name = "solidcraft-step"
version = "0.40.0-rc.2"
version = "0.40.0-rc.3"
dependencies = [
"solidcore-record",
"solidcraft-geom",
@@ -4475,18 +4343,17 @@ dependencies = [
[[package]]
name = "solidcraft-testkit"
version = "0.40.0-rc.2"
version = "0.40.0-rc.3"
[[package]]
name = "solidcraft-ui-egui"
version = "0.40.0-rc.2"
version = "0.40.0-rc.3"
dependencies = [
"arboard",
"egui",
"egui-wgpu",
"image",
"log",
"lopdf",
"minisign-verify",
"nix 0.31.3",
"pollster 0.4.0",
@@ -4512,7 +4379,7 @@ dependencies = [
[[package]]
name = "solidcraft-web"
version = "0.40.0-rc.2"
version = "0.40.0-rc.3"
dependencies = [
"eframe",
"egui",
@@ -4567,17 +4434,6 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
[[package]]
name = "stringprep"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
dependencies = [
"unicode-bidi",
"unicode-normalization",
"unicode-properties",
]
[[package]]
name = "subtle"
version = "2.6.1"
@@ -4701,7 +4557,7 @@ dependencies = [
"flate2",
"half",
"quick-error",
"weezl 0.1.12",
"weezl",
"zune-jpeg",
]
@@ -4740,12 +4596,6 @@ dependencies = [
"zerovec",
]
[[package]]
name = "tinyvec"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
[[package]]
name = "tokio"
version = "1.53.2"
@@ -4923,15 +4773,6 @@ version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "unicode-normalization"
version = "0.1.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d"
dependencies = [
"tinyvec",
]
[[package]]
name = "unicode-properties"
version = "0.1.5"
@@ -5313,12 +5154,6 @@ version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]]
name = "weezl"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4ca08e5ef825b65b056d9efbd95c8750683f0a6d0466d02e96dc2e4e360f3d2"
[[package]]
name = "wgpu"
version = "30.0.1"
@@ -5959,7 +5794,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.40.0-rc.2"
version = "0.40.0-rc.3"
dependencies = [
"flate2",
"proc-macro2",
+3 -3
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["vendor"]
[workspace.package]
version = "0.40.0-rc.2"
version = "0.40.0-rc.3"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -36,10 +36,10 @@ solidcraft-3dx-client = { path = "crates/threedx-client" }
# move the rev on its own commit. For a
# local checkout, add `[patch."https://github.com/tangency-labs/solidcore"] solidcore = { path =
# "../solidcore/crates/solidcore" }` to a `.cargo/config.toml` outside the repository.
solidcore = { git = "https://github.com/tangency-labs/solidcore", rev = "8ddce901671f1cebd9214a31d21895d725e886c8" }
solidcore = { git = "https://github.com/tangency-labs/solidcore", rev = "a445bdaac60a980b0e9d1dba3ee28eb7ada44f50" }
# Geometry as data (solidcore's layers §1, "Record"): the B-rep record, its structural validation
# and the native body file. Pinned like solidcore; for a local checkout, patch it as above.
solidcore-record = { git = "https://github.com/tangency-labs/solidcore", rev = "f5e8e9a29e0c9b7cf6c4040a5bc8878382b3c62f" }
solidcore-record = { git = "https://github.com/tangency-labs/solidcore", rev = "a445bdaac60a980b0e9d1dba3ee28eb7ada44f50" }
# test tooling: a dev-dependency only (xtask/src/layers.rs)
solidcraft-testkit = { path = "crates/testkit" }
+2 -2
View File
@@ -318,7 +318,7 @@ fn a_model_saved_under_another_check_mode_is_evaluated_again() {
/// opened from its model or evaluated anew. Two edges of a 20 mm box rounded r2 (a mitre at the
/// corner (20, 0, 20)), saved; opened, the upright edge there rounded r3: the corner all three
/// rounded at once make (SMLib's setback corner, kernel `three_rounds_at_a_corner`), 15 faces and
/// 7928.142563938 mm³, the same body as the design evaluated anew.
/// 7927.72428251348 mm³, the same body as the design evaluated anew.
#[test]
fn a_later_round_after_opening_blends_the_saved_rounds_again() {
let mut built = Session::default();
@@ -336,7 +336,7 @@ fn a_later_round_after_opening_blends_the_saved_rounds_again() {
let b = &st.body("Box").unwrap().body;
let v = solidcraft_engine::kernel::measure(b).unwrap().volume;
assert_eq!(b.face_count(), 15);
assert!((v - 7928.142563938).abs() < 1e-6, "{v}");
assert!((v - 7927.72428251348).abs() < 1e-6, "{v}"); // solidcore#2382's setback corner
}
assert_eq!(model("later round", &cached), model("later round", &rebuilt));
let _ = std::fs::remove_file(&path);
+43
View File
@@ -173,6 +173,35 @@ pub fn is_open(open: Option<&str>, wanted: &str) -> bool {
open == Some(wanted)
}
/// What follows once a design restored from a kept copy, or recovered, is open: it opens in
/// stages, a few features a frame with progress and Cancel (#10198), so this waits for the answer.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AfterOpen {
/// A kept copy (#10220): it is its own design again (saving goes to `path`, it is named
/// `name`), with its changes unsaved; then the copy `id` goes when `drop` says so.
KeptCopy { opened: String, id: String, path: Option<String>, name: String, drop: bool },
/// The saved design offered beside a kept copy: the copy `id` goes when `drop` says so.
KeptSaved { opened: String, id: String, drop: bool },
/// Unsaved work from the page's last visit: it belongs to `path` again, named `name`, unsaved.
Recovery { opened: String, path: Option<String>, name: String },
}
impl AfterOpen {
/// The file the open reads.
pub fn opened(&self) -> &str {
match self {
AfterOpen::KeptCopy { opened, .. } | AfterOpen::KeptSaved { opened, .. } | AfterOpen::Recovery { opened, .. } => opened,
}
}
}
/// Whether the open of `wanted` answered with `reply` put it in: the answer says so, and it is
/// the design now open (`open`: the open design's path). A refusal, a Cancel or another file
/// leaves the copy or the recovered work offered.
pub fn opened(reply: &Value, open: Option<&str>, wanted: &str) -> bool {
reply["ok"].as_bool() == Some(true) && is_open(open, wanted)
}
/// Whether leaving the page asks through the browser's "leave site?" box: there is unsaved work,
/// and either the user still wants to be asked or it isn't all kept in the browser yet (with the
/// keep preference off it never is, so there the box always stays).
@@ -224,6 +253,20 @@ pub fn answers(entry: &Entry, saved_exists: bool) -> Vec<Answer> {
mod tests {
use super::*;
/// A restored or recovered design opens in stages (#10198): only an answer that it is in, for
/// the file asked for, counts; a refusal, a Cancel or another design open leaves it offered.
#[test]
fn a_staged_restore_counts_only_when_its_file_is_in() {
let copy = "/opfs/.kept/a.sc";
assert!(opened(&json!({"ok": true}), Some(copy), copy));
assert!(!opened(&json!({"ok": false, "error": "cancelled"}), Some(copy), copy));
assert!(!opened(&json!({"ok": false, "error": "not a design"}), None, copy));
assert!(!opened(&json!({"ok": true}), Some("/opfs/other.sc"), copy));
assert!(!opened(&json!({}), Some(copy), copy));
let after = AfterOpen::KeptCopy { opened: copy.into(), id: "a".into(), path: None, name: "Bracket".into(), drop: true };
assert_eq!(after.opened(), copy);
}
fn entry(id: &str, changed_at: f64, path: Option<&str>) -> Entry {
Entry { id: id.into(), name: "Bracket".into(), path: path.map(str::to_string), changed_at }
}
+81 -27
View File
@@ -382,6 +382,9 @@ mod web {
keep_track: std::collections::HashMap<u64, Track>,
/// Every unsaved design's kept copy is current (and the preference is on).
all_kept: bool,
/// A kept copy or recovered work being opened, in stages with progress and Cancel
/// (#10198): the answer to come and what follows it ([`Shell::restored`]).
restoring: Option<(std::sync::mpsc::Receiver<serde_json::Value>, keep::AfterOpen)>,
}
impl Shell {
@@ -633,7 +636,7 @@ mod web {
/// (when it has a file) and Discard.
fn keep_prompt(&mut self, ctx: &egui::Context) {
let offered = self.files.keep_offered.borrow().clone();
if offered.is_empty() || !self.files.ready.get() {
if offered.is_empty() || !self.files.ready.get() || self.restoring.is_some() {
return;
}
let now = (js_sys::Date::now() / 1000.0) as u64;
@@ -673,24 +676,21 @@ mod web {
return;
}
};
self.app.open_path(&copy);
if self.app.session.path.as_deref() != Some(copy.as_str()) {
// Not opened (the status says why): it stays offered.
return;
}
// It is its own design again (saving goes to its file), with its changes unsaved.
self.app.session.path = entry.path.clone();
self.app.session.doc_mut().name = entry.name.clone();
self.app.session.mark_unsaved();
self.app.home.open = false;
// Finished in `restored` once it is in.
let after = keep::AfterOpen::KeptCopy {
opened: copy.clone(),
id: entry.id.clone(),
path: entry.path.clone(),
name: entry.name.clone(),
drop: plan.drop_copy,
};
self.restore(&copy, after);
return;
}
if let Some(path) = &plan.open_saved {
if vfs::exists(path) {
self.app.open_path(path);
if !keep::is_open(self.app.session.path.as_deref(), path) {
// Not opened (the status says why): the copy stays offered.
return;
}
self.restore(path, keep::AfterOpen::KeptSaved { opened: path.clone(), id: entry.id.clone(), drop: plan.drop_copy });
return;
} else {
// A file on the computer: the browser asks for access again, in this click.
// The copy goes once the file has opened, not before: if access is refused,
@@ -708,8 +708,62 @@ mod web {
}
}
if plan.drop_copy {
self.files.keep_offered.borrow_mut().retain(|e| e.id != entry.id);
files::remove_kept(&self.files, &entry.id);
self.drop_kept(&entry.id);
}
}
fn drop_kept(&self, id: &str) {
self.files.keep_offered.borrow_mut().retain(|e| e.id != id);
files::remove_kept(&self.files, id);
}
/// Open a kept copy, its saved design or recovered work as any design opens, a few
/// features a frame with progress and Cancel (#10198), so a large design doesn't freeze the
/// page; `after` follows once it is in ([`Self::restored`]).
fn restore(&mut self, path: &str, after: keep::AfterOpen) {
self.restoring = Some((self.app.open_path_answered(path), after));
self.restored();
}
/// What follows a restore that is in: the design gets back its file and name, and the
/// copy or the recovered work it came from goes. One that didn't open (refused or
/// cancelled; the status says why) stays offered.
fn restored(&mut self) {
let Some((rx, _)) = &self.restoring else { return };
let reply = match rx.try_recv() {
Ok(reply) => reply,
Err(std::sync::mpsc::TryRecvError::Empty) => return,
Err(std::sync::mpsc::TryRecvError::Disconnected) => serde_json::json!({"ok": false}),
};
let Some((_, after)) = self.restoring.take() else { return };
if !keep::opened(&reply, self.app.session.path.as_deref(), after.opened()) {
return;
}
match after {
keep::AfterOpen::KeptCopy { id, path, name, drop, .. } => {
// It is its own design again (saving goes to its file), with its changes unsaved.
self.app.session.path = path;
self.app.session.doc_mut().name = name;
self.app.session.mark_unsaved();
self.app.home.open = false;
if drop {
self.drop_kept(&id);
}
}
keep::AfterOpen::KeptSaved { id, drop, .. } => {
if drop {
self.drop_kept(&id);
}
}
keep::AfterOpen::Recovery { path, name, .. } => {
// It belongs to its own file again (saving goes there); it is unsaved.
self.app.session.path = path;
self.app.session.doc_mut().name = name;
self.app.session.mark_unsaved();
self.app.home.open = false;
*self.files.recovery.borrow_mut() = None;
files::autosave(&self.files, None);
}
}
}
@@ -917,6 +971,9 @@ mod web {
/// Offer the autosave an earlier session left.
fn recovery_prompt(&mut self, ctx: &egui::Context) {
if self.restoring.is_some() {
return;
}
let Some((name, path)) = self.files.recovery.borrow().clone() else { return };
let mut answer = None;
let _ = solidcraft_ui_egui::ui_kit::Window::new(egui::Id::unique("sc_browser_recovery"), "restore", "Recover unsaved work?")
@@ -941,15 +998,10 @@ mod web {
return;
}
};
self.app.open_path(&recovery);
if self.app.session.path.as_deref() != Some(recovery.as_str()) {
return;
}
// It belongs to its own file again (saving goes there); it is unsaved.
self.app.session.path = path;
self.app.session.doc_mut().name = name;
self.app.session.mark_unsaved();
self.app.home.open = false;
// Finished in `restored` once it is in; until then the prompt stays away.
let after = keep::AfterOpen::Recovery { opened: recovery.clone(), path, name };
self.restore(&recovery, after);
return;
}
*self.files.recovery.borrow_mut() = None;
files::autosave(&self.files, None);
@@ -1093,6 +1145,7 @@ mod web {
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.linked.tick(&mut self.app, ui.ctx());
self.restored();
UNSAVED.with(|u| u.set(self.asks_before_unload()));
if self.launch.embed {
ui_kit::Container::new(ui.ctx(), ui_kit::Surface::Chrome).edge("embed_header", ui_kit::Edge::Top).show(ui, |ui| {
@@ -1306,6 +1359,7 @@ mod web {
other_tab_prefs,
keep_track: Default::default(),
all_kept: false,
restoring: None,
}))
}),
)
+24 -10
View File
@@ -265,6 +265,7 @@
"Edge": "حافة",
"Edges": "الحواف",
"Edit": "تعديل",
"Edit Holes": "تحرير الثقوب",
"Edit Profile Sketch": "تعديل رسم المقطع",
"Edit shortcuts": "تعديل الاختصارات",
"Enable rule": "تفعيل القاعدة",
@@ -1206,6 +1207,7 @@
"cmd.material_properties_directional": "معامل المرونة الاتجاهي",
"cmd.material_properties_validate": "التحقق من بيانات المادة الفيزيائية",
"cmd.mesh_convert": "تحويل الشبكة",
"cmd.model_body_features": "سرد ميزات الجسم",
"cmd.model_edges": "قائمة الحواف",
"cmd.model_faces": "قائمة الأوجه",
"cmd.model_threads": "قائمة الأسنان اللولبية",
@@ -1366,6 +1368,7 @@
"cmd.sketch.construction_line.tangent_external": "مماس يمر بنقطة خارجية",
"cmd.sketch.construction_line.tangent_two": "مماس مشترك",
"cmd.sketch.construction_line.vertical": "خط إنشائي رأسي",
"cmd.sketch.replace": "استبدال الرسم",
"cmd.sketch_arc3d": "قوس ثلاثي الأبعاد",
"cmd.sketch_arc_center_point": "قوس بنقطة مركز",
"cmd.sketch_arc_tangent": "قوس مماسي",
@@ -2016,6 +2019,7 @@
"help.view.visual_style": "يبدّل طريقة رسم النموذج: مظلَّل مع الحواف أو بدونها، أو إطار سلكي، أو مع الحواف المخفية. استخدم الإطار السلكي لرؤية الداخل، والمظلَّل لمظهر نظيف.",
"history.sketch_under_constrained": "قيود ناقصة ({count} درجات حرية)",
"history.sketch_under_constrained_one": "قيود ناقصة ({count} درجة حرية)",
"hole_folder.applies_to": "تنطبق التغييرات على الثقوب الـ {count} في {folder}؛ ويحتفظ كل ثقب بموضعه.",
"hole_presets.confirm_delete": "تأكيد الحذف",
"hole_presets.delete": "حذف",
"hole_presets.delete_prompt": "هل تحذف هذا الإعداد المسبق الشخصي؟ الثقوب الموجودة لا تتغير.",
@@ -2515,6 +2519,8 @@
"prefs.bottom_centre": "أسفل الوسط",
"prefs.bottom_right": "أسفل اليمين",
"prefs.browser": "المستعرض",
"prefs.browser_body_features": "الميزات تحت الأجسام",
"prefs.browser_body_features_note": "يمكن فتح كل جسم في المستعرض لعرض ميزات المخطط الزمني التي بنته، بترتيب المخطط الزمني.",
"prefs.browser_components_open": "المكوّنات مفتوحة من البداية",
"prefs.browser_components_open_note": "يبدأ كل مكوّن في المستعرض مفتوحًا كما كان من قبل، بدلًا من أن يبقى مغلقًا حتى تفتحه.",
"prefs.browser_eye_left": "أيقونة الإظهار بجانب سهم الطي",
@@ -2555,7 +2561,11 @@
"prefs.design_tools.degrees": "درجات",
"prefs.design_tools.double_click_help": "أو لتحرير مكوّن مرتبط في موضعه.",
"prefs.design_tools.ghost": "إظهار الموضع القديم للأجزاء المنقولة كشبح",
"prefs.design_tools.hole_group": "تجميع الثقوب المصنوعة معًا",
"prefs.design_tools.hole_group_help": "الثقوب المصنوعة دفعة واحدة تشترك في مجلد في السجل، مغلق في البداية؛ افتحه لتحرير كل ثقب على حدة.",
"prefs.design_tools.hole_moves_help": "اختيار الوجه مجددًا ينقل الثقب بدلًا من إزالته.",
"prefs.design_tools.hole_several": "عدة ثقوب على وجه واحد",
"prefs.design_tools.hole_several_help": "الاختيار في موضع آخر من الوجه يضيف ثقبًا آخر، واختيار ثقب يزيله.",
"prefs.design_tools.hole_snaps_help": "منتصف الوجه ومراكز حوافه الدائرية.",
"prefs.design_tools.imported_files": "الملفات المستوردة",
"prefs.design_tools.note": "ملاحظة",
@@ -2637,6 +2647,8 @@
"prefs.history": "السجل",
"prefs.history_bar_groups": "طي شريط السجل في مجموعات",
"prefs.history_bar_groups_note": "في سجل طويل، تُطوى سلسلة ميزات المكوّن الواحد في شريحة واحدة، وتبدأ مجلدات الميزات مطوية. تدخل مجموعة STEP المُدرجة في مجلد Group. عند الإيقاف تظهر كل ميزة، وتبدأ المجلدات مفتوحة، ولا يُنشأ أي مجلد.",
"prefs.history_folder_kind_icon": "أيقونة المجلد من ميزاته",
"prefs.history_folder_kind_icon_note": "مجلد السجل الذي تكون ميزاته كلها من نوع واحد يعرض أيقونتها.",
"prefs.history_group_joints": "تجميع المفاصل في السجل",
"prefs.history_group_joints_note": "يظهر مفصلان أو أكثر كزر واحد مع عددها، والنقر عليه يُظهر كل مفصل. عند الإيقاف يظهر كل مفصل على حدة.",
"prefs.history_origin_bodies": "إظهار الأصل والأجسام في الأعلى",
@@ -2828,7 +2840,7 @@
"prefs.select_hidden_edges_help": "يتيح تحديد الحواف عبر الأوجه عند تفعيل نمط «مظلل مع الحواف المخفية»، بما في ذلك عند استخدام التقويس. عند الإيقاف يبقى سلوك التحديد كما في v0.30.1. تظل الأجسام المخفية غير قابلة للتحديد.",
"prefs.select_one_item": "تحديد عنصر واحد",
"prefs.select_other_hidden": "تحديد العناصر الهندسية المخفية باستخدام «تحديد عنصر آخر»",
"prefs.select_other_hidden_help": "عند التفعيل: تضمين الأجسام والمخططات المخفية والحواف المحجوبة قرب المؤشر. عند التعطيل: استخدام القائمة الأصلية للعناصر الهندسية المرئية أسفل المؤشر.",
"prefs.select_other_hidden_help": "عند التفعيل: تضمين الأجسام والرسومات المخفية والحواف المحجوبة قرب المؤشر. عند التعطيل: استخدام القائمة الأصلية للعناصر الهندسية المرئية أسفل المؤشر.",
"prefs.selecting": "التحديد",
"prefs.shift": "Shift",
"prefs.shift_add_ctrl_toggle": "Shift يضيف فقط، وCtrl / Cmd يبدّل",
@@ -3074,7 +3086,7 @@
"render.aging_metallic": "النسبة المعدنية للطبقة",
"render.aging_origin": "أصل الحقل · mm العالم",
"render.aging_patina": "زنجار نحاسي أخضر",
"render.aging_raytrace_help": "يُطبّق التعتيق في تتبع الأشعة على المعالج وتتبع المسارات على المعالج الرسومي. يبقى التصيير النقطي تخطيطياً.",
"render.aging_raytrace_help": "يُطبّق التعتيق في تتبع الأشعة على المعالج وتتبع المسارات على المعالج الرسومي. يبقى التصيير النقطي تخطيطيًا.",
"render.aging_refresh_targets": "فحص الأجسام الحالية",
"render.aging_roughness": "خشونة الطبقة",
"render.aging_rust": "حديد مؤكسد / صدأ",
@@ -3409,8 +3421,8 @@
"render.cpu_sky_bounds": "يجب أن يكون دوران السماء محدودًا ضمن ±1,000,000 راديان، وأن تكون الشدة بين 0 و100.",
"render.cpu_studio_enable": "تفعيل الإضاءة غير المباشرة لهذا التوزيع",
"render.cpu_studio_enable_apply": "تفعيل الإضاءة غير المباشرة وتطبيقها",
"render.cpu_studio_indirect_help": "يحتاج توزيع الشمس هذا إلى إضاءة غير مباشرة بعمق 8 وظلال متتبعة وعينة كاميرا واحدة لكل مرور. إذا كان التصيير التدريجي معطلاً، يبدأ بـ 16 عينة لكل بكسل وبذرة 0. تُحفظ الإعدادات التدريجية الحالية وملف الموارد. تمنع الإعدادات غير المتوافقة التطبيق دون تغييرها.",
"render.cpu_studio_radiance_limit": "سطوع المصدر المساحي في هذا التوزيع هو {radiance}، ويتجاوز حد CPU البالغ 64. تُحفظ الإضاءة المؤلَّفة؛ اختر توزيعاً آخر أو GPU.",
"render.cpu_studio_indirect_help": "يحتاج توزيع الشمس هذا إلى إضاءة غير مباشرة بعمق 8 وظلال متتبعة وعينة كاميرا واحدة لكل مرور. إذا كان التصيير التدريجي معطلًا، يبدأ بـ 16 عينة لكل بكسل وبذرة 0. تُحفظ الإعدادات التدريجية الحالية وملف الموارد. تمنع الإعدادات غير المتوافقة التطبيق دون تغييرها.",
"render.cpu_studio_radiance_limit": "سطوع المصدر المساحي في هذا التوزيع هو {radiance}، ويتجاوز حد CPU البالغ 64. تُحفظ الإضاءة المؤلَّفة؛ اختر توزيعًا آخر أو GPU.",
"render.cpu_subsurface_denoise_reason": "يتطلب التشتت داخل الزجاج على CPU إيقاف إزالة الضجيج. تبقى إعداداتك محفوظة.",
"render.cpu_subsurface_help": "يغيّر التشتت اتجاه الضوء داخل الزجاج المغلق. تتطلب المعدلات الموجبة إضاءة غير مباشرة مع إيقاف إزالة الضجيج. المعدل هو عدد التصادمات لكل مم؛ لا يضيف الصفر أي تشتت. يفضّل انحياز الاتجاه الموجب الأمام، والسالب الخلف.",
"render.cpu_summary": "تتبع الأشعة على CPU · انعكاسات · HDR · أضواء اتجاهية ونقطية",
@@ -3430,7 +3442,7 @@
"render.diag_preflight_details": "تفاصيل الفحص المسبق",
"render.diag_request": "الطلب الحالي بالضبط",
"render.diag_triangles": "المثلثات",
"render.directional_sun_limit": "تدعم إضاءة GPU ثمانية مصادر ضوء اتجاهية إجمالاً، من بينها شمس واحدة بظلال ناعمة كحد أقصى. انسخ ضوءاً اتجاهياً بظلال حادة لإضافة مصدر بعيد آخر.",
"render.directional_sun_limit": "تدعم إضاءة GPU ثمانية مصادر ضوء اتجاهية إجمالًا، من بينها شمس واحدة بظلال ناعمة كحد أقصى. انسخ ضوءًا اتجاهيًا بظلال حادة لإضافة مصدر بعيد آخر.",
"render.discard_continue": "تجاهل المسودة والمتابعة",
"render.draft_confirm_help": "في مسودة التصيير الحالية تعديلات غير مطبّقة. احفظها قبل التبديل، أو تجاهلها صراحةً، أو تابع التحرير.",
"render.draft_confirm_title": "الحفاظ على إعدادات التصيير المعدّلة",
@@ -4014,7 +4026,7 @@
"render.output_lock_ratio": "قفل نسبة الأبعاد",
"render.output_master_unavailable": "لتصدير EXR، اختر صورة مكتملة تحتوي على بيانات EXR خام. فعّل EXR الخام قبل تصيير صور جديدة.",
"render.output_over_budget": "الصورة المطلوبة تتجاوز نطاق الموارد المختار. اختر حجمًا أو ملفًا متوافقًا صراحةً؛ لا يحدث تخفيض تلقائي.",
"render.output_presentation_limits": "تدعم مؤثرات الصور على CPU وإخراج EXR صوراً بعرض وارتفاع لا يتجاوزان 8192 بكسل، وبإجمالي لا يتجاوز 16,777,216 بكسل.",
"render.output_presentation_limits": "تدعم مؤثرات الصور على CPU وإخراج EXR صورًا بعرض وارتفاع لا يتجاوزان 8192 بكسل، وبإجمالي لا يتجاوز 16,777,216 بكسل.",
"render.output_presets_help": "الصيغة والجودة وقائمة الفحص في محرر المخرجات.",
"render.output_quality": "الجودة",
"render.output_quality_waiting": "بانتظار عقد الجودة",
@@ -4265,7 +4277,7 @@
"render.soft_focus": "تركيز ناعم",
"render.spot_axis": "اتجاه انبعاث الضوء",
"render.spot_cone": "مخروط الكشّاف",
"render.spot_gpu_help": "تُقاس الزوايا من اتجاه انبعاث الضوء. يتلاشى الضوء بين الزاويتين الداخلية والخارجية. تتطلب نطاقات التلاشي الضيقة جداً التصيير باستخدام CPU.",
"render.spot_gpu_help": "تُقاس الزوايا من اتجاه انبعاث الضوء. يتلاشى الضوء بين الزاويتين الداخلية والخارجية. تتطلب نطاقات التلاشي الضيقة جدًا التصيير باستخدام CPU.",
"render.spot_help": "تُقاس الزوايا من اتجاه انبعاث الضوء. يتلاشى الضوء بين الزاويتين الداخلية والخارجية.",
"render.spot_inner": "نصف الزاوية الداخلية",
"render.spot_outer": "نصف الزاوية الخارجية",
@@ -5446,7 +5458,7 @@
"ui.finest": "الأدق",
"ui.finish_sketch": "إنهاء الرسم",
"ui.first_dimension_scales": "البُعد الأول يغيّر مقياس الرسم",
"ui.first_dimension_scales_tip": "تشغيل: أول بُعد طولي في المخطط يغيّر مقياس الرسم الذي يقيسه، فيبقى شكله كما هو. إيقاف: يغيّر البُعد حجم ما يقيسه فقط.",
"ui.first_dimension_scales_tip": "تشغيل: أول بُعد طولي في الرسم يغيّر مقياس الشكل الذي يقيسه، فيبقى شكله كما هو. إيقاف: يغيّر البُعد حجم ما يقيسه فقط.",
"ui.fit": "ملاءمة",
"ui.fix_unfix": "تثبيت/فك التثبيت",
"ui.flat": "مسطّح",
@@ -5771,7 +5783,7 @@
"ui.panel_layout.custom_hint": "تم نقل اللوحات منذ {name}.",
"ui.panel_layout.details": "التفاصيل",
"ui.panel_layout.layout_workspace": "مساحة عمل التخطيط",
"ui.panel_layout.page_defaults_hint": "إعادة ضبط إعدادات هذه الصفحة. تُحفظ ترتيبات اللوحات والتخطيطات المحفوظة.",
"ui.panel_layout.page_defaults_hint": "يعيد ضبط إعدادات هذه الصفحة. تُحفظ ترتيبات اللوحات والتخطيطات المحفوظة.",
"ui.panel_layout.panel_layout": "تخطيط اللوحات",
"ui.panel_layout.panel_style": "نمط اللوحات",
"ui.panel_layout.replace": "استبدال التخطيط",
@@ -5796,7 +5808,7 @@
"ui.panel_layout.workspace.modelling": "النمذجة",
"ui.panel_layout.workspace.render": "التصيير",
"ui.panel_layout.workspace.review": "المراجعة",
"ui.panel_layout.workspace.sketch": "الرسم التخطيطي",
"ui.panel_layout.workspace.sketch": "الرسم",
"ui.panel_layout.your_layout": "تخطيطك",
"ui.parallel": "متوازي",
"ui.parameters": "المعاملات",
@@ -5906,6 +5918,8 @@
"ui.renderer": "محرك التصيير",
"ui.repeat": "تكرار",
"ui.repeat_nothing": "لا يوجد ما يُكرَّر بعد: ابدأ أداة أو مربع حوار أولًا.",
"ui.replace_sketch.from_file": "من ملف تصميم…",
"ui.replace_sketch.no_sketches": "لا يحتوي هذا التصميم على رسم يمكن أخذه",
"ui.replace_with_link_done": "أصبحت النسخ الآن مواضع للتصميم المرتبط.",
"ui.report_a_bug": "الإبلاغ عن خطأ",
"ui.report_characters_left_one": "الأحرف المتبقية: {count}",
+448 -16
View File
File diff suppressed because it is too large Load Diff
+751 -3
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -206,6 +206,7 @@
"Edge": "Kante",
"Edges": "Kanten",
"Edit": "Bearbeiten",
"Edit Holes": "Bohrungen bearbeiten",
"Edit Profile Sketch": "Profile-Skizze bearbeiten",
"Edit shortcuts": "Tastenkürzel bearbeiten",
"Enable rule": "Regel aktivieren",
@@ -1017,6 +1018,7 @@
"cmd.material_properties_directional": "Richtungsabhängiger Elastizitätsmodul",
"cmd.material_properties_validate": "Physikalische Materialdaten prüfen",
"cmd.mesh_convert": "Netz konvertieren",
"cmd.model_body_features": "Körper-Features auflisten",
"cmd.model_edges": "Kanten auflisten",
"cmd.model_faces": "Flächen auflisten",
"cmd.model_threads": "Gewinde auflisten",
@@ -1177,6 +1179,7 @@
"cmd.sketch.construction_line.tangent_external": "Tangente durch äußeren Punkt",
"cmd.sketch.construction_line.tangent_two": "Gemeinsame Tangente",
"cmd.sketch.construction_line.vertical": "Vertikale Konstruktionslinie",
"cmd.sketch.replace": "Skizze ersetzen",
"cmd.sketch_arc3d": "3D-Bogen",
"cmd.sketch_arc_center_point": "Mittelpunkt-Kreisbogen",
"cmd.sketch_arc_tangent": "Tangentialbogen",
@@ -1819,6 +1822,7 @@
"help.view.visual_style": "Schaltet um, wie das Modell gezeichnet wird: schattiert mit oder ohne Kanten, Drahtmodell oder mit verdeckten Kanten. Drahtmodell zum Hineinschauen, schattiert für ein sauberes Bild.",
"history.sketch_under_constrained": "Unterbestimmt ({count} Freiheitsgrade)",
"history.sketch_under_constrained_one": "Unterbestimmt ({count} Freiheitsgrad)",
"hole_folder.applies_to": "Änderungen gelten für die {count} Bohrungen in {folder}; jede behält ihre Position.",
"hole_presets.confirm_delete": "Löschen bestätigen",
"hole_presets.delete": "Löschen",
"hole_presets.delete_prompt": "Möchten Sie diese persönliche Vorlage wirklich löschen? Bereits vorhandene Bohrungen bleiben unverändert.",
@@ -2302,6 +2306,8 @@
"prefs.bottom_centre": "Unten mittig",
"prefs.bottom_right": "Unten rechts",
"prefs.browser": "Browser",
"prefs.browser_body_features": "Features unter Körpern",
"prefs.browser_body_features_note": "Jeder Körper im Browser lässt sich aufklappen und zeigt die Features der Zeitleiste, die ihn erzeugt haben, in deren Reihenfolge.",
"prefs.browser_components_open": "Komponenten zu Beginn aufgeklappt",
"prefs.browser_components_open_note": "Jede Komponente im Browser ist wie bisher von Anfang an aufgeklappt, statt zugeklappt zu bleiben, bis du sie aufklappst.",
"prefs.browser_eye_left": "Auge neben dem Aufklapppfeil",
@@ -2342,7 +2348,11 @@
"prefs.design_tools.degrees": "Grad",
"prefs.design_tools.double_click_help": "Oder zum Bearbeiten einer verknüpften Komponente an Ort und Stelle.",
"prefs.design_tools.ghost": "Alte Position verschobener Teile als Geisterbild zeigen",
"prefs.design_tools.hole_group": "Gemeinsam erstellte Bohrungen gruppieren",
"prefs.design_tools.hole_group_help": "In einem Schritt erstellte Bohrungen teilen sich einen Ordner im Verlauf, zunächst geschlossen; öffnen Sie ihn, um eine Bohrung einzeln zu bearbeiten.",
"prefs.design_tools.hole_moves_help": "Erneutes Auswählen der Fläche verschiebt die Bohrung, statt sie zu entfernen.",
"prefs.design_tools.hole_several": "Mehrere Bohrungen auf einer Fläche",
"prefs.design_tools.hole_several_help": "Eine Auswahl an anderer Stelle der Fläche fügt eine weitere Bohrung hinzu; die Auswahl einer Bohrung entfernt sie.",
"prefs.design_tools.hole_snaps_help": "Die Mitte einer Fläche und die Mittelpunkte ihrer kreisförmigen Kanten.",
"prefs.design_tools.imported_files": "Importierte Dateien",
"prefs.design_tools.note": "Hinweis",
@@ -2424,6 +2434,8 @@
"prefs.history": "Verlauf",
"prefs.history_bar_groups": "Verlaufsleiste in Gruppen falten",
"prefs.history_bar_groups_note": "In einem langen Verlauf wird eine Folge von Features einer Komponente zu einem Chip gefaltet, und Merkmalsordner beginnen gefaltet. Eine eingefügte STEP-Baugruppe kommt in einen Group-Ordner. Aus zeigt jedes Feature, öffnet Ordner und legt keinen Ordner an.",
"prefs.history_folder_kind_icon": "Ordnersymbol aus seinen Features",
"prefs.history_folder_kind_icon_note": "Ein Ordner im Verlauf, dessen Features alle von einer Art sind, zeigt deren Symbol.",
"prefs.history_group_joints": "Gelenke im Verlauf gruppieren",
"prefs.history_group_joints_note": "Zwei oder mehr Gelenke erscheinen als eine Schaltfläche mit ihrer Anzahl; ein Klick zeigt jedes einzeln. Aus zeigt jedes Gelenk einzeln.",
"prefs.history_origin_bodies": "Ursprung und Körper oben zeigen",
@@ -5620,6 +5632,8 @@
"ui.renderer": "Renderer",
"ui.repeat": "Wiederholen",
"ui.repeat_nothing": "Noch nichts zu wiederholen: Starten Sie zuerst ein Werkzeug oder einen Dialog.",
"ui.replace_sketch.from_file": "Aus einer Konstruktionsdatei…",
"ui.replace_sketch.no_sketches": "Diese Konstruktion enthält keine Skizze",
"ui.replace_with_link_done": "Die Kopien sind jetzt Platzierungen des verknüpften Designs.",
"ui.report_a_bug": "Fehler melden",
"ui.report_characters_left_one": "{count} Zeichen übrig",
+14
View File
@@ -265,6 +265,7 @@
"Edge": "Edge",
"Edges": "Edges",
"Edit": "Edit",
"Edit Holes": "Edit Holes",
"Edit Profile Sketch": "Edit Profile Sketch",
"Edit shortcuts": "Edit shortcuts",
"Enable rule": "Enable rule",
@@ -1206,6 +1207,7 @@
"cmd.material_properties_directional": "Directional Elastic Modulus",
"cmd.material_properties_validate": "Validate Physical Material Data",
"cmd.mesh_convert": "Convert Mesh",
"cmd.model_body_features": "List Body Features",
"cmd.model_edges": "List Edges",
"cmd.model_faces": "List Faces",
"cmd.model_threads": "List Threads",
@@ -1366,6 +1368,7 @@
"cmd.sketch.construction_line.tangent_external": "Tangent through External Point",
"cmd.sketch.construction_line.tangent_two": "Common Tangent",
"cmd.sketch.construction_line.vertical": "Vertical Construction Line",
"cmd.sketch.replace": "Replace Sketch",
"cmd.sketch_arc3d": "3D Arc",
"cmd.sketch_arc_center_point": "Center Point Arc",
"cmd.sketch_arc_tangent": "Tangent Arc",
@@ -2016,6 +2019,7 @@
"help.view.visual_style": "Switches how the model is drawn: shaded with or without edges, wireframe, or with hidden edges. Use wireframe to see inside, shaded for a clean look.",
"history.sketch_under_constrained": "Under constrained ({count} degrees of freedom)",
"history.sketch_under_constrained_one": "Under constrained ({count} degree of freedom)",
"hole_folder.applies_to": "Changes apply to the {count} holes in {folder}; each keeps its place.",
"hole_presets.confirm_delete": "Confirm delete",
"hole_presets.delete": "Delete",
"hole_presets.delete_prompt": "Delete this personal preset? Existing holes are unchanged.",
@@ -2515,6 +2519,8 @@
"prefs.bottom_centre": "Bottom center",
"prefs.bottom_right": "Bottom right",
"prefs.browser": "Browser",
"prefs.browser_body_features": "Features under bodies",
"prefs.browser_body_features_note": "Each body in the browser folds open on the timeline features that built it, in timeline order.",
"prefs.browser_components_open": "Components start open",
"prefs.browser_components_open_note": "Every component in the browser starts open, as before, instead of closed until you open it.",
"prefs.browser_eye_left": "Eye beside the fold arrow",
@@ -2555,7 +2561,11 @@
"prefs.design_tools.degrees": "Degrees",
"prefs.design_tools.double_click_help": "Or to edit a linked one in place.",
"prefs.design_tools.ghost": "Show moved parts' old place as a ghost",
"prefs.design_tools.hole_group": "Group holes made together",
"prefs.design_tools.hole_group_help": "Holes made in one go share a folder in History, closed at first; open it to edit a hole on its own.",
"prefs.design_tools.hole_moves_help": "Picking the face again moves the hole instead of removing it.",
"prefs.design_tools.hole_several": "Several holes on one face",
"prefs.design_tools.hole_several_help": "Picking elsewhere on a face adds another hole; picking a hole removes it.",
"prefs.design_tools.hole_snaps_help": "The middle of a face and the centers of its round edges.",
"prefs.design_tools.imported_files": "Imported files",
"prefs.design_tools.note": "Note",
@@ -2637,6 +2647,8 @@
"prefs.history": "History",
"prefs.history_bar_groups": "Fold the History bar into groups",
"prefs.history_bar_groups_note": "In a long history, a run of one component's features folds into one chip, and feature folders start folded. An inserted STEP assembly goes into a Group folder. Off shows each feature, starts folders open and makes no folder.",
"prefs.history_folder_kind_icon": "Folder icon from its features",
"prefs.history_folder_kind_icon_note": "A History folder whose features are all of one kind shows their icon.",
"prefs.history_group_joints": "Group joints in the History",
"prefs.history_group_joints_note": "Two or more joints show as one button with their count, and a click shows each one. Off shows each joint.",
"prefs.history_origin_bodies": "Show origin and bodies at the top",
@@ -5906,6 +5918,8 @@
"ui.renderer": "Renderer",
"ui.repeat": "Repeat",
"ui.repeat_nothing": "Nothing to repeat yet: start a tool or dialog first.",
"ui.replace_sketch.from_file": "From a Design File…",
"ui.replace_sketch.no_sketches": "That design has no sketch to take",
"ui.replace_with_link_done": "The copies are now placements of the linked design.",
"ui.report_a_bug": "Report a bug",
"ui.report_characters_left_one": "{count} character left",
+6
View File
@@ -1000,6 +1000,7 @@
"cmd.material_properties_directional": "Module d'élasticité directionnel",
"cmd.material_properties_validate": "Valider les données du matériau physique",
"cmd.mesh_convert": "Convertir le maillage",
"cmd.model_body_features": "Lister les fonctions du corps",
"cmd.model_edges": "Lister les arêtes",
"cmd.model_faces": "Lister les faces",
"cmd.model_threads": "Lister les filetages",
@@ -1139,6 +1140,7 @@
"cmd.sketch.construction_line.tangent_external": "Tangente passant par un point extérieur",
"cmd.sketch.construction_line.tangent_two": "Tangente commune",
"cmd.sketch.construction_line.vertical": "Droite de construction verticale",
"cmd.sketch.replace": "Remplacer l’esquisse",
"cmd.sketch_arc3d": "Arc 3D",
"cmd.sketch_arc_center_point": "Arc par centre",
"cmd.sketch_arc_tangent": "Arc tangent",
@@ -2163,6 +2165,8 @@
"prefs.bottom_centre": "Bas centre",
"prefs.bottom_right": "Bas droite",
"prefs.browser": "Navigateur",
"prefs.browser_body_features": "Fonctions sous les corps",
"prefs.browser_body_features_note": "Chaque corps du navigateur se déplie sur les fonctions de l'historique qui l'ont construit, dans l'ordre de l'historique.",
"prefs.browser_components_open": "Composants dépliés au départ",
"prefs.browser_components_open_note": "Chaque composant du navigateur commence déplié, comme avant, au lieu de rester replié jusqu'à ce que vous le dépliiez.",
"prefs.browser_eye_left": "Œil à côté de la flèche de dépliage",
@@ -5015,6 +5019,8 @@
"ui.renderer": "Moteur de rendu",
"ui.repeat": "Répéter",
"ui.repeat_nothing": "Rien à répéter pour l'instant : lancer d'abord un outil ou une boîte de dialogue.",
"ui.replace_sketch.from_file": "Depuis un fichier de conception…",
"ui.replace_sketch.no_sketches": "Cette conception ne contient aucune esquisse",
"ui.replace_with_link_done": "Les copies sont maintenant des placements de la conception liée.",
"ui.report_a_bug": "Signaler un bug",
"ui.reset": "Réinitialiser",
+14
View File
@@ -265,6 +265,7 @@
"Edge": "מקצוע",
"Edges": "מקצועות",
"Edit": "עריכה",
"Edit Holes": "עריכת חורים",
"Edit Profile Sketch": "עריכת סקיצת הפרופיל",
"Edit shortcuts": "עריכת קיצורי דרך",
"Enable rule": "הפעלת הכלל",
@@ -1206,6 +1207,7 @@
"cmd.material_properties_directional": "מודול אלסטיות כיווני",
"cmd.material_properties_validate": "אימות נתוני חומר פיזיקלי",
"cmd.mesh_convert": "המרת רשת משולשים",
"cmd.model_body_features": "הצגת פיצ'רים של גוף",
"cmd.model_edges": "רשימת מקצועות",
"cmd.model_faces": "רשימת פאות",
"cmd.model_threads": "רשימת הברגות",
@@ -1366,6 +1368,7 @@
"cmd.sketch.construction_line.tangent_external": "משיק דרך נקודה חיצונית",
"cmd.sketch.construction_line.tangent_two": "משיק משותף",
"cmd.sketch.construction_line.vertical": "ישר עזר אנכי",
"cmd.sketch.replace": "החלפת סקיצה",
"cmd.sketch_arc3d": "קשת תלת־ממדית",
"cmd.sketch_arc_center_point": "קשת מרכז ונקודה",
"cmd.sketch_arc_tangent": "קשת משיקה",
@@ -2016,6 +2019,7 @@
"help.view.visual_style": "החלפת אופן הציור של המודל: מוצלל עם מקצועות או בלעדיהם, מסגרת תיל, או עם מקצועות מוסתרים. מסגרת תיל מאפשרת לראות פנימה, ומוצלל נותן מראה נקי.",
"history.sketch_under_constrained": "אילוצים חסרים ({count} דרגות חופש)",
"history.sketch_under_constrained_one": "אילוצים חסרים (דרגת חופש {count})",
"hole_folder.applies_to": "השינויים חלים על {count} החורים ב-{folder}; כל חור שומר על מיקומו.",
"hole_presets.confirm_delete": "אישור מחיקה",
"hole_presets.delete": "מחיקה",
"hole_presets.delete_prompt": "למחוק את ההגדרה המוכנה האישית הזו? חורים קיימים לא ישתנו.",
@@ -2515,6 +2519,8 @@
"prefs.bottom_centre": "למטה במרכז",
"prefs.bottom_right": "למטה מימין",
"prefs.browser": "עץ התכן",
"prefs.browser_body_features": "פיצ'רים מתחת לגופים",
"prefs.browser_body_features_note": "כל גוף בדפדפן נפתח ומציג את הפיצ'רים מציר הזמן שבנו אותו, לפי סדר ציר הזמן.",
"prefs.browser_components_open": "רכיבים פתוחים מההתחלה",
"prefs.browser_components_open_note": "כל רכיב בעץ התכן מתחיל פתוח, כמו קודם, במקום סגור עד שתפתח אותו.",
"prefs.browser_eye_left": "סמל העין ליד חץ הקיפול",
@@ -2555,7 +2561,11 @@
"prefs.design_tools.degrees": "מעלות",
"prefs.design_tools.double_click_help": "או לעריכת רכיב מקושר במקומו.",
"prefs.design_tools.ghost": "הצגת המיקום הקודם של חלקים שהוזזו כרפאים",
"prefs.design_tools.hole_group": "קיבוץ חורים שנוצרו יחד",
"prefs.design_tools.hole_group_help": "חורים שנוצרו בפעולה אחת חולקים תיקייה בהיסטוריה, סגורה בהתחלה; פתחו אותה כדי לערוך חור בנפרד.",
"prefs.design_tools.hole_moves_help": "בחירה חוזרת בפאה מזיזה את החור במקום להסירו.",
"prefs.design_tools.hole_several": "כמה חורים בפאה אחת",
"prefs.design_tools.hole_several_help": "בחירה במקום אחר בפאה מוסיפה חור נוסף; בחירה בחור מסירה אותו.",
"prefs.design_tools.hole_snaps_help": "אמצע הפאה ומרכזי הקצוות העגולים שלה.",
"prefs.design_tools.imported_files": "קבצים מיובאים",
"prefs.design_tools.note": "הערה",
@@ -2637,6 +2647,8 @@
"prefs.history": "ציר הזמן",
"prefs.history_bar_groups": "קיפול סרגל ציר הזמן לקבוצות",
"prefs.history_bar_groups_note": "בציר זמן ארוך, רצף פיצ'רים של רכיב אחד מתקפל לשבב אחד, ותיקיות פיצ'רים מתחילות מקופלות. מכלול STEP שהוכנס נכנס לתיקיית Group. כשהאפשרות כבויה, כל פיצ'ר מוצג, התיקיות מתחילות פתוחות ולא נוצרת תיקייה.",
"prefs.history_folder_kind_icon": "סמל התיקייה לפי התכונות שבה",
"prefs.history_folder_kind_icon_note": "תיקייה בהיסטוריה שכל התכונות בה מאותו סוג מציגה את הסמל שלהן.",
"prefs.history_group_joints": "קיבוץ מפרקים בציר הזמן",
"prefs.history_group_joints_note": "שני מפרקים או יותר מוצגים ככפתור אחד עם מספרם, ולחיצה מציגה כל אחד מהם. כשהאפשרות כבויה, כל מפרק מוצג בנפרד.",
"prefs.history_origin_bodies": "הצגת הראשית והגופים למעלה",
@@ -5906,6 +5918,8 @@
"ui.renderer": "מנוע גרפי",
"ui.repeat": "חזרה",
"ui.repeat_nothing": "אין עדיין מה לחזור עליו: התחל קודם כלי או תיבת דו־שיח.",
"ui.replace_sketch.from_file": "מקובץ תכנון…",
"ui.replace_sketch.no_sketches": "בתכנון הזה אין סקיצה לקחת",
"ui.replace_with_link_done": "העותקים הם עכשיו מיקומים של התכן המקושר.",
"ui.report_a_bug": "דיווח על באג",
"ui.report_characters_left_one": "תווים שנותרו: {count}",
+14
View File
@@ -265,6 +265,7 @@
"Edge": "エッジ",
"Edges": "エッジ",
"Edit": "編集",
"Edit Holes": "穴を編集",
"Edit Profile Sketch": "プロファイルスケッチを編集",
"Edit shortcuts": "ショートカットを編集",
"Enable rule": "ルールを有効化",
@@ -1206,6 +1207,7 @@
"cmd.material_properties_directional": "方向別弾性率",
"cmd.material_properties_validate": "物理材料データを検証",
"cmd.mesh_convert": "メッシュを変換",
"cmd.model_body_features": "ボディのフィーチャーを一覧表示",
"cmd.model_edges": "エッジを一覧表示",
"cmd.model_faces": "面を一覧表示",
"cmd.model_threads": "ねじを一覧表示",
@@ -1366,6 +1368,7 @@
"cmd.sketch.construction_line.tangent_external": "外部点を通る接線",
"cmd.sketch.construction_line.tangent_two": "共通接線",
"cmd.sketch.construction_line.vertical": "垂直補助直線",
"cmd.sketch.replace": "スケッチを置き換え",
"cmd.sketch_arc3d": "3D 円弧",
"cmd.sketch_arc_center_point": "中心点円弧",
"cmd.sketch_arc_tangent": "正接円弧",
@@ -2016,6 +2019,7 @@
"help.view.visual_style": "モデルの描画方法(エッジ付きまたはなしのシェード、ワイヤーフレーム、隠線付き)を切り替えます。内部を見るにはワイヤーフレーム、すっきり見せるにはシェードを使います。",
"history.sketch_under_constrained": "拘束不足(自由度 {count})",
"history.sketch_under_constrained_one": "拘束不足(自由度 {count})",
"hole_folder.applies_to": "変更は {folder} の {count} 個の穴に適用されます。各穴の位置はそのままです。",
"hole_presets.confirm_delete": "削除を確認",
"hole_presets.delete": "削除",
"hole_presets.delete_prompt": "この個人用プリセットを削除しますか?既存の穴は変更されません。",
@@ -2515,6 +2519,8 @@
"prefs.bottom_centre": "下中央",
"prefs.bottom_right": "右下",
"prefs.browser": "ブラウザー",
"prefs.browser_body_features": "ボディの下にフィーチャー",
"prefs.browser_body_features_note": "ブラウザーの各ボディを展開すると、そのボディを作ったタイムラインのフィーチャーがタイムライン順に表示されます。",
"prefs.browser_components_open": "コンポーネントを最初から展開",
"prefs.browser_components_open_note": "ブラウザーのすべてのコンポーネントを以前と同じく最初から展開して表示します。オフの場合は展開するまで折りたたまれています。",
"prefs.browser_eye_left": "折りたたみ矢印の隣の目のアイコン",
@@ -2555,7 +2561,11 @@
"prefs.design_tools.degrees": "度",
"prefs.design_tools.double_click_help": "リンクされたコンポーネントをその場で編集することもできます。",
"prefs.design_tools.ghost": "移動したパーツの元の位置をゴースト表示",
"prefs.design_tools.hole_group": "一緒に作成した穴をまとめる",
"prefs.design_tools.hole_group_help": "一度に作成した穴は履歴で1つのフォルダーにまとめられ、最初は閉じています。開くと穴を個別に編集できます。",
"prefs.design_tools.hole_moves_help": "面をもう一度選ぶと穴を削除せずに移動します。",
"prefs.design_tools.hole_several": "1つの面に複数の穴",
"prefs.design_tools.hole_several_help": "面の別の場所を選ぶと穴を追加し、穴を選ぶとその穴を削除します。",
"prefs.design_tools.hole_snaps_help": "面の中央と円形エッジの中心。",
"prefs.design_tools.imported_files": "インポートしたファイル",
"prefs.design_tools.note": "通知",
@@ -2637,6 +2647,8 @@
"prefs.history": "履歴",
"prefs.history_bar_groups": "履歴バーをグループにまとめる",
"prefs.history_bar_groups_note": "長い履歴では、1つのコンポーネントの連続したフィーチャーを1つのチップにまとめ、フィーチャーフォルダは閉じた状態で始まります。挿入したSTEPアセンブリはGroupフォルダに入ります。オフにすると、各フィーチャーを表示し、フォルダは開いた状態で始まり、フォルダは作成されません。",
"prefs.history_folder_kind_icon": "中のフィーチャーのアイコンをフォルダーに使う",
"prefs.history_folder_kind_icon_note": "中のフィーチャーがすべて同じ種類の履歴フォルダーは、そのアイコンで表示されます。",
"prefs.history_group_joints": "履歴でジョイントをまとめる",
"prefs.history_group_joints_note": "2つ以上のジョイントを、数を添えた1つのボタンにまとめます。クリックすると各ジョイントを表示します。オフにすると各ジョイントを個別に表示します。",
"prefs.history_origin_bodies": "原点とボディを先頭に表示",
@@ -5906,6 +5918,8 @@
"ui.renderer": "レンダラー",
"ui.repeat": "繰り返し",
"ui.repeat_nothing": "まだ繰り返すものがありません。先にツールかダイアログを開始してください。",
"ui.replace_sketch.from_file": "デザインファイルから…",
"ui.replace_sketch.no_sketches": "そのデザインには取り込めるスケッチがありません",
"ui.replace_with_link_done": "コピーはリンクされたデザインの配置になりました。",
"ui.report_a_bug": "不具合を報告",
"ui.report_characters_left_one": "残り {count} 文字",
+7
View File
@@ -1117,6 +1117,7 @@
"cmd.sketch.construction_line.tangent_external": "외부 점을 지나는 접선",
"cmd.sketch.construction_line.tangent_two": "공통 접선",
"cmd.sketch.construction_line.vertical": "수직 보조 직선",
"cmd.sketch.replace": "스케치 바꾸기",
"cmd.sketch_arc_center_point": "중심점 호",
"cmd.sketch_arc_tangent": "접선 호",
"cmd.sketch_arc_three_point": "3점 호",
@@ -1928,6 +1929,8 @@
"prefs.design_tools.degrees": "도",
"prefs.design_tools.double_click_help": "연결된 구성요소를 제자리에서 편집할 수도 있습니다.",
"prefs.design_tools.ghost": "이동한 부품의 이전 위치를 고스트로 표시",
"prefs.design_tools.hole_group": "함께 만든 구멍 그룹화",
"prefs.design_tools.hole_group_help": "한 번에 만든 구멍은 기록에서 하나의 폴더를 공유하며, 처음에는 닫혀 있습니다. 폴더를 열어 각 구멍을 개별적으로 편집하세요.",
"prefs.design_tools.hole_moves_help": "면을 다시 선택하면 구멍을 제거하지 않고 이동합니다.",
"prefs.design_tools.hole_snaps_help": "면의 가운데와 원형 모서리의 중심입니다.",
"prefs.design_tools.imported_files": "가져온 파일",
@@ -2005,6 +2008,8 @@
"prefs.history": "이력",
"prefs.history_bar_groups": "이력 막대를 그룹으로 접기",
"prefs.history_bar_groups_note": "긴 이력에서는 한 컴포넌트의 연속된 특성을 하나의 칩으로 접고, 특성 폴더는 접힌 상태로 시작합니다. 삽입한 STEP 어셈블리는 Group 폴더에 들어갑니다. 끄면 각 특성을 표시하고, 폴더를 펼친 상태로 시작하며, 폴더를 만들지 않습니다.",
"prefs.history_folder_kind_icon": "폴더 아이콘을 안의 피처에서 가져오기",
"prefs.history_folder_kind_icon_note": "피처가 모두 같은 종류인 기록 폴더는 그 피처의 아이콘을 표시합니다.",
"prefs.history_group_joints": "이력에서 조인트 묶기",
"prefs.history_group_joints_note": "조인트가 두 개 이상이면 개수가 표시된 버튼 하나로 묶고, 클릭하면 각 조인트를 표시합니다. 끄면 각 조인트를 하나씩 표시합니다.",
"prefs.history_origin_bodies": "원점과 바디를 맨 위에 표시",
@@ -5078,6 +5083,8 @@
"ui.renderer": "렌더러",
"ui.repeat": "반복",
"ui.repeat_nothing": "아직 반복할 것이 없습니다. 먼저 도구나 대화 상자를 시작하세요.",
"ui.replace_sketch.from_file": "디자인 파일에서…",
"ui.replace_sketch.no_sketches": "그 디자인에는 가져올 스케치가 없습니다",
"ui.replace_with_link_done": "복사본이 이제 링크된 디자인의 배치가 되었습니다.",
"ui.report_a_bug": "버그 신고",
"ui.reset": "재설정",
+2703 -1
View File
File diff suppressed because it is too large Load Diff
+3721 -1
View File
File diff suppressed because it is too large Load Diff
+527 -4
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -265,6 +265,7 @@
"Edge": "Cạnh",
"Edges": "Các cạnh",
"Edit": "Sửa",
"Edit Holes": "Sửa các lỗ",
"Edit Profile Sketch": "Chỉnh sửa phác thảo biên dạng",
"Edit shortcuts": "Sửa phím tắt",
"Enable rule": "Bật quy tắc",
@@ -1206,6 +1207,7 @@
"cmd.material_properties_directional": "Mô đun đàn hồi theo hướng",
"cmd.material_properties_validate": "Xác thực dữ liệu thuộc tính vật liệu",
"cmd.mesh_convert": "Chuyển đổi lưới",
"cmd.model_body_features": "Liệt kê tính năng của thân",
"cmd.model_edges": "Danh sách các cạnh",
"cmd.model_faces": "Danh sách các bề mặt",
"cmd.model_threads": "Danh sách các đoạn ren",
@@ -1366,6 +1368,7 @@
"cmd.sketch.construction_line.tangent_external": "Tiếp tuyến qua điểm ngoài",
"cmd.sketch.construction_line.tangent_two": "Tiếp tuyến chung",
"cmd.sketch.construction_line.vertical": "Đường dựng hình thẳng đứng",
"cmd.sketch.replace": "Thay thế phác thảo",
"cmd.sketch_arc3d": "Cung tròn 3D",
"cmd.sketch_arc_center_point": "Cung tròn xác định bởi tâm và điểm",
"cmd.sketch_arc_tangent": "Cung tròn tiếp tuyến",
@@ -2016,6 +2019,7 @@
"help.view.visual_style": "Đổi cách vẽ mô hình: tô bóng có hoặc không có cạnh, khung dây, hoặc hiện cạnh khuất. Dùng khung dây để nhìn vào bên trong, tô bóng để có hình ảnh gọn gàng.",
"history.sketch_under_constrained": "Thiếu ràng buộc ({count} bậc tự do)",
"history.sketch_under_constrained_one": "Thiếu ràng buộc ({count} bậc tự do)",
"hole_folder.applies_to": "Thay đổi áp dụng cho {count} lỗ trong {folder}; mỗi lỗ giữ nguyên vị trí.",
"hole_presets.confirm_delete": "Xác nhận xóa",
"hole_presets.delete": "Xóa",
"hole_presets.delete_prompt": "Xóa mẫu thiết lập sẵn cá nhân này? Các lỗ ren/khoan hiện có sẽ không thay đổi.",
@@ -2515,6 +2519,8 @@
"prefs.bottom_centre": "Giữa phía dưới",
"prefs.bottom_right": "Góc dưới bên phải",
"prefs.browser": "Cây thư mục",
"prefs.browser_body_features": "Tính năng dưới thân",
"prefs.browser_body_features_note": "Mỗi thân trong trình duyệt có thể mở ra để hiện các tính năng trên dòng thời gian đã tạo nên nó, theo thứ tự dòng thời gian.",
"prefs.browser_components_open": "Thành phần mở sẵn",
"prefs.browser_components_open_note": "Mọi thành phần trong trình duyệt đều mở sẵn như trước, thay vì đóng cho đến khi bạn mở.",
"prefs.browser_eye_left": "Biểu tượng mắt cạnh mũi tên thu gọn",
@@ -2555,7 +2561,11 @@
"prefs.design_tools.degrees": "Độ",
"prefs.design_tools.double_click_help": "Hoặc để chỉnh sửa thành phần được liên kết tại chỗ.",
"prefs.design_tools.ghost": "Hiện vị trí cũ của chi tiết đã di chuyển dưới dạng bóng mờ",
"prefs.design_tools.hole_group": "Nhóm các lỗ được tạo cùng lúc",
"prefs.design_tools.hole_group_help": "Các lỗ được tạo trong một lần dùng chung một thư mục trong Lịch sử, ban đầu được đóng; mở thư mục để sửa từng lỗ.",
"prefs.design_tools.hole_moves_help": "Chọn lại mặt sẽ di chuyển lỗ thay vì xóa lỗ.",
"prefs.design_tools.hole_several": "Nhiều lỗ trên một mặt",
"prefs.design_tools.hole_several_help": "Chọn chỗ khác trên mặt sẽ thêm một lỗ; chọn một lỗ sẽ xóa lỗ đó.",
"prefs.design_tools.hole_snaps_help": "Điểm giữa của mặt và tâm các cạnh tròn của mặt đó.",
"prefs.design_tools.imported_files": "Tệp đã nhập",
"prefs.design_tools.note": "Thông báo",
@@ -2637,6 +2647,8 @@
"prefs.history": "Lịch sử",
"prefs.history_bar_groups": "Thu gọn thanh Lịch sử thành nhóm",
"prefs.history_bar_groups_note": "Trong lịch sử dài, một chuỗi tính năng của cùng một thành phần được thu gọn thành một thẻ, và thư mục tính năng bắt đầu ở dạng thu gọn. Cụm lắp ráp STEP được chèn vào sẽ nằm trong thư mục Group. Khi tắt, mỗi tính năng đều hiện, thư mục bắt đầu ở dạng mở và không tạo thư mục nào.",
"prefs.history_folder_kind_icon": "Biểu tượng thư mục theo các tính năng bên trong",
"prefs.history_folder_kind_icon_note": "Thư mục Lịch sử có các tính năng cùng một loại sẽ hiện biểu tượng của chúng.",
"prefs.history_group_joints": "Nhóm các khớp nối trong Lịch sử",
"prefs.history_group_joints_note": "Từ hai khớp nối trở lên hiện thành một nút kèm số lượng, nhấp vào để hiện từng khớp nối. Khi tắt, mỗi khớp nối hiện riêng.",
"prefs.history_origin_bodies": "Hiển thị gốc tọa độ và thân khối ở trên cùng",
@@ -5906,6 +5918,8 @@
"ui.renderer": "Trình kết xuất hình ảnh (Renderer)",
"ui.repeat": "Lặp lại",
"ui.repeat_nothing": "Chưa có gì để lặp lại: hãy bắt đầu một công cụ hoặc hộp thoại trước.",
"ui.replace_sketch.from_file": "Từ một tệp thiết kế…",
"ui.replace_sketch.no_sketches": "Thiết kế đó không có phác thảo nào để lấy",
"ui.replace_with_link_done": "Các bản sao hiện đã trở thành các vị trí đặt của thiết kế liên kết.",
"ui.report_a_bug": "Báo cáo lỗi",
"ui.report_characters_left_one": "Còn {count} ký tự",
+14
View File
@@ -265,6 +265,7 @@
"Edge": "边",
"Edges": "边",
"Edit": "编辑",
"Edit Holes": "编辑孔",
"Edit Profile Sketch": "编辑轮廓草图",
"Edit shortcuts": "编辑快捷键",
"Enable rule": "启用规则",
@@ -1206,6 +1207,7 @@
"cmd.material_properties_directional": "方向弹性模量",
"cmd.material_properties_validate": "验证物理材料数据",
"cmd.mesh_convert": "转换网格",
"cmd.model_body_features": "列出实体特征",
"cmd.model_edges": "列出边",
"cmd.model_faces": "列出面",
"cmd.model_threads": "列出螺纹",
@@ -1366,6 +1368,7 @@
"cmd.sketch.construction_line.tangent_external": "过外部点的切线",
"cmd.sketch.construction_line.tangent_two": "公切线",
"cmd.sketch.construction_line.vertical": "竖直构造直线",
"cmd.sketch.replace": "替换草图",
"cmd.sketch_arc3d": "三维圆弧",
"cmd.sketch_arc_center_point": "中心点圆弧",
"cmd.sketch_arc_tangent": "相切圆弧",
@@ -2016,6 +2019,7 @@
"help.view.visual_style": "切换模型的绘制方式:带边或不带边的着色、线框,或带隐藏边。用线框看内部,用着色得到干净的外观。",
"history.sketch_under_constrained": "欠约束({count} 个自由度)",
"history.sketch_under_constrained_one": "欠约束({count} 个自由度)",
"hole_folder.applies_to": "更改将应用于 {folder} 中的 {count} 个孔;每个孔保持原位置。",
"hole_presets.confirm_delete": "确认删除",
"hole_presets.delete": "删除",
"hole_presets.delete_prompt": "删除此个人预设?现有孔不会改变。",
@@ -2515,6 +2519,8 @@
"prefs.bottom_centre": "底部居中",
"prefs.bottom_right": "右下角",
"prefs.browser": "浏览器",
"prefs.browser_body_features": "实体下显示特征",
"prefs.browser_body_features_note": "浏览器中的每个实体都可展开,按时间线顺序显示构建它的时间线特征。",
"prefs.browser_components_open": "组件初始展开",
"prefs.browser_components_open_note": "浏览器中的每个组件都像以前一样初始展开,而不是在您展开前保持折叠。",
"prefs.browser_eye_left": "折叠箭头旁的眼睛图标",
@@ -2555,7 +2561,11 @@
"prefs.design_tools.degrees": "度",
"prefs.design_tools.double_click_help": "或就地编辑链接的组件。",
"prefs.design_tools.ghost": "以虚影显示已移动零件的原位置",
"prefs.design_tools.hole_group": "将一起创建的孔分组",
"prefs.design_tools.hole_group_help": "一次创建的孔在历史中共用一个文件夹,初始为折叠;展开后可单独编辑每个孔。",
"prefs.design_tools.hole_moves_help": "再次选择该面会移动孔而非移除孔。",
"prefs.design_tools.hole_several": "一个面上多个孔",
"prefs.design_tools.hole_several_help": "在面的其他位置选择会再添加一个孔;选择孔会移除该孔。",
"prefs.design_tools.hole_snaps_help": "面的中点及其圆形边的圆心。",
"prefs.design_tools.imported_files": "导入的文件",
"prefs.design_tools.note": "提示",
@@ -2637,6 +2647,8 @@
"prefs.history": "历史",
"prefs.history_bar_groups": "将历史栏折叠为分组",
"prefs.history_bar_groups_note": "在较长的历史中,同一组件的连续特征折叠为一个标签,特征文件夹开始时为折叠状态。插入的 STEP 装配体放入 Group 文件夹。关闭后显示每个特征,文件夹开始时为展开状态,且不创建文件夹。",
"prefs.history_folder_kind_icon": "文件夹使用其中特征的图标",
"prefs.history_folder_kind_icon_note": "历史中特征全部属于同一类型的文件夹显示该类型的图标。",
"prefs.history_group_joints": "在历史中将关节分组",
"prefs.history_group_joints_note": "两个或更多关节显示为一个带数量的按钮,单击即可显示每个关节。关闭后逐个显示每个关节。",
"prefs.history_origin_bodies": "在顶部显示原点和实体",
@@ -5906,6 +5918,8 @@
"ui.renderer": "渲染器",
"ui.repeat": "重复",
"ui.repeat_nothing": "还没有可重复的操作:请先启动一个工具或对话框。",
"ui.replace_sketch.from_file": "从设计文件…",
"ui.replace_sketch.no_sketches": "该设计中没有可用的草图",
"ui.replace_with_link_done": "这些副本现在是链接设计的放置。",
"ui.report_a_bug": "报告缺陷",
"ui.report_characters_left_one": "剩余 {count} 个字符",
+62 -1
View File
@@ -196,6 +196,45 @@ fn edge_pick<'a>(st: &'a ModelState, at: Vec3, name: &Option<String>) -> Result<
}
}
/// The closest point on a polyline to `target`, projecting onto each segment rather than
/// choosing only its vertices. This keeps an edge pick on the edge between tessellation points.
fn closest_point_on_polyline(pts: &[Vec3], target: Vec3) -> Option<Vec3> {
let mut best: Option<(f64, Vec3)> = None;
for w in pts.windows(2) {
let (a, b) = (w[0], w[1]);
let ab = b - a;
let len_sq = ab.dot(ab);
let q = if len_sq < 1e-18 {
a
} else {
let t = ((target - a).dot(ab) / len_sq).clamp(0.0, 1.0);
a + ab * t
};
let d = target.dist(q);
if best.as_ref().is_none_or(|(bd, _)| d < *bd) {
best = Some((d, q));
}
}
best.map(|(_, q)| q)
}
/// The point on an edge nearest a pick location, projecting onto exact edge geometry
/// (such as `kernel::edge_circle`) when available, else onto its drawn polyline segments.
fn edge_point_at_pick(st: &ModelState, pick: Vec3, name: &Option<String>) -> Option<Vec3> {
let (b, i, pts) = edge_pick(st, pick, name).ok()?;
if let Some(c) = kernel::edge_circle(&b.body, i) {
let v = pick - c.center;
let v_perp = v - *c.axis * v.dot(*c.axis);
if let Some(dir) = v_perp.normalized() {
return Some(c.center + dir * c.radius);
}
}
// Until the kernel can project onto any edge geometry (circles are exact above via
// kernel::edge_circle), the fallback projects onto the drawn polyline. This is exact for
// straight edges and approximates splines within mesh display tolerance.
closest_point_on_polyline(&pts, pick)
}
/// The circle a picked edge lies on, from its exact curve (`kernel::edge_circle`) when the drawn
/// edge lies on it, else fitted to the drawn points: (centre, unit normal).
fn edge_circle(b: &ModelBody, index: usize, pts: &[Vec3]) -> Option<(Vec3, Vec3)> {
@@ -743,7 +782,29 @@ pub(crate) fn resolve_geo_plane(ctx: &Ctx, p: &PlaneRef) -> Result<Plane> {
PlaneRef::AxisPoint { axis, point: at } => {
let (origin, dir) = line(ctx, axis)?;
let dir = dir.normalized().ok_or_else(|| bad("the axis has no direction"))?;
let delta = point(ctx, at)? - origin;
let p = match point(ctx, at) {
Ok(p) => {
let delta = p - origin;
let across = delta - dir * delta.dot(dir);
if across.len().is_finite() && across.len() >= 1e-9 {
p
} else if let GeoRef::Edge { at: pick, name } = at.as_ref() {
// When the centre of a circular edge lies on the axis, resolve to the picked point on the edge.
edge_point_at_pick(ctx.st, *pick, name).unwrap_or(p)
} else {
p
}
}
Err(e) => {
// Non-circular edges are not converted to centres by `point`; resolve to the picked edge point.
if let GeoRef::Edge { at: pick, name } = at.as_ref() {
edge_point_at_pick(ctx.st, *pick, name).ok_or(e)?
} else {
return Err(e);
}
}
};
let delta = p - origin;
let across = delta - dir * delta.dot(dir);
if !across.len().is_finite() || across.len() < 1e-9 {
return Err(bad("the point is on the axis: pick a point off the axis to define a unique plane"));
+178 -71
View File
@@ -143,7 +143,7 @@ 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]>> {
pub fn joint_faces(&self) -> Result<Arc<[kernel::FaceInfo]>> {
let measure = || {
#[cfg(test)]
cost_tests::count(|c| c.joint_faces += 1);
@@ -1161,6 +1161,18 @@ pub struct Model {
placement_inputs: Option<placement_reuse::Inputs>,
/// All evaluated features use supported local inputs; computed with the existing owner index.
placement_local: bool,
/// A failed sketch shown while it is edited ([`Model::hold_failed_sketch`]).
held: Option<HeldSketch>,
}
/// A failed sketch built for its editor (#12085): `shown` is the final state `base` with that
/// sketch in it. [`Model::state`] gives it only while the results still end in `base`, so the
/// next evaluation, or anything else that changes the results, lets it go.
#[derive(Clone, Debug)]
struct HeldSketch {
feature: u64,
base: Arc<ModelState>,
shown: Arc<ModelState>,
}
/// Parameters a feature uses directly (inputs, sketch dimensions and the expressions of a
@@ -1647,6 +1659,21 @@ impl PlacementReads {
}
}
/// The bodies To / From Object's stored face names find, each where it is placed: evaluation
/// finds a named face by its name ([`with_named_face_points`] fails a feature whose name finds
/// none). Everything when a name is missing or empty, finds no one body, or `st` is `None`:
/// the face is then found by its point, on whatever body lies there.
fn object_faces(&mut self, st: Option<&ModelState>, names: Option<&[String]>) {
let bodies: Option<Vec<&str>> = st.zip(names).and_then(|(st, names)| {
let first_of_its_name = |b: &&ModelBody| st.body(&b.name).is_some_and(|first| std::ptr::eq(first, *b));
names.iter().map(|n| crate::name_lookup::face_body(st, n).filter(first_of_its_name).map(|b| b.name.as_str())).collect()
});
match bodies {
Some(bodies) => bodies.into_iter().for_each(|b| self.placed_body(st, b)),
None => self.all = true,
}
}
/// The same target selection as `apply_traced`, expanded only over the timeline input.
fn drawn_targets(&mut self, st: Option<&ModelState>, f: &Feature) {
let Some(st) = st else {
@@ -1826,11 +1853,22 @@ fn placement_digest(doc: &Document, f: &Feature, st: Option<&ModelState>) -> u64
}
}
}
// The faces To / From Object names, in `face_points` order.
let object_names = match &f.kind {
FeatureKind::Extrude { extent, .. } => usize::from(extent.to.is_some()) + usize::from(extent.from.is_some()),
FeatureKind::Revolve { to, .. } => usize::from(to.is_some()),
_ => 0,
};
match &f.kind {
// To / From Object finds its target where that target's component is placed (#928),
// over every body.
FeatureKind::Extrude { extent, .. } if extent.to.is_some() || extent.from.is_some() => reads.all = true,
FeatureKind::Revolve { to: Some(_), .. } => reads.all = true,
// To / From Object finds its target where the target's body is placed (#928): only that
// body's placement, not every part's (#6325). Its tools meet their targets as other
// joins and cuts do.
kind if object_names > 0 => {
reads.object_faces(st, f.face_names.get(..object_names));
if drawn_tools_change_bodies(kind) {
reads.drawn_targets(st, f);
}
}
// A mirror of placed bodies follows the placements it was picked at and its own component's
// frame, not every occurrence: moving an unrelated one leaves it (and its cache) alone.
FeatureKind::Mirror { placements, bodies, .. } if !placements.is_empty() => return mirror_placements_digest(doc, f, placements, bodies),
@@ -1978,9 +2016,56 @@ impl Model {
/// The final model state.
pub fn state(&self) -> Arc<ModelState> {
let last = self.evaluated_state();
match &self.held {
Some(held) if Arc::ptr_eq(&held.base, &last) => held.shown.clone(),
_ => last,
}
}
/// The state after the last feature evaluated, without a failed sketch held for its editor.
fn evaluated_state(&self) -> Arc<ModelState> {
self.results.iter().rev().find(|r| !r.skipped).map(|r| r.state.clone()).unwrap_or_else(|| self.empty.clone())
}
/// Show the sketch `feature` while it is edited although it failed (#12085): a link whose
/// named face or edge is gone fails the sketch, so the evaluation keeps no sketch for it, and
/// its editor had nothing to show or repair. Here the sketch is built as it is evaluated, but
/// with such a link marked lost and kept where it was (no other face or edge is taken for
/// it), and added to the final state only: the sketch's result keeps its error and the
/// features after it still fail. `None`, a sketch that did not fail, or one that fails for
/// another reason too, shows nothing more. Returns whether a held sketch is shown.
pub fn hold_failed_sketch(&mut self, doc: &Document, feature: Option<u64>) -> bool {
let Some(id) = feature else {
self.held = None;
return false;
};
let base = self.evaluated_state();
if self.held.as_ref().is_some_and(|held| held.feature == id && Arc::ptr_eq(&held.base, &base)) {
return true;
}
self.held = None;
if base.sketch(id).is_some() || !self.result(id).is_some_and(|r| r.error.is_some() && !r.skipped && !r.stale) {
return false;
}
let Some(f) = doc.feature(id) else { return false };
let FeatureKind::Sketch { plane, sketch } = &f.kind else { return false };
let mut next = (*self.state_before(id)).clone();
let vals = evaluation_values(doc).0;
let mut warning = None;
let built = kernel::guard(&f.name, || {
let _frames = ToolFrames::install(doc, f, &next);
eval_sketch(doc, &vals, f, plane, sketch, &mut next, &mut warning, crate::project::LostNames::Mark)
.map_err(|e| kernel::KernelError::Failed(e.to_string()))
});
let Some(solved) = built.ok().and_then(|()| next.sketches.iter().find(|s| s.feature == id).cloned()) else { return false };
let mut shown = (*base).clone();
shown.sketches.retain(|s| s.feature != id);
shown.sketches.push(solved);
self.held = Some(HeldSketch { feature: id, base, shown: Arc::new(shown) });
true
}
/// State just before the feature with this id (for editing a sketch in context).
pub fn state_before(&self, id: u64) -> Arc<ModelState> {
let Some(i) = self.results.iter().position(|r| r.id == id) else { return self.state() };
@@ -2144,6 +2229,10 @@ impl Model {
/// Re-evaluate the document, reusing results up to the first changed feature.
pub fn evaluate(&mut self, doc: &Document) {
// A failed sketch held for its editor is built again from the evaluated design: when the
// sketch and every feature after it fail, the results end in the state they ended in
// before, which would keep showing the sketch as it was before the edit.
self.held = None;
let _span = solidcraft_profile::span("doc", "evaluate");
self.placement_inputs = None;
self.fingerprinted = None;
@@ -7380,72 +7469,7 @@ fn eval_feature(
| FeatureKind::ReplaceFace { .. }
| FeatureKind::Align { .. }
| FeatureKind::Remove { .. } => more::eval(doc, vals, f, st, warning),
FeatureKind::Sketch { plane, sketch } => {
let anchor = crate::face_anchor::plane_face_anchor(plane);
let fst = match anchor {
Some(anchor) => crate::state_in_face_anchor_frame(doc, st, anchor, f.component)?,
// A sketch that reads no geometry (a fixed plane, no projections) needs no other
// component's bodies moved into its frame: placing every body of a large
// assembly again is what made each sketch edit as slow as the design is big.
None if !plane_reads_geometry(plane) && sketch.links.is_empty() => std::borrow::Cow::Borrowed(st),
None => state_in_frame(doc, st, f.component)?,
};
// A further placement a link reads is placed in its frame too (#9957).
let named: Vec<&str> = sketch.links.iter().flat_map(|l| l.source.bodies()).collect();
let fst = match anchor.map(|anchor| anchor.author_frame(doc, f.component)) {
_ if named.is_empty() => fst,
Some(Ok(frame)) => with_placed_copies(doc, st, fst, &frame, &named),
Some(Err(_)) => fst,
None => with_placed_copies(doc, st, fst, &doc.component_transform(f.component), &named),
};
let (plane, face_warning) = match (anchor, plane) {
(Some(_), plane) => doc.resolve_attached_plane_noted(vals, plane, f.component, st, &fst)?,
// A flat face of a sheet: the panel's exact plane, from its flat region and fold.
(None, plane) if crate::sheet::plane_sheet_face(plane).is_some() => {
(doc.resolve_plane_in(vals, &sheet_face_plane_in_frame(doc, st, f.component, plane)?, 0, Some(&fst))?, None)
}
(None, PlaneRef::Face { plane: picked, at, name, anchor: None }) => face_plane(&fst, picked, *at, name.as_deref())?,
(None, other) => (doc.resolve_plane_in(vals, other, 0, Some(&fst))?, None),
};
let mut sk = sketch.clone();
// A text's outlines are rebuilt from its numbers when the links are refreshed: the
// ones its expressions give come first.
doc.apply_text_values(vals, &mut sk)?;
let mut warns = match anchor {
Some(anchor) => crate::project::refresh_face_anchor_links(doc, vals, &fst, &plane, &mut sk, anchor)?,
None => crate::project::refresh_links(doc, vals, &fst, &plane, &mut sk)?,
};
drop(fst);
sk.refresh_offsets()?;
if let Some(w) = face_warning {
warns.insert(0, w);
}
// The stored sketch is solved for its stored values, with its links where they
// were stored: a link that moved must not change the side a dimension from it is on.
let stored = sk.driving_values();
let report = doc.solve_sketch(vals, &mut sk, &stored, Some(sketch))?;
if !report.keeps_orientation() {
warns.insert(0, report.failure(|f| format!("the sketch constraints conflict ({f})")));
} else {
// The solve may have moved the curve a text runs along.
warns.extend(crate::project::relay_path_texts(&mut sk));
}
if !warns.is_empty() {
*warning = Some(warns.join("; "));
}
sk.validate_periodic_splines()?;
// A group of touching curves too large to search whole says so, rather than
// leaving profiles out silently (#6896).
let search = solidcraft_sketch::find_profiles_checked(&sk);
if search.incomplete {
append_warning(warning, profiles_cut_short_note());
}
let profiles = search.profiles;
let ends = solidcraft_sketch::loose_ends(&sk);
st.sketches.retain(|s| s.feature != f.id);
st.sketches.push(Arc::new(SolvedSketch { feature: f.id, name: f.name.clone(), plane, sketch: sk, report, profiles, ends }));
Ok(())
}
FeatureKind::Sketch { plane, sketch } => eval_sketch(doc, vals, f, plane, sketch, st, warning, crate::project::LostNames::Fail),
FeatureKind::Extrude { operation, targets, .. }
| FeatureKind::Pipe { operation, targets, .. }
| FeatureKind::Revolve { operation, targets, .. }
@@ -8264,6 +8288,88 @@ fn eval_feature(
}
}
/// A sketch feature: its links refreshed against the model before it, solved, its profiles
/// found. `lost_names` says what a link whose named face or edge is gone does: the evaluation
/// fails the sketch ([`crate::project::LostNames::Fail`]); a failed sketch shown for editing
/// marks the link lost instead ([`Model::hold_failed_sketch`]).
#[allow(clippy::too_many_arguments)]
fn eval_sketch(
doc: &Document,
vals: &BTreeMap<String, Value>,
f: &Feature,
plane: &PlaneRef,
sketch: &solidcraft_sketch::Sketch,
st: &mut ModelState,
warning: &mut Option<String>,
lost_names: crate::project::LostNames,
) -> Result<()> {
let anchor = crate::face_anchor::plane_face_anchor(plane);
let fst = match anchor {
Some(anchor) => crate::state_in_face_anchor_frame(doc, st, anchor, f.component)?,
// A sketch that reads no geometry (a fixed plane, no projections) needs no other
// component's bodies moved into its frame: placing every body of a large
// assembly again is what made each sketch edit as slow as the design is big.
None if !plane_reads_geometry(plane) && sketch.links.is_empty() => std::borrow::Cow::Borrowed(st),
None => state_in_frame(doc, st, f.component)?,
};
// A further placement a link reads is placed in its frame too (#9957).
let named: Vec<&str> = sketch.links.iter().flat_map(|l| l.source.bodies()).collect();
let fst = match anchor.map(|anchor| anchor.author_frame(doc, f.component)) {
_ if named.is_empty() => fst,
Some(Ok(frame)) => with_placed_copies(doc, st, fst, &frame, &named),
Some(Err(_)) => fst,
None => with_placed_copies(doc, st, fst, &doc.component_transform(f.component), &named),
};
let (plane, face_warning) = match (anchor, plane) {
(Some(_), plane) => doc.resolve_attached_plane_noted(vals, plane, f.component, st, &fst)?,
// A flat face of a sheet: the panel's exact plane, from its flat region and fold.
(None, plane) if crate::sheet::plane_sheet_face(plane).is_some() => {
(doc.resolve_plane_in(vals, &sheet_face_plane_in_frame(doc, st, f.component, plane)?, 0, Some(&fst))?, None)
}
(None, PlaneRef::Face { plane: picked, at, name, anchor: None }) => face_plane(&fst, picked, *at, name.as_deref())?,
(None, other) => (doc.resolve_plane_in(vals, other, 0, Some(&fst))?, None),
};
let mut sk = sketch.clone();
// A text's outlines are rebuilt from its numbers when the links are refreshed: the
// ones its expressions give come first.
doc.apply_text_values(vals, &mut sk)?;
let mut warns = match (anchor, lost_names) {
(Some(anchor), crate::project::LostNames::Fail) => crate::project::refresh_face_anchor_links(doc, vals, &fst, &plane, &mut sk, anchor)?,
(None, crate::project::LostNames::Fail) => crate::project::refresh_links(doc, vals, &fst, &plane, &mut sk)?,
(anchor, crate::project::LostNames::Mark) => crate::project::refresh_links_marking_lost(doc, vals, &fst, &plane, &mut sk, anchor)?,
};
drop(fst);
sk.refresh_offsets()?;
if let Some(w) = face_warning {
warns.insert(0, w);
}
// The stored sketch is solved for its stored values, with its links where they
// were stored: a link that moved must not change the side a dimension from it is on.
let stored = sk.driving_values();
let report = doc.solve_sketch(vals, &mut sk, &stored, Some(sketch))?;
if !report.keeps_orientation() {
warns.insert(0, report.failure(|f| format!("the sketch constraints conflict ({f})")));
} else {
// The solve may have moved the curve a text runs along.
warns.extend(crate::project::relay_path_texts(&mut sk));
}
if !warns.is_empty() {
*warning = Some(warns.join("; "));
}
sk.validate_periodic_splines()?;
// A group of touching curves too large to search whole says so, rather than
// leaving profiles out silently (#6896).
let search = solidcraft_sketch::find_profiles_checked(&sk);
if search.incomplete {
append_warning(warning, profiles_cut_short_note());
}
let profiles = search.profiles;
let ends = solidcraft_sketch::loose_ends(&sk);
st.sketches.retain(|s| s.feature != f.id);
st.sketches.push(Arc::new(SolvedSketch { feature: f.id, name: f.name.clone(), plane, sketch: sk, report, profiles, ends }));
Ok(())
}
/// `copy`, a moved copy of `source` (instance `instance` of a pattern or mirror): its faces are
/// named after the source's.
fn moved_copy(source: &ModelBody, copy: Body, instance: usize) -> Traced {
@@ -8739,6 +8845,7 @@ impl PlacedBodies {
let mut fresh = ModelBody::new(name.to_string(), placed.body.clone(), b.feature);
fresh.mesh = placed.mesh.clone();
fresh.measured = placed.measured.clone();
fresh.joint_faces = placed.joint_faces.clone();
fresh.mesh_placement = placed.mesh_placement.clone();
fresh.plan = placed.plan.clone();
fresh.plan_mesh = placed.plan_mesh.clone();
+120
View File
@@ -656,6 +656,126 @@ fn a_tool_cutting_another_part_cuts_again_when_that_part_moves() {
assert!((volume(&model, &body) - (400.0 - 16.0)).abs() < 1e-6, "{}", volume(&model, &body));
}
/// A root design with two 10 x 10 x 4 mm plates, "Plate" at the origin and "Other" 30 mm along Y,
/// and a sketch on origin plane `plane` of the rectangle `profile` spans (opposite corners, in mm):
/// (the plate part, the other part, the sketch).
fn two_plates_and_a_sketch(doc: &mut Document, plane: &str, profile: [Vec2; 2]) -> (u64, u64, u64) {
let plate = doc.add_component(Some("Plate"), 0).unwrap();
let other = doc.add_component(Some("Other"), 0).unwrap();
place(doc, other, rigid(Vec3::new(0.0, 30.0, 0.0), Vec3::ZERO, Vec3::Z, 0.0));
plate_with_holes(doc, other, other, 0);
plate_with_holes(doc, plate, plate, 0);
let mut sk = Sketch::new();
let [a, c] = profile;
let p = [a, Vec2::new(c.x, a.y), c, Vec2::new(a.x, c.y)].map(|q| sk.add_point(q, None).unwrap());
for k in 0..4 {
sk.add_line_pts(p[k], p[(k + 1) % 4], None).unwrap();
}
let sketch = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: plane.into() }, sketch: sk }, None).unwrap();
(plate, other, sketch)
}
/// `kind` added to the root with its face points named after the faces under them, as the
/// command that makes it names them.
fn add_named(doc: &mut Document, kind: FeatureKind) -> u64 {
let mut model = Model::new();
model.evaluate(doc);
let names = face_point_names(doc, &model.state(), &kind, 0).unwrap();
assert!(names.iter().all(|n| !n.is_empty()), "{names:?}");
let id = doc.add_feature(kind, None).unwrap();
doc.feature_mut(id).unwrap().face_names = names;
id
}
/// An extrude in the root from the top of the plate (z 4) 6 mm up, over 2 to 8 mm on X and Y.
fn extrude_from_the_plate(doc: &mut Document) -> (u64, u64, u64) {
let (plate, other, sketch) = two_plates_and_a_sketch(doc, "XY", [Vec2::new(2.0, 2.0), Vec2::new(8.0, 8.0)]);
let kind = FeatureKind::Extrude {
thin: None,
sketch: SketchId(sketch),
profiles: ProfileSel::All,
extent: serde_json::from_value(serde_json::json!({"distance": "6", "from": [5.0, 5.0, 4.0]})).unwrap(),
operation: Operation::NewBody,
targets: vec![],
};
(plate, other, add_named(doc, kind))
}
/// Where the body feature `id` made has its centre.
fn centroid_of(model: &Model, id: u64) -> Vec3 {
let st = model.state();
kernel::measure(&st.bodies.iter().find(|b| b.feature == id).unwrap().body).unwrap().centroid
}
/// An extrude from a face of the plate reads where the plate is, and nothing else (#6325): a
/// joint moving another part leaves its result, and every result after it, as it was.
#[test]
fn an_extrude_from_its_own_part_keeps_its_result_when_another_part_moves() {
let mut doc = Document::new("from object");
let (plate, other, extrude) = extrude_from_the_plate(&mut doc);
let mut model = Model::new();
model.evaluate(&doc);
assert!(model.results.iter().all(|r| r.error.is_none()), "{:?}", model.results.iter().find(|r| r.error.is_some()));
assert!((centroid_of(&model, extrude).z - 7.0).abs() < 1e-6, "{:?}", centroid_of(&model, extrude));
place(&mut doc, other, rigid(Vec3::new(0.0, 40.0, 3.0), Vec3::ZERO, Vec3::Z, 0.0));
model.evaluate(&doc);
assert_eq!(model.last_recomputed, 0);
// The plate lifted 5 mm: the extrude starts on its top where it is now (#928).
place(&mut doc, plate, rigid(Vec3::new(0.0, 0.0, 5.0), Vec3::ZERO, Vec3::Z, 0.0));
model.evaluate(&doc);
assert_eq!(model.last_recomputed, 1);
assert!((centroid_of(&model, extrude).z - 12.0).abs() < 1e-6, "{:?}", centroid_of(&model, extrude));
}
/// A revolve To a face of the plate, as the extrude from it: it turns again only when the plate
/// moves.
#[test]
fn a_revolve_to_its_own_part_keeps_its_result_when_another_part_moves() {
let mut doc = Document::new("revolve to object");
let (plate, other, sketch) = two_plates_and_a_sketch(&mut doc, "XZ", [Vec2::new(12.0, 0.0), Vec2::new(15.0, 2.0)]);
let kind = FeatureKind::Revolve {
sketch: SketchId(sketch),
profiles: ProfileSel::All,
axis: AxisRef::SketchAxis { axis: "y".into() },
angle: "90 deg".into(),
operation: Operation::NewBody,
targets: vec![],
angle2: None,
symmetric: false,
convention: Default::default(),
to: Some(serde_json::from_value(serde_json::json!([5.0, 5.0, 4.0])).unwrap()),
};
let revolve = add_named(&mut doc, kind);
let mut model = Model::new();
model.evaluate(&doc);
assert!(model.results.iter().all(|r| r.error.is_none()), "{:?}", model.results.iter().find(|r| r.error.is_some()));
let turned = volume(&model, &model.state().bodies.iter().find(|b| b.feature == revolve).unwrap().name.clone());
place(&mut doc, other, rigid(Vec3::new(0.0, 40.0, 3.0), Vec3::ZERO, Vec3::Z, 0.0));
model.evaluate(&doc);
assert_eq!(model.last_recomputed, 0);
// The plate turned a quarter about Z: the revolve turns to its top where it is now.
place(&mut doc, plate, rigid(Vec3::ZERO, Vec3::ZERO, Vec3::Z, std::f64::consts::FRAC_PI_2));
model.evaluate(&doc);
assert_eq!(model.last_recomputed, 1);
let now = volume(&model, &model.state().bodies.iter().find(|b| b.feature == revolve).unwrap().name.clone());
assert!((now - turned).abs() > 1.0, "{turned} then {now}");
}
/// A From face without a name is found by its point, on whatever body lies there: moving any
/// part extrudes again.
#[test]
fn an_extrude_from_a_face_without_a_name_extrudes_again_when_any_part_moves() {
let mut doc = Document::new("from a point");
let (_, other, extrude) = extrude_from_the_plate(&mut doc);
doc.feature_mut(extrude).unwrap().face_names.clear();
let mut model = Model::new();
model.evaluate(&doc);
assert!((centroid_of(&model, extrude).z - 7.0).abs() < 1e-6, "{:?}", centroid_of(&model, extrude));
place(&mut doc, other, rigid(Vec3::new(0.0, 40.0, 3.0), Vec3::ZERO, Vec3::Z, 0.0));
model.evaluate(&doc);
assert_eq!(model.last_recomputed, 1);
}
#[test]
fn a_body_is_measured_once_while_it_stays_the_same() {
let mut doc = Document::new("measured once");
+27
View File
@@ -1122,4 +1122,31 @@ mod tests {
// The square stop leaves about half a pitch's worth of groove uncut: well over a mm³.
assert!(square > trimmed + 1.0, "{square} vs {trimmed}");
}
/// A modelled thread on one hole leaves the other hole's wall a cylinder to thread: the
/// second hole still takes its thread (#11367).
///
/// The modelled thread cuts the body, and the cut must not take the body's other cylinders
/// with it: the second hole's wall is still a cylinder, so it still takes a thread, modelled
/// or cosmetic.
#[test]
fn a_modelled_thread_leaves_the_next_hole_a_cylinder_to_thread() {
// An 80 x 80 x 20 mm block with two 5 mm holes.
let block = kernel::box_solid(Vec3::new(-40.0, -40.0, 0.0), Vec3::new(40.0, 40.0, 20.0)).unwrap();
let hole = |x: f64| kernel::cylinder(Vec3::new(x, 0.0, -1.0), Vec3::new(0.0, 0.0, 1.0), 2.5, 22.0).unwrap();
let mut body = kernel::boolean(&block, &hole(-20.0), BoolOp::Cut).unwrap().unwrap();
body = kernel::boolean(&body, &hole(20.0), BoolOp::Cut).unwrap().unwrap();
let mut st = ModelState::default();
st.bodies.push(ModelBody::new("Part".into(), body, 1));
let ask = ThreadAsk { designation: Some("M6"), ..ask() };
// The first hole takes a modelled M6 thread.
let on = thread_on(&st, 1, Vec3::new(-17.5, 0.0, 10.0), &ask).unwrap();
cut(&mut st, on, (ThreadTip::None, None, None)).unwrap();
// The second hole's wall is still a cylinder: it takes a thread too.
let on = thread_on(&st, 1, Vec3::new(22.5, 0.0, 10.0), &ask);
assert!(on.is_ok(), "the second hole still has a cylinder to thread: {:?}", on.err());
// And a modelled one cuts there as well.
let on = thread_on(&st, 1, Vec3::new(22.5, 0.0, 10.0), &ask).unwrap();
assert!(cut(&mut st, on, (ThreadTip::None, None, None)).is_ok());
}
}
+43
View File
@@ -982,6 +982,12 @@ fn call(f: &str, args: &[Value], d: Defaults) -> Result<Value, DocError> {
// Round in the value's own default unit (round(2.4 in) = 2 in for an inch parameter).
let scale = scale_bare(Value::num(1.0), a.len, a.ang, d);
let x = if scale != 0.0 { a.v / scale } else { a.v };
// Unit conversions leave a rounding error of an ulp or two (`3 in / 1 in` is
// 2.9999999999999996): a value that close to a whole number is that number, so `floor`
// does not drop to 2. The tolerance is relative to the whole number, so a tiny value
// never snaps to 0, and it stays under 1/8 so no real fraction is absorbed.
let nearest = x.round();
let x = if (x - nearest).abs() <= (4.0 * f64::EPSILON * nearest.abs()).min(0.125) { nearest } else { x };
let r = match f {
"floor" => x.floor(),
"ceil" => x.ceil(),
@@ -1444,6 +1450,43 @@ mod tests {
}
}
/// `floor`, `ceil`, `trunc` and `round` work on the number a person typed, not on float noise:
/// `3 in / 1 in` is 2.9999999999999996 in floats, and `floor` of it must still be 3.
#[test]
fn rounding_functions_ignore_float_noise() {
for (e, want) in [("floor(3 in / 1 in)", 3.0), ("ceil(3 in / 1 in)", 3.0), ("trunc(3 in / 1 in)", 3.0), ("floor(-3 in / 1 in)", -3.0)] {
assert_eq!(ev(e, Kind::Unitless).unwrap(), want, "{e}");
}
let d = Defaults { len: 25.4, ang: std::f64::consts::PI / 180.0 };
let look = |_: &str| Err(DocError::Expr("no parameters".into()));
for e in ["floor(3 in)", "ceil(3 in)", "trunc(3 in)", "round(3 in)"] {
let got = eval_with_defaults(e, &look, d).unwrap().to_kind_in(Kind::Length, d).unwrap();
assert!((got - 76.2).abs() < 1e-9, "{e} gave {got}");
}
let got = eval_with_defaults("floor(30 deg)", &look, Defaults::default()).unwrap().to_kind(Kind::Angle).unwrap();
assert!((got - 30f64.to_radians()).abs() < 1e-9, "floor(30 deg) gave {got}");
// A real fraction still rounds the way its name says, however small, and large values keep working.
for (e, want) in [
("floor(2.9999)", 2.0),
("ceil(3.0001)", 4.0),
("floor(100.99999995)", 100.0),
("ceil(100.00000005)", 101.0),
("floor(1000000000.7)", 1_000_000_000.0),
("ceil(1000000000.7)", 1_000_000_001.0),
("trunc(1000000000.7)", 1_000_000_000.0),
("round(1000000000.7)", 1_000_000_001.0),
("ceil(1e-16)", 1.0),
("floor(-1e-16)", -1.0),
("trunc(1e-16)", 0.0),
("trunc(-1e-16)", 0.0),
("round(1e-16)", 0.0),
("round(-1e-16)", 0.0),
("floor(1000000000000000.5)", 1_000_000_000_000_000.0),
] {
assert_eq!(ev(e, Kind::Unitless).unwrap(), want, "{e}");
}
}
/// A count from a length (`(L - 20) / 210`, the 210 read as unit-less) names the fix (#5901).
#[test]
fn a_length_where_a_count_is_expected_says_to_divide_by_a_length() {
+21
View File
@@ -339,6 +339,27 @@ pub fn locate_face(st: &ModelState, name: &str, p: Vec3) -> Option<FaceHit> {
locate_in(st.bodies.iter().enumerate().filter(|(_, b)| !b.body.is_mesh()), name, p).ok()
}
/// The one body of `st` with a face the stored face name finds, as [`locate_face`] looks for it.
/// `None` when no body has one, several do, or the name is one saved before format 14.
pub(crate) fn face_body<'a>(st: &'a ModelState, name: &str) -> Option<&'a ModelBody> {
if let Some((body, _)) = crate::sheet::split_sheet_face_name(name) {
return st.bodies.iter().find(|b| b.name == body && !b.body.is_mesh());
}
if name.is_empty() || legacy_name(name).is_some() {
return None;
}
let solids = || st.bodies.iter().filter(|b| !b.body.is_mesh());
naming::warm_names(solids());
let mut holders = solids().filter(|b| {
let names = naming::names(b);
(0..names.faces.len()).any(|face| crate::history_names::matches(&names, face, name).is_some_and(exactly))
});
match (holders.next(), holders.next()) {
(Some(b), None) => Some(b),
_ => None,
}
}
/// [`locate_face`] for a feature that works on the faces of the body called `body` (a shell, a
/// draft…): the face of that body the name finds, found without working out the names of every
/// other body (a body named from geometry is meshed for it: seconds in a large model). When
+45 -8
View File
@@ -120,6 +120,22 @@ pub fn profile_fallback_notes(ss: &SolvedSketch, selection: &ProfileSel) -> Vec<
profile_selection_indices_lenient(ss, selection).map(|(_, notes)| notes).unwrap_or_default()
}
/// A curve that is a line, an arc or a circle, and not the output of a free-form offset.
fn is_analytic(sketch: &solidcraft_sketch::Sketch, id: &str) -> bool {
sketch.curves.iter().any(|curve| {
curve.id == id
&& matches!(
curve.kind,
solidcraft_sketch::CurveKind::Line { .. } | solidcraft_sketch::CurveKind::Arc { .. } | solidcraft_sketch::CurveKind::Circle { .. }
)
}) && !sketch.freeform_offsets.iter().any(|offset| offset.output == id)
}
/// A loop made only of analytic curves: how many pieces each is cut into does not matter.
fn is_analytic_loop(sketch: &solidcraft_sketch::Sketch, curves: &[String]) -> bool {
!curves.is_empty() && curves.iter().all(|id| is_analytic(sketch, id))
}
fn quoted(ids: &[String]) -> String {
ids.iter().map(|id| format!("`{id}`")).collect::<Vec<_>>().join(", ")
}
@@ -135,17 +151,32 @@ fn quoted(ids: &[String]) -> String {
/// them; a tie goes to the one with the fewest curves the loop did not have, and a tie left
/// after that is not guessed.
///
/// Loops of offset curves and legacy graph-span multisets (a curve listed once per piece) keep
/// their exact meaning and never fall back.
/// Analytic loops (lines, arcs and circles only) may list a curve more than once, as the pieces a
/// circle was cut into when the loop was saved; the repeats are one curve here, since a sketch
/// edit changes how many pieces a curve has but not the region it bounds. The outputs of an
/// analytic offset (`sketch.offsets`) take step 1 only: their loop closes on its own or the pick
/// is lost, a partial match by identifiers never stands for it. Loops with free-form offset
/// outputs, free curves (splines, ellipses, conics) and legacy graph-span multisets keep their
/// exact meaning and never fall back.
fn fallback_loop(ss: &SolvedSketch, curves: &[String]) -> Option<(Vec<usize>, String)> {
let sketch = &ss.sketch;
let analytic_loop = is_analytic_loop(sketch, curves);
let deduped: Vec<String> = if analytic_loop {
let mut seen = BTreeSet::new();
curves.iter().filter(|id| seen.insert(id.as_str())).cloned().collect()
} else {
curves.to_vec()
};
let curves = deduped.as_slice();
let stored: BTreeSet<&str> = curves.iter().map(String::as_str).collect();
if stored.is_empty() || stored.len() != curves.len() || curves.len() > 512 || ss.profiles.len() > 10_000 {
return None;
}
let sketch = &ss.sketch;
let offset =
|id: &str| sketch.offsets.iter().any(|offset| offset.owns_output(id)) || sketch.freeform_offsets.iter().any(|offset| offset.output == id);
if stored.iter().any(|id| offset(id)) {
if stored.iter().any(|id| sketch.freeform_offsets.iter().any(|offset| offset.output == *id)) {
return None;
}
let from_offset = stored.iter().any(|id| sketch.offsets.iter().any(|offset| offset.owns_output(id)));
if from_offset && !analytic_loop {
return None;
}
let drawn = |id: &str| sketch.curves.iter().any(|curve| curve.id == id && !curve.construction && !curve.centerline);
@@ -180,6 +211,9 @@ fn fallback_loop(ss: &SolvedSketch, curves: &[String]) -> Option<(Vec<usize>, St
}
}
}
if from_offset {
return None;
}
// 2. The closest loop by the curves it shares.
let count = |profile: &solidcraft_sketch::Profile, inside: bool| {
profile.outer_curves.iter().map(String::as_str).filter(|id| stored.contains(id) == inside).collect::<BTreeSet<_>>().len()
@@ -231,9 +265,12 @@ fn selection_indices(ss: &SolvedSketch, selection: &ProfileSel, mut notes: Optio
}
indices.push(unique_profile(ss, &ss.sketch.offset_boundary(id)?)?);
} else {
// Repeated saved IDs identify exact graph spans. Their multiplicity
// must not silently turn into a whole-source certificate on resize.
// Repeated saved IDs of free curves identify exact graph spans. Their
// multiplicity must not silently turn into a whole-source certificate on
// resize. Lines, arcs and circles are exempt: how many pieces such a curve is
// cut into changes with every line drawn across it (#9368).
if curves.iter().collect::<BTreeSet<_>>().len() != curves.len()
&& !is_analytic_loop(&ss.sketch, curves)
&& !ss.profiles.iter().any(|profile| same_ids(curves, &profile.outer_curves))
{
return Err(crate::curve_pick_error(solidcraft_sketch::CurvePick::Missing, curves, &ss.name));
+192 -16
View File
@@ -912,7 +912,31 @@ pub fn refresh_face_anchor_links(
sk: &mut Sketch,
anchor: &crate::FaceAnchor,
) -> Result<Vec<String>> {
refresh_links_in(doc, vals, st, plane, sk, Some(anchor))
refresh_links_in(doc, vals, st, plane, sk, Some(anchor), LostNames::Fail)
}
/// What a link kept by the name of a face or edge does when the name finds nothing any more.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum LostNames {
/// The sketch fails, asking for the reference to be picked again ([`DocError::Reselect`]).
Fail,
/// The link is marked lost and keeps its last geometry, with a warning naming the reference.
/// No other face or edge is taken for it. Only for showing a failed sketch while it is edited
/// (#12085), never for the evaluation the features after it build from.
Mark,
}
/// [`refresh_links`] (or [`refresh_face_anchor_links`] with `anchor`) that marks a link whose
/// named face or edge is gone lost instead of failing ([`LostNames::Mark`]).
pub(crate) fn refresh_links_marking_lost(
doc: &Document,
vals: &BTreeMap<String, Value>,
st: &ModelState,
plane: &Plane,
sk: &mut Sketch,
anchor: Option<&crate::FaceAnchor>,
) -> Result<Vec<String>> {
refresh_links_in(doc, vals, st, plane, sk, anchor, LostNames::Mark)
}
/// Remove degree-two straight stations introduced by coplanar face subdivision.
@@ -989,7 +1013,7 @@ fn merge_family_lines(geom: Vec<LinkGeom>) -> Result<Vec<LinkGeom>> {
/// longer finds exactly that face or edge is not lost: the sketch fails, asking for the
/// reference to be picked again ([`DocError::Reselect`]).
pub fn refresh_links(doc: &Document, vals: &BTreeMap<String, Value>, st: &ModelState, plane: &Plane, sk: &mut Sketch) -> Result<Vec<String>> {
refresh_links_in(doc, vals, st, plane, sk, None)
refresh_links_in(doc, vals, st, plane, sk, None, LostNames::Fail)
}
/// Lay every text that runs along a curve of its sketch along that curve as the solve left it.
@@ -1025,6 +1049,7 @@ fn refresh_links_in(
plane: &Plane,
sk: &mut Sketch,
anchor: Option<&crate::FaceAnchor>,
lost_names: LostNames,
) -> Result<Vec<String>> {
let mut warn = Vec::new();
let links = sk.links.clone();
@@ -1034,16 +1059,17 @@ fn refresh_links_in(
if !l.auto_face
&& let (Some(name), LinkSource::Face { body, at }) = (&l.face_name, &mut source)
{
let (index, p) = st
.body(body)
.and_then(|target| {
if l.kind == LinkKind::Project {
crate::name_lookup::locate_projected_face(target, name, *at)
} else {
crate::naming::face_on_body(target, name, *at)
}
})
.ok_or_else(|| DocError::reselect(name.as_str()))?;
let found = st.body(body).and_then(|target| {
if l.kind == LinkKind::Project {
crate::name_lookup::locate_projected_face(target, name, *at)
} else {
crate::naming::face_on_body(target, name, *at)
}
});
let Some((index, p)) = found else {
lose_named(sk, &l.id, name, lost_names, &mut warn)?;
continue;
};
*at = p;
named_face = Some((body.clone(), index, p));
}
@@ -1051,7 +1077,10 @@ fn refresh_links_in(
// edge has the name any more no other edge is taken for it: the sketch fails.
let mut named_edge = None;
if let (Some(name), LinkSource::Edge { body, at }) = (&l.edge_name, &source) {
let (body, hit) = locate_named_edge(st, body, name, *at).ok_or_else(|| DocError::reselect(name.as_str()))?;
let Some((body, hit)) = locate_named_edge(st, body, name, *at) else {
lose_named(sk, &l.id, name, lost_names, &mut warn)?;
continue;
};
warn.extend(hit.note(name).map(|note| format!("projected {note}")));
named_edge = Some((body, hit.edge, *at));
}
@@ -1119,6 +1148,19 @@ fn refresh_links_in(
Ok(warn)
}
/// A link whose face or edge `name` finds nothing: the sketch fails asking for it again, or (only
/// while a failed sketch is shown for editing) the link is marked lost, with a warning naming it.
fn lose_named(sk: &mut Sketch, id: &str, name: &str, lost_names: LostNames, warn: &mut Vec<String>) -> Result<()> {
match lost_names {
LostNames::Fail => Err(DocError::reselect(name)),
LostNames::Mark => {
sk.set_link_lost(id, true);
warn.push(format!("projected reference lost: `{name}` no longer exists as it was picked; reselect it"));
Ok(())
}
}
}
// ---------------------------------------------------------------------------------------------
// Model lookups
@@ -1612,8 +1654,7 @@ pub(crate) fn section(plane: &Plane, m: &Mesh, face: Option<u32>) -> Vec<LinkGeo
let size = m.bounds().diagonal().max(1e-9);
let (segs, exact) = section_segments(plane, m, face);
let segs: Vec<(Vec2, Vec2)> = segs.into_iter().map(|(a, b, _)| (a, b)).collect();
let loose = size * 2e-3;
let refine = |v: Vec<LinkGeom>| -> Vec<LinkGeom> { v.into_iter().map(|g| refine_round(g, &exact, loose)).collect() };
let refine = |v: Vec<LinkGeom>| -> Vec<LinkGeom> { refine_rounds(v, &exact, size) };
let lossy = refine(fit_segments(&segs, size, false));
// Lines and arcs that follow the section closely (the corners of a rounded profile stay
// where they are) are kept when they take no more curves than the loose fit.
@@ -1627,7 +1668,57 @@ pub(crate) fn section(plane: &Plane, m: &Mesh, face: Option<u32>) -> Vec<LinkGeo
let tight = size * 1e-4;
let checkable = exact.len().saturating_mul(close.len()) <= 4_000_000;
let strays = |v: &[LinkGeom]| exact.iter().any(|p| !v.iter().any(|g| plain_distance(g, *p).is_none_or(|d| d <= tight)));
if plain(&close) && (close.len() <= lossy.len() || (checkable && strays(&lossy) && !strays(&close))) { close } else { lossy }
let fitted = if plain(&close) && (close.len() <= lossy.len() || (checkable && strays(&lossy) && !strays(&close))) { close } else { lossy };
join_rounds(fitted, size * 1e-5)
}
/// Where the fit left a line and an arc ends apart by at most `tol`, both end at one point. A
/// line can swallow an arc's first chord (a mesh crossing just past the tangent line), and
/// [`refine_round`] moves an arc's ends but not the lines': the outline would not close.
fn join_rounds(mut fitted: Vec<LinkGeom>, tol: f64) -> Vec<LinkGeom> {
for i in 0..fitted.len() {
let Some(&LinkGeom::Arc { c, mut a, mut b }) = fitted.get(i) else { continue };
let r = c.dist(a);
for end in [&mut a, &mut b] {
for curve in &mut fitted {
let LinkGeom::Line(p, q) = curve else { continue };
let (near, far) = if p.dist(*end) <= q.dist(*end) { (p, q) } else { (q, p) };
if near.dist(*end) <= tol {
let joint = line_meets_round(*far, *near, c, r, tol);
*near = joint;
*end = joint;
}
}
}
if let Some(arc) = fitted.get_mut(i) {
*arc = LinkGeom::Arc { c, a, b };
}
}
fitted
}
/// Where a line from `far` ending near `near` meets the circle (`c`, `r`): the tangent point when
/// the line runs into the circle there, within `tol` of it over the arc's first chord, else its
/// crossing nearest `near`, else the circle's point nearest `near`.
fn line_meets_round(far: Vec2, near: Vec2, c: Vec2, r: f64, tol: f64) -> Vec2 {
let nearest = |[x, y]: [Vec2; 2]| if x.dist(near) <= y.dist(near) { x } else { y };
let to_centre = c - far;
let span = to_centre.len();
if let Some(unit) = to_centre.normalized().filter(|_| span > r) {
let reach = (span * span - r * r).sqrt();
let (along, side) = (unit * (reach * reach / span), unit.perp() * (reach * r / span));
let tangent = nearest([far + along + side, far + along - side]);
if tangent.dist(near) <= tol + (2.0 * r * tol).sqrt() {
return tangent;
}
}
let Some(dir) = (near - far).normalized() else { return near };
let foot = far + dir * (c - far).dot(dir);
let half = r * r - (foot - c).len2();
if half >= 0.0 {
return nearest([foot + dir * half.sqrt(), foot - dir * half.sqrt()]);
}
(near - c).normalized().map_or(near, |unit| c + unit * r)
}
/// How far `p` lies from a line, arc or circle; None for any other curve.
@@ -1785,6 +1876,91 @@ fn section_segments(plane: &Plane, m: &Mesh, face: Option<u32>) -> (Vec<(Vec2, V
(segs, exact.into_inner())
}
/// Re-fit each circle and arc of a fitted section through the exact section points near it.
/// The outline stays closed: a line meeting an arc's end moves with it. Where two arcs meet,
/// their refits stand only while their ends stay within the distance a profile joins ends
/// across; else both keep their fits, since moving an arc's end would take it off its circle.
fn refine_rounds(mut v: Vec<LinkGeom>, exact: &[Vec2], size: f64) -> Vec<LinkGeom> {
// Ends this close meet: the fit joins crossings this close into one point.
let meet = size * 1e-7;
let ends = |h: &LinkGeom| match *h {
LinkGeom::Arc { a, b, .. } => Some([a, b]),
_ => None,
};
let fitted = v.clone();
// Each arc's refit, and whether it stands.
let refits: Vec<Option<LinkGeom>> =
fitted.iter().map(|g| matches!(g, LinkGeom::Arc { .. }).then(|| refine_round(g.clone(), exact, size * 2e-3))).collect();
let mut stands: Vec<bool> = refits.iter().map(Option::is_some).collect();
// Where two arcs met as fitted: (arc, its end, other arc, its end).
let mut joints = Vec::new();
for (i, gi) in fitted.iter().enumerate() {
for (j, gj) in fitted.iter().enumerate().skip(i + 1) {
let (Some(ei), Some(ej)) = (ends(gi), ends(gj)) else { continue };
for (ki, pi) in ei.iter().enumerate() {
for (kj, pj) in ej.iter().enumerate() {
if pi.dist(*pj) <= meet {
joints.push((i, ki, j, kj));
}
}
}
}
}
// Take back both refits at a joint they would tear, until no joint tears.
let end_now = |stands: &[bool], i: usize, k: usize| {
let g = if stands.get(i).copied().unwrap_or(false) { refits.get(i).and_then(Option::as_ref) } else { fitted.get(i) };
g.and_then(ends).and_then(|e| e.get(k).copied())
};
let mut changed = true;
while changed {
changed = false;
for &(i, ki, j, kj) in &joints {
let (Some(p), Some(q)) = (end_now(&stands, i, ki), end_now(&stands, j, kj)) else { continue };
if p.dist(q) > solidcraft_sketch::ENDPOINT_JOIN_TOL {
for k in [i, j] {
if let Some(s) = stands.get_mut(k).filter(|s| **s) {
*s = false;
changed = true;
}
}
}
}
}
for (i, g) in fitted.iter().enumerate() {
match g {
LinkGeom::Arc { a: a0, b: b0, .. } => {
let (true, Some(refined @ LinkGeom::Arc { a: a1, b: b1, .. })) =
(stands.get(i).copied().unwrap_or(false), refits.get(i).and_then(Option::as_ref))
else {
continue;
};
for h in v.iter_mut() {
if let LinkGeom::Line(s, t) = h {
for end in [s, t] {
if end.dist(*a0) <= meet {
*end = *a1;
} else if end.dist(*b0) <= meet {
*end = *b1;
}
}
}
}
if let Some(slot) = v.get_mut(i) {
*slot = refined.clone();
}
}
LinkGeom::Circle(..) => {
if let Some(slot) = v.get_mut(i) {
*slot = refine_round(g.clone(), exact, size * 2e-3);
}
}
// Lines have moved with the arcs they meet; nothing else is refitted.
_ => {}
}
}
v
}
/// Re-fit a circle or arc through the exact section points near it, when there are enough.
fn refine_round(g: LinkGeom, exact: &[Vec2], tol: f64) -> LinkGeom {
let (c, r) = match g {
+127
View File
@@ -368,6 +368,133 @@ mod tests {
}
}
#[test]
fn a_round_refitted_to_the_exact_section_still_meets_its_neighbours() {
use solidcore_record::Vec2;
use solidcraft_sketch::LinkGeom;
let v = Vec2::new;
// A 10 x 10 square with an R2 round at its top right corner, fitted from mesh crossings:
// the arc misses the round's circle (centre (8, 8)) by 5e-4 but meets the sides exactly.
// The exact crossings lie on the circle, and the refit puts the arc on it. The outline has
// to stay closed: a profile joins ends only within 1e-4, so a wider gap leaves none (the
// R2 rounds of a section at y = 20 through grooved walls, tangency-labs/solidcore#2908).
let c = v(7.9995, 7.9995);
let (p, q) = (v(10., c.y), v(c.x, 10.));
let exact: Vec<Vec2> =
(0..=8).map(|k| f64::from(k) * std::f64::consts::FRAC_PI_8 / 2.).map(|t| v(8. + 2. * t.cos(), 8. + 2. * t.sin())).collect();
let square = |top: Vec<LinkGeom>| {
let mut out = vec![LinkGeom::Line(v(0., 0.), v(10., 0.)), LinkGeom::Line(v(10., 0.), p), LinkGeom::Arc { c, a: p, b: q }];
out.extend(top);
out.push(LinkGeom::Line(v(0., 10.), v(0., 0.)));
out
};
// The top side a line, or an arc of radius 10 (a shallow round on it) and then a line.
let bulge = v(c.x, 20.);
let from = bulge + v(-(10f64.to_radians().sin()), -(10f64.to_radians().cos())) * 10.;
let cases =
[square(vec![LinkGeom::Line(q, v(0., 10.))]), square(vec![LinkGeom::Arc { c: bulge, a: from, b: q }, LinkGeom::Line(from, v(0., 10.))])];
// Ends as the sketch takes them: an arc ends on the circle through its start.
let ends = |g: &LinkGeom| match *g {
LinkGeom::Line(s, t) => vec![s, t],
LinkGeom::Arc { c, a, b } => vec![a, c + (b - c).normalized().unwrap_or(Vec2::X) * c.dist(a)],
_ => Vec::new(),
};
for (n, fitted) in cases.into_iter().enumerate() {
// The size of the section: the square's diagonal.
let out = super::super::refine_rounds(fitted, &exact, 200f64.sqrt());
for (i, g) in out.iter().enumerate() {
for e in ends(g) {
let gap =
out.iter().enumerate().filter(|(j, _)| *j != i).flat_map(|(_, h)| ends(h)).map(|x| x.dist(e)).fold(f64::INFINITY, f64::min);
assert!(gap < 1e-9, "case {n}: gap {gap} at {e:?}: {out:?}");
}
}
// Where the round meets only lines, the arc lies on the round's circle.
if n == 0 {
let Some(LinkGeom::Arc { c, a, b }) = out.get(2) else { panic!("{out:?}") };
assert!(c.dist(v(8., 8.)) < 1e-9 && (c.dist(*a) - 2.).abs() < 1e-9 && (c.dist(*b) - 2.).abs() < 1e-9, "{out:?}");
}
}
}
#[test]
fn a_round_beside_another_arc_is_refitted_when_their_joint_stays_put() {
use solidcore_record::Vec2;
use solidcraft_sketch::LinkGeom;
let v = Vec2::new;
// The R2 round of a 10 x 10 square misses its circle (centre (8, 8)) by `s`, and an arc of
// radius 10 runs on from its end. The refit moves that joint by `s`. While that is within
// the 1e-4 a profile joins ends across, the round is refitted as before the fix for
// tangency-labs/solidcore#2908, and the profile still closes.
let exact: Vec<Vec2> =
(0..=8).map(|k| f64::from(k) * std::f64::consts::FRAC_PI_8 / 2.).map(|t| v(8. + 2. * t.cos(), 8. + 2. * t.sin())).collect();
for s in [1e-8, 1e-6, 1e-5, 5e-5, 9e-5] {
let c = v(8., 8. + s);
let (p, q) = (v(10., c.y), v(8., 10. + s));
let bulge = v(8., 20. + s);
let from = bulge + v(-(10f64.to_radians().sin()), -(10f64.to_radians().cos())) * 10.;
let fitted = vec![
LinkGeom::Line(v(0., 0.), v(10., 0.)),
LinkGeom::Line(v(10., 0.), p),
LinkGeom::Arc { c, a: p, b: q },
LinkGeom::Arc { c: bulge, a: from, b: q },
LinkGeom::Line(from, v(0., 10.)),
LinkGeom::Line(v(0., 10.), v(0., 0.)),
];
let out = super::super::refine_rounds(fitted, &exact, 200f64.sqrt());
let (Some(LinkGeom::Arc { c, b, .. }), Some(LinkGeom::Arc { b: joint, .. })) = (out.get(2), out.get(3)) else { panic!("{out:?}") };
assert!(c.dist(v(8., 8.)) < 1e-12, "shift {s}: the round is refitted: {out:?}");
assert!(b.dist(*joint) < 1e-4, "shift {s}: {out:?}");
}
}
#[test]
fn two_arcs_refitted_the_same_way_keep_their_joint() {
use solidcore_record::Vec2;
use solidcraft_sketch::LinkGeom;
let v = Vec2::new;
// The R2 round of a 10 x 10 square and the arc of radius 10 running on from it both miss
// their circles (centres (8, 8) and (8, 20)) by `s` in the same direction. Both refits move
// their joint from (8, 10 + s) to (8, 10), so the two refitted arcs still meet, as they do
// before the fix for tangency-labs/solidcore#2908, even where `s` is past a profile's 1e-4.
let round = (0..=8).map(|k| f64::from(k) * std::f64::consts::FRAC_PI_8 / 2.).map(|t| v(8. + 2. * t.cos(), 8. + 2. * t.sin()));
let side = [261., 263., 265., 267., 269.].map(|d: f64| v(8. + 10. * d.to_radians().cos(), 20. + 10. * d.to_radians().sin()));
let exact: Vec<Vec2> = round.chain(side).collect();
for s in [1e-5, 2e-4, 5e-4] {
let c = v(8., 8. + s);
let (p, q) = (v(10., c.y), v(8., 10. + s));
let bulge = v(8., 20. + s);
let from = bulge + v(-(10f64.to_radians().sin()), -(10f64.to_radians().cos())) * 10.;
let fitted = vec![
LinkGeom::Line(v(0., 0.), v(10., 0.)),
LinkGeom::Line(v(10., 0.), p),
LinkGeom::Arc { c, a: p, b: q },
LinkGeom::Arc { c: bulge, a: from, b: q },
LinkGeom::Line(from, v(0., 10.)),
LinkGeom::Line(v(0., 10.), v(0., 0.)),
];
let out = super::super::refine_rounds(fitted, &exact, 200f64.sqrt());
let (Some(LinkGeom::Arc { c: c1, b: b1, .. }), Some(LinkGeom::Arc { c: c2, b: b2, .. })) = (out.get(2), out.get(3)) else {
panic!("{out:?}")
};
assert!(c1.dist(v(8., 8.)) < 1e-9 && c2.dist(v(8., 20.)) < 1e-9, "shift {s}: both arcs are refitted: {out:?}");
assert!(b1.dist(*b2) < 1e-4, "shift {s}: {out:?}");
// The lines on the arcs' other ends move with them: the whole outline stays closed.
let ends = |g: &LinkGeom| match *g {
LinkGeom::Line(s, t) => vec![s, t],
LinkGeom::Arc { c, a, b } => vec![a, c + (b - c).normalized().unwrap_or(Vec2::X) * c.dist(a)],
_ => Vec::new(),
};
for (i, g) in out.iter().enumerate() {
for e in ends(g) {
let gap =
out.iter().enumerate().filter(|(j, _)| *j != i).flat_map(|(_, h)| ends(h)).map(|x| x.dist(e)).fold(f64::INFINITY, f64::min);
assert!(gap < 1e-9, "shift {s}: gap {gap} at {e:?}: {out:?}");
}
}
}
}
#[test]
fn boundaries_touching_at_a_vertex_project() {
// Two squares of one face family meet only at the corner (1, 1): four boundary edges meet
+11 -1
View File
@@ -465,7 +465,17 @@ mod tests {
let doc = crate::Document { render: Arc::new(settings.clone()), ..Default::default() };
let wire = doc.to_json();
assert_eq!(crate::Document::from_json(&wire).unwrap(), doc);
assert!(wire.contains("embedded-surface-textures"));
// The saved design holds the texture's bytes under the resource's own key.
fn resource_saved(v: &serde_json::Value, key: &str) -> bool {
match v {
serde_json::Value::Object(map) => {
map.get("texture_resources").and_then(|t| t.get(key)).is_some() || map.values().any(|v| resource_saved(v, key))
}
serde_json::Value::Array(items) => items.iter().any(|v| resource_saved(v, key)),
_ => false,
}
}
assert!(resource_saved(&serde_json::from_str(&wire).unwrap(), &id.to_string()));
assert!(!wire.contains("builtin://"));
assert!(!wire.contains("file://"));
settings.texture_resources.remove(&id);
+80
View File
@@ -871,6 +871,86 @@ fn a_join_keeps_blocks_apart_in_one_body() {
assert_eq!(kept, None);
}
/// A body of two pieces joined apart, split by a plane through both pieces: the split gives the
/// halves of every piece, not the halves of one of them (#6738).
#[test]
fn a_split_of_a_body_of_two_pieces_gives_the_halves_of_both() {
let mut doc = Document::new("split two pieces");
// One sketch, two squares 20 mm across and 30 mm apart.
let mut sk = Sketch::new();
let mut corners = Vec::new();
for x in [0.0, 50.0] {
let p: Vec<usize> = [Vec2::new(x, 10.0), Vec2::new(x + 20.0, 10.0), Vec2::new(x + 20.0, 30.0), Vec2::new(x, 30.0)]
.iter()
.map(|c| sk.add_point(*c, None).unwrap())
.collect();
for i in 0..4 {
sk.add_line_pts(p[i], p[(i + 1) % 4], None).unwrap();
}
corners.extend(p);
}
for p in corners {
sk.add_constraint(ConstraintKind::Fix { p }, None).unwrap();
}
let s = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: sk }, None).unwrap();
doc.add_feature(
FeatureKind::Extrude {
thin: None,
sketch: crate::SketchId(s),
profiles: ProfileSel::All,
extent: Extent {
convention: crate::ExtrudeConvention::LegacyPerSide,
distance: "10".into(),
direction: Direction::Positive,
distance2: None,
start_offset: None,
through_all: false,
second_follows_first: false,
taper: None,
corner: Default::default(),
to: None,
to_offset: None,
from: None,
to_body_apart: false,
},
operation: Operation::NewBody,
targets: vec![],
},
None,
)
.unwrap();
doc.add_feature(
FeatureKind::Combine { target: "Body1".into(), tools: vec!["Body2".into()], operation: Operation::Join, keep_tools: false },
None,
)
.unwrap();
// A plane through the middle of both squares (the extrusion is 10 mm tall).
let split = doc
.add_feature(
FeatureKind::Split {
body: "Body1".into(),
plane: PlaneRef::Offset { base: Box::new(PlaneRef::Origin { name: "XY".into() }), distance: "5".into() },
tool: None,
sketch: None,
line: None,
},
None,
)
.unwrap();
let mut m = Model::new();
m.evaluate(&doc);
let error = m.result(split).unwrap().error.clone();
assert!(error.is_none(), "the split of a body of two pieces: {error:?}");
let bodies: Vec<(String, f64, usize)> =
m.state().bodies.iter().map(|b| (b.name.clone(), measure(&b.body).unwrap().volume, b.body.lumps().unwrap().len())).collect();
assert_eq!(bodies.len(), 2, "{bodies:?}");
for (name, volume, lumps) in &bodies {
// Each half holds the half of both squares: two pieces, half the body each.
assert!(rel(*volume, 4000.0) < 1e-6, "{name}: {bodies:?}");
assert_eq!(*lumps, 2, "{name}: {bodies:?}");
}
}
/// A square ring joined with an arm touching its outer corner along an edge and a hub apart in
/// its hole: the ring and the hub stay one body under the ring's name, the arm is split off, and
/// the join says it touched along an edge.
+35 -26
View File
@@ -6,9 +6,9 @@ use crate::model::{Integrator, MAX_MAGNITUDE, MAX_STEPS, Model, Settings};
use crate::system::System;
use crate::{DynamicsError, Result, Sample, Stats, Trajectory};
/// Dormand–Prince 5(4) tableau (the system is autonomous, so the nodes are not needed): stage
/// weights, fifth-order weights and the difference between the fifth- and fourth-order weights
/// (the error estimate).
/// Dormand–Prince 5(4) tableau: nodes, stage weights, fifth-order weights and the difference
/// between the fifth- and fourth-order weights (the error estimate).
const DP_C: [f64; 7] = [0.0, 1.0 / 5.0, 3.0 / 10.0, 4.0 / 5.0, 8.0 / 9.0, 1.0, 1.0];
const DP_A: [[f64; 6]; 7] = [
[0.0; 6],
[1.0 / 5.0, 0.0, 0.0, 0.0, 0.0, 0.0],
@@ -58,17 +58,18 @@ struct Run<'a> {
}
impl Run<'_> {
fn deriv(&self, y: &[f64]) -> Result<Vec<f64>> {
self.sys.accelerations(y).map(|a| a.ydot)
fn deriv(&self, t: f64, y: &[f64]) -> Result<Vec<f64>> {
self.sys.accelerations(t, y).map(|a| a.ydot)
}
/// Normalise, project and check a new state.
fn finish_step(&mut self, y: &mut [f64], t: f64) -> Result<()> {
self.sys.normalize(y).map_err(|e| at_time(e, t))?;
if self.settings.projection {
self.sys.project_positions(y, self.settings.projection_tol).map_err(|e| at_time(e, t))?;
self.sys.project_velocities(y).map_err(|e| at_time(e, t))?;
self.sys.project_positions(t, y, self.settings.projection_tol).map_err(|e| at_time(e, t))?;
self.sys.project_velocities(t, y).map_err(|e| at_time(e, t))?;
}
self.sys.resync(y);
if !y.iter().all(|x| x.is_finite() && x.abs() < MAX_MAGNITUDE) {
return Err(DynamicsError::Diverged { time: t });
}
@@ -82,23 +83,23 @@ impl Run<'_> {
Ok(())
}
fn rk4(&mut self, y: &[f64], h: f64) -> Result<Vec<f64>> {
let k1 = self.deriv(y)?;
let k2 = self.deriv(&combine(y, std::slice::from_ref(&k1), &[0.5], h))?;
let k3 = self.deriv(&combine(y, std::slice::from_ref(&k2), &[0.5], h))?;
let k4 = self.deriv(&combine(y, std::slice::from_ref(&k3), &[1.0], h))?;
fn rk4(&mut self, t: f64, y: &[f64], h: f64) -> Result<Vec<f64>> {
let k1 = self.deriv(t, y)?;
let k2 = self.deriv(t + 0.5 * h, &combine(y, std::slice::from_ref(&k1), &[0.5], h))?;
let k3 = self.deriv(t + 0.5 * h, &combine(y, std::slice::from_ref(&k2), &[0.5], h))?;
let k4 = self.deriv(t + h, &combine(y, std::slice::from_ref(&k3), &[1.0], h))?;
Ok(combine(y, &[k1, k2, k3, k4], &[1.0 / 6.0, 1.0 / 3.0, 1.0 / 3.0, 1.0 / 6.0], h))
}
/// One Dormand–Prince step: the fifth-order state and the scaled error norm.
fn dopri(&mut self, y: &[f64], h: f64, rel_tol: f64, abs_tol: f64) -> Result<(Vec<f64>, f64)> {
fn dopri(&mut self, t: f64, y: &[f64], h: f64, rel_tol: f64, abs_tol: f64) -> Result<(Vec<f64>, f64)> {
let mut ks: Vec<Vec<f64>> = Vec::with_capacity(7);
for a in DP_A.iter().take(6) {
for (a, c) in DP_A.iter().zip(DP_C).take(6) {
let stage = combine(y, &ks, a, h);
ks.push(self.deriv(&stage)?);
ks.push(self.deriv(t + c * h, &stage)?);
}
let y5 = combine(y, &ks, &DP_B, h);
ks.push(self.deriv(&y5)?);
ks.push(self.deriv(t + h, &y5)?);
let err = combine(&vec![0.0; y.len()], &ks, &DP_E, h);
let n = y.len().max(1) as f64;
let sum: f64 = err
@@ -123,7 +124,7 @@ impl Run<'_> {
let h = span / n;
for _ in 0..n as usize {
self.count_step(*t)?;
let mut next = self.rk4(y, h).map_err(|e| at_time(e, *t))?;
let mut next = self.rk4(*t, y, h).map_err(|e| at_time(e, *t))?;
*t += h;
self.finish_step(&mut next, *t)?;
*y = next;
@@ -136,7 +137,7 @@ impl Run<'_> {
self.count_step(*t)?;
let remaining = t_end - *t;
let h = h_next.clamp(min_step, max_step).min(remaining);
let (mut next, err) = self.dopri(y, h, rel_tol, abs_tol).map_err(|e| at_time(e, *t))?;
let (mut next, err) = self.dopri(*t, y, h, rel_tol, abs_tol).map_err(|e| at_time(e, *t))?;
// The usual controller: 0.9·err^(−1/5), within [0.2, 5].
let factor = if err > 0.0 { (0.9 * err.powf(-0.2)).clamp(0.2, 5.0) } else { 5.0 };
if err <= 1.0 {
@@ -164,11 +165,18 @@ impl Run<'_> {
}
fn sample(&mut self, y: &[f64], t: f64) -> Result<Sample> {
let acc = self.sys.accelerations(y).map_err(|e| at_time(e, t))?;
let constraint_error = self.sys.constraint_error(y);
let acc = self.sys.accelerations(t, y).map_err(|e| at_time(e, t))?;
let constraint_error = self.sys.constraint_error(t, y);
self.stats.max_constraint_error = self.stats.max_constraint_error.max(constraint_error);
self.stats.redundant_equations = self.stats.redundant_equations.max(acc.row_count().saturating_sub(acc.rank));
Ok(Sample { time: t, bodies: self.sys.body_states(y), joints: self.sys.joint_results(y, &acc), energy: self.sys.energy(y), constraint_error })
Ok(Sample {
time: t,
bodies: self.sys.body_states(y),
joints: self.sys.joint_results(y, &acc),
drives: self.sys.drive_efforts(&acc),
energy: self.sys.energy(y),
constraint_error,
})
}
}
@@ -177,11 +185,12 @@ impl Run<'_> {
pub fn simulate(model: &Model, settings: &Settings) -> Result<Trajectory> {
settings.validate()?;
let sys = System::new(model, settings.baumgarte)?;
let mut y = System::initial_state(model)?;
// An inconsistent start (frames a little apart, velocities the joints don't allow) is
// brought onto the joints first, whether or not later steps project.
sys.project_positions(&mut y, settings.projection_tol).map_err(|e| at_time(e, 0.0))?;
sys.project_velocities(&mut y).map_err(|e| at_time(e, 0.0))?;
let mut y = sys.initial_state(model)?;
// An inconsistent start (frames a little apart, velocities the joints don't allow, drives
// starting elsewhere) is brought onto the joints first, whether or not later steps project.
sys.project_positions(0.0, &mut y, settings.projection_tol).map_err(|e| at_time(e, 0.0))?;
sys.project_velocities(0.0, &mut y).map_err(|e| at_time(e, 0.0))?;
sys.resync(&mut y);
let mut run = Run { sys: &sys, settings, stats: Stats::default() };
let count = (settings.duration / settings.output_interval).ceil() as usize;
let mut samples = Vec::with_capacity(count + 1);
+22 -5
View File
@@ -3,7 +3,9 @@
//!
//! The crate knows nothing of documents or occurrences: a [`Model`] lists bodies (mass,
//! inertia about the centre of mass, starting pose and velocities), joints between frames on
//! them ([`JointKind`]: rigid, revolute, slider, cylindrical, planar, ball) and force elements;
//! them ([`JointKind`]: rigid, revolute, slider, cylindrical, planar, ball), force elements
//! (point and joint spring-dampers, motors along joint values, loads), drives that make a joint
//! value follow a [`Profile`] in time, and motion links (gears, racks, screws) between values;
//! [`simulate`] returns a [`Trajectory`] of poses, joint values and rates, joint reactions and
//! energies at a fixed output interval. The assembly layer maps occurrences and joints to it.
//!
@@ -31,8 +33,8 @@ use solidcore_record::Vec3;
pub use integrate::simulate;
pub use math::{Mat3, Quat};
pub use model::{
Anchor, Body, Force, Integrator, Joint, JointKind, MAX_BODIES, MAX_CONSTRAINT_ROWS, MAX_FORCES, MAX_JOINTS, MAX_MAGNITUDE,
MAX_PROJECTION_ITERATIONS, MAX_SAMPLES, MAX_STEPS, Marker, Model, STANDARD_GRAVITY, Settings,
Anchor, Body, Coord, Drive, Force, Integrator, Joint, JointKind, Link, MAX_BODIES, MAX_CONSTRAINT_ROWS, MAX_FORCES, MAX_JOINTS, MAX_MAGNITUDE,
MAX_PROFILE_POINTS, MAX_PROJECTION_ITERATIONS, MAX_SAMPLES, MAX_STEPS, Marker, Model, Profile, STANDARD_GRAVITY, Settings,
};
/// Why a simulation could not run or stopped.
@@ -90,14 +92,25 @@ pub struct Energy {
pub kinetic: f64,
/// Potential energy of gravity, zero at the world origin.
pub gravity: f64,
/// Energy stored in springs (preload included).
/// Energy stored in springs, point and joint ones (preload included).
pub spring: f64,
/// Work put in since the start by motors, drives and loads.
#[serde(default)]
pub input: f64,
/// Energy taken out since the start by dampers.
#[serde(default)]
pub dissipated: f64,
}
impl Energy {
/// Kinetic plus potential energy.
pub fn total(&self) -> f64 {
self.kinetic + self.gravity + self.spring
}
/// `total + dissipated − input`: constant along a run up to the integration error.
pub fn balance(&self) -> f64 {
self.total() + self.dissipated - self.input
}
}
/// The system at one output time.
@@ -107,8 +120,12 @@ pub struct Sample {
pub time: f64,
pub bodies: Vec<BodyState>,
pub joints: Vec<JointResult>,
/// What each drive applies along its joint value to make it follow (mN·mm for an angle, mN
/// for a distance), in [`Model::drives`] order.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub drives: Vec<f64>,
pub energy: Energy,
/// The largest joint equation error (mm for points; directions are unitless).
/// The largest constraint equation error (mm for points; directions are unitless).
pub constraint_error: f64,
}
+209 -2
View File
@@ -141,10 +141,172 @@ pub struct Joint {
pub b: Marker,
}
/// A force element.
/// One value of a joint: `dof` indexes [`JointKind::values`]. Revolute, slider, cylindrical
/// and planar joints have values that springs, motors, drives and links can act on; ball and
/// rigid joints have none.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Coord {
pub joint: usize,
#[serde(default)]
pub dof: usize,
}
impl Coord {
pub fn new(joint: usize, dof: usize) -> Coord {
Coord { joint, dof }
}
}
/// Points a table may hold at most.
pub const MAX_PROFILE_POINTS: usize = 10_000;
/// A value along time (s): a motor's torque or force, or the value a drive imposes (radians,
/// mm).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Profile {
Constant {
value: f64,
},
/// `value + rate·t`.
Linear {
value: f64,
rate: f64,
},
/// From `from` to `to` between `start` and `start + duration`, along a cycloidal step
/// (`τ − sin(2πτ)/2π`) whose speed and acceleration both start and end at zero.
Ramp {
from: f64,
to: f64,
#[serde(default)]
start: f64,
duration: f64,
},
/// `offset + amplitude·sin(2π·frequency·t + phase)` (frequency in Hz).
Harmonic {
#[serde(default)]
offset: f64,
amplitude: f64,
frequency: f64,
#[serde(default)]
phase: f64,
},
/// (time, value) points, times increasing: linear in between, held before the first and
/// after the last. As a drive its speed jumps at the points (its acceleration is taken as 0).
Table {
points: Vec<(f64, f64)>,
},
}
impl Profile {
/// The value and its first and second time derivatives at `t`.
pub fn eval(&self, t: f64) -> (f64, f64, f64) {
match self {
Profile::Constant { value } => (*value, 0.0, 0.0),
Profile::Linear { value, rate } => (value + rate * t, *rate, 0.0),
Profile::Ramp { from, to, start, duration } => {
let tau = (t - start) / duration;
if !(tau > 0.0) {
return (*from, 0.0, 0.0);
}
if tau >= 1.0 {
return (*to, 0.0, 0.0);
}
let span = to - from;
let (s, c) = (std::f64::consts::TAU * tau).sin_cos();
(
from + span * (tau - s / std::f64::consts::TAU),
span / duration * (1.0 - c),
span / (duration * duration) * std::f64::consts::TAU * s,
)
}
Profile::Harmonic { offset, amplitude, frequency, phase } => {
let w = std::f64::consts::TAU * frequency;
let (s, c) = (w * t + phase).sin_cos();
(offset + amplitude * s, amplitude * w * c, -amplitude * w * w * s)
}
Profile::Table { points } => {
let (Some(first), Some(last)) = (points.first(), points.last()) else { return (0.0, 0.0, 0.0) };
if t <= first.0 {
return (first.1, 0.0, 0.0);
}
if t >= last.0 {
return (last.1, 0.0, 0.0);
}
points
.windows(2)
.find_map(|w| {
let (a, b) = (w.first()?, w.get(1)?);
(t >= a.0 && t < b.0).then(|| {
let slope = (b.1 - a.1) / (b.0 - a.0);
(a.1 + slope * (t - a.0), slope, 0.0)
})
})
.unwrap_or((last.1, 0.0, 0.0))
}
}
}
fn validate(&self) -> std::result::Result<(), String> {
let fin = |x: f64| x.is_finite() && x.abs() < MAX_MAGNITUDE;
let ok = match self {
Profile::Constant { value } => fin(*value),
Profile::Linear { value, rate } => fin(*value) && fin(*rate),
Profile::Ramp { from, to, start, duration } => fin(*from) && fin(*to) && fin(*start) && fin(*duration) && *duration > 1e-9,
Profile::Harmonic { offset, amplitude, frequency, phase } => {
fin(*offset) && fin(*amplitude) && fin(*frequency) && fin(*phase) && frequency.abs() < 1e6
}
Profile::Table { points } => {
if points.is_empty() || points.len() > MAX_PROFILE_POINTS {
return Err(format!("a table needs 1 to {MAX_PROFILE_POINTS} points"));
}
points.iter().all(|(t, v)| fin(*t) && fin(*v))
&& points.windows(2).all(|w| matches!((w.first(), w.get(1)), (Some(a), Some(b)) if b.0 > a.0))
}
};
if ok { Ok(()) } else { Err("a profile's numbers must be finite (ramps need a duration, tables increasing times)".into()) }
}
}
/// A joint value made to follow a profile along time (radians or mm): the joint turns or
/// slides as told, whatever it takes; [`crate::Sample::drives`] reports what it takes.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Drive {
pub at: Coord,
pub motion: Profile,
}
/// A motion link: `follower = ratio·driver + offset`, between values of two joints (a gear
/// pair: angle to angle; a rack and pinion: angle to distance, the ratio being the pinion's
/// radius; a screw: angle to distance along the same cylindrical joint, the ratio being
/// pitch / 2π). Without an offset, the one that holds at the start is kept.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Link {
pub driver: Coord,
pub follower: Coord,
pub ratio: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub offset: Option<f64>,
}
/// A force element.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Force {
/// A spring and damper on a joint value: the joint is pushed back by
/// `stiffness·(value − rest) + damping·rate` (torsional for an angle: mN·mm per radian and
/// mN·mm·s; linear for a distance: kg/s² and kg/s).
JointSpring {
at: Coord,
stiffness: f64,
#[serde(default)]
damping: f64,
#[serde(default)]
rest: f64,
},
/// A motor's torque (mN·mm) or force (mN) along a joint value, following a profile: it
/// pushes B's side forward and A's side back.
Motor { at: Coord, effort: Profile },
/// A linear spring and damper between two points: the tension is
/// `stiffness·(length − free_length) + damping·(rate of length) + preload`.
Spring {
@@ -243,6 +405,10 @@ pub struct Model {
pub joints: Vec<Joint>,
#[serde(default)]
pub forces: Vec<Force>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub drives: Vec<Drive>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub links: Vec<Link>,
/// Gravity (mm/s²), e.g. `(0, 0, −9806.65)`.
#[serde(default)]
pub gravity: Vec3,
@@ -282,7 +448,10 @@ impl Model {
if self.forces.len() > MAX_FORCES {
return Err(DynamicsError::TooLarge(format!("{} force elements (at most {MAX_FORCES})", self.forces.len())));
}
let rows: usize = self.joints.iter().map(|j| j.kind.equations()).sum();
if self.drives.len() > MAX_JOINTS || self.links.len() > MAX_JOINTS {
return Err(DynamicsError::TooLarge(format!("drives and links: at most {MAX_JOINTS} each")));
}
let rows: usize = self.joints.iter().map(|j| j.kind.equations()).sum::<usize>() + self.drives.len() + self.links.len();
if rows > MAX_CONSTRAINT_ROWS {
return Err(DynamicsError::TooLarge(format!("{rows} constraint equations (at most {MAX_CONSTRAINT_ROWS})")));
}
@@ -339,8 +508,46 @@ impl Model {
return Err(bad(format!("force {i}: point, force and torque must be finite")));
}
}
Force::JointSpring { at, stiffness, damping, rest } => {
self.check_coord(*at).map_err(|e| bad(format!("force {i}: {e}")))?;
let ok = |x: f64| (0.0..MAX_MAGNITUDE).contains(&x);
if !ok(*stiffness) || !ok(*damping) || !rest.is_finite() || rest.abs() >= MAX_MAGNITUDE {
return Err(bad(format!("force {i}: stiffness and damping must be ≥ 0 and finite, rest finite")));
}
}
Force::Motor { at, effort } => {
self.check_coord(*at).map_err(|e| bad(format!("force {i}: {e}")))?;
effort.validate().map_err(|e| bad(format!("force {i}: {e}")))?;
}
}
}
for (i, d) in self.drives.iter().enumerate() {
self.check_coord(d.at).map_err(|e| bad(format!("drive {i}: {e}")))?;
d.motion.validate().map_err(|e| bad(format!("drive {i}: {e}")))?;
}
for (i, l) in self.links.iter().enumerate() {
self.check_coord(l.driver).map_err(|e| bad(format!("link {i}: {e}")))?;
self.check_coord(l.follower).map_err(|e| bad(format!("link {i}: {e}")))?;
if l.driver == l.follower {
return Err(bad(format!("link {i}: a value can't drive itself")));
}
let fin = |x: f64| x.is_finite() && x.abs() < MAX_MAGNITUDE;
if !fin(l.ratio) || !l.offset.is_none_or(fin) {
return Err(bad(format!("link {i}: ratio and offset must be finite")));
}
}
Ok(())
}
/// The joint and value a [`Coord`] names, if they exist and can be acted on.
fn check_coord(&self, c: Coord) -> std::result::Result<(), String> {
let j = self.joints.get(c.joint).ok_or_else(|| format!("no joint {}", c.joint))?;
if matches!(j.kind, JointKind::Ball | JointKind::Rigid) {
return Err(format!("joint {}: not supported yet: springs, motors, drives and links on {:?} joints", c.joint, j.kind));
}
if c.dof >= j.kind.values().len() {
return Err(format!("joint {} has no value {}", c.joint, c.dof));
}
Ok(())
}
}
+306 -40
View File
@@ -12,13 +12,20 @@
//!
//! through the Schur complement `(J M⁻¹ Jᵀ) λ = J M⁻¹ F − γ` (`linalg::solve_psd`, which takes
//! repeated joint equations), and `λ` gives the joints' reactions.
//!
//! Joint springs and motors act along joint values (generalized forces `Jᵀ Q` along a value's
//! row); drives add a rheonomic equation `value − f(t) = 0` (so `J u = ḟ`, `J u̇ = f̈ − J̇ u`),
//! and motion links a linear one between two values. The values they use are also kept in the
//! state, so an angle counts whole turns.
use std::f64::consts::TAU;
use solidcore_record::Vec3;
use solidcraft_geom::prelude::*;
use crate::linalg::{Sym, solve_psd};
use crate::math::{Mat3, Quat, cross2};
use crate::model::{Force, JointKind, MAX_MAGNITUDE, MAX_PROJECTION_ITERATIONS, Model, marker_axes};
use crate::model::{Coord, Force, JointKind, MAX_MAGNITUDE, MAX_PROJECTION_ITERATIONS, Model, Profile, marker_axes};
use crate::{BodyState, DynamicsError, Energy, JointResult, Result};
/// Numbers per body in the flat state: x (3), q (4), v (3), ω (3).
@@ -97,18 +104,32 @@ struct MarkerWorld {
/// A body's part of one Jacobian row: (linear, angular).
type Block = (usize, Vec3, Vec3);
/// One constraint equation at a state.
/// One constraint equation at a state: `Φ = 0`, `J u = rate_target`.
pub(crate) struct Row {
/// A's and B's parts (for a joint's own equations, its two sides).
a: Option<Block>,
b: Option<Block>,
/// A motion link's driver's parts.
c: Option<Block>,
d: Option<Block>,
pub(crate) phi: f64,
/// `J̇ u`: the equation's second derivative at zero accelerations.
/// `J̇ u` less the imposed acceleration: the equation's second derivative at zero
/// accelerations.
jdot_u: f64,
/// The rate a drive imposes (`ḟ`); 0 for the others.
rate_target: f64,
}
fn scaled(b: Option<Block>, k: f64) -> Option<Block> {
b.map(|(i, l, a)| (i, l * k, a * k))
}
impl Row {
fn new(a: Option<Block>, b: Option<Block>, phi: f64, jdot_u: f64) -> Row {
Row { a, b, c: None, d: None, phi, jdot_u, rate_target: 0.0 }
}
fn blocks(&self) -> impl Iterator<Item = &Block> {
self.a.iter().chain(self.b.iter())
self.a.iter().chain(self.b.iter()).chain(self.c.iter()).chain(self.d.iter())
}
/// `J u`.
fn rate(&self, kin: &[Kin]) -> f64 {
@@ -116,13 +137,58 @@ impl Row {
}
}
/// How a joint value is measured.
#[derive(Clone, Copy, PartialEq)]
enum CoordKind {
/// B's turn about A's z (from A's x towards A's y).
Angle,
/// B's origin along one of A's axes (0, 1, 2 = x, y, z).
Along(usize),
}
/// A joint value springs, motors, drives or links act on. Its value is kept in the state too,
/// so an angle counts whole turns.
#[derive(Clone, Copy)]
struct CoordConst {
joint: usize,
kind: CoordKind,
}
struct LinkConst {
driver: usize,
follower: usize,
ratio: f64,
offset: f64,
}
/// A model ready to integrate.
///
/// The flat state holds every body (`STRIDE` numbers each), then the tracked joint values,
/// then the work put in (motors, drives, loads) and the energy dissipated (dampers).
pub(crate) struct System {
bodies: Vec<BodyConst>,
joints: Vec<JointConst>,
/// Point springs and loads.
forces: Vec<Force>,
gravity: Vec3,
baumgarte: Option<(f64, f64)>,
coords: Vec<CoordConst>,
/// (value, stiffness, damping, rest).
springs: Vec<(usize, f64, f64, f64)>,
/// (value, effort).
motors: Vec<(usize, Profile)>,
/// (value, motion).
drives: Vec<(usize, Profile)>,
links: Vec<LinkConst>,
}
/// `θ` turned by whole turns to lie nearest `near`.
fn unwrap_angle(theta: f64, near: f64) -> f64 {
if !near.is_finite() {
return theta;
}
let turns = ((near - theta) / TAU).round();
if turns.is_finite() { theta + TAU * turns } else { theta }
}
/// Accelerations and multipliers at one state.
@@ -164,12 +230,81 @@ impl System {
.iter()
.map(|j| Ok(JointConst { kind: j.kind, a: marker_const(&j.a)?, b: marker_const(&j.b)? }))
.collect::<Result<Vec<_>>>()?;
Ok(System { bodies, joints, forces: model.forces.clone(), gravity: model.gravity, baumgarte })
let mut sys = System {
bodies,
joints,
forces: model.forces.iter().filter(|f| matches!(f, Force::Spring { .. } | Force::Load { .. })).cloned().collect(),
gravity: model.gravity,
baumgarte,
coords: Vec::new(),
springs: Vec::new(),
motors: Vec::new(),
drives: Vec::new(),
links: Vec::new(),
};
let mut found: Vec<Coord> = Vec::new();
let mut index = |c: Coord, sys: &mut System| -> Result<usize> {
if let Some(i) = found.iter().position(|f| *f == c) {
return Ok(i);
}
let kind = model.joints.get(c.joint).map(|j| j.kind);
let kind = match (kind, c.dof) {
(Some(JointKind::Revolute | JointKind::Cylindrical), 0) | (Some(JointKind::Planar), 2) => CoordKind::Angle,
(Some(JointKind::Slider), 0) | (Some(JointKind::Cylindrical), 1) => CoordKind::Along(2),
(Some(JointKind::Planar), d @ (0 | 1)) => CoordKind::Along(d),
_ => return Err(DynamicsError::InvalidModel(format!("joint {} has no value {} to act on", c.joint, c.dof))),
};
found.push(c);
sys.coords.push(CoordConst { joint: c.joint, kind });
Ok(found.len() - 1)
};
for f in &model.forces {
match f {
Force::JointSpring { at, stiffness, damping, rest } => {
let i = index(*at, &mut sys)?;
sys.springs.push((i, *stiffness, *damping, *rest));
}
Force::Motor { at, effort } => {
let i = index(*at, &mut sys)?;
sys.motors.push((i, effort.clone()));
}
Force::Spring { .. } | Force::Load { .. } => {}
}
}
for d in &model.drives {
let i = index(d.at, &mut sys)?;
sys.drives.push((i, d.motion.clone()));
}
let mut pending = Vec::new();
for l in &model.links {
pending.push((index(l.driver, &mut sys)?, index(l.follower, &mut sys)?, l.ratio, l.offset));
}
// A link without an offset keeps the one that holds at the start.
let y = sys.initial_state(model)?;
let kin = sys.kinematics(&y);
let value = |i: usize| sys.coord_row(&kin, &y, i).phi;
let links = pending
.into_iter()
.map(|(d, f, ratio, offset)| LinkConst { driver: d, follower: f, ratio, offset: offset.unwrap_or_else(|| value(f) - ratio * value(d)) })
.collect();
sys.links = links;
Ok(sys)
}
fn coord_base(&self) -> usize {
self.bodies.len() * STRIDE
}
fn work_base(&self) -> usize {
self.coord_base() + self.coords.len()
}
/// Numbers in the flat state.
pub(crate) fn state_len(&self) -> usize {
self.work_base() + 2
}
/// The flat state of the model's starting conditions.
pub(crate) fn initial_state(model: &Model) -> Result<Vec<f64>> {
let mut y = vec![0.0; model.bodies.len() * STRIDE];
pub(crate) fn initial_state(&self, model: &Model) -> Result<Vec<f64>> {
let mut y = vec![0.0; self.state_len()];
for (i, b) in model.bodies.iter().enumerate() {
let at = i * STRIDE;
let q = b.orientation.normalized().ok_or_else(|| DynamicsError::InvalidModel(format!("body {i}: orientation is not a rotation")))?;
@@ -178,9 +313,48 @@ impl System {
put3(&mut y, at + 7, b.velocity);
put3(&mut y, at + 10, b.angular_velocity);
}
// A driven angle counts its turns from where its motion starts.
for (i, motion) in &self.drives {
if let Some(s) = y.get_mut(self.coord_base() + i) {
*s = motion.eval(0.0).0;
}
}
self.resync(&mut y);
Ok(y)
}
/// Sets each tracked joint value to the one the bodies show (an angle: the turn nearest
/// the tracked one).
pub(crate) fn resync(&self, y: &mut [f64]) {
let kin = self.kinematics(y);
let values: Vec<f64> = (0..self.coords.len()).map(|i| self.coord_row(&kin, y, i).phi).collect();
for (i, v) in values.into_iter().enumerate() {
if let Some(s) = y.get_mut(self.coord_base() + i) {
*s = v;
}
}
}
/// A tracked joint value's equation row: `Φ` is the value itself, `J u` its rate.
fn coord_row(&self, kin: &[Kin], y: &[f64], i: usize) -> Row {
let (Some(c), tracked) = (self.coords.get(i), y.get(self.coord_base() + i).copied().unwrap_or(f64::NAN)) else {
return Row::new(None, None, 0.0, 0.0);
};
let Some(j) = self.joints.get(c.joint) else { return Row::new(None, None, 0.0, 0.0) };
let a = Self::marker_world(kin, &j.a);
let b = Self::marker_world(kin, &j.b);
let [ax, ay, az] = a.axes;
match c.kind {
CoordKind::Angle => {
let bx = b.axes[0];
let theta = unwrap_angle(bx.dot(ay).atan2(bx.dot(ax)), tracked);
// θ̇ = (ω_B − ω_A)·z_A; at zero accelerations its rate is (ω_B − ω_A)·(ω_A × z_A).
Row::new(a.body.map(|k| (k, Vec3::ZERO, -az)), b.body.map(|k| (k, Vec3::ZERO, az)), theta, (b.w - a.w).dot(a.w.cross(az)))
}
CoordKind::Along(k) => dot2_row(&a, a.axes.get(k).copied().unwrap_or(az), &b),
}
}
/// Unit quaternions again after a step.
pub(crate) fn normalize(&self, y: &mut [f64]) -> Result<()> {
for i in 0..self.bodies.len() {
@@ -210,17 +384,42 @@ impl System {
MarkerWorld { body: m.body, r, p: k.x + r, pdot: k.v + k.w.cross(r), axes, w: k.w }
}
/// The constraint equations of every joint at `kin`, in joint order.
fn rows(&self, kin: &[Kin]) -> Vec<Row> {
/// The constraint equations at time `t`: every joint's in joint order, then one per drive
/// (`value − f(t) = 0`), then one per link (`follower − ratio·driver − offset = 0`).
fn rows(&self, kin: &[Kin], t: f64, y: &[f64]) -> Vec<Row> {
let mut rows = Vec::new();
for j in &self.joints {
let a = Self::marker_world(kin, &j.a);
let b = Self::marker_world(kin, &j.b);
joint_rows(j.kind, &a, &b, &mut rows);
}
for (i, motion) in &self.drives {
let (f, fd, fdd) = motion.eval(t);
let mut r = self.coord_row(kin, y, *i);
r.phi -= f;
r.jdot_u -= fdd;
r.rate_target = fd;
rows.push(r);
}
for l in &self.links {
let (f, d) = (self.coord_row(kin, y, l.follower), self.coord_row(kin, y, l.driver));
rows.push(Row {
a: f.a,
b: f.b,
c: scaled(d.a, -l.ratio),
d: scaled(d.b, -l.ratio),
phi: f.phi - l.ratio * d.phi - l.offset,
jdot_u: f.jdot_u - l.ratio * d.jdot_u,
rate_target: 0.0,
});
}
rows
}
fn joint_row_count(&self) -> usize {
self.joints.iter().map(|j| j.kind.equations()).sum()
}
/// `M⁻¹` applied to one body's (force, torque).
fn minv(&self, kin: &[Kin], body: usize, f: Vec3, t: Vec3) -> (Vec3, Vec3) {
match (self.bodies.get(body), kin.get(body)) {
@@ -264,8 +463,38 @@ impl System {
(s, w)
}
/// Applied forces and torques (about each centre of mass, world), gyroscopic terms included.
fn applied(&self, kin: &[Kin]) -> Vec<(Vec3, Vec3)> {
/// Applied forces and torques (about each centre of mass, world), gyroscopic terms included,
/// and the power they put in and the power dampers take out.
fn applied(&self, kin: &[Kin], t: f64, y: &[f64]) -> (Vec<(Vec3, Vec3)>, f64, f64) {
let mut input = 0.0;
let mut dissipated = 0.0;
let mut out = self.point_forces(kin, &mut input, &mut dissipated);
// A generalized force Q along a joint value is Jᵀ Q on the bodies.
let mut along = |row: &Row, q: f64| {
for (i, l, a) in row.blocks() {
if let Some(slot) = out.get_mut(*i) {
slot.0 += *l * q;
slot.1 += *a * q;
}
}
};
for (i, k, c, rest) in &self.springs {
let row = self.coord_row(kin, y, *i);
let rate = row.rate(kin);
along(&row, -k * (row.phi - rest) - c * rate);
dissipated += c * rate * rate;
}
for (i, effort) in &self.motors {
let row = self.coord_row(kin, y, *i);
let q = effort.eval(t).0;
along(&row, q);
input += q * row.rate(kin);
}
(out, input, dissipated)
}
/// Gravity, gyroscopic terms, point springs and loads.
fn point_forces(&self, kin: &[Kin], input: &mut f64, dissipated: &mut f64) -> Vec<(Vec3, Vec3)> {
let mut out: Vec<(Vec3, Vec3)> = self
.bodies
.iter()
@@ -293,21 +522,25 @@ impl System {
let tension = stiffness * (len - free_length) + damping * rate + preload;
add(a.body, ra, n * tension, Vec3::ZERO);
add(b.body, rb, n * -tension, Vec3::ZERO);
*dissipated += damping * rate * rate;
}
Force::Load { body, point, force, torque } => {
let r = kin.get(body).map_or(Vec3::ZERO, |k| k.rot.mul_vec(point));
let k = kin.get(body).copied().unwrap_or(GROUND);
let r = k.rot.mul_vec(point);
add(Some(body), r, force, torque);
*input += force.dot(k.v + k.w.cross(r)) + torque.dot(k.w);
}
Force::JointSpring { .. } | Force::Motor { .. } => {}
}
}
out
}
/// The state's derivative and the joints' multipliers.
pub(crate) fn accelerations(&self, y: &[f64]) -> Result<Accel> {
pub(crate) fn accelerations(&self, t: f64, y: &[f64]) -> Result<Accel> {
let kin = self.kinematics(y);
let applied = self.applied(&kin);
let rows = self.rows(&kin);
let (applied, mut input, dissipated) = self.applied(&kin, t, y);
let rows = self.rows(&kin, t, y);
let (s, w) = self.schur(&kin, &rows);
// Unconstrained accelerations M⁻¹F.
let free: Vec<(Vec3, Vec3)> = applied.iter().enumerate().map(|(i, (f, t))| self.minv(&kin, i, *f, *t)).collect();
@@ -315,7 +548,7 @@ impl System {
.iter()
.map(|r| {
let jmf: f64 = r.blocks().map(|(i, l, a)| free.get(*i).map_or(0.0, |(fl, fa)| l.dot(*fl) + a.dot(*fa))).sum();
let stab = self.baumgarte.map_or(0.0, |(alpha, beta)| 2.0 * alpha * r.rate(&kin) + beta * beta * r.phi);
let stab = self.baumgarte.map_or(0.0, |(alpha, beta)| 2.0 * alpha * (r.rate(&kin) - r.rate_target) + beta * beta * r.phi);
jmf + r.jdot_u + stab
})
.collect();
@@ -338,25 +571,50 @@ impl System {
put3(&mut ydot, at + 7, a.0);
put3(&mut ydot, at + 10, a.1);
}
for i in 0..self.coords.len() {
let rate = self.coord_row(&kin, y, i).rate(&kin);
if let Some(s) = ydot.get_mut(self.coord_base() + i) {
*s = rate;
}
}
// A drive's generalized force is −λ along its value: its power is −λ·(value rate).
let first_drive = self.joint_row_count();
for k in 0..self.drives.len() {
if let (Some(r), Some(l)) = (rows.get(first_drive + k), lambda.get(first_drive + k)) {
input -= l * r.rate(&kin);
}
}
let wb = self.work_base();
for (k, p) in [input, dissipated].into_iter().enumerate() {
if let Some(s) = ydot.get_mut(wb + k) {
*s = p;
}
}
if !ydot.iter().all(|x| x.is_finite()) {
return Err(DynamicsError::Diverged { time: f64::NAN });
}
Ok(Accel { ydot, lambda, rows, rank })
}
/// The largest joint equation error at `y`.
pub(crate) fn constraint_error(&self, y: &[f64]) -> f64 {
self.rows(&self.kinematics(y)).iter().map(|r| r.phi.abs()).fold(0.0, f64::max)
/// What each drive applies along its value (mN·mm or mN), from the multipliers.
pub(crate) fn drive_efforts(&self, acc: &Accel) -> Vec<f64> {
let first = self.joint_row_count();
(0..self.drives.len()).map(|k| -acc.lambda.get(first + k).copied().unwrap_or(0.0)).collect()
}
/// The largest constraint equation error at `y` and time `t`.
pub(crate) fn constraint_error(&self, t: f64, y: &[f64]) -> f64 {
self.rows(&self.kinematics(y), t, y).iter().map(|r| r.phi.abs()).fold(0.0, f64::max)
}
/// Moves the bodies the least (mass-weighted) onto the joints: Gauss–Newton on `Φ = 0`.
/// Returns the error left.
pub(crate) fn project_positions(&self, y: &mut [f64], tol: f64) -> Result<f64> {
pub(crate) fn project_positions(&self, t: f64, y: &mut [f64], tol: f64) -> Result<f64> {
let mut err;
let mut iteration = 0;
loop {
let kin = self.kinematics(y);
let rows = self.rows(&kin);
let rows = self.rows(&kin, t, y);
err = rows.iter().map(|r| r.phi.abs()).fold(0.0, f64::max);
if !(err > tol) || iteration >= MAX_PROJECTION_ITERATIONS {
break;
@@ -387,15 +645,16 @@ impl System {
Ok(err)
}
/// Removes the velocities the joints don't allow (mass-weighted): `J u = 0`.
pub(crate) fn project_velocities(&self, y: &mut [f64]) -> Result<()> {
/// Removes the velocities the joints don't allow (mass-weighted): `J u = ḟ` (0 but for
/// drives).
pub(crate) fn project_velocities(&self, t: f64, y: &mut [f64]) -> Result<()> {
let kin = self.kinematics(y);
let rows = self.rows(&kin);
let rows = self.rows(&kin, t, y);
if rows.is_empty() {
return Ok(());
}
let (s, w) = self.schur(&kin, &rows);
let ju: Vec<f64> = rows.iter().map(|r| r.rate(&kin)).collect();
let ju: Vec<f64> = rows.iter().map(|r| r.rate(&kin) - r.rate_target).collect();
let (mu, _) = solve_psd(s, &ju, RANK_TOL).ok_or(DynamicsError::Singular { time: f64::NAN })?;
for (wr, m) in w.iter().zip(&mu) {
for (i, ml, ma) in wr {
@@ -417,7 +676,7 @@ impl System {
.collect()
}
/// Kinetic, gravity and spring energy.
/// Kinetic, gravity and spring energy, and the work put in and dissipated so far.
pub(crate) fn energy(&self, y: &[f64]) -> Energy {
let kin = self.kinematics(y);
let mut e = Energy::default();
@@ -433,6 +692,13 @@ impl System {
e.spring += 0.5 * stiffness * stretch * stretch + preload * stretch;
}
}
for (i, k, _, rest) in &self.springs {
let stretch = self.coord_row(&kin, y, *i).phi - rest;
e.spring += 0.5 * k * stretch * stretch;
}
let wb = self.work_base();
e.input = y.get(wb).copied().unwrap_or(0.0);
e.dissipated = y.get(wb + 1).copied().unwrap_or(0.0);
e
}
@@ -477,19 +743,19 @@ impl System {
/// `(Φ, J, J̇u)` for one body point's world component along `e`: `e·(p_B − p_A)`.
fn point_row(a: &MarkerWorld, b: &MarkerWorld, e: Vec3) -> Row {
let acc = |m: &MarkerWorld| cross2(m.w, m.r);
Row { a: a.body.map(|i| (i, -e, -a.r.cross(e))), b: b.body.map(|i| (i, e, b.r.cross(e))), phi: e.dot(b.p - a.p), jdot_u: e.dot(acc(b) - acc(a)) }
Row::new(a.body.map(|i| (i, -e, -a.r.cross(e))), b.body.map(|i| (i, e, b.r.cross(e))), e.dot(b.p - a.p), e.dot(acc(b) - acc(a)))
}
/// `u·v = 0` for a direction `u` of A and `v` of B.
fn dot1_row(a: &MarkerWorld, u: Vec3, b: &MarkerWorld, v: Vec3) -> Row {
let (ud, vd) = (a.w.cross(u), b.w.cross(v));
let (udd, vdd) = (cross2(a.w, u), cross2(b.w, v));
Row {
a: a.body.map(|i| (i, Vec3::ZERO, u.cross(v))),
b: b.body.map(|i| (i, Vec3::ZERO, v.cross(u))),
phi: u.dot(v),
jdot_u: udd.dot(v) + 2.0 * ud.dot(vd) + u.dot(vdd),
}
Row::new(
a.body.map(|i| (i, Vec3::ZERO, u.cross(v))),
b.body.map(|i| (i, Vec3::ZERO, v.cross(u))),
u.dot(v),
udd.dot(v) + 2.0 * ud.dot(vd) + u.dot(vdd),
)
}
/// `d·u = 0` for `d = p_B − p_A` and a direction `u` of A.
@@ -498,12 +764,12 @@ fn dot2_row(a: &MarkerWorld, u: Vec3, b: &MarkerWorld) -> Row {
let dd = b.pdot - a.pdot;
let ddd = cross2(b.w, b.r) - cross2(a.w, a.r);
let (ud, udd) = (a.w.cross(u), cross2(a.w, u));
Row {
a: a.body.map(|i| (i, -u, u.cross(d) - a.r.cross(u))),
b: b.body.map(|i| (i, u, b.r.cross(u))),
phi: d.dot(u),
jdot_u: ddd.dot(u) + 2.0 * dd.dot(ud) + d.dot(udd),
}
Row::new(
a.body.map(|i| (i, -u, u.cross(d) - a.r.cross(u))),
b.body.map(|i| (i, u, b.r.cross(u))),
d.dot(u),
ddd.dot(u) + 2.0 * dd.dot(ud) + d.dot(udd),
)
}
fn joint_rows(kind: JointKind, a: &MarkerWorld, b: &MarkerWorld, rows: &mut Vec<Row>) {
@@ -576,7 +842,7 @@ pub(crate) mod testing {
/// `(Φ, J u, J̇ u)` of every row at `y`.
pub(crate) fn rows_at(sys: &System, y: &[f64]) -> Vec<(f64, f64, f64)> {
let kin = sys.kinematics(y);
sys.rows(&kin).iter().map(|r| (r.phi, r.rate(&kin), r.jdot_u)).collect()
sys.rows(&kin, 0.0, y).iter().map(|r| (r.phi, r.rate(&kin), r.jdot_u)).collect()
}
/// The state moved along its velocities for `h` (positions and orientations only).
+265 -5
View File
@@ -38,7 +38,7 @@ fn pendulum(len: f64, theta: f64, omega: f64, inertia: f64) -> Model {
a: Marker::ground(Vec3::ZERO, Vec3::Y, Vec3::X),
b: Marker::new(Some(0), Vec3::new(0.0, 0.0, len), Vec3::Y, Vec3::X),
};
Model { bodies: vec![body], joints: vec![joint], forces: vec![], gravity: down() }
Model { bodies: vec![body], joints: vec![joint], forces: vec![], drives: vec![], links: vec![], gravity: down() }
}
/// Times where `f` crosses zero upwards, linearly interpolated between samples.
@@ -68,7 +68,7 @@ fn free_fall_follows_the_parabola() {
let mut body = Body::at_rest(2.5, Vec3::new(10.0, 20.0, 30.0), Vec3::new(1.0, 2.0, 3.0));
body.velocity = Vec3::new(10.0, -5.0, 50.0);
body.angular_velocity = Vec3::new(0.0, 0.0, 1.0);
let model = Model { bodies: vec![body.clone()], joints: vec![], forces: vec![], gravity: down() };
let model = Model { bodies: vec![body.clone()], joints: vec![], forces: vec![], drives: vec![], links: vec![], gravity: down() };
let traj = ok(simulate(&model, &Settings::rk4(1.0, 0.01, 0.1)));
assert_eq!(traj.samples.len(), 11);
for s in &traj.samples {
@@ -158,6 +158,7 @@ fn double_pendulum() -> Model {
],
forces: vec![],
gravity: down(),
..Model::default()
}
}
@@ -215,6 +216,7 @@ fn slider_crank_follows_its_closed_form() {
],
forces: vec![],
gravity: Vec3::ZERO,
..Model::default()
};
let traj = ok(simulate(&model, &Settings::rk4(2.0, 1e-3, 1e-2)));
// A planar linkage built from 3D joints repeats three equations; they are solved, not refused.
@@ -256,6 +258,7 @@ fn spring_mass(damping: f64) -> Model {
preload: 0.0,
}],
gravity: down(),
..Model::default()
}
}
@@ -294,7 +297,7 @@ fn torque_free_body_keeps_energy_and_momentum() {
let mut body = Body::at_rest(1.0, Vec3::new(1000.0, 2000.0, 3000.0), Vec3::ZERO);
body.orientation = Quat::from_axis_angle(Vec3::new(1.0, 2.0, 3.0), 0.7);
body.angular_velocity = Vec3::new(1.0, 0.3, 5.0);
let model = Model { bodies: vec![body], joints: vec![], forces: vec![], gravity: Vec3::ZERO };
let model = Model { bodies: vec![body], joints: vec![], forces: vec![], drives: vec![], links: vec![], gravity: Vec3::ZERO };
let traj = ok(simulate(&model, &Settings::rk4(2.0, 1e-4, 1e-2)));
let momentum = |s: &Sample| {
let b = s.bodies.first().copied();
@@ -329,6 +332,7 @@ fn spherical_pendulum_keeps_energy_and_vertical_momentum() {
}],
forces: vec![],
gravity: down(),
..Model::default()
};
let traj = ok(simulate(&model, &Settings::rk4(3.0, 5e-4, 1e-2)));
let lz = |s: &Sample| {
@@ -369,9 +373,10 @@ fn jacobians_match_finite_differences() {
a: Marker::new(a_body, Vec3::new(4.0, -3.0, 7.0), Vec3::new(0.2, 0.5, 1.0), Vec3::new(1.0, 0.0, 0.3)),
b: Marker::new(Some(1), Vec3::new(-6.0, 2.0, 1.0), Vec3::new(-0.4, 1.0, 0.1), Vec3::new(0.0, 0.3, 1.0)),
};
let model = Model { bodies: vec![a.clone(), b.clone()], joints: vec![joint], forces: vec![], gravity: Vec3::ZERO };
let model =
Model { bodies: vec![a.clone(), b.clone()], joints: vec![joint], forces: vec![], drives: vec![], links: vec![], gravity: Vec3::ZERO };
let sys = ok(System::new(&model, None));
let y = ok(System::initial_state(&model));
let y = ok(sys.initial_state(&model));
let h = 1e-4;
let (now, plus, minus) = (
testing::rows_at(&sys, &y),
@@ -400,6 +405,7 @@ fn joint_values_read_the_relative_motion() {
joints: vec![Joint { kind, a: Marker::ground(Vec3::ZERO, Vec3::Z, Vec3::X), b: Marker::new(Some(0), Vec3::ZERO, Vec3::Z, Vec3::X) }],
forces: vec![],
gravity: Vec3::ZERO,
..Model::default()
};
let traj = ok(simulate(&model, &Settings::rk4(0.01, 0.01, 0.01)));
traj.samples.first().and_then(|s| s.joints.first()).map(|j| j.values.clone()).unwrap_or_default()
@@ -523,6 +529,10 @@ fn hostile_input_is_an_error_not_a_panic() {
fn models_round_trip_through_json() {
let mut model = spring_mass(1.5);
model.forces.push(Force::Load { body: 0, point: Vec3::new(1.0, 0.0, 0.0), force: Vec3::new(0.0, 5.0, 0.0), torque: Vec3::ZERO });
model.forces.push(Force::JointSpring { at: Coord::new(0, 0), stiffness: 3.0, damping: 0.5, rest: 1.0 });
model.forces.push(Force::Motor { at: Coord::new(0, 0), effort: Profile::Table { points: vec![(0.0, 1.0), (2.0, 3.0)] } });
model.drives.push(Drive { at: Coord::new(0, 0), motion: Profile::Ramp { from: 0.0, to: 1.0, start: 0.5, duration: 2.0 } });
model.links.push(Link { driver: Coord::new(0, 0), follower: Coord::new(1, 0), ratio: 2.0, offset: Some(0.5) });
let text = match serde_json::to_string(&model) {
Ok(t) => t,
Err(e) => panic!("{e}"),
@@ -538,3 +548,253 @@ fn models_round_trip_through_json() {
};
assert!(settings.projection && settings.validate().is_ok());
}
// Joint springs, motors, drives and motion links.
/// `a − b` turned into (−π, π].
fn wrap(x: f64) -> f64 {
(x + PI).rem_euclid(TAU) - PI
}
/// A wheel turning about Z on a revolute to the ground at `at`, its centre of mass on the axis.
/// Wheels turning about Z on revolutes to the ground, centres of mass on their axes (inertia,
/// centre); joint `i` holds body `i`.
fn rotors(parts: &[(f64, Vec3)]) -> Model {
let mut model = Model::default();
for (i, (inertia, at)) in parts.iter().enumerate() {
model.bodies.push(Body::at_rest(1.0, Vec3::new(*inertia, *inertia, *inertia), *at));
model.joints.push(Joint {
kind: JointKind::Revolute,
a: Marker::ground(*at, Vec3::Z, Vec3::X),
b: Marker::new(Some(i), Vec3::ZERO, Vec3::Z, Vec3::X),
});
}
model
}
fn value(s: &Sample, joint: usize, dof: usize) -> f64 {
s.joints.get(joint).and_then(|j| j.values.get(dof)).copied().unwrap_or(f64::NAN)
}
#[test]
fn profiles_give_consistent_derivatives() {
let profiles = [
Profile::Constant { value: 2.0 },
Profile::Linear { value: 1.0, rate: -3.0 },
Profile::Ramp { from: 0.5, to: 2.0, start: 0.2, duration: 0.6 },
Profile::Harmonic { offset: 1.0, amplitude: 0.3, frequency: 2.0, phase: 0.4 },
];
let h = 1e-5;
for p in &profiles {
for k in 0..100 {
let t = k as f64 * 0.0123;
let (f, fd, fdd) = p.eval(t);
let (fp, fdp, _) = p.eval(t + h);
let (fm, fdm, _) = p.eval(t - h);
assert!((fd - (fp - fm) / (2.0 * h)).abs() < 1e-5 * (1.0 + fd.abs()), "{p:?} at {t}: ḟ");
assert!((fdd - (fdp - fdm) / (2.0 * h)).abs() < 1e-3 * (1.0 + fdd.abs()), "{p:?} at {t}: f̈");
assert!(f.is_finite());
}
}
let ramp = Profile::Ramp { from: 0.5, to: 2.0, start: 0.2, duration: 0.6 };
assert_eq!(ramp.eval(0.0).0, 0.5);
assert_eq!(ramp.eval(0.8).0, 2.0);
assert!((ramp.eval(0.5).0 - 1.25).abs() < 1e-12);
let table = Profile::Table { points: vec![(0.0, 1.0), (1.0, 3.0), (2.0, 0.0)] };
assert_eq!(table.eval(-1.0), (1.0, 0.0, 0.0));
assert_eq!(table.eval(0.5), (2.0, 2.0, 0.0));
assert_eq!(table.eval(1.5), (1.5, -3.0, 0.0));
assert_eq!(table.eval(9.0), (0.0, 0.0, 0.0));
}
#[test]
fn driven_pendulum_reaches_its_commanded_angle_and_holds_it() {
// A drive lifts the pendulum from hanging to horizontal along a cycloidal ramp in 1 s,
// then holds it: the angle follows the ramp, and the holding torque is m·g·L.
let (len, mass) = (100.0, 1.0);
let mut model = pendulum(len, 0.0, 0.0, 40.0);
let ramp = Profile::Ramp { from: 0.0, to: PI / 2.0, start: 0.0, duration: 1.0 };
model.drives.push(Drive { at: Coord::new(0, 0), motion: ramp.clone() });
let traj = ok(simulate(&model, &Settings::rk4(1.5, 1e-3, 1e-2)));
let b0 = traj.samples.first().map(|s| s.energy.balance()).unwrap_or(f64::NAN);
for s in &traj.samples {
let angle = value(s, 0, 0);
assert!((angle - ramp.eval(s.time).0).abs() < 1e-9, "t={}: angle {angle} vs {}", s.time, ramp.eval(s.time).0);
// Work put in = kinetic + gravity energy gained.
assert!((s.energy.balance() - b0).abs() < 1e-6 * G * len, "t={}: balance {}", s.time, s.energy.balance() - b0);
}
let held: Vec<&Sample> = traj.samples.iter().filter(|s| s.time >= 1.0).collect();
assert!(held.len() > 40);
for s in held {
let effort = s.drives.first().copied().unwrap_or(f64::NAN);
assert!((effort.abs() - mass * G * len).abs() < 1e-6 * G * len, "t={}: holding torque {effort}", s.time);
assert!((s.energy.input - mass * G * len).abs() < 1e-6 * G * len, "work put in {}", s.energy.input);
assert!(s.energy.kinetic < 1e-9 * G * len);
}
}
#[test]
fn driven_speed_counts_whole_turns() {
// 10 rad/s for 2 s: more than three turns; the driven angle never jumps a turn.
let mut model = rotors(&[(500.0, Vec3::ZERO)]);
model.drives.push(Drive { at: Coord::new(0, 0), motion: Profile::Linear { value: 0.0, rate: 10.0 } });
let traj = ok(simulate(&model, &Settings::rk4(2.0, 1e-3, 1e-2)));
for s in &traj.samples {
assert!(wrap(value(s, 0, 0) - 10.0 * s.time).abs() < 1e-9, "t={}", s.time);
let w = s.bodies.first().map(|b| b.angular_velocity.z).unwrap_or(f64::NAN);
assert!((w - 10.0).abs() < 1e-9);
assert!(s.drives.first().is_some_and(|e| e.abs() < 1e-6), "{:?}", s.drives);
}
assert!(traj.stats.max_constraint_error < 1e-9);
}
#[test]
fn torsion_spring_period() {
// ω = √(k/I) = √(5e5 / 500) rad/s; released from 0.3 rad.
let (inertia, k) = (500.0_f64, 5e5_f64);
let w = (k / inertia).sqrt();
let mut model = rotors(&[(inertia, Vec3::ZERO)]);
if let Some(b) = model.bodies.first_mut() {
b.orientation = Quat::from_axis_angle(Vec3::Z, 0.3);
}
model.forces.push(Force::JointSpring { at: Coord::new(0, 0), stiffness: k, damping: 0.0, rest: 0.0 });
let traj = ok(simulate(&model, &Settings::rk4(1.0, 1e-4, 1e-3)));
for s in &traj.samples {
let expect = 0.3 * (w * s.time).cos();
assert!((value(s, 0, 0) - expect).abs() < 1e-7, "t={}: {} vs {expect}", s.time, value(s, 0, 0));
}
let measured = mean_period(&upward_crossings(&traj, |s| value(s, 0, 0)));
assert!((measured - TAU / w).abs() < 1e-5 * TAU / w);
let e0 = traj.samples.first().map(|s| s.energy.total()).unwrap_or(0.0);
assert!((e0 - 0.5 * k * 0.09).abs() < 1e-9 * e0);
// With damping the energy left plus the energy dissipated stays what it was.
if let Some(Force::JointSpring { damping, .. }) = model.forces.first_mut() {
*damping = 2.0 * 0.05 * (k * inertia).sqrt();
}
let traj = ok(simulate(&model, &Settings::rk4(1.0, 1e-4, 1e-2)));
let last = traj.samples.last().map(|s| s.energy).unwrap_or_default();
assert!(last.total() < 0.2 * e0 && last.dissipated > 0.8 * e0);
assert!(traj.samples.iter().all(|s| (s.energy.balance() - e0).abs() < 1e-7 * e0));
}
#[test]
fn gear_pair_keeps_its_ratio_and_motor_work_is_kinetic_energy() {
// A motor's constant torque τ on gear 1; gear 2 turns at −r₁/r₂ = −0.5 of it. The pair
// accelerates as one inertia I₁ + 0.25·I₂: θ₁ = ½(τ/I_eff)t², and the motor's work τ·θ₁ is
// all kinetic energy. Over 3 s gear 1 makes more than half a turn past ±π.
let (i1, i2, ratio, tau) = (1000.0, 2000.0, -0.5, 1500.0);
let alpha = tau / (i1 + ratio * ratio * i2);
let mut model = rotors(&[(i1, Vec3::ZERO), (i2, Vec3::new(30.0, 0.0, 0.0))]);
model.forces.push(Force::Motor { at: Coord::new(0, 0), effort: Profile::Constant { value: tau } });
model.links.push(Link { driver: Coord::new(0, 0), follower: Coord::new(1, 0), ratio, offset: None });
let traj = ok(simulate(&model, &Settings::rk4(3.0, 1e-3, 1e-2)));
for s in &traj.samples {
let theta = 0.5 * alpha * s.time * s.time;
assert!(wrap(value(s, 0, 0) - theta).abs() < 1e-8, "t={}: gear 1 {} vs {theta}", s.time, value(s, 0, 0));
assert!(wrap(value(s, 1, 0) - ratio * theta).abs() < 1e-8, "t={}: gear 2", s.time);
assert!((s.energy.input - tau * theta).abs() < 1e-6 * tau * 4.5, "t={}: work {}", s.time, s.energy.input);
assert!((s.energy.kinetic - s.energy.input).abs() < 1e-6 * tau * 4.5, "t={}: kinetic {}", s.time, s.energy.kinetic);
}
assert!(0.5 * alpha * 9.0 > PI);
}
#[test]
fn rack_and_pinion_follow_the_pinion_radius() {
// A pinion of radius 20 mm drives a rack on a slider along X; a harmonic motor torque. The
// rack's travel is 20·θ, and the motor's work is the kinetic energy of both.
let mut model = rotors(&[(800.0, Vec3::ZERO)]);
model.bodies.push(Body::at_rest(0.5, Vec3::new(100.0, 100.0, 100.0), Vec3::new(0.0, 20.0, 0.0)));
model.joints.push(Joint {
kind: JointKind::Slider,
a: Marker::ground(Vec3::new(0.0, 20.0, 0.0), Vec3::X, Vec3::Y),
b: Marker::new(Some(1), Vec3::ZERO, Vec3::X, Vec3::Y),
});
model
.forces
.push(Force::Motor { at: Coord::new(0, 0), effort: Profile::Harmonic { offset: 0.0, amplitude: 2000.0, frequency: 1.0, phase: 0.0 } });
model.links.push(Link { driver: Coord::new(0, 0), follower: Coord::new(1, 0), ratio: 20.0, offset: None });
let traj = ok(simulate(&model, &Settings::rk4(2.0, 1e-3, 1e-2)));
// I_eff = 800 + 0.5·20²; θ̈ = τ(t)/I_eff with τ = A sin(2πt): θ = A/I_eff·(t/2π − sin(2πt)/4π²).
let i_eff = 800.0 + 0.5 * 400.0;
for s in &traj.samples {
let t = s.time;
let theta = 2000.0 / i_eff * (t / TAU - (TAU * t).sin() / (TAU * TAU));
assert!((value(s, 0, 0) - theta).abs() < 1e-8, "t={t}: pinion {} vs {theta}", value(s, 0, 0));
assert!((value(s, 1, 0) - 20.0 * theta).abs() < 1e-7, "t={t}: rack {}", value(s, 1, 0));
assert!((s.energy.kinetic - s.energy.input).abs() < 1e-8 * 2000.0, "t={t}: {:?}", s.energy);
}
}
#[test]
fn screw_turns_as_it_falls() {
// A nut on a vertical cylindrical joint, its travel tied to its turn by a 5 mm pitch, falls
// under gravity: z̈ = −m·g·k² / (I + m·k²) with k = pitch / 2π.
let (mass, inertia, k) = (0.2, 50.0, 5.0 / TAU);
let model = Model {
bodies: vec![Body::at_rest(mass, Vec3::new(inertia, inertia, inertia), Vec3::ZERO)],
joints: vec![Joint {
kind: JointKind::Cylindrical,
a: Marker::ground(Vec3::ZERO, Vec3::Z, Vec3::X),
b: Marker::new(Some(0), Vec3::ZERO, Vec3::Z, Vec3::X),
}],
links: vec![Link { driver: Coord::new(0, 0), follower: Coord::new(0, 1), ratio: k, offset: None }],
gravity: down(),
..Model::default()
};
let traj = ok(simulate(&model, &Settings::rk4(1.0, 1e-3, 1e-2)));
let a = -mass * G * k * k / (inertia + mass * k * k);
for s in &traj.samples {
let z = s.bodies.first().map(|b| b.position.z).unwrap_or(f64::NAN);
assert!((z - 0.5 * a * s.time * s.time).abs() < 1e-7, "t={}: z {z}", s.time);
assert!(wrap(value(s, 0, 0) - z / k).abs() < 1e-8, "t={}: turn", s.time);
assert!(s.energy.balance().abs() < 1e-6 * mass * G, "t={}: {:?}", s.time, s.energy);
}
// It went down more than one turn.
assert!(0.5 * a < -5.0);
}
#[test]
fn hostile_springs_motors_drives_and_links_are_errors() {
let good = rotors(&[(500.0, Vec3::ZERO), (500.0, Vec3::new(30.0, 0.0, 0.0))]);
let mut bad: Vec<Model> = Vec::new();
let mut with = |f: &dyn Fn(&mut Model)| {
let mut m = good.clone();
f(&mut m);
bad.push(m);
};
with(&|m| m.drives.push(Drive { at: Coord::new(5, 0), motion: Profile::Constant { value: 0.0 } }));
with(&|m| m.drives.push(Drive { at: Coord::new(0, 1), motion: Profile::Constant { value: 0.0 } }));
with(&|m| m.drives.push(Drive { at: Coord::new(0, 0), motion: Profile::Constant { value: f64::NAN } }));
with(&|m| m.drives.push(Drive { at: Coord::new(0, 0), motion: Profile::Ramp { from: 0.0, to: 1.0, start: 0.0, duration: 0.0 } }));
with(&|m| m.drives.push(Drive { at: Coord::new(0, 0), motion: Profile::Table { points: vec![] } }));
with(&|m| m.drives.push(Drive { at: Coord::new(0, 0), motion: Profile::Table { points: vec![(1.0, 0.0), (0.5, 1.0)] } }));
with(&|m| m.drives.push(Drive { at: Coord::new(0, 0), motion: Profile::Table { points: vec![(0.0, 0.0); MAX_PROFILE_POINTS + 1] } }));
with(&|m| m.forces.push(Force::JointSpring { at: Coord::new(0, 0), stiffness: -1.0, damping: 0.0, rest: 0.0 }));
with(&|m| m.forces.push(Force::JointSpring { at: Coord::new(0, 0), stiffness: 1.0, damping: f64::INFINITY, rest: 0.0 }));
with(&|m| m.forces.push(Force::Motor { at: Coord::new(9, 0), effort: Profile::Constant { value: 1.0 } }));
with(&|m| {
m.forces
.push(Force::Motor { at: Coord::new(0, 0), effort: Profile::Harmonic { offset: 0.0, amplitude: 1.0, frequency: f64::NAN, phase: 0.0 } })
});
with(&|m| m.links.push(Link { driver: Coord::new(0, 0), follower: Coord::new(0, 0), ratio: 1.0, offset: None }));
with(&|m| m.links.push(Link { driver: Coord::new(0, 0), follower: Coord::new(1, 0), ratio: f64::NAN, offset: None }));
with(&|m| m.links.push(Link { driver: Coord::new(0, 0), follower: Coord::new(1, 0), ratio: 1.0, offset: Some(f64::INFINITY) }));
with(&|m| m.links = vec![Link { driver: Coord::new(0, 0), follower: Coord::new(1, 0), ratio: 1.0, offset: None }; MAX_JOINTS + 1]);
with(&|m| {
// Ball and rigid joints have no value to act on yet.
if let Some(j) = m.joints.get_mut(0) {
j.kind = JointKind::Ball;
}
m.drives.push(Drive { at: Coord::new(0, 0), motion: Profile::Constant { value: 0.0 } });
});
for (i, m) in bad.iter().enumerate() {
assert!(simulate(m, &Settings::rk4(0.1, 1e-3, 1e-2)).is_err(), "case {i} should be refused");
}
// Links that fight each other, and drives that can't be met, stop or finish but never panic.
let mut locked = good.clone();
locked.links.push(Link { driver: Coord::new(0, 0), follower: Coord::new(1, 0), ratio: 1.0, offset: None });
locked.links.push(Link { driver: Coord::new(1, 0), follower: Coord::new(0, 0), ratio: 2.0, offset: None });
locked.drives.push(Drive { at: Coord::new(0, 0), motion: Profile::Harmonic { offset: 0.0, amplitude: 1e6, frequency: 1e3, phase: 0.0 } });
let _ = simulate(&locked, &Settings::rk4(0.1, 1e-3, 1e-2));
let _ = simulate(&locked, &Settings::adaptive(0.1, 1e-2, 1e-9, 1e-9));
}
+100
View File
@@ -0,0 +1,100 @@
//! Which timeline features built each body (#11945): the data behind `model.body_features` and
//! the Browser's optional features-under-bodies tree.
use std::collections::{BTreeMap, HashMap, HashSet};
use serde_json::Value;
use crate::params::{bad, str_};
use crate::schema::inspect::{BodyFeature, BodyFeatures, ModelBodyFeatures};
use crate::{Result, Session};
impl Session {
/// The features that built each body of the evaluated model, by the body's model name, in
/// timeline order: the feature that created it, every later feature that changed its
/// geometry, and the features of the bodies a feature consumed while it made or changed this
/// one (a Combine's tools, a body joined in). A piece a cut split off keeps the features of
/// the body it was cut from. Features behind the history marker, which built nothing yet,
/// are not listed.
///
/// Decided on exact body identity (a body a feature passed through unchanged is the same
/// record), never on a mesh.
pub fn body_features(&self) -> BTreeMap<String, Vec<u64>> {
let order: HashMap<u64, usize> = self.doc.features.iter().enumerate().map(|(i, f)| (f.id, i)).collect();
// Each body before the feature being looked at: its geometry, its creator and its features.
let mut before: HashMap<String, (solidcraft_doc::kernel::Body, u64, Vec<u64>)> = HashMap::new();
for r in &self.model.results {
let id = r.id;
let after = &r.state.bodies;
let names: HashSet<&str> = after.iter().map(|b| b.name.as_str()).collect();
// What the feature consumed: bodies there before and gone after.
let consumed: Vec<u64> =
before.iter().filter(|(name, _)| !names.contains(name.as_str())).flat_map(|(_, (_, _, list))| list.iter().copied()).collect();
let mut next: HashMap<String, (solidcraft_doc::kernel::Body, u64, Vec<u64>)> = HashMap::with_capacity(after.len());
for b in after {
let list = match before.get(&b.name) {
// Passed through unchanged.
Some((old, creator, list)) if old.same(&b.body) && *creator == b.feature => list.clone(),
// Changed: it keeps what built it so far.
Some((_, creator, list)) if *creator == b.feature => touched(list.clone(), &consumed, id, &order),
// Made by this feature.
_ if b.feature == id => touched(Vec::new(), &consumed, id, &order),
// A piece of a body made earlier (a split): what built the bodies of its creator.
_ => {
let inherited =
before.values().filter(|(_, creator, _)| *creator == b.feature).flat_map(|(_, _, list)| list.iter().copied()).collect();
touched(inherited, &consumed, id, &order)
}
};
next.insert(b.name.clone(), (b.body.clone(), b.feature, list));
}
before = next;
}
// The model as it stands (a sketch shown live keeps its last results).
self.model
.state()
.bodies
.iter()
.map(|b| (b.name.clone(), before.get(&b.name).map(|(_, _, list)| list.clone()).unwrap_or_else(|| vec![b.feature])))
.collect()
}
}
/// `list` with the features of what was consumed and the feature `id` added, once each, in
/// timeline order.
fn touched(mut list: Vec<u64>, consumed: &[u64], id: u64, order: &HashMap<u64, usize>) -> Vec<u64> {
list.extend(consumed.iter().copied());
list.push(id);
list.sort_by_key(|f| order.get(f).copied().unwrap_or(usize::MAX));
list.dedup();
list
}
/// `model.body_features`: the features that built each body, or one body (a placed copy's name
/// means the body it shows).
pub(super) fn model_body_features(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "model.body_features";
let all = s.body_features();
let wanted = match str_(p, "body") {
Some(name) => {
let st = s.world_state()?;
let b = st.body(name).ok_or_else(|| bad(cmd, format!("no body `{name}`")))?;
Some((b.name.clone(), b.definition_name().to_string()))
}
None => None,
};
let feature = |id: &u64| BodyFeature { id: *id, name: s.doc.feature(*id).map(|f| f.name.clone()).unwrap_or_default() };
let bodies = match wanted {
Some((shown, definition)) => {
vec![BodyFeatures { body: shown, features: all.get(&definition).map(|l| l.iter().map(feature).collect()).unwrap_or_default() }]
}
None => s
.model
.state()
.bodies
.iter()
.map(|b| BodyFeatures { body: b.name.clone(), features: all.get(&b.name).map(|l| l.iter().map(feature).collect()).unwrap_or_default() })
.collect(),
};
serde_json::to_value(ModelBodyFeatures { bodies, param_warnings: Vec::new() }).map_err(|e| bad(cmd, e.to_string()))
}
+49 -26
View File
@@ -13,30 +13,45 @@
use std::collections::{BTreeMap, HashSet};
use serde_json::{Map, Value, json};
use serde_json::{Value, json};
use solidcraft_doc::BomType;
use solidcraft_doc::metadata::{BOM_KEYS, BOM_TYPE, MAX_KEYS, Owner, check_text, key_list};
use solidcraft_doc::metadata::{BOM_KEYS, BOM_TYPE, DESCRIPTION, MAX_KEYS, Owner, PART_NUMBER, REVISION, check_text, key_list};
use super::CommandSpec;
use crate::params::{bad, str_};
use crate::schema::bom::{Bom, BomChild, BomParams, BomRow};
use crate::schema::bom::{
Bom, BomChild, BomParams, BomRow, ComponentMetadataParams, ComponentMetadataResult, ComponentMetadataSetParams, ComponentMetadataSetResult,
};
use crate::schema::{TypedContract, output_schema_for};
use crate::{EngineError, Result, Session};
const BOM_CONTRACT: TypedContract<BomParams> = TypedContract::new();
const METADATA_CONTRACT: TypedContract<ComponentMetadataParams> = TypedContract::new();
const METADATA_SET_CONTRACT: TypedContract<ComponentMetadataSetParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("component.metadata", "Part Properties", read).icon("settings").noundo().doc_only().params(
"component: id or name (0 or \"root\": the design's own properties) → component, name, part_number, description, revision, \
CommandSpec::new("component.metadata", "Part Properties", crate::checked!("component.metadata", read))
.icon("settings")
.noundo()
.doc_only()
.contract(&METADATA_CONTRACT)
.output(output_schema_for::<ComponentMetadataResult>)
.params(
"component: id or name (0 or \"root\": the design's own properties) → component, name, part_number, description, revision, \
bom_type (components only), metadata (as saved, other tools' keys too), properties: {name: text}",
),
CommandSpec::new("component.metadata.set", "Change Part Properties", set).icon("settings").doc_only().params(
"component: id or name (not the root: use document.metadata.set); part_number?, description?, revision?: text (empty or null \
),
CommandSpec::new("component.metadata.set", "Change Part Properties", crate::checked!("component.metadata.set", set))
.icon("settings")
.doc_only()
.contract(&METADATA_SET_CONTRACT)
.output(output_schema_for::<ComponentMetadataSetResult>)
.params(
"component: id or name (not the root: use document.metadata.set); part_number?, description?, revision?: text (empty or null \
removes); bom_type?: normal|phantom|reference|purchased|inseparable (empty or null: normal); values?: {key: value} \
(SolidCraft's keys, as document.metadata's properties lists them, bom_type, or a reverse-DNS key holding an object); \
properties?: {name: text, …} (free properties; empty or null removes). All or nothing, one undo step → component, \
metadata, properties, changed: [keys]",
),
),
CommandSpec::new("assembly.bom", "Bill of Materials", crate::checked!("assembly.bom", bom))
.at("SOLID", "INSPECT")
.icon("bom")
@@ -80,24 +95,25 @@ fn read(s: &mut Session, p: &Value) -> Result<Value> {
let id = component_id(s, p, cmd)?;
// The root is the design: its own properties, and no free ones.
let (name, m, properties) = if id == 0 {
(&s.doc.name, &s.doc.metadata, json!({}))
(&s.doc.name, &s.doc.metadata, BTreeMap::new())
} else {
let c = s.doc.components.iter().find(|c| c.id == id).ok_or_else(|| bad(cmd, format!("no component `{id}`")))?;
(&c.name, &c.metadata, json!(c.properties))
(&c.name, &c.metadata, c.properties.clone())
};
let mut out = Map::new();
out.insert("component".into(), json!(id));
out.insert("name".into(), json!(name));
for k in BOM_KEYS {
out.insert(k.to_string(), json!(m.text(k)));
}
// A bill of materials type is a component's; the design has none.
if id != 0 {
out.insert(BOM_TYPE.into(), json!(m.bom_type().as_str()));
}
out.insert("metadata".into(), json!(m));
out.insert("properties".into(), properties);
Ok(Value::Object(out))
let text = |k: &str| m.text(k).map(str::to_string);
let reply = ComponentMetadataResult {
component: id,
name: name.clone(),
part_number: text(PART_NUMBER),
description: text(DESCRIPTION),
revision: text(REVISION),
// A bill of materials type is a component's; the design has none.
bom_type: (id != 0).then(|| m.bom_type().as_str().to_string()),
metadata: m.0.clone(),
properties,
param_warnings: Vec::new(),
};
serde_json::to_value(reply).map_err(|e| bad(cmd, e.to_string()))
}
/// A free property as it may be saved (`None`: remove it), or why not.
@@ -178,8 +194,15 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
c.metadata = metadata;
c.properties = properties;
}
let c = s.doc.components.iter().find(|c| c.id == id);
Ok(json!({"component": id, "metadata": c.map(|c| &c.metadata), "properties": c.map(|c| &c.properties), "changed": changed}))
let c = s.doc.components.iter().find(|c| c.id == id).ok_or_else(|| bad(cmd, format!("no component `{id}`")))?;
let reply = ComponentMetadataSetResult {
component: id,
metadata: c.metadata.0.clone(),
properties: c.properties.clone(),
changed,
param_warnings: Vec::new(),
};
serde_json::to_value(reply).map_err(|e| bad(cmd, e.to_string()))
}
#[derive(Clone, Copy, PartialEq)]
+111 -44
View File
@@ -19,9 +19,11 @@ use crate::params::{bad, bool_, expr, str_};
use crate::schema::browser::{
BodyRenameParams, BodyRenamed, BrowserFoldParams, BrowserFoldResult, BrowserGroupMade, BrowserGroupParams, BrowserGroupRenamed,
BrowserMoveParams, BrowserMoved, BrowserOrderParams, BrowserOrdered, BrowserRenameGroupParams, BrowserUngroupParams, BrowserUngrouped,
BrowserVisibilityParams, BrowserVisibilityResult, DocumentUnitsParams, DocumentUnitsResult, Isolated, RepairDone, RepairFace, RepairHole,
RepairParams, RepairResult, RepairSewn, VisibilityState,
BrowserVisibilityParams, BrowserVisibilityResult, DocumentUnitsParams, DocumentUnitsResult, Isolated, PlaneFrame, RedefineAttachment,
RedefineSketchParams, RedefineSketchResult, RepairDone, RepairFace, RepairHole, RepairParams, RepairResult, RepairSewn, ViewApplyParams,
ViewDeleteParams, ViewDeleted, ViewListParams, ViewListResult, ViewRenameParams, ViewRenamed, ViewSaveParams, ViewSaved, VisibilityState,
};
use crate::schema::view_navigation::CameraReply;
use crate::schema::{TypedContract, output_schema_for};
use crate::{Result, Session};
@@ -35,6 +37,12 @@ const MOVE_CONTRACT: TypedContract<BrowserMoveParams> = TypedContract::new();
const ORDER_CONTRACT: TypedContract<BrowserOrderParams> = TypedContract::new();
const VISIBILITY_CONTRACT: TypedContract<BrowserVisibilityParams> = TypedContract::new();
const FOLD_CONTRACT: TypedContract<BrowserFoldParams> = TypedContract::new();
const REDEFINE_SKETCH_CONTRACT: TypedContract<RedefineSketchParams> = TypedContract::new();
const VIEW_SAVE_CONTRACT: TypedContract<ViewSaveParams> = TypedContract::new();
const VIEW_RENAME_CONTRACT: TypedContract<ViewRenameParams> = TypedContract::new();
const VIEW_DELETE_CONTRACT: TypedContract<ViewDeleteParams> = TypedContract::new();
const VIEW_LIST_CONTRACT: TypedContract<ViewListParams> = TypedContract::new();
const VIEW_APPLY_CONTRACT: TypedContract<ViewApplyParams> = TypedContract::new();
/// A reply as JSON: the commands here build their replies from the types their output schemas
/// describe, so the two can't drift apart.
@@ -56,8 +64,10 @@ pub static COMMANDS: &[CommandSpec] = &[
.params(
"body: its name (a part the modelling kernel refuses, or an open part that should be a solid); gap?: expr (default 0.2 mm, at most 1 mm: free edges that pair up within it are sewn, each sewn edge carrying its own gap as its tolerance); patch_holes?: bool (default false: loops of free edges left are patched, in the plane of the faces around them where those are that plane, else tangent to them, and sewn in; otherwise the reply offers the patches); name? → the feature, and repair: what changed face by face (rebuilt and refused faces, sewn gaps, holes with their surface.patch), faces and largest edge tolerance before and after, closed, modelling, volume. Refused with why when nothing applies",
),
CommandSpec::new("sketch.redefine", "Redefine Sketch Plane", redefine_sketch)
CommandSpec::new("sketch.redefine", "Redefine Sketch Plane", crate::checked!("sketch.redefine", redefine_sketch))
.icon("plane")
.contract(&REDEFINE_SKETCH_CONTRACT)
.output(output_schema_for::<RedefineSketchResult>)
.params("sketch: id or name; plane: as sketch.create; frame?: as sketch.create; author_path?: exact owner occurrence ids; explicitly rebinds or freezes the plane while preserving geometry and dimension names"),
CommandSpec::new("browser.group", "New Group", crate::checked!("browser.group", group))
.icon("folder")
@@ -84,13 +94,33 @@ pub static COMMANDS: &[CommandSpec] = &[
.contract(&UNITS_CONTRACT)
.output(crate::schema::output_schema_for::<DocumentUnitsResult>)
.params("units: mm|cm|m|in|ft (display and unit-less parameters; values keep their meaning)"),
CommandSpec::new("view.save", "New Named View", view_save)
CommandSpec::new("view.save", "New Named View", crate::checked!("view.save", view_save))
.icon("camera")
.contract(&VIEW_SAVE_CONTRACT)
.output(output_schema_for::<ViewSaved>)
.params("name?; camera: {target: [x,y,z], yaw, pitch, distance, fov?} (replaces a view of the same name)"),
CommandSpec::new("view.rename", "Rename Named View", view_rename).icon("rename").params("view: name, name"),
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(
CommandSpec::new("view.rename", "Rename Named View", crate::checked!("view.rename", view_rename))
.icon("rename")
.contract(&VIEW_RENAME_CONTRACT)
.output(output_schema_for::<ViewRenamed>)
.params("view: name, name"),
CommandSpec::new("view.delete", "Delete Named View", crate::checked!("view.delete", view_delete))
.icon("delete")
.contract(&VIEW_DELETE_CONTRACT)
.output(output_schema_for::<ViewDeleted>)
.params("view: name"),
CommandSpec::new("view.list", "List Named Views", crate::checked!("view.list", view_list))
.icon("list")
.noundo()
.contract(&VIEW_LIST_CONTRACT)
.output(output_schema_for::<ViewListResult>)
.doc_only(),
CommandSpec::new("view.apply", "Restore Named View", crate::checked!("view.apply", view_apply))
.icon("camera")
.noundo()
.contract(&VIEW_APPLY_CONTRACT)
.output(output_schema_for::<CameraReply>)
.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?, roll?} (radians, mm, about the up axis; yaw: heading of the line of sight, 0 along +Y (Front), π/2 along +X (Left), π (Back), −π/2 (Right); pitch: the eye's elevation, within ±π/2, π/2 = Top; roll defaults to 0, turns the picture clockwise, must be finite and is clamped to ±π; the View angles readout shows these in degrees; 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", crate::checked!("browser.order", order))
@@ -1049,14 +1079,47 @@ fn document_units(s: &mut Session, p: &Value) -> Result<Value> {
reply(kept)
}
/// A camera `view.save` keeps, as the handler reads it (and `ViewCamera`, its contract type): a
/// [`solidcraft_doc::NamedView`] of finite numbers with a positive distance, or the message the
/// handler refuses it with.
pub(crate) fn saved_view_form(c: &Value) -> std::result::Result<solidcraft_doc::NamedView, String> {
let v: solidcraft_doc::NamedView = serde_json::from_value(c.clone()).map_err(|e| format!("bad camera: {e}"))?;
let ok = [v.target.x, v.target.y, v.target.z, v.yaw, v.pitch, v.distance, v.fov].iter().all(|x| x.is_finite()) && v.distance > 0.0;
if ok { Ok(v) } else { Err("the camera must be finite with a positive distance".into()) }
}
/// The camera `view.apply` turns to for a `camera` it is given, as the handler reads it (and
/// `ApplyCamera`, its contract type), or the message the handler refuses it with.
pub(crate) fn applied_camera_form(c: &Value) -> std::result::Result<crate::render::Camera, String> {
if c.get("up").is_some() {
return Err("camera `up` is not supported: the up axis comes from the design settings".into());
}
let roll = match c.get("roll") {
Some(value) => value.as_f64().filter(|r| r.is_finite()).ok_or("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| format!("bad camera: {e}"))?;
// Past a pole the same view has other angles; the orbit would snap it back (#6032).
if !(v.pitch.abs() <= std::f64::consts::FRAC_PI_2 + 1e-9) {
return Err("camera `pitch` must be within ±π/2 radians (±90°)".into());
}
let cam = crate::render::Camera { roll: roll.clamp(-std::f64::consts::PI, std::f64::consts::PI), ..camera_of(&v) };
if !usable_camera(&cam) {
return Err("the camera must be finite with a positive distance".into());
}
Ok(cam)
}
/// A camera the viewport can be put at: finite, with a positive distance and a field of view of
/// at least 0.
fn usable_camera(cam: &crate::render::Camera) -> bool {
cam.is_valid() && cam.fov.is_finite() && cam.fov >= 0.0
}
fn view_save(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "view.save";
let c = p.get("camera").ok_or_else(|| bad(cmd, "`camera` is required"))?;
let mut v: solidcraft_doc::NamedView = serde_json::from_value(c.clone()).map_err(|e| bad(cmd, format!("bad camera: {e}")))?;
let ok = [v.target.x, v.target.y, v.target.z, v.yaw, v.pitch, v.distance, v.fov].iter().all(|x| x.is_finite()) && v.distance > 0.0;
if !ok {
return Err(bad(cmd, "the camera must be finite with a positive distance"));
}
let mut v = saved_view_form(c).map_err(|e| bad(cmd, e))?;
let n = s.doc.named_views.len();
v.name = match str_(p, "name").map(str::trim).filter(|n| !n.is_empty()) {
Some(n) if n.len() <= 128 => n.to_string(),
@@ -1070,7 +1133,7 @@ fn view_save(s: &mut Session, p: &Value) -> Result<Value> {
let d = s.doc_mut();
d.named_views.retain(|x| x.name != name);
d.named_views.push(v);
Ok(json!({ "view": name }))
reply(cmd, ViewSaved { view: name, param_warnings: Vec::new() })
}
fn view_apply(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1078,23 +1141,7 @@ fn view_apply(s: &mut Session, p: &Value) -> Result<Value> {
let cam = match (str_(p, "view"), p.get("camera")) {
(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}")))?;
// Past a pole the same view has other angles; the orbit would snap it back (#6032).
if !(v.pitch.abs() <= std::f64::consts::FRAC_PI_2 + 1e-9) {
return Err(bad(cmd, "camera `pitch` must be within ±π/2 radians (±90°)"));
}
crate::render::Camera { roll: roll.clamp(-std::f64::consts::PI, std::f64::consts::PI), ..camera_of(&v) }
}
(None, Some(c)) => applied_camera_form(c).map_err(|e| bad(cmd, e))?,
// Standard view names come first, as in the browser's Named Views folder.
(Some(name), None) => match crate::render::StandardView::parse(name) {
Some(sv) => {
@@ -1106,11 +1153,11 @@ fn view_apply(s: &mut Session, p: &Value) -> Result<Value> {
None => s.doc.named_views.iter().find(|v| v.name == name).map(camera_of).ok_or_else(|| bad(cmd, format!("no named view `{name}`")))?,
},
};
if !cam.is_valid() || !cam.fov.is_finite() || cam.fov < 0.0 {
if !usable_camera(&cam) {
return Err(bad(cmd, "the camera must be finite with a positive distance"));
}
s.camera = Some(cam);
Ok(json!({ "camera": cam }))
reply(cmd, CameraReply { camera: cam, param_warnings: Vec::new() })
}
/// The orbit camera of a named view (Z up, no roll).
@@ -1134,17 +1181,18 @@ fn view_rename(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(v) = s.doc_mut().named_views.get_mut(i) {
v.name = name.to_string();
}
Ok(json!({ "view": name }))
reply(cmd, ViewRenamed { view: name.to_string(), param_warnings: Vec::new() })
}
fn view_delete(s: &mut Session, p: &Value) -> Result<Value> {
let i = view_index(s, p, "view.delete")?;
let cmd = "view.delete";
let i = view_index(s, p, cmd)?;
let v = s.doc_mut().named_views.remove(i);
Ok(json!({ "deleted": v.name }))
reply(cmd, ViewDeleted { deleted: v.name, param_warnings: Vec::new() })
}
fn view_list(s: &mut Session, _p: &Value) -> Result<Value> {
Ok(json!({ "views": s.doc.named_views }))
reply("view.list", ViewListResult { views: s.doc.named_views.clone(), param_warnings: Vec::new() })
}
/// The keys whose strings name bodies, alone or in a list (`body`, `targets`…), at any depth of a
@@ -1193,7 +1241,9 @@ fn repair_body(s: &mut Session, p: &Value) -> Result<Value> {
use solidcraft_doc::expr::Kind;
use solidcraft_kernel::{Continuity, SurfaceKind};
let cmd = "body.repair";
let name = str_(p, "body").filter(|b| !b.trim().is_empty()).ok_or_else(|| bad(cmd, "`body` must name the part to repair"))?.to_string();
let name = str_(p, "body").filter(|b| !b.trim().is_empty()).ok_or_else(|| bad(cmd, "`body` must name the part to repair"))?;
// A placed copy's name means the body it shows (#9740); the repair goes on that body.
let name = s.own_body_name(name).ok_or_else(|| bad(cmd, format!("no body `{name}`")))?;
let state = s.model.state();
let source = state.body(&name).ok_or_else(|| bad(cmd, format!("no body `{name}`")))?;
if source.body.is_mesh() {
@@ -1491,17 +1541,17 @@ fn redefine_sketch(s: &mut Session, p: &Value) -> Result<Value> {
let attachment = s.doc.feature(id).and_then(|feature| match &feature.kind {
FeatureKind::Sketch { plane, .. } => Some(if super::sketch::face::anchor(plane).is_some() {
"exact_face"
RedefineAttachment::ExactFace
} else if solidcraft_doc::sheet::plane_sheet_face(plane).is_some() {
"sheet_face"
RedefineAttachment::SheetFace
} else {
"fixed"
RedefineAttachment::Fixed
}),
_ => None,
});
let mut out = json!({ "sketch": id,"attachment":attachment });
super::sketch::frame_reply(&mut out, frame, &placement);
Ok(out)
let note = frame.and_then(|f| super::sketch::frame_note(Some(f), &placement)).map(str::to_string);
let frame = frame.and_then(PlaneFrame::from_name);
reply(cmd, RedefineSketchResult { sketch: id, attachment, frame, note, created: None, param_warnings: Vec::new() })
}
#[cfg(test)]
@@ -1546,6 +1596,23 @@ mod tests {
assert!(back.iter().any(|n| n == "KB") && back.iter().any(|n| n == "KB (K:2)"), "{back:?}");
}
/// Repair takes a placed copy's name (`KB (K:2)`) for the body it shows, as Rename does.
#[test]
fn repair_part_takes_a_placed_copys_name() {
let mut s = Session::default();
run(&mut s, "component.create", json!({"name": "K"}));
run(&mut s, "solid.box", json!({"body_name": "KB", "length": 10, "width": 10, "height": 10}));
run(&mut s, "surface.unstitch", json!({"body": "KB", "faces": [[5, 5, 10]]}));
run(&mut s, "occurrence.copy", json!({"component": "K", "translate": [30, 0, 0]}));
assert!(s.model.state().body("KB").is_some_and(|b| b.body.is_surface()));
let features = s.doc.features.len();
run(&mut s, "body.repair", json!({"body": "KB (K:2)", "patch_holes": true}));
assert_eq!(s.doc.features.len(), features + 1);
assert!(s.model.state().body("KB").is_some_and(|b| !b.body.is_surface()), "the body both placements show is closed");
let err = s.execute("body.repair", &json!({"body": "KB (K:9)"})).unwrap_err().to_string();
assert!(err.contains("no body `KB (K:9)`"), "{err}");
}
#[test]
fn repair_part_patches_a_missing_face_as_one_undoable_feature() {
let mut s = Session::default();
+105 -23
View File
@@ -4,37 +4,70 @@
use std::collections::BTreeMap;
use serde_json::{Value, json};
use serde_json::Value;
use solidcraft_doc::{FeatureKind, PlaneRef};
use super::CommandSpec;
use crate::params::{bad, name_or_id_values, vec3};
use crate::schema::timeline_clipboard::{CopyParams, CopyResult, PasteError, PasteParams, PasteResult, Pasted};
use crate::schema::{TypedContract, output_schema_for};
use crate::{Result, Session};
const COPY_CONTRACT: TypedContract<CopyParams> = TypedContract::new();
const PASTE_CONTRACT: TypedContract<PasteParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("timeline.copy", "Copy Features", copy).icon("copy").noundo().params("features: [ids or names] (in timeline order on paste)"),
CommandSpec::new("timeline.paste", "Paste Features", paste).icon("paste").params(
"translate?: [x,y,z] (moves the copies' geometry); component?: where the copies go (default: the active one), \
CommandSpec::new("timeline.copy", "Copy Features", crate::checked!("timeline.copy", copy))
.icon("copy")
.noundo()
.contract(&COPY_CONTRACT)
.output(output_schema_for::<CopyResult>)
.params("features: [ids or names] (in timeline order on paste)"),
CommandSpec::new("timeline.paste", "Paste Features", crate::checked!("timeline.paste", paste))
.icon("paste")
.contract(&PASTE_CONTRACT)
.output(output_schema_for::<PasteResult>)
.params(
"translate?: [x,y,z] (moves the copies' geometry); component?: where the copies go (default: the active one), \
component_path?: its placement (default: the active one, else its only one); the copies go at the marker, each where \
its original is in the world (copies into their own component keep its coordinates); body transfers are left out; \
from_design?: true to paste sketch features from the host's cross-design clipboard snapshot",
),
),
];
fn copy(s: &mut Session, p: &Value) -> Result<Value> {
/// A command's reply as JSON: they build their replies from the types their output schemas
/// describe, so the two can't drift apart.
fn reply(cmd: &str, reply: impl serde::Serialize) -> Result<Value> {
serde_json::to_value(reply).map_err(|e| bad(cmd, e.to_string()))
}
/// The `features` of `timeline.copy`: named or numbered, both kinds in one list, 1…1000 of them.
/// The reader the command's contract uses too.
pub(crate) fn copy_keys(p: &Value) -> Result<&[Value]> {
let cmd = "timeline.copy";
// The features are named or numbered, and both kinds can sit in one list.
let keys = name_or_id_values(cmd, p, "features")?;
if keys.is_empty() || keys.len() > 1000 {
return Err(bad(cmd, "`features` must list 1…1000 features"));
}
Ok(keys)
}
/// A `component_path` of `timeline.paste`: 0 to 64 occurrence ids. The reader both pastes and the
/// command's contract use.
pub(crate) fn component_path_form(v: &Value) -> Option<Vec<u64>> {
v.as_array().filter(|a| a.len() <= 64).and_then(|a| a.iter().map(Value::as_u64).collect::<Option<Vec<_>>>())
}
fn copy(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "timeline.copy";
let keys = copy_keys(p)?;
let ids = keys.iter().map(|key| super::edit::feature_id(s, Some(key), cmd)).collect::<Result<Vec<_>>>()?;
// Keep timeline order so sketches come before the features that use them.
let copied: Vec<solidcraft_doc::Feature> = s.doc.features.iter().filter(|f| ids.contains(&f.id)).cloned().collect();
let names: Vec<String> = copied.iter().map(|f| f.name.clone()).collect();
s.clipboard = copied;
s.foreign_copy = Some(std::sync::Arc::new(crate::ForeignCopy::of_features(s)));
Ok(json!({"copied": names}))
reply(cmd, CopyResult { copied: names, param_warnings: Vec::new() })
}
/// Origin planes become explicit planes so a translation can move them.
@@ -86,12 +119,7 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
};
let given_path = match p.get("component_path") {
None => None,
Some(v) => Some(
v.as_array()
.filter(|a| a.len() <= 64)
.and_then(|a| a.iter().map(Value::as_u64).collect::<Option<Vec<_>>>())
.ok_or_else(|| bad(cmd, "`component_path` must list 0..64 occurrence ids"))?,
),
Some(v) => Some(component_path_form(v).ok_or_else(|| bad(cmd, "`component_path` must list 0..64 occurrence ids"))?),
};
let mut sketches: BTreeMap<u64, u64> = BTreeMap::new();
let mut bodies: BTreeMap<String, String> = BTreeMap::new();
@@ -102,13 +130,14 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
let author_path = match given_path {
Some(path) => path,
None if comp == s.active_component => s.active_occurrence_path.clone().unwrap_or_else(|| s.doc.first_path(comp)),
None => super::component::placing::placement_of(s, &s.placed_document(), comp, None, "component_path", cmd)?.0,
None => super::component::placing::placement_of(s, &s.design_document(), comp, None, "component_path", cmd)?.0,
};
solidcraft_doc::EmbossFace::component_frame(&s.placed_document(), comp, &author_path)?;
solidcraft_doc::EmbossFace::component_frame(&s.design_document(), comp, &author_path)?;
// A copy from another component goes where its original is in the world: its geometry moves
// from its component's frame (its first placement, where its bodies have their own names)
// into the target's.
let placed = s.placed_document();
// into the target's. The design's placements: an exploded view shown is left out, or the copy
// would keep its offset once the view is closed.
let placed = s.design_document();
let target_frame = placed.path_transform(comp, &author_path).ok_or_else(|| bad(cmd, "that path is not a placement of the target"))?;
let to_target = solidcraft_doc::mat_inverse(&target_frame).ok_or_else(|| bad(cmd, "the target's frame is not invertible"))?;
let frame_change: BTreeMap<u64, solidcraft_doc::Mat> = feats
@@ -229,15 +258,15 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
let new_name = doc.feature(id).map(|x| x.name.clone()).unwrap_or_default();
sketches.insert(f.id, id);
names.insert(f.name.clone(), new_name.clone());
made.push(json!({"id": id, "name": new_name, "from": f.name}));
made.push(Pasted { id, name: new_name, from: f.name.clone() });
}
s.refresh();
let errors: Vec<Value> = made
let errors: Vec<PasteError> = made
.iter()
.filter_map(|m| m["id"].as_u64())
.filter_map(|id| s.model.result(id).and_then(|r| r.error.clone()).map(|e| json!({"feature": id, "error": e})))
.map(|m| m.id)
.filter_map(|id| s.model.result(id).and_then(|r| r.error.clone()).map(|error| PasteError { feature: id, error }))
.collect();
Ok(json!({"pasted": made, "errors": errors, "notices":notices}))
reply(cmd, PasteResult { pasted: made, errors, notices, param_warnings: Vec::new() })
}
#[cfg(test)]
@@ -373,6 +402,33 @@ mod tests {
}
}
/// A sketch pasted from another design while an exploded view moves the target is where the
/// original is once the target is shown assembled again.
#[test]
fn foreign_sketch_pasted_while_an_exploded_view_is_shown_keeps_its_world_position() {
let mut source = dimensioned_sketch();
run(&mut source, "timeline.copy", json!({"features": ["S"]}));
let mut target = Session::default();
let made = run(&mut target, "component.create", json!({"name": "Target", "activate": false}));
let component = made["component"].as_u64().unwrap();
let occurrence = made["occurrence"].as_u64().unwrap();
run(&mut target, "occurrence.ground", json!({"occurrence": occurrence, "grounded": false}));
run(&mut target, "occurrence.move", json!({"occurrence": occurrence, "translate": [100, 20, 30]}));
run(&mut target, "component.create", json!({"name": "B", "parent": 0, "activate": false}));
let view =
run(&mut target, "explode.create", json!({"moves": [{"occurrence": occurrence, "translate": [0, 0, 40]}]}))["view"].as_u64().unwrap();
run(&mut target, "explode.show", json!({"view": view}));
target.foreign_copy = source.foreign_copy.clone();
let id = run(&mut target, "timeline.paste", json!({"from_design": true, "component": component, "component_path": [occurrence]}))["pasted"]
[0]["id"]
.as_u64()
.unwrap();
run(&mut target, "explode.show", json!({}));
let original = source.world_state().unwrap().sketch(1).unwrap().plane;
let pasted = target.world_state().unwrap().sketch(id).unwrap().plane;
assert!(pasted.origin.dist(original.origin) < 1e-6, "{original:?} vs {pasted:?}");
}
#[test]
fn foreign_sketch_paste_refuses_external_references_and_bad_parameters_atomically() {
let mut source = dimensioned_sketch();
@@ -496,6 +552,32 @@ mod tests {
assert_eq!(s.doc.model_body_component(state.body("Block (2)").unwrap()), a);
}
/// An exploded view shown while pasting is display only: once the design is shown assembled
/// again, the copy is where the original is, as it is without the view.
#[test]
fn features_pasted_while_an_exploded_view_is_shown_stay_where_they_were() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "S", "distance": 5, "body_name": "Block"}));
let made = run(&mut s, "component.create", json!({"name": "A", "activate": false}));
let (a, at) = (made["component"].as_u64().unwrap(), made["occurrence"].as_u64().unwrap());
run(&mut s, "occurrence.ground", json!({"occurrence": at, "grounded": false}));
run(&mut s, "occurrence.move", json!({"occurrence": at, "translate": [100, 0, 0], "axis": [0, 0, 1], "angle": "90 deg"}));
// An exploded view needs two placements: B stays put.
run(&mut s, "component.create", json!({"name": "B", "parent": 0, "activate": false}));
let view = run(&mut s, "explode.create", json!({"moves": [{"occurrence": at, "translate": [0, 50, 0]}]}))["view"].as_u64().unwrap();
run(&mut s, "explode.show", json!({"view": view}));
run(&mut s, "timeline.copy", json!({"features": ["S", "Extrude1"]}));
let r = run(&mut s, "timeline.paste", json!({"component": a, "component_path": [at]}));
assert_eq!(r["errors"].as_array().map(Vec::len), Some(0), "{r}");
run(&mut s, "explode.show", json!({}));
let world = s.world_state().unwrap();
let (original, copy) = (world.body("Block").unwrap().mesh().bounds(), world.body("Block (2)").unwrap().mesh().bounds());
assert!(copy.min.dist(original.min) < 1e-6 && copy.max.dist(original.max) < 1e-6, "{original:?} vs {copy:?}");
}
/// A sketch attached to a face of a body that isn't copied with it can't be carried into a
/// component placed elsewhere: the paste is refused, saying to make its plane fixed first.
#[test]
+9 -10
View File
@@ -2,10 +2,11 @@
use std::collections::{BTreeMap, BTreeSet};
use serde_json::{Value, json};
use serde_json::Value;
use solidcraft_doc::{Document, Feature, FeatureKind, Mat, PlaneRef};
use crate::params::{bad, vec3};
use crate::schema::timeline_clipboard::{PasteResult, Pasted};
use crate::{Result, Sel, Session};
const CMD: &str = "timeline.paste";
@@ -186,15 +187,12 @@ pub(super) fn paste(s: &mut Session, p: &Value) -> Result<Value> {
}
};
let path = match p.get("component_path") {
Some(v) => v
.as_array()
.filter(|a| a.len() <= 64)
.and_then(|a| a.iter().map(Value::as_u64).collect::<Option<Vec<_>>>())
.ok_or_else(|| bad(CMD, "`component_path` must list 0..64 occurrence ids"))?,
Some(v) => super::clipboard::component_path_form(v).ok_or_else(|| bad(CMD, "`component_path` must list 0..64 occurrence ids"))?,
None if component == s.active_component => s.active_occurrence_path.clone().unwrap_or_else(|| s.doc.first_path(component)),
None => super::component::placing::placement_of(s, &s.placed_document(), component, None, "component_path", CMD)?.0,
None => super::component::placing::placement_of(s, &s.design_document(), component, None, "component_path", CMD)?.0,
};
let placed = s.placed_document();
// The target as the design places it, without an exploded view shown.
let placed = s.design_document();
let frame = placed.path_transform(component, &path).ok_or_else(|| bad(CMD, "that path is not a placement of the target"))?;
let into_target = solidcraft_doc::mat_inverse(&frame).ok_or_else(|| bad(CMD, "the target's frame is not invertible"))?;
drop(placed);
@@ -248,7 +246,7 @@ pub(super) fn paste(s: &mut Session, p: &Value) -> Result<Value> {
}
ids.insert(feature.id, id);
names.insert(feature.name.clone(), name.clone());
made.push(json!({"id": id, "name": name, "from": feature.name}));
made.push(Pasted { id, name: name.clone(), from: feature.name.clone() });
}
parameters(scratch.doc_mut(), &copy.doc, &copy.features, &mut mapped)?;
let source_values = copy.doc.param_values().0;
@@ -306,5 +304,6 @@ pub(super) fn paste(s: &mut Session, p: &Value) -> Result<Value> {
}
scratch.selection = ids.values().map(|id| Sel::Feature { id: *id }).collect();
s.accept_computed(scratch);
Ok(json!({"pasted": made, "errors": [], "notices": []}))
serde_json::to_value(PasteResult { pasted: made, errors: Vec::new(), notices: Vec::new(), param_warnings: Vec::new() })
.map_err(|e| bad(CMD, e.to_string()))
}
+108 -34
View File
@@ -13,12 +13,23 @@ use solidcraft_doc::{Feature, FeatureKind, Mat, Occurrence};
use super::CommandSpec;
use crate::params::{bad, bool_, str_, string_list, vec3};
use crate::schema::component::{
EditInPlaceParams, EditInPlaceResult, FinishEditParams, FinishEditResult, MoveFeaturesParams, MoveFeaturesResult, MoveSketchesToPartsParams,
MoveSketchesToPartsResult, OccurrenceAlignParams, OccurrenceAlignResult, ReparentItemsParams, ReparentItemsResult,
ActivateParams, ActivateResult, ChildEntry, CompactPlacement, ComponentNode, CreateParams, CreateResult, DeleteParams, DeleteResult,
EditInPlaceParams, EditInPlaceResult, FinishEditParams, FinishEditResult, FromBodiesParams, FromBodiesResult, ListParams, ListResult,
MoveFeaturesParams, MoveFeaturesResult, MoveSketchesToPartsParams, MoveSketchesToPartsResult, NamesOrCount, OccurrenceAlignParams,
OccurrenceAlignResult, PlacementEntry, PlacementInfo, PropertiesParams, PropertiesResult, RenameParams, RenameResult, ReparentItemsParams,
ReparentItemsResult, UnlockParams, UnlockResult,
};
use crate::schema::{TypedContract, output_schema_for};
use crate::{Result, Session};
const ACTIVATE_CONTRACT: TypedContract<ActivateParams> = TypedContract::new();
const CREATE_CONTRACT: TypedContract<CreateParams> = TypedContract::new();
const DELETE_CONTRACT: TypedContract<DeleteParams> = TypedContract::new();
const FROM_BODIES_CONTRACT: TypedContract<FromBodiesParams> = TypedContract::new();
const LIST_CONTRACT: TypedContract<ListParams> = TypedContract::new();
const PROPERTIES_CONTRACT: TypedContract<PropertiesParams> = TypedContract::new();
const RENAME_CONTRACT: TypedContract<RenameParams> = TypedContract::new();
const UNLOCK_CONTRACT: TypedContract<UnlockParams> = TypedContract::new();
const EDIT_IN_PLACE_CONTRACT: TypedContract<EditInPlaceParams> = TypedContract::new();
const FINISH_EDIT_CONTRACT: TypedContract<FinishEditParams> = TypedContract::new();
const MOVE_FEATURES_CONTRACT: TypedContract<MoveFeaturesParams> = TypedContract::new();
@@ -67,14 +78,23 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("component.show_history", "Show Component History", show_history)
.icon("eye").doc_only()
.params("component: id or name; puts a component's features back in the History bar after Hide Component History (#10393)"),
CommandSpec::new("component.create", "New Component", new_component)
CommandSpec::new("component.create", "New Component", crate::checked!("component.create", new_component))
.at("SOLID", "ASSEMBLE")
.icon("component")
.contract(&CREATE_CONTRACT)
.output(output_schema_for::<CreateResult>)
.params("name?, parent?: component id or name (default: the active one); parent_path?: exact parent placement; activate?: bool (default true); grounded?: bool (default: true for the design's first part, false otherwise)"),
CommandSpec::new("component.from_bodies", "Create Components from Bodies", from_bodies)
CommandSpec::new("component.from_bodies", "Create Components from Bodies", crate::checked!("component.from_bodies", from_bodies))
.icon("component")
.contract(&FROM_BODIES_CONTRACT)
.output(output_schema_for::<FromBodiesResult>)
.params("bodies: [names] → one new component per body, named after it"),
CommandSpec::new("component.activate", "Activate Component", activate).icon("activate").noundo().params("component: id or name (0 or \"root\" for the design); occurrence?, path?: exact placement used to author shared geometry"),
CommandSpec::new("component.activate", "Activate Component", crate::checked!("component.activate", activate))
.icon("activate")
.noundo()
.contract(&ACTIVATE_CONTRACT)
.output(output_schema_for::<ActivateResult>)
.params("component: id or name (0 or \"root\" for the design); occurrence?, path?: exact placement used to author shared geometry"),
CommandSpec::new("component.edit_in_place", "Edit in Place", crate::checked!("component.edit_in_place", edit_in_place))
.icon("edit")
.noundo()
@@ -99,22 +119,41 @@ pub static COMMANDS: &[CommandSpec] = &[
fails while the design has unsaved changes (run file.save first) or something is open in it; asked during the agent's \
turn it finishes once the turn ends (queued: true); elsewhere nothing changes",
),
CommandSpec::new("component.list", "List Components", list).icon("list").noundo().params(
"compact?: bool (counts of bodies, features, sketches and construction planes instead of their names; occurrences leave out \
identity transforms; children as occurrence ids); bodies?: bool (with compact: body names, not their count)",
),
CommandSpec::new("component.properties", "Component Physical Properties", properties::component)
CommandSpec::new("component.list", "List Components", crate::checked!("component.list", list))
.icon("list")
.noundo()
.contract(&LIST_CONTRACT)
.output(output_schema_for::<ListResult>)
.params(
"compact?: bool (counts of bodies, features, sketches and construction planes instead of their names; occurrences leave out \
identity transforms; children as occurrence ids); bodies?: bool (with compact: body names, not their count)",
),
CommandSpec::new("component.properties", "Component Physical Properties", crate::checked!("component.properties", properties::component))
.icon("measure").noundo()
.contract(&PROPERTIES_CONTRACT)
.output(output_schema_for::<PropertiesResult>)
.params("component?: id or name (default active); descendants?: bool (default true); about?: [x, y, z] (mm). Measures the first placement, or all placements for root, including pending moves; material-aware mass and center of mass in world coordinates; volume, density, mass, center of mass and inertia_g_mm2 (the inertia tensor about the body's center of mass, world axes, from its tessellation) only for closed, consistently oriented bodies; total.open_bodies_skipped counts open bodies excluded from volume/mass totals; total.first_moment_g_mm (mass × center of mass), inertia_g_mm2 about the total center of mass, principal_moments_g_mm2 (smallest first) and principal_axes (unit, right-handed); with about, total.inertia_about {point, inertia_g_mm2}"),
CommandSpec::new("occurrence.properties", "Occurrence Physical Properties", properties::occurrence)
.icon("measure").noundo()
.params("occurrence: id or name; path?: [occurrence ids] (required if nested under multiple instances); descendants?: bool (default true); about?: [x, y, z] (mm). Physical properties of this placement in world coordinates, including pending moves; volume, density, mass, center of mass and inertia_g_mm2 only for closed, consistently oriented bodies; total.open_bodies_skipped counts open bodies excluded from volume/mass totals; total inertia as in component.properties"),
CommandSpec::new("component.rename", "Rename Component", rename).icon("rename").params("component, name"),
CommandSpec::new("component.delete", "Delete Component", delete).icon("delete").params("component: deletes it, its subcomponents, occurrences and features"),
CommandSpec::new("component.unlock", "Unlock Contents", unlock).icon("rigid_group").params(
"component: removes the rigid joints that hold its contents together (an imported assembly's rigid group), so its parts \
move and take joints one by one → joints (the ids removed)",
),
CommandSpec::new("component.rename", "Rename Component", crate::checked!("component.rename", rename))
.icon("rename")
.contract(&RENAME_CONTRACT)
.output(output_schema_for::<RenameResult>)
.params("component, name"),
CommandSpec::new("component.delete", "Delete Component", crate::checked!("component.delete", delete))
.icon("delete")
.contract(&DELETE_CONTRACT)
.output(output_schema_for::<DeleteResult>)
.params("component: deletes it, its subcomponents, occurrences and features"),
CommandSpec::new("component.unlock", "Unlock Contents", crate::checked!("component.unlock", unlock))
.icon("rigid_group")
.contract(&UNLOCK_CONTRACT)
.output(output_schema_for::<UnlockResult>)
.params(
"component: removes the rigid joints that hold its contents together (an imported assembly's rigid group), so its parts \
move and take joints one by one → joints (the ids removed)",
),
CommandSpec::new("component.move_bodies", "Move Bodies to Component", move_bodies).icon("move").params(
"bodies: [names], component; component_path?: its placement to place them by (default: its only one, else the active \
one; a component placed more than once gets them in every instance); from_path?: the placement of their component they \
@@ -203,6 +242,11 @@ pub static COMMANDS: &[CommandSpec] = &[
const ALIGN_CONTRACT: TypedContract<OccurrenceAlignParams> = TypedContract::new();
/// A typed reply as the JSON a handler returns.
pub(super) fn reply<T: serde::Serialize>(value: T) -> Result<Value> {
serde_json::to_value(value).map_err(|e| crate::EngineError::Other(e.to_string()))
}
fn key(v: Option<&Value>) -> Option<String> {
match v {
Some(Value::Number(n)) => Some(n.to_string()),
@@ -288,7 +332,14 @@ pub(super) fn new_component(s: &mut Session, p: &Value) -> Result<Value> {
}
let name = s.doc.components.iter().find(|c| c.id == id).map(|c| c.name.clone()).unwrap_or_default();
let occ = s.doc.occurrence_of(id);
Ok(json!({"component": id, "name": name, "parent": parent, "occurrence": occ.map(|o| o.id), "grounded": occ.is_some_and(|o| o.grounded)}))
reply(CreateResult {
component: id,
name,
parent,
occurrence: occ.map(|o| o.id),
grounded: occ.is_some_and(|o| o.grounded),
param_warnings: Vec::new(),
})
}
fn from_bodies(s: &mut Session, p: &Value) -> Result<Value> {
@@ -334,7 +385,7 @@ fn activate(s: &mut Session, p: &Value) -> Result<Value> {
s.active_component = id;
s.active_occurrence_path = path;
s.revision += 1;
Ok(json!({ "active": id }))
reply(ActivateResult { active: id, param_warnings: Vec::new() })
}
fn list(s: &mut Session, p: &Value) -> Result<Value> {
@@ -342,17 +393,20 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
let doc = &s.doc;
// Compact: what a large assembly needs to be found around in, without every name and matrix.
let compact = bool_(p, "compact").unwrap_or(false);
let names = |v: Vec<&str>| if compact { json!(v.len()) } else { json!(v) };
let names = |v: Vec<&str>| if compact { NamesOrCount::Count(v.len()) } else { NamesOrCount::Names(v.into_iter().map(str::to_string).collect()) };
let body_names = !compact || bool_(p, "bodies").unwrap_or(false);
let placement = |o: &Occurrence| {
if !compact {
return json!(o);
return PlacementEntry::Full(PlacementInfo::from(o));
}
let mut v = json!({"id": o.id, "name": o.name, "parent": o.parent, "grounded": o.grounded});
if o.transform.iter().flatten().zip(solidcraft_doc::IDENTITY.iter().flatten()).any(|(a, b)| (a - b).abs() > 1e-12) {
v["transform"] = json!(o.transform);
}
v
let moved = o.transform.iter().flatten().zip(solidcraft_doc::IDENTITY.iter().flatten()).any(|(a, b)| (a - b).abs() > 1e-12);
PlacementEntry::Compact(CompactPlacement {
id: o.id,
name: o.name.clone(),
parent: o.parent,
grounded: o.grounded,
transform: moved.then_some(o.transform),
})
};
// Each component's bodies, features, occurrences and children, grouped once rather than
// searched for every component (thousands of each in a design with many components).
@@ -376,17 +430,37 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
let sketches = named(|k| matches!(k, FeatureKind::Sketch { .. }));
let construction = named(|k| matches!(k, FeatureKind::ConstructionPlane { .. }));
let features = named(|k| !matches!(k, FeatureKind::Sketch { .. } | FeatureKind::ConstructionPlane { .. }));
let occurrences: Vec<Value> = occurrences_of.get(&id).into_iter().flatten().map(|o| placement(o)).collect();
let children: Vec<Value> = children_of.get(&id).into_iter().flatten().map(|o| if compact { json!(o.id) } else { json!(o) }).collect();
let bodies = if body_names { json!(bodies) } else { json!(bodies.len()) };
json!({"id": id, "name": name, "parent": parent, "bodies": bodies, "features": names(features), "sketches": names(sketches),
"construction": names(construction), "occurrences": occurrences, "children": children})
let occurrences: Vec<PlacementEntry> = occurrences_of.get(&id).into_iter().flatten().map(|o| placement(o)).collect();
let children: Vec<ChildEntry> = children_of
.get(&id)
.into_iter()
.flatten()
.map(|o| if compact { ChildEntry::Id(o.id) } else { ChildEntry::Full(PlacementInfo::from(*o)) })
.collect();
let bodies =
if body_names { NamesOrCount::Names(bodies.into_iter().map(str::to_string).collect()) } else { NamesOrCount::Count(bodies.len()) };
ComponentNode {
id,
name: name.to_string(),
parent,
bodies,
features: names(features),
sketches: names(sketches),
construction: names(construction),
occurrences,
children,
}
};
let mut out = vec![node(0, &doc.name, None)];
for c in &doc.components {
out.push(node(c.id, &c.name, Some(c.parent)));
}
Ok(json!({"active": s.active_component, "components": out, "pending_moves": s.pending_moves.keys().collect::<Vec<_>>()}))
reply(ListResult {
active: s.active_component,
components: out,
pending_moves: s.pending_moves.keys().copied().collect(),
param_warnings: Vec::new(),
})
}
fn rename(s: &mut Session, p: &Value) -> Result<Value> {
@@ -411,7 +485,7 @@ fn rename(s: &mut Session, p: &Value) -> Result<Value> {
// A sketch projecting a body of one of those placements keeps it.
super::sketch_project::follow_placed_renames(d, &st, &placed, None);
}
Ok(json!({"component": id, "name": name}))
reply(RenameResult { component: id, name: name.to_string(), param_warnings: Vec::new() })
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
@@ -428,7 +502,7 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
if gone.contains(&s.active_component) {
s.active_component = 0;
}
Ok(json!({"deleted": gone, "features": removed.features}))
reply(DeleteResult { deleted: gone, features: removed.features, param_warnings: Vec::new() })
}
fn unlock(s: &mut Session, p: &Value) -> Result<Value> {
@@ -448,7 +522,7 @@ fn unlock(s: &mut Session, p: &Value) -> Result<Value> {
}
doc.assembly.links.retain(|l| !gone.contains(&l.a) && !gone.contains(&l.b));
s.visibility.hidden_joints.retain(|h| !gone.contains(h));
Ok(json!({"component": id, "joints": gone}))
reply(UnlockResult { component: id, joints: gone, param_warnings: Vec::new() })
}
fn move_bodies(s: &mut Session, p: &Value) -> Result<Value> {
+3 -2
View File
@@ -25,9 +25,10 @@ pub(super) fn paste_from_design(s: &mut Session, p: &Value) -> Result<Value> {
let path = match given {
Some(path) => path,
None if to == s.active_component => s.active_occurrence_path.clone().unwrap_or_else(|| s.doc.first_path(to)),
None => placing::placement_of(s, &s.placed_document(), to, None, "component_path", cmd)?.0,
None => placing::placement_of(s, &s.design_document(), to, None, "component_path", cmd)?.0,
};
let frame = s.placed_document().path_transform(to, &path).ok_or_else(|| bad(cmd, "that path is not a placement of the target"))?;
// The target as the design places it, without an exploded view shown.
let frame = s.design_document().path_transform(to, &path).ok_or_else(|| bad(cmd, "that path is not a placement of the target"))?;
let into_target = solidcraft_doc::mat_inverse(&frame).ok_or_else(|| bad(cmd, "the target's frame is not invertible"))?;
if copy.bodies.is_empty() {
return paste_components(s, &copy, to, path, into_target, p);
+50 -22
View File
@@ -9,6 +9,7 @@ use solidcraft_geom::Aabb3;
use solidcraft_geom::inertia::{self, Tensor3};
use crate::params::bad;
use crate::schema::component::{BodyProperties, BoundingBox, InertiaAbout, PhysicalProperties, PropertiesResult, TotalProperties};
use crate::{Result, Session};
fn descendants(p: &Value, cmd: &str) -> Result<bool> {
@@ -84,10 +85,8 @@ pub(super) fn component(s: &mut Session, p: &Value) -> Result<Value> {
} else {
doc.components.iter().find(|c| c.id == id).map(|c| c.name.clone()).ok_or_else(|| bad(cmd, "no active component"))?
};
let mut out = properties(s, &doc, &path, descendants(p, cmd)?, about(p, cmd)?, cmd)?;
out["component"] = json!(id);
out["name"] = json!(name);
Ok(out)
let properties = measure(s, &doc, &path, descendants(p, cmd)?, about(p, cmd)?, cmd)?;
super::reply(PropertiesResult { component: id, name, properties, param_warnings: Vec::new() })
}
pub(super) fn occurrence(s: &mut Session, p: &Value) -> Result<Value> {
@@ -200,7 +199,12 @@ fn measure_placed(body: &solidcraft_doc::ModelBody, transform: Mat) -> Result<Pl
})
}
/// [`measure`] as the JSON `occurrence.properties` adds its own keys to.
fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool, about: Option<Vec3>, cmd: &str) -> Result<Value> {
super::reply(measure(s, doc, selected, descendants, about, cmd)?)
}
fn measure(s: &Session, doc: &Document, selected: &[u64], descendants: bool, about: Option<Vec3>, cmd: &str) -> Result<PhysicalProperties> {
let state = s.model.state();
let mut bodies = Vec::new();
let (mut volume, mut area, mut mass) = (0.0, 0.0, 0.0);
@@ -292,13 +296,20 @@ fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool,
faces += measured.faces;
edges += measured.edges;
vertices += measured.vertices;
bodies.push(json!({"name": body.name, "component": component, "path": path,
"material": doc.body_material(&body.name),
"closed": closed, "density_g_cm3": closed.then_some(density),
"mass_g": closed.then_some(body_mass), "volume_mm3": closed.then_some(measured.volume),
"area_mm2": measured.area, "center_of_mass": closed.then_some(measured.centroid),
"inertia_g_mm2": body_inertia,
"bbox": {"min": measured.bbox.min, "max": measured.bbox.max}}));
bodies.push(BodyProperties {
name: body.name.to_string(),
component,
path: path.clone(),
material: doc.body_material(&body.name),
closed,
density_g_cm3: closed.then_some(density),
mass_g: closed.then_some(body_mass),
volume_mm3: closed.then_some(measured.volume),
area_mm2: measured.area,
center_of_mass: closed.then_some(measured.centroid),
inertia_g_mm2: body_inertia,
bbox: BoundingBox { min: measured.bbox.min, max: measured.bbox.max },
});
}
}
if let Some(w) = &watch {
@@ -321,19 +332,36 @@ fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool,
});
let at_centre = at_centre.filter(|i| i.iter().flatten().all(|x| x.is_finite()));
let principal = at_centre.and_then(|i| inertia::principal(&i, EQUAL_MOMENTS));
let bbox = if bodies.is_empty() { Value::Null } else { json!({"min": bounds.min, "max": bounds.max}) };
let mut out = json!({"path": selected, "coordinate_frame": "world", "descendants": descendants,
"placement_count": placement_count, "body_count": bodies.len(), "bodies": bodies,
"total": {"open_bodies_skipped": skipped, "volume_mm3": volume, "area_mm2": area, "mass_g": mass,
"center_of_mass": centroid, "first_moment_g_mm": moment, "inertia_g_mm2": at_centre,
"principal_moments_g_mm2": principal.map(|(moments, _)| moments), "principal_axes": principal.map(|(_, axes)| axes),
"bbox": bbox, "faces": faces, "edges": edges, "vertices": vertices}});
if let Some(point) = about {
let bbox = (!bodies.is_empty()).then_some(BoundingBox { min: bounds.min, max: bounds.max });
let inertia_about = about.map(|point| {
let shifted =
centroid.zip(at_centre).map(|(c, i)| inertia::shifted(&i, mass, point - c)).filter(|i| i.iter().flatten().all(|x| x.is_finite()));
out["total"]["inertia_about"] = json!({"point": point, "inertia_g_mm2": shifted});
}
Ok(out)
InertiaAbout { point, inertia_g_mm2: shifted }
});
Ok(PhysicalProperties {
path: selected.to_vec(),
coordinate_frame: "world".to_string(),
descendants,
placement_count,
body_count: bodies.len(),
bodies,
total: TotalProperties {
open_bodies_skipped: skipped,
volume_mm3: volume,
area_mm2: area,
mass_g: mass,
center_of_mass: centroid,
first_moment_g_mm: moment,
inertia_g_mm2: at_centre,
principal_moments_g_mm2: principal.map(|(moments, _)| moments),
principal_axes: principal.map(|(_, axes)| axes),
bbox,
faces,
edges,
vertices,
inertia_about,
},
})
}
/// The tessellating work a properties command did on this thread, for tests: placed copies
@@ -36,7 +36,9 @@ pub(super) fn reauthor(s: &mut Session, id: u64, to: u64, cmd: &str) -> Result<(
if from == to {
return Ok(());
}
let doc = s.placed_document();
// Both placements as the design has them: an exploded view shown would move the sketch by
// the difference of its offsets.
let doc = s.design_document();
let path = if to == anchor.component {
anchor.path.clone()
} else if to == 0 {
+3 -2
View File
@@ -1,5 +1,6 @@
//! Atomic timeline ownership changes, preserving the selected world geometry.
use super::*;
use crate::schema::component::{BodyComponent, FromBodiesResult};
use solidcraft_doc::BodyName;
use std::collections::{BTreeMap, BTreeSet};
@@ -128,10 +129,10 @@ pub(super) fn from_bodies(s: &mut Session, p: &Value) -> Result<Value> {
for (name, component) in bodies.into_iter().zip(targets) {
let feature = transfer(&mut scratch, &name, component, &frames, cmd)?;
features.push(feature);
made.push(json!({"component":component,"body":name,"feature":feature}));
made.push(BodyComponent { component, body: name, feature });
}
finish(s, &mut scratch, &features, cmd)?;
Ok(json!({"components":made}))
super::reply(FromBodiesResult { components: made, param_warnings: Vec::new() })
}
/// A placement path parameter (`component_path`: the target's placement to place by,
+19 -3
View File
@@ -136,10 +136,12 @@ fn column_of(s: &Session, p: &Value, cmd: &str) -> Result<Column> {
for (key, appearance) in [("material", false), ("appearance", true)] {
if let Some(body) = str_(p, key) {
let c = if appearance { Column::Appearance(body.to_string()) } else { Column::Material(body.to_string()) };
if !s.doc.configs.columns.contains(&c) && s.model.state().body(body).is_none() {
return Err(bad(cmd, format!("no body `{body}`")));
if s.doc.configs.columns.contains(&c) {
return Ok(c);
}
return Ok(c);
// A placed copy's name means the body it shows (#9740); the column is keyed by that body's own name.
let body = s.own_body_name(body).ok_or_else(|| bad(cmd, format!("no body `{body}`")))?;
return Ok(if appearance { Column::Appearance(body) } else { Column::Material(body) });
}
}
if let Some(n) = str_(p, "param") {
@@ -538,6 +540,20 @@ mod tests {
use crate::Session;
/// A placed copy's body name (`KB (K:2)`, as inspect.measure lists it) means the body it
/// shows: the column is keyed by that body's own name, for every placement.
#[test]
fn a_column_by_a_placed_copys_name_is_keyed_by_the_body_it_shows() {
let mut s = Session::default();
run(&mut s, "component.create", json!({"name": "K"}));
run(&mut s, "solid.box", json!({"body_name": "KB", "length": 10, "width": 10, "height": 10}));
run(&mut s, "occurrence.copy", json!({"component": "K", "translate": [30, 0, 0]}));
run(&mut s, "config.column", json!({"material": "KB (K:2)"}));
assert!(s.doc.configs.columns.contains(&super::Column::Material("KB".into())), "{:?}", s.doc.configs.columns);
let err = s.execute("config.column", &json!({"material": "KB (K:9)"})).unwrap_err().to_string();
assert!(err.contains("no body `KB (K:9)`"), "{err}");
}
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
+14 -5
View File
@@ -10,11 +10,19 @@ use solidcraft_doc::FeatureKind;
use super::{CommandSpec, sketch_store};
use crate::params::{bad, bool_};
use crate::schema::delete::{SelectionDeleteParams, SelectionDeleteResult};
use crate::schema::{TypedContract, output_schema_for};
use crate::{EngineError, Result, Sel, Session};
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("selection.delete", "Delete", delete).icon("delete").params(
"items?: [selection items]; components?, occurrences?, canvases?, point_clouds?, joints?: [ids or names]; dry_run?: bool → deleted, deleted_joints, would_fail, skipped (one undo step)",
)];
const DELETE_CONTRACT: TypedContract<SelectionDeleteParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("selection.delete", "Delete", crate::checked!("selection.delete", delete))
.icon("delete")
.contract(&DELETE_CONTRACT)
.output(output_schema_for::<SelectionDeleteResult>)
.params(
"items?: [selection items]; components?, occurrences?, canvases?, point_clouds?, joints?: [ids or names]; dry_run?: bool → deleted, deleted_joints, would_fail, skipped (one undo step)",
)];
/// Run another command's body inside this one (so the whole delete is one undo step).
fn sub(s: &mut Session, id: &str, p: Value) -> Result<Value> {
@@ -227,11 +235,12 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, skipped.first().cloned().unwrap_or_else(|| "nothing to delete".into())));
}
s.refresh();
let deleted: Vec<&String> = features_before.iter().filter(|(id, _)| s.doc.feature(*id).is_none()).map(|(_, n)| n).collect();
let deleted: Vec<String> = features_before.iter().filter(|(id, _)| s.doc.feature(*id).is_none()).map(|(_, n)| n.clone()).collect();
// Features after a rolled-back marker count too (#3471).
let would_fail: Vec<String> =
super::edit::newly_broken_in_history(&before_doc, &before, &s.doc, &s.model).into_iter().map(|(name, _)| name).collect();
Ok(json!({ "deleted": deleted, "deleted_joints": joints, "bodies": bodies, "would_fail": would_fail, "skipped": skipped }))
let reply = SelectionDeleteResult { deleted, deleted_joints: joints, bodies, would_fail, skipped, param_warnings: Vec::new() };
serde_json::to_value(reply).map_err(|e| bad(cmd, e.to_string()))
}
#[cfg(test)]
+110 -43
View File
@@ -8,6 +8,13 @@ use solidcraft_doc::{Feature, FeatureKind};
use super::CommandSpec;
use crate::params::{bad, bool_, name_or_id_values, num, str_};
use crate::schema::TypedContract;
use crate::schema::edit::{
FailingFeature, FeatureInfo, FeatureInput, Inspected, OmittedField, SuppressChanged, TimelineComputeAllParams, TimelineComputeAllResult,
TimelineDeleteParams, TimelineDeleteResult, TimelineDependentsParams, TimelineDependentsResult, TimelineEditParams, TimelineEditResult,
TimelineInspectParams, TimelineInspectResult, TimelineMarkerResult, TimelineRenameParams, TimelineRenameResult, TimelineRollToParams,
TimelineRollbackParams, TimelineSuppressParams, TimelineSuppressResult,
};
use crate::{EngineError, Result, Sel, Session, Snapshot};
pub static COMMANDS: &[CommandSpec] = &[
@@ -53,7 +60,12 @@ pub static COMMANDS: &[CommandSpec] = &[
.contract(&RESTORE_CHECKPOINT_CONTRACT)
.output(crate::schema::output_schema_for::<crate::schema::edit::CheckpointResult>)
.params("checkpoint: id; forget?: bool (true = drop it without going back). Goes back to it, undo history included. Restoring or forgetting uses it and every later checkpoint up: take a new one to go back again"),
CommandSpec::new("timeline.compute_all", "Compute All", compute_all).at("SOLID", "MODIFY").icon("compute").noundo(),
CommandSpec::new("timeline.compute_all", "Compute All", crate::checked!("timeline.compute_all", compute_all))
.at("SOLID", "MODIFY")
.icon("compute")
.noundo()
.contract(&COMPUTE_ALL_CONTRACT)
.output(crate::schema::output_schema_for::<TimelineComputeAllResult>),
CommandSpec::new("timeline.play", "Play History", crate::checked!("timeline.play", play))
.at("INSPECT", "HISTORY")
.icon("timeline_play")
@@ -63,12 +75,18 @@ pub static COMMANDS: &[CommandSpec] = &[
.contract(&PLAY_CONTRACT)
.output(crate::schema::output_schema_for::<crate::schema::edit::TimelinePlayResult>)
.params("action?: toggle|play|stop (default toggle) → action: validated display request; the connected UI plays from the marker to the end with a half-second pause per rebuilt step. Headless callers only validate the request; no dialog, document edit or animation clock is created"),
CommandSpec::new("timeline.delete", "Delete", delete)
CommandSpec::new("timeline.delete", "Delete", crate::checked!("timeline.delete", delete))
.at("SOLID", "MODIFY")
.icon("delete")
.key("Delete")
.contract(&DELETE_CONTRACT)
.output(crate::schema::output_schema_for::<TimelineDeleteResult>)
.params("feature: id|name | features: [id|name] | groups: [timeline folder ids] (a folder goes with all its features, joints and nested folders; one undo step; a sketch takes its dependent features with it) → deleted: [feature ids]; joints?: [joint ids]; groups?: [folder ids deleted]; failing?: [{feature, error}] (later features that worked and now fail)"),
CommandSpec::new("timeline.rename", "Rename", rename).icon("rename").params("feature: id|name, name (references to it follow)"),
CommandSpec::new("timeline.rename", "Rename", crate::checked!("timeline.rename", rename))
.icon("rename")
.contract(&RENAME_CONTRACT)
.output(crate::schema::output_schema_for::<TimelineRenameResult>)
.params("feature: id|name, name (references to it follow)"),
CommandSpec::new("timeline.group", "New Feature Folder", group)
.icon("folder")
.doc_only()
@@ -83,8 +101,15 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("timeline.move_many", "Move Timeline Items", move_many).icon("reorder").params(
"features?: [id|name], groups?: [folder ids], group?: folder id, position: insertion slot, active?: bool (true = the moved contents end up before the history marker, false = behind it; other features keep their state); atomically changes folder membership and moves all selected contents as one block",
),
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", roll_to).icon("rollback")
CommandSpec::new("timeline.rollback", "Move Timeline Marker", crate::checked!("timeline.rollback", rollback))
.icon("rollback")
.contract(&ROLLBACK_CONTRACT)
.output(crate::schema::output_schema_for::<TimelineMarkerResult>)
.params("position: number of features to keep active, or \"end\" (omit = end)"),
CommandSpec::new("timeline.roll_to", "Roll History Marker", crate::checked!("timeline.roll_to", roll_to))
.icon("rollback")
.contract(&ROLL_TO_CONTRACT)
.output(crate::schema::output_schema_for::<TimelineMarkerResult>)
.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, 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, unless allow_errors?: bool (default false): then made, and broken: [{feature, error}] lists the features it broke → feature, position: its index now, from: its index before, moved: bool, note?: why nothing moved, or that a position past the end meant the last slot"),
@@ -93,21 +118,37 @@ pub static COMMANDS: &[CommandSpec] = &[
),
CommandSpec::new("timeline.redefine", "Edit Feature", redefine).icon("edit")
.params("feature: id|name, command: the feature's command id, params: its parameters (the feature is rebuilt in place) → feature, recomputed, restored, rolled_back: bool (the feature is after the history marker, so the edit is not built yet; `note` then says so), warning?"),
CommandSpec::new("timeline.dependents", "Feature Dependents", dependents).icon("graph")
CommandSpec::new("timeline.dependents", "Feature Dependents", crate::checked!("timeline.dependents", dependents))
.icon("graph")
.noundo()
.contract(&DEPENDENTS_CONTRACT)
.output(crate::schema::output_schema_for::<TimelineDependentsResult>)
.params("feature: id|name → features deleted with it, and features that would fail without it"),
CommandSpec::new("timeline.suppress", "Suppress Feature", suppress).icon("suppress").params(
CommandSpec::new("timeline.suppress", "Suppress Feature", crate::checked!("timeline.suppress", suppress))
.icon("suppress")
.contract(&SUPPRESS_CONTRACT)
.output(crate::schema::output_schema_for::<TimelineSuppressResult>)
.params(
"feature: id|name | features: [id|name] | groups: [timeline folder ids] (every feature and joint in those folders and their nested \
folders; one undo step), suppressed?: bool (default toggles: features and joints are all \
suppressed unless all of them already are, then all are unsuppressed) → feature: id (or features: [ids]; groups: [ids] and joints: [ids] when groups are given), suppressed",
),
CommandSpec::new("timeline.edit", "Edit Feature", edit_feature).icon("edit").params(
CommandSpec::new("timeline.edit", "Edit Feature", crate::checked!("timeline.edit", edit_feature))
.icon("edit")
.contract(&EDIT_CONTRACT)
.output(crate::schema::output_schema_for::<TimelineEditResult>)
.params(
"feature: id|name, set: {fields to change, merged into the feature's definition as timeline.inspect returns it, \
e.g. {\"extent\": {\"distance\": \"30\"}} (extrude), {\"angle\": \"90 deg\"} (revolve); \
a dotted key names a nested field ({\"plane.distance\": \"8 mm\"} = {\"plane\": {\"distance\": \"8 mm\"}}); \
expressions are text, or a number in mm or degrees} → recomputed: features rebuilt, restored: features whose earlier result for the same inputs was reused",
),
CommandSpec::new("timeline.inspect", "Inspect Feature", inspect_feature).icon("inspect").noundo().params(
CommandSpec::new("timeline.inspect", "Inspect Feature", crate::checked!("timeline.inspect", inspect_feature))
.icon("inspect")
.noundo()
.contract(&INSPECT_CONTRACT)
.output(crate::schema::output_schema_for::<TimelineInspectResult>)
.params(
"group: folder id → group, name, parent, features, joints; or feature: id|name → type, definition (the fields timeline.edit's set changes, with their current values), \
inputs: [{label, param, expression, unit}] (named inputs parameters.change can also set), error, warning",
),
@@ -121,6 +162,15 @@ pub static COMMANDS: &[CommandSpec] = &[
.params("body?: world body name, face?: face index; defaults to the current body/face selection → selects objects using the resolved appearance"),
];
const COMPUTE_ALL_CONTRACT: TypedContract<TimelineComputeAllParams> = TypedContract::new();
const DELETE_CONTRACT: TypedContract<TimelineDeleteParams> = TypedContract::new();
const RENAME_CONTRACT: TypedContract<TimelineRenameParams> = TypedContract::new();
const ROLLBACK_CONTRACT: TypedContract<TimelineRollbackParams> = TypedContract::new();
const ROLL_TO_CONTRACT: TypedContract<TimelineRollToParams> = TypedContract::new();
const DEPENDENTS_CONTRACT: TypedContract<TimelineDependentsParams> = TypedContract::new();
const SUPPRESS_CONTRACT: TypedContract<TimelineSuppressParams> = TypedContract::new();
const EDIT_CONTRACT: TypedContract<TimelineEditParams> = TypedContract::new();
const INSPECT_CONTRACT: TypedContract<TimelineInspectParams> = TypedContract::new();
const PLAY_CONTRACT: crate::schema::TypedContract<crate::schema::edit::TimelinePlayParams> = crate::schema::TypedContract::new();
fn play(_s: &mut Session, p: &Value) -> Result<Value> {
@@ -222,7 +272,7 @@ fn compute_all(s: &mut Session, _p: &Value) -> Result<Value> {
s.set_validation(validation);
s.refresh();
let errors = s.model.results.iter().filter(|r| r.error.is_some()).count();
Ok(json!({"features": s.model.results.len(), "errors": errors}))
reply("timeline.compute_all", TimelineComputeAllResult { features: s.model.results.len(), errors, param_warnings: Vec::new() })
}
pub(super) fn feature_id(s: &Session, v: Option<&Value>, cmd: &str) -> Result<u64> {
@@ -269,24 +319,21 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
}
s.selection.clear();
s.refresh();
let failing: Vec<Value> = s
let failing: Vec<FailingFeature> = s
.model
.results
.iter()
.filter(|r| worked.contains(&r.id))
.filter_map(|r| r.error.as_ref().map(|e| json!({"feature": r.name, "error": e})))
.filter_map(|r| r.error.as_ref().map(|e| FailingFeature { feature: r.name.clone(), error: e.clone() }))
.collect();
let mut out = json!({"deleted": gone});
if !joints.is_empty() {
out["joints"] = json!(joints);
}
if !folders.is_empty() {
out["groups"] = json!(folders);
}
if !failing.is_empty() {
out["failing"] = json!(failing);
}
Ok(out)
let out = TimelineDeleteResult {
deleted: gone,
joints: (!joints.is_empty()).then_some(joints),
groups: (!folders.is_empty()).then(|| folders.into_iter().collect()),
failing: (!failing.is_empty()).then_some(failing),
param_warnings: Vec::new(),
};
reply(cmd, out)
}
fn rename(s: &mut Session, p: &Value) -> Result<Value> {
@@ -302,7 +349,7 @@ fn rename(s: &mut Session, p: &Value) -> Result<Value> {
super::features::check_plane_name(name, cmd)?;
}
if name == old {
return Ok(json!({"feature": id, "name": name}));
return reply(cmd, TimelineRenameResult { feature: id, name: name.to_string(), param_warnings: Vec::new() });
}
let names = BTreeMap::from([(old.clone(), name.to_string())]);
let sketches = BTreeMap::new();
@@ -322,7 +369,7 @@ fn rename(s: &mut Session, p: &Value) -> Result<Value> {
*n = name.to_string();
}
}
Ok(json!({"feature": id, "name": name}))
reply(cmd, TimelineRenameResult { feature: id, name: name.to_string(), param_warnings: Vec::new() })
}
fn timeline_group_id(s: &Session, p: &Value, cmd: &str) -> Result<u64> {
@@ -756,7 +803,7 @@ fn move_marker(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
_ => None,
};
s.selection.clear();
Ok(json!({"marker": s.doc.marker.unwrap_or(n), "features": n}))
reply(cmd, TimelineMarkerResult { marker: s.doc.marker.unwrap_or(n), features: n, param_warnings: Vec::new() })
}
/// The features named by `feature: id|name` and/or `features: [id|name]` (or one id|name), in
@@ -838,11 +885,12 @@ fn suppress(s: &mut Session, p: &Value) -> Result<Value> {
f.suppressed = want;
}
super::joints::suppress_joints(s.doc_mut(), &joints, want);
match ids.as_slice() {
[id] if groups.is_empty() && p.get("features").is_none_or(Value::is_null) => Ok(json!({"feature": id, "suppressed": want})),
_ if groups.is_empty() => Ok(json!({"features": ids, "suppressed": want})),
_ => Ok(json!({"features": ids, "joints": joints, "groups": groups, "suppressed": want})),
}
let changed = match ids.as_slice() {
[id] if groups.is_empty() && p.get("features").is_none_or(Value::is_null) => SuppressChanged::One { feature: *id, suppressed: want },
_ if groups.is_empty() => SuppressChanged::Features { features: ids, suppressed: want },
_ => SuppressChanged::Folders { features: ids, joints, groups, suppressed: want },
};
reply(cmd, TimelineSuppressResult { changed, param_warnings: Vec::new() })
}
fn merge(dst: &mut Value, src: &Value, depth: usize) {
@@ -1204,7 +1252,14 @@ fn inspect_feature(s: &mut Session, p: &Value) -> Result<Value> {
if p.get("group").is_some() {
let id = timeline_group_id(s, p, cmd)?;
let group = s.doc.timeline_groups.iter().find(|group| group.id == id).ok_or_else(|| bad(cmd, "folder"))?;
return Ok(json!({"group": id, "name": group.name, "parent": group.parent, "features": group.features, "joints": group.joints}));
let inspected = Inspected::Group {
group: id,
name: group.name.clone(),
parent: group.parent,
features: group.features.clone(),
joints: group.joints.clone(),
};
return reply(cmd, TimelineInspectResult { inspected, param_warnings: Vec::new() });
}
let id = feature_id(s, p.get("feature"), cmd)?;
let f = s.doc.feature(id).ok_or_else(|| bad(cmd, "feature"))?;
@@ -1216,27 +1271,39 @@ fn inspect_feature(s: &mut Session, p: &Value) -> Result<Value> {
}
for (k, why) in heavy_fields(&f.kind) {
if o.remove(*k).is_some() {
omitted.push(json!({"field": k, "what": why}));
omitted.push(OmittedField { field: k.to_string(), what: why.to_string() });
}
}
}
let inputs: Vec<Value> = f
let inputs: Vec<FeatureInput> = f
.kind
.inputs()
.into_iter()
.enumerate()
.map(|(i, (label, e, k))| {
json!({"label": label, "param": f.param_names.get(i).filter(|n| !n.is_empty()), "expression": e, "unit": k.default_unit()})
.map(|(i, (label, e, k))| FeatureInput {
label: label.to_string(),
param: f.param_names.get(i).filter(|n| !n.is_empty()).cloned(),
expression: e,
unit: k.default_unit().to_string(),
})
.collect();
let marker = s.doc.marker.unwrap_or(usize::MAX);
let rolled_back = s.doc.features.iter().position(|x| x.id == id).is_some_and(|i| i >= marker);
let r = s.model.result(id);
Ok(json!({
"id": f.id, "name": f.name, "type": f.kind.kind_key(), "type_name": f.kind.type_name(), "suppressed": f.suppressed, "rolled_back": rolled_back,
"definition": def, "omitted": omitted, "inputs": inputs,
"error": r.and_then(|r| r.error.clone()), "warning": r.and_then(|r| r.warning.clone()),
}))
let info = FeatureInfo {
id: f.id,
name: f.name.clone(),
kind: f.kind.kind_key().to_string(),
type_name: f.kind.type_name().to_string(),
suppressed: f.suppressed,
rolled_back,
definition: def,
omitted,
inputs,
error: r.and_then(|r| r.error.clone()),
warning: r.and_then(|r| r.warning.clone()),
};
reply(cmd, TimelineInspectResult { inspected: Inspected::Feature(Box::new(info)), param_warnings: Vec::new() })
}
fn edit_feature(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1334,7 +1401,7 @@ fn edit_feature(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) {
return Err(EngineError::Other(e));
}
Ok(json!({"feature": id, "recomputed": s.model.last_recomputed, "restored": s.model.last_restored}))
reply(cmd, TimelineEditResult { feature: id, recomputed: s.model.last_recomputed, restored: s.model.last_restored, param_warnings: Vec::new() })
}
/// Complete a partial script pick without changing the stored selection type or reinterpreting
@@ -2049,7 +2116,7 @@ fn dependents(s: &mut Session, p: &Value) -> Result<Value> {
after.evaluate(&next);
let names = |ids: &[u64]| -> Vec<String> { ids.iter().filter(|x| **x != id).filter_map(|x| s.doc.feature(*x).map(|f| f.name.clone())).collect() };
let broken: Vec<String> = newly_broken_in_history(&s.doc, &s.model, &next, &after).into_iter().map(|(n, _)| n).collect();
Ok(json!({"deleted_with_it": names(&gone), "would_fail": broken}))
reply(cmd, TimelineDependentsResult { deleted_with_it: names(&gone), would_fail: broken, param_warnings: Vec::new() })
}
#[cfg(test)]
+10 -3
View File
@@ -13,8 +13,9 @@ use solidcraft_geom::prelude::*;
use super::{CommandSpec, not_in_sketch};
use crate::params::{UNKNOWN_COMMAND, bad, bool_, expr, expr_problem, req_expr, str_, string_list, string_list_or_default, vec2, vec3};
use crate::schema::features::{
AppliedRadius, BoundingSolidParams, BoxParams, ConeParams, CylinderParams, ExtrudeParams, FeatureNotice, FeatureResult, FilletParams,
FilletResult, NoticeAction, OffsetPlaneParams, PipeParams, PlaneAtAngleParams, ScaleParams, SphereParams, TorusParams,
AppliedRadius, BoundingSolidParams, BoxParams, CombineParams, CombineResult, ConeParams, CylinderParams, ExtrudeParams, FeatureNotice,
FeatureResult, FilletParams, FilletResult, NoticeAction, OffsetPlaneParams, PipeParams, PlaneAtAngleParams, ScaleParams, SphereParams,
TorusParams,
};
use crate::schema::{TypedContract, output_schema_for};
use crate::{EngineError, Result, Session};
@@ -28,6 +29,7 @@ const CONE_CONTRACT: TypedContract<ConeParams> = TypedContract::new();
const PIPE_CONTRACT: TypedContract<PipeParams> = TypedContract::new();
const BOUNDING_SOLID_CONTRACT: TypedContract<BoundingSolidParams> = TypedContract::new();
const SCALE_CONTRACT: TypedContract<ScaleParams> = TypedContract::new();
const COMBINE_CONTRACT: TypedContract<CombineParams> = TypedContract::new();
const OFFSET_PLANE_CONTRACT: TypedContract<OffsetPlaneParams> = TypedContract::new();
const PLANE_AT_ANGLE_CONTRACT: TypedContract<PlaneAtAngleParams> = TypedContract::new();
const FILLET_CONTRACT: TypedContract<FilletParams> = TypedContract::new();
@@ -87,7 +89,12 @@ pub static COMMANDS: &[CommandSpec] = &[
.output(output_schema_for::<FilletResult>)
.params("edges: [[x,y,z] point on edge | {body, index}] ; radius: expr; tangent_chain?: bool (default true: each picked edge's tangent chain is rounded with it; false: exactly the picked edges); body?; type?: constant|curvature (alias g2: curvature-continuous quintic between planar faces along straight edges, one planar end face at each end (including sloped ends); radius sets the equivalent circular setback, not constant curvature; exact fit only)|chord (radius is the width across)|variable (radius at the end nearest start, radius2 at the other; one end may be 0, where the round closes to the corner)|spline (the radius along the edge is a clamped B-spline from the end nearest start: radius is its first control radius, radii the rest, knots its knot vector from 0 to 1; one end may be 0); radius2?; start?: [x,y,z]; radii?: [expr, …]; knots?: [number, …]; mid?: [[position 0…1 from start, radius expr], …] (variable radii between the ends); smooth?: bool (smooth instead of straight between the radii); corner?: rolling|g2 (rolling, the default: the corners where three blended edges meet are pieces of the rolling ball's sphere, tangent to each blend; a curvature round's corners are always g2 patches, between its own sections; g2: each corner where three or more rounded edges meet, and every edge there is rounded, is set back (1.5 × the radius along each edge unless `setbacks` names it) and filled with a patch meeting the rounds and the faces round it to the second order (curvature-continuous seams, clear of the points where a round's section ends on a face), constant-radius exact fillets only, refused where it cannot be built); setbacks?: [{vertex: [x,y,z], distance: expr}, …] (corner setbacks: the corner at the vertex nearest `vertex`, where every edge meeting it is rounded, starts its patch `distance` along each of those edges from the vertex, past where the rounds would meet (past the radius at a square corner), so the rounds blend through a larger patch; one distance for all of a corner's edges; constant-radius exact fillets only; with `corner: g2`, the G2 patch's corners); turn?: mitre|swept (where two rounds turn round a sharp, straight, convex side edge square to both on a plane, such as the rounds along a boss's base at its upright edges: mitre, the default, runs them on to meet in a mitre; swept ends them at the side edge and fills the corner with their section revolved about it, a torus; swept takes type: constant, corner: rolling, no setbacks and fit: exact or reduce, and is refused with the reason where the rounds are unequal or the side edge is concave, curved or tangent); fit?: exact|reduce|taper (exact, the default: a radius too large for the geometry around the edges is refused, naming the largest that fits; reduce: where it doesn't fit it is reduced to the largest that does, one radius for edges sharing a face; the smallest used is returned as `radius_applied`, each edge's as `radii_applied: [{edge: [x,y,z], radius}]`, and noted in the feature's warning; taper: along each straight edge between planar faces where it doesn't fit, the radius follows the room there as a smooth spline, down to 0 where a face closes to a point, noted in the feature's warning)"),
CommandSpec::new("solid.chamfer", "Chamfer", chamfer).at("SOLID", "MODIFY").icon("chamfer").params("edges: [[x,y,z] | {body, index}] ; distance: expr; tangent_chain?: bool (default true: each picked edge's tangent chain is cut with it; false: exactly the picked edges); distance2?: expr (along the second face) | angle?: expr (from the first face); flip?: bool (which face is first: by default the one facing up most); body?"),
CommandSpec::new("solid.combine", "Combine", combine).at("SOLID", "MODIFY").icon("combine").params("target: body; tools: [body]; operation?: join|cut|intersect; touching?: separate|one_body (what a join makes of pieces that share no face: separate, the default, makes each piece that touches another only along an edge or at a point a body of its own and keeps pieces that touch nothing in one body; one_body keeps every piece in one body); keep_tools?: bool; new_component?: bool (the result goes into a new component; component_name?)"),
CommandSpec::new("solid.combine", "Combine", crate::checked!("solid.combine", combine))
.at("SOLID", "MODIFY")
.icon("combine")
.contract(&COMBINE_CONTRACT)
.output(output_schema_for::<CombineResult>)
.params("target: body; tools: [body]; operation?: join|cut|intersect; touching?: separate|one_body (what a join makes of pieces that share no face: separate, the default, makes each piece that touches another only along an edge or at a point a body of its own and keeps pieces that touch nothing in one body; one_body keeps every piece in one body); keep_tools?: bool; new_component?: bool (the result goes into a new component; component_name?)"),
CommandSpec::new("solid.pattern.rectangular", "Rectangular Pattern", pattern_rect)
.at("SOLID", "CREATE")
.icon("pattern_rect")
+9 -4
View File
@@ -1393,13 +1393,18 @@ mod tests {
assert_eq!(r["bytes"].as_u64(), Some(bytes.len() as u64), "{cmd}");
assert!(bytes.starts_with(signature), "{cmd}");
}
// The triangles `file.export` writes (the 80-byte header holds the file's name): a box is
// 12 triangles, 84 + 50 bytes each.
// The triangles `file.export` writes (the 80-byte header holds the file's name), 84 + 50
// bytes each. The mesher lays each planar face out as a regular grid whose sides are at
// most a fifth of the body's diagonal (kernel test `a_box_is_a_regular_grid_on_every_face`):
// 22.9 / 5 = 4.58 mm, so 5 × 3 × 2 cells along 20 × 10 × 5, two triangles each.
s.execute("file.export", &json!({"path": at("box.stl")})).unwrap();
assert_eq!(std::fs::read(at("StlBinary.txt")).unwrap()[80..], std::fs::read(at("box.stl")).unwrap()[80..]);
assert_eq!(std::fs::metadata(at("box.stl")).unwrap().len(), 84 + 50 * 12);
let triangles = 2 * 2 * (5 * 3 + 5 * 2 + 3 * 2);
let stl = std::fs::read(at("box.stl")).unwrap();
assert_eq!(u32::from_le_bytes([stl[80], stl[81], stl[82], stl[83]]), u32::try_from(triangles).unwrap());
assert_eq!(stl.len(), 84 + 50 * triangles);
let obj = std::fs::read_to_string(at("Obj.txt")).unwrap();
assert_eq!(obj.lines().filter(|l| l.starts_with("f ")).count(), 12, "{obj}");
assert_eq!(obj.lines().filter(|l| l.starts_with("f ")).count(), triangles, "{obj}");
let r = s.execute("file.export_urdf", &json!({"path": at("robot.urdf")})).unwrap();
assert_eq!(r["format"], "Urdf");
assert!(std::fs::read_to_string(at("robot.urdf")).unwrap().contains("<robot"));
+60 -25
View File
@@ -6,7 +6,7 @@
use solidcraft_geom::prelude::*;
use std::collections::BTreeMap;
use serde_json::{Value, json};
use serde_json::Value;
use solidcore_record::{Loop2, Seg2, Vec2};
use solidcraft_doc::expr::Kind;
use solidcraft_doc::frame::{self, Anchor, MemberProfile, Profile, ProfileSpec};
@@ -17,12 +17,23 @@ use super::features::{add_feature, check_expr, sketch_id};
use super::sketch::edit;
use super::{CommandSpec, in_sketch, not_in_sketch};
use crate::params::{bad, bool_, expr, num, str_, string_list, vec2};
use crate::schema::features::FeatureResult;
use crate::schema::frame::{
CreatedIds, FrameMemberParams, ProfileInsertParams, ProfileInsertResult, ProfileKindInfo, ProfileListParams, ProfileListResult, ProfileOut,
ProfilePropertiesParams, ProfilePropertiesResult, ProfileSection,
};
use crate::schema::{TypedContract, output_schema_for};
use crate::{Result, Session};
#[cfg(test)]
#[path = "frame_tests.rs"]
mod tests;
const MEMBER_CONTRACT: TypedContract<FrameMemberParams> = TypedContract::new();
const LIST_CONTRACT: TypedContract<ProfileListParams> = TypedContract::new();
const PROPERTIES_CONTRACT: TypedContract<ProfilePropertiesParams> = TypedContract::new();
const INSERT_CONTRACT: TypedContract<ProfileInsertParams> = TypedContract::new();
/// The profile parameter, as every frame command documents it.
macro_rules! profile_doc {
() => {
@@ -31,20 +42,40 @@ macro_rules! profile_doc {
}
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("frame.member", "Frame Member", member).at("WELDMENTS", "CREATE").icon("frame_member").enabled(not_in_sketch).params(concat!(
CommandSpec::new("frame.member", "Frame Member", crate::checked!("frame.member", member))
.at("WELDMENTS", "CREATE")
.icon("frame_member")
.enabled(not_in_sketch)
.contract(&MEMBER_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params(concat!(
"sketch: the frame sketch (id or name; 2D or 3D); lines?: [curve ids] (one member each; default: every straight line drawn in the sketch: not construction, not projected or otherwise linked, not of no length); ",
profile_doc!(),
" | profile_sketch: a sketch whose profile is the section, in its own x and y around its origin, with profiles?: picks as solid.extrude takes them (default: its material, nested loops alternating, so two circles make a tube); anchor?: origin|center|centroid|top_left|top|top_right|left|right|bottom_left|bottom|bottom_right (the profile point that sits on each line, default origin); angle?: angle expr (the profile turned about the line; default 0: its y as near world up as the line allows, a vertical member's x along world X); mirror?: bool (the profile mirrored across its y axis); name?; body_name? (what the members are named after, then numbered: `Rail`, `Rail (2)`…; default the profile's description, `Rect Tube 40x40x3`, or the profile sketch's name); names?: {line id: name} (names to keep, as Edit Feature keeps them). One body per line, from its start to its end; each member keeps its name when other lines are added or deleted. Returns the feature and the bodies it made"
)),
CommandSpec::new("frame.profile.list", "Profile Library", list).icon("library")
CommandSpec::new("frame.profile.list", "Profile Library", crate::checked!("frame.profile.list", list))
.icon("library")
.noundo()
.doc_only()
.contract(&LIST_CONTRACT)
.output(output_schema_for::<ProfileListResult>)
.params("Returns kinds: [{kind, label, required, optional, example}]: the built-in profiles, each SolidCraft's own parametric definition"),
CommandSpec::new("frame.profile.properties", "Profile Properties", properties).icon("measure").noundo().doc_only().params(concat!(
CommandSpec::new("frame.profile.properties", "Profile Properties", crate::checked!("frame.profile.properties", properties))
.icon("measure")
.noundo()
.doc_only()
.contract(&PROPERTIES_CONTRACT)
.output(output_schema_for::<ProfilePropertiesResult>)
.params(concat!(
profile_doc!(),
"; density?: kg/m³ (adds mass_per_m, kg/m). Returns profile (every default filled in), area (mm²), centroid [x,y] (mm, from the bounding box centre), ixx, iyy, ixy (mm⁴ about the centroid), width, height"
)),
CommandSpec::new("frame.profile.insert", "Insert Profile", insert).icon("import").enabled(in_sketch).params(concat!(
CommandSpec::new("frame.profile.insert", "Insert Profile", crate::checked!("frame.profile.insert", insert))
.icon("import")
.enabled(in_sketch)
.contract(&INSERT_CONTRACT)
.output(output_schema_for::<ProfileInsertResult>)
.params(concat!(
profile_doc!(),
"; at?: [x,y] (sketch mm, default the origin); anchor?: origin|center|centroid|top_left|top|top_right|left|right|bottom_left|bottom|bottom_right (the profile point placed on `at`: its origin, the default and a built-in profile's centre, its bounding box's centre, its centroid, or a corner or edge middle of its bounding box); angle?: radians, counter-clockwise; fixed?: bool (default true: the curves are locked, so solving the sketch keeps the shape); sketch?: id|name (default: the sketch being edited). Returns the section as frame.profile.properties does",
created_doc!()
@@ -73,29 +104,32 @@ fn profile(s: &Session, cmd: &str, p: &Value) -> Result<Profile> {
resolve(s, cmd, &profile_spec(cmd, p)?)
}
/// A frame command's reply, built from its schema's type so the two can't drift apart.
fn frame_reply(cmd: &str, r: impl serde::Serialize) -> Result<Value> {
serde_json::to_value(r).map_err(|e| bad(cmd, e.to_string()))
}
fn list(_: &mut Session, _: &Value) -> Result<Value> {
Ok(json!({"kinds": frame::profile_kinds()}))
let kinds = frame::profile_kinds().iter().map(ProfileKindInfo::from).collect();
frame_reply("frame.profile.list", ProfileListResult { kinds, param_warnings: Vec::new() })
}
/// The profile's section as the commands report it.
fn section_json(cmd: &str, profile: &Profile, density: Option<f64>) -> Result<Value> {
fn section(cmd: &str, profile: &Profile, density: Option<f64>) -> Result<ProfileSection> {
let region = profile.region().map_err(|e| bad(cmd, e.to_string()))?;
let s = frame::section(&region);
let mut out = json!({
"profile": profile.filled(),
"area": s.area,
"centroid": s.centroid,
"ixx": s.ixx,
"iyy": s.iyy,
"ixy": s.ixy,
"width": s.width,
"height": s.height,
});
if let (Some(rho), Some(o)) = (density, out.as_object_mut()) {
Ok(ProfileSection {
profile: ProfileOut(profile.filled()),
area: s.area,
centroid: s.centroid,
ixx: s.ixx,
iyy: s.iyy,
ixy: s.ixy,
width: s.width,
height: s.height,
// mm² × 1e-6 m²/mm² × kg/m³ = kg/m.
o.insert("mass_per_m".into(), json!(s.area * 1e-6 * rho));
}
Ok(out)
mass_per_m: density.map(|rho| s.area * 1e-6 * rho),
})
}
fn properties(s: &mut Session, p: &Value) -> Result<Value> {
@@ -109,7 +143,7 @@ fn properties(s: &mut Session, p: &Value) -> Result<Value> {
.ok_or_else(|| bad(cmd, "`density` must be a positive number of kg/m³ (at most 1e6)"))?,
),
};
section_json(cmd, &profile, density)
frame_reply(cmd, ProfilePropertiesResult { section: section(cmd, &profile, density)?, param_warnings: Vec::new() })
}
fn insert(s: &mut Session, p: &Value) -> Result<Value> {
@@ -135,15 +169,16 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
};
let region = profile.region().map_err(|e| bad(cmd, e.to_string()))?;
let placed = frame::placed(&region, frame::anchor_point(std::slice::from_ref(&region), anchor), at, angle);
let section = section_json(cmd, &profile, None)?;
let section = section(cmd, &profile, None)?;
let (created, info) = edit(s, p, cmd, false, |sk, _| {
let before = sk.clone();
for l in std::iter::once(&placed.outer).chain(&placed.holes) {
add_loop(sk, l, fixed, cmd)?;
}
Ok(super::sketch_created(Some(&before), sk))
serde_json::from_value::<CreatedIds>(super::sketch_created(Some(&before), sk)).map_err(|e| bad(cmd, e.to_string()))
})?;
Ok(json!({"created": created, "section": section, "sketch": info}))
let sketch = serde_json::from_value(info).map_err(|e| bad(cmd, e.to_string()))?;
frame_reply(cmd, ProfileInsertResult { created, section, sketch, param_warnings: Vec::new() })
}
/// Draw the closed loop `l` (lines and arcs) into the sketch, each segment sharing its end
+328 -139
View File
@@ -12,13 +12,25 @@ use solidcraft_sketch::CurveKind;
use super::CommandSpec;
use crate::params::{bad, bool_, str_, string_list};
use crate::schema::inspect::{
BodyListParams, FaceCylinder, ModelEdge, ModelEdges, ModelFace, ModelFaces, ModelThread, ModelThreads, ModelThreadsParams,
BodyFailure, BodyFeaturesParams, BodyFigures, BodyListParams, BodyMeasurement, BodyName, BodyNames, BodyOutcome, BoundingBox, CommandEntry,
CommandsParams, CurveShape, DocumentBody, DocumentBodyDetail, DocumentInspectParams, DocumentInspectResult, DocumentParam, DocumentSketch,
FaceCylinder, KernelCounts, ListedMaterial, MaterialAssignParams, MaterialAssignResult, MaterialListParams, MaterialListResult, MeasureParams,
MeasureResult, MeasureTotal, Measured, MeasuredBodies, MeasuredBody, ModelBodyFeatures, ModelEdge, ModelEdges, ModelFace, ModelFaces,
ModelThread, ModelThreads, ModelThreadsParams, SketchConstraint, SketchCurve, SketchInspectParams, SketchInspected, SketchPoint, SketchProfile,
SketchWire, TimelineEntry, WireArcInfo,
};
use crate::schema::{TypedContract, output_schema_for};
use crate::{EngineError, Result, Session};
const BODY_LIST_CONTRACT: TypedContract<BodyListParams> = TypedContract::new();
const THREADS_CONTRACT: TypedContract<ModelThreadsParams> = TypedContract::new();
const BODY_FEATURES_CONTRACT: TypedContract<BodyFeaturesParams> = TypedContract::new();
const MEASURE_CONTRACT: TypedContract<MeasureParams> = TypedContract::new();
const DOCUMENT_INSPECT_CONTRACT: TypedContract<DocumentInspectParams> = TypedContract::new();
const SKETCH_INSPECT_CONTRACT: TypedContract<SketchInspectParams> = TypedContract::new();
const MATERIAL_ASSIGN_CONTRACT: TypedContract<MaterialAssignParams> = TypedContract::new();
const MATERIAL_LIST_CONTRACT: TypedContract<MaterialListParams> = TypedContract::new();
const COMMANDS_CONTRACT: TypedContract<CommandsParams> = TypedContract::new();
#[path = "appearance_chain.rs"]
mod appearance_chain;
@@ -26,23 +38,39 @@ mod appearance_chain;
#[path = "appearance_gestures.rs"]
pub(super) mod appearance_gestures;
#[path = "body_features.rs"]
mod body_features;
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("inspect.colored_components", "Component Colors", colored_components)
.at("SOLID", "INSPECT").icon("colored_components").noundo()
.params("enabled?: bool (omitted: toggle); temporary component colours, without changing appearances"),
CommandSpec::new("inspect.measure", "Measure", measure)
CommandSpec::new("inspect.measure", "Measure", crate::checked!("inspect.measure", measure))
.at("SOLID", "INSPECT")
.icon("measure")
.key("I")
.noundo()
.contract(&MEASURE_CONTRACT)
.output(output_schema_for::<MeasureResult>)
.params("bodies?: [names] (default all); names_only?: bool (each world body's name and its definition's, nothing measured) → closed per body; volume_mm3, center_of_mass, density_g_cm3 and mass_g only for closed, consistently oriented bodies; area, bbox and counts for all; total.open_bodies_skipped counts bodies excluded from volume/mass totals; or items: [1-2 selections] (face/edge: type, body, point; index optional) → length/area/volume; diameter, radius, circumference or arc_length/angle_deg of circles, arcs and cylinders; slot sizes; min (distance_mm, from, to), max, center_distance (centre to centre, or from an axis) and angle"),
CommandSpec::new("document.inspect", "Inspect Design", inspect).icon("inspect").noundo().params("measure?: bool (include body measurements); sketch_counts?: bool (each sketch also gives its plane and its number of points and constraints)"),
CommandSpec::new("document.inspect", "Inspect Design", crate::checked!("document.inspect", inspect))
.icon("inspect")
.noundo()
.contract(&DOCUMENT_INSPECT_CONTRACT)
.output(output_schema_for::<DocumentInspectResult>)
.params("measure?: bool (include body measurements); sketch_counts?: bool (each sketch also gives its plane and its number of points and constraints)"),
CommandSpec::new("document.revision", "Design Revision", revision).icon("inspect").noundo().params(
"→ document_id (this open design's id: kept while it stays open, another for each design started or opened), \
revision (moves whenever the design, what is hidden or a component's uncaptured position changes, \
never on a selection or a view; from one counter for the whole run, so it never repeats). Read-only",
),
CommandSpec::new("sketch.inspect", "Inspect Sketch", sketch_inspect).at("SKETCH", "INSPECT").icon("inspect").noundo().params("sketch?: id|name (default active)"),
CommandSpec::new("sketch.inspect", "Inspect Sketch", crate::checked!("sketch.inspect", sketch_inspect))
.at("SKETCH", "INSPECT")
.icon("inspect")
.noundo()
.contract(&SKETCH_INSPECT_CONTRACT)
.output(output_schema_for::<SketchInspected>)
.params("sketch?: id|name (default active)"),
CommandSpec::new("model.edges", "List Edges", crate::checked!("model.edges", model_edges))
.icon("inspect")
.noundo()
@@ -65,9 +93,20 @@ pub static COMMANDS: &[CommandSpec] = &[
.noundo()
.contract(&THREADS_CONTRACT)
.output(output_schema_for::<ModelThreads>),
CommandSpec::new("material.assign", "Physical Material", physical_material)
CommandSpec::new("model.body_features", "List Body Features", crate::checked!("model.body_features", body_features::model_body_features))
.icon("inspect")
.noundo()
.contract(&BODY_FEATURES_CONTRACT)
.output(output_schema_for::<ModelBodyFeatures>)
.params(
"body?: name (a placed copy's name means the body it shows; default every body) → bodies: [{body, features: [{id, name}]}], \
in timeline order the feature that created each body, every later one that changed it, and those of bodies consumed into it. Read-only",
),
CommandSpec::new("material.assign", "Physical Material", crate::checked!("material.assign", physical_material))
.at("SOLID", "MODIFY")
.icon("material")
.contract(&MATERIAL_ASSIGN_CONTRACT)
.output(output_schema_for::<MaterialAssignResult>)
.params("bodies: [names]; material: name (Steel, Aluminum, ABS Plastic, …, or one made with material.create; `material.list`)"),
CommandSpec::new("appearance.assign", "Appearance", crate::checked!("appearance.assign", appearance))
.at("SOLID", "MODIFY")
@@ -104,11 +143,18 @@ pub static COMMANDS: &[CommandSpec] = &[
.contract(&LIST_CONTRACT)
.output(crate::schema::output_schema_for::<AppearanceListResult>)
.params("body?: name (or a placed copy's, `KB (K:2)`: the body it shows) → assigned appearances; with body, its resolved look and its faces' (face index, look)"),
CommandSpec::new("material.list", "List Materials", material_list).icon("list")
CommandSpec::new("material.list", "List Materials", crate::checked!("material.list", material_list))
.icon("list")
.noundo()
.doc_only()
.contract(&MATERIAL_LIST_CONTRACT)
.output(output_schema_for::<MaterialListResult>)
.params("→ built-in and design-owned materials (user: true, look, optional physical_card/physical_validation/density_comparison). Read-only; candidate engineering records do not certify sources or supply a solver"),
CommandSpec::new("engine.commands", "List Commands", commands).icon("list").noundo(),
CommandSpec::new("engine.commands", "List Commands", crate::checked!("engine.commands", commands))
.icon("list")
.noundo()
.contract(&COMMANDS_CONTRACT)
.output(output_schema_for::<Vec<CommandEntry>>),
CommandSpec::new("engine.describe", "Describe Command", crate::checked!("engine.describe", describe))
.icon("list")
.noundo()
@@ -128,7 +174,7 @@ pub(crate) fn encloses_volume(body: &solidcraft_kernel::Body) -> bool {
}
pub fn measure_json(b: &solidcraft_doc::ModelBody) -> Value {
measure_sheet_json(b, None)
serde_json::to_value(measure_sheet(b, None)).unwrap_or_default()
}
pub(super) fn measure_body(
@@ -150,27 +196,53 @@ pub(super) fn measure_body(
Ok(measured)
}
fn measure_sheet_json(b: &solidcraft_doc::ModelBody, definitions: Option<&solidcraft_doc::ModelState>) -> Value {
fn measure_sheet(b: &solidcraft_doc::ModelBody, definitions: Option<&solidcraft_doc::ModelState>) -> BodyMeasurement {
let name = b.name.clone();
match measure_body(b, definitions) {
Ok(m) => {
let closed = encloses_volume(&b.body);
json!({
"name": b.name,
"closed": closed,
"volume_mm3": closed.then_some(m.volume),
"area_mm2": m.area,
"center_of_mass": closed.then_some(m.centroid),
"bbox": {"min": m.bbox.min, "max": m.bbox.max},
"faces": m.merged.faces,
"edges": m.merged.edges,
"vertices": m.merged.vertices,
"face_types": m.merged.face_types,
"kernel": {"faces": m.faces, "edges": m.edges, "vertices": m.vertices, "shells": m.shells},
"measured_by": m.measured_by,
"notes": m.notes,
})
let outcome = BodyOutcome::Measured(BodyFigures {
closed,
volume_mm3: closed.then_some(m.volume),
area_mm2: m.area,
center_of_mass: closed.then(|| m.centroid.into()),
bbox: BoundingBox { min: m.bbox.min.into(), max: m.bbox.max.into() },
faces: count(m.merged.faces),
edges: count(m.merged.edges),
vertices: count(m.merged.vertices),
face_types: m.merged.face_types,
kernel: KernelCounts { faces: m.faces, edges: m.edges, vertices: m.vertices, shells: m.shells },
measured_by: m.measured_by.to_string(),
notes: m.notes,
});
BodyMeasurement { name, outcome }
}
Err(e) => measure_failed(name, e.to_string()),
}
}
fn measure_failed(name: String, error: String) -> BodyMeasurement {
BodyMeasurement { name, outcome: BodyOutcome::Failed(BodyFailure { error, faces: None, edges: None, vertices: None }) }
}
/// A topology count as the replies give it.
fn count(n: usize) -> i64 {
i64::try_from(n).unwrap_or(0)
}
/// A reply as JSON: the commands build their replies from the types their output schemas
/// describe, so the two can't drift apart.
fn typed_reply(cmd: &str, reply: impl serde::Serialize) -> Result<Value> {
serde_json::to_value(reply).map_err(|e| bad(cmd, e.to_string()))
}
impl BodyMeasurement {
/// The volume, when the body has one that is a number.
fn volume(&self) -> Option<f64> {
match &self.outcome {
BodyOutcome::Measured(f) => f.volume_mm3.filter(|v| v.is_finite()),
BodyOutcome::Failed(_) => None,
}
Err(e) => json!({"name": b.name, "error": e.to_string()}),
}
}
@@ -187,138 +259,217 @@ fn measure(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, "unknown body name"));
}
if bool_(p, "names_only").unwrap_or(false) {
let bodies: Vec<_> = selected.iter().map(|b| json!({"name": b.name, "definition": b.definition_name()})).collect();
return Ok(json!({"body_count": bodies.len(), "bodies": bodies}));
let bodies: Vec<_> = selected.iter().map(|b| BodyName { name: b.name.clone(), definition: b.definition_name().to_string() }).collect();
let measured = Measured::Names(BodyNames { body_count: bodies.len(), bodies });
return typed_reply(cmd, MeasureResult { measured, param_warnings: Vec::new() });
}
// Each body is measured on its own: on all cores, in body order.
let mut bodies: Vec<Value> = solidcraft_kernel::map_on_threads(&selected, 16, |b| measure_sheet_json(b, Some(&definitions)))
let measurements: Vec<BodyMeasurement> = solidcraft_kernel::map_on_threads(&selected, 16, |b| measure_sheet(b, Some(&definitions)))
.into_iter()
.zip(&selected)
.map(|(m, b)| m.unwrap_or_else(|| json!({"name": b.name, "error": "measuring this body failed"})))
.map(|(m, b)| m.unwrap_or_else(|| measure_failed(b.name.clone(), "measuring this body failed".into())))
.collect();
// Mass from each body's material.
for (b, body) in bodies.iter_mut().zip(selected) {
let mut bodies: Vec<MeasuredBody> = Vec::with_capacity(measurements.len());
for (measurement, body) in measurements.into_iter().zip(selected) {
let source = body.definition_name();
// Every body's material, measured or not: naming it takes no measuring.
b["material"] = json!(s.doc.body_material(source));
if let Some(v) = b["volume_mm3"].as_f64() {
let mut b = MeasuredBody { measurement, material: s.doc.body_material(source), density_g_cm3: None, mass_g: None, sheet_metal: None };
if let Some(v) = b.measurement.volume() {
let rho = s.doc.density(source);
b["density_g_cm3"] = json!(rho);
b["mass_g"] = json!(v * rho / 1000.0);
b.density_g_cm3 = Some(rho);
b.mass_g = Some(v * rho / 1000.0);
}
// Only Measure exposes sheet boundary counts; document.inspect keeps kernel counts.
if let Some(sheet) = definitions.sheets.iter().find(|sheet| sheet.body == source) {
let (df, de, dv) = sheet.round_hole_correction();
for (key, delta) in [("faces", df), ("edges", de), ("vertices", dv)] {
b[key] = json!(b["kernel"][key].as_i64().unwrap_or(0) + delta);
match &mut b.measurement.outcome {
BodyOutcome::Measured(f) => {
f.faces = count(f.kernel.faces) + df;
f.edges = count(f.kernel.edges) + de;
f.vertices = count(f.kernel.vertices) + dv;
}
BodyOutcome::Failed(x) => (x.faces, x.edges, x.vertices) = (Some(df), Some(de), Some(dv)),
}
b["sheet_metal"] = json!(true);
b.sheet_metal = Some(true);
}
bodies.push(b);
}
fn figures(b: &MeasuredBody) -> Option<&BodyFigures> {
match &b.measurement.outcome {
BodyOutcome::Measured(f) => Some(f),
BodyOutcome::Failed(_) => None,
}
}
let skipped = bodies.iter().filter(|b| b["closed"] == false).count();
let tv: f64 = bodies.iter().filter_map(|b| b["volume_mm3"].as_f64()).sum();
let ta: f64 = bodies.iter().filter_map(|b| b["area_mm2"].as_f64()).sum();
let tf: u64 = bodies.iter().filter_map(|b| b["faces"].as_u64()).sum();
let te: u64 = bodies.iter().filter_map(|b| b["edges"].as_u64()).sum();
let tvx: u64 = bodies.iter().filter_map(|b| b["vertices"].as_u64()).sum();
let tm: f64 = bodies.iter().filter_map(|b| b["mass_g"].as_f64()).sum();
Ok(
json!({"body_count": bodies.len(), "bodies": bodies, "total": {"open_bodies_skipped": skipped, "volume_mm3": tv, "area_mm2": ta, "mass_g": tm, "faces": tf, "edges": te, "vertices": tvx}}),
let counted = |n: Option<i64>| n.and_then(|n| u64::try_from(n).ok());
let faces = |b: &MeasuredBody| match &b.measurement.outcome {
BodyOutcome::Measured(f) => (counted(Some(f.faces)), counted(Some(f.edges)), counted(Some(f.vertices))),
BodyOutcome::Failed(x) => (counted(x.faces), counted(x.edges), counted(x.vertices)),
};
let total = MeasureTotal {
open_bodies_skipped: bodies.iter().filter(|b| figures(b).is_some_and(|f| !f.closed)).count(),
volume_mm3: bodies.iter().filter_map(|b| b.measurement.volume()).sum(),
area_mm2: bodies.iter().filter_map(|b| figures(b)).map(|f| f.area_mm2).filter(|a| a.is_finite()).sum(),
mass_g: bodies.iter().filter_map(|b| b.mass_g).filter(|m| m.is_finite()).sum(),
faces: bodies.iter().filter_map(|b| faces(b).0).sum(),
edges: bodies.iter().filter_map(|b| faces(b).1).sum(),
vertices: bodies.iter().filter_map(|b| faces(b).2).sum(),
};
typed_reply(
cmd,
MeasureResult { measured: Measured::Bodies(MeasuredBodies { body_count: bodies.len(), bodies, total }), param_warnings: Vec::new() },
)
}
/// A 2D position as the replies give it.
fn at2(p: solidcore_record::Vec2) -> [f64; 2] {
[p.x, p.y]
}
/// Sketch `id`, placed where the placed model `world` has it (its component's placement).
fn sketch_json(s: &Session, world: &solidcraft_doc::ModelState, id: u64) -> Option<Value> {
fn sketch_json(s: &Session, world: &solidcraft_doc::ModelState, id: u64) -> Option<SketchInspected> {
let state = s.model.state();
let ss = state.sketch(id)?;
let plane = world.sketch(id).map(|w| w.plane).unwrap_or(ss.plane);
let sk = &ss.sketch;
let curves: Vec<Value> = sk
let curves: Vec<SketchCurve> = sk
.curves
.iter()
.enumerate()
.map(|(i, c)| {
let det = ss.report.curve_determined.get(i).copied().unwrap_or(false);
let pid = |k: usize| sk.points.get(k).map(|p| p.id.clone()).unwrap_or_default();
let mut v = match c.kind {
CurveKind::Line { a, b } => json!({"id": c.id, "type": "line", "start": pid(a), "end": pid(b), "start_at": sk.point(a), "end_at": sk.point(b), "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}),
CurveKind::Circle { c: cc, r } => json!({"id": c.id, "type": "circle", "center": pid(cc), "center_at": sk.point(cc), "radius": r, "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}),
CurveKind::Arc { c: cc, a, b } => json!({"id": c.id, "type": "arc", "center": pid(cc), "start": pid(a), "end": pid(b), "center_at": sk.point(cc), "start_at": sk.point(a), "end_at": sk.point(b), "radius": sk.radius(i), "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}),
CurveKind::Ellipse { c: cc, m, r } => json!({"id": c.id, "type": "ellipse", "center": pid(cc), "major": pid(m), "center_at": sk.point(cc), "major_at": sk.point(m), "minor_radius": r, "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}),
CurveKind::Spline { ref pts, control, degree, periodic, ref nurbs } => {
let mut v = json!({"id": c.id, "type": "spline", "points": pts.iter().map(|q| pid(*q)).collect::<Vec<_>>(), "points_at": pts.iter().map(|q| sk.point(*q)).collect::<Vec<_>>(), "control": control, "degree": degree, "periodic": periodic, "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det});
// A control spline with its own knots (a projected free-form edge) says them.
if let (Some(n), Some(o)) = (nurbs, v.as_object_mut()) {
o.insert("knots".into(), json!(n.knots));
if !n.weights.is_empty() {
o.insert("weights".into(), json!(n.weights));
}
}
v
let at = |k: usize| sk.point(k).map(at2);
let shape = match c.kind {
CurveKind::Line { a, b } => CurveShape::Line { start: pid(a), end: pid(b), start_at: at(a), end_at: at(b) },
CurveKind::Circle { c: cc, r } => CurveShape::Circle { center: pid(cc), center_at: at(cc), radius: r },
CurveKind::Arc { c: cc, a, b } => CurveShape::Arc {
center: pid(cc),
start: pid(a),
end: pid(b),
center_at: at(cc),
start_at: at(a),
end_at: at(b),
radius: sk.radius(i),
},
CurveKind::Ellipse { c: cc, m, r } => {
CurveShape::Ellipse { center: pid(cc), major: pid(m), center_at: at(cc), major_at: at(m), minor_radius: r }
}
CurveKind::Spline { ref pts, control, degree, periodic, ref nurbs } => CurveShape::Spline {
points: pts.iter().map(|q| pid(*q)).collect(),
points_at: pts.iter().map(|q| at(*q)).collect(),
control,
degree,
periodic,
// A control spline with its own knots (a projected free-form edge) says them.
knots: nurbs.as_ref().map(|n| n.knots.clone()),
weights: nurbs.as_ref().filter(|n| !n.weights.is_empty()).map(|n| n.weights.clone()),
},
CurveKind::Conic { a, b, apex, rho } => {
CurveShape::Conic { start: pid(a), end: pid(b), apex: pid(apex), start_at: at(a), end_at: at(b), apex_at: at(apex), rho }
}
CurveKind::Conic { a, b, apex, rho } => json!({"id": c.id, "type": "conic", "start": pid(a), "end": pid(b), "apex": pid(apex), "start_at": sk.point(a), "end_at": sk.point(b), "apex_at": sk.point(apex), "rho": rho, "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}),
};
if let (Some(l), Some(o)) = (&c.link, v.as_object_mut()) {
o.insert("link".into(), json!(l));
SketchCurve {
id: c.id.clone(),
shape,
construction: c.construction,
fixed: sk.curve_locked(i),
fully_constrained: det,
link: c.link.clone(),
}
v
})
.collect();
let locked = sk.locked_points();
let points: Vec<Value> = sk
let points: Vec<SketchPoint> = sk
.points
.iter()
.enumerate()
.map(|(i, p)| json!({"id": p.id, "at": p.pos, "world": plane.to_world(p.pos), "fixed": locked.get(i).copied().unwrap_or(false), "link": p.link, "fully_constrained": ss.report.point_determined.get(i).copied().unwrap_or(false)}))
.map(|(i, p)| SketchPoint {
id: p.id.clone(),
at: at2(p.pos),
world: plane.to_world(p.pos).into(),
fixed: locked.get(i).copied().unwrap_or(false),
link: p.link.clone(),
fully_constrained: ss.report.point_determined.get(i).copied().unwrap_or(false),
})
.collect();
let constraints: Vec<Value> = sk
let constraints: Vec<SketchConstraint> = sk
.constraints
.iter()
.map(|c| {
let mut v = serde_json::to_value(&c.kind).unwrap_or_default();
if let Some(o) = v.as_object_mut() {
o.insert("id".into(), json!(c.id));
o.insert("name".into(), json!(c.kind.name()));
if let Some(p) = &c.param {
o.insert("param".into(), json!(p));
o.insert("expression".into(), json!(s.doc.param(p).map(|x| x.expr.clone())));
}
let mut kind = match serde_json::to_value(&c.kind) {
Ok(Value::Object(kind)) => kind,
_ => Default::default(),
};
// The constraint's own keys win over any of the kind's.
for key in ["id", "name", "param", "expression"] {
kind.remove(key);
}
SketchConstraint {
id: c.id.clone(),
name: c.kind.name().to_string(),
param: c.param.clone(),
expression: c.param.as_ref().map(|p| s.doc.param(p).map(|x| x.expr.clone())),
kind,
}
v
})
.collect();
let profiles: Vec<Value> = ss
let profiles: Vec<SketchProfile> = ss
.profiles
.iter()
.enumerate()
// A loop records one entry per piece it walks, and a conic or spline is walked as many
// short pieces: each loop names a curve once, in loop order.
.map(|(i, p)| {
let holes: Vec<Vec<String>> = p.hole_curves.iter().map(|h| solidcraft_sketch::distinct_curves(h)).collect();
json!({"index": i, "area_mm2": p.area, "centroid_sketch": p.centroid, "curves": solidcraft_sketch::distinct_curves(&p.outer_curves), "holes": holes})
.map(|(i, p)| SketchProfile {
index: i,
area_mm2: p.area,
centroid_sketch: at2(p.centroid),
curves: solidcraft_sketch::distinct_curves(&p.outer_curves),
holes: p.hole_curves.iter().map(|h| solidcraft_sketch::distinct_curves(h)).collect(),
})
.collect();
Some(json!({
"id": id,
"name": ss.name,
"plane": ss.plane,
"status": ss.report.status,
"dof": ss.report.dof,
"fully_constrained": ss.report.fully_constrained(),
"failing": ss.report.failing,
"curves": curves,
"points": points,
"constraints": constraints,
"profiles": profiles,
"links": sk.links,
"wires": sk.wires.iter().map(|w| json!({"id": w.id, "points": w.pts.len(), "start": w.pts.first(), "end": w.pts.last(), "link": w.link, "arc": w.arc.map(|a| json!({"center": a.center, "radius": a.radius, "start": a.start, "end": a.end, "sweep_deg": solidcraft_sketch::arc_sweep(&a).to_degrees()}))})).collect::<Vec<_>>(),
"visible": crate::view::sketch_shown(s, id),
}))
let wires = sk
.wires
.iter()
.map(|w| SketchWire {
id: w.id.clone(),
points: w.pts.len(),
start: w.pts.first().map(|&p| p.into()),
end: w.pts.last().map(|&p| p.into()),
link: w.link.clone(),
arc: w.arc.map(|a| WireArcInfo {
center: a.center.into(),
radius: a.radius,
start: a.start.into(),
end: a.end.into(),
sweep_deg: solidcraft_sketch::arc_sweep(&a).to_degrees(),
}),
})
.collect();
Some(SketchInspected {
id,
name: ss.name.clone(),
plane: serde_json::to_value(ss.plane).unwrap_or_default(),
status: ss.report.status.into(),
dof: ss.report.dof,
fully_constrained: ss.report.fully_constrained(),
failing: ss.report.failing.clone(),
curves,
points,
constraints,
profiles,
links: serde_json::to_value(&sk.links).unwrap_or_default(),
wires,
visible: crate::view::sketch_shown(s, id),
param_warnings: Vec::new(),
})
}
fn sketch_inspect(s: &mut Session, p: &Value) -> Result<Value> {
let id = super::sketch_store::target(s, p, "sketch.inspect")?;
sketch_json(s, &*s.world_state()?, id).ok_or_else(|| EngineError::Other("that sketch is not evaluated (rolled back or not a sketch)".into()))
let inspected = sketch_json(s, &*s.world_state()?, id)
.ok_or_else(|| EngineError::Other("that sketch is not evaluated (rolled back or not a sketch)".into()))?;
typed_reply("sketch.inspect", inspected)
}
/// Revisions of every design of this run (#6646).
@@ -377,24 +528,38 @@ fn revision(s: &mut Session, _p: &Value) -> Result<Value> {
fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.model.state();
let (vals, perr) = s.doc.param_values();
let params: Vec<Value> = s
let params: Vec<DocumentParam> = s
.doc
.params
.iter()
.map(|p| json!({"name": p.name, "expression": p.expr, "unit": p.unit, "value": vals.get(&p.name).map(|v| v.v), "model": p.model, "comment": p.comment, "error": perr.get(&p.name)}))
.map(|p| DocumentParam {
name: p.name.clone(),
expression: p.expr.clone(),
unit: p.unit.clone(),
value: vals.get(&p.name).map(|v| v.v),
model: p.model,
comment: p.comment.clone(),
error: perr.get(&p.name).cloned(),
})
.collect();
let marker = s.doc.marker.unwrap_or(s.doc.features.len());
let timeline: Vec<Value> = s
let timeline: Vec<TimelineEntry> = s
.doc
.features
.iter()
.enumerate()
.map(|(i, f)| {
let r = s.model.result(f.id);
json!({
"id": f.id, "name": f.name, "type": f.kind.type_name(), "suppressed": f.suppressed, "rolled_back": i >= marker,
"error": r.and_then(|r| r.error.clone()), "warning": r.and_then(|r| r.warning.clone()), "ms": r.map(|r| r.ms),
})
TimelineEntry {
id: f.id,
name: f.name.clone(),
kind: f.kind.type_name().to_string(),
suppressed: f.suppressed,
rolled_back: i >= marker,
error: r.and_then(|r| r.error.clone()),
warning: r.and_then(|r| r.warning.clone()),
ms: r.map(|r| r.ms),
}
})
.collect();
let definitions = s.model.state();
@@ -402,46 +567,69 @@ fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
let with_counts = bool_(p, "sketch_counts").unwrap_or(false);
// Each body is measured on its own: on all cores, in body order (as `inspect.measure`).
let mut measured =
if with_measure { solidcraft_kernel::map_on_threads(&st.bodies, 16, |b| measure_sheet_json(b, Some(&definitions))) } else { Vec::new() }
if with_measure { solidcraft_kernel::map_on_threads(&st.bodies, 16, |b| measure_sheet(b, Some(&definitions))) } else { Vec::new() }
.into_iter();
let bodies: Vec<Value> = st
let bodies: Vec<DocumentBody> = st
.bodies
.iter()
.map(|b| {
let mut v = match measured.next() {
Some(m) => m.unwrap_or_else(|| json!({"name": b.name, "error": "measuring this body failed"})),
None => json!({"name": b.name, "feature": b.feature}),
};
if let Some(o) = v.as_object_mut() {
o.insert("visible".into(), json!(s.body_visible(&b.name)));
if s.body_removed_later(&b.name) {
// Editing a sketch rolls the timeline back; this body is deleted further on.
o.insert("removed_later".into(), json!(true));
let detail = match measured.next() {
Some(m) => {
DocumentBodyDetail::Measured(Box::new(m.unwrap_or_else(|| measure_failed(b.name.clone(), "measuring this body failed".into()))))
}
}
v
None => DocumentBodyDetail::Listed { name: b.name.clone(), feature: b.feature },
};
// Editing a sketch rolls the timeline back; a body deleted further on is `removed_later`.
DocumentBody { detail, visible: s.body_visible(&b.name), removed_later: s.body_removed_later(&b.name).then_some(true) }
})
.collect();
let sketches: Vec<Value> = st
let sketches: Vec<DocumentSketch> = st
.sketches
.iter()
.map(|ss| {
let mut v = json!({"id": ss.feature, "name": ss.name, "dof": ss.report.dof, "status": ss.report.status, "curves": ss.sketch.curves.len(), "profiles": ss.profiles.len(), "visible": crate::view::sketch_shown(s, ss.feature)});
let mut v = DocumentSketch {
id: ss.feature,
name: ss.name.clone(),
dof: ss.report.dof,
status: ss.report.status.into(),
curves: ss.sketch.curves.len(),
profiles: ss.profiles.len(),
visible: crate::view::sketch_shown(s, ss.feature),
plane: None,
points: None,
constraints: None,
};
// A summary that stands in for the sketch's contents (the compact MCP inspect_design).
if with_counts && let Some(o) = v.as_object_mut() {
o.insert("plane".into(), json!(ss.plane));
o.insert("points".into(), json!(ss.sketch.points.len()));
o.insert("constraints".into(), json!(ss.sketch.constraints.len()));
if with_counts {
v.plane = Some(serde_json::to_value(ss.plane).unwrap_or_default());
v.points = Some(ss.sketch.points.len());
v.constraints = Some(ss.sketch.constraints.len());
}
v
})
.collect();
Ok(json!({
"name": s.doc.name, "units": s.doc.units, "metadata": s.doc.metadata, "path": s.path, "dirty": s.is_dirty(), "revision": s.revision,
"active_sketch": s.active_sketch, "marker": marker,
"params": params, "timeline": timeline, "bodies": bodies, "sketches": sketches,
"selection": s.selection, "undo": s.undo.len(), "redo": s.redo.len(), "hidden_origin": s.visibility.hidden_origin,
}))
typed_reply(
"document.inspect",
DocumentInspectResult {
name: s.doc.name.clone(),
units: s.doc.units.clone(),
metadata: s.doc.metadata.0.clone(),
path: s.path.clone(),
dirty: s.is_dirty(),
revision: s.revision,
active_sketch: s.active_sketch,
marker,
params,
timeline,
bodies,
sketches,
selection: serde_json::to_value(&s.selection).unwrap_or_default(),
undo: s.undo.len(),
redo: s.redo.len(),
hidden_origin: s.visibility.hidden_origin.clone(),
param_warnings: Vec::new(),
},
)
}
fn body_of<'a>(st: &'a solidcraft_doc::ModelState, p: &Value, cmd: &str) -> Result<&'a solidcraft_doc::ModelBody> {
@@ -530,7 +718,8 @@ fn describe(_s: &mut Session, p: &Value) -> Result<Value> {
}
fn commands(s: &mut Session, _p: &Value) -> Result<Value> {
Ok(Value::Array(crate::command_specs().iter().map(|c| serde_json::to_value(c.info(s)).unwrap_or_default()).collect()))
let list: Vec<CommandEntry> = crate::command_specs().iter().map(|c| c.info(s).into()).collect();
typed_reply("engine.commands", list)
}
fn model_threads(s: &mut Session, _p: &Value) -> Result<Value> {
@@ -554,7 +743,7 @@ fn physical_material(s: &mut Session, p: &Value) -> Result<Value> {
s.doc_mut().materials.insert(b.clone(), canonical.clone());
}
}
Ok(json!({"bodies": bodies, "material": canonical}))
typed_reply(cmd, MaterialAssignResult { bodies, material: canonical, param_warnings: Vec::new() })
}
/// A colour parameter: `"#rrggbb"` or `[r, g, b]` (0–255).
@@ -901,18 +1090,18 @@ fn appearance_list(s: &mut Session, p: &Value) -> Result<Value> {
}
fn material_list(s: &mut Session, _p: &Value) -> Result<Value> {
let built_in = solidcraft_doc::MATERIALS.iter().map(|(n, d)| json!({"name": n, "density_g_cm3": d}));
let built_in = solidcraft_doc::MATERIALS.iter().map(|(n, d)| ListedMaterial::BuiltIn { name: n.to_string(), density_g_cm3: *d });
let user = s
.doc
.user_materials
.iter()
.map(|m| {
let mut out = super::materials::material_json(m)?;
out["appearance"] = json!(s.doc.material_look(&m.name).as_ref().map(look_json));
Ok(out)
let mut out = super::materials::listed_material(m)?;
out.appearance = s.doc.material_look(&m.name).as_ref().map(LookReply::from);
Ok(ListedMaterial::Design(Box::new(out)))
})
.collect::<Result<Vec<_>>>()?;
Ok(json!({"materials": built_in.chain(user).collect::<Vec<_>>()}))
typed_reply("material.list", MaterialListResult { materials: built_in.chain(user).collect(), param_warnings: Vec::new() })
}
fn colored_components(s: &mut Session, p: &Value) -> Result<Value> {
+47 -23
View File
@@ -13,7 +13,7 @@
use std::collections::HashSet;
use std::sync::Arc;
use serde_json::{Value, json};
use serde_json::Value;
use solidcore_record::Vec3;
use solidcraft_geom::mesh_distance::TriangleSet;
use solidcraft_geom::{Aabb3, Mesh};
@@ -21,6 +21,8 @@ use solidcraft_kernel::Body;
use solidcraft_kernel::profile::KernelTimes;
use crate::params::{bad, num, string_list};
use crate::schema::clearance::{Overlap, OverlapBounds, Unchecked};
use crate::schema::interference::{InterferenceResult, PairTime};
use crate::{Result, Session};
/// Most pairs with overlapping bounding boxes one call intersects.
@@ -49,7 +51,7 @@ struct Job {
/// What an exact check of one pair came to.
enum Outcome {
Hit(Value),
Hit(Overlap),
Clear,
Unchecked(String),
}
@@ -159,26 +161,30 @@ pub(super) fn interference(s: &mut Session, p: &Value) -> Result<Value> {
let waited_ms = waited.elapsed().as_secs_f64() * 1000.0;
// Mesh bodies are not intersected: say so, rather than let `count: 0` read as "no overlap".
let mut unchecked: Vec<Value> = meshes_left.iter().map(|b| json!({"a": b.name, "b": null, "reason": MESH_NOT_CHECKED})).collect();
let mut unchecked: Vec<Unchecked> =
meshes_left.iter().map(|b| Unchecked { a: b.name.clone(), b: None, reason: MESH_NOT_CHECKED.to_string() }).collect();
let (mut hits, mut timed) = (Vec::new(), Vec::new());
let (mut not_checked, mut boolean_ms, mut tessellate_ms) = (0usize, 0.0, 0.0);
for ((a, b), d) in done {
let Some(d) = d else {
not_checked += 1;
unchecked.push(json!({"a": a, "b": b, "reason": format!("not checked: the time budget of {budget_s} s ran out (budget_s)")}));
unchecked.push(Unchecked { a, b: Some(b), reason: format!("not checked: the time budget of {budget_s} s ran out (budget_s)") });
continue;
};
boolean_ms += d.kernel.boolean_ms;
tessellate_ms += d.kernel.tessellate_ms;
timed.push((
d.ms,
json!({"a": a, "b": b, "ms": round_ms(d.ms), "boolean_ms": round_ms(d.kernel.boolean_ms),
"tessellate_ms": round_ms(d.kernel.tessellate_ms)}),
));
let time = PairTime {
a: a.clone(),
b: b.clone(),
ms: round_ms(d.ms),
boolean_ms: round_ms(d.kernel.boolean_ms),
tessellate_ms: round_ms(d.kernel.tessellate_ms),
};
timed.push((d.ms, time));
match d.outcome {
Outcome::Hit(v) => hits.push(v),
Outcome::Hit(overlap) => hits.push(overlap),
Outcome::Clear => {}
Outcome::Unchecked(reason) => unchecked.push(json!({"a": a, "b": b, "reason": reason})),
Outcome::Unchecked(reason) => unchecked.push(Unchecked { a, b: Some(b), reason }),
}
}
// A timing profile of the command (`--profile`) counts the kernel time the workers spent,
@@ -189,15 +195,28 @@ pub(super) fn interference(s: &mut Session, p: &Value) -> Result<Value> {
solidcraft_kernel::profile::credit(KernelTimes { boolean_ms: boolean_ms * share, tessellate_ms: tessellate_ms * share });
}
timed.sort_by(|x, y| y.0.total_cmp(&x.0));
let slowest: Vec<Value> = timed.into_iter().take(SLOWEST).map(|t| t.1).collect();
Ok(json!({
"interferences": hits, "count": hits.len(), "checked": bodies.len(), "unchecked": unchecked,
"truncated": skipped > 0, "skipped_pairs": skipped,
"pairs_candidate": pairs.len(), "pairs_cleared": cleared, "pairs_intersected": intersected,
"pairs_not_checked": not_checked, "budget_s": budget_s, "budget_exceeded": not_checked > 0,
"threads": threads, "boolean_ms": round_ms(boolean_ms), "tessellate_ms": round_ms(tessellate_ms),
"slowest": slowest, "ms": round_ms(started.elapsed().as_secs_f64() * 1000.0),
}))
let slowest: Vec<PairTime> = timed.into_iter().take(SLOWEST).map(|t| t.1).collect();
let reply = InterferenceResult {
count: hits.len(),
interferences: hits,
checked: bodies.len(),
unchecked,
truncated: skipped > 0,
skipped_pairs: skipped,
pairs_candidate: pairs.len(),
pairs_cleared: cleared,
pairs_intersected: intersected,
pairs_not_checked: not_checked,
budget_s,
budget_exceeded: not_checked > 0,
threads,
boolean_ms: round_ms(boolean_ms),
tessellate_ms: round_ms(tessellate_ms),
slowest,
ms: round_ms(started.elapsed().as_secs_f64() * 1000.0),
param_warnings: Vec::new(),
};
serde_json::to_value(reply).map_err(|e| crate::EngineError::Other(e.to_string()))
}
fn round_ms(ms: f64) -> f64 {
@@ -284,9 +303,14 @@ fn check_pair(a: &Body, b: &Body, (na, nb): &(String, String)) -> Outcome {
Err(e) => return Outcome::Unchecked(e.to_string()),
};
if m.volume > threshold {
let hit = json!({"a": na, "b": nb, "volume_mm3": m.volume, "area_mm2": m.area, "center": m.centroid,
"bbox": {"min": m.bbox.min, "max": m.bbox.max}});
return Outcome::Hit(hit);
return Outcome::Hit(Overlap {
a: na.clone(),
b: nb.clone(),
volume_mm3: m.volume,
area_mm2: m.area,
bbox: OverlapBounds { min: m.bbox.min.into(), max: m.bbox.max.into() },
center: m.centroid.into(),
});
}
Outcome::Clear
}
+225 -102
View File
@@ -15,11 +15,24 @@ use solidcraft_geom::Aabb3;
use super::CommandSpec;
use crate::params::{bad, bool_, num, str_, vec3};
use crate::schema::joints::EditJointResult;
use crate::schema::joints::{
AsBuiltJointParams, CamLift, ContactStop, CreateJointParams, DeleteJointParams, DeleteJointResult, DriveJointParams, DriveJointResult,
EditJointResult, FloatingChain, HeldValue, InspectJointParams, InspectJointResult, JointAddedResult, JointDeleted, JointFrame, JointLimitsParams,
JointProblem, LinkInfo, ListJointsParams, ListJointsResult, ListedJoint, ListedOrigin, LoopStop, NamedValue, OccurrenceDof, SolveJointsParams,
SolveReport, SolveResult, ValueUnit,
};
use crate::schema::{TypedContract, output_schema_for};
use crate::{EngineError, Result, Session};
const EDIT_CONTRACT: TypedContract<crate::schema::joints::EditJointParams> = TypedContract::new();
const CREATE_CONTRACT: TypedContract<CreateJointParams> = TypedContract::new();
const AS_BUILT_CONTRACT: TypedContract<AsBuiltJointParams> = TypedContract::new();
const DRIVE_CONTRACT: TypedContract<DriveJointParams> = TypedContract::new();
const LIMITS_CONTRACT: TypedContract<JointLimitsParams> = TypedContract::new();
const DELETE_CONTRACT: TypedContract<DeleteJointParams> = TypedContract::new();
const LIST_CONTRACT: TypedContract<ListJointsParams> = TypedContract::new();
const INSPECT_CONTRACT: TypedContract<InspectJointParams> = TypedContract::new();
const SOLVE_CONTRACT: TypedContract<SolveJointsParams> = TypedContract::new();
#[path = "joints_inspect.rs"]
mod inspect;
@@ -28,7 +41,13 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("joint.origin", "Joint Origin", joint_origin).at("SOLID", "CREATE").icon("joint_origin").params(
"name?, occurrence?: id|name (default: from the pick, or the named face's component), face: [x,y,z] | \"<name>\" (centre of a planar face) | circle: [x,y,z] | \"<name>\" (centre of a round end: a point on its wall or rim, the end's centre, or the round face's name as model.faces lists it) | edge: [x,y,z] (a point on an edge of a planar face: the edge's middle, z out of the face, y into it; on?: [x,y,z] a point on the face (default: the larger planar face beside the edge); corner?: [x,y,z] near one end: the corner there, x and y along its two edges into the face; found again by name, so it follows the face as it changes size; z?: [x,y,z] the frame's z instead of the face's normal, along the edge when it runs that way, x chosen from z: an edge or corner picked as it is, kept as a point where it can't be found by name) | point: [x,y,z], z?: [x,y,z]; a circle's z?: [x,y,z] (a round edge's centre with the way out of it: its end by name when that is the frame it shows, else a point there); body?: the named face's body; end?: \"<name>\" (a circle's end: the face across its rim there; default the end nearer the pick); x?: [x,y,z] (a direction, world) | \"<face name>\" | {face: [x,y,z]} (the frame's x: a direction, a plane's normal, or the way to another hole's axis; keeps a round origin's turn; a point's x is a direction, laid square to its z, default: chosen from z alone). Faces are kept by name: the origin follows its face, and a circle its end, when the design changes",
),
CommandSpec::new("joint.create", "Joint", joint).at("SOLID", "ASSEMBLE").icon("joint").key("J").params(
CommandSpec::new("joint.create", "Joint", crate::checked!("joint.create", joint))
.at("SOLID", "ASSEMBLE")
.icon("joint")
.key("J")
.contract(&CREATE_CONTRACT)
.output(output_schema_for::<JointAddedResult>)
.params(
"type: rigid|revolute|slider|cylindrical|pin_slot|planar|ball, or a relation: parallel|perpendicular|angle|point_on_plane|point_on_line|line_on_plane \
(ties only some directions or points, made where the parts are; several between two parts add up); a, b: joint origins ({occurrence, face|circle|edge|point: [x,y,z] in world, or face|circle: \"<face name>\"; z? for a point, end?, x? as joint.origin} | origin name); \
values?: [angles in deg or expressions, distances]; limits?: [[min, max] | null…]; flip?: bool (default false: the origins are mated, \
@@ -40,12 +59,22 @@ pub static COMMANDS: &[CommandSpec] = &[
kept with the joint and worked out again whenever the design changes, so the joint follows the parameters it names; a joint whose \
expression no longer works out (a parameter deleted) is left out of the solve with the reason in its errors",
),
CommandSpec::new("joint.as_built", "As-Built Joint", as_built).at("SOLID", "ASSEMBLE").icon("as_built").params(
CommandSpec::new("joint.as_built", "As-Built Joint", crate::checked!("joint.as_built", as_built))
.at("SOLID", "ASSEMBLE")
.icon("as_built")
.contract(&AS_BUILT_CONTRACT)
.output(output_schema_for::<JointAddedResult>)
.params(
"type, a: occurrence, b: occurrence (stay where they are); at?: joint origin (default: b's origin) — the joint's frame; one on a or b is kept on its face or hole there, so the joint follows it as the design changes; \
name?; limits?: [[min, max] | null…]; quiet?: bool",
),
CommandSpec::new("joint.rigid_group", "Rigid Group", rigid_group).at("SOLID", "ASSEMBLE").icon("rigid_group").params("occurrences: [ids or names] (move together, as they are)"),
CommandSpec::new("joint.drive", "Drive Joints", drive).at("SOLID", "ASSEMBLE").icon("drive").params(
CommandSpec::new("joint.drive", "Drive Joints", crate::checked!("joint.drive", drive))
.at("SOLID", "ASSEMBLE")
.icon("drive")
.contract(&DRIVE_CONTRACT)
.output(output_schema_for::<DriveJointResult>)
.params(
"joint: id|name; value | values: [angles in deg or expressions, distances] (values not given keep theirs; a value driven drops the expression joint.create gave it; clamped to the limits; the other joints of a \
closed loop follow, and the motion stops where the loop can no longer close → stopped_by_loop); quiet?: bool (leave out dof \
and fixed) → values: [{name, value, unit: deg|mm, limits}] as joint.list gives them; requested: the values asked for, in the same form; \
@@ -83,7 +112,11 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("joint.cam", "Cam", cam_cmd).at("SOLID", "ASSEMBLE").icon("cam").params(
"a: the cam's joint (revolute); b: the follower's joint, a slider (a translating follower) or a revolute (an oscillating one), b driven by a; profile: {sketch: id|name, index?: the profile (default: the largest)} | {edges: [[x,y,z]…] (points on edges of the cam's bodies forming one closed loop, world), occurrence?}: the cam's outline, in a plane square to its axis, read again whenever the design changes; follower?: knife|roller|flat (default knife; flat: a face square to a slider's line); radius?: the roller's radius (mm, an expression or {circle: [x,y,z], occurrence?}: the roller's round face, which is its tip too); tip?: a joint origin where the follower touches: its knife's point or its roller's centre, such as {circle: [x,y,z]} on the roller (needed for an oscillating follower; a translating one's line runs through it, by default along its slider's axis); flip?: bool (ride the cam from the other side than the one the follower is on now); link?: index (replace that motion link) → link, lift: {min, max, rise, unit} (b's travel over a turn of the cam), reverse (the side it rides from), follower_offset (mm: how far a translating follower's line passes from the cam's axis); b keeps where it is, also when the link replaces another (at the current pose)",
),
CommandSpec::new("joint.limits", "Joint Limits", limits).icon("joint").params("joint; index?: value index (default 0); min?, max? (omit both to clear)"),
CommandSpec::new("joint.limits", "Joint Limits", crate::checked!("joint.limits", limits))
.icon("joint")
.contract(&LIMITS_CONTRACT)
.output(output_schema_for::<SolveResult>)
.params("joint; index?: value index (default 0); min?, max? (omit both to clear)"),
CommandSpec::new("joint.edit", "Edit Joint", crate::checked!("joint.edit", edit))
.icon("edit")
.contract(&EDIT_CONTRACT)
@@ -94,12 +127,26 @@ pub static COMMANDS: &[CommandSpec] = &[
its limits and expressions (a relation's values start again where its parts are); an edit that places it again (not a rename alone) \
meets a loop it closes as joint.create does, turning an angle a half turn where only that meets it",
),
CommandSpec::new("joint.delete", "Delete Joint", delete).icon("delete").params("joint: id|name (or origin: name, link: index)"),
CommandSpec::new("joint.list", "List Joints", list).icon("list").noundo().params(
CommandSpec::new("joint.delete", "Delete Joint", crate::checked!("joint.delete", delete))
.icon("delete")
.contract(&DELETE_CONTRACT)
.output(output_schema_for::<DeleteJointResult>)
.params("joint: id|name (or origin: name, link: index)"),
CommandSpec::new("joint.list", "List Joints", crate::checked!("joint.list", list))
.icon("list")
.noundo()
.contract(&LIST_CONTRACT)
.output(output_schema_for::<ListJointsResult>)
.params(
"→ joints (type, occurrences, values, limits; expressions: {offset?, angle?, values?} when they were typed as expressions), origins, links, degrees of freedom, conflicts, floating: [{occurrences, joints}] \
(joint chains joined to nothing grounded: the solver places none of them, so driving them moves nothing)",
),
CommandSpec::new("joint.inspect", "Inspect Joint", inspect::inspect).icon("inspect").noundo().params(
CommandSpec::new("joint.inspect", "Inspect Joint", crate::checked!("joint.inspect", inspect::inspect))
.icon("inspect")
.noundo()
.contract(&INSPECT_CONTRACT)
.output(output_schema_for::<InspectJointResult>)
.params(
"joint?: id|name (default: every joint) → joints: [{id, name, type, suppressed, visible, flip, offset (mm), angle (deg), values: as joint.list \
gives them, dofs, expressions?: {offset?, angle?, values?}, a, b: {occurrence, occurrence_name, component, component_name, path \
(occurrence names from the top, `/` between), snap (as saved: type, pick, face, end, edge, corner, x, point, z, in the component's \
@@ -108,8 +155,18 @@ pub static COMMANDS: &[CommandSpec] = &[
unverified?: a name saved before exact names}], resolves, frame?: {origin, z, x} in the world, error?: why the origin is lost}, \
links: [{index, role: driver|driven, …as joint.list}], errors, conflicts}] (reads only: nothing changes)",
),
CommandSpec::new("joint.solve", "Solve Joints", solve_cmd).icon("compute").params("re-place the occurrences from the joints → conflicts, errors, dof"),
CommandSpec::new("inspect.interference", "Interference", super::interference::interference).at("SOLID", "INSPECT").icon("interference").noundo().params(
CommandSpec::new("joint.solve", "Solve Joints", crate::checked!("joint.solve", solve_cmd))
.icon("compute")
.contract(&SOLVE_CONTRACT)
.output(output_schema_for::<SolveResult>)
.params("re-place the occurrences from the joints → conflicts, errors, dof"),
CommandSpec::new("inspect.interference", "Interference", crate::checked!("inspect.interference", super::interference::interference))
.contract(&crate::schema::TypedContract::<crate::schema::interference::InterferenceParams>::new())
.output(crate::schema::output_schema_for::<crate::schema::interference::InterferenceResult>)
.at("SOLID", "INSPECT")
.icon("interference")
.noundo()
.params(
"bodies?: [names] (default: all, in world placement; a name that is no body is refused); budget_s?: seconds (default 240, at most 86400: pairs not finished by then are listed in unchecked as \"not checked: the time budget … ran out\" and the call returns) → interferences: pairs that overlap with the overlap volume, area, bounding box and centre; unchecked: [{a, b, reason}], the pairs that could not be checked, with the reason, and each mesh body (STL, 3MF or OBJ import: not intersected) as {a: name, b: null, reason: \"mesh body: not checked\"}, so count: 0 is not read as no overlap (checked counts the solid bodies). Bodies are pruned by bounding box first, then by their display meshes (pairs whose meshes stay apart by more than their chord tolerances, neither inside the other, are clear without a boolean), so any number of bodies is fine; the pairs left are intersected exactly, in parallel on desktop, the slowest first; at most 20000 pairs whose boxes overlap are intersected per call (truncated: true and skipped_pairs: n say when more were left out). Also: pairs_candidate (boxes overlap), pairs_cleared (by the meshes), pairs_intersected, pairs_not_checked, budget_exceeded, threads (worker threads; 0: on the calling thread), boolean_ms and tessellate_ms (kernel time summed over the pairs), slowest: [{a, b, ms, boolean_ms, tessellate_ms}] and ms",
),
];
@@ -222,30 +279,57 @@ fn response_analysis(p: &Value) -> joints::Analysis {
}
/// A solve's answer; `quiet: true` in the command's params leaves out the per-occurrence lists.
fn sol_json(sol: &joints::Solution, p: &Value) -> Value {
let mut out = json!({
"conflicts": sol.conflicts.iter().map(|(id, n, w)| json!({"joint": id, "name": n, "why": w})).collect::<Vec<_>>(),
"errors": sol.errors.iter().map(|(id, n, w)| json!({"joint": id, "name": n, "why": w})).collect::<Vec<_>>(),
});
if !bool_(p, "quiet").unwrap_or(false) {
out["dof"] = sol.dof.iter().map(|(o, d)| json!({"occurrence": o, "dof": d})).collect();
out["fixed"] = json!(sol.fixed);
fn solve_report(sol: &joints::Solution, p: &Value) -> SolveReport {
let problems = |list: &[(u64, String, String)]| -> Vec<JointProblem> {
list.iter().map(|(joint, name, why)| JointProblem { joint: *joint, name: name.clone(), why: why.clone() }).collect()
};
let quiet = bool_(p, "quiet").unwrap_or(false);
SolveReport {
conflicts: problems(&sol.conflicts),
errors: problems(&sol.errors),
dof: (!quiet).then(|| sol.dof.iter().map(|(o, d)| OccurrenceDof { occurrence: *o, dof: *d }).collect()),
fixed: (!quiet).then(|| sol.fixed.clone()),
}
out
}
/// [`solve_report`] as JSON, for the commands that add to it.
fn sol_json(sol: &joints::Solution, p: &Value) -> Value {
serde_json::to_value(solve_report(sol, p)).unwrap_or_default()
}
/// A command's reply as JSON: the joint commands that have output schemas build their replies from
/// the types those describe, so the two can't drift apart.
fn reply(cmd: &str, reply: impl serde::Serialize) -> Result<Value> {
serde_json::to_value(reply).map_err(|e| bad(cmd, e.to_string()))
}
/// A joint's values as `joint.list` gives them: degrees for angles, mm for distances, each with
/// its name and limits. The numbers are rounded for reading (#10747); the joint keeps full
/// precision.
pub(crate) fn named_values(j: &Joint, q: &[f64]) -> Vec<Value> {
named_value_list(j, q).into_iter().map(|v| serde_json::to_value(v).unwrap_or_default()).collect()
}
/// [`named_values`], typed.
pub(crate) fn named_value_list(j: &Joint, q: &[f64]) -> Vec<NamedValue> {
j.kind
.dofs()
.iter()
.enumerate()
.map(|(i, n)| {
let (v, angle) = (q.get(i).copied().unwrap_or(0.0), j.kind.is_angle(i));
json!({"name": n, "value": expr::round_reported(if angle { v.to_degrees() } else { v }), "unit": if angle { "deg" } else { "mm" },
"limits": j.limits.get(i).copied().flatten().map(|(a, b)| if angle { [expr::round_reported(a.to_degrees()), expr::round_reported(b.to_degrees())] } else { [expr::round_reported(a), expr::round_reported(b)] })})
NamedValue {
name: (*n).to_string(),
value: expr::round_reported(if angle { v.to_degrees() } else { v }),
unit: if angle { ValueUnit::Deg } else { ValueUnit::Mm },
limits: j.limits.get(i).copied().flatten().map(|(a, b)| {
if angle {
[expr::round_reported(a.to_degrees()), expr::round_reported(b.to_degrees())]
} else {
[expr::round_reported(a), expr::round_reported(b)]
}
}),
}
})
.collect()
}
@@ -253,9 +337,14 @@ pub(crate) fn named_values(j: &Joint, q: &[f64]) -> Vec<Value> {
/// A joint's values as the solver places it (after motion links, so a screw's travel follows its
/// turn), named as `joint.list` gives them.
pub(crate) fn effective_values(s: &Session, id: u64) -> Option<Vec<Value>> {
effective_value_list(s, id).map(|list| list.into_iter().map(|v| serde_json::to_value(v).unwrap_or_default()).collect())
}
/// [`effective_values`], typed.
fn effective_value_list(s: &Session, id: u64) -> Option<Vec<NamedValue>> {
let j = s.doc.assembly.joints.iter().find(|j| j.id == id)?;
let vals = joints::joint_values(&s.doc.assembly);
Some(named_values(j, vals.get(&id).map(Vec::as_slice).unwrap_or(&j.values)))
Some(named_value_list(j, vals.get(&id).map(Vec::as_slice).unwrap_or(&j.values)))
}
fn occurrence_name(s: &Session, id: u64) -> String {
@@ -287,7 +376,7 @@ pub(crate) fn cannot_move(s: &Session, sol: &joints::Solution, id: u64) -> Optio
/// Joint chains joined to nothing grounded, which the solver places none of: each chain's
/// occurrences and joints, by name.
fn floating_chains(s: &Session, sol: &joints::Solution) -> Vec<Value> {
fn floating_chains(s: &Session, sol: &joints::Solution) -> Vec<FloatingChain> {
let placed = |o: u64| o == 0 || sol.world.contains_key(&o);
let errors: BTreeSet<u64> = sol.errors.iter().map(|e| e.0).collect();
let mut next: BTreeMap<u64, Vec<(u64, &Joint)>> = BTreeMap::new();
@@ -313,8 +402,10 @@ fn floating_chains(s: &Session, sol: &joints::Solution) -> Vec<Value> {
}
}
}
out.push(json!({"occurrences": occs.iter().map(|o| occurrence_name(s, *o)).collect::<Vec<_>>(),
"joints": js.iter().map(|(_, n)| *n).collect::<Vec<_>>()}));
out.push(FloatingChain {
occurrences: occs.iter().map(|o| occurrence_name(s, *o)).collect(),
joints: js.iter().map(|(_, n)| (*n).to_string()).collect(),
});
}
out
}
@@ -748,21 +839,27 @@ fn add_joint(s: &mut Session, mut j: Joint, p: &Value) -> Result<Value> {
let mut q = j.values.clone();
q.resize(j.kind.dofs().len(), 0.0);
joints::clamp(&j, &mut q);
let round = |v: &Value| v.as_f64().map(|x| (x * 1e6).round() / 1e6).unwrap_or(f64::NAN);
let warnings: Vec<String> = named_values(&j, &q)
let round = |v: f64| (v * 1e6).round() / 1e6;
// As the reply shows them: a value that is not a number reads as null, and null is null.
let same = |a: f64, b: f64| a == b || (a.is_nan() && b.is_nan());
let warnings: Vec<String> = named_value_list(&j, &q)
.iter()
.zip(named_values(&j, &j.values))
.filter(|(now, was)| now["value"] != was["value"])
.zip(named_value_list(&j, &j.values))
.filter(|(now, was)| !same(now.value, was.value))
.map(|(now, was)| {
let unit = now["unit"].as_str().unwrap_or("");
let unit = match now.unit {
ValueUnit::Deg => "deg",
ValueUnit::Mm => "mm",
};
let [lo, hi] = now.limits.unwrap_or([f64::NAN; 2]);
format!(
"{} {} starts at {} {unit}, inside its limits [{}, {}], not at {} {unit}: the part moved there as it was joined",
j.name,
now["name"].as_str().unwrap_or(""),
round(&now["value"]),
round(&now["limits"][0]),
round(&now["limits"][1]),
round(&was["value"])
now.name,
round(now.value),
round(lo),
round(hi),
round(was.value)
)
})
.collect();
@@ -776,13 +873,7 @@ fn add_joint(s: &mut Session, mut j: Joint, p: &Value) -> Result<Value> {
if sol.conflicts.iter().any(|(c, ..)| *c == id) && close_by_half_turn(s, id, &sol) {
sol = resolve(s);
}
let mut out = sol_json(&sol, p);
out["joint"] = json!(id);
out["name"] = json!(name);
if !warnings.is_empty() {
out["warnings"] = json!(warnings);
}
Ok(out)
reply("joint.create", JointAddedResult { solved: solve_report(&sol, p), joint: id, name, warnings, param_warnings: Vec::new() })
}
/// Sets of angle values a new loop-closing joint tries a half turn round, at most.
@@ -1081,41 +1172,48 @@ fn drive(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(index) = index { super::motion::drive_joint_index(s, id, q, index)? } else { super::motion::drive_joint(s, id, q, given)? }
};
let sol = solution(s, &BTreeMap::new(), response_analysis(p));
let mut out = sol_json(&sol, p);
out["joint"] = json!(id);
out["values"] = json!(effective_values(s, id));
if bool_(p, "continuous") == Some(true) {
out["continuous"] = json!(true);
}
out["stopped_by_contact"] = r["stopped_by_contact"].clone();
out["stopped_by_loop"] = r["stopped_by_loop"].clone();
let moved = super::motion::moved(&before, &super::motion::positions(s)).next().is_some();
out["moved"] = json!(moved);
// How motion stopped, as `motion::drive_with_contact` says: an object, or null.
let stop = |key: &str| r.get(key).filter(|v| v.is_object()).cloned();
let stopped_by_contact =
stop("stopped_by_contact").map(serde_json::from_value::<ContactStop>).transpose().map_err(|e| bad(cmd, e.to_string()))?;
let stopped_by_loop = stop("stopped_by_loop").map(serde_json::from_value::<LoopStop>).transpose().map_err(|e| bad(cmd, e.to_string()))?;
let mut out = DriveJointResult {
solved: solve_report(&sol, p),
joint: id,
values: effective_value_list(s, id),
continuous: (bool_(p, "continuous") == Some(true)).then_some(true),
stopped_by_contact,
stopped_by_loop,
moved: super::motion::moved(&before, &super::motion::positions(s)).next().is_some(),
requested: None,
held: None,
reason: None,
unconnected: None,
param_warnings: Vec::new(),
};
if let Some(j) = s.doc.assembly.joints.iter().find(|j| j.id == id) {
out["requested"] = json!(named_values(j, &requested));
out.requested = Some(named_value_list(j, &requested));
let held = if hold { Vec::new() } else { held_values(s, j, &requested, &driven, &r) };
if let Some(first) = held.first()
&& !moved
&& !out.moved
{
out["reason"] = first["why"].clone();
out.reason = Some(first.why.clone());
}
if !held.is_empty() {
out["held"] = json!(held);
out.held = Some(held);
}
}
if let Some((why, unconnected)) = cannot_move(s, &sol, id) {
out["reason"] = json!(why);
if let Some(o) = unconnected {
out["unconnected"] = json!(o);
}
out.reason = Some(why);
out.unconnected = unconnected;
}
Ok(out)
reply(cmd, out)
}
/// The driven values of `j` that ended up other than `requested`, each with why: a motion link
/// sets it from another coordinate (a screw's travel follows its turn), a limit holds it, or
/// contact or a closed loop stopped the motion short. Degrees and mm, as `values` (#9742).
fn held_values(s: &Session, j: &Joint, requested: &[f64], driven: &[usize], r: &Value) -> Vec<Value> {
fn held_values(s: &Session, j: &Joint, requested: &[f64], driven: &[usize], r: &Value) -> Vec<HeldValue> {
let now = joints::joint_values(&s.doc.assembly).remove(&j.id).unwrap_or_else(|| j.values.clone());
let names = j.kind.dofs();
let mut out = Vec::new();
@@ -1153,7 +1251,13 @@ fn held_values(s: &Session, j: &Joint, requested: &[f64], driven: &[usize], r: &
} else {
continue;
};
out.push(json!({"name": name, "requested": shown(want), "value": shown(got), "unit": if angle { "deg" } else { "mm" }, "why": why}));
out.push(HeldValue {
name: name.to_string(),
requested: shown(want),
value: shown(got),
unit: if angle { ValueUnit::Deg } else { ValueUnit::Mm },
why,
});
}
out
}
@@ -1416,14 +1520,19 @@ fn cam_cmd(s: &mut Session, p: &Value) -> Result<Value> {
/// A cam's follower travel over a turn of the cam: b's lowest and highest values and their
/// difference, in degrees (`angle`) or mm.
fn lift_json(l: &MotionLink, angle: bool) -> Value {
serde_json::to_value(lift_info(l, angle)).unwrap_or_default()
}
/// [`lift_json`], typed: `None` for a table that is not whole.
fn lift_info(l: &MotionLink, angle: bool) -> Option<CamLift> {
// A table that is not whole (a hand-edited file) moves nothing, and says so.
if l.lift.len() != cam::CAM_SAMPLES {
return Value::Null;
return None;
}
let unit = |v: f64| if angle { v.to_degrees() } else { v };
let (lo, hi) = l.lift.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), v| (lo.min(*v), hi.max(*v)));
let (lo, hi) = (unit(lo + l.offset), unit(hi + l.offset));
json!({"min": lo, "max": hi, "rise": hi - lo, "unit": if angle { "deg" } else { "mm" }})
Some(CamLift { min: lo, max: hi, rise: hi - lo, unit: if angle { ValueUnit::Deg } else { ValueUnit::Mm } })
}
/// A cam's profile: a sketch's closed profile (kept by its outer loop's curve ids, so the sketch's
@@ -1579,7 +1688,7 @@ fn limits(s: &mut Session, p: &Value) -> Result<Value> {
joints::clamp(j, &mut q);
j.values = q;
let sol = resolve(s);
Ok(sol_json(&sol, p))
reply(cmd, SolveResult { solved: solve_report(&sol, p), param_warnings: Vec::new() })
}
fn edit(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1705,7 +1814,10 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
if doc.assembly.origins.len() == before {
return Err(bad(cmd, format!("no joint origin `{name}`")));
}
return Ok(json!({"deleted_origin": name, "joints": used}));
return reply(
cmd,
DeleteJointResult { deleted: JointDeleted::Origin { deleted_origin: name.to_string(), joints: used }, param_warnings: Vec::new() },
);
}
if let Some(i) = num(p, "link") {
let i = i.max(0.0) as usize;
@@ -1714,72 +1826,83 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, "no such motion link"));
}
doc.assembly.links.remove(i);
return Ok(json!({"deleted_link": i}));
return reply(cmd, DeleteJointResult { deleted: JointDeleted::Link { deleted_link: i }, param_warnings: Vec::new() });
}
let id = joint_id(s, p.get("joint"), cmd)?;
delete_joints(s.doc_mut(), &[id]);
let sol = resolve(s);
let mut out = sol_json(&sol, p);
out["deleted"] = json!(id);
Ok(out)
reply(cmd, DeleteJointResult { deleted: JointDeleted::Joint { solved: solve_report(&sol, p), deleted: id }, param_warnings: Vec::new() })
}
fn list(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.model.state();
let sol = joints::solve(&s.doc, &st);
let vals = joints::joint_values(&s.doc.assembly);
let js: Vec<Value> = s
let js: Vec<ListedJoint> = s
.doc
.assembly
.joints
.iter()
.map(|j| {
let named = named_values(j, vals.get(&j.id).map(Vec::as_slice).unwrap_or_default());
let world = |o: &JointOrigin| s.doc.origin_world(&st, o).ok().map(|m| json!({"origin": [m[3][0], m[3][1], m[3][2]], "z": [m[2][0], m[2][1], m[2][2]]}));
let mut out = json!({"id": j.id, "name": j.name, "type": j.kind, "a": j.a.occurrence, "b": j.b.occurrence, "values": named,
"flip": j.flip, "offset": j.offset, "suppressed": j.suppressed, "visible": s.joint_visible(j.id), "frame_a": world(&j.a), "frame_b": world(&j.b)});
if !j.exprs.is_empty() {
out["expressions"] = json!(j.exprs);
let world = |o: &JointOrigin| {
s.doc.origin_world(&st, o).ok().map(|m| JointFrame { origin: [m[3][0], m[3][1], m[3][2]], z: [m[2][0], m[2][1], m[2][2]] })
};
ListedJoint {
id: j.id,
name: j.name.clone(),
kind: j.kind,
a: j.a.occurrence,
b: j.b.occurrence,
values: named_value_list(j, vals.get(&j.id).map(Vec::as_slice).unwrap_or_default()),
flip: j.flip,
offset: j.offset,
suppressed: j.suppressed,
visible: s.joint_visible(j.id),
frame_a: world(&j.a),
frame_b: world(&j.b),
expressions: (!j.exprs.is_empty()).then(|| j.exprs.clone()),
}
out
})
.collect();
let links: Vec<Value> = s.doc.assembly.links.iter().map(|l| link_json(s, &st, l)).collect();
let origins: Vec<Value> = s.doc.assembly.origins.iter().map(|o| json!({"name": o.name, "occurrence": o.origin.occurrence})).collect();
let mut out = sol_json(&sol, p);
out["joints"] = json!(js);
out["links"] = json!(links);
out["origins"] = json!(origins);
out["floating"] = json!(floating_chains(s, &sol));
Ok(out)
let out = ListJointsResult {
solved: solve_report(&sol, p),
joints: js,
links: s.doc.assembly.links.iter().map(|l| link_info(s, &st, l)).collect(),
origins: s.doc.assembly.origins.iter().map(|o| ListedOrigin { name: o.name.clone(), occurrence: o.origin.occurrence }).collect(),
floating: floating_chains(s, &sol),
param_warnings: Vec::new(),
};
reply("joint.list", out)
}
/// A motion link as `joint.list` and `joint.inspect` give it: ratio per degree, offset in b's
/// units, a coupling's definition (and why it can't be worked out), a cam's lift in place of a ratio.
fn link_json(s: &Session, st: &solidcraft_doc::ModelState, l: &MotionLink) -> Value {
fn link_info(s: &Session, st: &solidcraft_doc::ModelState, l: &MotionLink) -> LinkInfo {
let angle = is_angle(s, l.b, l.ib);
let offset = if angle { l.offset.to_degrees() } else { l.offset };
let ratio = l.ratio / ratio_scale(s, l.a, l.ia, l.b, l.ib);
let mut v = json!({"a": l.a, "ia": l.ia, "b": l.b, "ib": l.ib, "ratio": ratio, "offset": offset, "unit": if angle { "deg" } else { "mm" }});
if let Some(c) = &l.coupling {
v["coupling"] = json!(c);
if let Some(Err(e)) = joints::coupling_values(&s.doc, st, l) {
v["error"] = json!(e.to_string());
}
}
// A cam's follower follows its table, not a ratio: its travel instead.
if matches!(l.coupling, Some(Coupling::Cam(_)))
&& let Some(o) = v.as_object_mut()
{
o.remove("ratio");
o.insert("lift".into(), lift_json(l, angle));
let cam = matches!(l.coupling, Some(Coupling::Cam(_)));
LinkInfo {
a: l.a,
ia: l.ia,
b: l.b,
ib: l.ib,
ratio: (!cam).then_some(ratio),
offset,
unit: if angle { ValueUnit::Deg } else { ValueUnit::Mm },
coupling: l.coupling.clone(),
error: match &l.coupling {
Some(_) => joints::coupling_values(&s.doc, st, l).and_then(|r| r.err()).map(|e| e.to_string()),
None => None,
},
lift: cam.then(|| lift_info(l, angle)),
}
v
}
fn solve_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let sol = resolve(s);
Ok(sol_json(&sol, p))
reply("joint.solve", SolveResult { solved: solve_report(&sol, p), param_warnings: Vec::new() })
}
/// Index pairs `(i, j)`, `i < j`, whose boxes overlap or touch (sort and sweep along X), at most
+69 -80
View File
@@ -3,13 +3,17 @@
//! persistent name refers to), whether each origin is found today and why not, its kind, offset,
//! angle, flip, limits, values, expressions and the motion links it takes part in. Reads only.
use serde_json::{Value, json};
use serde_json::Value;
use solidcraft_doc::joints::{self, Joint, JointOrigin, Snap, XRef};
use solidcraft_doc::naming::legacy_name;
use solidcraft_doc::persistent_name::{Base, EdgeName, FaceName, Role};
use solidcraft_doc::{Document, MAX_PLACEMENT_DEPTH, ModelState};
use super::{component_of, joint_id, link_json, named_values, occurrence_name};
use super::{component_of, joint_id, link_info, named_value_list, occurrence_name, reply};
use crate::schema::joints::{
EdgeReference, FaceReference, InspectJointResult, InspectedJoint, JointLink, JointSide, LinkRole, MadeBy, Reference, ReferenceRole, Referent,
SweptFrom, WorldFrame,
};
use crate::{Result, Session};
pub(super) fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
@@ -21,7 +25,7 @@ pub(super) fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.model.state();
let sol = joints::solve(&s.doc, &st);
let vals = joints::joint_values(&s.doc.assembly);
let out: Vec<Value> = ids
let out: Vec<InspectedJoint> = ids
.iter()
.filter_map(|id| s.doc.assembly.joints.iter().find(|j| j.id == *id))
.map(|j| {
@@ -29,50 +33,42 @@ pub(super) fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
joint_json(s, &st, &sol, j, q)
})
.collect();
Ok(json!({ "joints": out }))
reply(cmd, InspectJointResult { joints: out, param_warnings: Vec::new() })
}
fn joint_json(s: &Session, st: &ModelState, sol: &joints::Solution, j: &Joint, q: &[f64]) -> Value {
let links: Vec<Value> = s
fn joint_json(s: &Session, st: &ModelState, sol: &joints::Solution, j: &Joint, q: &[f64]) -> InspectedJoint {
let links: Vec<JointLink> = s
.doc
.assembly
.links
.iter()
.enumerate()
.filter(|(_, l)| l.a == j.id || l.b == j.id)
.map(|(i, l)| {
let mut v = link_json(s, st, l);
v["index"] = json!(i);
v["role"] = json!(if l.b == j.id { "driven" } else { "driver" });
v
})
.map(|(i, l)| JointLink { index: i, role: if l.b == j.id { LinkRole::Driven } else { LinkRole::Driver }, link: link_info(s, st, l) })
.collect();
let why = |list: &[(u64, String, String)]| list.iter().filter(|(id, ..)| *id == j.id).map(|(.., why)| why.clone()).collect::<Vec<_>>();
let mut v = json!({
"id": j.id,
"name": j.name,
"type": j.kind,
"suppressed": j.suppressed,
"visible": s.joint_visible(j.id),
"flip": j.flip,
"offset": j.offset,
"angle": j.angle.to_degrees(),
"values": named_values(j, q),
"dofs": j.kind.dofs(),
"a": side_json(s, st, &j.a),
"b": side_json(s, st, &j.b),
"links": links,
"errors": why(&sol.errors),
"conflicts": why(&sol.conflicts),
});
if !j.exprs.is_empty() {
v["expressions"] = json!(j.exprs);
InspectedJoint {
id: j.id,
name: j.name.clone(),
kind: j.kind,
suppressed: j.suppressed,
visible: s.joint_visible(j.id),
flip: j.flip,
offset: j.offset,
angle: j.angle.to_degrees(),
values: named_value_list(j, q),
dofs: j.kind.dofs().iter().map(|d| (*d).to_string()).collect(),
a: side_json(s, st, &j.a),
b: side_json(s, st, &j.b),
links,
errors: why(&sol.errors),
conflicts: why(&sol.conflicts),
expressions: (!j.exprs.is_empty()).then(|| j.exprs.clone()),
}
v
}
/// One of a joint's origins: what it sits on, what it refers to, and where it is now.
fn side_json(s: &Session, st: &ModelState, o: &JointOrigin) -> Value {
fn side_json(s: &Session, st: &ModelState, o: &JointOrigin) -> JointSide {
let component = component_of(s, o.occurrence);
let component_name = if component == 0 {
"root".to_string()
@@ -80,24 +76,24 @@ fn side_json(s: &Session, st: &ModelState, o: &JointOrigin) -> Value {
s.doc.components.iter().find(|c| c.id == component).map(|c| c.name.clone()).unwrap_or_default()
};
let occurrence_name = if o.occurrence == 0 { "root".to_string() } else { occurrence_name(s, o.occurrence) };
let mut v = json!({
"occurrence": o.occurrence,
"occurrence_name": occurrence_name,
"component": component,
"component_name": component_name,
"path": occurrence_path(&s.doc, o.occurrence),
"snap": o.snap,
"references": references(&s.doc, &o.snap),
});
let mut v = JointSide {
occurrence: o.occurrence,
occurrence_name,
component,
component_name,
path: occurrence_path(&s.doc, o.occurrence),
snap: o.snap.clone(),
references: references(&s.doc, &o.snap),
resolves: false,
frame: None,
error: None,
};
match s.doc.origin_world(st, o) {
Ok(m) => {
v["resolves"] = json!(true);
v["frame"] = json!({"origin": [m[3][0], m[3][1], m[3][2]], "z": [m[2][0], m[2][1], m[2][2]], "x": [m[0][0], m[0][1], m[0][2]]});
}
Err(e) => {
v["resolves"] = json!(false);
v["error"] = json!(e.to_string());
v.resolves = true;
v.frame = Some(WorldFrame { origin: [m[3][0], m[3][1], m[3][2]], z: [m[2][0], m[2][1], m[2][2]], x: [m[0][0], m[0][1], m[0][2]] });
}
Err(e) => v.error = Some(e.to_string()),
}
v
}
@@ -118,36 +114,36 @@ fn occurrence_path(doc: &Document, occurrence: u64) -> String {
}
/// Every persistent name the snap holds, with what it refers to.
fn references(doc: &Document, snap: &Snap) -> Vec<Value> {
fn references(doc: &Document, snap: &Snap) -> Vec<Reference> {
let mut out = Vec::new();
let mut face = |role: &str, name: &Option<String>| {
let mut face = |role: ReferenceRole, name: &Option<String>| {
if let Some(n) = name {
out.push(with_role(role, face_ref(doc, n)));
out.push(Reference { role, to: Referent::Face(face_ref(doc, n)) });
}
};
match snap {
Snap::FaceCenter { face: f, x, .. } => {
face("face", f);
face(ReferenceRole::Face, f);
if let Some(XRef::Face { face: n, .. }) = x {
face("x", &Some(n.clone()));
face(ReferenceRole::X, &Some(n.clone()));
}
}
Snap::CircleCenter { face: f, end, x, .. } => {
face("face", f);
face("end", end);
face(ReferenceRole::Face, f);
face(ReferenceRole::End, end);
if let Some(XRef::Face { face: n, .. }) = x {
face("x", &Some(n.clone()));
face(ReferenceRole::X, &Some(n.clone()));
}
}
Snap::Edge { edge, face: f, corner, .. } => {
if let Some(e) = edge {
out.push(with_role("edge", edge_ref(doc, e)));
out.push(Reference { role: ReferenceRole::Edge, to: Referent::Edge(edge_ref(doc, e)) });
}
if let Some(n) = f {
out.push(with_role("face", face_ref(doc, n)));
out.push(Reference { role: ReferenceRole::Face, to: Referent::Face(face_ref(doc, n)) });
}
if let Some(e) = corner {
out.push(with_role("corner", edge_ref(doc, e)));
out.push(Reference { role: ReferenceRole::Corner, to: Referent::Edge(edge_ref(doc, e)) });
}
}
Snap::Frame { .. } | Snap::Point { .. } => {}
@@ -155,19 +151,14 @@ fn references(doc: &Document, snap: &Snap) -> Vec<Value> {
out
}
fn with_role(role: &str, mut v: Value) -> Value {
v["role"] = json!(role);
v
}
/// What a face's persistent name refers to: `F<feature>:<role>` names the feature that made the
/// face, and a side `side:<curve>` (or a later profile's `side:s<k>.<curve>`) the sketch curve
/// it was swept from. Names of another form are given as they are.
fn face_ref(doc: &Document, name: &str) -> Value {
let mut v = json!({ "name": name });
fn face_ref(doc: &Document, name: &str) -> FaceReference {
let mut v = FaceReference { name: name.to_string(), unverified: None, role_in_feature: None, feature: None, sketch: None };
let bare = match legacy_name(name) {
Some(n) => {
v["unverified"] = json!(true);
v.unverified = Some(true);
n
}
None => name,
@@ -175,38 +166,36 @@ fn face_ref(doc: &Document, name: &str) -> Value {
let Ok(face) = FaceName::parse(bare) else { return v };
let Some(fid) = face.made_by().map(|id| id.0) else { return v };
let stem = face.stem().to_string();
v["role_in_feature"] = json!(stem.strip_prefix(&format!("F{fid}:")).unwrap_or(&stem));
v.role_in_feature = Some(stem.strip_prefix(&format!("F{fid}:")).unwrap_or(&stem).to_string());
let Some(f) = doc.feature(fid) else {
v["feature"] = json!({"id": fid, "error": format!("feature {fid} is gone")});
v.feature = Some(MadeBy::Gone { id: fid, error: format!("feature {fid} is gone") });
return v;
};
v["feature"] = json!({"id": f.id, "name": f.name, "type": f.kind.type_name()});
v.feature = Some(MadeBy::Feature { id: f.id, name: f.name.clone(), kind: f.kind.type_name().to_string() });
// A side swept from a sketch curve (a later profile's too, `side:s<k>.<curve>`).
if let Base::Made { role: Role::Side { curve, .. }, .. } = &face.base {
let found = f.kind.input_sketches().into_iter().find_map(|id| {
// design-only: a joint names a curve of one of the design's sketches.
let sk = doc.sketch(id).ok()?;
let c = sk.curves.iter().find(|c| c.id == *curve)?;
let curve_type = serde_json::to_value(&c.kind).ok().and_then(|k| k.get("type").cloned()).unwrap_or(Value::Null);
let curve_type = serde_json::to_value(&c.kind).ok().and_then(|k| k.get("type").and_then(Value::as_str).map(str::to_string));
let name = doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
Some(json!({"id": id, "name": name, "curve": curve, "curve_type": curve_type}))
Some(SweptFrom::Curve { id, name, curve: curve.clone(), curve_type })
});
v["sketch"] = found.unwrap_or_else(|| json!({"curve": curve, "error": format!("no sketch of {} has a curve `{curve}`", f.name)}));
v.sketch = Some(
found.unwrap_or_else(|| SweptFrom::Missing { curve: curve.clone(), error: format!("no sketch of {} has a curve `{curve}`", f.name) }),
);
}
v
}
/// An edge's persistent name (`<face A>|<face B>`, `~k` for one of several between them) with
/// what each of its faces refers to.
fn edge_ref(doc: &Document, name: &str) -> Value {
fn edge_ref(doc: &Document, name: &str) -> EdgeReference {
let bare = legacy_name(name).unwrap_or(name);
let faces: Vec<Value> = match EdgeName::parse(bare) {
let faces: Vec<FaceReference> = match EdgeName::parse(bare) {
Ok(e) => e.faces.iter().map(|f| face_ref(doc, &f.to_string())).collect(),
Err(_) => Vec::new(),
};
let mut v = json!({"name": name, "faces": faces});
if legacy_name(name).is_some() {
v["unverified"] = json!(true);
}
v
EdgeReference { name: name.to_string(), faces, unverified: legacy_name(name).is_some().then_some(true) }
}
+3 -3
View File
@@ -159,9 +159,9 @@ fn material_reply(cmd: &str, m: &UserMaterial) -> Result<MaterialReply> {
Ok(out)
}
/// [`material_reply`] as JSON, for `material.list` (`cmd/inspect.rs`), which isn't converted yet.
pub(super) fn material_json(m: &UserMaterial) -> Result<Value> {
reply("material.list", material_reply("material.list", m)?)
/// [`material_reply`] for `material.list` (`cmd/inspect.rs`).
pub(super) fn listed_material(m: &UserMaterial) -> Result<MaterialReply> {
material_reply("material.list", m)
}
fn reply(cmd: &str, reply: impl serde::Serialize) -> Result<Value> {
+138 -3
View File
@@ -3,26 +3,161 @@
//! A curved face's area is the area of the surface it lies on: a cylinder's band is
//! r·θ·h and a whole sphere 4πr². The display mesh's triangles run inside a curve, so
//! their sum falls short of it.
//!
//! A flat face's area is the area its exact edges enclose (#12372): its outer loop's area
//! less its holes'. The display mesh's polygon for a round hole runs inside the circle, so
//! the mesh's holes are too small and the face reads too big.
use std::f64::consts::PI;
use solidcore_record::Vec3;
use solidcore_record::brep::Curve;
use solidcore_record::brep::{Curve, Loop};
use solidcraft_geom::prelude::*;
use solidcraft_geom::{Mesh, UnitVec3};
use solidcraft_kernel::{Body, FaceSurface};
/// The area of the patch of faces `patch` of `body`, worked out on the surface behind
/// them: a cylinder's band, or a sphere. `None` where the surface does not give one, and
/// the caller keeps the measure of the mesh.
/// them: a flat patch, a cylinder's band, or a sphere. `None` where the surface does not
/// give one, and the caller keeps the measure of the mesh.
pub(crate) fn patch_area(body: &Body, mesh: &Mesh, patch: &[usize]) -> Option<f64> {
match solidcraft_kernel::face_surface(body, *patch.first()?)? {
FaceSurface::Plane { normal, point } => flat_patch(body, patch, normal, point),
FaceSurface::Cylinder { origin, axis, radius } => cylinder_band(body, mesh, patch, origin, axis, radius),
FaceSurface::Sphere { radius, .. } => sphere(body, patch, radius),
_ => None,
}
}
/// Two orthonormal directions in a plane and a point on it: the plane's own 2D coordinates.
struct PlaneFrame {
origin: Vec3,
normal: Vec3,
x: Vec3,
y: Vec3,
}
impl PlaneFrame {
fn new(normal: UnitVec3, origin: Vec3) -> Option<PlaneFrame> {
let n = normal.vec();
// The world axis least along the normal gives a direction across it.
let across = if n.x.abs() <= n.y.abs() && n.x.abs() <= n.z.abs() {
Vec3::X
} else if n.y.abs() <= n.z.abs() {
Vec3::Y
} else {
Vec3::Z
};
let x = n.cross(across).normalized()?;
Some(PlaneFrame { origin, normal: n, x, y: n.cross(x) })
}
/// The 2D coordinates of point `p`.
fn point(&self, p: Vec3) -> (f64, f64) {
let d = p - self.origin;
(d.dot(self.x), d.dot(self.y))
}
/// The 2D components of direction `d`.
fn dir(&self, d: Vec3) -> (f64, f64) {
(d.dot(self.x), d.dot(self.y))
}
}
/// The area of a flat patch: every face of it lies on one plane, and each face's area is the
/// area its loops of exact edges enclose.
fn flat_patch(body: &Body, patch: &[usize], normal: UnitVec3, point: Vec3) -> Option<f64> {
let tol = (body.size() * 1e-9).max(1e-9);
let frame = PlaneFrame::new(normal, point)?;
let mut total = 0.0;
for &face in patch {
match solidcraft_kernel::face_surface(body, face)? {
FaceSurface::Plane { normal: n, point: p }
if n.vec().dot(frame.normal).abs() >= 1.0 - 1e-9 && (p - point).dot(frame.normal).abs() <= tol => {}
_ => return None,
}
total += flat_face(body, face, &frame, tol)?;
}
(total.is_finite() && total > 0.0).then_some(total)
}
/// The area of flat face `face`: the area its outer loop encloses, less the areas its holes
/// enclose. The outer loop is the one that encloses the most: every hole lies inside it.
fn flat_face(body: &Body, face: usize, frame: &PlaneFrame, tol: f64) -> Option<f64> {
let mut loops: Vec<f64> = Vec::new();
for l in &body.record().faces.get(face)?.loops {
let Loop::Edges { edges } = l else { continue };
let mut enclosed = 0.0;
for used in edges {
enclosed += edge_sweep(body, used.edge as usize, used.forward, frame, tol)?;
}
loops.push(enclosed.abs());
}
let outer = loops.iter().copied().fold(0.0, f64::max);
let area = outer - (loops.iter().sum::<f64>() - outer);
(area.is_finite() && area > 0.0).then_some(area)
}
/// Four-point Gauss–Legendre nodes and weights on `[−1, 1]`.
const GAUSS4: [(f64, f64); 4] = [
(-0.861_136_311_594_052_6, 0.347_854_845_137_453_8),
(-0.339_981_043_584_856_3, 0.652_145_154_862_546_2),
(0.339_981_043_584_856_3, 0.652_145_154_862_546_2),
(0.861_136_311_594_052_6, 0.347_854_845_137_453_8),
];
/// Gauss panels along an edge that is neither a line nor a circle.
const SWEEP_PANELS: usize = 64;
/// The signed area that edge `edge` sweeps about the frame's origin, `½∫(x dy − y dx)`, run
/// along the loop's direction (`forward`: from the edge's start to its end). Summed round a
/// loop, it is the area the loop encloses (by Green's theorem). A circle or an arc of one is
/// worked out in closed form: the triangle to its chord and the circular segment beyond it.
/// `None` for an edge off the plane, or one whose curve gives no derivative.
fn edge_sweep(body: &Body, edge: usize, forward: bool, frame: &PlaneFrame, tol: f64) -> Option<f64> {
let triangle = |a: Vec3, b: Vec3| {
let ((ax, ay), (bx, by)) = (frame.point(a), frame.point(b));
0.5 * (ax * by - bx * ay)
};
if let Some(c) = solidcraft_kernel::edge_circle(body, edge) {
let along = c.axis.vec().dot(frame.normal);
if along.abs() < 1.0 - 1e-9 || (c.center - frame.origin).dot(frame.normal).abs() > tol {
return None;
}
// The arc runs counter-clockwise about its axis from its start to its end.
let (a, b, turn) = if forward { (c.start, c.end, along.signum()) } else { (c.end, c.start, -along.signum()) };
let segment = 0.5 * c.radius * c.radius * (c.angle - c.angle.sin());
return Some(triangle(a, b) + turn * segment);
}
let e = body.record().edges.get(edge)?;
if matches!(e.curve, Curve::Line { .. }) {
let c = solidcraft_geom::brep::eval::CurveEval::new(e.parameters()).ok()?;
let [t0, t1] = e.interval;
let (a, b) = (c.point(t0)?, c.point(t1)?);
let off = |p: Vec3| (p - frame.origin).dot(frame.normal).abs() > tol.max(e.tolerance);
if off(a) || off(b) {
return None;
}
return Some(if forward { triangle(a, b) } else { triangle(b, a) });
}
let c = solidcraft_geom::brep::eval::CurveEval::new(e.parameters()).ok()?;
let [t0, t1] = e.interval;
let h = (t1 - t0) / SWEEP_PANELS as f64;
let mut swept = 0.0;
for panel in 0..SWEEP_PANELS {
let start = t0 + h * panel as f64;
for (x, w) in GAUSS4 {
let (p, d) = c.derivs(start + 0.5 * h * (x + 1.0))?;
if (p - frame.origin).dot(frame.normal).abs() > tol.max(e.tolerance) {
return None;
}
let ((px, py), (dx, dy)) = (frame.point(p), frame.dir(d));
swept += 0.5 * h * w * 0.5 * (px * dy - py * dx);
}
}
let swept = if forward { swept } else { -swept };
swept.is_finite().then_some(swept)
}
/// The edges on the border of the patch: the ones a face of the patch shares with a face
/// outside it, and the ones it keeps to itself.
fn border_edges(mesh: &Mesh, patch: &[usize]) -> Vec<usize> {
+49 -45
View File
@@ -11,6 +11,7 @@ use solidcraft_geom::{Mesh, UnitVec3};
use super::measure_shape::{Shape, Slot, add_round, centre};
use crate::params::bad;
use crate::schema::inspect::{MeasureKind, MeasureResult, Measured, MeasuredItems};
use crate::{Result, Sel, Session};
/// Geometry of a picked item, as points or triangles.
@@ -351,8 +352,9 @@ fn describe(s: &Session, st: &solidcraft_doc::ModelState, sel: &Sel) -> Option<I
let patch = m.surface_patch(*index);
let tris = patch.iter().flat_map(|f| face_tris(&m, *f)).collect::<Vec<_>>();
let on_mesh: f64 = tris.iter().map(|[a, b, c]| (*b - *a).cross(*c - *a).len() * 0.5).sum();
// A curved face is measured on the surface it lies on, not on the triangles of
// the display mesh, which run inside the curve.
// A face is measured on the surface it lies on and the exact edges around it, not
// on the triangles of the display mesh, which run inside every curve: a curved
// face's and a round hole's alike.
let area = super::measure_exact::patch_area(&b.body, &m, &patch).unwrap_or(on_mesh);
let normals: Vec<UnitVec3> = tris.iter().filter_map(|[a, b, c]| (*b - *a).cross(*c - *a).unit()).collect();
let planar = normals.first().copied().filter(|n0| normals.iter().all(|n| n.dot(*n0) > 1.0 - 1e-6));
@@ -456,8 +458,10 @@ fn axis_named(s: &Session, name: &str) -> Option<(Vec3, Vec3)> {
}
/// The closest points of two exact shapes, where the sampled points would be wrong: a plane or
/// an axis runs on, so a point set of it is not the object.
fn exact_min(a: &Shape, b: &Shape) -> Option<(f64, Vec3, Vec3)> {
/// an axis runs on, so a point set of it is not the object. `anchor` is a point of `a` to
/// measure from where any point would do (two parallel planes); `unbounded` says whether `a`
/// and `b` run on for ever (datum planes and axes) rather than ending (faces and edges).
fn exact_min(a: &Shape, b: &Shape, anchor: Option<Vec3>, unbounded: (bool, bool)) -> Option<(f64, Vec3, Vec3)> {
use Shape::*;
let to_plane = |p: Vec3, point: Vec3, normal: UnitVec3| {
let n = normal.vec();
@@ -483,17 +487,7 @@ fn exact_min(a: &Shape, b: &Shape) -> Option<(f64, Vec3, Vec3)> {
}
(Point(p), Point(q)) => Some((p.dist(*q), *p, *q)),
(Plane { point: p1, normal: n1 }, Plane { point: p2, normal: n2 }) => {
let (v1, v2) = (n1.vec(), n2.vec());
if v1.cross(v2).len() < 1e-9 {
let h = (*p2 - *p1).dot(v1);
Some((h.abs(), *p1, *p2 - v1 * h))
} else {
// They meet: a point on the line of intersection has distance zero.
let dir = v1.cross(v2);
let t = -(*p1 - *p2).dot(v2) / dir.dot(v2);
let p = *p1 + dir * t;
Some((0.0, p, p))
}
super::measure_shape::plane_plane(*p1, *n1, *p2, *n2, anchor.unwrap_or(*p1), unbounded.0 && unbounded.1)
}
(Plane { point, normal }, Line { a: la, b: lb }) => {
let d = *lb - *la;
@@ -672,16 +666,20 @@ pub(crate) fn measure_items(s: &Session, items: &Value) -> Result<Value> {
.ok_or_else(|| bad(cmd, "an item can't be measured (pick vertices, edges, faces, bodies, sketch curves or profiles)"))?,
);
}
let mut out = json!({"items": described.iter().map(|d| d.json.clone()).collect::<Vec<_>>()});
let mut out =
MeasuredItems { items: described.iter().map(|d| d.json.as_object().cloned().unwrap_or_default()).collect(), ..MeasuredItems::default() };
if let [one] = described.as_slice() {
for k in ["diameter", "radius", "circumference", "angle_deg", "arc_length", "center", "slot"] {
if let Some(v) = one.json.get(k) {
out[k] = v.clone();
}
}
let copy = |k: &str| one.json.get(k).cloned();
out.diameter = copy("diameter");
out.radius = copy("radius");
out.circumference = copy("circumference");
out.angle_deg = copy("angle_deg");
out.arc_length = copy("arc_length");
out.center = copy("center");
out.slot = copy("slot");
if let Some(sl) = &one.slot {
out["center_from"] = json!(sl.centres.0);
out["center_to"] = json!(sl.centres.1);
out.center_from = Some(sl.centres.0.into());
out.center_to = Some(sl.centres.1.into());
}
}
if let [a, b] = described.as_slice() {
@@ -705,39 +703,45 @@ pub(crate) fn measure_items(s: &Session, items: &Value) -> Result<Value> {
max = Some(exact);
}
if let (Some((p, q)), Some(d)) = (near, min) {
out["distance_mm"] = json!(d);
out["from"] = json!(p);
out["to"] = json!(q);
out["delta"] = json!(q - p);
out["min"] = json!(d);
out.set_closest(d, p, q);
}
if let Some((d, p, q)) = exact_min(&a.shape, &b.shape) {
out["distance_mm"] = json!(d);
out["from"] = json!(p);
out["to"] = json!(q);
out["delta"] = json!(q - p);
out["min"] = json!(d);
let unbounded = |i: usize| matches!(sels.get(i), Some(Sel::Plane { .. } | Sel::Axis { .. }));
if let Some((d, p, q)) = exact_min(&a.shape, &b.shape, near.map(|(p, _)| p), (unbounded(0), unbounded(1)))
.filter(|(d, p, q)| d.is_finite() && p.is_finite() && q.is_finite())
{
out.set_closest(d, p, q);
}
if let (Some((p, q)), Some(d)) = (far, max) {
out["max"] = json!(d);
out["max_from"] = json!(p);
out["max_to"] = json!(q);
out["max_delta"] = json!(q - p);
out.max = Some(d);
out.max_from = Some(p.into());
out.max_to = Some(q.into());
out.max_delta = Some((q - p).into());
}
if let Some(c) = c {
out["center_distance"] = json!(c.distance);
out["center_from"] = json!(c.from);
out["center_to"] = json!(c.to);
out["center_delta"] = json!(c.to - c.from);
out.center_distance = Some(c.distance);
out.center_from = Some(c.from.into());
out.center_to = Some(c.to.into());
out.center_delta = Some((c.to - c.from).into());
}
out["measure"] = json!(if c.is_some() { "center" } else { "min" });
out.measure = Some(if c.is_some() { MeasureKind::Center } else { MeasureKind::Min });
if let (Some(da), Some(db)) = (a.dir, b.dir) {
let both_faces = matches!(sels.as_slice(), [Sel::Face { .. }, Sel::Face { .. }]);
let cos = if both_faces { da.dot(db) } else { corner_cos(a.shape, b.shape).unwrap_or_else(|| da.dot(db).abs()) };
out["angle_deg"] = json!(cos.clamp(-1.0, 1.0).acos().to_degrees());
out.angle_deg = Some(json!(cos.clamp(-1.0, 1.0).acos().to_degrees()));
}
}
Ok(out)
serde_json::to_value(MeasureResult { measured: Measured::Items(Box::new(out)), param_warnings: Vec::new() }).map_err(|e| bad(cmd, e.to_string()))
}
impl MeasuredItems {
/// The closest distance between two items and where it is: `distance_mm` and `min` are one.
fn set_closest(&mut self, d: f64, p: Vec3, q: Vec3) {
self.distance_mm = Some(d);
self.min = Some(d);
self.from = Some(p.into());
self.to = Some(q.into());
self.delta = Some((q - p).into());
}
}
/// Farthest pair of points between two pieces of geometry (on their samples and vertices).
+64
View File
@@ -271,6 +271,32 @@ fn from_round(a: &Shape, b: &Shape) -> Option<Centre> {
(c.distance.is_finite() && c.from.is_finite() && c.to.is_finite()).then_some(c)
}
/// The distance between two planes, `from` on the first and `to` on the second.
///
/// Parallel planes (normals within 1e-6 rad, as [`parallel`] counts axes) are the
/// perpendicular gap between them (#11833), measured from `anchor` (a point of the first item,
/// so the dimension is drawn on it) moved onto the first plane. Planes at an angle meet: only
/// when both run on for ever (datum planes, `meet`) is that a distance of zero on their line of
/// intersection; a face ends, so its distance is left to its own points (`None`).
pub(crate) fn plane_plane(p1: Vec3, n1: UnitVec3, p2: Vec3, n2: UnitVec3, anchor: Vec3, meet: bool) -> Option<(f64, Vec3, Vec3)> {
let (v1, v2) = (n1.vec(), n2.vec());
let across = v1.cross(v2);
if across.len() < 1e-6 {
let from = anchor - v1 * (anchor - p1).dot(v1);
let h = (from - p2).dot(v1);
return Some((h.abs(), from, from - v1 * h));
}
if !meet {
return None;
}
// The point of both planes nearest the first plane's point: from `p1` across the first
// plane, square to the line `n1 × n2`, until it reaches the second plane. Worked from the
// cross product, not `1 - (n1·n2)²`, which cancels when the planes are nearly parallel.
let w = across.cross(v1);
let p = p1 + w * ((p2 - p1).dot(v2) / across.dot(across));
p.is_finite().then_some((0.0, p, p))
}
/// A slot's sizes: width (the ends' diameter), the distance between the ends' centres and the
/// overall length, with the two centres.
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -504,4 +530,42 @@ mod tests {
assert!(slot_outline(&arcs, &skew).is_none());
assert!(slot_outline(&[(Vec2::new(0.0, 0.0), 3.0, PI), (Vec2::new(20.0, 0.0), 4.0, PI)], &lines).is_none());
}
/// Two walls of a box whose normals are parallel only to rounding (#11833): the gap between
/// the planes, from the picked point on the first to its foot on the second, never NaN.
#[test]
fn nearly_parallel_planes_measure_the_gap_between_them() {
let tilt = UnitVec3::new(Vec3::new(1e-8, 0.0, 1.0)).unwrap();
for (n2, gap) in [(UnitVec3::Z, 20.0), (-UnitVec3::Z, 20.0), (tilt, 20.0)] {
let anchor = Vec3::new(7.0, 3.0, 0.5);
let (d, from, to) = plane_plane(Vec3::new(100.0, -50.0, 0.0), UnitVec3::Z, Vec3::new(-4.0, 9.0, gap), n2, anchor, false).unwrap();
assert!((d - gap).abs() < 1e-6, "{n2:?}: {d}");
assert!(from.is_finite() && to.is_finite());
assert!(from.dist(Vec3::new(7.0, 3.0, 0.0)) < 1e-12, "from lies on the first plane under the pick: {from:?}");
assert!((to.z - gap).abs() < 1e-6 && (to - from).cross(Vec3::Z).len() < 1e-6, "to lies on the second plane: {to:?}");
}
}
/// Planes at an angle: datum planes meet (zero, on both planes); faces end, so their
/// distance is left to their points.
#[test]
fn planes_at_an_angle_meet_only_when_they_run_on() {
let (p1, p2) = (Vec3::new(0.0, 0.0, 5.0), Vec3::new(3.0, 0.0, 0.0));
let n2 = UnitVec3::new(Vec3::new(1.0, 0.0, 1.0)).unwrap();
let (d, from, to) = plane_plane(p1, UnitVec3::Z, p2, n2, p1, true).unwrap();
assert_eq!((d, from), (0.0, to));
assert!(from.is_finite() && (from.z - 5.0).abs() < 1e-12 && (from - p2).dot(n2.vec()).abs() < 1e-12, "{from:?}");
assert!(plane_plane(p1, UnitVec3::Z, p2, n2, p1, false).is_none());
assert!(plane_plane(p1, UnitVec3::Z, p2, UnitVec3::X, p1, true).is_some_and(|(_, p, _)| p.is_finite()));
// Just past parallel and far from the origin: the point still lies on both planes.
for (tilt, z1, z2) in [(2e-6, 0.0, 20.0), (2e-6, 100.0, 120.0), (1.01e-6, 1e5, 100_020.0)] {
let (p1, p2) = (Vec3::new(3.0, -4.0, z1), Vec3::new(-7.0, 2.0, z2));
let n2 = UnitVec3::new(Vec3::new(tilt, 0.0, 1.0)).unwrap();
let (d, p, _) = plane_plane(p1, UnitVec3::Z, p2, n2, p1, true).unwrap();
let scale = 1.0 + p.len();
assert_eq!(d, 0.0);
assert!((p.z - z1).abs() < 1e-9 * scale, "tilt {tilt}: {p:?} off the first plane");
assert!((p - p2).dot(n2.vec()).abs() < 1e-9 * scale, "tilt {tilt}: {p:?} off the second plane");
}
}
}
+5 -1
View File
@@ -85,8 +85,10 @@ mod point_cloud;
mod script;
mod section;
pub mod settings;
pub(crate) use clipboard::{component_path_form, copy_keys};
pub(crate) use measure_sel::{measure_items, shape_of};
pub(crate) use measure_shape::Shape;
pub(crate) use script::step_params;
#[cfg(test)]
mod kernel_heal_tests;
mod panels;
@@ -110,7 +112,7 @@ mod sketch;
pub(crate) use sketch::author_path_for_rebind;
pub(crate) use sketch::collapse_reason;
pub(crate) use sketch::created as sketch_created;
pub(crate) use sketch::{PLANE_REF_FORMS, custom_plane_form};
pub(crate) use sketch::{PLANE_REF_FORMS, custom_plane_form, point_arg_form};
mod canvas_sketch;
mod sketch_3d;
mod sketch_canvas;
@@ -118,6 +120,7 @@ pub mod sketch_clipboard;
mod sketch_constraints;
mod sketch_construction;
mod sketch_create;
pub(crate) use sketch_create::{AT_MUST, MAX_TEXT_EDITS};
mod sketch_divide;
mod sketch_ends;
mod sketch_equation;
@@ -126,6 +129,7 @@ mod sketch_import;
mod sketch_inspect;
mod sketch_modify;
mod sketch_project;
pub(crate) use sketch_project::{RefForm, ref_form};
mod sketch_store;
mod surface;
mod surface_analysis;
+38 -14
View File
@@ -16,8 +16,10 @@ use super::joints::{cannot_move, effective_values, joint_id, occurrence_id, plac
use crate::params::{bad, bool_, str_, vec3};
use crate::schema::motion::{
ContactCheck, ContactCollision, ContactCreateParams, ContactEditParams, ContactEnableParams, ContactNoParams, ContactSetChange,
ContactSetCreated, ContactSetEdited, ContactSetInfo, ContactSets, ContactSwitch, FindLimitsParams, FindLimitsResult, MotionListParams,
MotionStudies, MotionStudyDeleteParams, MotionStudyDeleted, MotionStudyInfo, MotionStudyKeyInfo, MotionStudyParams, MotionStudySaved,
ContactSetCreated, ContactSetEdited, ContactSetInfo, ContactSets, ContactSwitch, ExplodeCreateParams, ExplodeCreated, ExplodeDeleteParams,
ExplodeDeleted, ExplodeListParams, ExplodeMoveInfo, ExplodeShowParams, ExplodeShown, ExplodeViews, ExplodedViewInfo, FindLimitsParams,
FindLimitsResult, MotionListParams, MotionStudies, MotionStudyDeleteParams, MotionStudyDeleted, MotionStudyInfo, MotionStudyKeyInfo,
MotionStudyParams, MotionStudySaved,
};
use crate::schema::motion_play::{
DriveReport, ExportParams, PartPose, PlayParams, Pose, StudyExport, StudyExported, StudyPlayed, StudySample, UnmovedJoint,
@@ -53,6 +55,10 @@ const CONTACT_EDIT_CONTRACT: TypedContract<ContactEditParams> = TypedContract::n
const STUDY_CONTRACT: TypedContract<MotionStudyParams> = TypedContract::new();
const STUDY_DELETE_CONTRACT: TypedContract<MotionStudyDeleteParams> = TypedContract::new();
const STUDY_LIST_CONTRACT: TypedContract<MotionListParams> = TypedContract::new();
const EXPLODE_CREATE_CONTRACT: TypedContract<ExplodeCreateParams> = TypedContract::new();
const EXPLODE_SHOW_CONTRACT: TypedContract<ExplodeShowParams> = TypedContract::new();
const EXPLODE_LIST_CONTRACT: TypedContract<ExplodeListParams> = TypedContract::new();
const EXPLODE_DELETE_CONTRACT: TypedContract<ExplodeDeleteParams> = TypedContract::new();
const PLAY_CONTRACT: TypedContract<PlayParams> = TypedContract::new();
const EXPORT_CONTRACT: TypedContract<ExportParams> = TypedContract::new();
@@ -175,16 +181,32 @@ pub static COMMANDS: &[CommandSpec] = &[
.contract(&STUDY_DELETE_CONTRACT)
.output(output_schema_for::<MotionStudyDeleted>)
.params("study"),
CommandSpec::new("explode.create", "Exploded View", explode_create).at("SOLID", "ASSEMBLE").icon("explode").enabled(has_parts_to_explode).params(
"name?; scale? (automatic: each occurrence pushed away from the assembly's centre by scale × its offset, default 1); \
moves?: [{occurrence, translate: [x,y,z]}] (instead of automatic)",
),
CommandSpec::new("explode.show", "Show Exploded View", explode_show)
CommandSpec::new("explode.create", "Exploded View", crate::checked!("explode.create", explode_create))
.at("SOLID", "ASSEMBLE")
.icon("explode")
.enabled(has_parts_to_explode)
.contract(&EXPLODE_CREATE_CONTRACT)
.output(output_schema_for::<ExplodeCreated>)
.params(
"name?; scale? (automatic: each occurrence pushed away from the assembly's centre by scale × its offset, default 1); \
moves?: [{occurrence, translate: [x,y,z]}] (instead of automatic)",
),
CommandSpec::new("explode.show", "Show Exploded View", crate::checked!("explode.show", explode_show))
.icon("explode")
.noundo()
.contract(&EXPLODE_SHOW_CONTRACT)
.output(output_schema_for::<ExplodeShown>)
.params("view: id|name (or none: back to the assembled placement) — shown only; the design keeps its placements"),
CommandSpec::new("explode.list", "List Exploded Views", explode_list).icon("list").noundo(),
CommandSpec::new("explode.delete", "Delete Exploded View", explode_delete).icon("delete").params("view"),
CommandSpec::new("explode.list", "List Exploded Views", crate::checked!("explode.list", explode_list))
.icon("list")
.noundo()
.contract(&EXPLODE_LIST_CONTRACT)
.output(output_schema_for::<ExplodeViews>),
CommandSpec::new("explode.delete", "Delete Exploded View", crate::checked!("explode.delete", explode_delete))
.icon("delete")
.contract(&EXPLODE_DELETE_CONTRACT)
.output(output_schema_for::<ExplodeDeleted>)
.params("view"),
];
const MAX_ITEMS: usize = 10_000;
@@ -1233,7 +1255,8 @@ fn explode_create(s: &mut Session, p: &Value) -> Result<Value> {
let name =
str_(p, "name").map(str::to_string).unwrap_or_else(|| free_name("Exploded View", s.doc.assembly.exploded.iter().map(|v| v.name.as_str())));
s.doc_mut().assembly.exploded.push(ExplodedView { id, name: name.clone(), moves: moves.clone() });
Ok(json!({"view": id, "name": name, "moves": moves.iter().map(|(o, t)| json!({"occurrence": o, "translate": t})).collect::<Vec<_>>()}))
let moves = moves.iter().map(ExplodeMoveInfo::from).collect();
typed_reply(cmd, ExplodeCreated { view: id, name, moves, param_warnings: Vec::new() })
}
fn explode_show(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1242,7 +1265,7 @@ fn explode_show(s: &mut Session, p: &Value) -> Result<Value> {
s.pending_moves.clear();
s.exploded.clear();
s.revision += 1;
return Ok(json!({"view": null}));
return typed_reply(cmd, ExplodeShown { view: None, name: None, param_warnings: Vec::new() });
}
let i = view_index(s, p, cmd)?;
let v = s.doc.assembly.exploded.get(i).cloned().ok_or_else(|| bad(cmd, "view"))?;
@@ -1254,7 +1277,7 @@ fn explode_show(s: &mut Session, p: &Value) -> Result<Value> {
s.exploded.clone_from(&moves);
s.pending_moves = moves;
s.revision += 1;
Ok(json!({"view": v.id, "name": v.name}))
typed_reply(cmd, ExplodeShown { view: Some(v.id), name: Some(v.name), param_warnings: Vec::new() })
}
/// Whether the pending moves are an exploded view shown (`explode.show`), which places the
@@ -1287,13 +1310,14 @@ fn exploded_at(moves: &[(u64, Mat, Vec3)], amount: f64) -> std::collections::BTr
}
fn explode_list(s: &mut Session, _p: &Value) -> Result<Value> {
Ok(json!({"views": s.doc.assembly.exploded}))
let views = s.doc.assembly.exploded.iter().map(ExplodedViewInfo::from).collect();
typed_reply("explode.list", ExplodeViews { views, param_warnings: Vec::new() })
}
fn explode_delete(s: &mut Session, p: &Value) -> Result<Value> {
let i = view_index(s, p, "explode.delete")?;
let v = s.doc_mut().assembly.exploded.remove(i);
Ok(json!({"deleted": v.name}))
typed_reply("explode.delete", ExplodeDeleted { deleted: v.name, param_warnings: Vec::new() })
}
/// World position of a point of an occurrence (tests).
+25
View File
@@ -1830,6 +1830,31 @@ fn a_body_copied_in_another_design_pastes_as_an_imported_body() {
assert!(s.model.state().body("Cube (2)").is_none());
}
/// A body pasted from another design while an exploded view moves the target component is at
/// the world coordinates it had there once the target is shown assembled again.
#[test]
fn a_body_pasted_from_another_design_while_an_exploded_view_is_shown_keeps_its_world_position() {
let mut source = Session::default();
source.doc_mut().name = "Bracket".into();
run(&mut source, "solid.box", json!({"length": 2, "width": 2, "height": 2, "body_name": "Cube"}));
let was = source.world_state().unwrap().body("Cube").unwrap().mesh().bounds();
let copy = crate::ForeignCopy::of(&source, vec!["Cube".into()], Vec::new()).unwrap();
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 1, "width": 1, "height": 1, "body_name": "Block"}));
let (a, a_at) = created(&mut s, "A");
run(&mut s, "occurrence.move", json!({"occurrence": a_at, "translate": [100,0,0]}));
// An exploded view needs two placements: B stays put.
run(&mut s, "component.create", json!({"name": "B", "parent": 0, "activate": false}));
let view = run(&mut s, "explode.create", json!({"moves": [{"occurrence": a_at, "translate": [0, 60, 0]}]}))["view"].as_u64().unwrap();
run(&mut s, "explode.show", json!({"view": view}));
s.foreign_copy = Some(std::sync::Arc::new(copy));
run(&mut s, "component.paste_from_design", json!({"component": a, "component_path": [a_at]}));
run(&mut s, "explode.show", json!({}));
let is = s.world_state().unwrap().body("Cube").unwrap().mesh().bounds();
assert!(was.min.dist(is.min) < 1e-6 && was.max.dist(is.max) < 1e-6, "{was:?} vs {is:?}");
}
/// A body seen in another placement of its component ("Cube (Holder:2)") pastes into another
/// design under its own name, at that placement's position.
#[test]
+115 -63
View File
@@ -10,8 +10,11 @@ use solidcraft_sketch::{Analysis, ConstraintKind, CurveKind, Hold, Sketch, solve
use super::{CommandSpec, sketch_store};
use crate::params::{bad, bool_, str_};
use crate::schema::parameters::{
AddedValue, ParamAddParams, ParamAdded, ParamCommentParams, ParamCommented, ParamDeleted, ParamFavoriteParams, ParamFavorited, ParamMoveParams,
ParamMoved, ParamNameParams, ParamRenameParams, ParamRenamed,
AddedValue, Completion, CompletionKind, DrivenFeature, EvalKind, ExprCompleteParams, ExprCompletions, ExprEvaluateParams, ExprEvaluated,
ExprEvaluatedBody, FailedFeature, ParamAddParams, ParamAdded, ParamChangeParams, ParamChanged, ParamChangedBody, ParamChangedOne,
ParamCommentParams, ParamCommented, ParamDeleted, ParamExportParams, ParamExportResult, ParamFavoriteParams, ParamFavorited, ParamGraphParams,
ParamGraphResult, ParamImportParams, ParamImportResult, ParamListParams, ParamListResult, ParamMoveParams, ParamMoved, ParamNameParams,
ParamRename, ParamRenameParams, ParamRenamed, ParamUsersParams, ParamUsersResult, ParameterRow, ParameterUser, SwitchedFeature,
};
use crate::schema::{TypedContract, output_schema_for};
use crate::{EngineError, Result, Session};
@@ -22,9 +25,22 @@ const NAME_CONTRACT: TypedContract<ParamNameParams> = TypedContract::new();
const COMMENT_CONTRACT: TypedContract<ParamCommentParams> = TypedContract::new();
const FAVORITE_CONTRACT: TypedContract<ParamFavoriteParams> = TypedContract::new();
const MOVE_CONTRACT: TypedContract<ParamMoveParams> = TypedContract::new();
const CHANGE_CONTRACT: TypedContract<ParamChangeParams> = TypedContract::new();
const LIST_CONTRACT: TypedContract<ParamListParams> = TypedContract::new();
const USERS_CONTRACT: TypedContract<ParamUsersParams> = TypedContract::new();
const GRAPH_CONTRACT: TypedContract<ParamGraphParams> = TypedContract::new();
const EXPORT_CONTRACT: TypedContract<ParamExportParams> = TypedContract::new();
const IMPORT_CONTRACT: TypedContract<ParamImportParams> = TypedContract::new();
const EVALUATE_CONTRACT: TypedContract<ExprEvaluateParams> = TypedContract::new();
const COMPLETE_CONTRACT: TypedContract<ExprCompleteParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("parameters.change", "Change Parameters", change_param).at("SOLID", "MODIFY").icon("params").params(
CommandSpec::new("parameters.change", "Change Parameters", crate::checked!("parameters.change", change_param))
.at("SOLID", "MODIFY")
.icon("params")
.contract(&CHANGE_CONTRACT)
.output(output_schema_for::<ParamChanged>)
.params(
"name, expression (number or text), unit?: mm|cm|m|in|ft|deg|rad|\"\"|text (new parameters default to their value's unit, a length the design's units; \
a text parameter's expression is its text, quotes round it optional: sketch texts' words are built from it), comment?, \
favorite?: bool, new_name?: rename (references follow; an expression `new = value` renames to `new` and sets value, \
@@ -34,7 +50,13 @@ pub static COMMANDS: &[CommandSpec] = &[
(the configuration rules that switch a feature's suppression and read a parameter whose value changed run in their order, as on \
activation; driven parameters and restrictions wait for a configuration edit or activation)",
),
CommandSpec::new("parameters.list", "List Parameters", list).icon("list").noundo().doc_only().params(
CommandSpec::new("parameters.list", "List Parameters", crate::checked!("parameters.list", list))
.icon("list")
.noundo()
.doc_only()
.contract(&LIST_CONTRACT)
.output(output_schema_for::<ParamListResult>)
.params(
"favorites?: bool (only favourites), filter?: all|user|sketch|feature|favorites → parameters (name, expression, unit, value (mm/deg in every \
reply), display_value (in its own unit), source, feature/input, comment, favorite, uses, used_by, error) and cycles",
),
@@ -68,30 +90,42 @@ pub static COMMANDS: &[CommandSpec] = &[
.contract(&COMMENT_CONTRACT)
.output(output_schema_for::<ParamCommented>)
.params("name, comment"),
CommandSpec::new("parameters.users", "Parameter Users", users)
CommandSpec::new("parameters.users", "Parameter Users", crate::checked!("parameters.users", users))
.icon("graph")
.noundo()
.contract(&USERS_CONTRACT)
.output(output_schema_for::<ParamUsersResult>)
.params("name → users (parameters, feature inputs, dimensions) and the features it drives"),
CommandSpec::new("parameters.graph", "Parameter Graph", graph)
CommandSpec::new("parameters.graph", "Parameter Graph", crate::checked!("parameters.graph", graph))
.icon("graph")
.noundo()
.contract(&GRAPH_CONTRACT)
.output(output_schema_for::<ParamGraphResult>)
.params("→ nodes, edges [user, used] (users include Feature:<name>), cycles, errors"),
CommandSpec::new("parameters.export", "Export Parameters", export)
CommandSpec::new("parameters.export", "Export Parameters", crate::checked!("parameters.export", export))
.icon("export")
.noundo()
.contract(&EXPORT_CONTRACT)
.output(output_schema_for::<ParamExportResult>)
.params("format?: csv|json (default csv, or from the path), path?: write to a file → text"),
CommandSpec::new("parameters.import", "Import Parameters", import)
CommandSpec::new("parameters.import", "Import Parameters", crate::checked!("parameters.import", import))
.icon("import")
.contract(&IMPORT_CONTRACT)
.output(output_schema_for::<ParamImportResult>)
.params("text: CSV (header with name, expression, unit?, comment?) or JSON [{name, expression, unit?, comment?}] | path; all or nothing"),
CommandSpec::new("expr.evaluate", "Evaluate Expression", evaluate)
CommandSpec::new("expr.evaluate", "Evaluate Expression", crate::checked!("expr.evaluate", evaluate))
.icon("params")
.noundo()
.doc_only()
.contract(&EVALUATE_CONTRACT)
.output(output_schema_for::<ExprEvaluated>)
.params("expression, kind?: length|angle|unitless (or unit?: mm|in|deg…; text: the text a text parameter would hold, as `text`) → ok, value (mm/rad), display_value, references, error"),
CommandSpec::new("parameters.complete", "Complete Expression", complete)
CommandSpec::new("parameters.complete", "Complete Expression", crate::checked!("parameters.complete", complete))
.icon("params")
.noundo()
.doc_only()
.contract(&COMPLETE_CONTRACT)
.output(output_schema_for::<ExprCompletions>)
.params("prefix?: the text before the cursor → word, suggestions [{text, kind: parameter|function|unit|constant, detail}]"),
];
@@ -108,8 +142,8 @@ fn param_name<'a>(cmd: &str, p: &'a Value) -> Result<&'a str> {
}
/// Features that fail (name and error).
fn failing(s: &Session) -> Vec<Value> {
s.model.results.iter().filter_map(|r| r.error.as_ref().map(|e| json!({"feature": r.name, "error": e}))).collect()
fn failing(s: &Session) -> Vec<FailedFeature> {
FailedFeature::of(&s.model.results)
}
pub(super) fn known(s: &Session, name: &str) -> bool {
@@ -130,19 +164,20 @@ fn change_param(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(n) = str_(p, "delete") {
s.doc_mut().remove_param(n.trim())?;
s.settle_value_edit(&solved)?;
return Ok(json!({"deleted": n.trim()}));
return reply(cmd, ParamChanged { body: ParamChangedBody::Deleted { deleted: n.trim().to_string() }, param_warnings: Vec::new() });
}
let mut name = param_name(cmd, p)?.to_string();
let mut out = json!({});
let mut renamed: Option<ParamRename> = None;
let mut changed_through: Option<String> = None;
if let Some(new) = str_(p, "new_name") {
let n = s.doc_mut().rename_param(&name, new)?;
out["renamed"] = json!({"from": name, "to": new.trim(), "references_updated": n});
renamed = Some(ParamRename { from: name, to: new.trim().to_string(), references_updated: n });
name = new.trim().to_string();
}
let has_expr = p.get("expression").is_some() || p.get("value").is_some();
let mut e = if has_expr {
Some(expr_arg(p, "expression").or_else(|| expr_arg(p, "value")).ok_or_else(|| bad(cmd, "`expression` must be a number or text"))?)
} else if out.get("renamed").is_some() || p.get("favorite").is_some() || p.get("comment").is_some() {
} else if renamed.is_some() || p.get("favorite").is_some() || p.get("comment").is_some() {
None
} else {
return Err(bad(cmd, "`expression` must be a number or text"));
@@ -151,7 +186,7 @@ fn change_param(s: &mut Session, p: &Value) -> Result<Value> {
// `d7`, a feature input's) is renamed, references following, and takes the expression. A
// text parameter's text is only text.
if let Some((new, rhs)) = e.as_deref().filter(|_| !is_text(s, &name, str_(p, "unit"))).and_then(expr::definition) {
if out.get("renamed").is_some() {
if renamed.is_some() {
return Err(bad(cmd, format!("`{new} = …` renames `{name}` already: give `new_name` or `name = expression`, not both")));
}
if rhs.is_empty() {
@@ -162,7 +197,7 @@ fn change_param(s: &mut Session, p: &Value) -> Result<Value> {
// `w = 20` shows `w`, and `w = 30` typed over it sets `w`.
let refers_to = s.doc.all_param_exprs().into_iter().find(|(n, _, _)| *n == name).map(|(_, e, _)| e);
if refers_to.as_deref().map(str::trim) == Some(new.as_str()) && s.doc.param(&new).is_some() {
out["changed_through"] = json!(name);
changed_through = Some(name);
name = new.clone();
}
if new != name {
@@ -170,7 +205,7 @@ fn change_param(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, format!("there is already a parameter `{new}`: type `{new}` to use it, or change it in Parameters")));
}
let n = s.doc_mut().rename_param(&name, &new)?;
out["renamed"] = json!({"from": name, "to": new, "references_updated": n});
renamed = Some(ParamRename { from: name, to: new.clone(), references_updated: n });
name = new;
}
e = Some(rhs);
@@ -197,20 +232,20 @@ fn change_param(s: &mut Session, p: &Value) -> Result<Value> {
}
s.settle_value_edit(&solved)?;
let (vals, _) = s.doc.param_values();
if !switched.is_empty() {
out["switched"] = json!(switched);
}
out["name"] = json!(name);
out["expression"] = json!(e);
if let Some(t) = s.doc.text_param(&name) {
out["text"] = json!(t.text);
}
out["value"] = json!(vals.get(&name).map(|v| expr::round_reported(expr::reported_value(*v))));
out["display_value"] = json!(s.doc.param_display_value(&vals, &name).map(expr::round_reported));
out["recomputed"] = json!(s.model.last_recomputed);
out["restored"] = json!(s.model.last_restored);
out["errors"] = json!(failing(s));
Ok(out)
let one = ParamChangedOne {
renamed,
changed_through,
switched,
text: s.doc.text_param(&name).map(|t| t.text.clone()),
value: vals.get(&name).map(|v| expr::round_reported(expr::reported_value(*v))),
display_value: s.doc.param_display_value(&vals, &name).map(expr::round_reported),
recomputed: s.model.last_recomputed,
restored: s.model.last_restored,
errors: failing(s),
name,
expression: e,
};
reply(cmd, ParamChanged { body: ParamChangedBody::One(Box::new(one)), param_warnings: Vec::new() })
}
/// The point a length dimension keeps when its value changes: the first it measures from (a
@@ -308,19 +343,15 @@ fn change_many(s: &mut Session, changes: &Value, solved: &crate::SketchValues) -
}
s.settle_value_edit(solved)?;
let (vals, _) = s.doc.param_values();
let values: serde_json::Map<String, Value> =
list.iter().map(|(n, _)| (n.clone(), json!(vals.get(n).map(|v| expr::round_reported(expr::reported_value(*v)))))).collect();
let mut out = json!({"changed": values, "recomputed": s.model.last_recomputed, "restored": s.model.last_restored, "errors": failing(s)});
if !switched.is_empty() {
out["switched"] = json!(switched);
}
Ok(out)
let changed = list.iter().map(|(n, _)| (n.clone(), vals.get(n).map(|v| expr::round_reported(expr::reported_value(*v))))).collect();
let body = ParamChangedBody::Many { changed, recomputed: s.model.last_recomputed, restored: s.model.last_restored, errors: failing(s), switched };
reply(cmd, ParamChanged { body, param_warnings: Vec::new() })
}
/// The configuration rules that switch a feature's suppression and read a parameter this edit
/// changed (`Document::apply_suppression_rules`), applied before the design is rebuilt, so that
/// one rebuild has them: `[{feature, name, suppressed}]` for each feature they switched.
fn switch_by_rules(s: &mut Session, before: &std::collections::BTreeMap<String, expr::Value>) -> Vec<Value> {
fn switch_by_rules(s: &mut Session, before: &std::collections::BTreeMap<String, expr::Value>) -> Vec<SwitchedFeature> {
if s.doc.configs.rules.is_empty() {
return Vec::new();
}
@@ -329,12 +360,13 @@ fn switch_by_rules(s: &mut Session, before: &std::collections::BTreeMap<String,
.into_iter()
.map(|(id, suppressed)| {
let name = s.doc.features.iter().find(|f| f.id == id).map(|f| f.name.clone());
json!({"feature": id, "name": name, "suppressed": suppressed})
SwitchedFeature { feature: id, name, suppressed }
})
.collect()
}
fn list(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "parameters.list";
let filter = match (bool_(p, "favorites"), str_(p, "filter")) {
(Some(true), _) => "favorites".to_string(),
(_, Some(f)) => f.to_ascii_lowercase(),
@@ -354,7 +386,9 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
// Favourites first; otherwise the table order (user, sketch, feature).
rows.sort_by_key(|r| !r.favorite);
let cycles = solidcraft_doc::cycles(&s.doc.all_param_exprs());
Ok(json!({"parameters": rows, "count": rows.len(), "cycles": cycles}))
let count = rows.len();
let parameters = rows.into_iter().map(ParameterRow::from).collect();
reply(cmd, ParamListResult { parameters, count, cycles: cycles.into_iter().collect(), param_warnings: Vec::new() })
}
fn add(s: &mut Session, p: &Value) -> Result<Value> {
@@ -438,17 +472,24 @@ fn users(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, format!("no parameter `{name}`")));
}
let users = s.doc.param_users(name);
let drives: Vec<Value> =
s.doc.features_using_param(name).iter().filter_map(|id| s.doc.feature(*id)).map(|f| json!({"id": f.id, "name": f.name})).collect();
Ok(json!({"name": name, "users": users, "drives": drives}))
let users = users.into_iter().map(ParameterUser::from).collect();
let drives = s
.doc
.features_using_param(name)
.iter()
.filter_map(|id| s.doc.feature(*id))
.map(|f| DrivenFeature { id: f.id, name: f.name.clone() })
.collect();
reply(cmd, ParamUsersResult { name: name.to_string(), users, drives, param_warnings: Vec::new() })
}
fn graph(s: &mut Session, _p: &Value) -> Result<Value> {
let cmd = "parameters.graph";
let (_, errs) = s.doc.param_values();
let nodes: Vec<String> = s.doc.param_defs().into_iter().map(|d| d.name).collect();
let edges: Vec<[String; 2]> = s.doc.param_graph().into_iter().map(|(a, b)| [a, b]).collect();
let cycles = solidcraft_doc::cycles(&s.doc.all_param_exprs());
Ok(json!({"nodes": nodes, "edges": edges, "cycles": cycles, "errors": errs}))
reply(cmd, ParamGraphResult { nodes, edges, cycles: cycles.into_iter().collect(), errors: errs, param_warnings: Vec::new() })
}
fn export(s: &mut Session, p: &Value) -> Result<Value> {
@@ -477,7 +518,7 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(path) = path {
solidcraft_io::vfs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
}
Ok(json!({"format": format, "path": path, "text": text}))
reply(cmd, ParamExportResult { format, path: path.map(str::to_string), text, param_warnings: Vec::new() })
}
/// Largest parameter table we read from a file.
@@ -495,7 +536,7 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
let solved = s.solved_sketches();
let names = s.doc_mut().import_params(&text)?;
s.settle_value_edit(&solved)?;
Ok(json!({"imported": names, "errors": failing(s)}))
reply(cmd, ParamImportResult { imported: names, errors: failing(s), param_warnings: Vec::new() })
}
fn evaluate(s: &mut Session, p: &Value) -> Result<Value> {
@@ -504,7 +545,8 @@ fn evaluate(s: &mut Session, p: &Value) -> Result<Value> {
let unit = str_(p, "unit");
// A text parameter's expression is its text.
if unit.is_some_and(solidcraft_doc::text_params::is_text_unit) {
return Ok(json!({"ok": true, "kind": "text", "text": solidcraft_doc::text_params::unquote(&e), "references": []}));
let body = ExprEvaluatedBody::Text { ok: true, kind: "text".into(), text: solidcraft_doc::text_params::unquote(&e), references: Vec::new() };
return reply(cmd, ExprEvaluated { body, param_warnings: Vec::new() });
}
if let Some(u) = unit
&& expr::unit_info(u).is_none()
@@ -531,7 +573,7 @@ fn evaluate(s: &mut Session, p: &Value) -> Result<Value> {
});
// `name = expression` is worth its expression, and names what it would define (#4071).
let defines = expr::definition(&e).map(|(n, _)| n);
let mut out = match r {
let body = match r {
Ok((v, k)) => {
let value = match k {
Kind::Length => expr::Value::length(v),
@@ -543,17 +585,27 @@ fn evaluate(s: &mut Session, p: &Value) -> Result<Value> {
(None, Kind::Angle) => v.to_degrees(),
(None, _) => v,
};
json!({"ok": true, "value": expr::round_reported(v), "kind": k, "display_value": expr::round_reported(shown), "references": refs})
ExprEvaluatedBody::Value {
ok: true,
value: expr::round_reported(v),
kind: EvalKind::from(k),
display_value: expr::round_reported(shown),
references: refs,
defines: defines.map(str::to_string),
}
}
Err(err) => json!({"ok": false, "error": err.to_string().trim_start_matches("expression: ").to_string(), "references": refs}),
Err(err) => ExprEvaluatedBody::Failed {
ok: false,
error: err.to_string().trim_start_matches("expression: ").to_string(),
references: refs,
defines: defines.map(str::to_string),
},
};
if let Some(name) = defines {
out["defines"] = json!(name);
}
Ok(out)
reply(cmd, ExprEvaluated { body, param_warnings: Vec::new() })
}
fn complete(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "parameters.complete";
let text = str_(p, "prefix").unwrap_or("");
// The identifier being typed: trailing letters, digits and underscores.
let word: String = text.chars().rev().take_while(|c| c.is_alphanumeric() || *c == '_').collect::<Vec<_>>().into_iter().rev().collect();
@@ -565,7 +617,7 @@ fn complete(s: &mut Session, p: &Value) -> Result<Value> {
// start.
let contains = |n: &str| word.is_empty() || n.to_ascii_lowercase().contains(&lower);
let within = |n: &str| contains(n) || solidcraft_doc::name_words_match(n, &word);
let mut out: Vec<Value> = Vec::new();
let mut out: Vec<Completion> = Vec::new();
let mut rows: Vec<solidcraft_doc::ParamRow> = s.doc.param_rows().into_iter().filter(|r| within(&r.name)).collect();
// Names that start with the word, then names that contain it, then names matched only by
// the starts of their words; within each, favourites, then user parameters, then the others.
@@ -580,22 +632,22 @@ fn complete(s: &mut Session, p: &Value) -> Result<Value> {
if !r.comment.is_empty() {
detail.push_str(&format!(" — {}", r.comment));
}
out.push(json!({"text": r.name, "kind": "parameter", "detail": detail}));
out.push(Completion { text: r.name, kind: CompletionKind::Parameter, detail });
}
for (f, sig) in expr::FUNCTIONS {
if starts(f) {
out.push(json!({"text": format!("{f}("), "kind": "function", "detail": sig}));
out.push(Completion { text: format!("{f}("), kind: CompletionKind::Function, detail: sig.to_string() });
}
}
if starts("pi") {
out.push(json!({"text": "pi", "kind": "constant", "detail": "3.14159…"}));
out.push(Completion { text: "pi".into(), kind: CompletionKind::Constant, detail: "3.14159…".into() });
}
for (u, k, _) in expr::UNITS {
if !word.is_empty() && starts(u) {
out.push(json!({"text": u, "kind": "unit", "detail": k}));
out.push(Completion { text: u.to_string(), kind: CompletionKind::Unit, detail: EvalKind::from(*k).word().to_string() });
}
}
Ok(json!({"word": word, "suggestions": out}))
reply(cmd, ExprCompletions { word, suggestions: out, param_warnings: Vec::new() })
}
#[cfg(test)]
+71 -19
View File
@@ -12,8 +12,10 @@ use solidcraft_geom::prelude::*;
use super::CommandSpec;
use super::features::{add_feature, check_expr};
use crate::params::{bad, bool_, expr, req_expr, str_, string_list, vec3};
use crate::schema::features::FeatureResult;
use crate::schema::plastic::{
AssignRuleParams, AssignRuleResult, ManageRulesParams, ManageRulesResult, RuleExpressions, RuleInfo, RuleValuesOrError, RulesParams, RulesResult,
AssignRuleParams, AssignRuleResult, BossParams, LipParams, ManageRulesParams, ManageRulesResult, RestParams, RuleExpressions, RuleInfo,
RuleValuesOrError, RulesParams, RulesResult, SnapFitParams,
};
use crate::schema::{TypedContract, output_schema_for};
use crate::{EngineError, Result, Session};
@@ -21,23 +23,47 @@ use crate::{EngineError, Result, Session};
const MANAGE_RULES_CONTRACT: TypedContract<ManageRulesParams> = TypedContract::new();
const ASSIGN_RULE_CONTRACT: TypedContract<AssignRuleParams> = TypedContract::new();
const RULES_CONTRACT: TypedContract<RulesParams> = TypedContract::new();
const BOSS_CONTRACT: TypedContract<BossParams> = TypedContract::new();
const LIP_CONTRACT: TypedContract<LipParams> = TypedContract::new();
const SNAP_FIT_CONTRACT: TypedContract<SnapFitParams> = TypedContract::new();
const REST_CONTRACT: TypedContract<RestParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("plastic.boss", "Boss", boss).at("PLASTIC", "CREATE").icon("boss").params(
"position: [x,y,z] on a face; direction? (default: out of the face); diameter, height; hole_diameter?, hole_depth? (default: to the face); \
draft? (angle), fillet? (root radius); ribs? (count), rib_thickness?, rib_length? (out from the wall), rib_offset? (below the top); body?",
),
CommandSpec::new("plastic.lip", "Lip", lip).at("PLASTIC", "CREATE").icon("lip").params(
"face: [x,y,z] on the rim face of a shelled body; width, height; type?: lip|groove; gap? (groove clearance); side?: inside|outside (which rim edge); body?",
),
CommandSpec::new("plastic.snap_fit", "Snap Fit", snap_fit).at("PLASTIC", "CREATE").icon("snap_fit").params(
"position: [x,y,z] on a face; direction? (default: out of the face); hook: [x,y,z] (the catch's direction); length, thickness, width, catch_depth, catch_length; \
base_height? (extends the base below the face); new_body?: bool (make a separate body instead of joining); body?",
),
CommandSpec::new("plastic.rest", "Rest", rest).at("PLASTIC", "CREATE").icon("rest").params(
"position: [x,y,z] on a face (the rest's centre); direction? (default: out of the face); width (round: the diameter); length? (a rectangle, along `along`); along?: [x,y,z]; \
height; draft? (angle; default: the body's plastic rule); thickness? (wall of a hollow rest); body?",
),
CommandSpec::new("plastic.boss", "Boss", crate::checked!("plastic.boss", boss))
.at("PLASTIC", "CREATE")
.icon("boss")
.contract(&BOSS_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params(
"position: [x,y,z] on a face; direction? (default: out of the face); diameter, height; hole_diameter?, hole_depth? (default: to the face); \
draft? (angle), fillet? (root radius); ribs? (count), rib_thickness?, rib_length? (out from the wall), rib_offset? (below the top); body?",
),
CommandSpec::new("plastic.lip", "Lip", crate::checked!("plastic.lip", lip))
.at("PLASTIC", "CREATE")
.icon("lip")
.contract(&LIP_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params(
"face: [x,y,z] on the rim face of a shelled body; width, height; type?: lip|groove; gap? (groove clearance); side?: inside|outside (which rim edge); body?",
),
CommandSpec::new("plastic.snap_fit", "Snap Fit", crate::checked!("plastic.snap_fit", snap_fit))
.at("PLASTIC", "CREATE")
.icon("snap_fit")
.contract(&SNAP_FIT_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params(
"position: [x,y,z] on a face; direction? (default: out of the face); hook: [x,y,z] (the catch's direction); length, thickness, width, catch_depth, catch_length; \
base_height? (extends the base below the face); new_body?: bool (make a separate body instead of joining); body?",
),
CommandSpec::new("plastic.rest", "Rest", crate::checked!("plastic.rest", rest))
.at("PLASTIC", "CREATE")
.icon("rest")
.contract(&REST_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params(
"position: [x,y,z] on a face (the rest's centre); direction? (default: out of the face); width (round: the diameter); length? (a rectangle, along `along`); along?: [x,y,z]; \
height; draft? (angle; default: the body's plastic rule); thickness? (wall of a hollow rest); body?",
),
CommandSpec::new("plastic.manage_rules", "Manage Plastic Rules", crate::checked!("plastic.manage_rules", manage_rules))
.at("PLASTIC", "SETUP")
.icon("plastic_rule")
@@ -152,10 +178,20 @@ fn assign_rule(s: &mut Session, p: &Value) -> Result<Value> {
if bodies.is_empty() || bodies.len() > 10_000 {
return Err(bad(cmd, "`bodies` must list bodies"));
}
// A placed copy's name means the body it shows (#9740); the rule is kept by that body's own
// name. The placed bodies are named once for the whole list, not once per name.
let st = s.model.state();
if let Some(b) = bodies.iter().find(|b| st.body(b).is_none()) {
return Err(bad(cmd, format!("no body `{b}`")));
}
let mut frames = None;
let bodies = bodies
.iter()
.map(|b| {
if st.body(b).is_some() {
return Ok(b.clone());
}
let frames = frames.get_or_insert_with(|| s.body_frames());
frames.iter().find(|(world, ..)| world == b).map(|(_, own, _)| own.clone()).ok_or_else(|| bad(cmd, format!("no body `{b}`")))
})
.collect::<Result<Vec<_>>>()?;
let rule = str_(p, "rule").map(str::trim).unwrap_or_default().to_string();
if !rule.is_empty() && s.doc.plastic_rule(&rule).is_none() {
return Err(bad(cmd, format!("no plastic rule `{rule}` (plastic.rules lists them)")));
@@ -324,6 +360,22 @@ mod tests {
(a - b).abs() / b.abs().max(1e-12)
}
/// A placed copy's body name (`KB (K:2)`, as inspect.measure lists it) means the body it
/// shows: the rule goes on that body, under its own name, for every placement.
#[test]
fn a_rule_assigned_by_a_placed_copys_name_goes_on_the_body_it_shows() {
let mut s = Session::default();
run(&mut s, "component.create", json!({"name": "K"}));
run(&mut s, "solid.box", json!({"body_name": "KB", "length": 10, "width": 10, "height": 10}));
run(&mut s, "occurrence.copy", json!({"component": "K", "translate": [30, 0, 0]}));
run(&mut s, "plastic.manage_rules", json!({"name": "Thin", "thickness": "1.2 mm"}));
let r = run(&mut s, "plastic.assign_rule", json!({"bodies": ["KB (K:2)"], "rule": "Thin"}));
assert_eq!(r["bodies"], json!(["KB"]), "{r}");
assert_eq!(s.doc.plastic.assigned.get("KB").map(String::as_str), Some("Thin"));
let err = s.execute("plastic.assign_rule", &json!({"bodies": ["KB (K:9)"], "rule": "Thin"})).unwrap_err().to_string();
assert!(err.contains("no body `KB (K:9)`"), "{err}");
}
#[test]
fn boss_with_hole_fillet_and_ribs() {
let mut s = Session::default();
+46 -17
View File
@@ -9,6 +9,7 @@ use solidcraft_doc::render_settings::{RenderSettings, RenderSetup};
use super::CommandSpec;
use crate::params::bad;
use crate::schema::render_camera::{CameraApplied, CameraFitted, CameraInfo, CameraSet, RenderCamera};
use crate::schema::render_settings::{RenderSettingsGot, RenderSettingsInfo, RenderSettingsSaved, SETUP_FIELDS, SetupFields, set_message};
use crate::{Result, Session};
mod clear_coat;
@@ -140,7 +141,9 @@ CommandSpec::new("render.library.preview", "Inspect Original Surface Map", crate
.contract(&library::PREVIEW_CONTRACT)
.output(crate::schema::output_schema_for::<crate::schema::render_library::LibraryPreview>)
.params("id: SURF-*. Returns bounded hash-verified original base-color PNG bytes and source-map fidelity label, not a rendered material thumbnail. No external paths, rendering or document edits."),
CommandSpec::new("render.library.physical.prepare", "Prepare Physical Reference", physical_library::prepare).icon("inspect").noundo().doc_only()
CommandSpec::new("render.library.physical.prepare", "Prepare Physical Reference", crate::checked!("render.library.physical.prepare", physical_library::prepare)).icon("inspect").noundo().doc_only()
.contract(&physical_library::PREPARE_CONTRACT)
.output(crate::schema::output_schema_for::<crate::schema::render_library::PhysicalPlan>)
.params("id: named built-in physical profile, name: proposed new material label, acknowledge_reference:true. Read-only loss-aware creation plan using native ValidatedCard admission and one point density; returns source receipts, extended fields and omissions. Does not execute material.create, assign bodies or infer effective-part physics."),
CommandSpec::new("render.stage.ground", "Save Render Ground", crate::checked!("render.stage.ground", staging::ground)).icon("render_scene")
.contract(&staging::GROUND_CONTRACT)
@@ -176,15 +179,25 @@ CommandSpec::new("render.library.describe", "Inspect Render Library Resource", c
.contract(&library::DESCRIBE_CONTRACT)
.output(crate::schema::output_schema_for::<crate::schema::render_library::LibraryDescription>)
.params("kind, id: exact authored resource. Returns original specification, runtime projection variants and explicit omissions. A concept is not an executable preset."),
CommandSpec::new("render.library.appearance.apply", "Apply Library Opaque Appearance", library::apply_appearance).icon("appearance")
CommandSpec::new("render.library.appearance.apply", "Apply Library Opaque Appearance", crate::checked!("render.library.appearance.apply", library::apply_appearance)).icon("appearance")
.contract(&library::APPEARANCE_APPLY_CONTRACT)
.output(crate::schema::output_schema_for::<crate::schema::render_library::AppearanceApplied>)
.params("id: SURF-* or SELENE-AP-*; variant: uniform_opaque (required); bodies: 1..256 exact body names. Applies the original scalar baseline through ordinary appearance assignment in one undo, preserving physical properties. Spatial maps and advanced target effects are explicitly excluded."),
CommandSpec::new("render.library.environment.apply", "Apply Built-in HDR Environment", library::apply_environment).icon("settings").doc_only()
CommandSpec::new("render.library.environment.apply", "Apply Built-in HDR Environment", crate::checked!("render.library.environment.apply", library::apply_environment)).icon("settings").doc_only()
.contract(&library::ENVIRONMENT_APPLY_CONTRACT)
.output(crate::schema::output_schema_for::<crate::schema::render_library::EnvironmentApplied>)
.params("id: HDR-*; name: saved setup; set?: typed CPU/GPU setup patch; rotation_rad?: authored rotation (native origin offset is explicit in the reply); intensity?:1; samples?:16 for CPU; replace_environment?:false. Hash-verifies and embeds original bytes, saves one atomic undo without external files or a render device. An HDR-owned sun cannot duplicate an analytic sun."),
CommandSpec::new("render.library.surface.prepare", "Prepare Authored Surface Maps", library::prepare_surface).icon("appearance").noundo()
CommandSpec::new("render.library.surface.prepare", "Prepare Authored Surface Maps", crate::checked!("render.library.surface.prepare", library::prepare_surface)).icon("appearance").noundo()
.contract(&library::SURFACE_PREPARE_CONTRACT)
.output(crate::schema::output_schema_for::<crate::schema::render_library::SurfacePrepared>)
.params("id: SURF-*; body_index and body_name from render.scene; origin?:[0,0,0]; plane?:xy/yz/zx; scale?:1; rotation_rad?:0 (positive around the plane normal). Returns an explicit GPU temporary planar binding and a source usable by saved CPU/GPU surface.bind, with source hashes, original millimetre scale, image-up normal orientation and absolute base-color semantics. No document change; height/displacement and true anisotropy are not included."),
CommandSpec::new("render.library.studio.prepare", "Prepare Authored Studio Lights", library::prepare_studio).icon("inspect").noundo().doc_only()
CommandSpec::new("render.library.studio.prepare", "Prepare Authored Studio Lights", crate::checked!("render.library.studio.prepare", library::prepare_studio)).icon("inspect").noundo().doc_only()
.contract(&library::STUDIO_PREPARE_CONTRACT)
.output(crate::schema::output_schema_for::<crate::schema::render_library::StudioPrepared>)
.params("id: RIG-*; variant: complete for emitter-only rigs, emitters_only for rigs with passive cards; center_mm and subject_extent_mm required. Converts exact rectangular half-edges and solar direction/radius. Optional name and typed CPU set return the complete CPU projection and explicit indirect-lighting adoption plan; authored radiance beyond CPU intensity64 refuses without scaling. Blocked calibration and external-sun rigs refuse. Returns a geometry-clearance review requirement; does not create cards or modify the scene."),
CommandSpec::new("render.library.studio.apply", "Apply Authored Studio Lights", library::apply_studio).icon("settings")
CommandSpec::new("render.library.studio.apply", "Apply Authored Studio Lights", crate::checked!("render.library.studio.apply", library::apply_studio)).icon("settings")
.contract(&library::STUDIO_APPLY_CONTRACT)
.output(crate::schema::output_schema_for::<crate::schema::render_library::StudioApplied>)
.params("rig: id, variant, center_mm and subject_extent_mm; name; set?: typed CPU/GPU setup patch; expected_revision?:u64; enable_indirect?:false (explicit CPU Sun adoption after review); replace_lighting_and_environment:true required. Saves the explicit rig projection in one undo, removing prior lighting/environment only with that confirmation. Subject clearance still needs review; passive cards are never silently omitted."),
CommandSpec::new("render.hdr.inspect", "Inspect HDR Source", crate::checked!("render.hdr.inspect", resources::inspect)).icon("inspect").noundo().doc_only()
.contract(&resources::INSPECT_CONTRACT)
@@ -210,9 +223,13 @@ CommandSpec::new("render.backends", "Render Backends", backends).icon("list")
.noundo()
.doc_only()
.params("No parameters. Returns {schema_version: 1, default_backend: builtin, backends: [{id, label, availability, executors: [{id, availability, profiles, limits, scratch}]}]}. Metadata only; does not start rendering or probe a GPU. See docs/render-backends.md"),
CommandSpec::new("render.settings.get", "Render Settings", get_settings).icon("settings").noundo().doc_only()
CommandSpec::new("render.settings.get", "Render Settings", crate::checked!("render.settings.get", get_settings)).icon("settings").noundo().doc_only()
.contract(&crate::schema::TypedContract::<crate::schema::render_settings::GetParams>::new())
.output(crate::schema::output_schema_for::<RenderSettingsGot>)
.params("name?: saved setup. Without name returns {setups, defaults}; with name returns {name, setup}. Does not evaluate the model or apply a camera. See docs/render-settings.md"),
CommandSpec::new("render.settings.set", "Save Render Settings", set_settings).icon("settings").doc_only()
CommandSpec::new("render.settings.set", "Save Render Settings", crate::checked!("render.settings.set", set_settings)).icon("settings").doc_only()
.contract(&crate::schema::TypedContract::<crate::schema::render_settings::SetParams>::new())
.output(crate::schema::output_schema_for::<RenderSettingsSaved>)
.params("name: setup name, set: object of fields to replace (backend, mode, camera, width, height, format, exposure_ev, environment_rotation, environment_intensity, background, job, surfaces, optics, coats, stage). job?: typed active raster/cpu_raytrace/gpu_path configuration with optional validated embedded HDR reference; omitted preserves, null clears, object replaces the complete job. Creates from defaults and saves without a host, validates atomically, one undo step; identical edits are no-ops. See docs/render-settings.md"),
CommandSpec::new("render.scene", "Inspect Render Scene", scene).noundo().at("RENDER", "SCENE").icon("render_scene")
.params("No parameters. Authoritative product-scene metadata, without modelling overlays or mesh payloads. Explicitly settles stale geometry; does not render an image. See docs/render-workspace.md"),
@@ -317,13 +334,25 @@ struct GetSettings {
fn get_settings(s: &mut Session, params: &Value) -> Result<Value> {
let cmd = "render.settings.get";
let request: GetSettings = serde_json::from_value(params.clone()).map_err(|e| bad(cmd, e.to_string()))?;
match request.name {
None => Ok(json!({"setups": s.doc.render.setups, "defaults": RenderSetup::default()})),
let info = match request.name {
None => {
let setups = s.doc.render.setups.iter().map(|(name, setup)| Ok((name.clone(), setup_fields(cmd, setup)?))).collect::<Result<_>>()?;
RenderSettingsInfo::All { setups, defaults: setup_fields(cmd, &RenderSetup::default())? }
}
Some(name) => {
RenderSettings::validate_name(&name).map_err(|e| bad(cmd, e.to_string()))?;
let setup = s.doc.render.setups.get(&name).ok_or_else(|| bad(cmd, "no saved render setup with that name"))?;
Ok(json!({"name": name, "setup": setup}))
RenderSettingsInfo::One { setup: setup_fields(cmd, setup)?, name }
}
};
typed_reply(cmd, RenderSettingsGot { info, param_warnings: Vec::new() })
}
/// A render setup's fields, as it saves them, for a reply.
fn setup_fields(cmd: &str, setup: &RenderSetup) -> Result<SetupFields> {
match serde_json::to_value(setup).map_err(|e| bad(cmd, e.to_string()))? {
Value::Object(fields) => Ok(fields),
_ => Err(bad(cmd, "invalid render setup")),
}
}
@@ -338,23 +367,23 @@ fn set_settings(s: &mut Session, params: &Value) -> Result<Value> {
let cmd = "render.settings.set";
let request: SetSettings = serde_json::from_value(params.clone()).map_err(|e| bad(cmd, e.to_string()))?;
let setup = patched_settings(s, &request.name, &request.set, cmd)?;
let reply = json!({"name": request.name, "setup": setup});
let reply = RenderSettingsSaved { name: request.name.clone(), setup: setup_fields(cmd, &setup)?, param_warnings: Vec::new() };
if s.doc.render.setups.get(&request.name) == Some(&setup) {
return Ok(reply);
return typed_reply(cmd, reply);
}
let mut settings = (*s.doc.render).clone();
settings.setups.insert(request.name, setup);
settings.validate().map_err(|e| bad(cmd, e.to_string()))?;
s.doc_mut().render = Arc::new(settings);
Ok(reply)
typed_reply(cmd, reply)
}
fn patched_settings(s: &Session, name: &str, set: &Value, cmd: &str) -> Result<RenderSetup> {
RenderSettings::validate_name(name).map_err(|e| bad(cmd, e.to_string()))?;
// A full RenderSetup has 15 known fields, including maps, optics, coats and
// stage. Bound the patch by that schema; decoding below still rejects any
// A RenderSetup has `SETUP_FIELDS`, including surfaces, optics, coats and stage; the published
// schemas list the same fields. Bound the patch by them; decoding below still rejects any
// unknown field instead of silently dropping saved intent.
let patch = set.as_object().filter(|p| p.len() <= 15).ok_or_else(|| bad(cmd, "`set` must be an object with at most 15 fields"))?;
let patch = set.as_object().filter(|p| p.len() <= SETUP_FIELDS.len()).ok_or_else(|| bad(cmd, set_message()))?;
let current = s.doc.render.setups.get(name).cloned().unwrap_or_default();
let mut value = serde_json::to_value(&current).map_err(|e| bad(cmd, e.to_string()))?;
let object = value.as_object_mut().ok_or_else(|| bad(cmd, "invalid render setup"))?;
+133 -37
View File
@@ -18,8 +18,10 @@ use solidcraft_render::{
use crate::schema::TypedContract;
use crate::schema::render_library::{
LibraryDescribeParams, LibraryListParams, LibraryPage, LibraryPreview, LibraryPreviewParams, LibraryResource, LibraryTargets,
LibraryTargetsParams, Pbr, RenderTarget, ResourceSummary, SceneBounds, StableRenderTarget,
AppearanceApplied, AppearanceApplyParams, CpuProjection, EnvironmentApplied, EnvironmentApplyParams, IndirectAdoption, LibraryDescribeParams,
LibraryListParams, LibraryLook, LibraryPage, LibraryPreview, LibraryPreviewParams, LibraryResource, LibraryTargets, LibraryTargetsParams, Pbr,
PreparedTexture, ProgressiveDefaults, RenderTarget, ResourceSummary, RigProjection, SceneBounds, StableRenderTarget, StudioApplied,
StudioApplyParams, StudioPrepareParams, StudioPrepared, SurfacePrepareParams, SurfacePrepared,
};
use crate::{Result, Session, params::bad};
@@ -27,6 +29,11 @@ pub(super) const LIST_CONTRACT: TypedContract<LibraryListParams> = TypedContract
pub(super) const DESCRIBE_CONTRACT: TypedContract<LibraryDescribeParams> = TypedContract::new();
pub(super) const TARGETS_CONTRACT: TypedContract<LibraryTargetsParams> = TypedContract::new();
pub(super) const PREVIEW_CONTRACT: TypedContract<LibraryPreviewParams> = TypedContract::new();
pub(super) const APPEARANCE_APPLY_CONTRACT: TypedContract<AppearanceApplyParams> = TypedContract::new();
pub(super) const ENVIRONMENT_APPLY_CONTRACT: TypedContract<EnvironmentApplyParams> = TypedContract::new();
pub(super) const SURFACE_PREPARE_CONTRACT: TypedContract<SurfacePrepareParams> = TypedContract::new();
pub(super) const STUDIO_PREPARE_CONTRACT: TypedContract<StudioPrepareParams> = TypedContract::new();
pub(super) const STUDIO_APPLY_CONTRACT: TypedContract<StudioApplyParams> = TypedContract::new();
fn reply(command: &str, reply: impl serde::Serialize) -> Result<Value> {
serde_json::to_value(reply).map_err(|e| bad(command, e.to_string()))
@@ -397,20 +404,39 @@ pub(super) fn apply_appearance(s: &mut Session, params: &Value) -> Result<Value>
let color = std::array::from_fn::<_, 3, _>(|i| crate::geom::srgb::encode8((lo[i] + hi[i]) * 0.5));
(
value,
json!({"color":color,"opacity":1.0,"pbr":{"metallic":value["metallic"],"roughness":value["roughness"],"dielectric_ior":1.5}}),
LibraryLook {
name: String::new(),
color: json!(color),
opacity: json!(1.0),
pbr: json!({"metallic":value["metallic"],"roughness":value["roughness"],"dielectric_ior":1.5}),
bodies: Vec::new(),
},
json!(["spatial_maps", "height_displacement", "true_anisotropic_brdf"]),
)
} else {
let value = item("selene_appearances", &input.id, command)?;
let p = &value["baseline_projection"]["runtime_parameters"];
(value, json!({"color":p["color"],"opacity":p["opacity"],"pbr":p["pbr"]}), value["baseline_projection"]["omitted_target_features"].clone())
(
value,
LibraryLook { name: String::new(), color: p["color"].clone(), opacity: p["opacity"].clone(), pbr: p["pbr"].clone(), bodies: Vec::new() },
value["baseline_projection"]["omitted_target_features"].clone(),
)
};
let name = value["name"].as_str().ok_or_else(|| bad(command, "authored appearance has no name"))?;
look["name"] = json!(format!("{} {}", input.id, name).chars().take(128).collect::<String>());
look["bodies"] = json!(input.bodies);
let assigned = crate::cmd::inspect::appearance(s, &look)?;
Ok(
json!({"id":input.id,"variant":input.variant,"omitted":omitted,"appearance":look,"assignment":assigned,"engineering_properties_unchanged":true}),
look.name = format!("{} {}", input.id, name).chars().take(128).collect::<String>();
look.bodies = input.bodies;
let assigned = crate::cmd::inspect::appearance(s, &reply(command, &look)?)?;
reply(
command,
AppearanceApplied {
id: input.id,
variant: input.variant,
omitted,
appearance: look,
assignment: assigned,
engineering_properties_unchanged: true,
param_warnings: Vec::new(),
},
)
}
@@ -500,8 +526,21 @@ pub(super) fn apply_environment(s: &mut Session, params: &Value) -> Result<Value
if settings != *s.doc.render {
s.doc_mut().render = Arc::new(settings);
}
Ok(json!({"id":input.id,"name":input.name,"setup":setup,"resource":digest,"author_rotation_rad":input.rotation_rad,
"native_rotation_rad":rotation,"orientation_offset_rad":std::f64::consts::PI,"self_contained":true,"source_sha256":metadata["sha256"]}))
reply(
command,
EnvironmentApplied {
id: input.id,
name: input.name,
setup,
resource: digest,
author_rotation_rad: input.rotation_rad,
native_rotation_rad: rotation,
orientation_offset_rad: std::f64::consts::PI,
self_contained: true,
source_sha256: metadata["sha256"].clone(),
param_warnings: Vec::new(),
},
)
}
#[derive(Deserialize)]
@@ -562,14 +601,39 @@ pub(super) fn prepare_surface(s: &mut Session, params: &Value) -> Result<Value>
let frame = solidcraft_render::job::textures::PlanarFrame { origin, u, v };
frame.validate().map_err(|e| bad(command, e.to_string()))?;
let manifest = item("surface_manifest", &input.id, command)?;
Ok(json!({"id":input.id,"revision":s.revision,"executor":"gpu-path","variant":"gpu_planar_maps",
"texture":{"body_index":input.body_index,"body_name":input.body_name,"frame":frame,"normal_strength":1.0,"flip_v":true,"base_color_mode":"replace",
"base_color_srgb":format!("builtin://{}/base_color",input.id),"normal_opengl":format!("builtin://{}/normal_open_gl",input.id),
"roughness_linear":format!("builtin://{}/roughness",input.id),"metallic_linear":format!("builtin://{}/metallic",input.id)},
"source_maps":manifest["maps"],"omitted":["height_displacement","true_anisotropic_brdf","manufacturing_geometry"],
"dielectric_ior":"captured_body_value","persistence":"transient_request_binding; authored source bytes are built in",
"compatible_saved_methods":if cfg!(feature="bundled-render-library") {vec!["cpu_raytrace","gpu_path"]} else {Vec::<&str>::new()},
"cpu_maps":"saved_builtin_opaque_only; direct_or_indirect_lighting; level_zero_bilinear; incompatible_optics_refuse"}))
let map = |role: &str| format!("builtin://{}/{role}", input.id);
reply(
command,
SurfacePrepared {
revision: s.revision,
executor: "gpu-path".into(),
variant: "gpu_planar_maps".into(),
texture: PreparedTexture {
body_index: input.body_index,
body_name: input.body_name,
frame,
normal_strength: 1.0,
flip_v: true,
base_color_mode: "replace".into(),
base_color_srgb: map("base_color"),
normal_opengl: map("normal_open_gl"),
roughness_linear: map("roughness"),
metallic_linear: map("metallic"),
},
source_maps: manifest["maps"].clone(),
omitted: ["height_displacement", "true_anisotropic_brdf", "manufacturing_geometry"].map(String::from).into(),
dielectric_ior: "captured_body_value".into(),
persistence: "transient_request_binding; authored source bytes are built in".into(),
compatible_saved_methods: if cfg!(feature = "bundled-render-library") {
vec!["cpu_raytrace".into(), "gpu_path".into()]
} else {
Vec::new()
},
cpu_maps: "saved_builtin_opaque_only; direct_or_indirect_lighting; level_zero_bilinear; incompatible_optics_refuse".into(),
id: input.id.clone(),
param_warnings: Vec::new(),
},
)
}
#[derive(Deserialize)]
@@ -581,7 +645,9 @@ pub(super) struct RigInput {
pub(super) subject_extent_mm: f64,
}
pub(super) fn rig_lighting(input: &RigInput, command: &str) -> Result<(StudioLighting, Value)> {
/// The rig's lights and what it was scaled to, as `render.library.studio.prepare` and `.apply`
/// report them; `rig_lighting` gives the same projection as JSON.
fn rig_projection(input: &RigInput, command: &str) -> Result<(StudioLighting, RigProjection)> {
let value = item("rigs", &input.id, command)?;
if value["state"] != "authored_numeric_unbound" {
return Err(bad(command, "rig requires unresolved calibration or an existing external sun"));
@@ -643,11 +709,23 @@ pub(super) fn rig_lighting(input: &RigInput, command: &str) -> Result<(StudioLig
lighting.validate().map_err(|e| bad(command, e.to_string()))?;
Ok((
lighting,
json!({"id":input.id,"variant":input.variant,"passive_cards_omitted":if input.variant=="emitters_only" {cards}else{0},"passive_cards_included":if input.variant=="complete" {cards}else{0},"scale":scale,
"physical_units":"world millimetres; authored relative linear-sRGB radiance","reference_calibration_not_remeasured":true}),
RigProjection {
id: input.id.clone(),
variant: input.variant.clone(),
passive_cards_omitted: if input.variant == "emitters_only" { cards } else { 0 },
passive_cards_included: if input.variant == "complete" { cards } else { 0 },
scale,
physical_units: "world millimetres; authored relative linear-sRGB radiance".into(),
reference_calibration_not_remeasured: true,
},
))
}
pub(super) fn rig_lighting(input: &RigInput, command: &str) -> Result<(StudioLighting, Value)> {
let (lighting, projection) = rig_projection(input, command)?;
Ok((lighting, reply(command, projection)?))
}
pub(super) fn rig_stage(input: &RigInput, command: &str) -> Result<solidcraft_render::stage::Stage> {
use solidcraft_render::stage::{Stage, StagePanel};
if input.variant != "complete" {
@@ -774,10 +852,17 @@ pub(super) fn prepare_studio(s: &mut Session, params: &Value) -> Result<Value> {
let command = "render.library.studio.prepare";
let input: StudioPrepare = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
let rig = RigInput { id: input.id, variant: input.variant, center_mm: input.center_mm, subject_extent_mm: input.subject_extent_mm };
let (lighting, projection) = rig_lighting(&rig, command)?;
let (lighting, projection) = rig_projection(&rig, command)?;
let stage = rig_stage(&rig, command)?;
let mut report =
json!({"lighting":lighting,"stage":stage,"projection":projection,"requires_geometry_clearance_review":true,"document_changed":false});
let mut report = StudioPrepared {
lighting,
stage: stage.clone(),
projection,
requires_geometry_clearance_review: true,
document_changed: false,
cpu: None,
param_warnings: Vec::new(),
};
if let Some(name) = input.name {
let mut setup = super::patched_settings(s, &name, &input.set, command)?;
let Some(RenderJob::CpuRaytrace { options, .. }) = &mut setup.job else {
@@ -791,18 +876,22 @@ pub(super) fn prepare_studio(s: &mut Session, params: &Value) -> Result<Value> {
}
setup.stage = stage;
setup.validate().map_err(|e| bad(command, e.to_string()))?;
report["setup"] = json!(setup);
report["executor"] = json!("cpu-raytrace");
report["revision"] = json!(s.revision);
report["requires_indirect"] = json!(needs_indirect);
report["indirect_adoption"] = json!(
needs_indirect
.then_some(json!({"path_depth":8,"progressive_if_absent":{"samples_per_pixel":16,"seed":0},"primary_samples":1,"shadows":true}))
);
report.cpu = Some(CpuProjection {
setup,
executor: "cpu-raytrace".into(),
revision: s.revision,
requires_indirect: needs_indirect,
indirect_adoption: needs_indirect.then_some(IndirectAdoption {
path_depth: 8,
progressive_if_absent: ProgressiveDefaults { samples_per_pixel: 16, seed: 0 },
primary_samples: 1,
shadows: true,
}),
});
} else if input.enable_indirect || input.set != json!({}) {
return Err(bad(command, "CPU rig context needs name and typed set"));
}
Ok(report)
reply(command, report)
}
#[derive(Deserialize)]
@@ -832,7 +921,7 @@ pub(super) fn apply_studio(s: &mut Session, params: &Value) -> Result<Value> {
if !input.replace_stage && s.doc.render.setups.get(&input.name).is_some_and(|setup| !setup.stage.is_empty()) {
return Err(bad(command, "existing stage requires replace_stage: true"));
}
let (lighting, projection) = rig_lighting(&input.rig, command)?;
let (lighting, projection) = rig_projection(&input.rig, command)?;
let mut setup = super::patched_settings(s, &input.name, &input.set, command)?;
let stage = rig_stage(&input.rig, command)?;
if !setup.stage.is_empty() && !input.replace_stage {
@@ -865,8 +954,15 @@ pub(super) fn apply_studio(s: &mut Session, params: &Value) -> Result<Value> {
if settings != *s.doc.render {
s.doc_mut().render = Arc::new(settings);
}
Ok(
json!({"name":input.name,"setup":setup,"projection":projection,"clearance":"conservative product/stage AABB separation passed; not exact B-rep collision"}),
reply(
command,
StudioApplied {
name: input.name,
setup,
projection,
clearance: "conservative product/stage AABB separation passed; not exact B-rep collision".into(),
param_warnings: Vec::new(),
},
)
}
@@ -1,5 +1,7 @@
//! Read-only bridge between optical concepts and sourced physical references.
//! No appearance identifies a grade, assigns density, or supplies missing constants.
use crate::schema::TypedContract;
use crate::schema::render_library::{PhysicalPlan, PhysicalPrepareParams, PlannedCommand, PlannedMaterial};
use crate::{Result, Session, params::bad};
use serde::Deserialize;
use serde_json::{Value, json};
@@ -161,6 +163,7 @@ pub(super) fn associations(id: &str) -> Result<Value> {
ids.into_iter().map(|id| Ok(summary(by(profiles, "id", id, command)?, by(cards, "profile_id", id, command)?))).collect::<Result<Vec<_>>>()?;
Ok(json!({"specifications":selected,"candidate_profiles":references,"auto_assign":false,"unknown_is_zero":false}))
}
pub(super) const PREPARE_CONTRACT: TypedContract<PhysicalPrepareParams> = TypedContract::new();
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Prepare {
@@ -191,11 +194,30 @@ pub(super) fn prepare(_s: &mut Session, params: &Value) -> Result<Value> {
}
let density =
density.ok_or_else(|| bad(command, "profile lacks one supported point density; ranges and unknowns cannot be replaced by a midpoint"))?;
Ok(json!({"status":"plan_only_not_executed","profile_id":input.id,"receipts":description["receipts"],
"native_validation":description["native_validation"],"not_projected":description["native_projection"]["not_projected"],
"extended_profile":description["resource"],"document_changed":false,"assigned_bodies":[],
"command":{"command":"material.create","params":{"name":input.name,"density_g_cm3":density/1000.0,"physical_card":card}},
"requirements":["Review grade, formulation, source and conditions before execution.","This partial card is not a complete constitutive model or a certification.","Create does not assign a body; never use a constituent as an effective laminate, foam or populated assembly.","Retain this profile and omission receipt; the native card cannot encode all extended properties."]}))
let plan = PhysicalPlan {
status: "plan_only_not_executed".into(),
profile_id: input.id,
receipts: description["receipts"].clone(),
native_validation: description["native_validation"].clone(),
not_projected: description["native_projection"]["not_projected"].clone(),
extended_profile: description["resource"].clone(),
document_changed: false,
assigned_bodies: Vec::new(),
command: PlannedCommand {
command: "material.create".into(),
params: PlannedMaterial { name: input.name, density_g_cm3: density / 1000.0, physical_card: card },
},
requirements: [
"Review grade, formulation, source and conditions before execution.",
"This partial card is not a complete constitutive model or a certification.",
"Create does not assign a body; never use a constituent as an effective laminate, foam or populated assembly.",
"Retain this profile and omission receipt; the native card cannot encode all extended properties.",
]
.map(String::from)
.into(),
param_warnings: Vec::new(),
};
serde_json::to_value(plan).map_err(|e| bad(command, e.to_string()))
}
#[cfg(test)]
mod tests {
+38 -14
View File
@@ -11,15 +11,30 @@ use solidcraft_doc::{Document, Feature, FeatureKind, Param, expr};
use super::CommandSpec;
use crate::params::{bad, str_};
use crate::schema::timeline_clipboard::{AddParams, AddResult, ExportScriptParams, ExportScriptResult, Script, ScriptAbout, ScriptStep};
use crate::schema::{TypedContract, output_schema_for};
use crate::{EngineError, Result, Session};
const ADD_CONTRACT: TypedContract<AddParams> = TypedContract::new();
const EXPORT_SCRIPT_CONTRACT: TypedContract<ExportScriptParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("timeline.add", "Add Recorded Feature", add).icon("new").params(
CommandSpec::new("timeline.add", "Add Recorded Feature", crate::checked!("timeline.add", add))
.icon("new")
.contract(&ADD_CONTRACT)
.output(output_schema_for::<AddResult>)
.params(
"feature: a feature as the design file and timeline.inspect hold it ({type, name?, id?, …}; the name is kept even when another \
feature has it, the id is kept and refused when another feature has it, so later steps can refer to them); params?: [{name, expression, unit?, comment?}] (the model parameters its sketch \
dimensions use); the feature goes at the marker → feature, name, error, warning",
),
CommandSpec::new("timeline.export_script", "Export Command Script", export_script).icon("export").noundo().doc_only().params(
CommandSpec::new("timeline.export_script", "Export Command Script", crate::checked!("timeline.export_script", export_script))
.icon("export")
.noundo()
.doc_only()
.contract(&EXPORT_SCRIPT_CONTRACT)
.output(output_schema_for::<ExportScriptResult>)
.params(
"path?: write the script to this file → script: {commands: [...]} that rebuilds the design (solidcraft-cli eval/run), \
steps, omitted: what the script leaves out (appearances, folders, views, configurations, print-face assignments); components and assemblies \
are not supported yet",
@@ -56,7 +71,14 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
s.refresh();
let name = s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
let r = s.model.result(id);
Ok(json!({"feature": id, "name": name, "error": r.and_then(|r| r.error.clone()), "warning": r.and_then(|r| r.warning.clone())}))
let reply = AddResult {
feature: id,
name,
error: r.and_then(|r| r.error.clone()),
warning: r.and_then(|r| r.warning.clone()),
param_warnings: Vec::new(),
};
serde_json::to_value(reply).map_err(|e| bad(cmd, e.to_string()))
}
/// timeline.add keeps accepting legacy name-keyed pattern and mirror seeds and construction
@@ -139,7 +161,8 @@ fn recorded_feature(cmd: &str, raw: &Map<String, Value>) -> Result<Feature> {
Ok(f)
}
fn step_params(cmd: &str, v: Option<&Value>) -> Result<Vec<Param>> {
/// The model parameters of a `timeline.add` step. The reader the command's contract uses too.
pub(crate) fn step_params(cmd: &str, v: Option<&Value>) -> Result<Vec<Param>> {
let Some(v) = v.filter(|v| !v.is_null()) else { return Ok(Vec::new()) };
let list = v.as_array().filter(|a| a.len() <= MAX_STEP_PARAMS).ok_or_else(|| bad(cmd, "`params` must be a list of parameters"))?;
let text = |p: &Value, k: &str| match p.get(k) {
@@ -164,15 +187,15 @@ fn export_script(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "timeline.export_script";
let (commands, omitted) = script_steps(&s.doc)?;
let steps = commands.len();
let script = json!({
"about": {
"design": s.doc.name,
"format": solidcraft_doc::format::format_string(solidcraft_doc::format::FORMAT),
"version": env!("CARGO_PKG_VERSION"),
"replay": "solidcraft-cli eval script.json (timeline.add steps need a build that reads this design format)",
let script = Script {
about: ScriptAbout {
design: s.doc.name.clone(),
format: solidcraft_doc::format::format_string(solidcraft_doc::format::FORMAT),
replay: "solidcraft-cli eval script.json (timeline.add steps need a build that reads this design format)".into(),
version: env!("CARGO_PKG_VERSION").into(),
},
"commands": commands,
});
commands,
};
let path = str_(p, "path");
if let Some(path) = path {
let text = serde_json::to_string_pretty(&script).map_err(|e| EngineError::Other(e.to_string()))?;
@@ -180,11 +203,12 @@ fn export_script(s: &mut Session, p: &Value) -> Result<Value> {
} else if p.get("path").is_some_and(|v| !v.is_null()) {
return Err(bad(cmd, "`path` must be a file path"));
}
Ok(json!({"script": script, "steps": steps, "omitted": omitted, "path": path}))
let path = path.map(str::to_string);
serde_json::to_value(ExportScriptResult { omitted, path, script, steps, param_warnings: Vec::new() }).map_err(|e| bad(cmd, e.to_string()))
}
fn step(command: &str, params: Value) -> Value {
json!({"command": command, "params": params})
serde_json::to_value(ScriptStep { command: command.to_string(), params }).unwrap_or_default()
}
fn not_supported(what: &str) -> EngineError {
+3 -2
View File
@@ -38,11 +38,12 @@ fn section(s: &mut Session, p: &Value) -> Result<Value> {
let axis = [normal.x, normal.y, normal.z].iter().position(|c| c.abs() > 0.5);
(pl.origin, normal, axis)
}
// A construction plane (name or id): where it is now.
// A construction plane (name or id): where it stands in the world, its component placed,
// as the viewport draws it. The cut and the reply are in the world.
Some(v @ (Value::String(_) | Value::Number(_))) => {
let r = super::features::plane_param(s, Some(v), cmd)?;
let solidcraft_doc::PlaneRef::Construction { feature } = &r else { return Err(bad(cmd, "`plane` must be a plane")) };
let st = s.model.state();
let st = s.world_state()?;
let c = st
.construct
.iter()
+24 -14
View File
@@ -1,19 +1,27 @@
//! A tightly framed, view-aligned union of the projected body meshes.
use serde_json::{Value, json};
use serde_json::Value;
use solidcore_record::Vec3;
use solidcraft_geom::prelude::*;
use super::CommandSpec;
use crate::params::bad;
use crate::render::{Camera, StandardView};
use crate::schema::silhouette::{SilhouetteParams, SilhouetteProjection, SilhouetteResult};
use crate::schema::{TypedContract, output_schema_for};
use crate::{Result, Session};
const CMD: &str = "inspect.silhouette";
const CONTRACT: TypedContract<SilhouetteParams> = TypedContract::new();
const MAX_PIXELS: usize = 4 * 1024 * 1024;
// For agents comparing the model with a picture; nothing to show a person, so no toolbar button.
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new(CMD, "Silhouette", run).noundo().icon("silhouette").params(
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new(CMD, "Silhouette", crate::checked!("inspect.silhouette", run))
.noundo()
.icon("silhouette")
.contract(&CONTRACT)
.output(output_schema_for::<SilhouetteResult>)
.params(
"view?: front|back|left|right|top|bottom|iso (default front); or camera?: {target: [x,y,z], yaw, pitch, distance, fov?} (mm, radians; fov 0 means parallel); bodies?: [world body names] (default visible bodies, named hidden bodies included); rows?: mask height in pixels (default 240, clamped to 16..1024) → tightly framed run-length mask, box_mm, mm_per_px, right/up, projection, body and triangle counts",
)];
@@ -23,18 +31,20 @@ fn run(s: &mut Session, p: &Value) -> Result<Value> {
let wanted = body_names(p)?;
let out = outline(s, &camera, wanted.as_deref(), rows)?;
let mask: Vec<Vec<[usize; 2]>> = out.mask.iter().map(|row| spans(row)).collect();
Ok(json!({
"width": out.width,
"height": out.height,
"mm_per_px": round4(out.mm_per_px),
"box_mm": [[round4(out.lo[0]), round4(out.lo[1])], [round4(out.hi[0]), round4(out.hi[1])]],
"right": [clean(out.right.x), clean(out.right.y), clean(out.right.z)],
"up": [clean(out.up.x), clean(out.up.y), clean(out.up.z)],
"projection": if out.perspective { "perspective" } else { "parallel" },
"bodies": out.bodies,
"triangles": out.triangles,
"mask": mask,
}))
let reply = SilhouetteResult {
width: out.width,
height: out.height,
mm_per_px: round4(out.mm_per_px),
box_mm: [[round4(out.lo[0]), round4(out.lo[1])], [round4(out.hi[0]), round4(out.hi[1])]],
right: [clean(out.right.x), clean(out.right.y), clean(out.right.z)],
up: [clean(out.up.x), clean(out.up.y), clean(out.up.z)],
projection: if out.perspective { SilhouetteProjection::Perspective } else { SilhouetteProjection::Parallel },
bodies: out.bodies,
triangles: out.triangles,
mask,
param_warnings: Vec::new(),
};
serde_json::to_value(reply).map_err(|e| bad(CMD, e.to_string()))
}
/// A projected, tightly framed silhouette: the pixel grid, its scale and placement, and the
+312 -85
View File
@@ -8,7 +8,15 @@ use solidcraft_sketch::{Analysis, Budget, ConstraintKind, CurveKind, Hold, Point
use super::{CommandSpec, in_sketch};
use crate::params::{bad, bool_, expr, num, req_expr, req_vec2, str_, string_list, vec2, vec3};
use crate::schema::sketch::{CancelResult, EditParams, EditResult, ExitParams, FinishResult};
use crate::schema::sketch::{
CancelResult, CoincidentMade, CoincidentParams, CoincidentResult, CollinearParams, ConcentricParams, Constrained, ConstraintResult, EditParams,
EditResult, EqualMade, EqualParams, EqualResult, ExitParams, FinishResult, FixOutcome, FixParams, FixResult, HorizontalVerticalParams,
MidpointParams, ParallelParams, PerpendicularParams, SymmetryParams, SymmetryResult, TangentMade, TangentParams, TangentResult,
};
use crate::schema::sketch_shapes::{
ArcCenterPointParams, ArcThreePointParams, CircleCenterParams, CircleThreePointParams, CircleTwoPointParams, Created, DrawResult, LineParams,
PointParams, PointResult,
};
use crate::schema::{TypedContract, output_schema_for};
use crate::{EngineError, Result, Session, SketchEntry, Snapshot};
@@ -38,6 +46,24 @@ macro_rules! model_operand_doc {
const EDIT_CONTRACT: TypedContract<EditParams> = TypedContract::new();
const EXIT_CONTRACT: TypedContract<ExitParams> = TypedContract::new();
const HORIZONTAL_VERTICAL_CONTRACT: TypedContract<HorizontalVerticalParams> = TypedContract::new();
const COINCIDENT_CONTRACT: TypedContract<CoincidentParams> = TypedContract::new();
const TANGENT_CONTRACT: TypedContract<TangentParams> = TypedContract::new();
const EQUAL_CONTRACT: TypedContract<EqualParams> = TypedContract::new();
const PARALLEL_CONTRACT: TypedContract<ParallelParams> = TypedContract::new();
const PERPENDICULAR_CONTRACT: TypedContract<PerpendicularParams> = TypedContract::new();
const FIX_CONTRACT: TypedContract<FixParams> = TypedContract::new();
const MIDPOINT_CONTRACT: TypedContract<MidpointParams> = TypedContract::new();
const CONCENTRIC_CONTRACT: TypedContract<ConcentricParams> = TypedContract::new();
const COLLINEAR_CONTRACT: TypedContract<CollinearParams> = TypedContract::new();
const SYMMETRY_CONTRACT: TypedContract<SymmetryParams> = TypedContract::new();
const LINE_CONTRACT: TypedContract<LineParams> = TypedContract::new();
const POINT_CONTRACT: TypedContract<PointParams> = TypedContract::new();
const CIRCLE_CENTER_CONTRACT: TypedContract<CircleCenterParams> = TypedContract::new();
const CIRCLE_TWO_POINT_CONTRACT: TypedContract<CircleTwoPointParams> = TypedContract::new();
const CIRCLE_THREE_POINT_CONTRACT: TypedContract<CircleThreePointParams> = TypedContract::new();
const ARC_THREE_POINT_CONTRACT: TypedContract<ArcThreePointParams> = TypedContract::new();
const ARC_CENTER_POINT_CONTRACT: TypedContract<ArcCenterPointParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.create", "Create Sketch", create_sketch)
@@ -58,11 +84,13 @@ pub static COMMANDS: &[CommandSpec] = &[
.enabled(in_sketch)
.contract(&EXIT_CONTRACT).output(output_schema_for::<CancelResult>)
.params("Leaves the sketch without keeping what changed since it was created or opened for editing: the design goes back to how it was then (a new sketch is removed), as one step that Undo brings back. Refused when the undo history no longer leads back to that point (use Undo instead). restore_marker?: bool (default true); false transfers the temporary marker to another editor"),
CommandSpec::new("sketch.line", "Line", draw_line)
CommandSpec::new("sketch.line", "Line", crate::checked!("sketch.line", draw_line))
.at("SKETCH", "CREATE")
.icon("line")
.key("L")
.enabled(in_sketch)
.contract(&LINE_CONTRACT)
.output(output_schema_for::<DrawResult>)
.params(concat!("points: [[x,y] | \"l1.end\" | {at, ref}…] (x and y each a number or an expression over the design's parameters, \"W / 2\": such a point is fixed where it comes to and follows the parameters; likewise wherever a point argument also takes a point id), closed?: bool, construction?: bool, ids?: [..], infer?: bool (add horizontal/vertical)", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.draw.dimensioned", "Draw Dimensioned Sketch Geometry", draw_dimensioned).icon("dimension")
.enabled(in_sketch)
@@ -88,31 +116,39 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("rect_center")
.enabled(in_sketch)
.params(concat!("center, corner: [x,y] (a coordinate may be an expression over the design's parameters: the point follows them); construction?: bool. Returns sides {bottom, right, top, left} (line ids) and corners {bottom_left, bottom_right, top_right, top_left} (point ids)", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.circle.center", "Center Diameter Circle", circle_center)
CommandSpec::new("sketch.circle.center", "Center Diameter Circle", crate::checked!("sketch.circle.center", circle_center))
.at("SKETCH", "CREATE")
.icon("circle")
.key("C")
.enabled(in_sketch)
.contract(&CIRCLE_CENTER_CONTRACT)
.output(output_schema_for::<DrawResult>)
.params(concat!(
"center: [x,y] or point ref (a coordinate may be an expression over the design's parameters: the centre follows them), radius | diameter: number or expression (an expression naming a parameter is a dimension driven by it; with `through` a point ref, that and the point both fix the radius); through?: point ref (a point the circle passes through, kept on it: where the tool's second click snapped); construction?: bool",
connect_doc!(),
created_doc!()
)),
CommandSpec::new("sketch.circle.two_point", "2-Point Circle", circle_two)
CommandSpec::new("sketch.circle.two_point", "2-Point Circle", crate::checked!("sketch.circle.two_point", circle_two))
.at("SKETCH", "CREATE")
.icon("circle2")
.enabled(in_sketch)
.contract(&CIRCLE_TWO_POINT_CONTRACT)
.output(output_schema_for::<DrawResult>)
.params(concat!("p0, p1: diameter ends; construction?: bool", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.circle.three_point", "3-Point Circle", circle_three)
CommandSpec::new("sketch.circle.three_point", "3-Point Circle", crate::checked!("sketch.circle.three_point", circle_three))
.at("SKETCH", "CREATE")
.icon("circle3")
.enabled(in_sketch)
.contract(&CIRCLE_THREE_POINT_CONTRACT)
.output(output_schema_for::<DrawResult>)
.params(concat!("p0, p1, p2: [x,y] or point ref (a referenced point stays on the circle); construction?: bool", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.arc.three_point", "3-Point Arc", arc_three).at("SKETCH", "CREATE").icon("arc3").enabled(in_sketch).params(concat!("start, end, through: [x,y] or point ref (a referenced through point stays on the arc); construction?: bool", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.arc.center_point", "Center Point Arc", arc_center)
CommandSpec::new("sketch.arc.three_point", "3-Point Arc", crate::checked!("sketch.arc.three_point", arc_three)).at("SKETCH", "CREATE").icon("arc3").enabled(in_sketch).contract(&ARC_THREE_POINT_CONTRACT).output(output_schema_for::<DrawResult>).params(concat!("start, end, through: [x,y] or point ref (a referenced through point stays on the arc); construction?: bool", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.arc.center_point", "Center Point Arc", crate::checked!("sketch.arc.center_point", arc_center))
.at("SKETCH", "CREATE")
.icon("arc_center")
.enabled(in_sketch)
.contract(&ARC_CENTER_POINT_CONTRACT)
.output(output_schema_for::<DrawResult>)
.params(concat!("center, start, end (counter-clockwise from start; clockwise?: bool for clockwise) | center, start, sweep: signed degrees; construction?: bool", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.polygon.inscribed", "Inscribed Polygon", polygon_inscribed)
.at("SKETCH", "CREATE")
@@ -139,64 +175,86 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("slot_overall")
.enabled(in_sketch)
.params(concat!("p0, p1: overall ends, width", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.point", "Point", draw_point).at("SKETCH", "CREATE").icon("point").enabled(in_sketch).params(concat!("point: [x,y] (each a number or an expression over the design's parameters: the point is then fixed where it comes to and follows them) or a snap: \"mid:<line>\" (held at the line's midpoint), \"on:<curve>:x,y\" (on the curve near x,y), {vertex: [x,y,z]} or {on_edge: [x,y,z]} of the model; id?", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.point", "Point", crate::checked!("sketch.point", draw_point)).at("SKETCH", "CREATE").icon("point").enabled(in_sketch).contract(&POINT_CONTRACT).output(output_schema_for::<PointResult>).params(concat!("point: [x,y] (each a number or an expression over the design's parameters: the point is then fixed where it comes to and follows them) or a snap: \"mid:<line>\" (held at the line's midpoint), \"on:<curve>:x,y\" (on the curve near x,y), {vertex: [x,y,z]} or {on_edge: [x,y,z]} of the model; id?", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.dimension", "Sketch Dimension", dimension).at("SKETCH", "CREATE").icon("dimension").key("D").enabled(in_sketch).params(
"entities: [refs] (a line midpoint is \"mid:line_id\"; a model edge or vertex, {edge: [x,y,z], body?} or {vertex: [x,y,z], body?}, is projected into the sketch first and measured there: a point against the projected edge measures the distance to it), type?: auto|distance|horizontal|distance_x|vertical|distance_y|length|radius|diameter|major|minor|angle, value?: number or expression (default: current; a bare number is in the design's units, e.g. 2 means 2 in in an inch design; give a unit such as \"50 mm\" to override), or `name = expression` (e.g. \"width = 40\"; also `#name = expression`), which adds the user parameter `name` with that expression and dimensions with `name` (refused when the name is taken or reserved; the reply's `defined` names it); text_at?: [x, y]. Two points or a line off the axes: with auto, text_at across from them (between the perpendiculars through them, or a tenth of their distance past) is the distance between them; pulled further past an end, up or down from it horizontal, sideways vertical. Two midpoints: placing text above or below their bounding box measures horizontally, beside it vertically, anywhere else (inside it or past its corners) the distance between them. Two lines: the angle between them (acute, or 180° − it when the value is nearer that) unless text_at picks a sector. A line and a circle or arc: the distance from the line to its edge (the gap, or how far the edge reaches past a line that cuts it; 0 is tangent); dimension the centre point for the distance to the centre. A point and a circle or arc: the distance from the point to its edge (or how far within it a point inside lies). Two circles or arcs: the gap between their edges (or how far they overlap when they cross, or the narrowest gap between them when one lies inside the other), or the centre-to-centre distance when they are the two caps of a slot. An ellipse: a half-axis, the distance from the centre to an axis end (major or minor; with auto, text_at pulled across the major axis gives the major half-axis, pulled out past an end of it the minor one, the minor without text_at; previous_axis?: major|minor, the half-axis an earlier placement chose, kept while text_at is near the boundary between them; the reply's axis names the one chosen; with auto, when a driving dimension already holds one half-axis (or its whole axis), the other one, wherever text_at is; without its major axis line the major half-axis goes to the ellipse's own major end, without its minor axis line the minor half-axis is the minor radius); any other type, radius among them, the minor radius; an ellipse's axis line with auto: its length, the whole axis, wherever text_at is; half_axes?: bool (default true; false: with auto, the minor radius wherever text_at is, as before half-axes, which the Dimension tool sends with Draw ellipses with their axes off). An arc and one of its end points: the arc's length (type arc_length does it from the arc alone). A dimension the sketch already determines is refused (naming what determines it); if_redundant?: \"driven\" places it as a driven (reference) dimension instead. scale_sketch?: bool (the dimension tool sends the Sketch preference, on by default): when this is the sketch's first length dimension and nothing in the sketch fixes a size (no other driving length dimension, nothing fixed or projected), the whole sketch scales about its origin to the value instead of distorting. Returns measured in unit (mm or deg), and scaled: the factor when the sketch was scaled",
),
CommandSpec::new("sketch.constraint.horizontal_vertical", "Horizontal/Vertical", c_horizontal_vertical)
CommandSpec::new("sketch.constraint.horizontal_vertical", "Horizontal/Vertical", crate::checked!("sketch.constraint.horizontal_vertical", c_horizontal_vertical))
.at("SKETCH", "CONSTRAINTS")
.icon("c_hv")
.enabled(in_sketch)
.contract(&HORIZONTAL_VERTICAL_CONTRACT)
.output(output_schema_for::<ConstraintResult>)
.params("line: id | points: [a, b] (point ids or {midpoint: line_id}; accepted midpoints reuse or create a point with a Midpoint constraint); mode?: horizontal|vertical"),
CommandSpec::new("sketch.constraint.coincident", "Coincident", c_coincident)
CommandSpec::new("sketch.constraint.coincident", "Coincident", crate::checked!("sketch.constraint.coincident", c_coincident))
.at("SKETCH", "CONSTRAINTS")
.icon("c_coincident")
.enabled(in_sketch)
.contract(&COINCIDENT_CONTRACT)
.output(output_schema_for::<CoincidentResult>)
.params(concat!("a, b: points (or a line's middle as {midpoint: line}, which becomes a point tied there), a point and a curve or sketch axis (x / y: the sketch's own axes, unless a curve has that id; an axis becomes a linked construction line on it, reported in created), or two distinct lines (joins their closest endpoints), or two circles/arcs (the second lies on the first: concentric and equal, `constraints` lists both)", model_operand_doc!())),
CommandSpec::new("sketch.constraint.tangent", "Tangent", c_tangent)
CommandSpec::new("sketch.constraint.tangent", "Tangent", crate::checked!("sketch.constraint.tangent", c_tangent))
.at("SKETCH", "CONSTRAINTS")
.icon("c_tangent")
.enabled(in_sketch)
.params(concat!("a, b: curves or sketch axes (x / y: the sketch's own axes, unless a curve has that id; an axis becomes a linked construction line on it, reported in created)", model_operand_doc!())),
CommandSpec::new("sketch.constraint.equal", "Equal", c_equal)
.contract(&TANGENT_CONTRACT)
.output(output_schema_for::<TangentResult>)
.params(concat!("a, b: curves or sketch axes (x / y: the sketch's own axes, unless a curve has that id; an axis becomes a linked construction line on it, reported in created), or the end point of a line or an arc: that end then lies on the other curve (or on the other end point), and its curve is tangent there, in one step (`constraints` lists both)", model_operand_doc!())),
CommandSpec::new("sketch.constraint.equal", "Equal", crate::checked!("sketch.constraint.equal", c_equal))
.at("SKETCH", "CONSTRAINTS")
.icon("c_equal")
.enabled(in_sketch)
.contract(&EQUAL_CONTRACT)
.output(output_schema_for::<EqualResult>)
.params(concat!("a, b: lines or circles/arcs; or curves: [2 to 200 lines or circles/arcs], each made equal to the first, in one step", model_operand_doc!())),
CommandSpec::new("sketch.constraint.parallel", "Parallel", c_parallel)
CommandSpec::new("sketch.constraint.parallel", "Parallel", crate::checked!("sketch.constraint.parallel", c_parallel))
.at("SKETCH", "CONSTRAINTS")
.icon("c_parallel")
.enabled(in_sketch)
.contract(&PARALLEL_CONTRACT)
.output(output_schema_for::<ConstraintResult>)
.params(concat!("a, b: lines or sketch axes (x / y: the sketch's own axes, unless a curve has that id; an axis becomes a linked construction line on it, reported in created)", model_operand_doc!())),
CommandSpec::new("sketch.constraint.perpendicular", "Perpendicular", c_perpendicular)
CommandSpec::new("sketch.constraint.perpendicular", "Perpendicular", crate::checked!("sketch.constraint.perpendicular", c_perpendicular))
.at("SKETCH", "CONSTRAINTS")
.icon("c_perpendicular")
.enabled(in_sketch)
.contract(&PERPENDICULAR_CONTRACT)
.output(output_schema_for::<ConstraintResult>)
.params(concat!("a, b: lines or sketch axes (x / y: the sketch's own axes, unless a curve has that id; an axis becomes a linked construction line on it, reported in created)", model_operand_doc!())),
CommandSpec::new("sketch.constraint.fix", "Fix/Unfix", c_fix)
CommandSpec::new("sketch.constraint.fix", "Fix/Unfix", crate::checked!("sketch.constraint.fix", c_fix))
.at("SKETCH", "CONSTRAINTS")
.icon("c_fix")
.enabled(in_sketch)
.contract(&FIX_CONTRACT)
.output(output_schema_for::<FixResult>)
.params("entity: point or curve | entities: [points and curves]; fixed?: bool (default: fix them all unless all are fixed, then unfix)"),
CommandSpec::new("sketch.constraint.midpoint", "MidPoint", c_midpoint)
CommandSpec::new("sketch.constraint.midpoint", "MidPoint", crate::checked!("sketch.constraint.midpoint", c_midpoint))
.at("SKETCH", "CONSTRAINTS")
.icon("c_midpoint")
.enabled(in_sketch)
.contract(&MIDPOINT_CONTRACT)
.output(output_schema_for::<ConstraintResult>)
.params(concat!("point, line | a, b in pick order: a point and a line (either way round), or two lines whose midpoints meet (the first stays, the second moves)", model_operand_doc!())),
CommandSpec::new("sketch.constraint.concentric", "Concentric", c_concentric)
CommandSpec::new("sketch.constraint.concentric", "Concentric", crate::checked!("sketch.constraint.concentric", c_concentric))
.at("SKETCH", "CONSTRAINTS")
.icon("c_concentric")
.enabled(in_sketch)
.contract(&CONCENTRIC_CONTRACT)
.output(output_schema_for::<ConstraintResult>)
.params(concat!("a, b: two circles/arcs, or a circle/arc and a sketch point (including origin), in either order; the point and the curve's centre become coincident", model_operand_doc!())),
CommandSpec::new("sketch.constraint.collinear", "Collinear", c_collinear)
CommandSpec::new("sketch.constraint.collinear", "Collinear", crate::checked!("sketch.constraint.collinear", c_collinear))
.at("SKETCH", "CONSTRAINTS")
.icon("c_collinear")
.enabled(in_sketch)
.contract(&COLLINEAR_CONTRACT)
.output(output_schema_for::<ConstraintResult>)
.params(concat!("a, b: lines or sketch axes (x / y: the sketch's own axes, unless a curve has that id; an axis becomes a linked construction line on it, reported in created)", model_operand_doc!())),
CommandSpec::new("sketch.constraint.symmetry", "Symmetry", c_symmetry)
CommandSpec::new("sketch.constraint.symmetry", "Symmetry", crate::checked!("sketch.constraint.symmetry", c_symmetry))
.at("SKETCH", "CONSTRAINTS")
.icon("c_symmetry")
.enabled(in_sketch)
.contract(&SYMMETRY_CONTRACT)
.output(output_schema_for::<SymmetryResult>)
.params(concat!("a, b: two points or two curves (lines mirror their ends, circles/arcs their centres); line: symmetry line id, or x / y for the sketch's own axes (a curve with that id wins; an axis becomes a linked construction line on it, reported in created); or point (or a point id as line, \"origin\" too): symmetric about that point, each pair joined by a construction line it is the midpoint of", model_operand_doc!())),
CommandSpec::new("sketch.construction", "Normal/Construction", construction)
.at("SKETCH", "CREATE")
@@ -576,7 +634,7 @@ pub(super) fn parg(sk: &mut Sketch, doc: &solidcraft_doc::Document, v: &Value, c
if let Some(at) = v.get("at").and_then(vec2) {
return Ok(PArg::At(at));
}
Err(bad(cmd, "a point must be [x, y], a point id like \"l1.end\", or {at, ref}"))
Err(bad(cmd, POINT_ARG_MUST))
}
pub(super) fn req_parg(sk: &mut Sketch, doc: &solidcraft_doc::Document, p: &Value, k: &str, cmd: &str) -> Result<PArg> {
@@ -585,6 +643,39 @@ pub(super) fn req_parg(sk: &mut Sketch, doc: &solidcraft_doc::Document, p: &Valu
const POINT_MUST: &str = "a point's coordinates must be numbers or expressions over the design's parameters";
/// What a point argument must be, when it is none of the forms [`parg`] reads.
const POINT_ARG_MUST: &str = "a point must be [x, y], a point id like \"l1.end\", or {at, ref}";
/// Whether `v` is a point argument [`parg`] reads, looking only at the JSON: what the design
/// decides (an unknown point id, an expression naming no parameter) stays the handler's. `snaps`:
/// the command also reads the forms `sketch_project::snap_points` rewrites first (`{vertex}`,
/// `{on_edge}`). The error is the handler's message.
pub(crate) fn point_arg_form(v: &Value, snaps: bool) -> std::result::Result<(), &'static str> {
if snaps && (v.get("vertex").and_then(vec3).is_some() || v.get("on_edge").and_then(vec3).is_some()) {
return Ok(());
}
if vec2(v).is_some() {
return Ok(());
}
if let Some([x, y]) = v.as_array().map(Vec::as_slice)
&& (x.is_string() || y.is_string())
{
// The coordinates `expr_point` reads: a finite number, or text that isn't blank.
let coordinate = |c: &Value| match c {
Value::Number(n) => n.as_f64().and_then(crate::params::finite).is_some(),
Value::String(e) => !e.trim().is_empty() && e.len() <= 4096,
_ => false,
};
return if coordinate(x) && coordinate(y) { Ok(()) } else { Err(POINT_MUST) };
}
let named = match v {
Value::String(_) => true,
Value::Object(o) => o.get("ref").is_some_and(Value::is_string),
_ => false,
};
if named || v.get("at").and_then(vec2).is_some() { Ok(()) } else { Err(POINT_ARG_MUST) }
}
/// A point given as `[x, y]` with a coordinate typed as text: where it is now and, when a
/// coordinate names a parameter, the expressions that place it (none: a plain point).
pub(super) fn expr_point(doc: &solidcraft_doc::Document, v: &Value, cmd: &str) -> Result<Option<(Vec2, Option<PointExprs>)>> {
@@ -671,14 +762,16 @@ pub(crate) fn created(before: Option<&Sketch>, after: &Sketch) -> Value {
let curves: Vec<&str> = after.curves.iter().map(|x| x.id.as_str()).filter(new).collect();
let (dims, cons): (Vec<_>, Vec<_>) = after.constraints.iter().filter(|c| new(&c.id.as_str())).partition(|c| c.kind.is_dimension());
let ids = |v: Vec<&solidcraft_sketch::Constraint>| v.into_iter().map(|c| c.id.clone()).collect::<Vec<_>>();
let mut out = json!({"points": points, "curves": curves, "constraints": ids(cons), "dimensions": ids(dims)});
let wires: Vec<&str> = after.wires.iter().map(|x| x.id.as_str()).filter(new).collect();
if !wires.is_empty()
&& let Some(o) = out.as_object_mut()
{
o.insert("wires".into(), json!(wires));
}
out
let wires: Vec<String> = after.wires.iter().map(|x| x.id.as_str()).filter(new).map(str::to_string).collect();
let owned = |v: Vec<&str>| v.into_iter().map(str::to_string).collect::<Vec<_>>();
let out = Created { points: owned(points), curves: owned(curves), constraints: ids(cons), dimensions: ids(dims), wires };
serde_json::to_value(out).unwrap_or_default()
}
/// The reply of a line, circle or arc: what `result` gives, built from [`DrawResult`].
fn draw_reply(out: (Vec<String>, Vec<String>), info: Value) -> Result<Value> {
let reply = DrawResult { curves: out.0, constraints: out.1, sketch: info, created: None, param_warnings: Vec::new() };
serde_json::to_value(reply).map_err(|e| EngineError::Other(e.to_string()))
}
pub(super) fn result(sk_out: (Vec<String>, Vec<String>), info: Value) -> Value {
@@ -789,17 +882,23 @@ pub(super) fn plane_frame(p: &Value, cmd: &str) -> Result<Option<&'static str>>
}
}
/// The note a reply carries when a world plane went into a moved or turned component
/// (`placement`: that component's) for an explicit plane read in `frame`, where it differs from
/// the component's own.
pub(super) fn frame_note(frame: Option<&str>, placement: &solidcraft_doc::Mat) -> Option<&'static str> {
(frame == Some("world") && !solidcraft_doc::is_identity(placement)).then_some(
"the plane was read in world coordinates and the component is moved or turned, so it is stored at that world place in the component; frame: \"component\" reads it in the component's own coordinates",
)
}
/// The reply's `frame` for an explicit plane, with a note when a world plane went into a moved
/// or turned component (`placement`: that component's), where it differs from the component's own.
pub(super) fn frame_reply(out: &mut Value, frame: Option<&str>, placement: &solidcraft_doc::Mat) {
let Some(o) = out.as_object_mut() else { return };
let Some(frame) = frame else { return };
o.insert("frame".into(), json!(frame));
if frame == "world" && !solidcraft_doc::is_identity(placement) {
o.insert(
"note".into(),
json!("the plane was read in world coordinates and the component is moved or turned, so it is stored at that world place in the component; frame: \"component\" reads it in the component's own coordinates"),
);
if let Some(note) = frame_note(Some(frame), placement) {
o.insert("note".into(), json!(note));
}
}
@@ -1217,7 +1316,7 @@ fn draw_line(s: &mut Session, p: &Value) -> Result<Value> {
mark_construction(sk, &curves, p);
Ok((ids_of(sk, &curves), cons))
})?;
Ok(result((curves, cons), info))
draw_reply((curves, cons), info)
}
/// A relation the new line `c` has with the curve it starts from (drawn exactly so): tangent
@@ -1620,7 +1719,7 @@ fn circle_center(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok((ids_of(sk, &[ci]), cons))
})?;
Ok(result(out, info))
draw_reply(out, info)
}
fn circle_two(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1635,7 +1734,7 @@ fn circle_two(s: &mut Session, p: &Value) -> Result<Value> {
mark_construction(sk, &[ci], p);
Ok((ids_of(sk, &[ci]), Vec::new()))
})?;
Ok(result(out, info))
draw_reply(out, info)
}
/// Circle through three points.
@@ -1670,7 +1769,7 @@ fn circle_three(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok((ids_of(sk, &[ci]), cons))
})?;
Ok(result(out, info))
draw_reply(out, info)
}
fn arc_three(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1697,7 +1796,7 @@ fn arc_three(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok((ids_of(sk, &[ci]), cons))
})?;
Ok(result(out, info))
draw_reply(out, info)
}
fn arc_center(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1764,7 +1863,7 @@ fn arc_center(s: &mut Session, p: &Value) -> Result<Value> {
mark_construction(sk, &[ci], p);
Ok((ids_of(sk, &[ci]), Vec::new()))
})?;
Ok(result(out, info))
draw_reply(out, info)
}
fn polygon(s: &mut Session, p: &Value, cmd: &str, center: Vec2, r_vertex: f64, start: f64, n: usize, inscribed: bool) -> Result<Value> {
@@ -1857,6 +1956,12 @@ fn polygon_edge(s: &mut Session, p: &Value) -> Result<Value> {
}
pub(super) fn slot(s: &mut Session, p: &Value, cmd: &str, c0: Vec2, c1: Vec2, w: f64) -> Result<Value> {
let (out, info) = slot_parts(s, p, cmd, c0, c1, w)?;
Ok(result(out, info))
}
/// What [`slot`] adds: the curve and constraint ids, and the sketch's solve.
pub(super) fn slot_parts(s: &mut Session, p: &Value, cmd: &str, c0: Vec2, c1: Vec2, w: f64) -> Result<((Vec<String>, Vec<String>), Value)> {
let h = w / 2.0;
let d = (c1 - c0).normalized().ok_or_else(|| bad(cmd, "the slot ends must differ"))?;
let n = d.perp();
@@ -1875,7 +1980,7 @@ pub(super) fn slot(s: &mut Session, p: &Value, cmd: &str, c0: Vec2, c1: Vec2, w:
cons.push(add_c(sk, ConstraintKind::Equal { a: a0, b: a1 })?);
Ok((ids_of(sk, &[l1, a1, l2, a0]), cons))
})?;
Ok(result(out, info))
Ok((out, info))
}
fn slot_c2c(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1930,7 +2035,7 @@ fn draw_point(s: &mut Session, p: &Value) -> Result<Value> {
));
};
let ((), info) = edit(s, p, cmd, false, |_, _| Ok(()))?;
return Ok(json!({"point": pid, "constraints": [], "sketch": info}));
return point_reply(pid, Vec::new(), info);
}
let ((pid, cons), info) = edit(s, p, cmd, false, |sk, doc| {
let i = match expr_point(doc, p.get("point").unwrap_or(&Value::Null), cmd)? {
@@ -1941,13 +2046,38 @@ fn draw_point(s: &mut Session, p: &Value) -> Result<Value> {
let cons = connect_points(sk, i, p)?;
Ok((sk.points.get(i).map(|q| q.id.clone()).unwrap_or_default(), cons))
})?;
Ok(json!({"point": pid, "constraints": cons, "sketch": info}))
point_reply(pid, cons, info)
}
/// The reply of `sketch.point`, built from [`PointResult`].
fn point_reply(point: String, constraints: Vec<String>, sketch: Value) -> Result<Value> {
let reply = PointResult { point, constraints, sketch, created: None, param_warnings: Vec::new() };
serde_json::to_value(reply).map_err(|e| EngineError::Other(e.to_string()))
}
// ---------------------------------------------------------------------------------------------
// Constraints
fn constrain(s: &mut Session, p: &Value, cmd: &str, f: impl FnOnce(&mut Sketch) -> Result<ConstraintKind>) -> Result<Value> {
/// A command's reply as JSON: they build their replies from the types their output schemas
/// describe, so the two can't drift apart.
fn reply(cmd: &str, reply: impl serde::Serialize) -> Result<Value> {
serde_json::to_value(reply).map_err(|e| bad(cmd, e.to_string()))
}
/// [`constrain`], replying as the commands that add one constraint do.
fn constrain_reply(s: &mut Session, p: &Value, cmd: &str, f: impl FnOnce(&mut Sketch) -> Result<ConstraintKind>) -> Result<Value> {
let made = constrain(s, p, cmd, f)?;
reply(cmd, ConstraintResult { made, param_warnings: Vec::new() })
}
/// The constraint a command added and, when the sketch already determined it, what determines it.
fn constrained(p: &Value, cmd: &str, id: String, info: Value, by: Option<Vec<String>>) -> Result<Constrained> {
let Some(by) = by else { return Ok(Constrained { constraint: id, sketch: info, redundant: None, determined_by: None }) };
keep_redundant(p, cmd, &by)?;
Ok(Constrained { constraint: id, sketch: info, redundant: Some(true), determined_by: Some(by) })
}
fn constrain(s: &mut Session, p: &Value, cmd: &str, f: impl FnOnce(&mut Sketch) -> Result<ConstraintKind>) -> Result<Constrained> {
let mut before = None;
let mut residual = None;
let (id, info) = edit(s, p, cmd, true, |sk, _| {
@@ -1966,7 +2096,7 @@ fn constrain(s: &mut Session, p: &Value, cmd: &str, f: impl FnOnce(&mut Sketch)
Ok(id)
})?;
let by = redundancy(s, p, cmd, before, &info, residual, &id)?;
constraint_result(p, cmd, id, info, by, Value::Null)
constrained(p, cmd, id, info, by)
}
/// A sketch entity a constraint is applied to.
@@ -2253,21 +2383,6 @@ pub(super) fn keep_redundant(p: &Value, cmd: &str, by: &[String]) -> Result<()>
Ok(())
}
/// The result of a constraint command: the constraint, and whether it is redundant (and what
/// determines it).
pub(super) fn constraint_result(p: &Value, cmd: &str, id: String, info: Value, by: Option<Vec<String>>, extra: Value) -> Result<Value> {
let mut out = json!({"constraint": id, "sketch": info});
if let Some(by) = by {
keep_redundant(p, cmd, &by)?;
out["redundant"] = json!(true);
out["determined_by"] = json!(by);
}
if let (Some(o), Value::Object(e)) = (out.as_object_mut(), extra) {
o.extend(e);
}
Ok(out)
}
/// The residual of the constraint `id` where the sketch is now (see
/// [`solidcraft_sketch::constraint_residual`]).
pub(super) fn residual_of(sk: &Sketch, id: &str) -> Option<f64> {
@@ -2310,7 +2425,7 @@ impl AlignmentTarget {
fn c_horizontal_vertical(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.constraint.horizontal_vertical";
let mode = str_(p, "mode").map(str::to_ascii_lowercase);
constrain(s, p, cmd, |sk| {
constrain_reply(s, p, cmd, |sk| {
if let Some(pts) = p.get("points").and_then(Value::as_array) {
if pts.len() != 2 {
return Err(bad(cmd, "`points` needs exactly two point references"));
@@ -2375,7 +2490,8 @@ fn c_coincident(s: &mut Session, p: &Value) -> Result<Value> {
&& let Some(c) = sk.constraints.iter().find(|c| matches!(c.kind, ConstraintKind::Coincident { p, q } if (p, q) == (a, b) || (p, q) == (b, a)))
{
let rep = solve(&mut sk.clone());
return Ok(json!({"constraint": c.id, "existing": true, "sketch": report_json(&rep)}));
let made = CoincidentMade::Existing { constraint: c.id.clone(), existing: true, sketch: report_json(&rep) };
return reply(cmd, CoincidentResult { made, param_warnings: Vec::new() });
}
let pick = |sk: &mut Sketch| -> Result<ConstraintKind> {
if let Some((a, b)) = line_points {
@@ -2418,7 +2534,7 @@ fn c_coincident(s: &mut Session, p: &Value) -> Result<Value> {
let c = curve_or_axis(sk, bref)?.ok_or_else(|| bad(cmd, format!("unknown entity `{bref}`")))?;
Ok(ConstraintKind::PointOnCurve { p: a, c })
};
constrain(s, p, cmd, |sk| {
let made = constrain(s, p, cmd, |sk| {
let k = pick(sk)?;
// A line's two ends on each other shrink it to a point, as a line's own end at its
// middle does (see `c_midpoint`).
@@ -2428,7 +2544,8 @@ fn c_coincident(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, "a line's two ends can't be coincident: the line would shrink to a point"));
}
Ok(k)
})
})?;
reply(cmd, CoincidentResult { made: CoincidentMade::Added(made), param_warnings: Vec::new() })
}
/// `a` and `b` both circles or arcs (two different ones).
@@ -2463,7 +2580,8 @@ fn coincident_circles(s: &mut Session, p: &Value, cmd: &str, a: usize, b: usize)
apply_in_order(sk, &rep, &[Ent::Curve(a)], Ent::Curve(b), cmd, &ids)?;
Ok(ids)
})?;
Ok(json!({"constraint": ids[0], "constraints": ids, "sketch": info}))
let constraint = ids.first().cloned().unwrap_or_default();
reply(cmd, CoincidentResult { made: CoincidentMade::Circles { constraint, constraints: ids, sketch: info }, param_warnings: Vec::new() })
}
/// Four endpoint pairs, ordered independently of pick order when distances tie.
@@ -2518,23 +2636,132 @@ fn two_curves_or_axes(sk: &mut Sketch, p: &Value, cmd: &str) -> Result<(usize, u
}
fn c_tangent(s: &mut Session, p: &Value) -> Result<Value> {
let p = &super::sketch_project::project_operands(s, p, "sketch.constraint.tangent", &["a", "b"])?;
constrain(s, p, "sketch.constraint.tangent", |sk| {
two_curves_or_axes(sk, p, "sketch.constraint.tangent")
.and_then(|ab| two_different("sketch.constraint.tangent", ab))
.map(|(a, b)| ConstraintKind::Tangent { a, b })
})
let cmd = "sketch.constraint.tangent";
let p = &super::sketch_project::project_operands(s, p, cmd, &["a", "b"])?;
let sk = sketch_store::read(&s.doc, sketch_store::target(s, p, cmd)?)?;
if tangent_end(sk, p, "a").is_some() || tangent_end(sk, p, "b").is_some() {
return tangent_at_end(s, p, cmd);
}
let made = constrain(s, p, cmd, |sk| {
two_curves_or_axes(sk, p, cmd).and_then(|ab| two_different(cmd, ab)).map(|(a, b)| ConstraintKind::Tangent { a, b })
})?;
reply(cmd, TangentResult { made: TangentMade::Curves(made), param_warnings: Vec::new() })
}
/// The point a Tangent operand names when it names no curve and no sketch axis (#12092). `x` and
/// `y` name the axes even before their lines exist, as in [`line_or_axis_ref`].
fn tangent_end(sk: &Sketch, p: &Value, key: &str) -> Option<usize> {
let id = p.get(key)?.as_str()?;
if sk.curve_index(id).is_some() || solidcraft_sketch::sketch_axis_geom(id).is_some() {
return None;
}
sk.resolve_point(id)
}
/// The one curve that ends at point `q`, leaving out `other`; `None` when no curve or more than
/// one does.
fn ended_by(sk: &Sketch, q: usize, other: Option<usize>) -> Option<usize> {
let mut owners = sk
.curves
.iter()
.enumerate()
.filter(|(i, c)| Some(*i) != other && c.kind.ends().is_some_and(|(start, end)| start == q || end == q))
.map(|(i, _)| i);
let first = owners.next()?;
owners.next().is_none().then_some(first)
}
/// Whether `k` already holds through the sketch's own constraints, so Tangent need not add it
/// (#12092): the same constraint (a coincident in either order), or a point on a curve that is one
/// of that curve's ends or is joined to one by a coincident.
fn already_held(sk: &Sketch, k: &ConstraintKind) -> bool {
let joined = |p: usize, q: usize| {
p == q
|| sk
.constraints
.iter()
.any(|c| !c.driven && matches!(c.kind, ConstraintKind::Coincident { p: x, q: y } if (x, y) == (p, q) || (x, y) == (q, p)))
};
match *k {
ConstraintKind::Coincident { p, q } => joined(p, q),
ConstraintKind::PointOnCurve { p, c } => {
sk.curves.get(c).and_then(|curve| curve.kind.ends()).is_some_and(|(start, end)| joined(p, start) || joined(p, end))
|| sk.constraints.iter().any(|x| !x.driven && x.kind == *k)
}
_ => sk.constraints.iter().any(|c| !c.driven && c.kind == *k),
}
}
/// Tangent with a curve's end point for `a` or `b` (#12092): that end lies on the other curve,
/// or on the other end point, and the curve it ends is tangent to the other curve there. Both
/// constraints come in one step. A line's end picked with a curve is what moves; otherwise the
/// first pick stays and the second moves, as with two curves.
fn tangent_at_end(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
let not_an_end = |key: &str| {
let id = p.get(key).and_then(Value::as_str).unwrap_or_default();
bad(cmd, format!("`{id}` is not the end of one curve: pick a curve, or the end point of a line or an arc"))
};
let (out, info) = edit(s, p, cmd, true, |sk, _| {
let rep = solve(&mut sk.clone());
let (pa, pb) = (tangent_end(sk, p, "a"), tangent_end(sk, p, "b"));
let ca = if pa.is_none() { Some(line_or_axis_ref(sk, p.get("a"), cmd, "a")?) } else { None };
let cb = if pb.is_none() { Some(line_or_axis_ref(sk, p.get("b"), cmd, "b")?) } else { None };
let a = match (ca, pa) {
(Some(c), _) => c,
(None, Some(q)) => ended_by(sk, q, cb).ok_or_else(|| not_an_end("a"))?,
(None, None) => return Err(not_an_end("a")),
};
let b = match (cb, pb) {
(Some(c), _) => c,
(None, Some(q)) => ended_by(sk, q, Some(a)).ok_or_else(|| not_an_end("b"))?,
(None, None) => return Err(not_an_end("b")),
};
let (a, b) = two_different(cmd, (a, b))?;
let touch = match (pa, pb) {
(Some(q), Some(r)) if q != r => Some(ConstraintKind::Coincident { p: q, q: r }),
(Some(q), None) => Some(ConstraintKind::PointOnCurve { p: q, c: b }),
(None, Some(r)) => Some(ConstraintKind::PointOnCurve { p: r, c: a }),
_ => None,
};
// A joint: the end already lies on the other curve (it is that curve's end, or joined to it).
let joint = touch.as_ref().is_some_and(|k| already_held(sk, k));
let mut ids = Vec::new();
if let Some(k) = touch.filter(|_| !joint) {
ids.push(add_c(sk, k)?);
}
let tangent = add_c(sk, ConstraintKind::Tangent { a, b })?;
ids.push(tangent.clone());
// A line's end and a curve, in either order: only that end moves, so the line turns about
// its other end and gets longer or shorter to meet the curve (#12092). At a joint that end
// can't leave the other curve: the curve it ends stays, and the other curve moves. Otherwise,
// as for two curves, the first pick stays and the second moves.
let end = match (pa, pb) {
(Some(q), None) => Some((q, a, b)),
(None, Some(r)) => Some((r, b, a)),
_ => None,
};
let far = end.filter(|_| !joint).and_then(|(q, line, other)| match sk.curves.get(line).map(|c| &c.kind) {
Some(k @ CurveKind::Line { .. }) => k.ends().map(|(start, end)| (q, if start == q { end } else { start }, other)),
_ => None,
});
let (stay, moves) = end.filter(|_| joint).map_or((a, b), |(_, own, other)| (own, other));
match far {
Some((q, f, other)) => apply_in_order(sk, &rep, &[Ent::Curve(other), Ent::Point(f)], Ent::Point(q), cmd, &ids)?,
None => apply_in_order(sk, &rep, &[Ent::Curve(stay)], Ent::Curve(moves), cmd, &ids)?,
}
Ok((tangent, ids))
})?;
let (constraint, constraints) = out;
reply(cmd, TangentResult { made: TangentMade::End { constraint, constraints, sketch: info }, param_warnings: Vec::new() })
}
fn c_equal(s: &mut Session, p: &Value) -> Result<Value> {
if p.get("curves").is_some() {
return equal_curves(s, p);
}
let p = &super::sketch_project::project_operands(s, p, "sketch.constraint.equal", &["a", "b"])?;
constrain(s, p, "sketch.constraint.equal", |sk| {
two_curves(sk, p, "sketch.constraint.equal")
.and_then(|ab| two_different("sketch.constraint.equal", ab))
.map(|(a, b)| ConstraintKind::Equal { a, b })
})
let cmd = "sketch.constraint.equal";
let made = constrain(s, p, cmd, |sk| two_curves(sk, p, cmd).and_then(|ab| two_different(cmd, ab)).map(|(a, b)| ConstraintKind::Equal { a, b }))?;
reply(cmd, EqualResult { made: EqualMade::Pair(made), param_warnings: Vec::new() })
}
/// Equal over a list of curves (#4985): each one is made equal to the first, in one edit (one
@@ -2566,11 +2793,11 @@ fn equal_curves(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok(out)
})?;
Ok(json!({"constraints": out, "sketch": info}))
reply(cmd, EqualResult { made: EqualMade::Curves { constraints: out, sketch: info }, param_warnings: Vec::new() })
}
fn c_parallel(s: &mut Session, p: &Value) -> Result<Value> {
let p = &super::sketch_project::project_operands(s, p, "sketch.constraint.parallel", &["a", "b"])?;
constrain(s, p, "sketch.constraint.parallel", |sk| {
constrain_reply(s, p, "sketch.constraint.parallel", |sk| {
two_curves_or_axes(sk, p, "sketch.constraint.parallel")
.and_then(|ab| two_different("sketch.constraint.parallel", ab))
.map(|(a, b)| ConstraintKind::Parallel { a, b })
@@ -2578,7 +2805,7 @@ fn c_parallel(s: &mut Session, p: &Value) -> Result<Value> {
}
fn c_perpendicular(s: &mut Session, p: &Value) -> Result<Value> {
let p = &super::sketch_project::project_operands(s, p, "sketch.constraint.perpendicular", &["a", "b"])?;
constrain(s, p, "sketch.constraint.perpendicular", |sk| {
constrain_reply(s, p, "sketch.constraint.perpendicular", |sk| {
two_curves_or_axes(sk, p, "sketch.constraint.perpendicular")
.and_then(|ab| two_different("sketch.constraint.perpendicular", ab))
.map(|(a, b)| ConstraintKind::Perpendicular { a, b })
@@ -2587,7 +2814,7 @@ fn c_perpendicular(s: &mut Session, p: &Value) -> Result<Value> {
fn c_concentric(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.constraint.concentric";
let p = &super::sketch_project::project_operands(s, p, cmd, &["a", "b"])?;
constrain(s, p, cmd, |sk| {
constrain_reply(s, p, cmd, |sk| {
let centre = |key: &str| {
let curve = sk.curves.get(sk.curve_index(p.get(key)?.as_str()?)?)?;
match curve.kind {
@@ -2604,7 +2831,7 @@ fn c_concentric(s: &mut Session, p: &Value) -> Result<Value> {
}
fn c_collinear(s: &mut Session, p: &Value) -> Result<Value> {
let p = &super::sketch_project::project_operands(s, p, "sketch.constraint.collinear", &["a", "b"])?;
constrain(s, p, "sketch.constraint.collinear", |sk| {
constrain_reply(s, p, "sketch.constraint.collinear", |sk| {
two_curves_or_axes(sk, p, "sketch.constraint.collinear")
.and_then(|ab| two_different("sketch.constraint.collinear", ab))
.map(|(a, b)| ConstraintKind::Collinear { a, b })
@@ -2648,7 +2875,7 @@ fn c_midpoint(s: &mut Session, p: &Value) -> Result<Value> {
_ => Err(bad(cmd, "MidPoint needs a point and a line, or two lines")),
}
};
constrain(s, p, cmd, |sk| {
constrain_reply(s, p, cmd, |sk| {
let k = pick(sk)?;
// A line's own end at its middle shrinks the line to a point; an angle, tangent or
// parallel on it afterwards had no direction to hold and threw points millions of
@@ -2764,7 +2991,7 @@ fn c_symmetry(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok(out)
})?;
Ok(json!({"constraints": ids, "sketch": info}))
reply(cmd, SymmetryResult { constraints: ids, sketch: info, param_warnings: Vec::new() })
}
/// True when the ends of line `a` mirror (about the line `l`) closer onto the ends of `b` crossed
@@ -2890,9 +3117,9 @@ fn c_fix(s: &mut Session, p: &Value) -> Result<Value> {
sk.constraints.retain(|k| !matches!(k.kind, ConstraintKind::Fix { p } if ids.contains(&p)));
}
}
Ok(json!({"fixed": fix, "changed": n}))
Ok(FixOutcome { fixed: fix, changed: n })
})?;
Ok(json!({"result": n, "sketch": info}))
reply(cmd, FixResult { result: n, sketch: info, param_warnings: Vec::new() })
}
// ---------------------------------------------------------------------------------------------
+35 -11
View File
@@ -11,16 +11,30 @@ use super::sketch::edit;
use super::sketch_store;
use super::{CommandSpec, in_sketch};
use crate::params::{bad, req_vec3, vec3};
use crate::schema::sketch_3d::{Curves3dResult, Line3dParams, Move3dParams, Move3dResult, Point3dParams, Point3dResult, Spline3dParams};
use crate::schema::{TypedContract, output_schema_for};
use crate::{Result, Session};
const LINE3D_CONTRACT: TypedContract<Line3dParams> = TypedContract::new();
const SPLINE3D_CONTRACT: TypedContract<Spline3dParams> = TypedContract::new();
const POINT3D_CONTRACT: TypedContract<Point3dParams> = TypedContract::new();
const MOVE3D_CONTRACT: TypedContract<Move3dParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.line3d", "3D Line", line3d).icon("line").enabled(in_sketch_3d).params(concat!(
"points: [[x,y,z]…] world points (2 or more): one 3D line per segment, joined end to end (needs 3D Sketch on)",
created_doc!()
)),
CommandSpec::new("sketch.spline3d", "3D Spline", spline3d)
CommandSpec::new("sketch.line3d", "3D Line", crate::checked!("sketch.line3d", line3d))
.icon("line")
.enabled(in_sketch_3d)
.contract(&LINE3D_CONTRACT)
.output(output_schema_for::<Curves3dResult>)
.params(concat!(
"points: [[x,y,z]…] world points (2 or more): one 3D line per segment, joined end to end (needs 3D Sketch on)",
created_doc!()
)),
CommandSpec::new("sketch.spline3d", "3D Spline", crate::checked!("sketch.spline3d", spline3d))
.icon("spline")
.enabled(in_sketch_3d)
.contract(&SPLINE3D_CONTRACT)
.output(output_schema_for::<Curves3dResult>)
.params(concat!("points: [[x,y,z]…] world fit points (2 or more): a smooth 3D curve through them (needs 3D Sketch on)", created_doc!())),
CommandSpec::new("sketch.arc3d", "3D Arc", arc3d)
.icon("arc3")
@@ -29,16 +43,26 @@ pub static COMMANDS: &[CommandSpec] = &[
"start, through, end: [x,y,z] world points: an exact circular arc through them, from start through through to end (needs 3D Sketch on). Stored exactly (centre, radius, angles), so path consumers can treat it as a true arc",
created_doc!()
)),
CommandSpec::new("sketch.point3d", "3D Point", point3d)
CommandSpec::new("sketch.point3d", "3D Point", crate::checked!("sketch.point3d", point3d))
.icon("point")
.enabled(in_sketch_3d)
.contract(&POINT3D_CONTRACT)
.output(output_schema_for::<Point3dResult>)
.params(concat!("point: [x,y,z] world point (needs 3D Sketch on)", created_doc!())),
CommandSpec::new("sketch.move3d", "Move 3D Point", move3d)
CommandSpec::new("sketch.move3d", "Move 3D Point", crate::checked!("sketch.move3d", move3d))
.icon("sketch_move")
.enabled(in_sketch)
.contract(&MOVE3D_CONTRACT)
.output(output_schema_for::<Move3dResult>)
.params("wire: drawn 3D curve or point id, index?: fit point (default 0), to: [x,y,z] | by: [dx,dy,dz]; points joined to it move too"),
];
/// A command's reply as JSON: built from the type its output schema describes, so the two can't
/// drift apart.
fn reply(cmd: &str, reply: impl serde::Serialize) -> Result<Value> {
serde_json::to_value(reply).map_err(|e| bad(cmd, e.to_string()))
}
fn points(cmd: &str, p: &Value, min: usize) -> Result<Vec<Vec3>> {
let a = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`points` must list [x,y,z] points"))?;
if a.len() < min || a.len() > solidcraft_sketch::MAX_FIT_POINTS {
@@ -106,7 +130,7 @@ fn line3d(s: &mut Session, p: &Value) -> Result<Value> {
needs_3d(sk, cmd)?;
pts.windows(2).map(|w| sk.add_drawn_wire_in(w, WireSpace::Component).map_err(Into::into)).collect::<Result<Vec<String>>>()
})?;
Ok(json!({"curves": ids, "sketch": info}))
reply(cmd, Curves3dResult { curves: ids, sketch: info, param_warnings: Vec::new() })
}
fn spline3d(s: &mut Session, p: &Value) -> Result<Value> {
@@ -117,7 +141,7 @@ fn spline3d(s: &mut Session, p: &Value) -> Result<Value> {
needs_3d(sk, cmd)?;
Ok(sk.add_drawn_wire_in(&pts, WireSpace::Component)?)
})?;
Ok(json!({"curves": [id], "sketch": info}))
reply(cmd, Curves3dResult { curves: vec![id], sketch: info, param_warnings: Vec::new() })
}
fn arc3d(s: &mut Session, p: &Value) -> Result<Value> {
@@ -145,7 +169,7 @@ fn point3d(s: &mut Session, p: &Value) -> Result<Value> {
needs_3d(sk, cmd)?;
Ok(sk.add_drawn_wire_in(&[at], WireSpace::Component)?)
})?;
Ok(json!({"point": id, "sketch": info}))
reply(cmd, Point3dResult { point: id, sketch: info, param_warnings: Vec::new() })
}
fn move3d(s: &mut Session, p: &Value) -> Result<Value> {
@@ -183,7 +207,7 @@ fn move3d(s: &mut Session, p: &Value) -> Result<Value> {
};
Ok(sk.move_drawn_point(&wire, index, to)?)
})?;
Ok(json!({"moved": n, "sketch": info}))
reply(cmd, Move3dResult { moved: n, sketch: info, param_warnings: Vec::new() })
}
#[cfg(test)]
@@ -95,6 +95,23 @@ fn a_real_curve_named_like_an_axis_wins() {
assert!((start.x + end.x - 10.0).abs() < 1e-7, "mirrored about x = 5: {start:?} {end:?}");
}
/// Tangent also takes a curve's end point (#12092), but `x` and `y` still name the axes before
/// their lines exist: a point called `x` does not take the axis's place.
#[test]
fn a_point_named_like_an_axis_leaves_tangent_on_the_axis() {
for (a, b) in [("c", "x"), ("x", "c")] {
let mut s = on(json!("XY"));
run(&mut s, "sketch.point", json!({"point": [3, 7], "id": "x", "connect": false}));
run(&mut s, "sketch.circle.center", json!({"center": [10, 4], "radius": 2, "id": "c"}));
run(&mut s, "sketch.constraint.tangent", json!({"a": a, "b": b}));
let sk = sketch(&s);
assert_eq!(axis_lines(&sk).iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>(), ["x"], "{a}, {b}: the x axis's line");
let CurveKind::Circle { c: centre, r } = sk.curves[sk.curve_index("c").unwrap()].kind else { panic!() };
assert!((sk.point(centre).unwrap().y.abs() - r).abs() < 1e-7, "{a}, {b}: tangent to x");
assert!(pt(&sk, "x").dist(Vec2::new(3.0, 7.0)) < 1e-9, "{a}, {b}: the point called x moved");
}
}
#[test]
fn a_rectangle_centred_on_the_origin_keeps_one_profile() {
let mut s = on(json!("XY"));
+197 -69
View File
@@ -9,6 +9,7 @@ use std::collections::BTreeMap;
use serde_json::{Value, json};
use solidcore_record::Vec2;
use solidcraft_doc::Document;
use solidcraft_geom::Aabb2;
use solidcraft_sketch::{ConstraintKind, CurveKind, LinkKind, LinkSource, Sketch};
@@ -16,13 +17,14 @@ use super::sketch::edit;
use super::sketch_store;
use super::{CommandSpec, in_sketch};
use crate::params::{bad, string_list, vec2};
use crate::schema::sketch_clipboard::{Copied, CopyParams, CutParams, PasteParams, Pasted};
use crate::schema::sketch_clipboard::{Copied, CopyParams, CutParams, PasteParams, Pasted, ReplaceParams, Replaced};
use crate::schema::{TypedContract, output_schema_for};
use crate::{Result, Sel, Session};
const COPY_CONTRACT: TypedContract<CopyParams> = TypedContract::new();
const CUT_CONTRACT: TypedContract<CutParams> = TypedContract::new();
const PASTE_CONTRACT: TypedContract<PasteParams> = TypedContract::new();
const REPLACE_CONTRACT: TypedContract<ReplaceParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.copy", "Copy Sketch Entities", crate::checked!("sketch.copy", copy)).icon("copy")
@@ -44,6 +46,13 @@ pub static COMMANDS: &[CommandSpec] = &[
.params(
"at?: [x, y] (sketch point the copy's centre goes to) | translate?: [x, y]; without either, offset from the original in its own sketch and at the same coordinates in another; sketch?: target (default: the active sketch)",
),
CommandSpec::new("sketch.replace", "Replace Sketch", crate::checked!("sketch.replace", replace))
.icon("sketch")
.contract(&REPLACE_CONTRACT)
.output(output_schema_for::<Replaced>)
.params(
"sketch?: the sketch to replace (default: the active sketch); from: the sketch whose contents take its place (id or name); file?: a design file `from` is in (default: this design). The sketch keeps its plane, place in the timeline and the features built on it; its curves, points, constraints, dimensions and texts become a copy of `from`'s, at the same sketch coordinates (one undo step)",
),
];
/// A curve of the clipboard; its points are indices into [`SketchClip::points`].
@@ -85,8 +94,14 @@ pub struct SketchClip {
pub extent: f64,
/// Pastes made without a position since the copy (each lands one offset further on).
pub pastes: u32,
/// The clipboard point that stands for the sketch origin (in a whole-sketch copy): a paste
/// ties it to the origin of the sketch it goes to rather than adding a point.
pub origin: Option<usize>,
}
/// The id of a sketch's origin point.
const ORIGIN: &str = "origin";
impl SketchClip {
/// This clipboard as another design pastes it (#9258): at the same sketch coordinates (its
/// sketch ids mean nothing there), and with nothing that names the copied design's
@@ -146,8 +161,17 @@ fn copy(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, "select the sketch curves or points to copy"));
}
let sk = sketch_store::read(&s.doc, id)?;
let (clip, out) = clip_of(&s.doc, sk, id, &ids, false, cmd)?;
s.sketch_clipboard = Some(clip);
reply(cmd, out)
}
/// The clipboard copy of the entities `ids` of sketch `id` (`sk`, in `doc`), and what it holds.
/// `whole` is a copy of the whole sketch for [`replace`]: its fixes come along, its constraints to
/// the origin stay tied to the origin of the sketch it goes to, and an empty sketch is a copy too.
fn clip_of(doc: &Document, sk: &Sketch, id: u64, ids: &[String], whole: bool, cmd: &str) -> Result<(SketchClip, Copied)> {
let (mut cs, mut ps, mut texts): (Vec<usize>, Vec<usize>, Vec<String>) = (Vec::new(), Vec::new(), Vec::new());
for e in &ids {
for e in ids {
if let Some(c) = sk.curve_index(e) {
match text_of(sk, c) {
Some(t) if !texts.iter().any(|x| x == t) => texts.push(t.to_string()),
@@ -169,6 +193,11 @@ fn copy(s: &mut Session, p: &Value) -> Result<Value> {
let mut clip = SketchClip { sketch: id, ..Default::default() };
// Points: those of the copied curves, then the lone ones, each once.
let mut pmap: BTreeMap<usize, usize> = BTreeMap::new();
if whole && let Some(o) = sk.points.iter().position(|q| q.id == ORIGIN) {
clip.points.push(Vec2::ZERO);
clip.origin = Some(0);
pmap.insert(o, 0);
}
let take = |q: usize, clip: &mut SketchClip, pmap: &mut BTreeMap<usize, usize>| -> usize {
*pmap.entry(q).or_insert_with(|| {
clip.points.push(sk.point(q).unwrap_or_default());
@@ -193,21 +222,17 @@ fn copy(s: &mut Session, p: &Value) -> Result<Value> {
}
}
for c in &sk.constraints {
// Fixes and pattern or offset relations stay with the original.
if matches!(
c.kind,
ConstraintKind::Fix { .. }
| ConstraintKind::PatternPoint { .. }
| ConstraintKind::PatternRadius { .. }
| ConstraintKind::OffsetDistance { .. }
) {
// Pattern or offset relations stay with the original, and so do the fixes of a part copy.
if matches!(c.kind, ConstraintKind::PatternPoint { .. } | ConstraintKind::PatternRadius { .. } | ConstraintKind::OffsetDistance { .. })
|| (!whole && matches!(c.kind, ConstraintKind::Fix { .. }))
{
continue;
}
let mut kind = c.kind.clone();
if !kind.remap(&|q| pmap.get(&q).copied(), &|k| cmap.get(&k).copied()) {
continue;
}
let param = c.param.as_ref().map(|name| match s.doc.param(name) {
let param = c.param.as_ref().map(|name| match doc.param(name) {
Some(x) => (x.expr.clone(), x.unit.clone()),
None => (format!("{}", kind.value().unwrap_or_default()), if kind.is_angle() { "rad".into() } else { "mm".into() }),
});
@@ -238,12 +263,13 @@ fn copy(s: &mut Session, p: &Value) -> Result<Value> {
add(*q);
}
}
if bounds.is_empty() {
if !bounds.is_empty() {
clip.center = bounds.center();
let size = bounds.size();
clip.extent = size.x.max(size.y);
} else if !whole {
return Err(bad(cmd, "nothing to copy"));
}
clip.center = bounds.center();
let size = bounds.size();
clip.extent = size.x.max(size.y);
let out = Copied {
curves: clip.curves.len() + texts.iter().map(|t| sk.link_curves(t).len()).sum::<usize>(),
points: clip.lone.len(),
@@ -252,8 +278,7 @@ fn copy(s: &mut Session, p: &Value) -> Result<Value> {
center: [clip.center.x, clip.center.y],
param_warnings: Vec::new(),
};
s.sketch_clipboard = Some(clip);
reply(cmd, out)
Ok((clip, out))
}
fn reply(cmd: &str, reply: impl serde::Serialize) -> Result<Value> {
@@ -315,58 +340,7 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
}
let mut p = p.clone();
p["sketch"] = json!(target);
let ((curves, lone, constraints), info) = edit(s, &p, cmd, false, |sk, doc| {
if sk.curves.len().saturating_add(clip.curves.len()) > solidcraft_sketch::MAX_CURVES {
return Err(solidcraft_sketch::SketchError::TooLarge.into());
}
let mut pts = Vec::with_capacity(clip.points.len());
for q in &clip.points {
pts.push(sk.add_point(*q + t, None)?);
}
let mut made = Vec::with_capacity(clip.curves.len());
for c in &clip.curves {
let mut kind = c.kind.clone();
kind.map_points(&|q| pts.get(q).copied().unwrap_or(usize::MAX));
let i = sk.add_curve(kind, None)?;
if let Some(cu) = sk.curves.get_mut(i) {
cu.construction = c.construction;
cu.centerline = c.centerline;
cu.reversed = c.reversed;
cu.infinite = c.infinite;
}
made.push(i);
}
let mut cons = Vec::new();
for c in &clip.constraints {
let mut kind = c.kind.clone();
if !kind.remap(&|q| pts.get(q).copied(), &|k| made.get(k).copied()) {
continue;
}
// Each pasted dimension gets its own parameter, with the same expression.
let param = c.param.as_ref().map(|(e, u)| doc.new_model_param(e, u));
let id = sk.add_constraint(kind, param)?;
if let Some(nc) = sk.constraints.last_mut() {
nc.driven = c.driven;
nc.text = c.text;
}
cons.push(id);
}
let mut curve_ids: Vec<String> = made.iter().filter_map(|i| sk.curves.get(*i).map(|c| c.id.clone())).collect();
for (src, path) in &clip.texts {
let mut src = src.clone();
let LinkSource::Text { at, style, exprs, .. } = &mut src else { continue };
style.path = path.and_then(|k| made.get(k)).and_then(|i| sk.curves.get(*i)).map(|c| c.id.clone());
*at += t;
// The copy sits where it was pasted: its place no longer follows the original's expressions.
exprs.x = None;
exprs.y = None;
let geom = sk.text_source_geometry(&src)?;
let link = sk.add_link(LinkKind::Text, src, &geom)?;
curve_ids.extend(sk.link_curves(&link).into_iter().filter_map(|i| sk.curves.get(i).map(|c| c.id.clone())));
}
let lone: Vec<String> = clip.lone.iter().filter_map(|n| pts.get(*n)).filter_map(|i| sk.points.get(*i).map(|q| q.id.clone())).collect();
Ok((curve_ids, lone, cons))
})?;
let ((curves, lone, constraints), info) = edit(s, &p, cmd, false, |sk, doc| paste_clip(sk, doc, &clip, t))?;
if !positioned
&& target == clip.sketch
&& let Some(c) = s.sketch_clipboard.as_mut()
@@ -384,6 +358,160 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
reply(cmd, Pasted { curves, points: lone, constraints, translate: [t.x, t.y], sketch: info, param_warnings: Vec::new() })
}
/// The feature id of the sketch `key` (an id or a name, as [`sketch_store::target`] reads them)
/// in `doc`.
fn sketch_in(doc: &Document, key: &Value, cmd: &str) -> Result<u64> {
let (name, found) = match key {
Value::Number(n) => {
let id = n.as_u64().ok_or_else(|| bad(cmd, "`from` must be a sketch id or name"))?;
(id.to_string(), doc.feature(id))
}
Value::String(x) => (x.clone(), doc.find_feature(x)),
_ => return Err(bad(cmd, "`from` must be a sketch id or name")),
};
let f = found.ok_or_else(|| bad(cmd, format!("no sketch `{name}`")))?;
if !matches!(f.kind, solidcraft_doc::FeatureKind::Sketch { .. }) {
return Err(bad(cmd, format!("`{name}` is not a sketch")));
}
Ok(f.id)
}
/// A copy of every curve, point, constraint, dimension and text of sketch `id` in `doc`.
fn whole_clip(doc: &Document, id: u64, cmd: &str) -> Result<SketchClip> {
let sk = sketch_store::read(doc, id)?;
let ids: Vec<String> = sk.curves.iter().map(|c| c.id.clone()).chain(sk.points.iter().filter(|q| q.id != ORIGIN).map(|q| q.id.clone())).collect();
Ok(clip_of(doc, sk, id, &ids, true, cmd)?.0)
}
/// `sketch.replace` (#11125): the sketch keeps its feature (plane, place in the timeline, name,
/// display options and what is built on it) and takes a copy of another sketch's contents, as a
/// whole-sketch copy and paste would. Projected geometry comes as ordinary geometry; patterns,
/// offsets and formula curves come as the geometry they made. The dimension parameters the old
/// contents had, when nothing else uses them, go with them.
fn replace(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.replace";
if !(p.is_object() || p.is_null()) {
return Err(bad(cmd, "params must be an object"));
}
let target = sketch_store::target(s, p, cmd)?;
let from = p.get("from").filter(|v| !v.is_null()).ok_or_else(|| bad(cmd, "`from`: the sketch to take the contents of"))?;
let file = match p.get("file").filter(|v| !v.is_null()) {
None => None,
Some(Value::String(f)) if !f.trim().is_empty() => Some(f.clone()),
Some(_) => return Err(bad(cmd, "`file` must be the path of a design file")),
};
let clip = match &file {
Some(path) => {
let (other, _) =
solidcraft_io::read_design_file(path, None::<fn(solidcraft_io::DesignPreview)>).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
let source = sketch_in(&other, from, cmd)?;
whole_clip(&other, source, cmd)?.for_another_design()
}
None => {
let source = sketch_in(&s.doc, from, cmd)?;
if source == target {
return Err(bad(cmd, "a sketch can't be replaced by itself"));
}
whole_clip(&s.doc, source, cmd)?
}
};
let ((curves, points, constraints), info) = edit(s, &json!({ "sketch": target }), cmd, false, |sk, doc| {
let old: Vec<String> = sk.constraints.iter().filter_map(|c| c.param.clone()).collect();
// Emptied, but its id counters kept: the new contents' ids carry on from the old ones, so
// a reference to an old curve or point (a sweep path, a profile picked by its curves)
// finds nothing and says so, rather than taking a new, unrelated curve that happens to get
// the same id.
let mut fresh = sk.clone();
fresh.points.retain(|q| q.id == ORIGIN);
fresh.curves.clear();
fresh.constraints.clear();
fresh.patterns.clear();
fresh.links.clear();
fresh.offsets.clear();
fresh.freeform_offsets.clear();
fresh.wires.clear();
fresh.equations.clear();
// The old contents' own dimension parameters go with them; one something else uses stays.
sketch_store::write(doc, target, fresh.clone())?;
for name in &old {
if doc.param(name).is_some_and(|x| x.model) {
let _ = doc.remove_param(name);
}
}
*sk = fresh;
paste_clip(sk, doc, &clip, Vec2::ZERO)
})?;
if s.active_sketch == Some(target) {
s.selection.clear();
}
let source = match from {
Value::String(x) => x.clone(),
other => other.to_string(),
};
reply(cmd, Replaced { sketch_id: target, from: source, file, curves, points, constraints, sketch: info, param_warnings: Vec::new() })
}
/// The ids of what a paste added: curves (those of texts included), lone points, constraints.
type PastedIds = (Vec<String>, Vec<String>, Vec<String>);
/// Add a copy of `clip`, moved by `t`, to `sk`.
fn paste_clip(sk: &mut Sketch, doc: &mut Document, clip: &SketchClip, t: Vec2) -> Result<PastedIds> {
if sk.curves.len().saturating_add(clip.curves.len()) > solidcraft_sketch::MAX_CURVES {
return Err(solidcraft_sketch::SketchError::TooLarge.into());
}
let mut pts = Vec::with_capacity(clip.points.len());
let origin = sk.points.iter().position(|q| q.id == ORIGIN);
for (i, q) in clip.points.iter().enumerate() {
match origin.filter(|_| clip.origin == Some(i)) {
Some(o) => pts.push(o),
None => pts.push(sk.add_point(*q + t, None)?),
}
}
let mut made = Vec::with_capacity(clip.curves.len());
for c in &clip.curves {
let mut kind = c.kind.clone();
kind.map_points(&|q| pts.get(q).copied().unwrap_or(usize::MAX));
let i = sk.add_curve(kind, None)?;
if let Some(cu) = sk.curves.get_mut(i) {
cu.construction = c.construction;
cu.centerline = c.centerline;
cu.reversed = c.reversed;
cu.infinite = c.infinite;
}
made.push(i);
}
let mut cons = Vec::new();
for c in &clip.constraints {
let mut kind = c.kind.clone();
if !kind.remap(&|q| pts.get(q).copied(), &|k| made.get(k).copied()) {
continue;
}
// Each pasted dimension gets its own parameter, with the same expression.
let param = c.param.as_ref().map(|(e, u)| doc.new_model_param(e, u));
let id = sk.add_constraint(kind, param)?;
if let Some(nc) = sk.constraints.last_mut() {
nc.driven = c.driven;
nc.text = c.text;
}
cons.push(id);
}
let mut curve_ids: Vec<String> = made.iter().filter_map(|i| sk.curves.get(*i).map(|c| c.id.clone())).collect();
for (src, path) in &clip.texts {
let mut src = src.clone();
let LinkSource::Text { at, style, exprs, .. } = &mut src else { continue };
style.path = path.and_then(|k| made.get(k)).and_then(|i| sk.curves.get(*i)).map(|c| c.id.clone());
*at += t;
// The copy sits where it was pasted: its place no longer follows the original's expressions.
exprs.x = None;
exprs.y = None;
let geom = sk.text_source_geometry(&src)?;
let link = sk.add_link(LinkKind::Text, src, &geom)?;
curve_ids.extend(sk.link_curves(&link).into_iter().filter_map(|i| sk.curves.get(i).map(|c| c.id.clone())));
}
let lone: Vec<String> = clip.lone.iter().filter_map(|n| pts.get(*n)).filter_map(|i| sk.points.get(*i).map(|q| q.id.clone())).collect();
Ok((curve_ids, lone, cons))
}
#[cfg(test)]
#[path = "sketch_clipboard_tests.rs"]
mod tests;
@@ -294,3 +294,166 @@ fn sketch_geometry_pastes_into_another_design_with_values_not_foreign_parameters
let at = pos(sketch(&b, sb), &again[0]);
assert!(at.iter().zip(&corner).all(|(p, q)| p.dist(*q) < 1e-6), "{at:?}");
}
fn volume(s: &Session) -> f64 {
s.model.state().bodies.iter().map(|b| solidcraft_kernel::measure(&b.body).unwrap().volume).sum()
}
/// A design with sketch `Small` (a 10 x 10 square from the origin) extruded 5 mm, then sketch
/// `Big` (a dimensioned 30 x 20 rectangle tied to the origin) on the same plane.
fn two_sketches() -> Session {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Small"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "Small", "distance": 5.0, "operation": "new_body"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Big"}));
let rect = strs(&run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [30, 20]}))["curves"]);
run(&mut s, "sketch.constraint.coincident", json!({"a": format!("{}.start", rect[0]), "b": "origin"}));
run(&mut s, "sketch.dimension", json!({"entities": [rect[0]], "value": 30}));
run(&mut s, "sketch.dimension", json!({"entities": [rect[1]], "value": 20}));
run(&mut s, "sketch.finish", json!({}));
s
}
/// Replace Sketch (#11125): the sketch keeps its feature, and the extrude built on it now makes
/// the other sketch's shape (10 x 10 x 5 = 500 mm³ becomes 30 x 20 x 5 = 3000 mm³). The copy's
/// constraints come along, the one to the origin tied to this sketch's origin, its dimensions
/// with their own parameters; the source is untouched; one undo step puts it all back.
#[test]
fn replace_sketch_gives_a_sketch_another_sketchs_contents_and_keeps_its_features() {
let mut s = two_sketches();
assert!((volume(&s) - 500.0).abs() < 1e-6, "{}", volume(&s));
let small = s.doc.find_feature("Small").unwrap().id;
let big = s.doc.find_feature("Big").unwrap().id;
let features = s.doc.features.len();
let params = s.doc.params.len();
let source = s.doc.sketch(big).unwrap().clone();
let before = s.doc.to_json();
let out = run(&mut s, "sketch.replace", json!({"sketch": "Small", "from": "Big"}));
assert_eq!(out["sketch_id"], json!(small));
assert_eq!(strs(&out["curves"]).len(), 4);
assert_eq!(s.doc.features.len(), features, "no feature added or removed");
assert_eq!(s.doc.find_feature("Small").map(|f| f.id), Some(small), "the sketch keeps its id and name");
assert!((volume(&s) - 3000.0).abs() < 1e-6, "the extrude follows: {}", volume(&s));
let sk = sketch(&s, small);
assert_eq!(sk.constraints.len(), source.constraints.len(), "every constraint comes along");
assert_eq!(sk.points.iter().filter(|q| q.id == "origin").count(), 1, "one origin");
let origin = sk.points.iter().position(|q| q.id == "origin").unwrap();
assert!(
sk.constraints.iter().any(|c| matches!(c.kind, ConstraintKind::Coincident { p, q } if p == origin || q == origin)),
"the corner stays tied to the origin: {:?}",
sk.constraints
);
let names: Vec<String> = sk.constraints.iter().filter_map(|c| c.param.clone()).collect();
let theirs: Vec<String> = source.constraints.iter().filter_map(|c| c.param.clone()).collect();
assert_eq!(names.len(), 2);
assert!(names.iter().all(|n| !theirs.contains(n)), "its own parameters: {names:?} vs {theirs:?}");
assert_eq!(s.doc.params.len(), params + 2);
assert_eq!(serde_json::to_value(s.doc.sketch(big).unwrap()).unwrap(), serde_json::to_value(&source).unwrap(), "the source is untouched");
// Its dimensions drive it: a new width changes this sketch only.
run(&mut s, "parameters.change", json!({"name": names[0], "expression": "40"}));
assert!((volume(&s) - 4000.0).abs() < 1e-6, "{}", volume(&s));
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.to_json(), before, "one undo step");
}
/// The old contents' dimension parameters go with them, unless something else uses them; a
/// parameter the user made stays.
#[test]
fn replace_sketch_drops_the_old_dimension_parameters_nothing_else_uses() {
let mut s = two_sketches();
run(&mut s, "parameters.change", json!({"name": "w", "expression": "12"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Third"}));
let rect = strs(&run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [12, 8]}))["curves"]);
run(&mut s, "sketch.dimension", json!({"entities": [rect[0]], "value": "w"}));
run(&mut s, "sketch.dimension", json!({"entities": [rect[1]], "value": 8}));
run(&mut s, "sketch.finish", json!({}));
let third = s.doc.find_feature("Third").unwrap().id;
let params: Vec<String> = sketch(&s, third).constraints.iter().filter_map(|c| c.param.clone()).collect();
assert_eq!(params.len(), 2, "{params:?}");
run(&mut s, "sketch.replace", json!({"sketch": third, "from": "Small"}));
for name in &params {
let kept = s.doc.param(name);
assert!(kept.is_none_or(|p| !p.model), "`{name}` went with the old dimension: {kept:?}");
}
assert!(s.doc.param("w").is_some(), "the user's parameter stays");
}
/// A feature that names the old contents' curves (here a profile picked by its curve ids) never
/// takes the new contents' curves that happen to get the same ids: the new ids carry on from the
/// old ones, so the reference finds nothing and the feature says so, instead of quietly building
/// from unrelated geometry (30 x 20 x 5 = 3000 mm³ here).
#[test]
fn replace_sketch_never_rebinds_a_reference_to_an_old_curve_onto_a_new_one() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Small"}));
let square = strs(&run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]}))["curves"]);
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "Small", "distance": 5.0, "operation": "new_body", "profiles": [{"curves": square}]}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Big"}));
let rect = strs(&run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [30, 20]}))["curves"]);
run(&mut s, "sketch.finish", json!({}));
assert_eq!(square, rect, "both sketches numbered their curves alike: {square:?} {rect:?}");
assert!((volume(&s) - 500.0).abs() < 1e-6, "{}", volume(&s));
let before = s.doc.to_json();
match s.execute("sketch.replace", &json!({"sketch": "Small", "from": "Big"})) {
Ok(out) => {
let new = strs(&out["curves"]);
assert!(new.iter().all(|id| !square.contains(id)), "new ids, not the old ones again: {new:?} vs {square:?}");
assert!(s.model.results.iter().any(|r| r.error.is_some()), "the extrude says its curves are gone");
}
Err(_) => assert_eq!(s.doc.to_json(), before, "a refused replace changes nothing"),
}
assert!((volume(&s) - 3000.0).abs() > 1.0, "never built from the unrelated new curves: {}", volume(&s));
}
/// Sketch contents come from a sketch in another design file, at the same sketch coordinates; a
/// dimension that named that design's parameter keeps its value, without the parameter.
#[test]
fn replace_sketch_takes_a_sketch_from_another_design_file() {
let dir = std::env::temp_dir().join(format!("solidcraft-replace-sketch-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("Other.sc");
let mut other = Session::default();
run(&mut other, "parameters.change", json!({"name": "w", "expression": "1 in"}));
run(&mut other, "sketch.create", json!({"plane": "XZ", "name": "Plate"}));
let rect = strs(&run(&mut other, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [25.4, 10]}))["curves"]);
run(&mut other, "sketch.dimension", json!({"entities": [rect[0]], "value": "w"}));
run(&mut other, "sketch.finish", json!({}));
run(&mut other, "file.save_as", json!({"path": path.to_string_lossy()}));
let mut s = two_sketches();
let file = path.to_string_lossy().to_string();
let out = run(&mut s, "sketch.replace", json!({"sketch": "Small", "from": "Plate", "file": file}));
assert_eq!(out["file"], json!(file));
assert!((volume(&s) - 25.4 * 10.0 * 5.0).abs() < 1e-6, "{}", volume(&s));
assert!(s.doc.param("w").is_none(), "the other design's parameter is not brought in");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn replace_sketch_refuses_bad_requests_and_leaves_the_design_as_it_was() {
let mut s = two_sketches();
let before = s.doc.to_json();
let extrude = s.doc.features.iter().find(|f| !matches!(f.kind, solidcraft_doc::FeatureKind::Sketch { .. })).unwrap().id;
for bad in [
json!({"sketch": "Small"}),
json!({"sketch": "Small", "from": "Small"}),
json!({"sketch": "Small", "from": "Nope"}),
json!({"sketch": "Small", "from": 1.5}),
json!({"sketch": "Small", "from": [1]}),
json!({"sketch": "Small", "from": extrude}),
json!({"sketch": "Nope", "from": "Big"}),
json!({"from": "Big"}),
json!({"sketch": "Small", "from": "Big", "file": 3}),
json!({"sketch": "Small", "from": "Big", "file": "Z:/no/such/file.solidcraft"}),
json!([1, 2]),
json!("text"),
] {
assert!(s.execute("sketch.replace", &bad).is_err(), "{bad}");
assert_eq!(s.doc.to_json(), before, "{bad}");
}
}
+108 -40
View File
@@ -1,54 +1,81 @@
//! More sketch constraints (curvature, polygon), constraint glyph anchors and snapping
//! inference for interactive tools.
use serde_json::{Value, json};
use serde_json::Value;
use solidcore_record::Vec2;
use solidcraft_geom::prelude::*;
use solidcraft_sketch::{ConstraintKind, CurveKind, Sketch};
use super::sketch::{add_c, constraint_result, dof_now, edit, listed, redundancy, residual_of};
use super::sketch::{add_c, dof_now, edit, keep_redundant, listed, redundancy, residual_of};
use super::sketch_store;
use super::{CommandSpec, in_sketch};
use crate::params::{bad, num, req_vec2, str_, string_list, vec2};
use crate::schema::sketch_constraints::{ToggleDiameterParams, ToggleDiameterResult};
use crate::schema::sketch_constraints::{
AutoConstrainDatumParams, AutoConstrainParams, AutoConstrainResult, ConstrainerParams, ConstrainerResult, ConstraintAdded, CurvatureParams,
FinishAutoConstrainParams, Glyph, GlyphsParams, GlyphsResult, PolygonParams, PolygonResult, Snap, SnapParams, SnapResult, ToggleDiameterParams,
ToggleDiameterResult, ToggleDrivenParams, ToggleDrivenResult,
};
use crate::schema::{TypedContract, output_schema_for};
use crate::{EngineError, Result, Session};
const TOGGLE_DIAMETER_CONTRACT: TypedContract<ToggleDiameterParams> = TypedContract::new();
const CURVATURE_CONTRACT: TypedContract<CurvatureParams> = TypedContract::new();
const POLYGON_CONTRACT: TypedContract<PolygonParams> = TypedContract::new();
const AUTO_CONSTRAIN_CONTRACT: TypedContract<AutoConstrainParams> = TypedContract::new();
const AUTO_CONSTRAIN_DATUM_CONTRACT: TypedContract<AutoConstrainDatumParams> = TypedContract::new();
const FINISH_AUTO_CONSTRAIN_CONTRACT: TypedContract<FinishAutoConstrainParams> = TypedContract::new();
const CONSTRAINER_CONTRACT: TypedContract<ConstrainerParams> = TypedContract::new();
const TOGGLE_DRIVEN_CONTRACT: TypedContract<ToggleDrivenParams> = TypedContract::new();
const GLYPHS_CONTRACT: TypedContract<GlyphsParams> = TypedContract::new();
const SNAP_CONTRACT: TypedContract<SnapParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.constraint.curvature", "Curvature", c_smooth)
CommandSpec::new("sketch.constraint.curvature", "Curvature", crate::checked!("sketch.constraint.curvature", c_smooth))
.at("SKETCH", "CONSTRAINTS")
.icon("c_smooth")
.enabled(in_sketch)
.contract(&CURVATURE_CONTRACT)
.output(output_schema_for::<ConstraintAdded>)
.params("a, b: two curves sharing an end point (curvature continuous, G2)"),
CommandSpec::new("sketch.constraint.polygon", "Polygon", c_polygon)
CommandSpec::new("sketch.constraint.polygon", "Polygon", crate::checked!("sketch.constraint.polygon", c_polygon))
.at("SKETCH", "CONSTRAINTS")
.icon("c_polygon")
.enabled(in_sketch)
.contract(&POLYGON_CONTRACT)
.output(output_schema_for::<PolygonResult>)
.params("lines: [line ids] of a closed chain: equal sides with every corner on one circle (a regular polygon)"),
CommandSpec::new("sketch.auto_constrain", "AutoConstrain", auto_constrain)
CommandSpec::new("sketch.auto_constrain", "AutoConstrain", crate::checked!("sketch.auto_constrain", auto_constrain))
.at("SKETCH", "CONSTRAINTS")
.icon("auto_constrain")
.enabled(in_sketch)
.contract(&AUTO_CONSTRAIN_CONTRACT)
.output(output_schema_for::<AutoConstrainResult>)
.params("tolerance?: mm (default 0.01), angle_tolerance?: deg (default 1), dimensions?: bool (default true): infer constraints, then dimension until fully constrained"),
CommandSpec::new("sketch.auto_constrain_from_datum", "AutoConstrain from Datum", auto_constrain_datum)
CommandSpec::new("sketch.auto_constrain_from_datum", "AutoConstrain from Datum", crate::checked!("sketch.auto_constrain_from_datum", auto_constrain_datum))
.at("SKETCH", "CONSTRAINTS")
.icon("auto_constrain")
.enabled(in_sketch)
.contract(&AUTO_CONSTRAIN_DATUM_CONTRACT)
.output(output_schema_for::<AutoConstrainResult>)
.params("datum: point ref (default origin); like AutoConstrain with positions measured from the datum"),
CommandSpec::new("sketch.finish_auto_constrain", "Finish with AutoConstrain", auto_constrain_finish)
CommandSpec::new("sketch.finish_auto_constrain", "Finish with AutoConstrain", crate::checked!("sketch.finish_auto_constrain", auto_constrain_finish))
.at("SKETCH", "FINISH SKETCH")
.icon("finish")
.enabled(in_sketch)
.contract(&FINISH_AUTO_CONSTRAIN_CONTRACT)
.output(output_schema_for::<AutoConstrainResult>)
.params("like AutoConstrain, then Finish Sketch"),
CommandSpec::new("sketch.constrainer", "Sketch Constrainer", smart_constrain)
CommandSpec::new("sketch.constrainer", "Sketch Constrainer", crate::checked!("sketch.constrainer", smart_constrain))
.at("SKETCH", "CREATE")
.icon("constrainer")
.enabled(in_sketch)
.contract(&CONSTRAINER_CONTRACT)
.output(output_schema_for::<ConstrainerResult>)
.params("entities: [one or two sketch entity ids]: applies the constraint the geometry nearly has (horizontal/vertical, parallel, perpendicular, collinear, coincident, concentric, equal, tangent)"),
CommandSpec::new("sketch.toggle_driven", "Toggle Driven Dimension", toggle_driven).icon("dimension")
CommandSpec::new("sketch.toggle_driven", "Toggle Driven Dimension", crate::checked!("sketch.toggle_driven", toggle_driven)).icon("dimension")
.enabled(in_sketch)
.contract(&TOGGLE_DRIVEN_CONTRACT)
.output(output_schema_for::<ToggleDrivenResult>)
.params("constraint: dimension id, driven?: bool (default toggles); a driving dimension gets a parameter with its current value"),
CommandSpec::new("sketch.toggle_diameter", "Toggle Radius / Diameter", crate::checked!("sketch.toggle_diameter", toggle_diameter))
.icon("dimension")
@@ -56,13 +83,17 @@ pub static COMMANDS: &[CommandSpec] = &[
.contract(&TOGGLE_DIAMETER_CONTRACT)
.output(output_schema_for::<ToggleDiameterResult>)
.params("constraint: a circle's or an arc's radius or diameter dimension id; diameter?: bool (default: the other one); sketch?: id|name (default: the one being edited). The circle stays the same size: its parameter is halved or doubled (a plain value is rewritten, an expression becomes `(e) / 2` or `2 * (e)`); refused when anything else uses that parameter"),
CommandSpec::new("sketch.glyphs", "Constraint Glyphs", glyphs).icon("eye")
CommandSpec::new("sketch.glyphs", "Constraint Glyphs", crate::checked!("sketch.glyphs", glyphs)).icon("eye")
.enabled(in_sketch)
.noundo()
.contract(&GLYPHS_CONTRACT)
.output(output_schema_for::<GlyphsResult>)
.params("sketch?: id|name: every constraint with where to draw its glyph (sketch coordinates)"),
CommandSpec::new("sketch.snap", "Snap Inference", snap).icon("snap")
CommandSpec::new("sketch.snap", "Snap Inference", crate::checked!("sketch.snap", snap)).icon("snap")
.enabled(in_sketch)
.noundo()
.contract(&SNAP_CONTRACT)
.output(output_schema_for::<SnapResult>)
.params("at: [x,y], from?: [x,y] (previous click), radius?: mm (snap distance, default 1): what a click there would snap to and infer"),
];
@@ -78,7 +109,26 @@ fn c_smooth(s: &mut Session, p: &Value) -> Result<Value> {
Ok(id)
})?;
let by = redundancy(s, p, cmd, before, &info, residual, &id)?;
constraint_result(p, cmd, id, info, by, Value::Null)
let (redundant, determined_by) = redundant_part(p, cmd, by)?;
reply(cmd, ConstraintAdded { constraint: id, sketch: info, redundant, determined_by, created: None, param_warnings: Vec::new() })
}
/// A command's reply as JSON: they build their replies from the types their output schemas
/// describe, so the two can't drift apart.
fn reply(cmd: &str, reply: impl serde::Serialize) -> Result<Value> {
serde_json::to_value(reply).map_err(|e| bad(cmd, e.to_string()))
}
/// What a new constraint's reply says of redundancy: whether it is, and by which constraints. A
/// redundant one is refused when the call asked for `if_redundant: "refuse"`.
fn redundant_part(p: &Value, cmd: &str, by: Option<Vec<String>>) -> Result<(bool, Option<Vec<String>>)> {
match by {
Some(by) => {
keep_redundant(p, cmd, &by)?;
Ok((true, Some(by)))
}
None => Ok((false, None)),
}
}
fn curve(sk: &Sketch, v: Option<&Value>, cmd: &str) -> Result<usize> {
@@ -126,7 +176,7 @@ fn c_polygon(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok(cons)
})?;
Ok(json!({"constraints": out, "sketch": info}))
reply(cmd, PolygonResult { constraints: out, sketch: info, created: None, param_warnings: Vec::new() })
}
/// Most constraints AutoConstrain tries.
@@ -273,7 +323,7 @@ fn auto(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
// its freedom whenever they change constraints; no separate initial solve is needed.
Ok(run_auto(sk, doc, p, datum, rep.dof))
})?;
Ok(json!({"added": added, "dof": dof, "sketch": info}))
reply(cmd, AutoConstrainResult { added, dof, sketch: info, created: None, param_warnings: Vec::new() })
}
fn auto_constrain(s: &mut Session, p: &Value) -> Result<Value> {
@@ -375,7 +425,19 @@ fn smart_constrain(s: &mut Session, p: &Value) -> Result<Value> {
Ok(id)
})?;
let by = redundancy(s, p, cmd, before, &info, residual, &id)?;
constraint_result(p, cmd, id, info, by, json!({"type": name}))
let (redundant, determined_by) = redundant_part(p, cmd, by)?;
reply(
cmd,
ConstrainerResult {
constraint: id,
sketch: info,
redundant,
determined_by,
kind: name.to_string(),
created: None,
param_warnings: Vec::new(),
},
)
}
fn toggle_driven(s: &mut Session, p: &Value) -> Result<Value> {
@@ -417,7 +479,7 @@ fn toggle_driven(s: &mut Session, p: &Value) -> Result<Value> {
}
}
s.doc_mut().prune_model_params();
Ok(json!({"constraint": cid, "driven": driven, "sketch": info}))
reply(cmd, ToggleDrivenResult { constraint: cid, driven, sketch: info, created: None, param_warnings: Vec::new() })
}
fn toggle_diameter(s: &mut Session, p: &Value) -> Result<Value> {
@@ -528,26 +590,32 @@ fn glyphs(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.model.state();
let ss = st.sketch(id).ok_or_else(|| EngineError::Other("that sketch is not evaluated".into()))?;
let failing = &ss.report.failing;
let list: Vec<Value> = ss
let glyphs: Vec<Glyph> = ss
.sketch
.constraints
.iter()
.map(|c| {
json!({
"id": c.id,
"name": c.kind.name(),
"dimension": c.kind.is_dimension(),
"driven": c.driven,
"value": c.kind.value(),
"param": c.param,
"at": anchor(&ss.sketch, &c.kind),
"failing": failing.contains(&c.id),
// Implied by the others (it removes no freedom) but holding: shown dimmed.
"redundant": ss.report.redundant.contains(&c.id),
})
.map(|c| Glyph {
id: c.id.clone(),
name: c.kind.name().to_string(),
dimension: c.kind.is_dimension(),
driven: c.driven,
value: c.kind.value(),
param: c.param.clone(),
at: anchor(&ss.sketch, &c.kind).map(|a| [a.x, a.y]),
failing: failing.contains(&c.id),
// Implied by the others (it removes no freedom) but holding: shown dimmed.
redundant: ss.report.redundant.contains(&c.id),
})
.collect();
Ok(json!({"sketch": id, "glyphs": list, "dof": ss.report.dof, "fully_constrained": ss.report.fully_constrained(), "conflicts": failing}))
let result = GlyphsResult {
sketch: id,
glyphs,
dof: ss.report.dof,
fully_constrained: ss.report.fully_constrained(),
conflicts: failing.clone(),
param_warnings: Vec::new(),
};
reply("sketch.glyphs", result)
}
/// Snap inference for a click at `at`: the nearest sketch point (coincident), the midpoint of a
@@ -562,12 +630,12 @@ fn snap(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.model.state();
let ss = st.sketch(id).ok_or_else(|| EngineError::Other("the sketch is not evaluated".into()))?;
let sk = &ss.sketch;
let mut out: Vec<(f64, Value)> = Vec::new();
let mut out: Vec<(f64, Snap)> = Vec::new();
// Points (end points, centres, the origin).
for q in &sk.points {
let d = q.pos.dist(at);
if d <= r {
out.push((d * 0.5, json!({"type": "coincident", "point": q.id, "at": q.pos})));
out.push((d * 0.5, Snap::Coincident { point: q.id.clone(), at: [q.pos.x, q.pos.y] }));
}
}
for (i, c) in sk.curves.iter().enumerate() {
@@ -577,13 +645,13 @@ fn snap(s: &mut Session, p: &Value) -> Result<Value> {
let m = (pa + pb) * 0.5;
let d = m.dist(at);
if d <= r {
out.push((d * 0.6, json!({"type": "midpoint", "curve": c.id, "at": m})));
out.push((d * 0.6, Snap::Midpoint { curve: c.id.clone(), at: [m.x, m.y] }));
}
}
if let Some(sh) = sk.shape(i) {
let q = sh.project(at);
if sh.dist(at) <= r && q.dist(at) <= r {
out.push((sh.dist(at) + r * 0.5, json!({"type": "on_curve", "curve": c.id, "at": q})));
out.push((sh.dist(at) + r * 0.5, Snap::OnCurve { curve: c.id.clone(), at: [q.x, q.y] }));
}
}
}
@@ -591,15 +659,15 @@ fn snap(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(f) = from {
let d = at - f;
if d.y.abs() <= r && d.x.abs() > r {
out.push((d.y.abs() + r, json!({"type": "horizontal", "at": [at.x, f.y]})));
out.push((d.y.abs() + r, Snap::Horizontal { at: [at.x, f.y] }));
}
if d.x.abs() <= r && d.y.abs() > r {
out.push((d.x.abs() + r, json!({"type": "vertical", "at": [f.x, at.y]})));
out.push((d.x.abs() + r, Snap::Vertical { at: [f.x, at.y] }));
}
}
out.sort_by(|a, b| a.0.total_cmp(&b.0));
let list: Vec<Value> = out.into_iter().take(8).map(|x| x.1).collect();
Ok(json!({"snaps": list}))
let snaps: Vec<Snap> = out.into_iter().take(8).map(|x| x.1).collect();
reply(cmd, SnapResult { snaps, param_warnings: Vec::new() })
}
#[cfg(test)]
+142 -62
View File
@@ -13,11 +13,16 @@ macro_rules! text_style_doc {
};
}
use super::sketch::{add_c, circumcircle, edit, ids_of, mark_construction, req_parg, result, slot};
use super::sketch::{add_c, circumcircle, edit, ids_of, mark_construction, req_parg, slot_parts};
use super::sketch_store;
use super::{CommandSpec, in_sketch};
use crate::params::{bad, num, req_vec2, str_, string_list};
use crate::schema::sketch_create::{SplineRemovePointParams, SplineRemovePointResult, SplineReverseParams, SplineReverseResult};
use crate::schema::sketch_create::{
ArcSlotCenterParams, ArcSlotResult, ArcSlotThreePointParams, ArcTangentParams, BlendCurveParams, CenterlineParams, ConicParams,
ControlPointSplineParams, DrawResult, EditTextParams, EditTextResult, EllipseParams, FitPointSplineParams, InfiniteLineParams, LineTypeResult,
MidpointLineParams, SketchTextParams, SlotCenterPointParams, SplineRemovePointParams, SplineRemovePointResult, SplineReverseParams,
SplineReverseResult, TextChange, TextEditsResult, TextExpressions, TextResult, ThreeTangentCircleParams, TwoTangentCircleParams,
};
use crate::schema::{TypedContract, output_schema_for};
use crate::{EngineError, Result, Session};
@@ -29,62 +34,108 @@ mod text_move_tests;
#[path = "sketch_text_expr_tests.rs"]
mod text_expr_tests;
const MIDPOINT_LINE_CONTRACT: TypedContract<MidpointLineParams> = TypedContract::new();
const ARC_TANGENT_CONTRACT: TypedContract<ArcTangentParams> = TypedContract::new();
const TWO_TANGENT_CONTRACT: TypedContract<TwoTangentCircleParams> = TypedContract::new();
const THREE_TANGENT_CONTRACT: TypedContract<ThreeTangentCircleParams> = TypedContract::new();
const SLOT_CENTER_POINT_CONTRACT: TypedContract<SlotCenterPointParams> = TypedContract::new();
const ARC_SLOT_THREE_CONTRACT: TypedContract<ArcSlotThreePointParams> = TypedContract::new();
const ARC_SLOT_CENTER_CONTRACT: TypedContract<ArcSlotCenterParams> = TypedContract::new();
const ELLIPSE_CONTRACT: TypedContract<EllipseParams> = TypedContract::new();
const SPLINE_FIT_CONTRACT: TypedContract<FitPointSplineParams> = TypedContract::new();
const SPLINE_CONTROL_CONTRACT: TypedContract<ControlPointSplineParams> = TypedContract::new();
const CONIC_CONTRACT: TypedContract<ConicParams> = TypedContract::new();
const BLEND_CONTRACT: TypedContract<BlendCurveParams> = TypedContract::new();
const CENTERLINE_CONTRACT: TypedContract<CenterlineParams> = TypedContract::new();
const INFINITE_CONTRACT: TypedContract<InfiniteLineParams> = TypedContract::new();
const TEXT_CONTRACT: TypedContract<SketchTextParams> = TypedContract::new();
const EDIT_TEXT_CONTRACT: TypedContract<EditTextParams> = TypedContract::new();
const SPLINE_REVERSE_CONTRACT: TypedContract<SplineReverseParams> = TypedContract::new();
const SPLINE_REMOVE_POINT_CONTRACT: TypedContract<SplineRemovePointParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.line.midpoint", "Midpoint Line", midpoint_line)
CommandSpec::new("sketch.line.midpoint", "Midpoint Line", crate::checked!("sketch.line.midpoint", midpoint_line))
.at("SKETCH", "CREATE")
.icon("line_mid")
.enabled(in_sketch)
.contract(&MIDPOINT_LINE_CONTRACT)
.output(output_schema_for::<DrawResult>)
.params(concat!("mid: [x,y], point ref or snap (\"mid:<line>\", \"on:<curve>:x,y\", model vertex/edge); end: [x,y]; construction?", created_doc!())),
CommandSpec::new("sketch.arc.tangent", "Tangent Arc", arc_tangent).at("SKETCH", "CREATE").icon("arc_tangent").enabled(in_sketch).params(concat!(
CommandSpec::new("sketch.arc.tangent", "Tangent Arc", crate::checked!("sketch.arc.tangent", arc_tangent))
.at("SKETCH", "CREATE")
.icon("arc_tangent")
.enabled(in_sketch)
.contract(&ARC_TANGENT_CONTRACT)
.output(output_schema_for::<DrawResult>)
.params(concat!(
"start: end point of a line or arc (\"l1.end\"), end: [x,y] or an existing point's id (\"l2.start\": the arc ends on that point); curve?: the curve to be tangent to (default: the one ending at start)",
created_doc!()
)),
CommandSpec::new("sketch.circle.two_tangent", "2-Tangent Circle", circle_tan_tan)
CommandSpec::new("sketch.circle.two_tangent", "2-Tangent Circle", crate::checked!("sketch.circle.two_tangent", circle_tan_tan))
.at("SKETCH", "CREATE")
.icon("circle_tt")
.enabled(in_sketch)
.contract(&TWO_TANGENT_CONTRACT)
.output(output_schema_for::<DrawResult>)
.params(concat!("curves: [two lines/circles/arcs], radius | diameter: expr, near: [x,y] (which of the solutions)", created_doc!())),
CommandSpec::new("sketch.circle.three_tangent", "3-Tangent Circle", circle_three_tan)
CommandSpec::new("sketch.circle.three_tangent", "3-Tangent Circle", crate::checked!("sketch.circle.three_tangent", circle_three_tan))
.at("SKETCH", "CREATE")
.icon("circle_ttt")
.enabled(in_sketch)
.contract(&THREE_TANGENT_CONTRACT)
.output(output_schema_for::<DrawResult>)
.params(concat!("curves: [three lines/circles/arcs], near: [x,y] (which of the solutions)", created_doc!())),
CommandSpec::new("sketch.slot.center_point", "Center Point Slot", slot_center_point)
CommandSpec::new("sketch.slot.center_point", "Center Point Slot", crate::checked!("sketch.slot.center_point", slot_center_point))
.at("SKETCH", "CREATE")
.icon("slot_center")
.enabled(in_sketch)
.contract(&SLOT_CENTER_POINT_CONTRACT)
.output(output_schema_for::<DrawResult>)
.params(concat!("center: slot centre, end: one arc centre, width", created_doc!())),
CommandSpec::new("sketch.slot.arc_three_point", "Three Point Arc Slot", arc_slot_three)
CommandSpec::new("sketch.slot.arc_three_point", "Three Point Arc Slot", crate::checked!("sketch.slot.arc_three_point", arc_slot_three))
.at("SKETCH", "CREATE")
.icon("arc_slot")
.enabled(in_sketch)
.contract(&ARC_SLOT_THREE_CONTRACT)
.output(output_schema_for::<ArcSlotResult>)
.params(concat!("start, through, end: points on the centre arc, width", created_doc!())),
CommandSpec::new("sketch.slot.arc_center", "Center Point Arc Slot", arc_slot_center)
CommandSpec::new("sketch.slot.arc_center", "Center Point Arc Slot", crate::checked!("sketch.slot.arc_center", arc_slot_center))
.at("SKETCH", "CREATE")
.icon("arc_slot_center")
.enabled(in_sketch)
.contract(&ARC_SLOT_CENTER_CONTRACT)
.output(output_schema_for::<ArcSlotResult>)
.params(concat!("center, start: centre arc start, end: direction of the centre arc end (counter-clockwise) or sweep: degrees (0 < sweep < 360), width; returns centre_arc", created_doc!())),
CommandSpec::new("sketch.ellipse", "Ellipse", ellipse).at("SKETCH", "CREATE").icon("ellipse").enabled(in_sketch).params(concat!(
CommandSpec::new("sketch.ellipse", "Ellipse", crate::checked!("sketch.ellipse", ellipse))
.at("SKETCH", "CREATE")
.icon("ellipse")
.enabled(in_sketch)
.contract(&ELLIPSE_CONTRACT)
.output(output_schema_for::<DrawResult>)
.params(concat!(
"center, major: end of the major axis [x,y], minor: a point the ellipse passes through on the minor side [x,y] | minor_radius. Also adds its major and minor axes as construction lines through the centre (their ends: the major end and the point opposite it, and the two ends of the minor axis on the ellipse), held by midpoint, perpendicular and point-on-curve constraints, so a half-axis is the distance from the centre to an axis end and an axis line's length is a full axis; axes?: bool (default true; false: the ellipse alone, as the Ellipse tool draws it with Draw ellipses with their axes off)",
created_doc!()
)),
CommandSpec::new("sketch.spline.fit_point", "Fit Point Spline", spline_fit)
CommandSpec::new("sketch.spline.fit_point", "Fit Point Spline", crate::checked!("sketch.spline.fit_point", spline_fit))
.at("SKETCH", "CREATE")
.icon("spline")
.enabled(in_sketch)
.contract(&SPLINE_FIT_CONTRACT)
.output(output_schema_for::<DrawResult>)
.params(concat!("points: [[x,y] or point refs…] (2…500): the spline passes through them, periodic?: boolean (default false); periodic cubic fit closes smoothly through at least 3 distinct samples", created_doc!())),
CommandSpec::new("sketch.spline.control_point", "Control Point Spline", spline_cv3)
CommandSpec::new("sketch.spline.control_point", "Control Point Spline", crate::checked!("sketch.spline.control_point", spline_cv3))
.at("SKETCH", "CREATE")
.icon("spline_cv")
.enabled(in_sketch)
.contract(&SPLINE_CONTROL_CONTRACT)
.output(output_schema_for::<DrawResult>)
.params(concat!("points: control points (2…500), cubic", created_doc!())),
CommandSpec::new("sketch.spline.control_point_5", "Control Point Spline (Degree 5)", spline_cv5)
CommandSpec::new("sketch.spline.control_point_5", "Control Point Spline (Degree 5)", crate::checked!("sketch.spline.control_point_5", spline_cv5))
.at("SKETCH", "CREATE")
.icon("spline_cv")
.enabled(in_sketch)
.contract(&SPLINE_CONTROL_CONTRACT)
.output(output_schema_for::<DrawResult>)
.params(concat!("points: control points (2…500), degree 5", created_doc!())),
CommandSpec::new("sketch.spline.insert_point", "Add Spline Point", spline_insert_point)
.icon("spline")
@@ -102,36 +153,54 @@ pub static COMMANDS: &[CommandSpec] = &[
.contract(&SPLINE_REVERSE_CONTRACT)
.output(output_schema_for::<SplineReverseResult>)
.params("curve: spline id (fit point, control point or closed): its last point becomes its first and the curve runs the other way along the same path; its points and the constraints on them stay. Refused for a projected spline and for one an offset uses or makes. Returns curve"),
CommandSpec::new("sketch.conic", "Conic Curve", conic)
CommandSpec::new("sketch.conic", "Conic Curve", crate::checked!("sketch.conic", conic))
.at("SKETCH", "CREATE")
.icon("conic")
.enabled(in_sketch)
.contract(&CONIC_CONTRACT)
.output(output_schema_for::<DrawResult>)
.params(concat!("start, end, apex: [x,y] or point refs; rho?: 0…1 (default 0.5: parabola)", created_doc!())),
CommandSpec::new("sketch.blend_curve", "Blend Curve", blend)
CommandSpec::new("sketch.blend_curve", "Blend Curve", crate::checked!("sketch.blend_curve", blend))
.at("SKETCH", "MODIFY")
.icon("blend")
.enabled(in_sketch)
.contract(&BLEND_CONTRACT)
.output(output_schema_for::<DrawResult>)
.params(concat!(
"a, b: end points of two curves (\"l1.end\", \"a2.start\"), continuity?: g1|g2|g3|g4 (default g1: a tangent spline; g2…g4 match the reference curves' curvature, curvature rate and 4th derivative as well, as one Bézier of degree 5, 7 or 9)",
created_doc!()
)),
CommandSpec::new("sketch.centerline", "Centerline", centerline)
CommandSpec::new("sketch.centerline", "Centerline", crate::checked!("sketch.centerline", centerline))
.at("SKETCH", "CREATE")
.icon("centerline")
.enabled(in_sketch)
.contract(&CENTERLINE_CONTRACT)
.output(output_schema_for::<LineTypeResult>)
.params("curves: [line ids], value?: bool (default toggles): centerline line type (not in profiles; an axis)"),
CommandSpec::new("sketch.infinite", "Infinite Line", infinite_line).icon("infinite")
CommandSpec::new("sketch.infinite", "Infinite Line", crate::checked!("sketch.infinite", infinite_line))
.icon("infinite")
.enabled(in_sketch)
.contract(&INFINITE_CONTRACT)
.output(output_schema_for::<LineTypeResult>)
.params("curves: [line ids], value?: bool (default toggles): infinite line type (#1359): construction geometry drawn across the whole view through the line's two points; off, a normal line"),
CommandSpec::new("sketch.text", "Text", text).at("SKETCH", "CREATE").icon("sketch_text").enabled(in_sketch).params(concat!(
CommandSpec::new("sketch.text", "Text", crate::checked!("sketch.text", text))
.at("SKETCH", "CREATE")
.icon("sketch_text")
.enabled(in_sketch)
.contract(&TEXT_CONTRACT)
.output(output_schema_for::<TextResult>)
.params(concat!(
"text: string (\\n breaks lines), or text_expression: the words built from text parameters (parameters.add with unit text), number parameters and quoted texts joined by + (\"Label + '-L'\", \"'W' + Width\"; a number parameter shows its value in the design's units, \"85 mm\", an angle in degrees, \"30°\"; number(Width) is the number alone and number(Width, 2) has 2 decimals): the text keeps it, its words follow the parameters (a parameter edit or a configuration row relabels it) and `exprs` returns it as `text`; at: baseline start [x,y], height?: capital height mm (default 5), angle?: deg; at's x and y, height and angle each take a number or an expression over the design's parameters as a string (\"plate_width / 2\", \"label_size\"): the text keeps the expression, follows the parameters, and returns it in `exprs`; an expression naming no parameter is the number it comes to, a bare number in one is in the design's units, \
font?: an installed font's family or full name, or a .ttf/.otf file path (~ and %VAR% expand; default: the built-in Liberation Sans), ",
text_style_doc!(),
created_doc!()
)),
CommandSpec::new("sketch.edit_text", "Edit Text", edit_text).icon("edit")
CommandSpec::new("sketch.edit_text", "Edit Text", crate::checked!("sketch.edit_text", edit_text))
.icon("edit")
.enabled(in_sketch)
.live(edit_text_live)
.contract(&EDIT_TEXT_CONTRACT)
.output(output_schema_for::<EditTextResult>)
.params(concat!(
"link: the text's link id (or entity: one of its curves), text? or text_expression? (as in sketch.text; typed words replace the expression), at?, height?, angle?: deg (numbers or expressions as in sketch.text; a number replaces an expression, and so does moving the text), font?: family or file path (empty: the built-in font), ",
text_style_doc!(),
@@ -141,6 +210,20 @@ pub static COMMANDS: &[CommandSpec] = &[
)),
];
/// A reply made of a typed value, which is also what its output schema describes.
fn reply<T: serde::Serialize>(value: T) -> Result<Value> {
serde_json::to_value(value).map_err(|e| EngineError::Other(e.to_string()))
}
/// What a drawing command returns: the curves and constraints it made, and the sketch's solve.
fn drawn_result(out: (Vec<String>, Vec<String>), info: Value) -> DrawResult {
DrawResult { curves: out.0, constraints: out.1, sketch: info, created: None, param_warnings: Vec::new() }
}
fn drawn(out: (Vec<String>, Vec<String>), info: Value) -> Result<Value> {
reply(drawn_result(out, info))
}
/// The style a command's parameters give, over `old`.
fn text_style(p: &Value, cmd: &str, old: TextStyle) -> Result<TextStyle> {
let mut style = old;
@@ -181,7 +264,7 @@ fn text_style(p: &Value, cmd: &str, old: TextStyle) -> Result<TextStyle> {
}
/// What a text's `at` must be.
const AT_MUST: &str = "`at` must be the baseline start [x, y], each a number or an expression";
pub(crate) const AT_MUST: &str = "`at` must be the baseline start [x, y], each a number or an expression";
/// One number of a text, from a command's parameter `key`. A number is that number (mm, or
/// degrees for the angle). A string is an expression over the design's parameters: a bare number
@@ -278,15 +361,17 @@ fn text_source(doc: &solidcraft_doc::Document, p: &Value, cmd: &str, old: Option
}
/// The expressions behind a text's numbers, for a command's reply (`{"height": "plate_h / 10"}`).
fn text_exprs_json(src: &LinkSource) -> Option<Value> {
fn text_exprs(src: &LinkSource) -> Option<TextExpressions> {
let LinkSource::Text { exprs, .. } = src else { return None };
let mut out = serde_json::Map::new();
for (key, e) in [("x", &exprs.x), ("y", &exprs.y), ("height", &exprs.height), ("angle", &exprs.angle), ("text", &exprs.text)] {
if let Some(e) = e {
out.insert(key.into(), json!(e));
}
}
(!out.is_empty()).then_some(Value::Object(out))
let out = TextExpressions {
x: exprs.x.clone(),
y: exprs.y.clone(),
height: exprs.height.clone(),
angle: exprs.angle.clone(),
text: exprs.text.clone(),
};
let any = [&out.x, &out.y, &out.height, &out.angle, &out.text].iter().any(|e| e.is_some());
any.then_some(out)
}
fn text(s: &mut Session, p: &Value) -> Result<Value> {
@@ -296,7 +381,7 @@ fn text(s: &mut Session, p: &Value) -> Result<Value> {
}
let src = text_source(&s.doc, p, cmd, None)?;
let id = sketch_store::active(s).ok_or_else(|| bad(cmd, "no sketch is being edited"))?;
let exprs = text_exprs_json(&src);
let exprs = text_exprs(&src);
let (l, curves, guide) = sketch_store::update(s, id, |_, _, sk| {
// A text needs nothing outside its sketch (a path is one of its curves).
let geom = sk.text_source_geometry(&src).map_err(|e| bad(cmd, e.to_string()))?;
@@ -308,11 +393,7 @@ fn text(s: &mut Session, p: &Value) -> Result<Value> {
let curves = ids_of(sk, &sk.link_curves(&l));
Ok((l, curves, guide))
})?;
let mut out = json!({"link": l, "curves": curves, "guide": guide});
if let Some(exprs) = exprs {
out["exprs"] = exprs;
}
Ok(out)
reply(TextResult { change: TextChange { link: l, curves, guide, exprs }, created: None, param_warnings: Vec::new() })
}
/// A text's path is its guide: the curve it follows becomes construction. Left as it was, it
@@ -378,17 +459,18 @@ pub(super) fn move_text(sk: &mut Sketch, link: &str, translate: Vec2, rotation:
}
/// The most texts one `sketch.edit_text {edits}` changes.
const MAX_TEXT_EDITS: usize = 1000;
pub(crate) const MAX_TEXT_EDITS: usize = 1000;
fn edit_text(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.edit_text";
let id = sketch_store::active(s).ok_or_else(|| bad(cmd, "no sketch is being edited"))?;
sketch_store::update(s, id, |_, doc, sk| {
let edited = sketch_store::update(s, id, |_, doc, sk| {
// The numbers its expressions give now (a parameter may have changed since the sketch was
// last stored); one that cannot be evaluated is left as it was, to be replaced by this edit.
let _ = doc.apply_text_values(&doc.param_values().0, sk);
apply_text_edits(sk, doc, p)
})
})?;
reply(edited)
}
/// One frame of an interactive resize or rotation: the sketch is solved and shown, the
@@ -399,21 +481,24 @@ fn edit_text_live(s: &mut Session, p: &Value) -> Result<(Value, Option<crate::Li
let (out, report) = super::sketch::edit_solved(s, id, false, solidcraft_sketch::Analysis::Full, |sk, doc| {
Ok((apply_text_edits(sk, doc, p)?, super::sketch::Left::Unsolved))
})?;
Ok((out, report.map(|report| crate::LiveEdit { sketch: id, report })))
Ok((reply(out)?, report.map(|report| crate::LiveEdit { sketch: id, report })))
}
/// `sketch.edit_text`'s change: one text's (`link` or `entity`), or each of `edits` in turn.
/// One command, so the features after the sketch are rebuilt once for all of them.
fn apply_text_edits(sk: &mut Sketch, doc: &solidcraft_doc::Document, p: &Value) -> Result<Value> {
fn apply_text_edits(sk: &mut Sketch, doc: &solidcraft_doc::Document, p: &Value) -> Result<EditTextResult> {
let cmd = "sketch.edit_text";
match p.get("edits") {
None | Some(Value::Null) => apply_text_edit(sk, doc, p),
None | Some(Value::Null) => {
let change = apply_text_edit(sk, doc, p)?;
Ok(EditTextResult::One(TextResult { change, created: None, param_warnings: Vec::new() }))
}
Some(Value::Array(edits)) => {
if edits.is_empty() || edits.len() > MAX_TEXT_EDITS {
return Err(bad(cmd, format!("`edits` must hold 1 to {MAX_TEXT_EDITS} edits")));
}
let outs = edits.iter().map(|e| apply_text_edit(sk, doc, e)).collect::<Result<Vec<_>>>()?;
Ok(json!({"edits": outs}))
Ok(EditTextResult::Many(TextEditsResult { edits: outs, created: None, param_warnings: Vec::new() }))
}
Some(_) => Err(bad(cmd, "`edits` must be a list of edits")),
}
@@ -421,7 +506,7 @@ fn apply_text_edits(sk: &mut Sketch, doc: &solidcraft_doc::Document, p: &Value)
/// `sketch.edit_text`'s change to its sketch: the text source's new values and its outlines
/// regenerated from them.
fn apply_text_edit(sk: &mut Sketch, doc: &solidcraft_doc::Document, p: &Value) -> Result<Value> {
fn apply_text_edit(sk: &mut Sketch, doc: &solidcraft_doc::Document, p: &Value) -> Result<TextChange> {
let cmd = "sketch.edit_text";
let l = match (str_(p, "link"), str_(p, "entity")) {
(Some(l), _) => l.to_string(),
@@ -438,15 +523,11 @@ fn apply_text_edit(sk: &mut Sketch, doc: &solidcraft_doc::Document, p: &Value) -
return Err(bad(cmd, "the text makes no outline"));
}
let guide = guide(sk, &src);
let exprs = text_exprs_json(&src);
let exprs = text_exprs(&src);
sk.set_link_source(&l, src.clone());
sk.update_link(&l, &geom)?;
let curves = ids_of(sk, &sk.link_curves(&l));
let mut out = json!({"link": l, "curves": curves, "guide": guide});
if let Some(exprs) = exprs {
out["exprs"] = exprs;
}
Ok(out)
Ok(TextChange { link: l, curves, guide, exprs })
}
/// The curve ending at point `pi` and the direction leaving it there.
@@ -552,7 +633,7 @@ fn blend(s: &mut Session, p: &Value) -> Result<Value> {
solve_new_only(sk, first_new);
Ok((ids_of(sk, &[sp]), cons))
})?;
Ok(result(out, info))
drawn(out, info)
}
fn centerline(s: &mut Session, p: &Value) -> Result<Value> {
@@ -577,7 +658,7 @@ fn centerline(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok(ids.len())
})?;
Ok(json!({"changed": n, "sketch": info}))
reply(LineTypeResult { changed: n, sketch: info, created: None, param_warnings: Vec::new() })
}
fn infinite_line(s: &mut Session, p: &Value) -> Result<Value> {
@@ -601,7 +682,7 @@ fn infinite_line(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok(ids.len())
})?;
Ok(json!({"changed": n, "sketch": info}))
reply(LineTypeResult { changed: n, sketch: info, created: None, param_warnings: Vec::new() })
}
fn ellipse(s: &mut Session, p: &Value) -> Result<Value> {
@@ -659,7 +740,7 @@ fn ellipse(s: &mut Session, p: &Value) -> Result<Value> {
];
Ok((ids_of(sk, &[e]), cons))
})?;
Ok(result(out, info))
drawn(out, info)
}
fn point_list(sk: &mut Sketch, doc: &solidcraft_doc::Document, p: &Value, cmd: &str) -> Result<Vec<usize>> {
@@ -704,7 +785,7 @@ fn spline(s: &mut Session, p: &Value, cmd: &str, control: bool, degree: u8) -> R
mark_construction(sk, &[c], p);
Ok((ids_of(sk, &[c]), Vec::new()))
})?;
Ok(result(out, info))
drawn(out, info)
}
fn spline_fit(s: &mut Session, p: &Value) -> Result<Value> {
@@ -790,7 +871,7 @@ fn conic(s: &mut Session, p: &Value) -> Result<Value> {
mark_construction(sk, &[c], p);
Ok((ids_of(sk, &[c]), Vec::new()))
})?;
Ok(result(out, info))
drawn(out, info)
}
fn midpoint_line(s: &mut Session, p: &Value) -> Result<Value> {
@@ -812,7 +893,7 @@ fn midpoint_line(s: &mut Session, p: &Value) -> Result<Value> {
mark_construction(sk, &[l], p);
Ok((ids_of(sk, &[l]), vec![k]))
})?;
Ok(result(out, info))
drawn(out, info)
}
/// Direction leaving curve `ci` at its end point `pi` (continuing the curve past that end).
@@ -966,7 +1047,7 @@ fn arc_tangent(s: &mut Session, p: &Value) -> Result<Value> {
let k = add_c(sk, ConstraintKind::Tangent { a: ci, b: a })?;
Ok((ids_of(sk, &[a]), vec![k]))
})?;
Ok(result(out, info))
drawn(out, info)
}
/// Initial radius for a circle near `at` touching the curves: the mean distance to them.
@@ -1052,7 +1133,7 @@ fn tangent_circle(s: &mut Session, p: &Value, cmd: &str, n: usize, radius: Optio
if info["solved"] != json!(true) {
return Err(bad(cmd, "no tangent circle there"));
}
Ok(result(out, info))
drawn(out, info)
}
fn circle_tan_tan(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1079,7 +1160,8 @@ fn slot_center_point(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.slot.center_point";
let (c, e) = (req_vec2(cmd, p, "center")?, req_vec2(cmd, p, "end")?);
let w = width(p, cmd)?;
slot(s, p, cmd, c * 2.0 - e, e, w)
let (out, info) = slot_parts(s, p, cmd, c * 2.0 - e, e, w)?;
drawn(out, info)
}
/// Arc slot around `o` (centre-line radius `r`) from angle `t0` sweeping `sweep` (> 0).
@@ -1117,9 +1199,7 @@ fn arc_slot(s: &mut Session, p: &Value, cmd: &str, o: Vec2, r: f64, t0: f64, swe
}
Ok((ids_of(sk, &[outer, cap_e, inner, cap_s]), cons))
})?;
let mut out = result(out, info);
out["centre_arc"] = json!(centre_arc);
Ok(out)
reply(ArcSlotResult { drawn: drawn_result(out, info), centre_arc })
}
fn arc_slot_three(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1534,7 +1614,7 @@ mod blend_tests {
assert_eq!(ids(&r["created"]["curves"]), vec![sp.clone()], "{r}");
assert_eq!(ids(&r["created"]["points"]).len(), 8, "{r}");
assert!(ids(&r["created"]["constraints"]).is_empty() && ids(&r["created"]["dimensions"]).is_empty(), "{r}");
run(&mut s, "sketch.line", json!({"points": [[20, 10], [2, 0]], "id": "chord"}));
run(&mut s, "sketch.line", json!({"points": [[20, 10], [2, 0]], "ids": "chord"}));
// Drop the two references: what is left is the blend and its chord, one closed region.
run(&mut s, "sketch.delete", json!({"entities": [a, b]}));
let id = s.active_sketch.unwrap();
+420 -18
View File
@@ -5,7 +5,7 @@ use std::collections::HashMap;
use serde_json::{Value, json};
use solidcore_record::Vec2;
use solidcraft_io::{Geom2, MAX_DRAWING_BYTES};
use solidcraft_io::{DxfEntity, DxfLayer, Geom2, MAX_DRAWING_BYTES};
use solidcraft_sketch::{CurveKind, LinkKind, LinkSource, Sketch};
use super::CommandSpec;
@@ -29,7 +29,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.params("path | text: SVG or DXF (by `format`: \"svg\" | \"dxf\", default by the file name, else SVG); returns min, max, width and height in the file's own size"),
CommandSpec::new("sketch.export_dxf", "Save As DXF", export_dxf).icon("sketch_dxf")
.noundo()
.params("sketch?: id|name (default active); path?: file to write (else the DXF text is returned)"),
.params("sketch?: id|name (default active); path?: file to write (else the DXF text is returned); units?: \"design\" | \"in\" | \"inch\" | \"mm\" | \"cm\" | \"m\" | \"ft\" (default mm); layer?: profile layer name (default \"0\"); layers?: \"single\" | \"roles\" (default \"single\": all on `layer`; \"roles\": outer on \"OUTLINE\", holes on \"CUT\"); join?: bool (default false: R12 LINE/ARC; true: closed loops as LWPOLYLINE with arc bulges)"),
];
/// The sketch import command for a drawing file name, regardless of extension case.
@@ -290,39 +290,248 @@ fn insert_svg(s: &mut Session, p: &Value) -> Result<Value> {
insert(s, p, "sketch.insert_svg", true)
}
fn sorted_curve_ids<'a>(ids: &'a [String]) -> Vec<&'a str> {
let mut v: Vec<&'a str> = ids.iter().map(String::as_str).collect();
v.sort_unstable();
v
}
fn export_dxf(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.export_dxf";
let id = sketch_store::target(s, p, cmd)?;
let st = s.model.state();
let ss = st.sketch(id).ok_or_else(|| EngineError::Other("that sketch is not evaluated".into()))?;
let sk = &ss.sketch;
let mut geom = Vec::new();
for (i, c) in sk.curves.iter().enumerate() {
if c.construction {
continue;
}
let pt = |k: usize| sk.point(k).unwrap_or_default();
match &c.kind {
CurveKind::Line { a, b } => geom.push(Geom2::Line(pt(*a), pt(*b))),
CurveKind::Circle { c, r } => geom.push(Geom2::Circle(pt(*c), *r)),
CurveKind::Arc { c, a, b } => geom.push(Geom2::Arc { c: pt(*c), a: pt(*a), b: pt(*b) }),
_ => {
let poly = sk.polyline(i);
geom.extend(poly.windows(2).map(|w| Geom2::Line(w[0], w[1])));
let units_val = p.get("units").filter(|v| !v.is_null());
if let Some(v) = units_val
&& !v.is_string()
{
return Err(bad(cmd, "`units` must be a string"));
}
let units_param = str_(p, "units").map(str::to_ascii_lowercase);
let (insunits, scale) = match units_param.as_deref() {
None => (4, 1.0),
Some("design") => {
let doc_units = solidcraft_doc::expr::length_unit_or_mm(&s.doc.units);
match doc_units.to_ascii_lowercase().as_str() {
"in" | "inch" => (1, 1.0 / 25.4),
"ft" => (2, 1.0 / 304.8),
"cm" => (5, 0.1),
"m" => (6, 0.001),
_ => (4, 1.0),
}
}
Some("in" | "inch") => (1, 1.0 / 25.4),
Some("ft") => (2, 1.0 / 304.8),
Some("mm") => (4, 1.0),
Some("cm") => (5, 0.1),
Some("m") => (6, 0.001),
Some(other) => {
return Err(bad(cmd, format!("unsupported unit `{other}` (use \"design\", \"mm\", \"in\", \"cm\", \"m\", or \"ft\")")));
}
};
let layer_val = p.get("layer").filter(|v| !v.is_null());
if let Some(v) = layer_val
&& !v.is_string()
{
return Err(bad(cmd, "`layer` must be a string"));
}
let text = solidcraft_io::write_dxf(&geom);
let custom_layer = str_(p, "layer").map(str::trim).filter(|s| !s.is_empty());
let layers_val = p.get("layers").filter(|v| !v.is_null());
if let Some(v) = layers_val
&& !v.is_string()
{
return Err(bad(cmd, "`layers` must be \"single\" or \"roles\""));
}
let roles_layers = match str_(p, "layers").map(str::to_ascii_lowercase).as_deref() {
None | Some("single") => false,
Some("roles") => true,
Some(_) => return Err(bad(cmd, "`layers` must be \"single\" or \"roles\"")),
};
let join = match p.get("join").filter(|v| !v.is_null()) {
None => false,
Some(Value::Bool(b)) => *b,
Some(_) => return Err(bad(cmd, "`join` must be true or false")),
};
let path_val = p.get("path").filter(|v| !v.is_null());
if let Some(v) = path_val
&& !v.is_string()
{
return Err(bad(cmd, "`path` must be a string"));
}
let is_default = units_val.is_none() && layer_val.is_none() && layers_val.is_none() && !join;
let (text, entity_count) = if is_default {
let mut geom = Vec::new();
for (i, c) in sk.curves.iter().enumerate() {
if c.construction {
continue;
}
let pt = |k: usize| sk.point(k).unwrap_or_default();
match &c.kind {
CurveKind::Line { a, b } => geom.push(Geom2::Line(pt(*a), pt(*b))),
CurveKind::Circle { c, r } => geom.push(Geom2::Circle(pt(*c), *r)),
CurveKind::Arc { c, a, b } => geom.push(Geom2::Arc { c: pt(*c), a: pt(*a), b: pt(*b) }),
_ => {
let poly = sk.polyline(i);
geom.extend(poly.windows(2).map(|w| Geom2::Line(w[0], w[1])));
}
}
}
let count = geom.len();
(solidcraft_io::write_dxf(&geom), count)
} else if !join {
let hole_keys: std::collections::HashSet<Vec<&str>> =
ss.profiles.iter().flat_map(|p| p.hole_curves.iter().map(|h| sorted_curve_ids(h))).collect();
let mut outline_ids = std::collections::HashSet::new();
let mut cut_ids = std::collections::HashSet::new();
if roles_layers {
for prof in &ss.profiles {
for holes in &prof.hole_curves {
for id in holes {
cut_ids.insert(id.as_str());
}
}
if !hole_keys.contains(&sorted_curve_ids(&prof.outer_curves)) {
for id in &prof.outer_curves {
outline_ids.insert(id.as_str());
}
}
}
}
let base_layer = custom_layer.unwrap_or("0");
let mut entities = Vec::new();
for (i, c) in sk.curves.iter().enumerate() {
if c.construction {
continue;
}
let layer = if roles_layers {
if cut_ids.contains(c.id.as_str()) {
"CUT"
} else if outline_ids.contains(c.id.as_str()) {
"OUTLINE"
} else {
base_layer
}
} else {
base_layer
};
let pt = |k: usize| sk.point(k).unwrap_or_default() * scale;
match &c.kind {
CurveKind::Line { a, b } => entities.push((layer, Geom2::Line(pt(*a), pt(*b)))),
CurveKind::Circle { c, r } => entities.push((layer, Geom2::Circle(pt(*c), *r * scale))),
CurveKind::Arc { c, a, b } => entities.push((layer, Geom2::Arc { c: pt(*c), a: pt(*a), b: pt(*b) })),
_ => {
let poly = sk.polyline(i);
for w in poly.windows(2) {
entities.push((layer, Geom2::Line(w[0] * scale, w[1] * scale)));
}
}
}
}
let count = entities.len();
(solidcraft_io::write_dxf_entities(&entities, insunits), count)
} else {
// join: true -> emit closed loops as LWPOLYLINE with bulges for arcs
let hole_keys: std::collections::HashSet<Vec<&str>> =
ss.profiles.iter().flat_map(|p| p.hole_curves.iter().map(|h| sorted_curve_ids(h))).collect();
let mut outline_ids = std::collections::HashSet::new();
let mut cut_ids = std::collections::HashSet::new();
for prof in &ss.profiles {
for id in &prof.outer_curves {
outline_ids.insert(id.as_str());
}
for holes in &prof.hole_curves {
for id in holes {
cut_ids.insert(id.as_str());
}
}
}
let base_layer = custom_layer.unwrap_or("0");
let mut entities = Vec::new();
for prof in &ss.profiles {
if hole_keys.contains(&sorted_curve_ids(&prof.outer_curves)) {
continue;
}
let outer_layer = if roles_layers { "OUTLINE" } else { base_layer };
let shape = solidcraft_io::loop_lwpolyline_scaled(&prof.region.outer, scale);
entities.push(DxfEntity { layer: outer_layer.into(), shape });
for hole in &prof.region.holes {
let hole_layer = if roles_layers { "CUT" } else { base_layer };
let shape = solidcraft_io::loop_lwpolyline_scaled(hole, scale);
entities.push(DxfEntity { layer: hole_layer.into(), shape });
}
}
// Preserve any non-profile open curves as disconnected entities so nothing is lost
for (i, c) in sk.curves.iter().enumerate() {
if c.construction || outline_ids.contains(c.id.as_str()) || cut_ids.contains(c.id.as_str()) {
continue;
}
let pt = |k: usize| sk.point(k).unwrap_or_default() * scale;
match &c.kind {
CurveKind::Line { a, b } => {
entities.push(DxfEntity { layer: base_layer.into(), shape: solidcraft_io::DxfShape::Line(pt(*a), pt(*b)) })
}
CurveKind::Circle { c, r } => {
entities.push(DxfEntity { layer: base_layer.into(), shape: solidcraft_io::DxfShape::Circle { c: pt(*c), r: *r * scale } })
}
CurveKind::Arc { c, a, b } => {
let center = pt(*c);
let pa = pt(*a);
let pb = pt(*b);
let r = center.dist(pa);
let a0 = (pa - center).angle().to_degrees().rem_euclid(360.0);
let a1 = (pb - center).angle().to_degrees().rem_euclid(360.0);
entities.push(DxfEntity { layer: base_layer.into(), shape: solidcraft_io::DxfShape::Arc { c: center, r, a0, a1 } });
}
_ => {
let poly = sk.polyline(i);
let points: Vec<Vec2> = poly.into_iter().map(|p| p * scale).collect();
entities.push(DxfEntity { layer: base_layer.into(), shape: solidcraft_io::DxfShape::Polyline { points } });
}
}
}
let mut layer_names = std::collections::BTreeSet::new();
if roles_layers {
layer_names.insert("OUTLINE".to_string());
layer_names.insert("CUT".to_string());
}
for e in &entities {
layer_names.insert(e.layer.clone());
}
let layers: Vec<DxfLayer> = layer_names
.iter()
.map(|name| match name.as_str() {
"OUTLINE" => DxfLayer::new(name, 7),
"CUT" => DxfLayer::new(name, 4),
_ => DxfLayer::new(name, 7),
})
.collect();
let count = entities.len();
(solidcraft_io::write_dxf_drawing_units(&layers, &entities, insunits)?, count)
};
if let Some(path) = str_(p, "path").filter(|x| !x.trim().is_empty()) {
solidcraft_io::vfs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
return Ok(json!({"path": path, "entities": geom.len()}));
return Ok(json!({"path": path, "entities": entity_count}));
}
Ok(json!({"dxf": text, "entities": geom.len()}))
Ok(json!({"dxf": text, "entities": entity_count}))
}
#[cfg(test)]
mod tests {
use serde_json::json;
use solidcraft_geom::prelude::*;
use solidcraft_io::Geom2;
use crate::Session;
@@ -495,4 +704,197 @@ mod tests {
assert!(s.execute("sketch.insert_svg", &p).is_err(), "{bad}");
}
}
#[test]
fn export_dxf_default_is_byte_identical_to_before() {
let mut s = Session::default();
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
s.execute("sketch.rectangle.two_point", &json!({"p0": [0, 0], "p1": [40, 30]})).unwrap();
s.execute("sketch.circle.center", &json!({"center": [15, 15], "radius": 5})).unwrap();
let out = s.execute("sketch.export_dxf", &json!({})).unwrap();
let text = out["dxf"].as_str().unwrap();
// Build expected R12 DXF exactly as before
let st = s.model.state();
let ss = st.sketch(1).unwrap();
let sk = &ss.sketch;
let mut geom = Vec::new();
for (i, c) in sk.curves.iter().enumerate() {
if c.construction {
continue;
}
let pt = |k: usize| sk.point(k).unwrap_or_default();
match &c.kind {
solidcraft_sketch::CurveKind::Line { a, b } => geom.push(Geom2::Line(pt(*a), pt(*b))),
solidcraft_sketch::CurveKind::Circle { c, r } => geom.push(Geom2::Circle(pt(*c), *r)),
solidcraft_sketch::CurveKind::Arc { c, a, b } => geom.push(Geom2::Arc { c: pt(*c), a: pt(*a), b: pt(*b) }),
_ => {
let poly = sk.polyline(i);
geom.extend(poly.windows(2).map(|w| Geom2::Line(w[0], w[1])));
}
}
}
let expected = solidcraft_io::write_dxf(&geom);
assert_eq!(text, expected, "default output must be byte-identical to previous write_dxf output");
// Null options must be treated as omitted (default behavior)
for null_params in [
json!({"units": null}),
json!({"layer": null}),
json!({"layers": null}),
json!({"join": null}),
json!({"path": null}),
json!({"units": null, "layer": null, "layers": null, "join": null, "path": null}),
] {
let out_null = s.execute("sketch.export_dxf", &null_params).unwrap();
assert_eq!(out_null["dxf"].as_str().unwrap(), expected, "null options must behave identically to default options");
assert_eq!(out_null["entities"], out["entities"]);
}
// Hostile inputs
assert!(s.execute("sketch.export_dxf", &json!({"units": 123})).is_err());
assert!(s.execute("sketch.export_dxf", &json!({"units": "unknown_unit"})).is_err());
assert!(s.execute("sketch.export_dxf", &json!({"layer": 456})).is_err());
assert!(s.execute("sketch.export_dxf", &json!({"layers": 123})).is_err());
assert!(s.execute("sketch.export_dxf", &json!({"layers": "all"})).is_err());
assert!(s.execute("sketch.export_dxf", &json!({"join": "true"})).is_err());
assert!(s.execute("sketch.export_dxf", &json!({"path": 123})).is_err());
}
#[test]
fn export_dxf_layers_present() {
let mut s = Session::default();
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
s.execute("sketch.rectangle.two_point", &json!({"p0": [0, 0], "p1": [50, 50]})).unwrap();
s.execute("sketch.circle.center", &json!({"center": [25, 25], "radius": 10})).unwrap();
// 1. Single custom layer
let out_cut = s.execute("sketch.export_dxf", &json!({"layer": "CUT"})).unwrap();
let dxf_cut = out_cut["dxf"].as_str().unwrap();
assert!(dxf_cut.contains("\n8\nCUT\n"));
assert!(!dxf_cut.contains("\n8\n0\n"));
// 2. Roles layers (OUTLINE for outer profile, CUT for holes)
let out_roles = s.execute("sketch.export_dxf", &json!({"layers": "roles"})).unwrap();
let dxf_roles = out_roles["dxf"].as_str().unwrap();
assert!(dxf_roles.contains("\n8\nOUTLINE\n"));
assert!(dxf_roles.contains("\n8\nCUT\n"));
// 3. Roles layers with join: true
let out_joined_roles = s.execute("sketch.export_dxf", &json!({"join": true, "layers": "roles"})).unwrap();
assert_eq!(out_joined_roles["entities"], 2);
let dxf_joined = out_joined_roles["dxf"].as_str().unwrap();
assert!(dxf_joined.contains("AcDbPolyline"));
assert!(dxf_joined.contains("\n 8\nOUTLINE\n") || dxf_joined.contains("\n8\nOUTLINE\n"));
assert!(dxf_joined.contains("\n 8\nCUT\n") || dxf_joined.contains("\n8\nCUT\n"));
}
#[test]
fn export_dxf_join_does_not_duplicate_holes() {
let mut s = Session::default();
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
s.execute("sketch.rectangle.two_point", &json!({"p0": [0, 0], "p1": [100, 80]})).unwrap();
s.execute("sketch.circle.center", &json!({"center": [30, 40], "radius": 5})).unwrap();
s.execute("sketch.circle.center", &json!({"center": [70, 40], "radius": 8})).unwrap();
// With join: true, layers: "roles": exactly 3 entities (1 outer outline, 2 hole cuts)
let out_roles = s.execute("sketch.export_dxf", &json!({"join": true, "layers": "roles"})).unwrap();
assert_eq!(out_roles["entities"], 3, "expected 1 outer outline and 2 hole cuts, got {}", out_roles["entities"]);
let dxf_roles = out_roles["dxf"].as_str().unwrap();
let lwpolyline_count = dxf_roles.lines().filter(|l| l.trim() == "LWPOLYLINE").count();
assert_eq!(lwpolyline_count, 3, "expected exactly 3 LWPOLYLINE entities in DXF");
// Without roles (single layer): still exactly 3 entities
let out_single = s.execute("sketch.export_dxf", &json!({"join": true})).unwrap();
assert_eq!(out_single["entities"], 3, "entities without roles must also be exactly 3");
}
#[test]
fn export_dxf_join_closed_lwpolyline_with_bulge() {
let mut s = Session::default();
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
// Quarter-pie shape: center at (0, 0), arc from (10, 0) to (0, 10), and lines to (0, 0)
s.execute("sketch.line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
s.execute("sketch.arc.center_point", &json!({"center": [0, 0], "start": [10, 0], "end": [0, 10]})).unwrap();
s.execute("sketch.line", &json!({"points": [[0, 10], [0, 0]]})).unwrap();
let out = s.execute("sketch.export_dxf", &json!({"join": true})).unwrap();
let dxf = out["dxf"].as_str().unwrap();
assert!(dxf.contains("LWPOLYLINE"));
// 70: bit 1 (closed) + bit 128 (plinegen) = 129
assert!(dxf.contains("\n 70\n129\n") || dxf.contains("\n70\n129\n"));
// Bulge: 90° arc has sweep = pi/2, bulge = tan(sweep / 4) = tan(pi / 8) = sqrt(2) - 1 ≈ 0.414213562373
let expected_bulge = (std::f64::consts::FRAC_PI_2 / 4.0).tan();
let has_bulge = dxf
.lines()
.zip(dxf.lines().skip(1))
.any(|(k, v)| k.trim() == "42" && (v.trim().parse::<f64>().unwrap_or(0.0).abs() - expected_bulge).abs() < 1e-6);
assert!(has_bulge, "DXF must contain group 42 bulge approx {expected_bulge}");
// Re-import the joined DXF into a new sketch and verify round-trip profile area
s.execute("sketch.finish", &json!({})).unwrap();
let r = s.execute("sketch.insert_dxf", &json!({"text": dxf, "at": [100, 0]})).unwrap();
let id = r["sketch"].as_u64().unwrap();
let ss = s.model.state().sketch(id).unwrap().clone();
assert_eq!(ss.profiles.len(), 1);
let expected_area = std::f64::consts::PI * 100.0 / 4.0; // quarter circle of radius 10
assert!((ss.profiles[0].area - expected_area).abs() < 1e-4, "area was {}", ss.profiles[0].area);
}
#[test]
fn export_dxf_inch_design_round_trip() {
let mut s = Session::default();
s.doc_mut().units = "in".to_string();
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
// A 2 in by 3 in rectangle in an inch design (stored internally as 50.8 mm by 76.2 mm)
s.execute("sketch.rectangle.two_point", &json!({"p0": [0, 0], "p1": [50.8, 76.2]})).unwrap();
// 1. Export with units: "design"
let out = s.execute("sketch.export_dxf", &json!({"units": "design"})).unwrap();
let dxf = out["dxf"].as_str().unwrap();
// $INSUNITS must be 1 (inches)
assert!(dxf.contains("\n$INSUNITS\n70\n1\n"));
// Sizes in inches: 2.0 and 3.0
assert!(dxf.contains("2.000000000"));
assert!(dxf.contains("3.000000000"));
// Round-trip through read_dxf: reads $INSUNITS=1, scales by 25.4 to mm
let geom = solidcraft_io::read_dxf(dxf).unwrap();
let mut widths = Vec::new();
let mut heights = Vec::new();
for g in &geom {
if let Geom2::Line(a, b) = g {
let dx = (b.x - a.x).abs() / 25.4;
let dy = (b.y - a.y).abs() / 25.4;
if dx > 0.1 {
widths.push(dx);
}
if dy > 0.1 {
heights.push(dy);
}
}
}
for w in widths {
assert!((w - 2.0).abs() < 1e-8, "width in inches was {w}");
}
for h in heights {
assert!((h - 3.0).abs() < 1e-8, "height in inches was {h}");
}
// 2. Export with join: true and explicit units: "in"
let out_join = s.execute("sketch.export_dxf", &json!({"join": true, "units": "in"})).unwrap();
let dxf_join = out_join["dxf"].as_str().unwrap();
assert!(dxf_join.contains("LWPOLYLINE"));
assert!(dxf_join.contains("\n$INSUNITS\n 70\n1\n") || dxf_join.contains("\n$INSUNITS\n70\n1\n"));
let geom_join = solidcraft_io::read_dxf(dxf_join).unwrap();
assert_eq!(geom_join.len(), 4);
for g in &geom_join {
if let Geom2::Line(a, b) = g {
let len_in = a.dist(*b) / 25.4;
assert!((len_in - 2.0).abs() < 1e-8 || (len_in - 3.0).abs() < 1e-8, "edge length {len_in}");
}
}
}
}
+123 -77
View File
@@ -13,6 +13,13 @@ use super::sketch::{add_c, coincident_points, dof_now, edit, ids_of, line_or_axi
use super::sketch_store;
use super::{CommandSpec, in_sketch};
use crate::params::{bad, bool_, expr, num, req_expr, req_vec2, str_, string_list, vec2, vec2_list};
use crate::schema::sketch_modify::{
BreakParams, BreakResult, ChamferDistanceAngleParams, ChamferEqualParams, ChamferResult, ChamferTwoParams, CircularPatternParams, ExtendParams,
ExtendResult, FilletParams, FilletResult, MAX_BREAK_POINTS, MAX_COPIES, MAX_INSTANCES, MAX_PATH_POINTS, MirrorParams, MirrorResult, MoveParams,
MoveResult, OffsetBreakParams, OffsetBreakResult, OffsetParams, OffsetResult, OffsetSide, PatternResult, RectangularPatternParams, ScaleParams,
ScaleResult, TrimOutcome, TrimParams, TrimPathParams, TrimPathResult, TrimResult,
};
use crate::schema::{TypedContract, output_schema_for};
use crate::{Result, Session};
#[path = "sketch_pattern.rs"]
@@ -20,28 +27,54 @@ mod pattern;
#[path = "sketch_support.rs"]
mod support;
const TRIM_CONTRACT: TypedContract<TrimParams> = TypedContract::new();
const TRIM_PATH_CONTRACT: TypedContract<TrimPathParams> = TypedContract::new();
const BREAK_CONTRACT: TypedContract<BreakParams> = TypedContract::new();
const EXTEND_CONTRACT: TypedContract<ExtendParams> = TypedContract::new();
const FILLET_CONTRACT: TypedContract<FilletParams> = TypedContract::new();
const CHAMFER_EQUAL_CONTRACT: TypedContract<ChamferEqualParams> = TypedContract::new();
const CHAMFER_TWO_CONTRACT: TypedContract<ChamferTwoParams> = TypedContract::new();
const CHAMFER_DISTANCE_ANGLE_CONTRACT: TypedContract<ChamferDistanceAngleParams> = TypedContract::new();
const OFFSET_CONTRACT: TypedContract<OffsetParams> = TypedContract::new();
const OFFSET_BREAK_CONTRACT: TypedContract<OffsetBreakParams> = TypedContract::new();
const MIRROR_CONTRACT: TypedContract<MirrorParams> = TypedContract::new();
const MOVE_CONTRACT: TypedContract<MoveParams> = TypedContract::new();
const SCALE_CONTRACT: TypedContract<ScaleParams> = TypedContract::new();
const CIRCULAR_PATTERN_CONTRACT: TypedContract<CircularPatternParams> = TypedContract::new();
const RECTANGULAR_PATTERN_CONTRACT: TypedContract<RectangularPatternParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.fillet", "Fillet", fillet)
CommandSpec::new("sketch.fillet", "Fillet", crate::checked!("sketch.fillet", fillet))
.contract(&FILLET_CONTRACT)
.output(output_schema_for::<FilletResult>)
.at("SKETCH", "MODIFY")
.icon("sketch_fillet")
.enabled(in_sketch)
.params(concat!("point: corner shared by two curves (\"l1.end\") | points: [corners] (all rounded alike: one radius, the rest equal; a corner is a point reference or {a, b}, the two curves meeting there) | a, b: two lines or arcs meeting at a corner (two lines that share no end are first brought to where they meet: the nearer end of a line that stops short moves there, and a line that runs past is cut there, losing its shorter piece); radius: expr. A corner that dimensions or constraints measure to stays as a virtual sharp corner on both curves' extensions (`virtual_corners`; the moved ones in `moved_to_virtual_corner`). An ellipse's axis line is never a side of a corner (also for the chamfers)", created_doc!())),
CommandSpec::new("sketch.chamfer.equal_distance", "Equal Distance Chamfer", chamfer_equal)
CommandSpec::new("sketch.chamfer.equal_distance", "Equal Distance Chamfer", crate::checked!("sketch.chamfer.equal_distance", chamfer_equal))
.contract(&CHAMFER_EQUAL_CONTRACT)
.output(output_schema_for::<ChamferResult>)
.at("SKETCH", "MODIFY")
.icon("sketch_chamfer")
.enabled(in_sketch)
.params(concat!("point | points: [corners] (every corner cut alike; a corner is a point reference or {a, b}) | a, b: a corner of two lines (two that share no end are first brought to where they meet, as in sketch.fillet); distance", created_doc!())),
CommandSpec::new("sketch.chamfer.distance_angle", "Distance and Angle Chamfer", chamfer_dist_angle)
CommandSpec::new("sketch.chamfer.distance_angle", "Distance and Angle Chamfer", crate::checked!("sketch.chamfer.distance_angle", chamfer_dist_angle))
.contract(&CHAMFER_DISTANCE_ANGLE_CONTRACT)
.output(output_schema_for::<ChamferResult>)
.at("SKETCH", "MODIFY")
.icon("sketch_chamfer_angle")
.enabled(in_sketch)
.params(concat!("point | points: [corners] (every corner cut alike; a corner is a point reference or {a, b}, and a point's `a` is the first of its two lines in curve order) | a, b: a corner of two lines (two that share no end are first brought to where they meet, as in sketch.fillet); distance: along a, angle: deg from a", created_doc!())),
CommandSpec::new("sketch.chamfer.two_distance", "Two Distance Chamfer", chamfer_two)
CommandSpec::new("sketch.chamfer.two_distance", "Two Distance Chamfer", crate::checked!("sketch.chamfer.two_distance", chamfer_two))
.contract(&CHAMFER_TWO_CONTRACT)
.output(output_schema_for::<ChamferResult>)
.at("SKETCH", "MODIFY")
.icon("sketch_chamfer_two_distances")
.enabled(in_sketch)
.params(concat!("point | points: [corners] (every corner cut alike; a corner is a point reference or {a, b}, and a point's `a` is the first of its two lines in curve order) | a, b: a corner of two lines (two that share no end are first brought to where they meet, as in sketch.fillet); distance: along a, distance2: along b", created_doc!())),
CommandSpec::new("sketch.offset", "Offset", offset)
CommandSpec::new("sketch.offset", "Offset", crate::checked!("sketch.offset", offset))
.contract(&OFFSET_CONTRACT)
.output(output_schema_for::<OffsetResult>)
.at("SKETCH", "MODIFY")
.icon("offset")
.key("O")
@@ -53,33 +86,45 @@ pub static COMMANDS: &[CommandSpec] = &[
"instances?: whole number or unitless expression, 1…100 (default 1): each instance after the first offsets the instance before it by the same distance again, so instance n stands n distances from the curves picked. Every instance of a side is driven by that side's own dimension, and each entry of `sides` names its `instance`; an instance the shape cannot take refuses the whole command",
created_doc!()
)),
CommandSpec::new("sketch.offset.break", "Break Offset Association", break_offset).icon("sketch_break")
CommandSpec::new("sketch.offset.break", "Break Offset Association", crate::checked!("sketch.offset.break", break_offset))
.contract(&OFFSET_BREAK_CONTRACT)
.output(output_schema_for::<OffsetBreakResult>).icon("sketch_break")
.enabled(in_sketch)
.params("curve: derived offset curve id (retains geometry and makes its points editable)"),
CommandSpec::new("sketch.trim", "Trim", trim)
CommandSpec::new("sketch.trim", "Trim", crate::checked!("sketch.trim", trim))
.contract(&TRIM_CONTRACT)
.output(output_schema_for::<TrimResult>)
.at("SKETCH", "MODIFY")
.icon("trim")
.key("T")
.enabled(in_sketch)
.params(concat!("curve: id, at: [x,y] (the piece to remove: between the nearest intersections around it). Trimming a line source or its single-line offset releases linked extents (offset_released: curve ids), retaining their driving normal-distance dimensions and parallel relations", created_doc!())),
CommandSpec::new("sketch.trim.path", "Trim Along Path", trim_path)
CommandSpec::new("sketch.trim.path", "Trim Along Path", crate::checked!("sketch.trim.path", trim_path))
.contract(&TRIM_PATH_CONTRACT)
.output(output_schema_for::<TrimPathResult>)
.icon("trim")
.enabled(in_sketch)
.params(concat!(
"points: [[x,y]…] (2…2000 positions: a path drawn across the sketch). Every piece the path crosses is removed as `sketch.trim` removes the piece at that crossing, in the order the path crosses them; a piece crossed again after it is gone is not trimmed twice; eligible single-line offset outputs are trimmed while preserving their distance; projected geometry, infinite lines, other offset outputs and pieces trim refuses are passed over (why in `skipped`). dry_run?: bool (report without changing the design). One undo step → trimmed: [each trim's result], removed: [[[x,y]…]] (each removed piece as a polyline)",
created_doc!()
)),
CommandSpec::new("sketch.extend", "Extend", extend)
CommandSpec::new("sketch.extend", "Extend", crate::checked!("sketch.extend", extend))
.contract(&EXTEND_CONTRACT)
.output(output_schema_for::<ExtendResult>)
.at("SKETCH", "MODIFY")
.icon("extend")
.enabled(in_sketch)
.params("curve: id, at: [x,y] near the end to extend (a line runs on to the nearest curve, or the previewed reach when nothing is in the way; an arc grows along its circle and closes into a whole circle when nothing stops it) | curves: [id, id] (two lines or arcs of this sketch, grown until they meet and joined there), at?: [[x,y],[x,y]] (one point per curve: the end to grow; default the end nearest the meeting point). `curve` and `curves` exclude each other; the two-curve form returns moved: the meeting point's id"),
CommandSpec::new("sketch.break", "Break", break_cmd)
CommandSpec::new("sketch.break", "Break", crate::checked!("sketch.break", break_cmd))
.contract(&BREAK_CONTRACT)
.output(output_schema_for::<BreakResult>)
.at("SKETCH", "MODIFY")
.icon("sketch_break")
.enabled(in_sketch)
.params(concat!("curve: id, at: [x,y] (split at intersections; at a point where the curve ends and another curve meets, the curve gets its own end there and the two are no longer joined) | points: [[x,y]…] (1…1000 positions projected onto the curve; duplicates are merged)", created_doc!())),
CommandSpec::new("sketch.scale", "Sketch Scale", scale)
CommandSpec::new("sketch.scale", "Sketch Scale", crate::checked!("sketch.scale", scale))
.contract(&SCALE_CONTRACT)
.output(output_schema_for::<ScaleResult>)
.at("SKETCH", "MODIFY")
.icon("sketch_scale")
.enabled(in_sketch)
@@ -90,34 +135,47 @@ pub static COMMANDS: &[CommandSpec] = &[
.key("G")
.enabled(in_sketch)
.params("entities: [curve/point ids], factor?: number (scale), angle?: degrees (turn), center?: [x,y] or point ref (what scales and turns stay put; default [0,0]), translate?: [dx,dy] (applied last); scales, turns and moves in one step"),
CommandSpec::new("sketch.move", "Move/Copy Sketch Entities", move_copy)
CommandSpec::new("sketch.move", "Move/Copy Sketch Entities", crate::checked!("sketch.move", move_copy))
.contract(&MOVE_CONTRACT)
.output(output_schema_for::<MoveResult>)
.at("SKETCH", "MODIFY")
.icon("sketch_move")
.enabled(in_sketch)
.params(concat!("entities: [ids], translate?: [dx,dy], angle?: deg, center?: [x,y], copy?: bool, rotate_constraints?: bool (default true; false keeps constraints unchanged); a rotation turns the constraints tied to the sketch axes when all their geometry moves (horizontal and vertical relations and dimensions swap at odd quarter turns and stay at half turns, line angles turn with the line) and removes the horizontal and vertical ones that cannot hold at other angles; picking a text outline or linked point transforms its whole text source once, and copies remain editable text; copy also accepts drawn 3D curves, preserving exact arcs and height above the sketch plane", created_doc!())),
CommandSpec::new("sketch.mirror", "Mirror", mirror)
CommandSpec::new("sketch.mirror", "Mirror", crate::checked!("sketch.mirror", mirror))
.contract(&MIRROR_CONTRACT)
.output(output_schema_for::<MirrorResult>)
.at("SKETCH", "PATTERN")
.icon("sketch_mirror")
.enabled(in_sketch)
.params(concat!("entities: [curve/point ids], line: straight curve id, x / y for a sketch axis, or {axis: X|Y|Z} for an origin axis (the mirror line is the axis's projection into the sketch plane; an axis perpendicular to the plane is refused) (symmetry constraints are added)", created_doc!())),
CommandSpec::new("sketch.pattern.circular", "Circular Pattern", pattern_circular)
CommandSpec::new("sketch.pattern.circular", "Circular Pattern", crate::checked!("sketch.pattern.circular", pattern_circular))
.contract(&CIRCULAR_PATTERN_CONTRACT)
.output(output_schema_for::<PatternResult>)
.at("SKETCH", "PATTERN")
.icon("sketch_circ_pattern")
.enabled(in_sketch)
.params(concat!("entities: [ids], center: [x,y] or point ref, count: 2…500 (number or unitless expression, retained through edits), angle?: total deg, number or expression (default 360); alignment?: left|center|right (default right); reversed?: bool (default false). Quantity includes the original. Center balances copies around it; reversed flips the direction and extra side for even totals. Partial arcs span angle with angle/(count-1) spacing; full turns use angle/count. Adds associative pattern constraints; a coordinate center creates a constrainable point. Returns pattern and center ids. pattern?: existing pattern id to edit (omitted values retained; surviving instance ids retained)", created_doc!())),
CommandSpec::new("sketch.pattern.rectangular", "Rectangular Pattern", pattern_rect)
CommandSpec::new("sketch.pattern.rectangular", "Rectangular Pattern", crate::checked!("sketch.pattern.rectangular", pattern_rect))
.contract(&RECTANGULAR_PATTERN_CONTRACT)
.output(output_schema_for::<PatternResult>)
.at("SKETCH", "PATTERN")
.icon("sketch_rect_pattern")
.enabled(in_sketch)
.params(concat!("entities: [ids], count, spacing; count2?, spacing2?; counts: positive integer numbers or expressions, at most 500 instances total; spacings: length numbers or expressions; direction?, direction2?: sketch line ids (live references); dir?, dir2?: [x,y] vectors (defaults: sketch x, perpendicular to direction 1); alignment?, alignment2?: left|center|right (default right). Signed spacings reverse each direction. reversed?, reversed2?: bool (default false); reverses placement and the extra side of even centered totals. Quantities include the original. Adds associative pattern constraints. Returns pattern id. pattern?: existing pattern id to edit (omitted values retained; surviving instance ids retained)", created_doc!())),
];
/// A reply built from its typed value, which is what the command's output schema describes.
fn reply(cmd: &str, value: impl serde::Serialize) -> Result<Value> {
serde_json::to_value(value).map_err(|e| bad(cmd, e.to_string()))
}
/// The strings of a JSON list, or `None` when it isn't one.
fn strings(v: &Value) -> Option<Vec<String>> {
v.as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect())
}
const TOL: f64 = 1e-7;
const MAX_COPIES: usize = 500;
/// The most offsets `sketch.offset` stacks one behind the other in a single run. Each instance
/// is its own association, so this stays below the sketch's own caps on analytic offset groups
/// and free-form associations (both 128).
const MAX_INSTANCES: usize = 100;
fn curve_arg(sk: &Sketch, p: &Value, cmd: &str) -> Result<usize> {
let id = str_(p, "curve").ok_or_else(|| bad(cmd, "`curve` must be a curve id"))?;
@@ -523,14 +581,11 @@ fn trim(s: &mut Session, p: &Value) -> Result<Value> {
let (out, info) = edit(s, p, cmd, false, |sk, doc| {
let ci = curve_arg(sk, p, cmd)?;
let released = release_line_offsets(sk, doc, ci, cmd)?;
let (mut out, _) = remove_piece(sk, ci, at, cmd)?;
if !released.is_empty() {
out["offset_released"] = json!(released);
}
let (out, _) = remove_piece(sk, ci, at, cmd)?;
doc.prune_model_params();
Ok(out)
Ok(out.released(released))
})?;
Ok(json!({"result": out, "sketch": info}))
reply(cmd, TrimResult { result: out, sketch: info, param_warnings: Vec::new() })
}
/// Source lines and their single-line offsets can have independent endpoints. Materialize
@@ -685,7 +740,7 @@ fn hold_cut(sk: &mut Sketch, before: Option<&Sketch>, midpoints: &[usize], pi: u
/// Remove the piece of curve `ci` around `at`: between the nearest intersections on either
/// side, or the whole curve when nothing crosses it. Returns what `sketch.trim` reports and the
/// removed piece as a polyline.
fn remove_piece(sk: &mut Sketch, ci: usize, at: Vec2, cmd: &str) -> Result<(Value, Vec<Vec2>)> {
fn remove_piece(sk: &mut Sketch, ci: usize, at: Vec2, cmd: &str) -> Result<(TrimOutcome, Vec<Vec2>)> {
loosen_along(sk, ci);
let id = sk.curves.get(ci).map(|c| c.id.clone()).unwrap_or_default();
if sk.curves.get(ci).is_some_and(|c| c.infinite) {
@@ -698,7 +753,8 @@ fn remove_piece(sk: &mut Sketch, ci: usize, at: Vec2, cmd: &str) -> Result<(Valu
Some(pts) => pts.iter().filter_map(|q| Some(Vec2::new(q.get(0)?.as_f64()?, q.get(1)?.as_f64()?))).collect(),
None => whole,
};
return Ok((json!({"trimmed": id, "pieces": r["curves"]}), removed));
let pieces = r.get("curves").and_then(strings);
return Ok((TrimOutcome::Freeform { trimmed: id, pieces, offset_released: Vec::new() }, removed));
}
let sh = sk.shape(ci).ok_or_else(|| bad(cmd, "curve"))?;
let tq = sh.param(sh.project(at));
@@ -707,7 +763,7 @@ fn remove_piece(sk: &mut Sketch, ci: usize, at: Vec2, cmd: &str) -> Result<(Valu
// Nothing crosses it: the whole curve goes.
let removed = sk.polyline(ci);
sk.remove_curves(&[ci]);
return Ok((json!({"deleted": id}), removed));
return Ok((TrimOutcome::Deleted { deleted: id, offset_released: Vec::new() }, removed));
}
refuse_offset_ends(sk, ci, "trim", cmd)?;
// For parallel lines, any point on the output measures the same normal distance.
@@ -788,14 +844,12 @@ fn remove_piece(sk: &mut Sketch, ci: usize, at: Vec2, cmd: &str) -> Result<(Valu
}
let removed = sk.polyline(gone);
sk.remove_curves(&[gone]);
Ok((json!({"trimmed": id}), removed))
Ok((TrimOutcome::Trimmed { trimmed: id, offset_released: Vec::new() }, removed))
}
/// Most crossings one `sketch.trim.path` trims (each one splits a curve, so this also caps the
/// work a hostile path can ask for).
const MAX_PATH_TRIMS: usize = 500;
/// Most positions in a trim path.
const MAX_PATH_POINTS: usize = 2_000;
/// The first place along `path` after `after` (segment, parameter along it) where the path
/// crosses a curve that a trim may change: (segment, parameter, curve, the crossing point). Crossings are found on the
@@ -860,6 +914,10 @@ fn seg_cross(a: Vec2, b: Vec2, c: Vec2, d: Vec2) -> Option<(f64, Vec2)> {
/// Preview or trim the pieces crossed by a path, skipping protected or unsupported geometry.
fn trim_path(s: &mut Session, p: &Value) -> Result<Value> {
reply("sketch.trim.path", trim_path_reply(s, p)?)
}
fn trim_path_reply(s: &mut Session, p: &Value) -> Result<TrimPathResult> {
let cmd = "sketch.trim.path";
let path = vec2_list(cmd, p, "points", MAX_PATH_POINTS)?;
if path.len() < 2 {
@@ -871,13 +929,13 @@ fn trim_path(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(o) = q.as_object_mut() {
o.remove("dry_run");
}
let mut out = trim_path(&mut t, &q)?;
out["dry_run"] = json!(true);
let mut out = trim_path_reply(&mut t, &q)?;
out.dry_run = Some(true);
return Ok(out);
}
let ((trimmed, removed, skipped), info) = edit(s, p, cmd, false, |sk, doc| {
let mut trimmed: Vec<Value> = Vec::new();
let mut removed: Vec<Value> = Vec::new();
let mut trimmed: Vec<TrimOutcome> = Vec::new();
let mut removed: Vec<Vec<[f64; 2]>> = Vec::new();
let mut skipped: Vec<String> = Vec::new();
let mut after = (0, f64::NEG_INFINITY);
for _ in 0..MAX_PATH_TRIMS {
@@ -886,16 +944,13 @@ fn trim_path(s: &mut Session, p: &Value) -> Result<Value> {
// A piece trim refuses (a closed spline crossed once) stays as it was.
let before = sk.clone();
let cut = release_line_offsets(sk, doc, ci, cmd).and_then(|released| {
let (mut out, gone) = remove_piece(sk, ci, q, cmd)?;
if !released.is_empty() {
out["offset_released"] = json!(released);
}
Ok((out, gone))
let (out, gone) = remove_piece(sk, ci, q, cmd)?;
Ok((out.released(released), gone))
});
match cut {
Ok((out, gone)) => {
trimmed.push(out);
removed.push(json!(gone.iter().map(|v| [v.x, v.y]).collect::<Vec<_>>()));
removed.push(gone.iter().map(|v| [v.x, v.y]).collect());
}
Err(e) => {
*sk = before;
@@ -912,7 +967,7 @@ fn trim_path(s: &mut Session, p: &Value) -> Result<Value> {
doc.prune_model_params();
Ok((trimmed, removed, skipped))
})?;
Ok(json!({"trimmed": trimmed, "removed": removed, "skipped": skipped, "sketch": info}))
Ok(TrimPathResult { trimmed, removed, skipped, sketch: info, dry_run: None, param_warnings: Vec::new() })
}
/// Cuts closer than this to a curve's end or to each other are one place (mm).
@@ -920,7 +975,7 @@ const BREAK_GAP: f64 = 1e-6;
fn break_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.break";
let points = if p.get("points").is_some() { Some(vec2_list(cmd, p, "points", 1000)?) } else { None };
let points = if p.get("points").is_some() { Some(vec2_list(cmd, p, "points", MAX_BREAK_POINTS)?) } else { None };
let (out, info) = edit(s, p, cmd, false, |sk, doc| {
let ci = curve_arg(sk, p, cmd)?;
if points.is_none()
@@ -998,7 +1053,7 @@ fn break_cmd(s: &mut Session, p: &Value) -> Result<Value> {
tie(sk, &pieces)?;
Ok(ids_of(sk, &pieces))
})?;
Ok(json!({"curves": out, "sketch": info}))
reply(cmd, BreakResult { curves: out, sketch: info, param_warnings: Vec::new() })
}
/// The end of curve `ci` at `at` that another curve also ends at or passes through: Break there
@@ -1478,7 +1533,7 @@ fn extend(s: &mut Session, p: &Value) -> Result<Value> {
};
grow_line(sk, ci, aim)
})?;
Ok(json!({"moved": out, "sketch": info}))
reply(cmd, ExtendResult { moved: out, sketch: info, param_warnings: Vec::new() })
}
/// Grow line `ci` past the end `aim` names, to `aim.hit`: a new line when that end is joined to
@@ -2166,8 +2221,8 @@ fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok((arcs, cons, pname, kept_json(kept)))
})?;
let (vc, moved) = out.3;
Ok(json!({"curves": out.0, "constraints": out.1, "param": out.2, "virtual_corners": vc, "moved_to_virtual_corner": moved, "sketch": info}))
let (curves, constraints, param, (virtual_corners, moved_to_virtual_corner)) = out;
reply(cmd, FilletResult { curves, constraints, param, virtual_corners, moved_to_virtual_corner, sketch: info, param_warnings: Vec::new() })
}
/// The virtual corners kept and the constraints moved to them, for a response.
@@ -2381,8 +2436,8 @@ fn chamfer(s: &mut Session, p: &Value, cmd: &str, dists: impl Fn(f64) -> Result<
}
Ok((ids_of(sk, &lines), kept_json(kept)))
})?;
let (curves, (vc, moved)) = out;
Ok(json!({"curves": curves, "virtual_corners": vc, "moved_to_virtual_corner": moved, "sketch": info}))
let (curves, (virtual_corners, moved_to_virtual_corner)) = out;
reply(cmd, ChamferResult { curves, virtual_corners, moved_to_virtual_corner, sketch: info, param_warnings: Vec::new() })
}
/// Cut one corner (`point` or `a`, `b`) of two lines with a straight line, `dists` giving how
@@ -3167,30 +3222,25 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
doc.params.retain(|p| !made.contains(&p.name) || param.as_ref() == Some(&p.name) || param2.as_ref() == Some(&p.name));
Ok((sides, param, param2))
})?;
let curves: Vec<&String> = sides.iter().flat_map(|(_, side)| side.curves.iter()).collect();
let constraints: Vec<&String> = sides.iter().flat_map(|(_, side)| side.constraints.iter()).collect();
let each: Vec<Value> = sides
let curves: Vec<String> = sides.iter().flat_map(|(_, side)| side.curves.iter().cloned()).collect();
let constraints: Vec<String> = sides.iter().flat_map(|(_, side)| side.constraints.iter().cloned()).collect();
let each: Vec<OffsetSide> = sides
.iter()
.map(|(instance, side)| {
let mut entry = json!({"distance": side.distance, "curves": side.curves, "param": side.param, "instance": instance});
if let Some(association) = &side.association {
entry["association"] = json!(association);
}
entry
.map(|(instance, side)| OffsetSide {
distance: side.distance,
curves: side.curves.clone(),
param: side.param.clone(),
instance: *instance,
association: side.association.clone(),
})
.collect();
let mut response = json!({"curves":curves,"constraints":constraints,"sketch":info,"sides":each,"param":param,"instances":instances});
if let Some(param2) = param2 {
response["param2"] = json!(param2);
}
// One side keeps the single `association` it always returned. Two sides, or an instance
// behind another, have one each in `sides`, so there is no single association left to name.
if let [(_, only)] = sides.as_slice()
&& let Some(association) = &only.association
{
response["association"] = json!(association);
}
Ok(response)
let association = match sides.as_slice() {
[(_, only)] => only.association.clone(),
_ => None,
};
reply(cmd, OffsetResult { curves, constraints, sketch: info, sides: each, param, instances, param2, association, param_warnings: Vec::new() })
}
fn break_offset(s: &mut Session, p: &Value) -> Result<Value> {
@@ -3281,11 +3331,7 @@ fn break_offset(s: &mut Session, p: &Value) -> Result<Value> {
Ok((output.to_string(), None))
})?;
s.doc_mut().prune_model_params();
let mut response = json!({"curve":out.0,"sketch":info});
if let Some(curves) = out.1 {
response["curves"] = json!(curves);
}
Ok(response)
reply(cmd, OffsetBreakResult { curve: out.0, sketch: info, curves: out.1, param_warnings: Vec::new() })
}
/// A broken logical boundary becomes ordinary geometry. Keep downstream features
@@ -3530,7 +3576,7 @@ fn mirror(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok((ids_of(sk, &made), cons))
})?;
Ok(json!({"curves": out.0, "constraints": out.1, "sketch": info}))
reply(cmd, MirrorResult { curves: out.0, constraints: out.1, sketch: info, param_warnings: Vec::new() })
}
fn point_or_ref(sk: &Sketch, p: &Value, k: &str, cmd: &str) -> Result<Vec2> {
@@ -3740,7 +3786,7 @@ fn move_copy(s: &mut Session, p: &Value) -> Result<Value> {
Ok(ids_of(sk, &cs))
})?;
s.doc_mut().prune_model_params();
Ok(json!({"curves": out, "sketch": info}))
reply(cmd, MoveResult { curves: out, sketch: info, param_warnings: Vec::new() })
}
/// Scale, turn and move the entities in one step: scaled about `center`, then turned about it,
@@ -3838,7 +3884,7 @@ fn scale(s: &mut Session, p: &Value) -> Result<Value> {
transform_in_place(sk, &cs, &ps, &f, k, cmd)?;
Ok(ids_of(sk, &cs))
})?;
Ok(json!({"curves": out, "sketch": info}))
reply(cmd, ScaleResult { curves: out, sketch: info, param_warnings: Vec::new() })
}
#[cfg(test)]
+4 -6
View File
@@ -4,6 +4,7 @@ use solidcraft_sketch::{SketchPattern, SketchPatternKind};
use super::*;
use crate::cmd::{alignment_param, reversed_param};
use crate::schema::sketch_modify::PatternResult;
pub(super) fn run(s: &mut Session, p: &Value, circular: bool) -> Result<Value> {
let cmd = if circular { "sketch.pattern.circular" } else { "sketch.pattern.rectangular" };
@@ -194,13 +195,10 @@ pub(super) fn run(s: &mut Session, p: &Value, circular: bool) -> Result<Value> {
let pattern = sk.patterns.get(index).ok_or_else(|| bad(cmd, "missing sketch pattern"))?;
let curves: Vec<_> = pattern.instances.iter().flat_map(|i| i.curves.iter().cloned()).collect();
let constraints: Vec<_> = pattern.instances.iter().flat_map(|i| i.constraints.iter().cloned()).collect();
Ok(json!({"pattern":id,"center":center_id,"curves":curves,"constraints":constraints}))
Ok((id, center_id, curves, constraints))
})?;
let mut out = out;
if let Some(out) = out.as_object_mut() {
out.insert("sketch".into(), info);
}
Ok(out)
let (pattern, center, curves, constraints) = out;
reply(cmd, PatternResult { pattern, center, curves, constraints, sketch: info, param_warnings: Vec::new() })
}
fn count(p: &Value, k: &str, cmd: &str) -> Result<usize> {
+136 -69
View File
@@ -13,6 +13,12 @@ use solidcraft_sketch::{ConstraintKind, LinkKind, LinkSource, Sketch};
use super::sketch_store;
use super::{CommandSpec, in_sketch};
use crate::params::{bad, bool_, str_, vec3};
use crate::schema::sketch_project::{
AutoProjectParams, AutoProjectResult, FitCurvesParams, FitSectionResult, Include3dParams, IntersectParams, IntersectionCurveParams,
IsoCurveParams, LinkMade, ProjectParams, ProjectResult, ProjectToSurfaceParams, ProjectToSurfaceResult, ProjectedLink, SketchBreakLinkParams,
SketchBreakLinkResult, SketchLinkResult, SpunProfileParams,
};
use crate::schema::{TypedContract, output_schema_for};
use crate::{EngineError, Result, Session};
macro_rules! refs_doc {
@@ -21,105 +27,160 @@ macro_rules! refs_doc {
};
}
const PROJECT_CONTRACT: TypedContract<ProjectParams> = TypedContract::new();
const INTERSECT_CONTRACT: TypedContract<IntersectParams> = TypedContract::new();
const INCLUDE_3D_CONTRACT: TypedContract<Include3dParams> = TypedContract::new();
const PROJECT_TO_SURFACE_CONTRACT: TypedContract<ProjectToSurfaceParams> = TypedContract::new();
const INTERSECTION_CURVE_CONTRACT: TypedContract<IntersectionCurveParams> = TypedContract::new();
const SPUN_PROFILE_CONTRACT: TypedContract<SpunProfileParams> = TypedContract::new();
const ISO_CURVE_CONTRACT: TypedContract<IsoCurveParams> = TypedContract::new();
const FIT_SECTION_CONTRACT: TypedContract<FitCurvesParams> = TypedContract::new();
const BREAK_LINK_CONTRACT: TypedContract<SketchBreakLinkParams> = TypedContract::new();
const AUTO_PROJECT_CONTRACT: TypedContract<AutoProjectParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.project", "Project", project_cmd).at("SKETCH", "CREATE").icon("project").key("P").enabled(in_sketch).params(
CommandSpec::new("sketch.project", "Project", crate::checked!("sketch.project", project_cmd)).at("SKETCH", "CREATE").icon("project").key("P").enabled(in_sketch).contract(&PROJECT_CONTRACT).output(output_schema_for::<ProjectResult>).params(
concat!(refs_doc!(), "; projection_link?: bool (default true)", created_doc!()),
),
CommandSpec::new("sketch.intersect", "Intersect", intersect_cmd).at("SKETCH", "CREATE").icon("intersect").enabled(in_sketch).params(
CommandSpec::new("sketch.intersect", "Intersect", crate::checked!("sketch.intersect", intersect_cmd)).at("SKETCH", "CREATE").icon("intersect").enabled(in_sketch).contract(&INTERSECT_CONTRACT).output(output_schema_for::<ProjectResult>).params(
concat!("refs: [{body}, {face: [x,y,z], body?}, {edge: [x,y,z], body?}, {sketch, curve}]: section curves (or points) with the sketch plane; projection_link?: bool (default true)", created_doc!()),
),
CommandSpec::new("sketch.include_3d", "Include 3D Geometry", include_cmd)
CommandSpec::new("sketch.include_3d", "Include 3D Geometry", crate::checked!("sketch.include_3d", include_cmd))
.at("SKETCH", "CREATE")
.icon("include")
.enabled(in_sketch)
.params(concat!("refs: like Project (edges, vertices, sketch curves and points); curves out of the sketch plane are flattened onto it", created_doc!())),
CommandSpec::new("sketch.project_to_surface", "Project To Surface", project_to_surface)
.contract(&INCLUDE_3D_CONTRACT).output(output_schema_for::<ProjectResult>).params(concat!("refs: like Project (edges, vertices, sketch curves and points); curves out of the sketch plane are flattened onto it", created_doc!())),
CommandSpec::new("sketch.project_to_surface", "Project To Surface", crate::checked!("sketch.project_to_surface", project_to_surface))
.at("SKETCH", "CREATE")
.icon("project_surface")
.enabled(in_sketch)
.params(concat!("curves: [{sketch, curve}…] (curves of other sketches), face: [x,y,z] point on the target face, body?: projected along the active sketch's normal onto the face, as linked 3D curves", created_doc!())),
CommandSpec::new("sketch.intersection_curve", "Intersection Curve", intersection_curve)
.contract(&PROJECT_TO_SURFACE_CONTRACT).output(output_schema_for::<ProjectToSurfaceResult>).params(concat!("curves: [{sketch, curve}…] (curves of other sketches), face: [x,y,z] point on the target face, body?: projected along the active sketch's normal onto the face, as linked 3D curves", created_doc!())),
CommandSpec::new("sketch.intersection_curve", "Intersection Curve", crate::checked!("sketch.intersection_curve", intersection_curve))
.at("SKETCH", "CREATE")
.icon("intersection_curve")
.enabled(in_sketch)
.params(concat!("a, b: {body} | {face: [x,y,z], body?} | {sketch, curve} (a curve of another sketch, standing in for its extrusion along that sketch's normal): where they meet, as linked 3D curves", created_doc!())),
CommandSpec::new("sketch.spun_profile", "Spun Profile", spun_profile)
.contract(&INTERSECTION_CURVE_CONTRACT).output(output_schema_for::<SketchLinkResult>).params(concat!("a, b: {body} | {face: [x,y,z], body?} | {sketch, curve} (a curve of another sketch, standing in for its extrusion along that sketch's normal): where they meet, as linked 3D curves", created_doc!())),
CommandSpec::new("sketch.spun_profile", "Spun Profile", crate::checked!("sketch.spun_profile", spun_profile))
.at("SKETCH", "CREATE")
.icon("spun")
.enabled(in_sketch)
.params(concat!("body: name, axis: a line of the active sketch (curve id) | X|Y|Z: the body's outline revolved about the axis, in the sketch plane", created_doc!())),
CommandSpec::new("sketch.isoparametric_curve", "Isoparametric Curve", iso_curve)
.contract(&SPUN_PROFILE_CONTRACT).output(output_schema_for::<SketchLinkResult>).params(concat!("body: name, axis: a line of the active sketch (curve id) | X|Y|Z: the body's outline revolved about the axis, in the sketch plane", created_doc!())),
CommandSpec::new("sketch.isoparametric_curve", "Isoparametric Curve", crate::checked!("sketch.isoparametric_curve", iso_curve))
.at("SKETCH", "CREATE")
.icon("iso_curve")
.enabled(in_sketch)
.params(concat!("face: [x,y,z] point on the face (the curve passes through it), body?, direction?: u|v|[x,y,z] (default u): linked 3D curve", created_doc!())),
CommandSpec::new("sketch.fit_curves_to_section", "Fit Curves to Mesh Section", fit_section)
.contract(&ISO_CURVE_CONTRACT).output(output_schema_for::<SketchLinkResult>).params(concat!("face: [x,y,z] point on the face (the curve passes through it), body?, direction?: u|v|[x,y,z] (default u): linked 3D curve", created_doc!())),
CommandSpec::new("sketch.fit_curves_to_section", "Fit Curves to Mesh Section", crate::checked!("sketch.fit_curves_to_section", fit_section))
.at("SKETCH", "CREATE")
.icon("fit_section")
.enabled(in_sketch)
.params(concat!("body: name (a mesh or solid body): its section with the sketch plane, fitted with lines and arcs, as normal (unlinked) geometry", created_doc!())),
CommandSpec::new("sketch.break_link", "Break Link", break_link).icon("sketch_break")
.contract(&FIT_SECTION_CONTRACT).output(output_schema_for::<FitSectionResult>).params(concat!("body: name (a mesh or solid body): its section with the sketch plane, fitted with lines and arcs, as normal (unlinked) geometry", created_doc!())),
CommandSpec::new("sketch.break_link", "Break Link", crate::checked!("sketch.break_link", break_link)).icon("sketch_break")
.enabled(in_sketch)
.params("link?: link id (j1) | entities?: [curve or point ids]: linked geometry becomes normal sketch geometry"),
CommandSpec::new("sketch.auto_project", "Auto-Project Edges on Reference", auto_project).icon("project")
.contract(&BREAK_LINK_CONTRACT).output(output_schema_for::<SketchBreakLinkResult>).params("link?: link id (j1) | entities?: [curve or point ids]: linked geometry becomes normal sketch geometry"),
CommandSpec::new("sketch.auto_project", "Auto-Project Edges on Reference", crate::checked!("sketch.auto_project", auto_project)).icon("project")
.noundo()
.params("value?: bool (default toggles): project a face's edges when a sketch is created on it"),
.contract(&AUTO_PROJECT_CONTRACT).output(output_schema_for::<AutoProjectResult>).params("value?: bool (default toggles): project a face's edges when a sketch is created on it"),
];
/// Parse one reference.
fn source(s: &Session, v: &Value, cmd: &str) -> Result<LinkSource> {
/// What a reference says in its JSON, before the design is asked what it names (`source` goes on
/// from here, and so does the contract of the commands that take references).
pub(crate) enum RefForm<'a> {
/// Text: `origin`, an axis letter or a body name, which the design decides.
Name(&'a str),
/// `{edge: [x,y,z], body?}`.
Edge { body: String, at: Vec3 },
/// `{face: [x,y,z], body?}`.
Face { body: String, at: Vec3 },
/// `{vertex: [x,y,z], body?}`.
Vertex { body: String, at: Vec3 },
/// `{sketch, curve}` or `{sketch, point}`: the sketch as the lookup reads it (an id or a name)
/// and the `curve` or `point` given. With neither, the handler refuses it once the sketch is
/// found.
Sketch { key: String, curve: Option<&'a str>, point: Option<&'a str> },
/// `{body: name}`.
Body(&'a str),
/// `{axis: X|Y|Z|name}`.
Axis(&'a str),
/// `{plane: XY|XZ|YZ|name}`.
Plane(&'a str),
/// `{origin: …}`.
Origin,
}
/// The form of one reference, or the message (without the command's name) for what no design
/// could make sense of. The forms are tried in this order, the first that fits wins: an edge, face
/// or vertex whose value is not a point is passed over, not refused.
pub(crate) fn ref_form(v: &Value) -> std::result::Result<RefForm<'_>, String> {
if let Some(name) = v.as_str() {
return match name.to_ascii_lowercase().as_str() {
"origin" => Ok(LinkSource::Origin),
"x" | "y" | "z" => Ok(LinkSource::Axis { name: name.to_ascii_uppercase() }),
_ if s.model.state().body(name).is_some() => Ok(LinkSource::Body { body: name.to_string() }),
_ => Err(bad(cmd, format!("unknown reference `{name}`"))),
};
return Ok(RefForm::Name(name));
}
let body = || str_(v, "body").unwrap_or("").to_string();
if let Some(at) = v.get("edge").and_then(vec3) {
return Ok(LinkSource::Edge { body: body(), at });
return Ok(RefForm::Edge { body: body(), at });
}
if let Some(at) = v.get("face").and_then(vec3) {
return Ok(LinkSource::Face { body: body(), at });
return Ok(RefForm::Face { body: body(), at });
}
if let Some(at) = v.get("vertex").and_then(vec3) {
return Ok(LinkSource::Vertex { body: body(), at });
return Ok(RefForm::Vertex { body: body(), at });
}
if let Some(sk) = v.get("sketch") {
let key = match sk {
Value::Number(n) => n.to_string(),
Value::String(x) => x.clone(),
_ => return Err(bad(cmd, "`sketch` must be an id or a name")),
_ => return Err("`sketch` must be an id or a name".into()),
};
let f = s.doc.find_feature(&key).ok_or_else(|| bad(cmd, format!("no sketch `{key}`")))?;
if !matches!(f.kind, FeatureKind::Sketch { .. }) {
return Err(bad(cmd, format!("`{key}` is not a sketch")));
}
if Some(f.id) == s.active_sketch {
return Err(bad(cmd, "a sketch cannot project its own geometry"));
}
if let Some(c) = str_(v, "curve") {
return Ok(LinkSource::SketchCurve { sketch: f.id, curve: c.to_string() });
}
if let Some(p) = str_(v, "point") {
return Ok(LinkSource::SketchPoint { sketch: f.id, point: p.to_string() });
}
return Err(bad(cmd, "a sketch reference needs `curve` or `point`"));
return Ok(RefForm::Sketch { key, curve: str_(v, "curve"), point: str_(v, "point") });
}
if let Some(b) = str_(v, "body") {
return Ok(LinkSource::Body { body: b.to_string() });
return Ok(RefForm::Body(b));
}
if let Some(a) = str_(v, "axis") {
return axis_source(s, a, cmd);
return Ok(RefForm::Axis(a));
}
if let Some(p) = str_(v, "plane") {
return Ok(LinkSource::Plane { name: p.to_string() });
return Ok(RefForm::Plane(p));
}
if v.get("origin").is_some() {
return Ok(LinkSource::Origin);
return Ok(RefForm::Origin);
}
Err(format!("cannot project {v}"))
}
/// Parse one reference.
fn source(s: &Session, v: &Value, cmd: &str) -> Result<LinkSource> {
match ref_form(v).map_err(|why| bad(cmd, why))? {
RefForm::Name(name) => match name.to_ascii_lowercase().as_str() {
"origin" => Ok(LinkSource::Origin),
"x" | "y" | "z" => Ok(LinkSource::Axis { name: name.to_ascii_uppercase() }),
_ if s.model.state().body(name).is_some() => Ok(LinkSource::Body { body: name.to_string() }),
_ => Err(bad(cmd, format!("unknown reference `{name}`"))),
},
RefForm::Edge { body, at } => Ok(LinkSource::Edge { body, at }),
RefForm::Face { body, at } => Ok(LinkSource::Face { body, at }),
RefForm::Vertex { body, at } => Ok(LinkSource::Vertex { body, at }),
RefForm::Sketch { key, curve, point } => {
let f = s.doc.find_feature(&key).ok_or_else(|| bad(cmd, format!("no sketch `{key}`")))?;
if !matches!(f.kind, FeatureKind::Sketch { .. }) {
return Err(bad(cmd, format!("`{key}` is not a sketch")));
}
if Some(f.id) == s.active_sketch {
return Err(bad(cmd, "a sketch cannot project its own geometry"));
}
if let Some(c) = curve {
return Ok(LinkSource::SketchCurve { sketch: f.id, curve: c.to_string() });
}
if let Some(p) = point {
return Ok(LinkSource::SketchPoint { sketch: f.id, point: p.to_string() });
}
Err(bad(cmd, "a sketch reference needs `curve` or `point`"))
}
RefForm::Body(b) => Ok(LinkSource::Body { body: b.to_string() }),
RefForm::Axis(a) => axis_source(s, a, cmd),
RefForm::Plane(p) => Ok(LinkSource::Plane { name: p.to_string() }),
RefForm::Origin => Ok(LinkSource::Origin),
}
Err(bad(cmd, format!("cannot project {v}")))
}
/// An origin axis (`X`, `Y` or `Z`, in any case), or a construction axis by its name (#9324).
@@ -391,10 +452,17 @@ fn add_link_unless_present(
Ok(Some(link))
}
fn link_json(sk: &Sketch, link: &str) -> Value {
/// A command's reply as JSON: they build their replies from the types their output schemas
/// describe, so the two can't drift apart.
fn reply(cmd: &str, reply: impl serde::Serialize) -> Result<Value> {
serde_json::to_value(reply).map_err(|e| bad(cmd, e.to_string()))
}
/// The ids of the curves and of the points that link `link` made.
fn link_parts(sk: &Sketch, link: &str) -> (Vec<String>, Vec<String>) {
let curves: Vec<String> = sk.link_curves(link).iter().filter_map(|c| sk.curves.get(*c).map(|c| c.id.clone())).collect();
let points: Vec<String> = sk.link_points(link).iter().filter_map(|p| sk.points.get(*p).map(|p| p.id.clone())).collect();
json!({"link": link, "curves": curves, "points": points})
(curves, points)
}
fn run_links(s: &mut Session, p: &Value, cmd: &str, kind: LinkKind, projection_link: bool) -> Result<Value> {
@@ -418,9 +486,10 @@ fn run_links(s: &mut Session, p: &Value, cmd: &str, kind: LinkKind, projection_l
let what = src.describe();
match add_link_unless_present(s, doc, id, sk, kind, src, kind == LinkKind::Project) {
Ok(Some(l)) => {
let mut result = link_json(sk, &l);
let (curves, points) = link_parts(sk, &l);
let mut result = ProjectedLink { link: Some(l.clone()), curves, points };
if !projection_link {
result["link"] = Value::Null;
result.link = None;
sk.break_link(&l)?;
}
out.push(result);
@@ -435,11 +504,7 @@ fn run_links(s: &mut Session, p: &Value, cmd: &str, kind: LinkKind, projection_l
}
Ok((out, present))
})?;
let mut r = json!({"links": out});
if !present.is_empty() {
r["already_projected"] = json!(present);
}
Ok(r)
reply(cmd, ProjectResult { links: out, already_projected: present, created: None, param_warnings: Vec::new() })
}
fn project_cmd(s: &mut Session, p: &Value) -> Result<Value> {
@@ -543,16 +608,15 @@ pub(super) fn project_entities(s: &mut Session, p: &Value, cmd: &str) -> Result<
}
/// Add one link from `src` to the active sketch and report it.
fn one_link(s: &mut Session, cmd: &str, kind: LinkKind, src: LinkSource) -> Result<Value> {
fn one_link(s: &mut Session, cmd: &str, kind: LinkKind, src: LinkSource) -> Result<LinkMade> {
let id = sketch_store::active(s).ok_or_else(|| EngineError::Other("no sketch is being edited".into()))?;
sketch_store::update(s, id, |s, doc, sk| {
refresh(s, doc, id, sk);
let what = src.describe();
let l = add_link(s, doc, id, sk, kind, src).map_err(|e| bad(cmd, format!("{what}: {e}")))?;
let mut out = link_json(sk, &l);
let (curves, points) = link_parts(sk, &l);
let wires: Vec<String> = sk.link_wires(&l).iter().filter_map(|w| sk.wires.get(*w).map(|w| w.id.clone())).collect();
out["wires"] = json!(wires);
Ok(out)
Ok(LinkMade { link: l, curves, points, wires })
})
}
@@ -571,7 +635,7 @@ fn project_to_surface(s: &mut Session, p: &Value) -> Result<Value> {
};
out.push(one_link(s, cmd, LinkKind::Project, LinkSource::OnSurface { sketch, curve, body: body.clone(), at })?);
}
Ok(json!({"links": out}))
reply(cmd, ProjectToSurfaceResult { links: out, created: None, param_warnings: Vec::new() })
}
fn intersection_curve(s: &mut Session, p: &Value) -> Result<Value> {
@@ -584,7 +648,8 @@ fn intersection_curve(s: &mut Session, p: &Value) -> Result<Value> {
}
};
let (a, b) = (side("a")?, side("b")?);
one_link(s, cmd, LinkKind::Include, LinkSource::Intersection { a: Box::new(a), b: Box::new(b) })
let made = one_link(s, cmd, LinkKind::Include, LinkSource::Intersection { a: Box::new(a), b: Box::new(b) })?;
reply(cmd, SketchLinkResult { made, created: None, param_warnings: Vec::new() })
}
fn spun_profile(s: &mut Session, p: &Value) -> Result<Value> {
@@ -605,7 +670,8 @@ fn spun_profile(s: &mut Session, p: &Value) -> Result<Value> {
(wa, wb - wa)
}
};
one_link(s, cmd, LinkKind::Project, LinkSource::Spun { body, origin, dir })
let made = one_link(s, cmd, LinkKind::Project, LinkSource::Spun { body, origin, dir })?;
reply(cmd, SketchLinkResult { made, created: None, param_warnings: Vec::new() })
}
fn iso_curve(s: &mut Session, p: &Value) -> Result<Value> {
@@ -620,7 +686,8 @@ fn iso_curve(s: &mut Session, p: &Value) -> Result<Value> {
format!("{},{},{}", d.x, d.y, d.z)
}
};
one_link(s, cmd, LinkKind::Include, LinkSource::Iso { body, at, dir })
let made = one_link(s, cmd, LinkKind::Include, LinkSource::Iso { body, at, dir })?;
reply(cmd, SketchLinkResult { made, created: None, param_warnings: Vec::new() })
}
fn fit_section(s: &mut Session, p: &Value) -> Result<Value> {
@@ -636,7 +703,7 @@ fn fit_section(s: &mut Session, p: &Value) -> Result<Value> {
sk.break_link(&l)?;
Ok(curves)
})?;
Ok(json!({"curves": curves}))
reply(cmd, FitSectionResult { curves, created: None, param_warnings: Vec::new() })
}
fn break_link(s: &mut Session, p: &Value) -> Result<Value> {
@@ -670,12 +737,12 @@ fn break_link(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok(links)
})?;
Ok(json!({"broken": links}))
reply(cmd, SketchBreakLinkResult { broken: links, created: None, param_warnings: Vec::new() })
}
fn auto_project(s: &mut Session, p: &Value) -> Result<Value> {
s.auto_project = bool_(p, "value").unwrap_or(!s.auto_project);
Ok(json!({"auto_project": s.auto_project}))
reply("sketch.auto_project", AutoProjectResult { auto_project: s.auto_project, created: None, param_warnings: Vec::new() })
}
/// `"x:<a>+<b>:x,y"` from interactive snapping: two distinct curve ids and a hint point.
@@ -171,6 +171,59 @@ fn a_projected_edge_that_is_gone_fails_its_sketch_not_replaced() {
/// A boss 20 x 20 x 10 made as its own body on the plate and joined into it afterwards: no body
/// has the link's body name any more. The join left the boss's top edge from (30, 10, 30) to
/// (30, 30, 30) as it was, and the link finds it by its name on the body that has it now.
/// A sketch that fails for an edge gone by name still opens for editing (#12085): its own circle
/// and the projected line, kept where it was and marked lost, are there to repair. It still
/// fails, as does the extrude built from it; deleting the lost projection repairs both.
#[test]
fn a_sketch_that_lost_its_projected_edge_opens_with_its_curves_and_the_link_marked_lost() {
let mut s = Session::default();
plate(&mut s);
run(&mut s, "solid.fillet", json!({"edges": [[100, 25, 20]], "radius": "4 mm", "name": "Round"}));
run(&mut s, "timeline.suppress", json!({"feature": "Round", "suppressed": true}));
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": "20 mm", "name": "Top"}));
let projected_curves = run(&mut s, "sketch.project", json!({"refs": [{"edge": [100, 25, 20], "body": "Body1"}]}))["created"]["curves"].clone();
run(&mut s, "sketch.circle.center", json!({"center": [50, 25], "radius": 10}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "Top", "distance": "5 mm", "name": "Boss"}));
let top = s.doc.find_feature("Top").unwrap().id;
let boss_error = |s: &Session| s.model.result(s.doc.find_feature("Boss").unwrap().id).unwrap().error.clone();
let curves = |s: &mut Session| run(s, "sketch.inspect", json!({"sketch": "Top"}))["curves"].as_array().map_or(0, Vec::len);
let name = stored(&s).edge_name.unwrap();
run(&mut s, "timeline.suppress", json!({"feature": "Round", "suppressed": false}));
assert_eq!(reselect(&s), Some(name.clone()), "{}", trouble(&s));
assert!(s.model.state().sketch(top).is_none(), "a failed sketch not edited is not shown");
assert!(boss_error(&s).is_some());
run(&mut s, "sketch.edit", json!({"sketch": "Top"}));
assert_eq!(reselect(&s), Some(name.clone()), "still fails while edited");
assert!(boss_error(&s).is_some(), "the extrude does not build from the held sketch");
assert!(link(&s).lost);
assert_line(&s, 100.0, (0.0, 50.0), "kept where it was");
assert_eq!(curves(&mut s), 2, "the circle and the lost projection");
// Another edit keeps it shown, with what the edit added: the sketch and the extrude after it
// fail again, so the design ends in the same state as before the edit.
let has_point = |s: &Session| {
let st = s.model.state();
let sk = &st.sketch(top).unwrap().sketch;
(0..sk.points.len()).filter_map(|i| sk.point(i)).any(|p| (p.x - 10.0).abs() < 1e-6 && (p.y - 10.0).abs() < 1e-6)
};
assert!(!has_point(&s));
run(&mut s, "sketch.point", json!({"point": [10, 10]}));
assert!(link(&s).lost);
assert!(has_point(&s), "the held sketch shows the new point");
assert_eq!(curves(&mut s), 2);
run(&mut s, "sketch.finish", json!({}));
assert!(s.model.state().sketch(top).is_none(), "finished, it is not shown");
// Repaired from inside: the lost projection deleted.
run(&mut s, "sketch.edit", json!({"sketch": "Top"}));
run(&mut s, "sketch.delete", json!({"entities": projected_curves}));
run(&mut s, "sketch.finish", json!({}));
assert_eq!(trouble(&s), "");
assert_eq!(boss_error(&s), None);
assert!(s.doc.sketch(top).unwrap().links.is_empty());
}
#[test]
fn a_projected_edge_whose_body_was_joined_into_another_is_found_there() {
let mut s = Session::default();
+57
View File
@@ -340,6 +340,63 @@ fn tangent_moves_only_the_second_pick() {
check_order(line_and_circle(), "sketch.constraint.tangent", "c1", "l1");
}
/// #12092: Tangent with a line's end point and a circle puts that end on the circle and makes the
/// line tangent there, in one step. In either pick order only the picked end moves: the line turns
/// about its other end and gets longer or shorter, and the circle stays.
#[test]
fn tangent_at_a_lines_end_puts_the_end_on_the_circle() {
// Where the line ends up: its end's distance from the centre, and the line's.
let touch = |s: &Session| {
let sk = sketch(s);
let (a, b) = (pt(s, "l1.start"), pt(s, "l1.end"));
let c = sk.point(sk.resolve_point("c1.center").unwrap()).unwrap();
let line = (b - a).cross(c - a).abs() / a.dist(b);
(b.dist(c), line, sk.radius(sk.curve_index("c1").unwrap()).unwrap())
};
for (a, b) in [("c1", "l1.end"), ("l1.end", "c1")] {
let mut s = line_and_circle();
let circle = snap(&s, "c1");
let undo = s.undo.len();
let r = run(&mut s, "sketch.constraint.tangent", json!({"a": a, "b": b}));
assert_eq!(r["constraints"].as_array().map(Vec::len), Some(2), "{a}, {b}: {r}");
let (end, line, radius) = touch(&s);
assert!((radius - 5.0).abs() < 1e-9, "{a}, {b}: the circle keeps its size: {radius}");
assert!((end - 5.0).abs() < 1e-6 && (line - 5.0).abs() < 1e-6, "{a}, {b}: end {end}, line {line} from the centre");
assert!(same(&snap(&s, "c1"), &circle), "{a}, {b}: the circle moved");
let start = pt(&s, "l1.start");
assert!(start.dist(solidcore_record::Vec2::new(5.0, 3.0)) < 1e-9, "{a}, {b}: the line's other end moved to {start:?}");
assert_eq!(s.undo.len(), undo + 1, "{a}, {b}: one undo step");
}
// A point that ends no curve names no curve to be tangent: the refusal says what to pick.
let mut s = line_and_circle();
let e = s.execute("sketch.constraint.tangent", &json!({"a": "l1", "b": "c1.center"})).unwrap_err().to_string();
assert!(e.contains("end"), "{e}");
}
/// A line whose end is already joined to an arc's end (the coincident a click there adds): Tangent
/// on that end and the arc, or on the two ends in either order, adds only the tangent, since the
/// end already lies on the arc. The joint holds. With the end and the arc the line stays and the
/// arc moves; with two ends the first pick stays.
#[test]
fn tangent_at_a_joint_adds_only_the_tangent() {
for (a, b, stays) in [("l1.end", "a1", "l1"), ("a1", "l1.end", "l1"), ("l1.end", "a1.start", "l1"), ("a1.start", "l1.end", "a1")] {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[2, 3], [12, 3]]}));
run(&mut s, "sketch.arc.center_point", json!({"center": [16, 9], "start": [12, 3], "sweep": 90}));
let joints = sketch(&s).constraints.len();
let kept = snap(&s, stays);
let r = run(&mut s, "sketch.constraint.tangent", json!({"a": a, "b": b}));
assert!(same(&snap(&s, stays), &kept), "{a}, {b}: {stays} moved");
assert_eq!(r["constraints"].as_array().map(Vec::len), Some(1), "{a}, {b}: {r}");
assert_eq!(sketch(&s).constraints.len(), joints + 1, "{a}, {b}: only the tangent is new");
let sk = sketch(&s);
let arc = sk.curve_index("a1").unwrap();
let (p, q, c) = (pt(&s, "l1.start"), pt(&s, "l1.end"), sk.center(arc).unwrap());
assert!(q.dist(pt(&s, "a1.start")) < 1e-6, "{a}, {b}: the joint came apart");
assert!(((q - p).cross(c - p).abs() / p.dist(q) - sk.radius(arc).unwrap()).abs() < 1e-6, "{a}, {b}: not tangent");
}
}
#[test]
fn a_fixed_second_pick_makes_the_first_move() {
let mut s = line_and_circle();

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