Compare commits

..

86 Commits

Author SHA1 Message Date
dudedagger42 27423c2b78 Extrude To Object: refuse an offset past a curved face instead of cutting nothing (#12617) (#13463)
A Join or Cut To Object on a curved or slanted face is cut back to that face, which removes the
part a To offset adds past it, so the offset changed nothing and the Cut took nothing off. It now
fails with a reason; an offset of 0 or short of the face is not refused.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 10:24:36 +00:00
dudedagger42 f5d9538979 Pattern on Path: curves listed out of chain order make the same path (#12199) (#12965)
* Pattern on Path: test that curves listed out of chain order make the same path (#12199)

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

* Pattern on Path: order curves listed out of chain order into one chain (#12199)

path_segments_at walked the curves in the listed order and refused one that
did not start where the last ended. When the list is not end to end, chain the
curves by their ends first; a real gap still gives the same error.

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

* Pattern on Path: rustfmt; test that a path with a gap is still refused (#12199)

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 10:07:52 +00:00
dudedagger42 32f791000d Hole: a sketch on an offset plane inside the part starts at the plane (#11538) (#12919)
* Hole: a sketch on an offset plane inside the part starts the hole at the plane (#11538)

hole_on_surface moved every sketch point inside a body back to the body's
surface, so a hole from a sketch on an offset or construction plane started at
the top face instead of the plane. Sketch points keep their place when the
sketch plane is derived (offset, construction, through point ...); picked faces
and explicit planes still follow the surface as before.

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

* rustfmt

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

* Hole: standard planes keep drilling from the surface; test for it (#11538)

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

* Hole: patterned and mirrored copies start where the original does (#11538)

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

* Hole: designs saved before keep their sketch-point holes at the surface; mesh-decisions entry (#11538)

- format/pending/hole-sketch-plane-start.rs marks every saved hole at sketch points (not a Single
  hole) `old_surface_start`; with it a point inside a body still moves back to the surface, as
  before, whatever plane the sketch lies on. New holes leave it off; Edit Hole keeps it
  (`solid.hole` takes `old_surface_start`, edit.rs carries it over).
- xtask/mesh-decisions.txt: the entry names `hole_on_surface_in`, where the mesh test now lives.
- Test: a_saved_offset_plane_hole_keeps_starting_at_the_surface (reopened as format 50, then
  edited) and the step's own test.

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

* rustfmt

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

* Hole: a hole that starts inside the part leaves the material above it

A hole drawn on a plane through the part (#11538) kept the tool's reach above
the start, which is there so a tool's top never lies on a face. Inside the part
that reach cut material above the plane. When the point just above the start is
inside a body (exact classify_point), the reach is now 1e-4 of the part's size.

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

* Hole: test that a sketch plane just under the top face keeps the roof (#11538)

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

* Hole: an inside start is judged at the spot itself (#11538)

The shorter tool top for a hole starting inside the part is chosen only for
spots that stay inside (a sketch on a derived plane), judged at the spot, so a
plane just under a face keeps the thin roof above it.

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 10:07:49 +00:00
dudedagger42 525171eb86 Extrude: a Bézier piece whose control points fold back goes in as its halves (#13243, #12107) (#13324)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 09:51:51 +00:00
Frank bb81f13a17 Fillet: the inside edge of a boss builds when the fillet is wider than the flat face beside it (#14894) (#14895)
* claim: #14894

* Fillet: the inside edge of a boss builds when the fillet is wider than the flat face beside it (#14894)

A fillet that adds material on one straight edge was refused once its
contact line ran off the flat face it rolls on (solidcore#3252). When the
kernel refuses such a fillet, fillet_removing now builds it by carrying
the flat face on as a slab behind its plane, rounding the edge there, and
joining the material that round adds within the face's outline.

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

* Fillet: copy the flat face by its index and report a fillet's history for the carried-on round

The face's sheet was picked by a point beside the edge, which could
take another face. It is now copied by its index. The result carried
the union's history over two bodies; it now carries a history over the
one body, as a fillet reports it: the body's faces and edges stay
theirs, and what the added material brought is made in place of the
rounded edge.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-11 09:51:30 +00:00
Werner1303 211a904df6 Sketch profiles: treat pieces shorter than the end join tolerance as a point (#14520)
* claim: #14512

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

* Sketch profiles: a test that near-zero arcs from a split at a touching line keep the profile (#14512)

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

* Sketch profiles: treat pieces shorter than the end join tolerance as a point (#14512)

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

* Sketch profiles: test that a lone short piece stays a gap (#14512)

The square from sketch_import's dxf_and_svg_round_trip_into_sketches, imported
at tolerance 1e-6: fails on d6a715a71, which treats every piece up to
ENDPOINT_JOIN_TOL as a point.

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

* Sketch profiles: shrink a short piece only beside another between the same nodes (#14512)

A piece no longer than ENDPOINT_JOIN_TOL becomes a point only when a second
piece joins the same two nodes, the hairline pair of #14512. A lone short
piece is a gap the sketch keeps open, so an import at a tighter tolerance
leaves its gaps open again.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brennen Herbruck <brennen.herbruck@gmail.com>
2026-11-11 09:34:19 +00:00
HiroKenEz dc911ea384 Edit Feature: every construction plane opens its dialog again (#12022) (#12024)
* claim: #12022

* Edit Feature: every construction plane opens its dialog again (#12022)

Edit Feature on a Plane Through Two Edges, Plane Through Three Points or
Perpendicular Plane opened the command box with an empty timeline.edit
instead of the plane's form. These planes now reopen their form like
Plane Through Axis and Point, with each saved reference shown as its pick
and kept exactly until a new pick replaces it; a reference that cannot be
shown leaves its row empty to pick again instead of falling back to the
command box. A saved point outside a sketch is labelled with its 3D
position instead of a sketch's (0.00, 0.00).

The four planes made of picks share one parser (cmd::picked_plane) and its
inverse (picked_plane_refs), and an edit of any of them in a moved
component maps its saved references through the world and back once.
Plane Through Axis and Point run directly in a moved component now goes
into the component's frame as a whole like the others (its point was left
unmapped).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 09:17:45 +00:00
Mateusz Sych 12189e018d solidcore: SolidCraft #14416 case (a small round on the outer rim of a 300 degree revolve) (#14888)
* claim: #14416

* solidcore: SolidCraft #14416 case (a small round on the outer rim of a 300 degree revolve)

A 0.4 mm round on the outer top arc of a revolve at 270 to 330 degrees, the 1 and 2 mm rounds and a 0.4 chamfer at 300, and the inner arc's round. SolidCraft 0.40 refuses the round at 280 to 320 degrees and 0.30.1 builds it; solidcore refuses the same cases with status 1001.

* solidcore: #14416 case notes quote SolidCraft's whole refusal

* solidcore: #14416 case summary: the probe's passing cases are within 8e-8

---------

Co-authored-by: MateuszSychXpl <151755914+MateuszSychXpl@users.noreply.github.com>
2026-11-11 09:09:33 +00:00
CamWight d1f1631f88 Sheet metal: a test checks that holes in a bent part's end flanges show in its flat pattern and DXF, as cut-outs or edge notches (#11728) (#14616)
* claim: #11728

* Sheet metal: a test pins holes in a contour flange's end panels as cut-outs or edge notches of the flat pattern (#11728)

The report's M3 clearance holes (diameter 3.4) at x +-27.5 reach 0.2 mm past the
feet's free edges at x +-29, so the flat pattern keeps them as notches in its
outline and the DXF draws them as arcs on OUTLINE. Nothing drops them.

The new engine test builds the issue's hat from one contour flange and checks:
- holes moved inside the feet (x +-26.8): 7 cut-outs and 7 DXF circles, the
  feet holes 2.2 mm in from each end;
- the issue's holes at x +-27.5: 5 cut-outs and 5 circles, the outline running
  round each notch, the notch arcs ending 0.8 mm either side of the end edges'
  middle, and the volume each notch removes worked out by hand;
- unfold and refold keep that volume;
- a cut that reaches a foot's bend is refused.

Test only: no product code changes.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 09:01:48 +00:00
Jackson Oursland 20d8b8bd29 File operations: a row of the operations panel; the design stays usable (#9461)
Opening, inserting, exporting a DXF and Update Links no longer put a
card over the design that makes everything wait. Each is a row of the
operations panel with its progress and Cancel; several queue there, and
the person can go on working while they run. A result lands after the
person's edits:

- A file opened or recovered lands as before when the person is not in
  the middle of something. Otherwise (a dialog, a tool or another
  sketch open, their own command computing or being saved) it goes in
  a new tab behind the one shown, and the status says so.
- An insert, an export and Update Links belong to the tab shown when
  they were asked for. One that finishes while another tab is shown,
  or while the person is busy, waits ("Ready") until they are done.
  One whose design changed meanwhile runs again for the design as it
  is now, with its first result kept so the file it read stays cached,
  at most three times.

Tabs carry a number for this, so a request finds its design across tab
switches and edits. Agents' design changes still wait for a running
file operation, so an agent's open and the edits after it go in order.

Preferences > Interface > "Wait for files to open" (file_operations_wait,
off) brings back the card over the design; the tests of the card now
turn it on.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 09:01:31 +00:00
Jackson Oursland acb532e16e Render preview: draw placed copies of a part as instances (#7487)
The product preview built every placement of a part as its own triangles, so
an assembly whose parts are placed many times exceeded the preview's vertex
bound (a synthetic 812-body assembly of 7.1M triangles was refused). The
engine now names, for each product mesh, the shared mesh it is moved from
(product_scene::placements, beside the unchanged ProductScene contract), and
the preview draws bodies placed from a mesh two or more share as instances of
it: its triangles once, a placement and a palette slot each, opaque,
see-through or PBR (a new InstanceLook::Pbr, vs_pbr_inst, for smooth meshes).

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-11 09:01:28 +00:00
CamWight a8567bf6af Configurations: each row has a fixed id, so what points at a row survives its rename (#14750) (#14751)
* claim: #14750

* Configurations: each row has a fixed id that a rename keeps (#14750)

A configuration row now saves an id from the design's one id counter, as
joints and exploded views do. A rename keeps it, and a deleted row's id is
never given out again, so anything that points at a row still finds it.

- config.activate, config.preview and config.row take a row by its name
  (text) or its id (a number); a row named "17" is not row 17.
- config.table lists each row's id.
- The table still saves the active row by name: the last release keeps a
  newer design's whole table only while `active` stays text.
- Older designs give their rows ids in order as they open (one pending
  format file). A design whose rows repeat an id is refused as damaged.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 08:52:27 +00:00
Brett Lawson 0b9ff4c122 Bounds: a thread's screw wall boxes in a few dozen cells, not one per knot span (#14887)
* Bounds: claim the thread flank's tight box cells (part of #14460)

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

* Bounds: divide a trimmed face's undecided cells only the ways a knot lies inside

The trimmed-face tight box search (face_directed_max) divided every
undecided cell both ways: at the knot inside it nearest its middle, else
at its middle. A rational patch is read monotone only within one knot
span each way, so a cell over several spans along u stays undecided; on
a thread's screw wall (a knot every eighth of a turn along u, one span
across v) halving v each level as well only doubled the cells along the
top and bottom trims: about 910 cells per direction, each solved twice,
1.2 s per wall and 1.6 s per tight box of the threaded body.

A cell is now divided only the ways a knot lies inside it while either
way has one, and halved both ways once it lies within one span each way.
Any division is sound; this one reaches single-span cells in log2(spans)
levels with a few cells per level. The flat-shoulder body's tight box:
1.62 s -> 0.08 s, the same box.

Regression test: a rational ramp one span across, trimmed short of its
+z extreme, at 16 and 128 spans. Control points read: 2,848 / 37,152
before, 728 / 4,872 now; the test bounds them by 30 whole nets and by
linear growth in the spans.

Part of #14460.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 08:51:26 +00:00
Mateusz Sych 4e4f5d4182 Assemblies: dragging a jointed part stops where a contact set says two parts touch, as driving a joint does (#14682) (#14885)
* claim: #14682

* Assemblies: a test that a drag stops where a contact set says two parts touch (#14682)

* Assemblies: dragging a jointed part stops where a contact set says two parts touch, as driving a joint does (#14682)

joint.reach (the mouse drag of a jointed part, undo step "Reach Point") wrote the values it found
straight into the joints, so contact sets never saw the motion and a dragged part passed through
the parts of its set. joint.drive, joint.sweep and motion studies move joints through
motion::drive_with_contact, which stops where a pair of a set would pass into each other.

With contact sets on, joint.reach now moves the joints toward the values it found through
drive_with_contact too, and its reply says where it stopped (`stopped_by_contact`, as joint.drive
says it). No value is held on the way, so the loops close from each step: a closed loop that the
found values close at both ends doesn't stop the drag in between. With contact sets off, nothing
changes.

* Assemblies: a drag stopped by a contact reports the contact, not a limit or loop further on (#14682)

The reach's own limits and loop are where the drag would have ended without the contact; with
the contact first, the part rests at the touch. Found in review.

* Assemblies: a reach that moves nothing adds no undo step, with contact sets off as before (#14682)

The contact branch had left the no-change guard out of the other path. Found in review.

---------

Co-authored-by: MateuszSychXpl <151755914+MateuszSychXpl@users.noreply.github.com>
2026-11-11 08:35:31 +00:00
leValdemar19 30d77e83f9 Preferences: the Layout page's "Origin and bodies on top" checkbox shows its label again (#14564) (#14601)
* claim: #14564

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

* Preferences: the Layout page's Origin and bodies on top checkbox keeps its label (#14564)

The checkbox is greyed out unless History is on the left. It was wrapped in a
disabled area that started at the end of its wrapped row, so its width was
clamped to the row's leftover space and the label vanished behind the (i) icon.
The box is now placed (and wraps) as a whole and is disabled inside its own space.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 08:35:16 +00:00
Brett Lawson f937ac0f31 solidcore: the remaining query, nurbs, primitive, curve and geom failures carry a typed reason (#14883)
* solidcore: claim the remaining query, primitive and geom typed-failure leftovers

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

* solidcore: the remaining query, primitive and geom failures carry a typed reason

The primitive solids, the 2D boolean and the analytic rotational sweep
(primitive_creation/p1/primitives.rs), the B-rep point classification
(query/brep.rs), the face point classification's seam failure
(query/face/p2.rs, still at its face) and the singular linear system
(geom/matrix.rs) now fail with PrimitiveFailure, QueryFailure and
GeomFailure. Each keeps the status of the bare Upstream it replaces. The
opt-in boolean2d_plane_covers_far_faces refusal is the new
PrimitiveFailure::PlaneCannotCover (SM_ERR_INVALID_INPUT).

The query, primitive, geom and nurbs ratchets walk their whole area with
exact counts, through one helper whose test-module cut handles
attribute-led and pub(crate) test modules.

Part of #13790.

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

* solidcore: the face point classification no longer imports SM_ERR outside its tests

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 08:05:55 +00:00
Mateusz Sych 3fc688677c solidcore: SolidCraft #12117 case (a round end joined flush onto a thin strap's leg) (#14272)
* claim: #12117

* solidcore: SolidCraft #12117 case (a round end joined flush onto a thin strap's leg)

A cylinder joined to the end of a thin strap's leg, its flat faces in the leg's faces and its diameter equal to the strap's width, with the report's four variants. SolidCraft 0.30.1 failed the join; solidcore builds every case to its closed-form volume.

* solidcore: #12117 case summary: the tolerance's bound is a move along y, not a retry

---------

Co-authored-by: MateuszSychXpl <151755914+MateuszSychXpl@users.noreply.github.com>
2026-11-11 07:53:33 +00:00
Mateusz Sych cb1b4ed203 solidcore: SolidCraft #12435 case (a chamfer round a rim with a rounded notch) (#14241)
* claim: #12435

* solidcore: SolidCraft #12435 case (a chamfer round a rim with a rounded notch)

A cylinder with a notch rounded into its rim and a chamfer round the rim's tangent chain, at 1, 0.5, 0.2 and 0.05 mm, with two controls. SolidCraft 0.40 refuses that chamfer; on solidcore, with SolidCraft's two chamfer-cone fixes, it builds 0.0074 to 0.0079 mm^3 short of the closed-form volume.

* solidcore: #12435 case summary: the probe's B-spline results are within 0.001

---------

Co-authored-by: MateuszSychXpl <151755914+MateuszSychXpl@users.noreply.github.com>
2026-11-11 07:53:31 +00:00
nedawson 1bacd8323d Viewport: faces draw in perspective view when zoomed into a design far from the origin (#14880)
In perspective view, with the eye inside the bounding box of the whole
design and the viewed part tens of metres from the origin, every face
went missing and only edges were drawn.

With the eye inside the scene's box the near plane is 0.001 mm. The
view-projection was converted to f32 and the depth turned round for the
reversed depth buffer (w - z) afterwards, where the near-plane term is
below f32 resolution, so every point landed on the far plane. Faces,
which are pushed slightly away from the eye, then failed the depth test.

Mat4::turned_depth_f32 works out w - z in f64 and Frame::turned_depth
carries it to the uniform. The viewport fills it in; other callers pass
None and keep the old behaviour.

Co-authored-by: nedawson <177795407+nedawson@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-11-11 07:30:13 +00:00
clocker 38cecb1c6a Sketch Intersect: a face with a hole cut through the hole keeps both pieces (#14282) (#14325)
A link's layout read pieces that all fall on one line as a face seen
edge-on and joined them into one line end to end. That is right for a
projection, but a section of a face with a hole, cut through the hole,
is two pieces on one line with the gap between them: Intersect on a
pipe's top ring gave one line across the bore. The edge-on rule now
skips Intersect links, when they are made and when they are refreshed.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 07:20:44 +00:00
Brett Lawson 3134799ac3 solidcore: intersect and offset failures carry a typed reason instead of bare statuses (part 2) (#14853)
The surface intersector (p2-p4), the topology solver (p1, p2), the
crossing-loop resolution, the cavity test and the draft build their failures
as IntersectFailure / OffsetFailure reasons with a phrase, keeping the status
the Upstream they replace gave. OffsetFailure gains Failed, Missing,
InvalidValue and Inconsistent, which replace an upstream status
(replaces_upstream_status): upstream_status() reads them, the crossing-loop
face naming leaves them as they are, and SolidCraft's blend status passes them
on as their status. Directory ratchets with exact per-file counts replace the
intersect file list and cover offset; the goldens runners that matched
Upstream read upstream_status.

Part of #13790.


Claude-Session: https://claude.ai/code/session_01SdcgEtwSrdwZxqTQp5MfnU

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 07:06:01 +00:00
Brett Lawson a23b231a60 solidcore: surface failures carry a typed reason instead of bare statuses (part 2) (#14863)
* solidcore: claim surface typed-errors part 2

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

* solidcore: surface failures carry a typed reason instead of bare statuses (part 2)

Converts the 97 bare sm_err/null_pointer/Upstream sites left in the
unclaimed surface files (extend and its analytic, ball, blend and sides
parts, side, implicit, the volumes, srf_in_volume, the B-spline volumes,
procedural_blend and its offset, trim_srf_cache) to SurfaceFailure
reasons. Each keeps the status, kind, operation and entity of the
Upstream it replaces: the blend re-certification refusal keeps its face
through the new surface_at. The ratchet lists only the files other open
PRs touch, and a sample test pins the converted files' answers.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 06:56:02 +00:00
Henry 3c7329899a Booleans: a body minus its own copy is empty by default (the four fixes of #14711 without flags) (#14799)
* Kernel: a body minus its own copy is empty by default (retire #14711's four flags)

The four fixes of #14711 become the kernel's only behaviour, as the
rules ask (no compatibility flags): boolean_capture_keeps_operand_
vertices_apart, radial_sector_step_off_coincidence_by_own_face,
split_edge_definition_owns_its_curves and simple_coincidence_side_
across_the_curve are removed with their old paths, SolidCraft's
assignments and the rig's fixes.json entries. The regression tests
assert the fixed result only. SolidCraft and the rig already ran with
the four on, so their results are unchanged.

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

* Goldens: the moved cases of the own-copy fixes by default; the step-tolerance test on the default kernel

Making #14711's four fixes the default moves 20 golden cases (the
goldens run on Kernel::default()). 18 are blessed with --filter after
checking each: the five query.edge radial-sector classifications at an
edge's end were wrong upstream (an edge use against itself is
CoincidentSector, against its mate CoincidentSectorMate); six
sub-millimetre boolean cases now build with union, intersection and
difference volumes consistent to 1e-12, where upstream's capture
distance spanned the whole part; two taper extrudes of a zero-area
profile swap which of them reports no body; three offsets lose a 0.0138
sliver edge and its vertex. ub175_after_editing_imprint_failure and
gen0150 now return the sphere alone for a sphere-box union and are
listed as known failing.

a_part_with_step_tolerances_minus_itself_is_empty also runs on the
default kernel.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 06:47:10 +00:00
bherbruck 1971d71904 ops: the kernel's offset refusals as Error::OffsetRefused { face, why } (#14783) (#14876)
Every OffsetFailure but Folds, WallsTouch and EdgeVanishes fell through to a
bare Status(1001). They now come back as Error::OffsetRefused, with the
reason (OffsetRefusal: NotCertified, Collapses, LoopsCross, DistanceUnresolved,
ToleranceUnresolved, SeamOpened, ExactCurveUncut, FacesApart,
ReachUncertified, LoopGapUncertified, Unsound) and the face it names as a
record index. A reason the kernel adds later is Other { message } with the
kernel's own words, never a bare status.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 06:47:07 +00:00
bherbruck 180b51468c blend certificate: a support's net continues past its end where its kind is a line (#14284) (#14838)
A round that ends flush with its support's end has a foot on the support net's domain edge there, and every feet's Krawczyk box around it ran past the net's knots. knot_cuts refused it ("a box past the face's knots"), gn_try! made that "nothing proved", and definition_partials_over_box returned SM_ERR 1001, so the round's end pieces proved nothing (margin pi).

EndContinuation::of decides, from the support's kind alone, which NURB directions may continue: a plane both, a cylinder along its axis, an extrusion along its direction, nothing else (a cone included). nurbs_partials_over_box_ending encloses the part of a box past such an end on the end span's polynomial, where the net is of degree 1 in that direction: the face's edge jet continued, P(u, v_e) + (v - v_e) P_v(u, v_e). The same polynomial lies on both sides of the end, so the feet's system is smooth to every order across it, which FEET_BOX_CONTINUITY = 2 needs (the face C^2 across a line a box crosses, the system C^1 for Krawczyk's mean-value form). A box within the domain is enclosed exactly as before.

Anywhere else past an end is refused as Reason::FootPastDomainEnd, naming the support, the leader's piece, the box and the net's domain. That is the one refusal gn_try! passes on; every other failed evaluation still proves nothing. definition_box carries each support's continuation (GnDefFace.ends) and passes the refusal up.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 06:47:05 +00:00
Brett Lawson 27de5ba0cc solidcore: exact mass properties grow linearly, not quadratically, with the knot spans a face crosses (#14808)
* solidcore: integrate a face's mass properties in bands between its v knots

Every trim sample's iso-u integral ran from the trims' lowest v up to the
sample, span by span, so a face long in v (a thread flank over all turns,
#11737) cost the square of its knot spans. Each trim piece now integrates
from its own band's floor, and each band's full height under the trims is
integrated once along u between where the trims cross its top line, split
only where the crossing count changes. Same Green's-theorem integral,
regrouped by band; linear in the spans. Trim breaks include the crossings
of the band lines. A work counter (mass_iso_evaluations) backs a regression
test on a 8- and 64-span strip against Gauss-Legendre references.

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

* solidcore: band only faces whose trims span at least 12 v knots; re-bless checked goldens

Short faces keep the single base: their iso-u integrals are short and a
band's own integral along u costs more than it saves, and their mass
properties stay bit-identical. The goldens rows that move (fillet_executive_p3,
corpus.parity, topology.face_p2) were checked against tight-accuracy and
Gauss-Legendre references; the per-row figures are in the PR body.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 06:36:49 +00:00
N3MI-DG eda725dc23 STEP import and export: colours keep the names the file gives them (#14855) (#14857)
* claim: #14855

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

* STEP import and export: colours keep the names the file gives them

A STEP file names its colours in COLOUR_RGB (or with a pre-defined colour's
word). The reader dropped the names and export wrote empty ones, so every
imported colour was called "Custom".

The reader now keeps the name with the body colour (Paint.name) and each
face colour (FacePaint.name), cleaned as a design keeps look names: escapes
decoded, control characters dropped, trimmed, at most 128 characters. An
imported look takes the file's name, else a stable one from its values:
Custom(R,G,B) when opaque, Custom(R,G,B,opacity) when not. ImportedLook
carries the names through later features. The native body envelope keeps no
names, so the model cache records do, and the cache format is now /10.
Export writes each look's name into COLOUR_RGB; a plain picked "Custom"
stays unnamed.

Fixes #14855

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

* STEP import: an opaque body colour keeps its name

Body::with_paint dropped a paint that was opaque with no face colours, so an
opaque body colour lost the name the file gave it. A paint with a name is now
kept, and with_paint cleans names as the reader does. The colour-name test
reads a name without assuming every import has paint.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 06:21:09 +00:00
bherbruck 977f7a6df2 CI: a label run's ci check copies the newest full run by when it finished, not a newer label run (#14868)
The label-run ci job picked the commit's latest run by creation time, label runs included. A label
run that copied a full run's first-attempt failure stayed the newest run after the full run passed
on a rerun, and every later label run copied it again (#14836). Now it takes the newest run that
ran checks (a job other than ci not skipped), ordered by when its latest attempt finished, so a
passing rerun counts and label runs never copy each other.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 05:57:40 +00:00
bherbruck d7920f71cd curve: length_bound's blossom rounding from each level's own knots (#14434) (#14820)
A blossom level's coefficient a = (u - l) / (h - l) is formed from exact
doubles with l <= u <= h, so it rounds relative to itself: a level is within
gamma_9 of its inputs' largest, p levels gamma_(9p+3) (weights gamma_(9p)),
with no term in the knots' size. A piece outside its knot span, which the
bound needs, is refused, named (CurveFailure::PieceOutsideSpan). The cone
frustum's arc floor falls from 9.67e-8 to 1.33e-8, as the floor law predicts.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 05:47:26 +00:00
makreft ec363a78f3 Insert SVG: size by the sheet, fixed points, the sheet in drawing_bounds (#14759)
`sketch.insert_svg` sized a drawing by its geometry only: an SVG laid out
on a sheet with a margin came in larger than the sheet it was designed
for, and nothing said what the sheet was. `size_from: "viewbox"` sizes by
the root's viewBox (through the root transform) or its width and height
in mm; the default, `geometry`, is unchanged; a DXF and an SVG without a
sheet refuse it. `sketch.drawing_bounds` adds `sheet` for an SVG.

`fixed: true` puts a Fix constraint on every point the drawing adds, as
`sketch.constraint.fix` does, so pinning a traced picture no longer
takes a second call with every point id; the reply says how many. A
circle's radius stays free; a drawing whose points would not fit the
sketch's constraint limit is refused as a whole.

The contract knows both keys. Tests: the sheet from viewBox with and
without width/height and from width/height alone; a 20 x 10 rectangle on
a 100 x 50 viewBox sized by the sheet; four fixed lines with no degrees
of freedom and a fixed circle with one; strict refuses a wrong word with
the handler's message, and a reply with `fixed` matches the schema.

Co-authored-by: makreft <makreft@users.noreply.github.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-11-11 05:39:08 +00:00
makreft 26a643a7b1 MCP: a compact reply counts long created lists instead of listing them (#14760)
`execute` and `batch` with `verbose: false` already leave out what the
design had before, and more new points than the new curves have ends as
`point_count`. The `created` lists themselves still came in full:
inserting a traced drawing returned hundreds of point, curve and
constraint ids an agent reads once and never uses.

A `created` list of more than 20 ids is now left out and counted instead,
like the poles: `curve_count`, `constraint_count`, `dimension_count`
and `point_count`. The ids are no sequence an agent could
derive (each kind of curve counts on its own, taken ids are skipped), so
nothing pretends they are; `sketch.inspect` lists every id. The tool
descriptions and docs/mcp.md say so. Test: 30 lines count, 20 are still
listed, and a batch step counts too.

Co-authored-by: makreft <makreft@users.noreply.github.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-11-11 05:39:04 +00:00
infield 14a78595b2 Assemblies: dragging a part on a ball joint turns it smoothly, near the pivot and at a cone limit (#13262) (#14441)
* claim: #13262

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

* Assemblies: dragging a part on a ball joint turns it like a trackball (#13262)

A drag on a part only asked for the grabbed point to reach the pointer. That
left the part free to spin about the line through the joint and the point, and
the solver settled the spin by the smallest change in yaw, pitch and roll, not
the smallest turn, so the part twisted and swung at a cone limit. A grab near
the joint also turned it far for a small move.

A part held by a ball joint to a part fixed in place now asks each frame for
the smallest turn about the joint that moves the grabbed point with the
pointer, its axis turned with it, measured on a sphere at least the part's
size. Other drags are unchanged. Preference joint_drag_trackball (on) keeps
the old drag when off.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-11-11 05:39:03 +00:00
bherbruck fcc79ae793 solidcore-ops: each call's kernel profile, behind the profile feature (#14865)
session::profiled(f) runs f and returns the kernel profile of each ops call
the thread makes inside it (CallProfile { call, profile }), each taken under
the kernel's lock with a root scope named after the call, so calls on other
threads never mix in. A new `profile` feature forwards to solidcore/profile;
without it ops has no profiling code, item or doc text.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 05:38:47 +00:00
Tianyi fc81ca5a5b Translate the remaining sketch hints and errors in Simplified Chinese (#14633)
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-11-11 05:35:48 +00:00
bherbruck d8b95e7bf7 STEP import: a solid with voids and a face that cannot be made comes in as surfaces (#14836)
A solid read again without the faces that cannot be made is a sheet, but a void shell still closed kept only the sheet's region, and the record refused it ("a closed shell bounds two regions and encloses one"). Stitching thousands of faces then failed too, and the whole solid was dropped. A closed shell of such a read now bounds a region of its own: the void's cavity, or the inside of a shell still whole.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 05:34:50 +00:00
bherbruck 5608c7b930 solidcore-tess: a sliver the lenient fill leaves empty is fanned from its boundary nodes (#14859)
A face far below the display tolerance whose boundary nodes are a few points in space, each twice (its pcurves apart in (u, v) where its edges meet), crosses itself in (u, v), so it is filled leniently; every Delaunay triangle then had two corners on one point, was dropped, and the face had no triangles and was reported as not shown. The outer loop is now fanned from its first node, the triangles on three distinct points kept, counter-clockwise; the face stays named as filled leniently.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 05:10:10 +00:00
Brett Lawson 48f1f9e7e0 solidcore: topology failures carry a typed reason instead of bare statuses (part 2) (#14824)
* solidcore: claim typed topology failures, part 2

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

* solidcore: topology failures carry a typed reason in the B-rep edit files (part of #13790)

The bare sm_err / null_pointer / Upstream sites in topology/brep/p1, p5 to
p10, face/p2 and validation/pointers return a TopologyFailure, each with a
phrase saying what failed, keeping the operation path, status and entity
(none) of the Upstream it replaces. p9's face-orientation repair keeps its
bare final status 1 (upstream TRUE), which no failure stands for; the ratchet
holds exactly that one back. The local sm_err/status helpers are gone; p6's
region removal still maps an invalid input or port invariant to a plain
failure with no cause.

The goldens runners of these units and the sweep topology unit read
upstream_status, and tests that matched the Upstream variant check
upstream_status. The pin test covers a sample from the new files.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 04:33:33 +00:00
Brett Lawson 5e54501f34 solidcore: surface failures carry a typed reason instead of bare statuses (part 1) (#14798)
* solidcore: surface failures carry a typed reason instead of bare statuses (part 1)

Adds Reason::Surface(SurfaceFailure) (Failed, Missing, InvalidValue,
OutsideDomain, OffSurface) and converts 241 bare sm_err/null_pointer/Upstream
sites in the uncontended surface files. Each keeps the status, kind,
operation and entity of the Upstream it replaces, and upstream_status()
reports it, so behaviour is unchanged. The step-inversion matchers and the
surface goldens runners read upstream_status(); SolidCraft's blend status()
passes the failures on as their status. A ratchet test lists the files still
holding bare sites (part 2).

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

* solidcore: read surface failures' statuses where tests and runners matched Upstream

The surface failures are built lazily (ok_or_else), and the three goldens
runners and two tests that matched a step inversion's or a polar
converter's Upstream status read upstream_status() instead, so their rows
and assertions are unchanged.

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

* solidcore: surface ratchet stops at attribute-led test modules; rebase fixes after #14797

The bare-status ratchet cut each file at "#[cfg(test)]\nmod ", so a test module
with an #[allow(..)] between the two was counted as library code. It now skips
attributes before `mod`. That frees exact_pieces.rs (its 2 were test code) and
keeps step_window.rs at 0 after #14797's test. The off-generator test from
#14797 names SM_ERR_NOT_WITHIN_TOLERANCE by path, since this branch no longer
imports it in the module.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 04:33:18 +00:00
bherbruck 9b95bdd829 SMLib export: the widened end slack moves only knots past the edge's end (#14842)
A knot inside the edge's interval nearer its end than a trim's overhang was moved onto the end with the overhang, changing the curve. Past the rounding, only knots on the side the trim runs over the end are moved.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 04:24:30 +00:00
raykholo 7df1139854 Control channel: ui.inspect reports the Recover unsaved design? prompt, ui.recover answers it (#3750) (#3751)
* claim: #3750

* ui.inspect reports the Recover unsaved design? prompt; ui.recover answers it (#3750)

While the prompt shown after an unclean exit was open, ui.inspect said
"dialog": null and the only way to answer it was a coordinate click.
ui.inspect's dialog now describes the prompt (kind recover_unsaved, the
designs offered, the actions), and ui.recover {action, id?} answers it
through the same code its Recover, Discard and Later buttons run.

* ui.recover: refuse a non-string id, keep an entry the open-design limit refuses

A supplied `id` that is not a string is now an error instead of falling back to
the first offered design. Recovering when 32 designs are open and the active one
is not blank is refused before the entry leaves the prompt. The docs say when
`dialog` is null (only when no designs remain offered).
2026-11-11 04:17:13 +00:00
jyviko 5e33828396 solidcore: free curves from control points, a moved CV, their combs and display points (#14837)
* solidcore: the clamped uniform knots of a curve made from control points alone

knots::clamped_uniform(count, degree): degree + 1 zeros, interior knots
i / (count - degree), degree + 1 ones (The NURBS Book, 2.2), refusing a
degree of 0 or past MAX_DEGREE and too few points.

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

* solidcore: the curvature comb of any curve, not only of an edge

The curvature formula of curve_curvature becomes curvature_of(k, curve, t),
over a kernel curve; curve_curvature and edge_comb call it, and the new
curve_comb(k, curve, interval, teeth, closed) combs a curve that bounds no
face. One formula; the edge combs are unchanged.

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

* solidcore-tess: a free curve's display points, by the edge sampling rules

curve_points(curve, interval, tolerance) samples a record curve that bounds
no face with the bisection the mesher samples edges with (chord within the
tolerance at the quarter points, the facet angle, at most a quarter turn per
segment), factored out of sample_edge unchanged as edges::bisect. Edge nodes
are the same as before: the tess suite passes unchanged.

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

* solidcore-ops: free curves: a curve from control points, a moved CV, its comb

curve::cv_curve(points, degree): the clamped B-spline on the kernel's
uniform knots, degree at most one less than the points. curve::move_cv:
one control point of a spline or polyline moved, knots and the rest kept.
curve::curve_comb(curve, interval, teeth): the kernel's curve comb through
the record conversion, with the edge comb's tooth bounds.

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

* solidcore: a curve evaluation without its second derivative is a typed query failure

curvature_of reported it as a bare status; the query routines name their
failures (Reason::Query), and the bare-status scan holds them to it.

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

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 03:58:08 +00:00
jyviko d6982e5aa7 CI and local builds: pin Rust 1.98.0, the version SolidCore and FieldCraft use (#14835)
A root rust-toolchain.toml pins 1.98.0 (rustfmt, clippy, the wasm32 target), so local
builds use the same rustc as CI and share its compiler cache, and a new stable release's
lints cannot break CI unannounced. Every dtolnay/rust-toolchain step moves from @stable to
@1.98.0, the FreeBSD VM installs 1.98.0 instead of stable, and the web container builds on
rust:1.98.0. The solidcore jobs keep reading solidcore/rust-toolchain.toml (also 1.98.0).
Cargo.toml's rust-version stays 1.95.


Claude-Session: https://claude.ai/code/session_01DtL5M2d5MaefaEQeSeAwZw

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 03:40:48 +00:00
bherbruck 3bdb61aec8 SMLib export: a trim ending where its circle edge ends, within the edge's tolerance, is kept (#14829)
A circle edge on its NURBS form whose end lies a rounding past a quarter line (10^-9 to 10^-6 of the form's parameter) had SMLib's UV trim, which stops exactly on the quarter line, refused as off its edge: the trim map allowed only 10^-9 of parameter. One such face lost the whole solid. An end gap is now also accepted where the edge's points at the two parameters lie within the edge's tolerance; the trim's knots there are moved onto the edge's end.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 03:21:25 +00:00
JIJINBEI 63a8892f82 refactor(ui): control methods read typed params (#2157)
* refactor(ui): control methods read typed params, one function each

dispatch in control.rs becomes a routing table: each larger ui.* method is
its own function, and its params are read once into a type in
control_params.rs (what a request leaves out is filled in or refused there).
A field that is present must have the documented type; docs/control-protocol.md
says so under Params. Methods main added since (ui.blur, document.export,
import and saved, mcp.options) and main's changes to the others (ui.drag's
via, ui.scroll's ctrl and unit, ui.shortcut's slot, ui.home's sample check,
the panel commands in engine.batch and engine.script) are kept as they are.

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

* Review: ui.view refuses an unknown name before changing the view; Params says which methods it covers

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

* Review: ui.home checks sample before closing Home, ui.menu checks fields beside close, ui.start null documented

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 03:12:57 +00:00
MarkusRe 8a3c5ec645 solidcore: retire two boolean flags that change no result (A3) (#14749)
* solidcore: retire the boolean flags that change no result (A3)

The code each flag turned on is now the only path; the fields, preset lines,
the goldens mapping, fixes.json entries and the tests' flag-off halves are gone.

Part of #14246

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

* solidcore: retire remove_slit_loops_where_solids_touch too (A3)

The third flag of batch A3, read in boolean/merge/p4.rs: the slit-loop
removal after a volumetric Boolean is now the only path. Its field, preset
line, fixes.json entry, docs mentions and the test's flag-off half are gone.
Also rustfmt's layout for the test struct literal left by the first commit.

Part of #14246

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

* solidcore: keep fillet_cleanup_removes_input_slits for now (A3)

Retired alone, it breaks issue_423_default_removes_the_targets_own_edge:
that test shows fillet_cleanup_keeps_target_faces keeping a slit, the edge
WR-602 removes. The flag goes with fillet_cleanup_keeps_target_faces instead.

Part of #14246

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

* solidcore: A3 records cite the measured goldens and rig run

The addenda for WR-586 and WR-360 state the full goldens and kernel rig
result, and the module note on the temporary shift reads "Not upstream"
like its neighbours.

Part of #14246

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

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
2026-11-11 03:06:19 +00:00
SS-EN f6498ea285 Insert Part: seat on a flat face head out, flip, report the direction (#13949)
An `at` pick on a flat face, off any hole, now seats the part at that
point with its head out of the face, whichever way the face points
(it was refused). `flip` turns a seated or point-placed part over, and
the reply lists each placement's head direction in the world.

Refs #12150

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 03:04:27 +00:00
Brett Lawson 3a00f19e71 Fillet and chamfer: a refusal's search for the largest size that fits starts from the size the refusal names (#14784)
A blend refused as too large under the exact fit searches for the largest size
that builds, to name it (largest_that_fits). The search bisected the grid of
three-significant-digit sizes, 11 or more candidate blends at a few seconds each
on curved bodies. The refusals already name an estimate of that size, usually
within a step or two of it.

The search now keeps the bracket (largest built, smallest refused) and narrows it
with the estimates first: the size a refusal names is tried next, and once it
builds the sizes 1, 2, 4, ... steps above it until one is refused, then bisection
as before. An estimate within 1% of the size refused (solidcore's size less a
margin) is not followed: on the engine fuzzer's seed-2 design a 2.99 mm round,
the 3 mm refusal's estimate, ran 834 s in the G^n certificate before it failed.
Where building is monotone in the size the size found is bisection's.

FitSearch::tried counts the candidates. Regression tests use the seed-2 design's
estimates: 2 candidates instead of 11+ for an estimate within a step, the
margin-only estimate not followed, and bisection's answer over a sweep of
estimates above and below the size.

Seed 2 of atomic_commands (fuzz::run(2, 8)), ci profile, P-cores: its two refused
fillets take 9 and 2 candidates instead of 11 and 11, the same refusal messages.


Claude-Session: https://claude.ai/code/session_01SdcgEtwSrdwZxqTQp5MfnU

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 03:04:11 +00:00
Brett Lawson 5799c0d56e solidcore: the curve routines describe their failures (CurveFailure::Failed, Missing, ZeroDirection) (#14776)
The bare `sm_err`/`null_pointer`/`Upstream {}` sites in 20 files of `curve/`
(composite curves, conic, curve in volume, curve on surface, the curve base
and its solvers and property functions in `curve/curve/`, the curve cache,
ellipse, hyperbola, line, offset, parabola, projected and tangent-field
curves) become `CurveFailure::Failed(phrase)` (status SM_ERR),
`CurveFailure::Missing(phrase)` (SM_ERR_NULL_POINTER) or
`CurveFailure::ZeroDirection(phrase)` (SM_ERR_INVALID_INPUT), extending the
existing `Reason::Curve`. Behaviour-neutral: `sm_status`, `kind`,
`operation`, `entity`, `is_port_failure` and `is_status_answer` are those of
the `Upstream` each replaced, and `upstream_status()` covers the new
variants (`CurveFailure::replaces_upstream_status`). The length bracket's
`LengthBelowFloor`/`LengthPastPieceCap` report as before.

`curve/curve/p3.rs`, `curve/curve/p7.rs` and `curve/ellipse/p1.rs` stay
bare: open PRs edit the lines of their sites. A ratchet test keeps bare
statuses out of `curve/` with those three files' counts. The curve goldens
runners read `upstream_status`; SolidCraft's `status()` passes the new
variants on as their status, with a test.

Part of #13790.


Claude-Session: https://claude.ai/code/session_01SdcgEtwSrdwZxqTQp5MfnU

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 03:03:41 +00:00
DuBearte 1f560322b8 UI kit: the host-texture menu test clears its texture changes (#14811)
egui panics when a TexturesDelta is dropped with unapplied changes, so
a_menu_row_draws_a_hosts_texture_and_an_item_still_reads_as_its_kit_name
failed and stopped cargo xtask ci at the shared-crate check.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 02:55:05 +00:00
DuBearte 6ca5165a92 Control channel: turning it off frees its port before returning (#14752) (#14779)
The listener thread signalled Stop::stop through a channel sender that was a
local of the closure body, but the TcpListener was a captured value, which
drops after the body's locals. Stop::stop could return while the port was
still bound, so a_stopped_listener_closes_its_port_and_its_connections failed
now and then on 'the port is free again'. Drop the listener explicitly first.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 02:55:03 +00:00
PeppaWutz 76d68ca211 Agents and scripts: Export Motion Animation describes its exact parameters and reply (#13310) (#14203)
* Agents and scripts: Export Motion Animation describes its exact parameters and reply (#13310)

motion.export_animation now publishes typed input and output schemas, so
agents and scripts can read which parameters it takes and what it returns,
and the warn and strict parameter checks cover it.

The contract reads the whole call in the handler's order and with its
messages: path, the frame size, fps, grid and axes, what moves (an exploded
view with back, a study, or a joint with from and to), the camera, and
samples, whose upper end follows from the frame size and the format. What
only the design can tell stays the handler's. The reply is built from a
typed value that serializes to the same JSON as before. Its line leaves
unconverted.txt.

Part of #2829.

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

* Export Motion Animation: one copy of the limits and camera fields (#13310)

The handler now takes MAX_FRAMES, MAX_SIDE, MAX_GIF_PIXELS, MAX_PNG_PIXELS
and CAMERA_FIELDS from the schema module, so the schema, the strict check
and the handler cannot drift apart.

Made with Claude Opus 5.5 in Claude Code (Ultra plan, default effort, fast mode off, Ultracode off).
2026-11-11 02:54:21 +00:00
thocsmith 72f039add9 Render tests: a stop sent to the render worker always wakes it, so a test no longer hangs CI (#14500) (#14508)
* claim: #14500

* Render host: a stop always wakes the worker, so a test no longer hangs CI (#14500)

The native worker checks `stop` and starts waiting under the queue lock.
The tests set `stop` and notified without that lock, so a wake sent
between the check and the wait was lost and join() waited for good.
Shared::stop_worker sets the flag and notifies under the lock; the tests,
join_worker_for_test and Owner::drop's final wake use it. New test
a_stop_sent_while_the_worker_is_about_to_wait_still_wakes_it failed with
the old pattern.

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-11 02:52:01 +00:00
thocsmith c8fc027c53 Sketch Scale: scale the dimensions of the scaled curves, as Transform does (#14411) (#14435)
* claim: #14411

* Sketch Scale: scale the dimensions of the scaled curves, as Transform does (#14411)

sketch.scale moved points and scaled circle radii but left every dimension
as it was, so the solve pulled the geometry back toward the old sizes: a
filleted rectangle scaled 2x kept its r5 radius dimension and came out
skewed. It now calls scale_dimensions, as sketch.transform does, and prunes
unused model parameters afterwards.

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-11 02:49:05 +00:00
glitchau e166d22373 Assemblies: assembly.constraint.edit changes a constraint's rule, gap or angle in place (#6061) (#14563)
A constraint made with assembly.constraint.create can now be changed in
place: its rule (Mate to Distance to Flush...), its gap, an angle rule's
angle, which way it faces, its name, or whether it is suppressed.

A new rule is mapped onto a joint kind by the same table create uses, and
must fit the references the constraint already has: a rule that doesn't is
refused with create's reason, and nothing changes. The gap carries over, a
tangent's as its gap, not its offset. `flip` turns the part over, from the
new rule's way after a type change, else from how it faces now.

The change runs on joint.edit's own path (`edit_joint`), so the values start
again where the parts are, and the joint is solved as any edited joint is.
Through that path a change of kind keeps the new rule's tag; joint.edit
itself still makes a constraint changed to another kind a plain joint. A
joint made as a joint is refused: joint.edit changes it. The command has a
typed contract (`EditConstraintParams`) and answers as joint.edit does.


Claude-Session: https://claude.ai/code/session_01PoD5bGb8KWxkNrvkJujMvE

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 02:45:24 +00:00
Brett Lawson ea2fa58254 solidcore: decisions on an upstream status also accept the typed failures that replaced it (#14797)
* solidcore: claim the typed-errors upstream-status decision gap

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

* solidcore: decisions on an upstream status also accept the typed failures that replaced it

The typed-errors slices replaced bare SolidError::Upstream failures with
SolidError::Failed carrying a typed Reason whose upstream_status() is the
old code. Library decisions that matched the Upstream variant directly
stopped recognising those failures. Every such decision now reads
upstream_status() (with operation() and entity() where it keyed on them):

- fillet finish: the WR-696 FilletRefused conversion, the WR-212
  non-manifold refusal and without_entity (which now also clears a
  replacing failure's entity, keeping its typed reason);
- feature executive: the WR-656 open-base refusal;
- MakeEdgeInFace's no-vertex-sector check;
- the N-dimensional local solve's bad-Jacobian exit;
- STEP window's off-surface answer;
- the surface-of-revolution off-generator answer (the cone's trim
  intervals and intersections now call the same helper);
- the fillet-section certificate's subdivide-on-SM_ERR (two sites);
- Surface::step_inversion_answered.

Typed failures that never stood for an upstream status keep their
handling: upstream_status() is None for them. Each decision has a test
with intersect and geom failures that replaced an Upstream, and sweep
and curve failures that did not.

Part of #13790

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 02:42:46 +00:00
MarkusRe 9a34227883 solidcore: plane sections on a cylinder get their exact UV trims by default (retire cylinder_plane_section_trims_exact, #13836) (#14744)
* claim: #13836

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

* test(solidcore): plane-section trims on a cylinder are exact on the default kernel (#13836)

The tests of #2109 run on the default kernel instead of setting
`cylinder_plane_section_trims_exact`, and drop the two tests that pinned
the traced cubic trims (their values stay in the file's header). Two closed-form tests are added:
- the 2358 boss tilted 20 degrees: its union is 38601.117929444641;
- the 10x20x30 box with edges rounded r0.5 and mitred (the
  piecewise-box-edges-* bodies of blend.fillet_executive_p3): its volume
  is 6000 - r^2 (1 - pi/4) L + n r^3 (5/3 - pi/2).

Red on main: the default kernel still fits a cubic to each section on a
cylinder, so the three trim tests, the union test (3.6e-5 off) and the
box test (4.5e-6 off) fail.

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

* solidcore: plane sections on a cylinder get their exact UV trims by default (#13836)

Retire `UpstreamFixes::cylinder_plane_section_trims_exact` (WR-680):
`Surface::drop_curve_base` always tries `exact_uv_curve` once the domain
checks pass, so a curve lying on a cylinder (a plane section, a round's
rail) gets its exact rational UV curve instead of a traced cubic. A
curve it does not apply to is traced as before. The field, its preset
line in crates/smlib/src/core.rs and its kernel_cases/fixes.json entry
go; SolidCraft and the rig already ran with it on.

The #2358 boss's removed volumes were pinned to the fork's with this
flag off; they are re-pinned as this kernel's, tolerances unchanged
(1.7e-4 to 4.3e-4 from the fork's), and its union now equals the closed
form to 1e-12 relative. The WR-680 addendum records the old and new
values.

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

* goldens: bless the rows the exact cylinder trims move (#13836)

44 rows of two bodies move, values only. Rows that moved only within the
goldens' 1e-9 keep their blessed text. Every moved row with its old and
new values: plan/findings/intersect.surface_drop_lib_p3--exact-cylinder-trims-move-goldens-2026-11-10.md

- blend.fillet_executive_p3 piecewise-box-edges-* (8): the 10x20x30 box
  rounded r0.5 with mitres. Integrated to 1e-10 its volume now equals
  6000 - r^2 (1 - pi/4) L + n r^3 (5/3 - pi/2) to 1e-10..8e-10 (edges
  0-1: 5998.402470018067 vs 5998.402470017965), where the traced trims
  were 4.5e-6..8.1e-6 off (mitred_box_rounds_are_their_closed_form checks
  it); the rounds' trims are within 2.1e-14 of their edges (traced:
  4.3e-6). The unit integrates to 1e-4.
- sphere_diff_3cylinders.smb (36: fillet_executive_p3 10, face_p3 15,
  edgeuse_p3 10, corpus.parity 1): with its trims rebuilt, 17 cylinder
  trims become rational quartics within 1.6e-13 of their edges (traced
  cubics 1.3e-5 off, two 9.6e-3). Volumes move by at most 2.6e-3 of
  169497, areas by at most 2.2e-4 of 38237.

Details: plan/upstream-issues/WR-680--addendum-2026-11-10-default-and-flag-retired.md

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

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
2026-11-11 02:05:14 +00:00
bherbruck ef49550c5e CI: a label run's ci ignores cancelled runs when it repeats the commit's result (#14814)
A label run's ci job repeats the commit's newest run that ran checks. It counted cancelled
runs too, so a cancelled run (replaced by a newer one, or a fork run that waited for approval
and was cancelled with no jobs) made ci fail for good and blocked PRs that had passed
(#14633, #14759, #14760, #14441 today). Only runs that passed or failed are a result now.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 02:05:11 +00:00
Bryan a17e1d2c9b mcp: inspect_design and check_parameters can give compact replies on large designs (#14162) (#14186)
document.inspect / inspect_design take timeline: true | "summary" | false
and bodies: true | "names" | false; check_parameters takes bodies: [names]
and summary: true (measure as the measure tool's summary). Every default
reply, and what an app is sent when the new arguments are left out, is
unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brennen Herbruck <brennen.herbruck@gmail.com>
2026-11-11 01:46:43 +00:00
SS-EN c7ce50187d Timeline: an edit in one component keeps the other components' results (#6183) (#6295)
* Timeline: key features on the constructions and bodies they read (#6183)

Builds on #3134 (keep results of features whose inputs did not change),
ported here onto current main because that PR conflicts with it:
- Construction planes, axes and points no longer go into every feature's
  key. Each feature hashes only the constructions it names (and those they
  stand on), as evaluated, so editing A_top recomputes its users only.
- Wider read sets: fillets and chamfers read the body their edges lie on,
  extrudes and revolves that cut or join read their sketch and targets,
  and sketches on construction planes or with projections read what they
  project. A cached fillet's blend records come back with its change.

Co-authored-by: jijinbei <hallucigenia2002@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Timeline: a read set covers every blend pick, patterns placed by numbers alone, construction geometry and a join's kept-apart notice (CodeRabbit review on #6295)

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

* Timeline: a pattern of a cut reads the bodies its source cuts, as its copies cut them (CodeRabbit review on #6295)

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

* Timeline: test that a coil cut from a sketch follows the sketch and its target (#6183)

A coil sweeping a sketch's profile was keyed as a tool from numbers alone in
groove. The rebased #6295 keys only coils without a sketch that way; this test
fails without that and passes with it.

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

* Timeline: a sketch's read-based key holds its stored points exactly, so a sketch that only rounds alike keeps its own solve (#6295)

A sketch is fingerprinted with its stored solve on a grid. Deleting a dimension that had moved
its points by less than the grid gave back the solve from before the dimension, and the check
before a delete in a rolled-back history, which compares solves exactly, built the history
after the marker again.

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

* Key a feature linked to a construction point on the whole model

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

* Timeline: an in-place sketch edit counts the later features made again from their changes as evaluated (#6295)

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

* Timeline: the overlapped checks hold a feature's whole result and its change together (#6295)

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

---------

Co-authored-by: jijinbei <hallucigenia2002@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 01:46:39 +00:00
phanoob 32528d4dcd Sketch Move/Copy: a moved shape stays whole when it is tied to the origin, and the status line says which constraints were released (#2980) (#3581)
* claim: #2980

* Sketch Move: entities held to what stays move instead of bending (#2980)

Moving a rectangle drawn from the origin moved half of it: the corner's
coincidence with the origin pulled that corner back and the solve bent
the rectangle. A move now lets go of the constraints that tie the moved
geometry to geometry that stays and that the move breaks, and returns
their ids as `released`; constraints among the moved entities (sides,
a typed angle) move with them and are kept.

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

* Sketch Move: the status line names the constraints the move let go, a click undoes (#2980)

The move returns each released constraint as {id, kind}; after a Move that let go of any,
the status message lists their kinds and offers Undo.

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

* Sketch Move: judge a broken constraint by its own geometry, drop "Click to undo"

Whether a constraint still holds after a move was measured against the
largest coordinate in the whole sketch, so one far-away point could hide a
broken constraint. The tolerance now comes from the points the constraint
touches, with a fixed one for the sine or cosine between two lines.

The status message no longer says "Click to undo": a click on its text does
nothing, only its Undo button undoes the move.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brennen Herbruck <brennen.herbruck@gmail.com>
2026-11-11 01:45:57 +00:00
MarkusRe 9f4fa95e13 solidcore: the setback scale test bounds its QP count instead of pinning Linux's (#13739) (#14793)
* claim: #13739

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

* test: the setback scale search's QP counts are held below least first's, not pinned

thousandth_scales_and_attempt_budget pinned the search's QP count per
corner (22, 26, 21, 30, 3, 2), measured on Linux x86-64. The count
depends on which failing attempts' degree-9 programme ends unproven: such
an attempt still solves degrees 5 to 8, four programmes more. That
differs between targets and machines: on Windows x86-64 (MSVC) the second
corner solves 22, the fourth 26, and on another Windows machine the first
solves 26, so the test failed there.

What the count is for is rejecting the old order, each attempt's degrees
from the least up, where every failing attempt solved five programmes.
Those counts (35, 35, 39, 34, 2, 10) are the same on Linux and Windows.
The search must now solve fewer on the four corners where degree 9 has
room to decide: it needs two of five or six failing attempts decided by
degree 9 (one of two where every edge is held), where Linux and Windows
decide all but at most one. The corner asked 4.25 gets no bound: its
eight certifying attempts each solve one programme more, and it falls
below 34 only where degree 9 decides three of its four failing attempts.
The r 5 corner's one certifying attempt is held at 3.

Refs #13739

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

* test: the setback scale search's QP count has a floor, its comments the exact condition

Each attempt solves at least its degree-9 programme, so the count is held
at the attempts from below; the bound above alone let any under-count pass.
A failing attempt solves five where degree 9 is not proven infeasible (an
unproven programme or an uncertified solution), not only where it ends
unproven. The corner asked 4.25 needs at least three of its four failing
attempts decided by degree 9. The step budget's comment says the long
programme below 9 is caught by it alone. The per-machine counts move to
the pull request.

Refs #13739

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

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
2026-11-11 01:40:00 +00:00
PaddleStroke cd2fe9920b Translation checks: use the CI build profile to read command labels (#14188) (#14189)
* claim: #14188

* Translation checks: read command labels using the CI build profile

---------

Co-authored-by: Brennen Herbruck <brennen.herbruck@gmail.com>
2026-11-11 01:37:42 +00:00
MarkusRe eda13321af STEP export: characters beyond the BMP (emoji, some CJK) are written as \X4\, not as surrogate pairs (#9089) (#14747)
* STEP export: test that characters outside the BMP are written as \X4\ (#9089)

ISO 10303-21 defines \X2\ as UCS-2, so a character above U+FFFF belongs in
\X4\ with eight hex digits. The writer emits a UTF-16 surrogate pair in
\X2\ instead; the exact-form assertions fail until it is fixed.

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

* STEP export: write characters beyond the BMP as \X4\, not as surrogate pairs (fixes #9089)

`write::string` encoded every non-ASCII character as UTF-16 inside \X2\. ISO
10303-21 defines \X2\ as UCS-2, so a character above U+FFFF (an emoji, some
CJK) came out as a surrogate pair, two invalid code points to a strict
reader. It now writes \X2\ with four hex digits up to U+FFFF and \X4\ with
eight above; BMP output is unchanged.

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

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
2026-11-11 01:18:59 +00:00
Brett Lawson 794cfde212 solidcore: topology failures carry a typed reason instead of bare statuses (part of #13790) (#14746)
The bare sm_err / null_pointer / Upstream sites in the topology files that no
open PR touches (B-rep constructor, cache and data streams, face p1, edge,
edge uses p1/p3, loop uses, vertex uses, vertex corner, shell, trimming tools
and curve loops, cutting, mirror, planar section, vertex fans, assert_valid
seams, projection, traverser, database and file wrappers: 190 sites in 25
files) return a TopologyFailure under the new Reason::Topology, each with a
phrase saying what failed. Failed, Missing, InvalidValue, Inconsistent and
OutOfMemory keep the statuses SM_ERR, SM_ERR_NULL_POINTER,
SM_ERR_INVALID_INPUT, SM_ERR_ASSERT_FAILURE and SM_ERR_OUT_OF_MEMORY;
DropFailed keeps the edge use's recorded drop status. The operation path and
entity (the vertex use for the vertex-use routines, none elsewhere) are those
of the Upstream each replaces, so sm_status(), kind(), entity(),
is_port_failure() and is_status_answer() answer as before; no cause is kept
and no PortInvariant is wrapped. A failure about an entity names it in its
message, as Reason::Failed does.

upstream_status() answers for Reason::Topology as for an Upstream. The
corner's ignored more-than-three-faces status is matched by operation and
status instead of the Upstream variant. The goldens runners of the converted
units, and the constructor outcome, read upstream_status, so rows stay as
they were. Tests that matched the Upstream variant check upstream_status.
The error messages in these files that named SMLib types are worded in
solidcore terms; geometry_write's file text is unchanged.

SolidCraft's status() passes topology failures on as their status, as it did
the Upstream, with a test.


Claude-Session: https://claude.ai/code/session_01SdcgEtwSrdwZxqTQp5MfnU

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 00:51:32 +00:00
Homero Thompson 0f8112b74d Sweep: a body's edge can be the path of a sweep (#2216) (#12263)
* test(ui): a sweep takes a body's edge as its path (#2216)

The Path input of the Sweep dialog takes only a sketch's curves, so a
user cannot pick an edge of the model as the path of a sweep.

* Sweep: a body's edge can be the path of a sweep (#2216)

The Path input of the sweep dialog accepted only the curves of a sketch, so a
user who picked an edge of the model as the path of the sweep got "select the
path first" and the sweep was refused. The Path input now accepts a body's
edges as well: when the picks are edges of one body, the sweep is built with
that chain of edges as its path, each edge followed by the curve it is, and
the path starting at the end nearest the profile. The command takes the path
as path_body (the body) and path_edges (a point on each edge of the chain); a
path of sketch curves is unchanged. A curve of the profile's own sketch is
never a valid path, so the pick no longer stops on one and falls through to
the edge under the cursor.

* Sweep: a body's edge path is restored on its own body (#2216)

* test(engine): a closed body-edge path starts at the profile (#2216)

* Sweep: a closed body-edge path starts at the profile (#2216)

* style(test): format the closed path sweep test (#2216)

* Sweep: the contract knows the path of a body's edges (#2216)

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

* Sweep: format the contract's path of a body's edges (#2216)

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

---------

Co-authored-by: agent <agent@localhost>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: vdirienzo <vdirienzo@users.noreply.github.com>
Co-authored-by: Hermes Agent <hermes@nousresearch.com>
2026-11-11 00:34:43 +00:00
Brett Lawson 224e33033c solidcore: the setback fill's convexity products scale shared factors once (#14754)
The setback corner's G1 fill (and its certified scale search, up to 12
fills) spent about 70% of its time in affine_fundamentals: every product
of every unknown's share with the same four cofactors re-scaled the
cofactor and evaluated two binomials per coefficient of each factor and
of the product, and the minors of H, Hu, Hv were recomputed for o, l, m
and n.

- t_mul_fast reads its binomials from a per-call table built by the same
  binomial function; affine_fundamentals and fundamentals share one table,
  scale each cofactor and each unknown's share and partials once, and take
  the recurring minors once (det4_minors, t_det4_along).
- The dual QP's violation scans read each row as one slice.

The same operations on the same values in the same order: results are
bit-identical. Tests compare the products, affine_fundamentals and
fundamentals bit for bit against the previous per-coefficient code on a
rational patch, and count the binomial evaluations (406 against 219140).

Part of #14460.


Claude-Session: https://claude.ai/code/session_01SdcgEtwSrdwZxqTQp5MfnU

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 23:53:17 +00:00
Brett Lawson c2fe9216bd solidcore: G^n certificate feet Taylor models evaluate each series order once (#14740)
* solidcore: claim incremental series evaluation in the feet Taylor models

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

* solidcore: G^n feet Taylor models evaluate each series coefficient once

gn_enclose_tm solves the feet's series order by order and evaluated G at
every order from scratch, recomputing every lower order's coefficients.
GnIncremental keeps every series gn_evaluate_vn takes and computes only
the coefficients whose quadrant of x changed (Trunc::skipping), with the
same operations in the same order: bit for bit the whole evaluation's
(tested in f64, intervals and Taylor models). gn_evaluate_vn is unchanged
for its other callers. WorkCounters::feet_tm_coefficients counts the
coefficients computed.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 23:18:45 +00:00
bherbruck 40e95b706d Fillet: a setback too short for its corner's fill is refused naming the setback, as data (#13913) (#14731)
solidcore's Setback refuses a setback at which its fill is not certified convex (or, beside a curved face, no more concave than its faces), and it knows the least setback that is certified. It said so only in the refusal's comment. BlendRefusal::SetbackTooShort now carries a `why`, mirroring SetbackTooLong's: RailsCross (the existing refusal, a setback short of where the rails cross), ConvexFill and ConcaveFill. The two fill refusals are typed with the least setback where it lies within the corner's rooms. Where none fits they stay untyped, since there is no size to name. Comments, codes and fit_setback are unchanged.

SolidCraft maps the two fill reasons to smlib's new Error::SetbackTooShort { least, at, builds }:
- Where solidcore's fill is the corner (fillet_setback), the message names the smallest setback that builds, in the wording the fillet dialogs read (SETBACK_AT_LEAST).
- Where SolidCraft's G2 patch replaces that fill (fillet_g2_corners), it names only what solidcore's corner needs ("needs a setback of at least"). At that setback the patch is not shown to fit (tangency-labs/solidcraft#14427).
- RailsCross is not mapped: its setback passes that one test only.

Tests:
- solidcore: the 40 x 30 x 20 box rounded r3 at (40, 30, 20), set back 3.5, is refused with SetbackTooShort { ConvexFill, 3.5595 }, and set back 3.5595 it builds. The rails-cross refusal asserts RailsCross.
- SolidCraft: the plain path's 3.5 is refused naming the smallest that builds, 3.5595 mm, and that one builds a valid body. The G2 path names only the lower bound. The kernel case every-box-corner-three-edges-r3-curvature-setback-3-5 expects that text.
- fillet_setback_tests::a_setback_not_past_the_radius_is_refused_naming_the_least now passes, so its known-failing line goes.

Fixes tangency-labs/solidcraft#13913.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 23:10:28 +00:00
bherbruck 6b63b3ce6e Fold the assembly constraint format change into format 52 for 0.40.0-rc.4 (#14790)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 19:02:50 -05:00
bherbruck 3b561df768 Release 0.40.0-rc.4 (#14777)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 18:32:54 -05:00
Daniel 5d5e1208f5 Placed components: Convert Mesh, the Browser's body count and the changed-faces highlight follow every placement (#14768)
* Convert Mesh: a second placement's body converts under the name the world shows

mesh.convert looked the selected or given body up in the model's own namespace, so the body of a component's second placement, named "Mesh (Scan:2)" by the selection, the Browser and inspect.measure, answered "no body". The name is now mapped through own_body_name first, as appearance.assign maps it (#9740): the conversion edits the component's body and every placement shows the solid.

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

* Browser: the card's header counts one body per placement, as its rows do

The header counted the model's bodies while the card lists a body row under each placement of its component, so one body placed twice showed two rows under "1 body". The count now comes from the world state, one per placement; when the design cannot be placed it counts the model's bodies as before.

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

* Viewport: a feature's changed-faces highlight lights every placement of its component

When a later Offset Face, Replace Face or Delete Face renamed a feature's faces, the highlight found the faces it changed by comparing the states either side of it, then looked the body up by its definition name in the placed world and matched a face in the body's own coordinates against the placed body's faces by plane. A second placement of the component was never lit, and the plane match mixed the body's own coordinates with the placed body's. The match now runs in the body's own coordinates and the face is lit by its name on every copy the viewport shows, since placed copies share the body's face names; visibility is checked per copy.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-10 22:50:02 +00:00
Daniel cfbd729aa8 Primitive dialogs: Corner, Base and Center read typed text as the size fields do (#14764)
In a design that is not in millimetres, OK sent a typed Corner, Base or
Center through with_design_unit, which adds the unit only to a plain
number, so a decimal comma such as 1,5 was read as millimetres while the
size fields read it in the design's unit. The placement marker read the
same fields with doc.eval, so a unitless expression such as 2 * 1 put the
marker in millimetres although OK made the solid in the design's unit.

OK now writes those fields with length_input::expression and the marker
reads them with length_input::eval, as the size fields do, so the marker
stands where OK makes the solid.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 22:49:59 +00:00
glitchau a97524ea67 Assemblies: place a part by a rule such as Mate, Flush or Distance with assembly.constraint.create (#6061) (#13508)
* claim: #6061

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

* Assemblies: assembly.constraint.create places a part by a rule (Mate, Flush, Distance and more) (#6061)

A new command places one part against another by a geometric rule between
a reference on each: mate, flush, distance, concentric, insert, parallel,
perpendicular, angle, tangent or point on axis. Each rule is kept as a joint
of the kind it needs (a mate and a flush are planar, concentric cylindrical,
insert revolute, tangent line on plane...), tagged with the rule it was made
as (`Joint::constraint`, one pending format file). So the one joint solver
places, counts and checks it with the other joints: several rules between
one pair add up, and the part keeps every motion they leave free.

A constraint starts where its parts are, as a relation does, so the part
moves no further than the rule needs; joint.edit restarts it the same way.
A tangent's offset is its gap plus its round face's radius as the face is
now, so the part keeps resting on the plane when the radius changes. A rule
given the wrong kind of reference is refused with what it needs. The command
has a typed contract and reply; joint.list and joint.inspect name the rule
as `constraint`. joint.create's handler is split so both commands build a
joint the same way (`new_joint`, then `add_joint`).

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

* Assemblies: a constraint's default name and a type change on joint.edit

A constraint given a blank or null name is named after its rule (`Mate1`),
as one given no name is, not after its joint kind.

joint.edit to another kind of joint makes a constraint a plain joint: the
tag goes, so it no longer restarts where its parts are or reads as its old
rule in joint.list, and a former tangent's offset stops following its round
face's radius.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 22:49:08 +00:00
Henry 5d28467789 STEP import: a face on a spline closed in v gets its seam, so such parts import as closed solids (#14712)
* STEP import: a face on a spline closed in v gets a seam; spline surfaces written unclamped are clamped

A face winding round a B-spline surface closed in v (its loops turning
in v, no seam edge) was refused, and the part read as an open shell.
Kind::Iso now carries the direction the spline is closed in, its seam
line the boundary row at the first v knot for one closed in v, and
Kind::winds_in() names the parameter a face's loops wind in (v round a
torus's tube and a spline closed in v), which band_seam, seam_start
and the seam's interval read. A face covering a pole at an end of u
lies on the lower-u side of a loop running +v, so that loop's pole is
the one at u0.

Spline surfaces written with unclamped (periodic, uniform) knots are
clamped exactly on reading, row by row and column by column, with the
knot insertion curves already used (now the shared clamped()), so a
periodic surface's first and last lines are one and it is found
closed.

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

* STEP import: review: boundary line in the closed-spline refusal, start for u0, one clamping helper for rows and columns

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

* STEP import: clamped_lines collects without pre-reserving from the input's count (panic-site check)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 22:48:40 +00:00
Brett Lawson 8c82399584 solidcore: the query routines describe their failures (Reason::Query) (#14704)
The bare `sm_err`/`null_pointer`/`Upstream` sites in `query/` (edge and face
queries, point classification, contact and distance bounds, continuity,
normals, the face-surface intersection, the poly B-rep, its copy and
properties, tessellation, B-rep rays) become a new `QueryFailure` under
`Reason::Query` (`error/query.rs`): `Failed(phrase)` (SM_ERR),
`Missing(phrase)` (SM_ERR_NULL_POINTER) or `InvalidValue(phrase)`
(SM_ERR_INVALID_INPUT, the zero-vector statuses of `SER(Unitize)` and an
undefined poly scale). Behaviour-neutral: `sm_status`, `kind`, `operation`,
`entity`, `is_port_failure` and `is_status_answer` are those of the
`Upstream` each replaced, and `upstream_status()` covers the new reason. No
`PortInvariant` is wrapped as a cause.

Held bare: `query/api_queries.rs` and `query/brep.rs` (open PRs on them),
and the point classification's seam failure in `query/face/p2.rs`, which
names its face (blend's `without_entity` strips the entity of `Upstream`
only). A ratchet test pins these counts and reads files with CRLF
normalised before the test-module cut.

The query goldens runners read `upstream_status`; SolidCraft's `status()`
passes query failures on as their status, with a test.

Part of #13790.


Claude-Session: https://claude.ai/code/session_01SdcgEtwSrdwZxqTQp5MfnU

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 22:48:38 +00:00
bherbruck 75a74aeee9 Pleats join the case walls again: the cylinder recogniser makes its circles from control points (#13536) (#14348)
* Cones and cylinders: end circles from the control points, and copies proved on their form (#13536)

The cone and cylinder recognisers fitted each end circle of a NURBS through
three samples and took the axis between the two centres. On the battery
case's r 40 corner (a 0.156 rad arc extruded 94 mm), the two fits differ by
1.35e-11 mm, so the axis tilted 1.44e-13 rad and the cylinder was off its own
form. The local reach found no exact class for the wall, and CaseShell was
refused.

The circles are now made from the iso curves' control points (The NURBS
Book, sections 7.3 and 7.5). The centre is the start point plus r along the
inward normal, with r = |chord| / (2 sin a). The circle passes through the
start point exactly, and rows that are translates give translated circles.
CopyAndAddAnalytics keeps a recognised plane, cylinder, cone, sphere or torus
only when the form lies on it at bound 0.

The three pleat tests pass again and their known-failing lines go.

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

* Cones: the arc's radius guard reads as clippy asks (#13536)

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

* Cones: an arc's legs are made unit before their cross product; the copy's proof is left for later (#13536)

A 0.001 degree arc of radius 1 has legs 8.7e-6 long. Their raw cross product
(1.3e-15) fell under the unit vector's absolute floor, so the arc was not
recognised (issue1433_cyl_tiny_angle).

CopyAndAddAnalytics no longer proves a recognised label on its form: other
recognisers still return true cones a few 1e-11 off their forms, which the
proof would drop. It comes back once they are exact.

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

* Cones: an arc's legs are made unit before their cross product (#13536)

A 0.001 degree arc of radius 1 has legs 8.7e-6 long. Their raw cross product
(1.3e-15) fell under the unit vector's absolute floor, so the arc was not
recognised (issue1433_cyl_tiny_angle). Micrometre cones whose sampled circles
failed before are recognised now too.

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

* Cones: the circle checks allow the angle the points' rounding turns a circle by (#13536)

Control points with coordinates up to s are known to about 8 eps s. Over a
circle of radius r, that turns the circle's normal by up to 8 eps s / r. A
tolerance scaled below that decided on the rounding of the input, not on its
shape. A cylinder of r 2.94 reaching 1.2e6 along its axis, recognised at a
thousandth of the default tolerance, was refused (goldens gen0196). The
recogniser and the rebuild now take at least that angle.

(The previous commit's message is wrong: it holds this change's first draft,
not the unit-leg fix, which is in 224d8a44dd.)

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

* Cones: an arc's radius is its leg over tan of the half sweep (#13536)

|chord| / (2 sin a) gave 5.000000000000001 for a quarter arc of r 5 (a
revolved line along its axis, face_surface's test). |leg| cos a / sin a,
both from the leg's and the chord's directions, gives 5 exactly there and
keeps the case wall's short arc on its form.

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

* Goldens: bless the units the exact cone and cylinder circles move (#13536)

Every case moved here was compared, old against new, with the exact
geometry where there is one:

- Rounding or renumbering (most of it): blend.feature_executive,
  blend.fillet_executive_p3 (but for the cases below), blend.fillet_removal,
  blend.fillet_standard_solver_p2, boolean.api_brep, primitive.api_trim_ops,
  query.brep, query.face_p1, query.face_p3, sweep.topology_sweep,
  topology.api_files, topology.assert_valid, topology.brep_cutting,
  topology.brep_p4, topology.edge, topology.edgeuse_p1, topology.edgeuse_p3,
  topology.face_p3, topology.face_p4, topology.vertexuse. Counts, statuses
  and type codes are the same, sorted values agree to 3e-11 relative, and
  gaps stay at or below 3e-13. Written files differ only in doubles that
  moved by at most 3.9e-14 relative.
- surface.cone_p2: 22 true cylinders and cones are now recognised (tight
  tolerances, micrometre sizes, rigid moves, row-weight scalings), and a
  cone frustum's rebuild (gen0230) succeeds. adv_reb_cyl_s0i1_id_neg is now
  refused: a row with negative weights runs through infinity, so it is not a
  cylinder.
- sweep.trans_sweep_geometry gen0411: a circle of r 1.5e-6 swept straight
  is now its cylinder.
- offset.offset_executive_p2
  review_box_smallest_buildable_translated_source_selected: the shell moved
  by 1e6 now builds like the untranslated one (it was 3 regions and 8 faces).
- blend.hcr global_visibility_cylinder_z / cone_z: the underside arcs are
  now hidden, as the symmetry needs.
- topology.api_projection cylinder and cone: a duplicate projected curve
  is gone.
- Lost exact edges, not wrong shapes: in offset_executive_p2 gen0121 (and
  the box shells api_shell_box_top_round_out and
  review_box_smallest_buildable_source_selected), 8 straight edges and one
  circular edge are now B-spline curves. They are still straight, to 1e-15.
  They came out exact on main only because the tilted cylinders sent the
  shell through the stitch's glue. In blend.fillet_executive_p3
  source-file-box_on_box-all-edges, one circle edge is now a B-spline; it
  has not been traced, and #14666 lists it as the same class. #14666
  tracks both.
- Invalid before and after: offset_executive_p2
  defeature_extrusion_face0_outset2_5_rounded and
  extrude_holes1_face0_outset24_7_rounded (and the overlap_pc_extrudeHoles1
  outsets) fail integrity::check ("cached face shell: face 1 not found") on
  main and here; the corner sphere faces split differently. #14676.
- Within tolerance: fillet_executive_p3 source-file-box_diff_cyl-all-edges
  and its curve-sih and overhang-union kin. Converged to 1e-8, the volume
  is 950.6480958 on main and 950.6480122 here (8.8e-8 relative), and the
  area moves 5e-7 relative. This is far below the fillet's tolerance and
  follows from the cylinder's axis now being exact. It is not yet shown
  which value is closer to exact.

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

* Cones: an arc's circle comes from a well-conditioned segment, and every segment is checked on it (#13536)

A box round offset for the local operation's merge carries 1e-5 long
extension segments at both ends of its arc. Each one fixed a centre of its
own only to the rounding of its points over its 2e-5 rad sweep (5e-10),
past the recogniser's 1e-11 tolerance, so the offset round was no cylinder
(wr669 a_box_round_offsets_to_the_round_of_r_plus_d_on_the_offset_faces).
The circle now comes from the first segment at least half as wide as the
widest. Every segment is then checked as an arc of it: end points on the
circle, middle point r / cos a from the centre, plane parallel, and end to
end.

fork_eb883ad_keep_features: the kept cylinder hole, cone pit and astride
pocket at r 8 move by their last bit; the r 1.9 cylinder boss and hole move
by 1.1e-11 (5e-16 relative). They are re-pinned, with the note. The rig's
known failure report-peg-front-and-concave-chamfers-together-d2 records
its failing volume one ulp lower.

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

* Goldens: bless the two units the well-conditioned circle segment moves (#13536)

- topology.api_files: 30 written-file digests move. These are the files of
  cylinders, cones and extrusions whose frames now come from the base
  segment's tangent; only the written doubles changed, as in the earlier
  bless.
- blend.fillet_executive_p3 real-box-fillet12-r3-shell-open-top-issue-460:
  the shell's volume moves by 1.3e-8 relative and its area by 4e-8,
  within the integrator's 1e-4.

All 283 units pass.

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

* test(solidcore-ops): a dome 1e-8 off its circle joins a cylinder and its mirror (#13713)

A dome revolved from an arc drawn through a point rounded to eight decimals
(radius 10 + 1.01e-8, base 1.01e-8 above y = 0) joined flat to a cylinder of
radius 10, and to its own mirror about y = 0. Both unions give the closed-form
volume but fail face test 36 ("Face Near missing Seam") on main: the seams end
1.43e-8 and 2.03e-8 apart at the shared vertex, radially and along the seam,
not across it.

Co-Authored-By: Claude Code <noreply@anthropic.com>
(cherry picked from commit 323b3f0931)

* fix(intersect): a vertex is a near miss of a seam only across it (#13713)

HasNearMisses counted a vertex end's whole 3D gap to the classified curve.
For a seam that includes the parts along the seam and off the surface, which
the vertex's tolerance bounds and which leave no sliver between the seam and
an edge. Two operands whose seams end 1e-8 apart along the seam (a dome whose
arc is 1e-8 off its circle, joined to a cylinder or to its mirror) were
refused with face test 36 although no construction can put one vertex on both
seams.

A vertex end whose gap reaches 1000 * EFF_ZERO is now measured again across
the curve within surface 1: the component along n x C'(t). The threshold is
unchanged, and so are coincident edges: a seam that misses an edge across the
surface (#13802's rounded slant plane) still reads as a near miss.

Co-Authored-By: Claude Code <noreply@anthropic.com>
(cherry picked from commit c76f1a1bbb)

* plan(findings): the seam near miss at a vertex two seams share (#13713)

The cause with its measured gaps, a VectorCraft schematic of the gap at the
shared vertex split off the surface, along and across the seam, and why only
the part across the seam leaves a sliver.

Co-Authored-By: Claude Code <noreply@anthropic.com>
(cherry picked from commit 79b205bc4e)

* fix(intersect): the seam near-miss refinement never fails, and is tested on a curved seam (#13713)

has_near_misses could not fail on main with in_surface_gaps false. The
refinement now resolves surface 1 without an error, and a failing curve, UV
curve, normal or vertex evaluation means the direction is not available: the
whole gap counts, as on main. Only Unported and cancellation propagate.

A unit test classifies a quarter circle on a plane ending at a vertex 1e-8
off its end: across the circle in the plane it is a near miss, along the
circle or off the plane it is not, and without a resolvable UV curve or
surface the whole gap counts. The circle's turn makes the end parameter and
the tangent's side matter. The two joins now also check their face, edge and
vertex counts and their volumes to 1e-7.

Co-Authored-By: Claude Code <noreply@anthropic.com>
(cherry picked from commit 2e62ba0f8d)

* plan(findings): where the 1e-8 comes from, and what the change leaves as it was (#13713)

The gap is in the operands SolidCraft hands the kernel, not made by the
boolean. With the change on top of the slant-plane construction fix, its 16
perturbation rows report what they report without it, and the tube-end case
pinning face test 36 passes unchanged. Coincident edges keep upstream's gap.

Co-Authored-By: Claude Code <noreply@anthropic.com>
(cherry picked from commit aa9b6b00c8)

* goldens: bless heal.face_props and topology.assert_valid for the seam near-miss change (#13713)

Only near-miss findings at vertex ends drop, where the vertex is off the
surface or along the seam, not across it. Nothing else in either unit moves.

topology.assert_valid, 7 cases (sphere_mutation6_v0.1_i0/_i3,
cylinder_mutation6_v0.1_i0/_i3, update_cylinder_m6_i0_v0.0001_l2,
update_cylinder_m6_i1_v1e-06_l2, gen0002): mutation 6 moves a vertex +x,
radially at a cylinder's seam vertex and along the seam at a sphere's pole.
In each case the one report that goes is SmFace test 36; every other report
stays (the vertex gap reports still flag the 0.1 and 1e-4 moves).
update_cylinder_m6_i1_v1e-06_l2, a vertex 1e-6 off with tolerance 1e-5, is
now valid.

heal.face_props: per face only bad_near_miss_seam and its direction change,
True to False with U, V or Both to Neither, or Both to U or V. Crossed and
missing seams, loops, poles and every other property are unchanged, and the
heal units that act on these properties do not move.

Co-Authored-By: Claude Code <noreply@anthropic.com>
(cherry picked from commit df0697145b)

* plan(findings): every golden the seam near-miss change moves, old and new (#13713)

Co-Authored-By: Claude Code <noreply@anthropic.com>
(cherry picked from commit 4563146311)

* Tests: boolean.rs keeps main's coil-cut cancel test and the dome seam tests side by side (#13536)

The merge's conflict markers split a test in two; this restores the file as
the dome seam tests followed by main's new test.

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

* Tests: the rim round builds without the torus fix too, and rim_cut passes (#13536)

issue_417: with the corner rounds' circles made from their control points,
the top rim's round also builds with fillet_torus_center_on_axis off, at the
closed-form volume (to 1.1e-9); the test pinned that it failed.

solidcraft-engine rim_cut_after_the_round_on_the_other_end passes now; its
known-failing line goes.

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

* Cylinders only: the cylinder recogniser makes its circles from control points; the cone recogniser and rebuild keep IsArc (#13536)

The battery case's wall comes in through SolidCraft's import as a cylinder
(Cone::is_nurb_surface_cylinder). Giving that recogniser exact end circles
is what the pleats need. Doing the same in the cone recogniser and in the
cone rebuild put the chamfer revolutions of issue_420 1.8e-12 rad off
their edges' circles, which the curve recogniser still fits through
samples, and moved many goldens. So that part is left for a follow-up.

- surface/cone/p2.rs is main's, plus iso_arc_exact (the control-point
  circle: built from a well-conditioned segment, every segment checked on
  it) used only by is_nurb_surface_cylinder, and its regression test on the
  case wall's record form.
- The goldens, the fork_eb883ad pins, issue_417 and the rig's known
  failure go back to main's values. topology.assert_valid moves in 32
  cases, all at rounding (gaps at or below 3e-13), and is blessed with
  #14581's cases.

Pleats (pleat_join, both pleat_regressions), rim_cut, issue_420,
issue_417, wr669, fork_eb883ad, the kernel rig, the hair-thin hole and all
283 goldens units pass.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Markus Reichart <52602624+MarkusReichart@users.noreply.github.com>
2026-11-10 18:02:50 -05:00
Mike111177 95dec905cd Construct: Plane, Axis and Point buttons that choose the method from what you pick (#11340) (#11341)
* claim: #11340

* Construct: Plane, Axis and Point tools that choose the method from what you pick

* Construct: list Plane, Axis and Point in the kernel-cases EXEMPT list, tested in construct_tests.rs

* Construct: test that the old Construct layout comes back with the toolbar preference, follow the new buttons in the narrow-toolbar scenario, document the touched functions

* Construct: the Plane window test also takes the second plane out and checks Offset Plane is chosen again

* Construct: Plane, Axis and Point lead their entity's heading in the Construct menu, and plane_auto joins the requested-icon manifest
2026-11-10 22:06:10 +00:00
bherbruck 3a40027699 Cancel on a computing feature stops it and applies nothing (#14725)
* solidcore-ops: cancel ops calls with a scoped token (solidcore#3247)

solidcore_ops::cancel::within runs work under a CancelToken. Every ops call
it makes on that thread runs with the token on the session's kernel, whose
long loops already poll it; once cancelled, the running call stops at the
kernel's next check and later calls are refused before they wait for the
kernel, each with Error::Cancelled.

A cancelled call leaves no partial state: an edited body is put back by the
session's rollback, no result is returned even if the kernel finished
first, the kernel is replaced as after a panic, and nothing reaches the
failure callback. Nested scopes stop with either token
(CancelToken::either in the kernel).

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

* Cancel stops SMLib's full check and the tight box (solidcore#3247)

The full validity check of a feature's result was most of a long boolean's
time (64% of #3962's 20-turn coil cut) and ran with no token: Cancel left it
running for seconds and it returned a result. solidcore now polls the token
once per entity in the check's topology walk, in the coincident-topology
search and in the tight bounding box.

SolidCraft runs the check and the box under the work's token where the body
is the call's own: the step result or copy a deferred feature check takes
(which now carries the token of the work that detached it onto its thread),
and a copy measured for the tight box. A call on a body others read still
runs to its end. A stopped smlib call returns the typed Error::Cancelled and
never a result solidcore finished first.

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

* Cancel on a computing feature stops it and applies nothing (solidcore#3247)

Session::execute answers EngineError::Cancelled whenever the work's watcher
cancelled during the command (doc::progress::cancelled), even if the kernel
finished first, and puts the design back as on any other error. With the
kernel now stopping at the token's next check, Cancel or Esc on a computing
OK, a changed input during a preview, closing the design and an agent's Stop
all end the computation within milliseconds, and the evaluation never caches
a result computed after the cancel.

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

* Cancel: keep the tight box on the body itself; guard check_owned

The tight box measured on a copy under a token is dropped: a copy starts
with empty caches, and the box is read by later features, so it stays on
the body as before (still uncancellable in the app; the kernel polls it
for ops callers). Brep::check_owned joins the entry-point guard.

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

* Opening a design keeps its own Cancel

doc.open, new and recover replace the session; under a cancelled watch they
skip the features not yet built and show what is built (#10577), so
execute answers Cancelled only for commands that keep the session.

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

* issue_2371 cancel test: the section check point is asserted inside the sections

On a slower machine 4 s into the boss-root round is past its sections, in
the glue that follows (glue_edges_general), where the global solver's own
check ends the round as promptly. The test now times the round uncancelled
and asserts the fillet_section check point only for a cancel in the first
three quarters of it.

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

* Merge origin/main into cancel-wire: progress::cancelled now comes from #3754

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 22:05:31 +00:00
Kikomali 4eaa0adb86 Sketch text: place the block of lines by its top, middle, baseline or bottom (#12443) (#12447)
* Sketch text: place the block of lines by its top, middle, baseline or bottom

Sketch text could only be placed by the first line's baseline. Text now has a
vertical alignment: baseline (the default, as before), top (the capitals' tops),
middle (halfway between those and the last line's baseline) or bottom (the
bottom of the last line's descenders). It works on a path too.

`sketch.text` and `sketch.edit_text` take `vertical_align`. The Edit Text
dialog shows both alignments as symbol buttons: paragraph lines for left,
centre and right, and "Ag" with a line at the anchor for the vertical ones.
`ui_kit::Segments` gets `glyphs` to draw a symbol in place of a label.

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

* Sketch text: use the shared ui-text crate and format the alignment row

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

* Sketch text: theme tokens and the semibold face from where main keeps them

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 21:39:57 +00:00
Dahak Builds 5db0502eb7 Dialog buttons answer their first letter (#9841) (#9973)
* claim: #9841

* ui: dialog buttons answer their first letter (#9841)

ui_kit::Confirm answers the first letter of each visible button's
translated label when the new dialog_first_letter_buttons preference is
on (Preferences > General > Dialogs). The preference defaults off, so
typing keeps its old behaviour of doing nothing to the buttons; the
kit's accelerator is off until the host turns it on from the preference.

On: D=Don't Save, C=Cancel, S=Save in English, N/A/S in German. Matching
is on the typed glyph (Event::Text), so it is layout-independent, and on
the label after translation, so it follows the interface language.
Ambiguous first letters do nothing; a focused text field inside the
dialog keeps the keystroke; Enter and Escape keep their existing
meanings; the matched keystroke is consumed so it cannot reach the app
behind the dialog.

Written by Qwen.

---------

Co-authored-by: Dahak Builds <dahakbuilds@users.noreply.github.com>
2026-11-10 21:31:52 +00:00
Brett Lawson bf68955a1f solidcore: the trimmed-face tight box solves each cell on its own subpatch (#14692)
The trim search of SmFace::CalculateTightBoundingBox (WR-588) gave every
cell's directed solve the face's whole surface. A solve's control-net
guesses read every control point of the surface it is given, so a G2
corner patch (a 129 x 131 rational bicubic net) read all 16,899 points
in each of its thousands of cells along the trim: 3-4 s a face, 46 s
for the tight box of a box with eight such corners.

Each cell of a B-spline face open both ways is now solved on its exact
subpatch over the cell, with the face's owner and zone tolerance, and a
cell whose subpatch's control points reach no higher than the box so
far is skipped without a solve (the convex hull bounds the cell). A
cell whose subpatch gives no solution is divided like an undecided one
and, at the depth or cell limit, counts with that hull bound.

The G2-cornered box's tight box drops from 25-30 s to 4.5 s in
solidcore and agrees with the old one to 1e-20. A new work counter,
face_extreme_control_points, counts the control points the solves read;
the regression test's trimmed 40 x 40 sheet reads 40,562 now against
1,433,600 before.


Claude-Session: https://claude.ai/code/session_01SdcgEtwSrdwZxqTQp5MfnU

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 21:29:40 +00:00
Ehuntabi 0e7812992a Export Current View as DXF: option to put the drawing's zero at the model origin (#14727)
The view's DXF is centred on the camera target, so its 0,0 sits in the air.
The new optional zero parameter (target by default, or origin) shifts the
drawing so the model origin is 0,0, for machining from a part corner (#14686).

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 21:20:52 +00:00
bherbruck 3170219cec curve: certified length bracket; setbacks build only where certified to fit (#3184) (#14628)
Curve::length_bound returns a Bracket [chord, chord/cos φ] over Bernstein
pieces of the hodograph, halved until as narrow as asked or refused, named
(CurveFailure::LengthBelowFloor / LengthPastPieceCap). The setback checks
(overlapping_setbacks, set_backs, gather_ends) take the bracket's lower side,
asked as wide as the corner's tolerance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 21:19:49 +00:00
Brett Lawson 2e8098ec5c solidcore: remove SolidError::Nlib; crv_common reports typed NURBS failures (#14703)
* solidcore: claim removal of SolidError::Nlib (part of #13790)

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

* solidcore: remove SolidError::Nlib; crv_common reports typed NURBS failures

The last five SolidError::Nlib sites, in nurbs/crv_common.rs, now return
NurbsFailure::Input / NurbsFailure::Numerical through SolidError::nurbs, the
same kind (Failed), operation, comparison status (SM_ERR) and NL number (14,
11) as before. With no constructor left, SolidError::Nlib, SolidError::nlib
and NlibCode are removed, along with their arms in sm_status, kind,
operation, entity, is_nurbs_failure and the goldens hook nlib_number.

The NURBS ratchet now scans every library source of the crate (CRLF
normalised) for the variant, the builder and NlibCode, and crv_common pins
the status, kind and operation of its reachable NL_INP_ERR site.

Part of #13790.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 21:07:14 +00:00
Brett Lawson ac9b9125f7 Naming corpus test: build the heaviest designs first, each design's two builds on two workers (#14709)
The test's wall time was one design, oracle/77-fillet-variable-linear,
built twice back to back on one worker. Each design's two builds are now
two jobs, and a short measured list of the heaviest designs (kept next to
the test, checked against the corpus) is handed out first, so the two
builds of design 77 run at the same time on two workers while the rest of
the corpus runs beside them. What the test asserts is unchanged.

tests/action_matrix/costs.json is left alone: the action matrix shards by
it, and its load column is stale.


Claude-Session: https://claude.ai/code/session_01SdcgEtwSrdwZxqTQp5MfnU

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 20:52:29 +00:00
Dahak Builds 43d4f6fa7e Add model-anchored, screen-facing notes (#1631) (#6966)
* claim: #1631

* Add model-anchored, screen-facing notes (#1631)

* Format note commands

* Drop needless borrow in note command errors

* Add pending format change for notes

---------

Co-authored-by: Dahak Builds <dahakbuilds@users.noreply.github.com>
2026-11-10 20:52:02 +00:00
560 changed files with 28006 additions and 5524 deletions
+30 -16
View File
@@ -110,7 +110,7 @@ jobs:
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.98.0
- name: Install advisory scanner
run: cargo install cargo-deny --version 0.20.2 --locked
- name: Check locked dependencies
@@ -405,7 +405,7 @@ jobs:
timeout-minutes: 25
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.98.0
with:
components: rustfmt
# Main saves the cache; every other run restores it, read-only.
@@ -465,7 +465,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Size parallel compiles to the job's memory
run: scripts/ci/build-jobs.sh
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.98.0
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: quick-compile
@@ -481,7 +481,7 @@ jobs:
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.98.0
with:
components: clippy
# Main saves the cache; every other run restores it, read-only.
@@ -517,7 +517,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Size parallel compiles to the job's memory
run: scripts/ci/build-jobs.sh
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.98.0
# sccache lives on a Blacksmith sticky disk (an ext4 volume cloned from the last snapshot,
# no 10 GB limit and no download). Only main commits it; pull requests, the merge queue and
# forks mount a clone and never write back, so nothing they build can reach main's cache.
@@ -596,7 +596,7 @@ jobs:
sudo apt-get install -y --no-install-recommends mesa-vulkan-drivers libvulkan1
- name: Size parallel compiles to the job's memory
run: scripts/ci/build-jobs.sh
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.98.0
# sccache lives on a Blacksmith sticky disk (an ext4 volume cloned from the last snapshot,
# no 10 GB limit and no download). Only main commits it; pull requests, the merge queue and
# forks mount a clone and never write back, so nothing they build can reach main's cache.
@@ -723,7 +723,7 @@ jobs:
sudo apt-get install -y --no-install-recommends mesa-vulkan-drivers libvulkan1
- name: Size parallel compiles to the job's memory
run: scripts/ci/build-jobs.sh
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.98.0
# sccache lives on a Blacksmith sticky disk (an ext4 volume cloned from the last snapshot,
# no 10 GB limit and no download). Only main commits it; pull requests, the merge queue and
# forks mount a clone and never write back, so nothing they build can reach main's cache.
@@ -897,7 +897,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Size parallel compiles to the job's memory
run: scripts/ci/build-jobs.sh
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.98.0
# sccache lives on a Blacksmith sticky disk (an ext4 volume cloned from the last snapshot,
# no 10 GB limit and no download). Only main commits it; pull requests, the merge queue and
# forks mount a clone and never write back, so nothing they build can reach main's cache.
@@ -961,7 +961,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Size parallel compiles to the job's memory
run: scripts/ci/build-jobs.sh
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.98.0
# sccache lives on a Blacksmith sticky disk (an ext4 volume cloned from the last snapshot,
# no 10 GB limit and no download). Only main commits it; pull requests, the merge queue and
# forks mount a clone and never write back, so nothing they build can reach main's cache.
@@ -1020,7 +1020,7 @@ jobs:
VIEWPORT_TEST_REQUIRE_GPU: "1"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.98.0
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: windows-fxc
@@ -1044,7 +1044,7 @@ jobs:
timeout-minutes: 60
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.98.0
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: windows-startup
@@ -1093,9 +1093,23 @@ jobs:
echo "Waiting for $pending run(s) of this commit to finish."
sleep 30
done
# Runs with every job skipped (labels, before this job reported) are not a result.
latest=$(jq -r '[.[] | select(.status == "completed" and .conclusion != "skipped")]
| sort_by(.created_at) | last | "\(.conclusion) \(.html_url)"' <<<"$runs")
# The result is the newest run that ran checks, by when its latest attempt finished
# (updated_at), so a rerun that passed replaces its first attempt's failure. Only runs that
# passed or failed count (cancelled ones don't: a run replaced by a newer push or label, or a
# fork run that waited for approval and was cancelled), and label runs like this one don't
# either: their only job is ci, which repeats an older result, so counting them let one
# copied failure outlive the rerun that fixed it.
latest=""
for id in $(jq -r '[.[] | select(.status == "completed" and (.conclusion == "success" or .conclusion == "failure"))]
| sort_by(.updated_at) | reverse | .[].id' <<<"$runs"); do
ran=$(gh api "repos/$REPO/actions/runs/$id/jobs?filter=latest&per_page=100" \
--jq '[.jobs[] | select(.name != "ci" and .conclusion != "skipped" and .conclusion != null)] | length')
if [ "${ran:-0}" -gt 0 ]; then
latest=$(jq -r --argjson id "$id" '.[] | select(.id == $id) | "\(.conclusion) \(.html_url)"' <<<"$runs")
break
fi
done
[ -n "$latest" ] || latest="none (no run of this commit ran its checks)"
echo "The commit's latest run: $latest"
echo "A label started this run: \`ci\` repeats the commit's latest result ($latest)." >> "$GITHUB_STEP_SUMMARY"
test "${latest%% *}" = success
@@ -1187,7 +1201,7 @@ jobs:
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.98.0
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
@@ -1443,7 +1457,7 @@ jobs:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.98.0
with:
targets: riscv64gc-unknown-linux-gnu
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
+1 -1
View File
@@ -46,7 +46,7 @@ jobs:
packages: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.98.0
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
timeout-minutes: 120
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.98.0
- name: Build the CLI
run: cargo build --profile ci -p solidcraft-cli
- name: Import and check every entry
+1 -1
View File
@@ -70,7 +70,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Size parallel compiles to the job's memory
run: scripts/ci/build-jobs.sh
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.98.0
# Only main and develop commit the cache; pull requests and forks mount a clone and never
# write back.
- name: Mount the compiler cache
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
ref: main
# main goes through the merge queue; this deploy key may push past it.
ssh-key: ${{ secrets.GENERATED_FILES_DEPLOY_KEY }}
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.98.0
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: generated-files
+1 -1
View File
@@ -129,7 +129,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Size parallel compiles to the job's memory
run: scripts/ci/build-jobs.sh
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.98.0
# Only pushes to main and develop commit the cache; pull requests, merge groups and forks
# mount a clone and never write back.
- name: Mount the compiler cache
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.98.0
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: lists
+1 -1
View File
@@ -38,7 +38,7 @@ jobs:
timeout-minutes: 60
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.98.0
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
+6 -6
View File
@@ -122,7 +122,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.98.0
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-icons
@@ -161,7 +161,7 @@ jobs:
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.98.0
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-linux-${{ matrix.arch }}
@@ -369,7 +369,7 @@ jobs:
envs: "SOLIDCRAFT_VERSION SOLIDCRAFT_BUILD_DATE"
prepare: |
pkg install -y bash git curl pkgconf libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain 1.98.0
run: |
set -e
. "$HOME/.cargo/env"
@@ -404,7 +404,7 @@ jobs:
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.98.0
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
@@ -502,7 +502,7 @@ jobs:
with:
name: icons
path: icons
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.98.0
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
@@ -633,7 +633,7 @@ jobs:
TRUNK_VERSION: 0.21.14
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.98.0
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
+5 -2
View File
@@ -43,8 +43,11 @@ contributors without write access can't push to them. To update one:
## Get it running (about 10 minutes on a fast machine)
You need stable Rust 1.95 or newer (required by egui/eframe 0.36). For the web
build you also need `rustup target add wasm32-unknown-unknown` and [`trunk`](https://github.com/trunk-rs/trunk)
You need [rustup](https://rustup.rs/). The repository pins Rust 1.98.0 in
`rust-toolchain.toml`, the version CI builds with, and rustup installs it with rustfmt, clippy and
the `wasm32-unknown-unknown` target the first time you run `cargo` here. The code itself builds on
Rust 1.95 or newer (`rust-version` in Cargo.toml, required by egui/eframe 0.36). For the web build
you also need [`trunk`](https://github.com/trunk-rs/trunk)
([documentation](https://trunk-rs.github.io/trunk/)).
```sh
Generated
+33 -33
View File
@@ -4259,7 +4259,7 @@ dependencies = [
[[package]]
name = "solidcraft"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"block2 0.6.2",
"eframe",
@@ -4302,11 +4302,11 @@ dependencies = [
[[package]]
name = "solidcraft-3dx-client"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
[[package]]
name = "solidcraft-cli"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"image",
"log",
@@ -4322,11 +4322,11 @@ dependencies = [
[[package]]
name = "solidcraft-color"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
[[package]]
name = "solidcraft-control"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"getrandom 0.3.4",
"serde",
@@ -4335,7 +4335,7 @@ dependencies = [
[[package]]
name = "solidcraft-control-egui"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"egui",
"image",
@@ -4345,7 +4345,7 @@ dependencies = [
[[package]]
name = "solidcraft-derive"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"proc-macro2",
"quote",
@@ -4354,7 +4354,7 @@ dependencies = [
[[package]]
name = "solidcraft-doc"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"log",
"serde",
@@ -4376,7 +4376,7 @@ dependencies = [
[[package]]
name = "solidcraft-dynamics"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"serde",
"serde_json",
@@ -4387,7 +4387,7 @@ dependencies = [
[[package]]
name = "solidcraft-engine"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"image",
"log",
@@ -4413,7 +4413,7 @@ dependencies = [
[[package]]
name = "solidcraft-geom"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"serde",
"serde_json",
@@ -4426,7 +4426,7 @@ dependencies = [
[[package]]
name = "solidcraft-gpu"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"image",
"log",
@@ -4451,7 +4451,7 @@ dependencies = [
[[package]]
name = "solidcraft-io"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"cadmpeg-core",
"cadmpeg-ir",
@@ -4479,7 +4479,7 @@ dependencies = [
[[package]]
name = "solidcraft-kernel"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"log",
"proptest",
@@ -4500,7 +4500,7 @@ dependencies = [
[[package]]
name = "solidcraft-look"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"egui",
"log",
@@ -4512,7 +4512,7 @@ dependencies = [
[[package]]
name = "solidcraft-mcp"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"image",
"serde_json",
@@ -4526,14 +4526,14 @@ dependencies = [
[[package]]
name = "solidcraft-mcp-server"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"serde_json",
]
[[package]]
name = "solidcraft-profile"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"serde_json",
"web-time",
@@ -4541,7 +4541,7 @@ dependencies = [
[[package]]
name = "solidcraft-render"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"flate2",
"image",
@@ -4564,7 +4564,7 @@ dependencies = [
[[package]]
name = "solidcraft-sketch"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"harfrust",
"serde",
@@ -4578,7 +4578,7 @@ dependencies = [
[[package]]
name = "solidcraft-smlib"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"flate2",
"log",
@@ -4592,18 +4592,18 @@ dependencies = [
[[package]]
name = "solidcraft-space-mouse"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"serde",
]
[[package]]
name = "solidcraft-testkit"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
[[package]]
name = "solidcraft-theme"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"egui",
"serde",
@@ -4613,7 +4613,7 @@ dependencies = [
[[package]]
name = "solidcraft-ui-egui"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"arboard",
"egui",
@@ -4655,7 +4655,7 @@ dependencies = [
[[package]]
name = "solidcraft-ui-kit"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"arboard",
"egui",
@@ -4678,14 +4678,14 @@ dependencies = [
[[package]]
name = "solidcraft-ui-registry"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"egui",
]
[[package]]
name = "solidcraft-ui-text"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"log",
"serde_json",
@@ -4695,7 +4695,7 @@ dependencies = [
[[package]]
name = "solidcraft-viewport"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"image",
"serde",
@@ -4707,7 +4707,7 @@ dependencies = [
[[package]]
name = "solidcraft-viewport-egui"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"egui",
"egui-wgpu",
@@ -4718,7 +4718,7 @@ dependencies = [
[[package]]
name = "solidcraft-viewport-gpu"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"log",
"naga",
@@ -4734,7 +4734,7 @@ dependencies = [
[[package]]
name = "solidcraft-web"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"eframe",
"egui",
@@ -6220,7 +6220,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"flate2",
"proc-macro2",
+1 -1
View File
@@ -6,7 +6,7 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["solidcore", "vendor"]
[workspace.package]
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
+2 -2
View File
@@ -35,12 +35,12 @@ fn inspect_schema(args: &[&str], env: Option<&str>) -> Result<Value, String> {
fn compact_output_is_off_unless_asked() {
let plain = inspect_schema(&[], None).unwrap();
assert_eq!(plain["properties"]["sketches"]["type"], "boolean", "{plain}");
assert!(plain["properties"].get("bodies").is_none(), "{plain}");
assert_eq!(plain["properties"]["bodies"]["type"], json!(["boolean", "string"]), "no list of names: {plain}");
assert_eq!(inspect_schema(&[], Some("0")).unwrap(), plain);
for (args, env) in [(&["--compact"][..], None), (&[][..], Some("1")), (&[][..], Some("true"))] {
let compact = inspect_schema(args, env).unwrap();
assert!(compact["properties"]["bodies"].is_object(), "{args:?} {env:?}: {compact}");
assert!(compact["properties"]["bodies"]["type"].as_array().is_some_and(|t| t.contains(&json!("array"))), "{args:?} {env:?}: {compact}");
}
let e = inspect_schema(&[], Some("maybe")).unwrap_err();
+37 -6
View File
@@ -361,23 +361,54 @@ const UNSTABLE: [(&str, &str); 1] = [(
"the halves of each swept wall lie level along every axis, so which is `#1` and which `#2` follows the order the kernel gives the faces in, which changes from one build to the next",
)];
/// The designs that take longest to build, heaviest first, with one build's time (ci profile,
/// CLI `run`, one design at a time, on P-cores, 2026-11-10). The test hands them out first, and
/// each design's two builds go to two workers, so a heavy design is never built twice in a row on
/// one worker while the rest wait. Every other design took under 3.6 s. Only the order of the
/// work depends on this list: a stale entry makes the test slower, never wrong. Re-measure when a
/// design here gets much faster, or one not listed takes over 5 s.
const HEAVIEST: [&str; 6] = [
"oracle/77-fillet-variable-linear", // 35.6 s
"oracle/83-boundary-loop-thicken", // 12.4 s
"regressions/closed_sweep_torus_cut", // 6.8 s
"oracle/41-tube-radial-holes", // 4.6 s
"oracle/42-overlapping-patterns", // 4.2 s
"oracle/35-fillet-curved-curved", // 3.2 s
];
#[test]
fn corpus_designs_name_alike_every_build_and_fail_where_a_reference_is_gone() {
let designs = designs();
// Every design is built twice, as two jobs: (design, which build). The heaviest go first.
for heavy in HEAVIEST {
assert!(designs.iter().any(|(name, _)| name == heavy), "HEAVIEST lists `{heavy}`, which is not in the corpus any more");
}
let rank = |name: &str| HEAVIEST.iter().position(|h| *h == name).unwrap_or(HEAVIEST.len());
let mut jobs: Vec<(usize, usize)> = (0..designs.len()).flat_map(|d| [(d, 0), (d, 1)]).collect();
jobs.sort_by_key(|&(d, build)| (rank(&designs[d].0), d, build));
let next = std::sync::atomic::AtomicUsize::new(0);
let results = std::sync::Mutex::new(Vec::new());
let workers = std::thread::available_parallelism().map_or(4, |n| n.get()).min(designs.len().max(1));
let builds = std::sync::Mutex::new(std::collections::BTreeMap::new());
let workers = std::thread::available_parallelism().map_or(4, |n| n.get()).min(jobs.len().max(1));
std::thread::scope(|scope| {
for _ in 0..workers {
scope.spawn(|| {
while let Some((name, source)) = designs.get(next.fetch_add(1, std::sync::atomic::Ordering::Relaxed)) {
let got = load(source).and_then(|first| Ok((build(&first), build(&load(source)?))));
results.lock().unwrap_or_else(|e| e.into_inner()).push((name.clone(), got));
while let Some(&(d, which)) = jobs.get(next.fetch_add(1, std::sync::atomic::Ordering::Relaxed)) {
let got = load(&designs[d].1).map(|s| build(&s));
builds.lock().unwrap_or_else(|e| e.into_inner()).insert((d, which), got);
}
});
}
});
let mut results = results.into_inner().unwrap_or_else(|e| e.into_inner());
let mut builds = builds.into_inner().unwrap_or_else(|e| e.into_inner());
let mut results: Vec<(String, Result<(Build, Build), String>)> = designs
.iter()
.enumerate()
.map(|(d, (name, _))| {
let first = builds.remove(&(d, 0)).unwrap();
let second = builds.remove(&(d, 1)).unwrap();
(name.clone(), first.and_then(|first| Ok((first, second?))))
})
.collect();
results.sort_by(|a, b| a.0.cmp(&b.0));
let mut problems = Vec::new();
for (name, got) in &results {
@@ -0,0 +1,34 @@
{
"en": {
"cmd.assembly_constraint_create": "Constrain",
"cmd.assembly_constraint_edit": "Edit Constraint"
},
"de": {
"cmd.assembly_constraint_create": "Abhängigkeit",
"cmd.assembly_constraint_edit": "Abhängigkeit bearbeiten"
},
"ja": {
"cmd.assembly_constraint_create": "拘束",
"cmd.assembly_constraint_edit": "拘束を編集"
},
"zh": {
"cmd.assembly_constraint_create": "约束",
"cmd.assembly_constraint_edit": "编辑约束"
},
"ko": {
"cmd.assembly_constraint_create": "구속",
"cmd.assembly_constraint_edit": "구속 편집"
},
"vi": {
"cmd.assembly_constraint_create": "Ràng buộc",
"cmd.assembly_constraint_edit": "Sửa ràng buộc"
},
"ar": {
"cmd.assembly_constraint_create": "تقييد",
"cmd.assembly_constraint_edit": "تحرير التقييد"
},
"he": {
"cmd.assembly_constraint_create": "אילוץ",
"cmd.assembly_constraint_edit": "עריכת אילוץ"
}
}
@@ -0,0 +1,80 @@
{
"en": {
"cmd.construct.axis": "Axis",
"cmd.construct.plane": "Plane",
"cmd.construct.point": "Point",
"help.construct.axis": "Creates a construction axis with the method your picks call for: along an edge or line, through a hole or round face, where two planes meet, through two points, or square to a face through a point. Use it when you know the references and not the method.",
"help.construct.plane": "Creates a construction plane with the method your picks call for: offset from a plane, through a point, between two planes, through lines or points, tangent to a curved face, or square to a path. Use it when you know the references and not the method.",
"help.construct.point": "Creates a construction point with the method your picks call for: at a vertex, at the centre of a circle or sphere, along an edge, where two lines or three planes meet, or where a line crosses a plane. Use it when you know the references and not the method.",
"ui.construct_auto.chosen": "Chosen from your picks. Pick another reference, or take one out, to change the method.",
"ui.construct_auto.no_fit": "Pick other references: no method builds from these.",
"ui.construct_auto.pick": "Pick what to build from: the method follows from your picks.",
"ui.construct_auto.refs": "References",
"ui.construct_auto.select": "Select references to build from first"
},
"ja": {
"cmd.construct.axis": "軸",
"cmd.construct.plane": "平面",
"cmd.construct.point": "点",
"help.construct.axis": "選択に応じた方法で構築軸を作ります。エッジや線に沿って、穴や丸い面を通って、2つの平面の交線に、2点を通って、または点を通り面に垂直に作ります。参照は決まっているが方法を選びたくないときに使います。",
"help.construct.plane": "選択に応じた方法で構築平面を作ります。平面からのオフセット、点を通る、2つの平面の中間、線や点を通る、曲面に接する、またはパスに垂直に作ります。参照は決まっているが方法を選びたくないときに使います。",
"help.construct.point": "選択に応じた方法で構築点を作ります。頂点、円や球の中心、エッジ上、2本の線や3つの平面の交点、または線が平面と交わる位置に作ります。参照は決まっているが方法を選びたくないときに使います。",
"ui.construct_auto.chosen": "選択から方法を選びました。参照を追加または削除すると方法が変わります。",
"ui.construct_auto.no_fit": "別の参照を選択してください。これらから作れる方法がありません。",
"ui.construct_auto.pick": "作成元を選択してください。方法は選択から決まります。",
"ui.construct_auto.refs": "参照",
"ui.construct_auto.select": "先に作成元の参照を選択してください"
},
"zh": {
"cmd.construct.axis": "轴",
"cmd.construct.plane": "平面",
"cmd.construct.point": "点",
"help.construct.axis": "按所选对象对应的方法创建构造轴:沿边或直线、穿过孔或圆形面、两平面的交线、穿过两点,或过一点垂直于面。在已知参照但不想选择方法时使用。",
"help.construct.plane": "按所选对象对应的方法创建构造平面:偏移自平面、过一点、两平面之间、过直线或点、与曲面相切,或垂直于路径。在已知参照但不想选择方法时使用。",
"help.construct.point": "按所选对象对应的方法创建构造点:在顶点、圆或球的中心、沿边、两直线或三个平面的交点,或直线与平面的交点。在已知参照但不想选择方法时使用。",
"ui.construct_auto.chosen": "已根据所选对象选定方法。添加或移除参照即可更换方法。",
"ui.construct_auto.no_fit": "请选择其他参照:没有方法能由这些对象创建。",
"ui.construct_auto.pick": "选择创建依据:方法由所选对象决定。",
"ui.construct_auto.refs": "参照",
"ui.construct_auto.select": "请先选择创建依据的参照"
},
"ar": {
"cmd.construct.axis": "محور",
"cmd.construct.plane": "مستوى",
"cmd.construct.point": "نقطة",
"help.construct.axis": "ينشئ محورًا إنشائيًا بالطريقة التي تناسب ما تختاره: على امتداد حافة أو خط، أو عبر ثقب أو وجه دائري، أو عند تقاطع مستويين، أو عبر نقطتين، أو عموديًا على وجه عبر نقطة. استخدمه عندما تعرف المراجع ولا تعرف الطريقة.",
"help.construct.plane": "ينشئ مستوى إنشائيًا بالطريقة التي تناسب ما تختاره: إزاحة عن مستوى، أو عبر نقطة، أو بين مستويين، أو عبر خطوط أو نقاط، أو مماسًا لوجه منحنٍ، أو عموديًا على مسار. استخدمه عندما تعرف المراجع ولا تعرف الطريقة.",
"help.construct.point": "ينشئ نقطة إنشائية بالطريقة التي تناسب ما تختاره: عند رأس، أو في مركز دائرة أو كرة، أو على امتداد حافة، أو عند تقاطع خطين أو ثلاثة مستويات، أو حيث يقطع خط مستوى. استخدمه عندما تعرف المراجع ولا تعرف الطريقة.",
"ui.construct_auto.chosen": "اختيرت الطريقة من اختياراتك. اختر مرجعًا آخر أو أزل مرجعًا لتغيير الطريقة.",
"ui.construct_auto.no_fit": "اختر مراجع أخرى: لا توجد طريقة تبني من هذه المراجع.",
"ui.construct_auto.pick": "اختر ما تبني منه: الطريقة تتبع اختياراتك.",
"ui.construct_auto.refs": "المراجع",
"ui.construct_auto.select": "اختر أولًا المراجع التي تبني منها"
},
"he": {
"cmd.construct.axis": "ציר",
"cmd.construct.plane": "מישור",
"cmd.construct.point": "נקודה",
"help.construct.axis": "יוצר ציר עזר בשיטה שמתאימה למה שבחרת: לאורך קצה או קו, דרך חור או פאה עגולה, במפגש של שני מישורים, דרך שתי נקודות, או ניצב לפאה דרך נקודה. השתמש בו כשאתה יודע את ההפניות ולא את השיטה.",
"help.construct.plane": "יוצר מישור עזר בשיטה שמתאימה למה שבחרת: בהיסט ממישור, דרך נקודה, בין שני מישורים, דרך קווים או נקודות, משיק לפאה מעוקלת, או ניצב למסלול. השתמש בו כשאתה יודע את ההפניות ולא את השיטה.",
"help.construct.point": "יוצר נקודת עזר בשיטה שמתאימה למה שבחרת: בקודקוד, במרכז של מעגל או כדור, לאורך קצה, במפגש של שני קווים או שלושה מישורים, או במקום שבו קו חוצה מישור. השתמש בו כשאתה יודע את ההפניות ולא את השיטה.",
"ui.construct_auto.chosen": "השיטה נבחרה לפי הבחירות שלך. בחר הפניה נוספת, או הסר אחת, כדי לשנות את השיטה.",
"ui.construct_auto.no_fit": "בחר הפניות אחרות: אין שיטה שבונה מאלה.",
"ui.construct_auto.pick": "בחר ממה לבנות: השיטה נקבעת לפי הבחירות שלך.",
"ui.construct_auto.refs": "הפניות",
"ui.construct_auto.select": "בחר קודם את ההפניות לבנייה"
},
"vi": {
"cmd.construct.axis": "Trục",
"cmd.construct.plane": "Mặt phẳng",
"cmd.construct.point": "Điểm",
"help.construct.axis": "Tạo trục dựng theo phương pháp phù hợp với những gì bạn chọn: dọc theo cạnh hoặc đường, qua lỗ hoặc mặt tròn, nơi hai mặt phẳng giao nhau, qua hai điểm, hoặc vuông góc với mặt qua một điểm. Dùng khi bạn biết các tham chiếu nhưng không biết phương pháp.",
"help.construct.plane": "Tạo mặt phẳng dựng theo phương pháp phù hợp với những gì bạn chọn: cách một mặt phẳng, qua một điểm, giữa hai mặt phẳng, qua các đường hoặc điểm, tiếp xúc với mặt cong, hoặc vuông góc với đường dẫn. Dùng khi bạn biết các tham chiếu nhưng không biết phương pháp.",
"help.construct.point": "Tạo điểm dựng theo phương pháp phù hợp với những gì bạn chọn: tại đỉnh, tại tâm đường tròn hoặc mặt cầu, dọc theo cạnh, nơi hai đường hoặc ba mặt phẳng giao nhau, hoặc nơi một đường cắt mặt phẳng. Dùng khi bạn biết các tham chiếu nhưng không biết phương pháp.",
"ui.construct_auto.chosen": "Đã chọn phương pháp theo lựa chọn của bạn. Chọn thêm hoặc bỏ một tham chiếu để đổi phương pháp.",
"ui.construct_auto.no_fit": "Hãy chọn tham chiếu khác: không phương pháp nào dựng được từ các tham chiếu này.",
"ui.construct_auto.pick": "Chọn những gì để dựng: phương pháp theo lựa chọn của bạn.",
"ui.construct_auto.refs": "Tham chiếu",
"ui.construct_auto.select": "Hãy chọn tham chiếu để dựng trước"
}
}
@@ -0,0 +1,34 @@
{
"en": {
"prefs.dialog_first_letter_buttons": "Dialog buttons answer their first letter",
"prefs.dialog_first_letter_buttons_help": "With a dialog open, typing the first letter of a button's label presses it: O for OK, C for Cancel. A letter that fits two buttons does nothing, and a focused text field keeps the keystroke. Off: typing does nothing to the buttons, as before."
},
"ar": {
"prefs.dialog_first_letter_buttons": "أزرار نوافذ الحوار تستجيب لحرفها الأول",
"prefs.dialog_first_letter_buttons_help": "عند فتح نافذة حوار، كتابة الحرف الأول من تسمية الزر تضغطه: حرف «ا» يضغط زر «إلغاء» مثلًا. الحرف الذي يناسب زرَّين لا يفعل شيئًا، وحقل النص المركَّز عليه يحتفظ بالتضغطة. عند الإيقاف: الكتابة لا تفعل شيئًا للأزرار، كما كان سابقًا."
},
"de": {
"prefs.dialog_first_letter_buttons": "Dialogschaltflächen reagieren auf ihren Anfangsbuchstaben",
"prefs.dialog_first_letter_buttons_help": "Ist ein Dialog geöffnet, drückt der Anfangsbuchstabe einer Beschriftung die zugehörige Schaltfläche: O für OK, A für Abbrechen. Ein Buchstabe, der zu zwei Schaltflächen passt, tut nichts, und ein fokussiertes Textfeld behält die Tasteneingabe. Aus: Eingaben drücken keine Schaltfläche, wie bisher."
},
"he": {
"prefs.dialog_first_letter_buttons": "כפתורי תיבות דו-שיח נענים לאות הראשונה שלהם",
"prefs.dialog_first_letter_buttons_help": "כשתיבת דו-שיח פתוחה, הקלדת האות הראשונה של שם הכפתור לוחצת עליו: האות «ב» לוחצת על «ביטול», למשל. אות שמתאימה לשני כפתורים אינה עושה דבר, ושדה טקסט שבו נמצא הסמן שומר את ההקלדה. כבוי: הקלדה אינה משפיעה על הכפתורים, כמו קודם."
},
"ja": {
"prefs.dialog_first_letter_buttons": "ダイアログのボタンはラベルの先頭文字で押せる",
"prefs.dialog_first_letter_buttons_help": "ダイアログが開いているとき、ボタンのラベルの先頭文字を入力するとそのボタンが押されます:「キ」で「キャンセル」など。2 つのボタンに当てはまる文字は何もしません。フォーカス中のテキストフィールドはそのまま入力を受け取ります。オフ:従来どおり、入力ではボタンは押されません。"
},
"ko": {
"prefs.dialog_first_letter_buttons": "다이얼로그 단추가 첫 글자에 반응",
"prefs.dialog_first_letter_buttons_help": "다이얼로그가 열려 있을 때 단추 레이블의 첫 글자를 입력하면 그 단추를 누릅니다: 예를 들어 「ㅊ」은 「취소」를 누릅니다. 두 단추에 모두 해당하는 글자는 아무 동작을 하지 않으며, 포커스된 텍스트 필드는 입력을 그대로 받습니다. 끔: 기존처럼 입력이 단추에 영향을 주지 않습니다."
},
"vi": {
"prefs.dialog_first_letter_buttons": "Nút trong hộp thoại trả lời chữ cái đầu của nhãn",
"prefs.dialog_first_letter_buttons_help": "Khi một hộp thoại đang mở, gõ chữ cái đầu của nhãn một nút sẽ bấm nút đó: chữ H bấm nút Hủy bỏ, chẳng hạn. Một chữ khớp với hai nút không thực hiện gì, và trường văn bản đang tiêu điểm giữ lại phím gõ. Tắt: gõ phím không bấm nút, như trước đây."
},
"zh": {
"prefs.dialog_first_letter_buttons": "对话框按钮响应其首字母",
"prefs.dialog_first_letter_buttons_help": "打开对话框时,按下按钮标签的首字母即可触发该按钮:如“取”对应“取消”。一个字母若匹配两个按钮则不起作用,获得焦点的文本框保留按键输入。关闭时:按键不影响按钮,与以前相同。"
}
}
@@ -0,0 +1,50 @@
{
"en": {
"file_load.opened_behind": "{name} opened in a new tab",
"operations.waiting": "Ready",
"prefs.file_operations_wait": "Wait for files to open",
"prefs.file_operations_wait_note": "While a file opens or is inserted, or links are read again, the design waits behind a card with the file's progress and Cancel. Off: the file shows in the operations panel and you can keep working; an insert lands once you finish what you are doing, and a file opened meanwhile goes in a new tab."
},
"de": {
"file_load.opened_behind": "{name} in einem neuen Tab geöffnet",
"operations.waiting": "Bereit",
"prefs.file_operations_wait": "Auf das Öffnen von Dateien warten",
"prefs.file_operations_wait_note": "Während eine Datei geöffnet oder eingefügt wird oder Verknüpfungen neu gelesen werden, wartet die Konstruktion hinter einer Karte mit dem Fortschritt und Abbrechen. Aus: Die Datei erscheint im Vorgangsbereich und Sie können weiterarbeiten; ein Einfügen wird übernommen, sobald Sie fertig sind, und eine inzwischen geöffnete Datei kommt in einen neuen Tab."
},
"ja": {
"file_load.opened_behind": "{name} を新しいタブで開きました",
"operations.waiting": "準備完了",
"prefs.file_operations_wait": "ファイルを開く間は待つ",
"prefs.file_operations_wait_note": "ファイルを開く・挿入する・リンクを読み直す間、デザインは進行状況とキャンセルを示すカードの後ろで待機します。オフ:ファイルは処理パネルに表示され、作業を続けられます。挿入は作業が終わってから反映され、その間に開いたファイルは新しいタブに入ります。"
},
"ko": {
"file_load.opened_behind": "{name}을(를) 새 탭에서 열었습니다",
"operations.waiting": "준비됨",
"prefs.file_operations_wait": "파일이 열릴 때까지 기다리기",
"prefs.file_operations_wait_note": "파일을 열거나 삽입하거나 링크를 다시 읽는 동안 디자인이 진행 상황과 취소가 있는 카드 뒤에서 기다립니다. 끄면: 파일이 작업 패널에 표시되고 계속 작업할 수 있습니다. 삽입은 하던 작업을 마친 뒤 반영되고, 그 사이에 연 파일은 새 탭에 열립니다."
},
"zh": {
"file_load.opened_behind": "已在新标签页中打开{name}",
"operations.waiting": "就绪",
"prefs.file_operations_wait": "等待文件打开",
"prefs.file_operations_wait_note": "打开或插入文件、重新读取链接时,设计会在显示进度和取消按钮的卡片后等待。关闭:文件显示在操作面板中,您可以继续工作;插入会在您完成当前操作后生效,期间打开的文件会放在新标签页中。"
},
"ar": {
"file_load.opened_behind": "فُتح {name} في علامة تبويب جديدة",
"operations.waiting": "جاهز",
"prefs.file_operations_wait": "انتظار فتح الملفات",
"prefs.file_operations_wait_note": "أثناء فتح ملف أو إدراجه أو إعادة قراءة الروابط، ينتظر التصميم خلف بطاقة تعرض التقدم وزر الإلغاء. عند الإيقاف: يظهر الملف في لوحة العمليات ويمكنك متابعة العمل؛ يُطبَّق الإدراج بعد أن تنتهي مما تفعله، ويُفتح الملف المفتوح في الأثناء في علامة تبويب جديدة."
},
"he": {
"file_load.opened_behind": "{name} נפתח בכרטיסייה חדשה",
"operations.waiting": "מוכן",
"prefs.file_operations_wait": "המתנה לפתיחת קבצים",
"prefs.file_operations_wait_note": "בזמן שקובץ נפתח או מוכנס, או שקישורים נקראים מחדש, התכנון ממתין מאחורי כרטיס עם ההתקדמות וביטול. כבוי: הקובץ מוצג בחלונית הפעולות ואפשר להמשיך לעבוד; הוספה נכנסת לאחר שתסיימו את מה שאתם עושים, וקובץ שנפתח בינתיים נפתח בכרטיסייה חדשה."
},
"vi": {
"file_load.opened_behind": "Đã mở {name} trong thẻ mới",
"operations.waiting": "Sẵn sàng",
"prefs.file_operations_wait": "Chờ tệp mở xong",
"prefs.file_operations_wait_note": "Khi một tệp đang mở, đang chèn hoặc các liên kết được đọc lại, thiết kế chờ sau một thẻ hiện tiến độ và nút Hủy. Tắt: tệp hiện trong bảng thao tác và bạn có thể tiếp tục làm việc; phần chèn được áp dụng khi bạn làm xong việc đang làm, và tệp mở trong lúc đó được đặt vào thẻ mới."
}
}
@@ -0,0 +1,42 @@
{
"en": {
"prefs.assemblies": "Assemblies",
"prefs.joint_drag_trackball": "Turn parts on a ball joint like a trackball when dragged",
"prefs.joint_drag_trackball_help": "Dragging a part that a ball joint holds turns it by the smallest turn about the joint, as if rolling a ball the size of the part. Off: the point you grabbed follows the pointer exactly, and the part may also spin about it."
},
"ar": {
"prefs.assemblies": "التجميعات",
"prefs.joint_drag_trackball": "تدوير الأجزاء على مفصل كروي مثل كرة التتبع عند سحبها",
"prefs.joint_drag_trackball_help": "سحب جزء يثبّته مفصل كروي يدوّره بأصغر دوران حول المفصل، كأنك تدحرج كرة بحجم الجزء. عند الإيقاف: تتبع النقطة التي أمسكتها المؤشر تمامًا، وقد يدور الجزء أيضًا حولها."
},
"de": {
"prefs.assemblies": "Baugruppen",
"prefs.joint_drag_trackball": "Teile an einem Kugelgelenk beim Ziehen wie einen Trackball drehen",
"prefs.joint_drag_trackball_help": "Wenn Sie ein Teil ziehen, das ein Kugelgelenk hält, dreht es sich um die kleinste Drehung um das Gelenk, als rollten Sie eine Kugel von der Größe des Teils. Aus: Der gegriffene Punkt folgt genau dem Zeiger, und das Teil kann sich zusätzlich um ihn drehen."
},
"he": {
"prefs.assemblies": "מכלולים",
"prefs.joint_drag_trackball": "סיבוב חלקים על מפרק כדורי כמו כדור עקיבה בעת גרירה",
"prefs.joint_drag_trackball_help": "גרירת חלק שמפרק כדורי מחזיק מסובבת אותו בסיבוב הקטן ביותר סביב המפרק, כאילו מגלגלים כדור בגודל החלק. כבוי: הנקודה שתפסת עוקבת אחרי המצביע במדויק, והחלק עלול גם להסתובב סביבה."
},
"ja": {
"prefs.assemblies": "アセンブリ",
"prefs.joint_drag_trackball": "ボールジョイントの部品をドラッグするとトラックボールのように回転",
"prefs.joint_drag_trackball_help": "ボールジョイントで保持された部品をドラッグすると、部品の大きさのボールを転がすように、ジョイントを中心に最小の回転で回ります。オフ:つかんだ点がポインターに正確に追従し、部品がその点を軸にさらに回転することがあります。"
},
"ko": {
"prefs.assemblies": "어셈블리",
"prefs.joint_drag_trackball": "볼 조인트의 부품을 끌 때 트랙볼처럼 회전",
"prefs.joint_drag_trackball_help": "볼 조인트가 잡고 있는 부품을 끌면 부품 크기의 공을 굴리듯 조인트를 중심으로 가장 작은 회전만큼 돌아갑니다. 끄면: 잡은 점이 포인터를 정확히 따라가고, 부품이 그 점을 축으로 더 돌 수도 있습니다."
},
"vi": {
"prefs.assemblies": "Cụm lắp ráp",
"prefs.joint_drag_trackball": "Xoay chi tiết trên khớp cầu như trackball khi kéo",
"prefs.joint_drag_trackball_help": "Kéo một chi tiết do khớp cầu giữ sẽ xoay nó theo góc xoay nhỏ nhất quanh khớp, như lăn một quả bóng có kích thước bằng chi tiết. Tắt: điểm bạn nắm theo đúng con trỏ, và chi tiết có thể tự xoay quanh điểm đó."
},
"zh": {
"prefs.assemblies": "装配",
"prefs.joint_drag_trackball": "拖动球关节上的零件时像轨迹球一样旋转",
"prefs.joint_drag_trackball_help": "拖动由球关节固定的零件时,它绕关节以最小的转动旋转,就像滚动一个与零件同样大小的球。关闭:抓取的点精确跟随指针,零件还可能绕该点自旋。"
}
}
+38
View File
@@ -0,0 +1,38 @@
{
"ar": {
"cmd.note_add": "إضافة ملاحظة",
"cmd.note_delete": "حذف ملاحظة",
"cmd.note_list": "عرض الملاحظات",
"cmd.note_update": "تحرير الملاحظة"
},
"en": {
"cmd.note_add": "Add Note",
"cmd.note_delete": "Delete Note",
"cmd.note_list": "List Notes",
"cmd.note_update": "Edit Note"
},
"he": {
"cmd.note_add": "הוספת הערה",
"cmd.note_delete": "מחיקת הערה",
"cmd.note_list": "הצגת הערות",
"cmd.note_update": "עריכת הערה"
},
"ja": {
"cmd.note_add": "メモを追加",
"cmd.note_delete": "メモを削除",
"cmd.note_list": "メモを一覧表示",
"cmd.note_update": "メモを編集"
},
"vi": {
"cmd.note_add": "Thêm ghi chú",
"cmd.note_delete": "Xóa ghi chú",
"cmd.note_list": "Liệt kê ghi chú",
"cmd.note_update": "Chỉnh sửa ghi chú"
},
"zh": {
"cmd.note_add": "添加备注",
"cmd.note_delete": "删除备注",
"cmd.note_list": "列出备注",
"cmd.note_update": "编辑备注"
}
}
@@ -0,0 +1,8 @@
{
"en": { "render.error_product_placement": "invalid product placement" },
"ar": { "render.error_product_placement": "موضع منتج غير صالح" },
"he": { "render.error_product_placement": "מיקום מוצר לא תקין" },
"ja": { "render.error_product_placement": "製品の配置が無効です" },
"vi": { "render.error_product_placement": "vị trí đặt sản phẩm không hợp lệ" },
"zh": { "render.error_product_placement": "产品放置无效" }
}
@@ -0,0 +1,38 @@
{
"en": {
"sketch.move_released": "Moved: {count} constraints tying it to what stays were let go ({names}).",
"sketch.move_released_one": "Moved: {count} constraint tying it to what stays was let go ({names})."
},
"ar": {
"sketch.move_released": "تم النقل: أُزيلت {count} قيود كانت تربطه بما بقي ({names}).",
"sketch.move_released_one": "تم النقل: أُزيل {count} قيد كان يربطه بما بقي ({names})."
},
"de": {
"sketch.move_released": "Verschoben: {count} Abhängigkeiten zum Rest wurden gelöst ({names}).",
"sketch.move_released_one": "Verschoben: {count} Abhängigkeit zum Rest wurde gelöst ({names})."
},
"es": {
"sketch.move_released": "Movido: se soltaron {count} restricciones que lo unían a lo que queda ({names}).",
"sketch.move_released_one": "Movido: se soltó {count} restricción que lo unía a lo que queda ({names})."
},
"fr": {
"sketch.move_released": "Déplacé : {count} contraintes qui le liaient au reste ont été relâchées ({names}).",
"sketch.move_released_one": "Déplacé : {count} contrainte qui le liait au reste a été relâchée ({names})."
},
"he": {
"sketch.move_released": "הוזז: {count} אילוצים שקשרו אותו למה שנשאר שוחררו ({names}).",
"sketch.move_released_one": "הוזז: {count} אילוץ שקשר אותו למה שנשאר שוחרר ({names})."
},
"ja": {
"sketch.move_released": "移動しました: 残る要素とつないでいた拘束 {count} 個を解除しました ({names})。",
"sketch.move_released_one": "移動しました: 残る要素とつないでいた拘束 {count} 個を解除しました ({names})。"
},
"vi": {
"sketch.move_released": "Đã di chuyển: đã bỏ {count} ràng buộc nối nó với phần còn lại ({names}).",
"sketch.move_released_one": "Đã di chuyển: đã bỏ {count} ràng buộc nối nó với phần còn lại ({names})."
},
"zh": {
"sketch.move_released": "已移动:已解除 {count} 个将其与保留部分相连的约束({names})。",
"sketch.move_released_one": "已移动:已解除 {count} 个将其与保留部分相连的约束({names})。"
}
}
@@ -0,0 +1,240 @@
{
"en": {
"Vertical": "Vertical",
"sketch_text.valign_baseline": "Baseline",
"sketch_text.valign_top": "Top",
"sketch_text.valign_middle": "Middle",
"sketch_text.valign_bottom": "Bottom"
},
"ar": {
"Vertical": "عمودي",
"sketch_text.valign_baseline": "خط الأساس",
"sketch_text.valign_top": "أعلى",
"sketch_text.valign_middle": "وسط",
"sketch_text.valign_bottom": "أسفل"
},
"bg": {
"Vertical": "Вертикално",
"sketch_text.valign_baseline": "Базова линия",
"sketch_text.valign_top": "Горе",
"sketch_text.valign_middle": "Среда",
"sketch_text.valign_bottom": "Долу"
},
"bs": {
"Vertical": "Vertikalno",
"sketch_text.valign_baseline": "Osnovna linija",
"sketch_text.valign_top": "Vrh",
"sketch_text.valign_middle": "Sredina",
"sketch_text.valign_bottom": "Dno"
},
"ca": {
"Vertical": "Vertical",
"sketch_text.valign_baseline": "Línia base",
"sketch_text.valign_top": "Dalt",
"sketch_text.valign_middle": "Mig",
"sketch_text.valign_bottom": "Baix"
},
"cs": {
"Vertical": "Svisle",
"sketch_text.valign_baseline": "Účaří",
"sketch_text.valign_top": "Nahoře",
"sketch_text.valign_middle": "Uprostřed",
"sketch_text.valign_bottom": "Dole"
},
"da": {
"Vertical": "Lodret",
"sketch_text.valign_baseline": "Grundlinje",
"sketch_text.valign_top": "Top",
"sketch_text.valign_middle": "Midte",
"sketch_text.valign_bottom": "Bund"
},
"de": {
"Vertical": "Vertikal",
"sketch_text.valign_baseline": "Grundlinie",
"sketch_text.valign_top": "Oben",
"sketch_text.valign_middle": "Mitte",
"sketch_text.valign_bottom": "Unten"
},
"es": {
"Vertical": "Vertical",
"sketch_text.valign_baseline": "Línea base",
"sketch_text.valign_top": "Arriba",
"sketch_text.valign_middle": "Centro",
"sketch_text.valign_bottom": "Abajo"
},
"fi": {
"Vertical": "Pystysuunta",
"sketch_text.valign_baseline": "Perusviiva",
"sketch_text.valign_top": "Ylä",
"sketch_text.valign_middle": "Keski",
"sketch_text.valign_bottom": "Ala"
},
"fr": {
"Vertical": "Vertical",
"sketch_text.valign_baseline": "Ligne de base",
"sketch_text.valign_top": "Haut",
"sketch_text.valign_middle": "Milieu",
"sketch_text.valign_bottom": "Bas"
},
"gl": {
"Vertical": "Vertical",
"sketch_text.valign_baseline": "Liña base",
"sketch_text.valign_top": "Arriba",
"sketch_text.valign_middle": "Medio",
"sketch_text.valign_bottom": "Abaixo"
},
"he": {
"Vertical": "אנכי",
"sketch_text.valign_baseline": "קו בסיס",
"sketch_text.valign_top": "למעלה",
"sketch_text.valign_middle": "אמצע",
"sketch_text.valign_bottom": "למטה"
},
"hr": {
"Vertical": "Okomito",
"sketch_text.valign_baseline": "Osnovna linija",
"sketch_text.valign_top": "Vrh",
"sketch_text.valign_middle": "Sredina",
"sketch_text.valign_bottom": "Dno"
},
"hu": {
"Vertical": "Függőleges",
"sketch_text.valign_baseline": "Alapvonal",
"sketch_text.valign_top": "Fent",
"sketch_text.valign_middle": "Középen",
"sketch_text.valign_bottom": "Lent"
},
"id": {
"Vertical": "Vertikal",
"sketch_text.valign_baseline": "Garis dasar",
"sketch_text.valign_top": "Atas",
"sketch_text.valign_middle": "Tengah",
"sketch_text.valign_bottom": "Bawah"
},
"it": {
"Vertical": "Verticale",
"sketch_text.valign_baseline": "Linea di base",
"sketch_text.valign_top": "Alto",
"sketch_text.valign_middle": "Centro",
"sketch_text.valign_bottom": "Basso"
},
"ja": {
"Vertical": "垂直",
"sketch_text.valign_baseline": "ベースライン",
"sketch_text.valign_top": "上",
"sketch_text.valign_middle": "中央",
"sketch_text.valign_bottom": "下"
},
"ka": {
"Vertical": "ვერტიკალური",
"sketch_text.valign_baseline": "საბაზისო ხაზი",
"sketch_text.valign_top": "ზემოთ",
"sketch_text.valign_middle": "შუა",
"sketch_text.valign_bottom": "ქვემოთ"
},
"ko": {
"Vertical": "수직",
"sketch_text.valign_baseline": "기준선",
"sketch_text.valign_top": "위",
"sketch_text.valign_middle": "가운데",
"sketch_text.valign_bottom": "아래"
},
"ms": {
"Vertical": "Menegak",
"sketch_text.valign_baseline": "Garis dasar",
"sketch_text.valign_top": "Atas",
"sketch_text.valign_middle": "Tengah",
"sketch_text.valign_bottom": "Bawah"
},
"nb": {
"Vertical": "Loddrett",
"sketch_text.valign_baseline": "Grunnlinje",
"sketch_text.valign_top": "Topp",
"sketch_text.valign_middle": "Midt",
"sketch_text.valign_bottom": "Bunn"
},
"nl": {
"Vertical": "Verticaal",
"sketch_text.valign_baseline": "Basislijn",
"sketch_text.valign_top": "Boven",
"sketch_text.valign_middle": "Midden",
"sketch_text.valign_bottom": "Onder"
},
"pl": {
"Vertical": "Pionowo",
"sketch_text.valign_baseline": "Linia bazowa",
"sketch_text.valign_top": "Góra",
"sketch_text.valign_middle": "Środek",
"sketch_text.valign_bottom": "Dół"
},
"pt-BR": {
"Vertical": "Vertical",
"sketch_text.valign_baseline": "Linha de base",
"sketch_text.valign_top": "Topo",
"sketch_text.valign_middle": "Meio",
"sketch_text.valign_bottom": "Base"
},
"ro": {
"Vertical": "Vertical",
"sketch_text.valign_baseline": "Linie de bază",
"sketch_text.valign_top": "Sus",
"sketch_text.valign_middle": "Mijloc",
"sketch_text.valign_bottom": "Jos"
},
"ru": {
"Vertical": "По вертикали",
"sketch_text.valign_baseline": "Базовая линия",
"sketch_text.valign_top": "Верх",
"sketch_text.valign_middle": "Середина",
"sketch_text.valign_bottom": "Низ"
},
"sr": {
"Vertical": "Вертикално",
"sketch_text.valign_baseline": "Основна линија",
"sketch_text.valign_top": "Врх",
"sketch_text.valign_middle": "Средина",
"sketch_text.valign_bottom": "Дно"
},
"sv": {
"Vertical": "Lodrät",
"sketch_text.valign_baseline": "Baslinje",
"sketch_text.valign_top": "Överkant",
"sketch_text.valign_middle": "Mitten",
"sketch_text.valign_bottom": "Nederkant"
},
"tr": {
"Vertical": "Dikey",
"sketch_text.valign_baseline": "Taban çizgisi",
"sketch_text.valign_top": "Üst",
"sketch_text.valign_middle": "Orta",
"sketch_text.valign_bottom": "Alt"
},
"uk": {
"Vertical": "По вертикалі",
"sketch_text.valign_baseline": "Базова лінія",
"sketch_text.valign_top": "Верх",
"sketch_text.valign_middle": "Середина",
"sketch_text.valign_bottom": "Низ"
},
"vi": {
"Vertical": "Dọc",
"sketch_text.valign_baseline": "Đường cơ sở",
"sketch_text.valign_top": "Trên",
"sketch_text.valign_middle": "Giữa",
"sketch_text.valign_bottom": "Dưới"
},
"zh-TW": {
"Vertical": "垂直",
"sketch_text.valign_baseline": "基線",
"sketch_text.valign_top": "頂端",
"sketch_text.valign_middle": "置中",
"sketch_text.valign_bottom": "底端"
},
"zh": {
"Vertical": "垂直",
"sketch_text.valign_baseline": "基线",
"sketch_text.valign_top": "顶端",
"sketch_text.valign_middle": "居中",
"sketch_text.valign_bottom": "底端"
}
}
+26 -26
View File
@@ -4681,32 +4681,32 @@
"sketch.dof_left": "剩余 {count} 个自由度",
"sketch.dof_left_one": "剩余 {count} 个自由度",
"sketch.draw_curves": "绘制为 {linetype}",
"sketch.error.closed_curve_two_crossings": "a closed curve needs two crossings to trim",
"sketch.error.nothing_to_break": "nothing to break here",
"sketch.error.offset_fillet_chamfer": "`{id}` is an offset, and its corners follow its source curves: round or cut the source corners before offsetting, or break the offset association first",
"sketch.error.offset_trim": "`{id}` is an offset, and its ends follow its source curves: edit the source curves instead, or break the offset association first",
"sketch.hint.arc.center_point": "Arc: click center and start, go round either way and click the end, or type a signed sweep",
"sketch.hint.arc.three_point": "Arc: click start, end, then a point on the arc",
"sketch.hint.arc3d": "3D Arc: click start, end, then a point on the arc (points may leave the sketch plane)",
"sketch.hint.break": "Break: click a curve where it should split",
"sketch.hint.circle.center": "Circle: click the center, then a point on the circle",
"sketch.hint.constraint.generic": "Constraint: pick the sketch entities",
"sketch.hint.constraint.horizontal_vertical": "Horizontal/Vertical: pick a line, two points, or a point and a line; hold Shift to pick a line midpoint",
"sketch.hint.constraint.midpoint": "MidPoint: pick a point and a line, or two lines to center the second on the first",
"sketch.hint.dimension": "Dimension: pick one or two entities and type the value (or click to place it first); click a dimension to edit it; Enter applies",
"sketch.hint.extend": "Extend: click a curve near the end to extend (Shift: click two curves to extend both to where they meet)",
"sketch.hint.fallback": "Click in the sketch",
"sketch.hint.line": "Line: click points; right-click or Esc to finish",
"sketch.hint.line3d": "3D Line: click world points; right-click or Esc to finish (points may leave the sketch plane)",
"sketch.hint.open_done": "Open done",
"sketch.hint.point3d": "3D Point: click where a 3D point goes",
"sketch.hint.rectangle.center": "Center rectangle: click the center, then a corner",
"sketch.hint.rectangle.rounded": "Rounded rectangle: click two opposite corners (Tab to the radius box to type the corner radius)",
"sketch.hint.rectangle.three_point": "3-point rectangle: click two points of one edge, then the width",
"sketch.hint.rectangle.two_point": "Rectangle: click two opposite corners",
"sketch.hint.slot": "Slot: click both ends, then the width",
"sketch.hint.spline3d": "3D Spline: click fit points in 3D; right-click or Esc to finish",
"sketch.hint.trim": "Trim: click the piece of a curve to remove, or drag across the pieces to remove",
"sketch.error.closed_curve_two_crossings": "闭合曲线需要两个交点才能修剪",
"sketch.error.nothing_to_break": "此处没有可断开的位置",
"sketch.error.offset_fillet_chamfer": "`{id}` 是偏移曲线,其拐角跟随源曲线:请先对源曲线的拐角倒圆角或倒角再偏移,或先断开偏移关联",
"sketch.error.offset_trim": "`{id}` 是偏移曲线,其端点跟随源曲线:请改为编辑源曲线,或先断开偏移关联",
"sketch.hint.arc.center_point": "圆弧:单击圆心和起点,向任一方向转动后单击终点,或输入带符号的扫过角度",
"sketch.hint.arc.three_point": "圆弧:依次单击起点、终点和圆弧上的一点",
"sketch.hint.arc3d": "三维圆弧:依次单击起点、终点和圆弧上的一点(点可以离开草图平面)",
"sketch.hint.break": "打断:在曲线要分开的位置单击",
"sketch.hint.circle.center": "圆:单击圆心,再单击圆上一点",
"sketch.hint.constraint.generic": "约束:选择草图图元",
"sketch.hint.constraint.horizontal_vertical": "水平/竖直:选择一条直线、两个点,或一个点和一条直线;按住 Shift 可选取直线中点",
"sketch.hint.constraint.midpoint": "中点:选择一个点和一条直线,或选择两条直线,使第二条的中点落在第一条上",
"sketch.hint.dimension": "尺寸:选择一个或两个图元并输入数值(也可先单击放置尺寸);单击已有尺寸可编辑;按 Enter 应用",
"sketch.hint.extend": "延伸:单击曲线靠近端点的位置进行延伸(Shift:依次单击两条曲线,将两者延伸到相交处)",
"sketch.hint.fallback": "在草图中单击",
"sketch.hint.line": "直线:依次单击各点;右键单击或按 Esc 结束",
"sketch.hint.line3d": "三维直线:依次单击空间中的点;右键单击或按 Esc 结束(点可以离开草图平面)",
"sketch.hint.open_done": "开放轮廓已完成",
"sketch.hint.point3d": "三维点:单击三维点的位置",
"sketch.hint.rectangle.center": "中心矩形:单击中心,再单击一个角点",
"sketch.hint.rectangle.rounded": "圆角矩形:单击两个对角点(按 Tab 切换到半径框,输入圆角半径)",
"sketch.hint.rectangle.three_point": "三点矩形:单击一条边的两个端点,再确定宽度",
"sketch.hint.rectangle.two_point": "矩形:单击两个对角点",
"sketch.hint.slot": "槽:单击两端,再确定宽度",
"sketch.hint.spline3d": "三维样条曲线:在三维空间中单击拟合点;右键单击或按 Esc 结束",
"sketch.hint.trim": "修剪:单击要删除的曲线段,或拖动划过要删除的各段",
"sketch.make_selection": "将选择转换为 {linetype}",
"sketch.open_ends": "开放 {count}",
"sketch.status_conflict": "约束冲突",
@@ -0,0 +1,3 @@
| File | Author | Source | Licence |
|---|---|---|---|
| crates/look/icons/design/svg/model/plane_auto.svg | SolidCraft contributors | original construction icon master (64 grid) | SolidCraft Brand and Interface Licence (LICENSE-BRAND) |
+4 -9
View File
@@ -144,6 +144,9 @@ impl Hub {
shared.lock().retain(|(i, _)| *i != id);
});
}
// A captured value drops after the body's locals, so without this `_closed` would tell
// `Stop::stop` the port is free while the listener still holds it (#14752).
drop(listener);
});
Ok(stop)
}
@@ -1417,14 +1420,6 @@ mod tests {
}
}
assert!(started.elapsed() < Duration::from_secs(3), "before the token wait runs out");
// The listener thread closes its socket when it notices the stop; on a loaded machine that can
// take a moment after `stop()` returns. Wait for the port to be free, not for one instant.
let freed = Instant::now();
let mut bound = TcpListener::bind(("127.0.0.1", port));
while bound.is_err() && freed.elapsed() < Duration::from_secs(2) {
std::thread::sleep(Duration::from_millis(10));
bound = TcpListener::bind(("127.0.0.1", port));
}
assert!(bound.is_ok(), "the port is free again: {:?}", bound.err());
assert!(TcpListener::bind(("127.0.0.1", port)).is_ok(), "the port is free again");
}
}
+120 -16
View File
@@ -47,9 +47,41 @@ fn opaque() -> f64 {
1.0
}
/// The name a colour picked or typed in gets: no library entry's, no file's.
pub const CUSTOM: &str = "Custom";
/// The name of a colour a file gave without a name: `Custom(R,G,B)` when it is opaque, else
/// `Custom(R,G,B,opacity)`, the opacity to two decimals with at least one shown
/// (`Custom(196,199,204)`, `Custom(210,226,240,0.35)`), so the same colour always reads the same.
pub fn unnamed_colour(color: [u8; 3], opacity: f64) -> String {
let hundredths = (if opacity.is_finite() { opacity.clamp(0.0, 1.0) } else { 1.0 } * 100.0).round() as u32;
let [r, g, b] = color;
if hundredths == 100 {
return format!("{CUSTOM}({r},{g},{b})");
}
let mut fraction = format!("{:02}", hundredths % 100);
if fraction.ends_with('0') {
fraction.pop();
}
format!("{CUSTOM}({r},{g},{b},{}.{fraction})", hundredths / 100)
}
impl Look {
pub fn custom(color: [u8; 3], opacity: f64) -> Look {
Look { name: "Custom".into(), color, opacity: opacity.clamp(0.0, 1.0), pbr: None }
Look { name: CUSTOM.into(), color, opacity: opacity.clamp(0.0, 1.0), pbr: None }
}
/// A colour read from a file: called what the file calls it, else [`unnamed_colour`].
pub fn imported(color: [u8; 3], opacity: f64, name: Option<&str>) -> Look {
let opacity = opacity.clamp(0.0, 1.0);
let name = name.map_or_else(|| unnamed_colour(color, opacity), str::to_string);
Look { name, color, opacity, pbr: None }
}
/// The name to write with this look's colour in a file (a STEP `COLOUR_RGB` name): its own,
/// unless it is the plain [`CUSTOM`], which says nothing.
pub fn file_name(&self) -> Option<String> {
(self.name != CUSTOM).then(|| self.name.clone()).and_then(|n| solidcraft_kernel::colour_name(&n))
}
pub fn opaque_surface(&self) -> crate::Result<Option<solidcraft_viewport::surface::OpaqueSurface>> {
if self.pbr.is_some() && self.opacity != 1.0 {
@@ -93,6 +125,10 @@ pub struct ImportedLook {
pub color: Option<[f32; 3]>,
pub opacity: Option<f32>,
pub faces: BTreeMap<String, ([f32; 3], f32)>,
/// The body colour's name in the file, if it had one.
pub name: Option<String>,
/// The names the file gives the faces' colours, by the same face names as `faces`.
pub face_names: BTreeMap<String, String>,
}
impl ImportedLook {
@@ -102,14 +138,17 @@ impl ImportedLook {
return b.imported.clone();
}
let names = crate::naming::face_names(b);
let faces = b
.body
.paint()
.map(|p| {
p.faces.iter().filter_map(|f| names.get(f.face).map(|n| (crate::naming::strip_piece(n).to_string(), (f.color, f.opacity)))).collect()
})
.unwrap_or_default();
Some(std::sync::Arc::new(ImportedLook { color: b.body.color(), opacity: b.body.paint().map(|p| p.opacity), faces }))
let named = |f: &solidcraft_kernel::FacePaint| names.get(f.face).map(|n| crate::naming::strip_piece(n).to_string());
let painted = b.body.paint().map_or(&[][..], |p| p.faces.as_slice());
let faces = painted.iter().filter_map(|f| named(f).map(|n| (n, (f.color, f.opacity)))).collect();
let face_names = painted.iter().filter_map(|f| Some((named(f)?, f.name.clone()?))).collect();
Some(std::sync::Arc::new(ImportedLook {
color: b.body.color(),
opacity: b.body.paint().map(|p| p.opacity),
faces,
name: b.body.paint().and_then(|p| p.name.clone()),
face_names,
}))
}
}
@@ -317,7 +356,11 @@ impl Document {
pub fn model_display_look(&self, body: &crate::ModelBody) -> Option<Look> {
self.model_body_look(body).or_else(|| {
let color = body.body.color().or_else(|| body.imported.as_ref().and_then(|p| p.color))?;
Some(Look::custom(color.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8), self.model_body_opacity(body)))
let name = match body.body.color() {
Some(_) => body.body.paint().and_then(|p| p.name.as_deref()),
None => body.imported.as_ref().and_then(|p| p.name.as_deref()),
};
Some(Look::imported(color.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8), self.model_body_opacity(body), name))
})
}
@@ -380,14 +423,17 @@ impl Document {
Vec::new()
} else {
match b.body.paint() {
Some(p) => p.faces.iter().map(|f| (f.face, Look::custom(f.color.map(to8), f.opacity as f64))).collect(),
Some(p) => p.faces.iter().map(|f| (f.face, Look::imported(f.color.map(to8), f.opacity as f64, f.name.as_deref()))).collect(),
// Carried from an earlier version of the body: by face name (worked out only here:
// the viewport asks every body for its face colours on every change).
None => match &b.imported {
Some(imp) => crate::naming::face_names(b)
.iter()
.enumerate()
.filter_map(|(i, n)| imp.faces.get(crate::naming::strip_piece(n)).map(|(c, o)| (i, Look::custom(c.map(to8), *o as f64))))
.filter_map(|(i, n)| {
let n = crate::naming::strip_piece(n);
imp.faces.get(n).map(|(c, o)| (i, Look::imported(c.map(to8), *o as f64, imp.face_names.get(n).map(String::as_str))))
})
.collect(),
None => Vec::new(),
},
@@ -532,9 +578,9 @@ mod tests {
.faces(0.01)
.unwrap()
.iter()
.map(|face| solidcraft_kernel::FacePaint { face: face.index, color: [1., 0., 0.], opacity: 0.5 })
.map(|face| solidcraft_kernel::FacePaint { face: face.index, color: [1., 0., 0.], opacity: 0.5, name: None })
.collect();
crate::ModelBody::new("Imported".into(), body.with_paint(Some(solidcraft_kernel::Paint { opacity: 1., faces })), 1)
crate::ModelBody::new("Imported".into(), body.with_paint(Some(solidcraft_kernel::Paint { opacity: 1., faces, name: None })), 1)
}
fn check_imported_appearance_precedence(body: &crate::ModelBody) {
@@ -542,7 +588,7 @@ mod tests {
let face_count = body.body.faces(0.01).unwrap().len();
let imported = doc.face_colors(body);
assert_eq!(imported.len(), face_count);
assert!(imported.iter().all(|(_, look)| *look == Look::custom([255, 0, 0], 0.5)));
assert!(imported.iter().all(|(_, look)| *look == Look::imported([255, 0, 0], 0.5, None)));
let body_look = Look::custom([0, 0, 255], 0.8);
doc.appearances.bodies.insert(body.definition_name().into(), body_look.clone());
@@ -565,7 +611,7 @@ mod tests {
doc.appearances.bodies.clear();
let restored = doc.face_colors(body);
assert_eq!(restored.len(), face_count);
assert!(restored.iter().all(|(face, look)| *look == if *face == 0 { face_look.clone() } else { Look::custom([255, 0, 0], 0.5) }));
assert!(restored.iter().all(|(face, look)| *look == if *face == 0 { face_look.clone() } else { Look::imported([255, 0, 0], 0.5, None) }));
}
#[test]
@@ -590,6 +636,64 @@ mod tests {
assert!(faces.iter().all(|face| face.color == [1., 0., 0.] && face.opacity == 0.5));
}
/// A colour a file names is called by that name; one it leaves unnamed by its values, the
/// opacity to two decimals with at least one shown. A picked colour's plain name is not written
/// to a file.
#[test]
fn imported_colours_are_called_by_their_file_name_or_their_values() {
assert_eq!(unnamed_colour([196, 199, 204], 1.0), "Custom(196,199,204)");
assert_eq!(unnamed_colour([210, 226, 240], 0.35), "Custom(210,226,240,0.35)");
assert_eq!(unnamed_colour([90, 96, 104], 0.5), "Custom(90,96,104,0.5)");
assert_eq!(unnamed_colour([0, 0, 0], 0.0), "Custom(0,0,0,0.0)");
// A 32-bit file opacity reads as the number it was written as.
assert_eq!(unnamed_colour([1, 2, 3], f64::from(0.35_f32)), "Custom(1,2,3,0.35)");
assert_eq!(unnamed_colour([1, 2, 3], 0.995), "Custom(1,2,3)", "rounds to opaque");
assert_eq!(unnamed_colour([1, 2, 3], 0.004), "Custom(1,2,3,0.0)");
for hostile in [f64::NAN, f64::INFINITY, 7.0] {
assert_eq!(unnamed_colour([1, 2, 3], hostile), "Custom(1,2,3)", "{hostile}");
}
assert_eq!(unnamed_colour([1, 2, 3], -3.0), "Custom(1,2,3,0.0)");
let named = Look::imported([160, 160, 160], 1.0, Some("Steel - Satin"));
assert_eq!((named.name.as_str(), named.color, named.opacity), ("Steel - Satin", [160, 160, 160], 1.0));
assert_eq!(Look::imported([1, 2, 3], 2.0, None).opacity, 1.0);
assert_eq!(named.file_name().as_deref(), Some("Steel - Satin"));
assert_eq!(Look::imported([1, 2, 3], 1.0, None).file_name().as_deref(), Some("Custom(1,2,3)"));
assert_eq!(Look::custom([1, 2, 3], 1.0).file_name(), None, "a picked colour's plain name says nothing");
}
/// The names a file gives a body's colour and its faces' colours name the looks the body
/// shows, still after a later feature rebuilds the body without its paint, and they are
/// exported with the colours.
#[test]
fn imported_colour_names_name_the_looks_and_outlive_later_features() {
let mut body = imported_box();
let mut paint = body.body.paint().unwrap().clone();
paint.name = Some("Body grey".into());
paint.faces[0].name = Some("Accent".into());
body.body = body.body.clone().with_color(Some([0.5, 0.5, 0.5])).with_paint(Some(paint));
let doc = Document::default();
let check = |body: &crate::ModelBody| {
assert_eq!(doc.model_display_look(body).map(|l| l.name), Some("Body grey".into()));
let faces = doc.face_colors(body);
assert_eq!(faces.iter().find(|(face, _)| *face == 0).map(|(_, l)| l.name.as_str()), Some("Accent"));
assert!(faces.iter().filter(|(face, _)| *face != 0).all(|(_, l)| l.name == "Custom(255,0,0,0.5)"), "{faces:?}");
};
check(&body);
// A later feature's body carries the look by face name.
let mut later = body.clone();
later.imported = ImportedLook::of(&body);
later.body = later.body.clone().with_color(None).with_paint(None);
assert_eq!(later.imported.as_ref().and_then(|l| l.name.as_deref()), Some("Body grey"));
check(&later);
// Exported, the colours carry their names.
let mut state = crate::ModelState::default();
state.bodies.push(body);
let painted = doc.painted(&state);
let paint = painted.bodies[0].body.paint().unwrap();
assert_eq!(paint.name.as_deref(), Some("Body grey"));
assert_eq!(paint.faces.iter().find(|f| f.face == 0).and_then(|f| f.name.as_deref()), Some("Accent"));
}
#[test]
fn body_appearance_overrides_imported_face_paint() {
check_imported_appearance_precedence(&imported_box());
+99 -12
View File
@@ -38,6 +38,9 @@ pub enum Column {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ConfigRow {
/// Fixed for the row's life, from the design's id counter ([`Document::allocate_id`]): a rename
/// keeps it and no other row is ever given it, so what points at a row still finds it (#14750).
pub id: u64,
pub name: String,
/// One cell per column: expression, suppression, material name or appearance look.
pub cells: Vec<String>,
@@ -64,10 +67,59 @@ pub fn suppression_cell(value: &str) -> Result<bool> {
}
}
/// A row of the table, by its id or by its name.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum RowKey<'a> {
Id(u64),
Name(&'a str),
}
impl<'a> RowKey<'a> {
/// A row as a command or a file names it: a whole number is its id, text its name, so a row
/// named `"17"` is not row 17. Anything else names no row.
pub fn read(v: &'a serde_json::Value) -> Option<Self> {
v.as_u64().map(RowKey::Id).or_else(|| v.as_str().map(RowKey::Name))
}
}
impl<'a> From<&'a str> for RowKey<'a> {
fn from(name: &'a str) -> Self {
RowKey::Name(name)
}
}
impl From<u64> for RowKey<'_> {
fn from(id: u64) -> Self {
RowKey::Id(id)
}
}
impl std::fmt::Display for RowKey<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RowKey::Id(id) => write!(f, "{id}"),
RowKey::Name(name) => f.write_str(name),
}
}
}
impl ConfigTable {
pub fn is_empty(&self) -> bool {
self.columns.is_empty() && self.rows.is_empty() && self.rules.is_empty()
}
/// The row `key` names: the one with that id, or with that name.
pub fn row(&self, key: RowKey<'_>) -> Option<&ConfigRow> {
self.rows.iter().find(|r| match key {
RowKey::Id(id) => r.id == id,
RowKey::Name(name) => r.name == name,
})
}
/// The active row, when the table still has it.
pub fn active_row(&self) -> Option<&ConfigRow> {
self.row(RowKey::Name(self.active.as_deref()?))
}
}
/// Parse an appearance cell. Empty/Default follows the body's material; named library looks
@@ -164,13 +216,25 @@ impl Document {
}
}
/// Add a row to the configuration table, with an id of its own; at most [`MAX_ROWS`] rows.
pub fn add_configuration_row(&mut self, name: String, cells: Vec<String>) -> Result<u64> {
if self.configs.rows.len() >= MAX_ROWS {
return Err(DocError::Invalid("too many rows".into()));
}
let id = self.allocate_id()?;
self.configs.rows.push(ConfigRow { id, name, cells });
Ok(id)
}
/// Set the design to a row's values (all or nothing).
pub fn apply_configuration(&mut self, row: &str) -> Result<()> {
pub fn apply_configuration<'a>(&mut self, row: impl Into<RowKey<'a>>) -> Result<()> {
self.apply_configuration_with_trace(row).map(|_| ())
}
pub fn apply_configuration_with_trace(&mut self, row: &str) -> Result<Vec<RuleTrace>> {
let r = self.configs.rows.iter().find(|r| r.name == row).cloned().ok_or_else(|| DocError::Unknown(format!("configuration `{row}`")))?;
pub fn apply_configuration_with_trace<'a>(&mut self, row: impl Into<RowKey<'a>>) -> Result<Vec<RuleTrace>> {
let key = row.into();
let r = self.configs.row(key).cloned().ok_or_else(|| DocError::Unknown(format!("configuration `{key}`")))?;
let row = r.name.as_str();
if self.configs.columns.len() > MAX_COLUMNS || self.configs.rows.len() > MAX_ROWS || r.cells.len() != self.configs.columns.len() {
return Err(DocError::Invalid("configuration table exceeds limits or has incomplete rows".into()));
}
@@ -250,13 +314,13 @@ impl Document {
/// The active row's values all hold in the design.
pub fn configuration_matches(&self) -> bool {
let Some(r) = self.configs.active.as_ref().and_then(|a| self.configs.rows.iter().find(|r| r.name == *a)) else { return false };
let Some(r) = self.configs.active_row() else { return false };
if r.cells.len() != self.configs.columns.len() {
return false;
}
if !self.configs.rules.is_empty() {
let mut expected = self.clone();
if expected.apply_configuration(&r.name).is_err() {
if expected.apply_configuration(r.id).is_err() {
return false;
}
return self.configs.columns.iter().all(|column| self.config_current(column) == expected.config_current(column));
@@ -291,11 +355,11 @@ mod tests {
d.set_param("a", "1 mm", None, None).unwrap();
d.set_param("b", "a", None, None).unwrap();
d.configs.columns = vec![Column::Param("a".into()), Column::Param("b".into())];
d.configs.rows.push(ConfigRow { name: "Reordered".into(), cells: vec!["b".into(), "2 mm".into()] });
d.configs.rows.push(ConfigRow { id: 1, name: "Reordered".into(), cells: vec!["b".into(), "2 mm".into()] });
d.apply_configuration("Reordered").unwrap();
assert!(d.configuration_matches());
assert_eq!(d.param_values().0["a"].v, 2.0);
d.configs.rows.push(ConfigRow { name: "Cycle".into(), cells: vec!["b".into(), "a".into()] });
d.configs.rows.push(ConfigRow { id: 2, name: "Cycle".into(), cells: vec!["b".into(), "a".into()] });
let before = d.clone();
assert!(d.apply_configuration("Cycle").is_err());
assert_eq!(d, before);
@@ -323,28 +387,50 @@ mod tests {
d.set_param("width", "10 mm", None, None).unwrap();
d.params[0].expr = "1/".into();
d.configs.columns = vec![Column::Param("width".into())];
d.configs.rows.push(ConfigRow { name: "Default".into(), cells: vec!["1/".into()] });
d.configs.rows.push(ConfigRow { id: 3, name: "Default".into(), cells: vec!["1/".into()] });
d.apply_configuration("Default").unwrap();
assert!(d.configuration_matches());
d.configs.rows.push(ConfigRow { name: "Different error".into(), cells: vec!["missing + 1".into()] });
d.configs.rows.push(ConfigRow { id: 4, name: "Different error".into(), cells: vec!["missing + 1".into()] });
let before = d.clone();
assert!(d.apply_configuration("Different error").is_err());
assert_eq!(d, before);
}
/// A row is found by its id or its name, and activating it by id names it active. A deleted
/// row's id is never given out again (#14750).
#[test]
fn a_row_is_found_by_its_id_or_its_name() {
let mut d = Document::new("A");
d.set_param("w", "10 mm", None, None).unwrap();
d.configs.columns.push(Column::Param("w".into()));
let (small, large) = (d.allocate_id().unwrap(), d.allocate_id().unwrap());
d.configs.rows.push(ConfigRow { id: small, name: "Small".into(), cells: vec!["10 mm".into()] });
d.configs.rows.push(ConfigRow { id: large, name: "Large".into(), cells: vec!["40 mm".into()] });
assert_eq!(d.configs.row(RowKey::Id(large)).map(|r| r.name.as_str()), Some("Large"));
assert_eq!(d.configs.row("Small".into()).map(|r| r.id), Some(small));
d.apply_configuration(large).unwrap();
assert_eq!((d.configs.active.as_deref(), d.configs.active_row().map(|r| r.id)), (Some("Large"), Some(large)));
assert!(d.configuration_matches());
d.configs.rows.retain(|r| r.id != large);
assert!(d.configs.active_row().is_none() && !d.configuration_matches());
assert!(d.allocate_id().unwrap() > large, "a deleted row's id is never given again");
let gone = d.apply_configuration(large).unwrap_err().to_string();
assert!(gone.contains(&format!("configuration `{large}`")), "{gone}");
}
#[test]
fn rows_switch_parameters() {
let mut d = Document::new("A");
d.set_param("w", "10 mm", None, None).unwrap();
d.configs.columns.push(Column::Param("w".into()));
d.configs.rows.push(ConfigRow { name: "Small".into(), cells: vec!["10 mm".into()] });
d.configs.rows.push(ConfigRow { name: "Large".into(), cells: vec!["40 mm".into()] });
d.configs.rows.push(ConfigRow { id: 5, name: "Small".into(), cells: vec!["10 mm".into()] });
d.configs.rows.push(ConfigRow { id: 6, name: "Large".into(), cells: vec!["40 mm".into()] });
d.apply_configuration("Large").unwrap();
assert_eq!(d.config_current(&Column::Param("w".into())).as_deref(), Some("40 mm"));
assert!(d.configuration_matches());
d.set_param("w", "12 mm", None, None).unwrap();
assert!(!d.configuration_matches());
d.configs.rows.push(ConfigRow { name: "Bad".into(), cells: vec!["1/".into()] });
d.configs.rows.push(ConfigRow { id: 7, name: "Bad".into(), cells: vec!["1/".into()] });
let before = d.clone();
assert!(d.apply_configuration("Bad").is_err());
assert_eq!(d, before, "all or nothing");
@@ -356,6 +442,7 @@ mod tests {
d.set_param("width", "10 mm", None, None).unwrap();
d.configs.columns = vec![Column::Param("width".into()), Column::Material("Body".into()), Column::Appearance("Body".into())];
d.configs.rows.push(ConfigRow {
id: 8,
name: "Broken".into(),
cells: vec!["20 mm".into(), "Steel".into(), r#"{"name":"Invalid","color":[1,2,3],"opacity":2}"#.into()],
});
+2 -1
View File
@@ -147,6 +147,7 @@ mod tests {
}),
],
rows: vec![ConfigRow {
id: 1,
name: "Default".into(),
cells: vec!["d1 * 2 + d2".into(), "4 mm".into(), "d1".into(), String::new(), String::new()],
}],
@@ -203,7 +204,7 @@ mod tests {
certificate: None,
}),
],
rows: vec![ConfigRow { name: "Default".into(), cells: vec!["2 mm".into(), String::new()] }],
rows: vec![ConfigRow { id: 2, name: "Default".into(), cells: vec!["2 mm".into(), String::new()] }],
..Default::default()
};
let original = table.clone();
+2 -2
View File
@@ -529,7 +529,7 @@ mod tests {
doc.set_param("width", "10 mm", None, None).unwrap();
doc.set_param("depth", "5 mm", None, None).unwrap();
doc.configs.columns = vec![Column::Param("width".into()), Column::Param("depth".into())];
doc.configs.rows.push(ConfigRow { name: "Default".into(), cells: vec!["10 mm".into(), "5 mm".into()] });
doc.configs.rows.push(ConfigRow { id: 1, name: "Default".into(), cells: vec!["10 mm".into(), "5 mm".into()] });
doc
}
fn rule(id: &str, condition: RuleCondition, effect: RuleEffect) -> ConfigRule {
@@ -730,7 +730,7 @@ mod tests {
doc.rename_param(&width, "width").unwrap();
doc.rename_param(&depth, "depth").unwrap();
doc.configs.columns = vec![Column::Param("width".into()), Column::Param("depth".into())];
doc.configs.rows.push(ConfigRow { name: "Default".into(), cells: vec!["10 mm".into(), "5 mm".into()] });
doc.configs.rows.push(ConfigRow { id: 2, name: "Default".into(), cells: vec!["10 mm".into(), "5 mm".into()] });
doc.units = "in".into();
let mut first =
rule("first", RuleCondition::Always, RuleEffect::DriveParameter { parameter: "width".into(), expression: "depth + 1".into() });
+32 -4
View File
@@ -1019,6 +1019,12 @@ pub enum FeatureKind {
/// part changes. `position` keeps where it was placed.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
references: Vec<HoleReference>,
/// Holes at sketch points as older builds drilled them: a point inside a body moves back
/// to the body's surface whatever plane the sketch lies on. Designs saved before #11538
/// carry it, so their holes rebuild where they were; new holes leave it off, and Edit
/// Hole keeps it.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
old_surface_start: bool,
},
/// A thread on a cylindrical face. Cosmetic by default (no geometry change; drawn and
/// exported as an annotation); `modeled` cuts the real helical ISO profile into the face.
@@ -1087,13 +1093,19 @@ pub enum FeatureKind {
#[serde(default, skip_serializing_if = "RailFit::is_scale")]
rail_fit: RailFit,
},
/// Sweep profiles along a chain of sketch curves.
/// Sweep profiles along a chain of sketch curves, or along a chain of a body's edges.
Sweep {
sketch: SketchId,
#[walk(profiles_of = sketch)]
profiles: ProfileSel,
path_sketch: SketchId,
path: Vec<String>,
/// The path on a body's edges, instead of the curves of `path_sketch` (#2216): the body
/// and a point on each edge of the chain.
#[serde(default, skip_serializing_if = "Option::is_none")]
path_body: Option<BodyName>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
path_edges: Vec<Position>,
/// How the profile turns along the path (default: perpendicular to it).
#[serde(default, skip_serializing_if = "SweepOrientation::is_perpendicular")]
orientation: SweepOrientation,
@@ -1815,6 +1827,12 @@ pub enum FeatureKind {
curves: Vec<String>,
path_sketch: SketchId,
path: Vec<String>,
/// The path on a body's edges, instead of the curves of `path_sketch` (#2216): the body
/// and a point on each edge of the chain.
#[serde(default, skip_serializing_if = "Option::is_none")]
path_body: Option<BodyName>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
path_edges: Vec<Position>,
/// How the profile turns along the path (default: perpendicular to it).
#[serde(default, skip_serializing_if = "SweepOrientation::is_perpendicular")]
orientation: SweepOrientation,
@@ -3399,6 +3417,9 @@ pub struct Document {
/// Named views: saved cameras.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub named_views: Vec<NamedView>,
/// Notes: text anchored to points in model space, drawn as screen-facing callouts (#1631).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub notes: Vec<crate::notes::Note>,
/// What was hidden when the design was saved. A session takes it from here when the design
/// opens and keeps the live state itself (`browser.visibility`: saved, and undone like any
/// other change), so a document in a session leaves this empty; the session puts it back when
@@ -3641,10 +3662,12 @@ impl Document {
.chain(self.timeline_groups.iter().map(|g| g.id))
.chain(self.canvases.iter().map(|c| c.id))
.chain(self.point_clouds.iter().map(|c| c.id))
.chain(self.notes.iter().map(|n| n.id))
.chain(a.joints.iter().map(|j| j.id))
.chain(a.contact_sets.iter().map(|c| c.id))
.chain(a.studies.iter().map(|s| s.id))
.chain(a.exploded.iter().map(|v| v.id))
.chain(self.configs.rows.iter().map(|r| r.id))
.max()
.unwrap_or(0);
let floor = largest.checked_add(1).ok_or_else(|| DocError::Invalid("too many items in the design".into()))?;
@@ -3696,6 +3719,7 @@ impl Document {
timeline_groups: Vec::new(),
browser_order: Default::default(),
named_views: Vec::new(),
notes: Vec::new(),
visibility: Visibility::default(),
}
}
@@ -3899,10 +3923,14 @@ impl Document {
for b in &mut st.bodies {
let c = self.body_color(b);
let face_colors = if authored_faces { self.face_colors(b) } else { self.imported_face_colors(b) };
let faces: Vec<solidcraft_kernel::FacePaint> =
face_colors.into_iter().map(|(face, l)| solidcraft_kernel::FacePaint { face, color: l.rgb(), opacity: l.opacity as f32 }).collect();
let faces: Vec<solidcraft_kernel::FacePaint> = face_colors
.into_iter()
.map(|(face, l)| solidcraft_kernel::FacePaint { face, color: l.rgb(), opacity: l.opacity as f32, name: l.file_name() })
.collect();
let opacity = self.model_body_opacity(b) as f32;
let paint = solidcraft_kernel::Paint { opacity, faces };
// The look's name goes with its colour (a STEP `COLOUR_RGB` name), so it comes back on import.
let name = c.and_then(|_| self.model_display_look(b)).and_then(|l| l.file_name());
let paint = solidcraft_kernel::Paint { opacity, faces, name };
if c != b.body.color() || b.body.paint() != Some(&paint) {
b.body = b.body.clone().with_color(c).with_paint(Some(paint));
}
+1128 -151
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -142,6 +142,7 @@ fn joint_face_measurements_are_shared_across_motion_samples_and_snap_kinds() {
suppressed: false,
exprs: Default::default(),
captured: None,
constraint: None,
});
// One body supplies both old nearest-face snaps and persistent named-face snaps.
// A scratch model shares its measurements, even though each sample changes the pose.
@@ -1455,6 +1456,7 @@ fn a_tap_marking_searches_only_the_bodies_near_it() {
ease_out: None,
to: None,
references: Vec::new(),
old_surface_start: false,
};
let (work, threads) = marking_work(&mut doc, hole);
assert_eq!(work.cylinder_search, 1, "only the plate is searched");
@@ -1572,6 +1574,7 @@ fn a_tapped_hole_reads_the_bodies_along_its_drill_line() {
ease_out: None,
to: None,
references: Vec::new(),
old_surface_start: false,
};
let (work, threads) = marking_work(&mut doc, hole);
assert_eq!(work.tapped_wall, 3, "Plate, Below and Grazing are read");
+25 -27
View File
@@ -13,7 +13,7 @@ use std::sync::{Arc, Mutex};
use solidcraft_kernel as kernel;
use super::{CachedEval, EvalCache, FeatureResult, KeptSlot, ModelBody, ModelState};
use super::{CacheEntry, EvalCache, FeatureResult, KeptSlot, ModelBody, ModelState};
/// The evaluation's loop state just before a feature was evaluated: what a refusal of its check
/// puts back before evaluating it again.
@@ -61,7 +61,7 @@ pub(super) struct Checks {
most: usize,
running: VecDeque<Running>,
/// Cache entries of features evaluated after a check that is still running, in order.
held: Vec<(usize, u64, CachedEval)>,
held: Vec<(usize, CacheEntry)>,
/// Features whose check refused their bodies, to fail again as it did.
refused: HashMap<usize, Failure>,
}
@@ -92,6 +92,11 @@ impl Checks {
}
/// The failure feature `index` ends with, when its check refused it before.
/// Whether feature `index`'s check refused its bodies, leaving the failure in place.
pub fn refused_at(&self, index: usize) -> Option<&Failure> {
self.refused.get(&index)
}
pub fn refused(&mut self, index: usize) -> Option<Failure> {
self.refused.remove(&index)
}
@@ -125,11 +130,11 @@ impl Checks {
}
/// Stores the cache entry of feature `index` once no check before it, or of it, is running.
pub fn store(&mut self, index: usize, key: u64, entry: CachedEval, cache: &Mutex<EvalCache>, kept: &KeptSlot) {
pub fn store(&mut self, index: usize, entry: CacheEntry, cache: &Mutex<EvalCache>, kept: &KeptSlot) {
if self.running.front().is_some_and(|r| r.index <= index) {
self.held.push((index, key, entry));
self.held.push((index, entry));
} else {
put(key, entry, cache, kept);
entry.put(cache, kept);
}
}
@@ -150,15 +155,15 @@ impl Checks {
// What came after it was evaluated on bodies the check refuses: none of it stands.
// The checks still running are left to end on their own.
self.running.clear();
self.held.retain(|(i, _, _)| *i < r.index);
self.held.retain(|(i, _)| *i < r.index);
return Some(Refused { index: r.index, saved: r.saved, failure: Failure { error, ..r.failure } });
}
let upto = self.running.front().map_or(usize::MAX, |next| next.index);
for (i, key, entry) in std::mem::take(&mut self.held) {
for (i, entry) in std::mem::take(&mut self.held) {
if i < upto {
put(key, entry, cache, kept);
entry.put(cache, kept);
} else {
self.held.push((i, key, entry));
self.held.push((i, entry));
}
}
}
@@ -171,17 +176,9 @@ impl Checks {
}
}
fn put(key: u64, entry: CachedEval, cache: &Mutex<EvalCache>, kept: &KeptSlot) {
if let Some(k) = &kept.0 {
k.put(key, &entry);
}
if let Ok(mut m) = cache.lock() {
m.put(key, entry);
}
}
#[cfg(test)]
mod tests {
use super::super::CachedEval;
use super::*;
use kernel::KernelError;
@@ -205,8 +202,9 @@ mod tests {
Failure { error: KernelError::Failed(String::new()), warning: Some("kept".into()), import_report: None, repair: None, ms: 1.0 }
}
fn entry() -> CachedEval {
CachedEval { error: None, warning: None, import_report: None, repair: None, ms: 0.0, state: Arc::new(ModelState::default()), bytes: 1 }
fn entry(key: u64) -> CacheEntry {
let whole = CachedEval { error: None, warning: None, import_report: None, repair: None, ms: 0.0, state: Arc::new(ModelState::default()) };
CacheEntry { key, whole, bytes: 1, change: None }
}
fn checks() -> Checks {
@@ -229,10 +227,10 @@ mod tests {
let (cache, kept) = (Mutex::new(EvalCache::default()), KeptSlot(None));
let mut c = checks();
assert!(c.start(0, saved(), failure(), later(Ok(()))).is_ok());
c.store(0, 10, entry(), &cache, &kept);
c.store(0, entry(10), &cache, &kept);
assert!(c.start(1, saved(), failure(), later(Err(KernelError::Failed("refused".into())))).is_ok());
c.store(1, 11, entry(), &cache, &kept);
c.store(2, 12, entry(), &cache, &kept);
c.store(1, entry(11), &cache, &kept);
c.store(2, entry(12), &cache, &kept);
assert!(cache.lock().unwrap().is_empty(), "held while the checks run");
let refused = c.settle(true, &cache, &kept).expect("the second check refuses");
assert_eq!(refused.index, 1);
@@ -240,7 +238,7 @@ mod tests {
assert_eq!(refused.failure.warning.as_deref(), Some("kept"));
{
let m = cache.lock().unwrap();
assert!(m.map.contains_key(&10) && !m.map.contains_key(&11) && !m.map.contains_key(&12));
assert!(m.states.map.contains_key(&10) && !m.states.map.contains_key(&11) && !m.states.map.contains_key(&12));
}
c.refuse(1, refused.failure);
assert!(c.refused(1).is_some() && c.refused(1).is_none());
@@ -253,11 +251,11 @@ mod tests {
fn passed_checks_store_what_waited() {
let (cache, kept) = (Mutex::new(EvalCache::default()), KeptSlot(None));
let mut c = checks();
c.store(0, 10, entry(), &cache, &kept);
c.store(0, entry(10), &cache, &kept);
assert_eq!(cache.lock().unwrap().len(), 1);
assert!(c.start(1, saved(), failure(), later(Ok(()))).is_ok());
c.store(1, 11, entry(), &cache, &kept);
c.store(2, 12, entry(), &cache, &kept);
c.store(1, entry(11), &cache, &kept);
c.store(2, entry(12), &cache, &kept);
assert!(c.settle(true, &cache, &kept).is_none());
assert_eq!(cache.lock().unwrap().len(), 3);
}
+29 -1
View File
@@ -379,8 +379,36 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
}
Ok(())
}
FeatureKind::SurfaceSweep { sketch, profiles, curves, path_sketch, path, orientation, twist, scale, rails, rail_fit } => {
FeatureKind::SurfaceSweep {
sketch,
profiles,
curves,
path_sketch,
path,
path_body,
path_edges,
orientation,
twist,
scale,
rails,
rail_fit,
} => {
let ss = st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?;
// The path is a chain of a body's edges (#2216) or the curves of a sketch.
if let Some(body) = path_body {
let chains = sheet_chains(ss, profiles.as_ref(), curves)?;
// The path runs on from the end nearest the middle of the curves.
let pts: Vec<Vec2> = chains.iter().flatten().flat_map(|s| s.polyline(1e-2)).collect();
let start = ss.plane.to_world(pts.iter().fold(Vec2::default(), |a, p| a + *p) * (1.0 / pts.len().max(1) as f64));
let segs = crate::eval::edge_path_segments(st, body, path_edges, start, Some(&ss.plane))?;
let path_start = segs.first().map(kernel::PathSeg::start).unwrap_or(start);
let rails = crate::eval::rail_polylines(doc, st, rails, f.component, path_start)?;
let ctl = crate::eval::sweep_control(vals, orientation, twist, scale, rails, *rail_fit)?;
let body = kernel::sweep_sheet_with(&ss.plane, &chains, &segs, &ctl)?;
let traced = crate::lineage::ChainRoles { sketch: ss, chains: &chains }.tool(body);
push_sheet(st, f, traced);
return Ok(());
}
let ps =
st.sketch(path_sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {path_sketch} (it must come earlier in the timeline)")))?;
// As Sweep: a 3D path sketch is followed in the feature's frame, the curves with it.
+3 -3
View File
@@ -883,7 +883,7 @@ fn cut_with_flanks(
kernel::boolean(&input.body, &sleeve, BoolOp::Union)?.ok_or_else(|| DocError::Invalid("narrowing the hole removed everything".into()))?;
let unnamed = crate::lineage::Lineage::unnamed_faces(faces);
let lineage = input.lineage.and_then(|l| crate::lineage::Lineage::step(&filled, &[&l, &unnamed]));
input = Traced { body: filled, lineage };
input = Traced { body: filled, lineage, recipe: None };
groove_radius = r_min;
}
// The exact grooves: SMLib's screw sweeps, cut by one B-rep boolean.
@@ -905,7 +905,7 @@ fn cut_with_flanks(
kernel::boolean(&input.body, &cone, BoolOp::Cut)?.ok_or_else(|| DocError::Invalid("the chamfer removed everything".into()))?;
let unnamed = crate::lineage::Lineage::unnamed_faces(faces);
let lineage = input.lineage.and_then(|l| crate::lineage::Lineage::step(&cut, &[&l, &unnamed]));
input = Traced { body: cut, lineage };
input = Traced { body: cut, lineage, recipe: None };
}
}
Ok(input)
@@ -957,7 +957,7 @@ fn cut_with_flanks(
// The groove's faces have no role to name them by.
let groove = crate::lineage::Lineage::unnamed_faces(groove_faces);
let lineage = input.lineage.and_then(|l| crate::lineage::Lineage::step(&nb, &[&l, &groove]));
let nb = Traced { body: nb, lineage };
let nb = Traced { body: nb, lineage, recipe: None };
let nb = if continuous_flanks { nb } else { chamfer(nb)? };
if let Some(slot) = st.bodies.get_mut(i) {
*slot = ModelBody::traced_new(mb.name, nb, mb.feature);
+10 -3
View File
@@ -819,8 +819,9 @@ pub(crate) fn decode(mut v: Value, version: u32, applied: Option<&[String]>) ->
if !all_unique(d.features.iter().map(|f| f.id))
|| !all_unique(d.components.iter().map(|c| c.id))
|| !all_unique(d.occurrences.iter().map(|o| o.id))
|| !all_unique(d.configs.rows.iter().map(|r| r.id))
{
return Err(DocError::Invalid("damaged design: duplicate feature, component or occurrence id".into()));
return Err(DocError::Invalid("damaged design: duplicate feature, component, occurrence or configuration row id".into()));
}
let component_ids: std::collections::BTreeSet<_> = d.components.iter().map(|component| component.id).chain(std::iter::once(0)).collect();
for feature in &d.features {
@@ -2212,9 +2213,10 @@ mod tests {
)
.unwrap();
d.configs.columns = vec![Column::Param("width".into()), Column::Suppress(feature)];
let ids = [d.allocate_id().unwrap(), d.allocate_id().unwrap()];
d.configs.rows = vec![
ConfigRow { name: "Default".into(), cells: vec!["10 mm".into(), "false".into()] },
ConfigRow { name: "Alternate".into(), cells: vec!["width / 2 + 5 mm".into(), "yes".into()] },
ConfigRow { id: ids[0], name: "Default".into(), cells: vec!["10 mm".into(), "false".into()] },
ConfigRow { id: ids[1], name: "Alternate".into(), cells: vec!["width / 2 + 5 mm".into(), "yes".into()] },
];
d.configs.active = Some("Default".into());
let mut legacy: Value = serde_json::from_str(&d.to_json()).unwrap();
@@ -2238,6 +2240,7 @@ mod tests {
let mut d = Document::new("configured styles");
d.configs.columns = vec![Column::Material("Body".into()), Column::Appearance("Body".into())];
d.configs.rows = vec![ConfigRow {
id: d.allocate_id().unwrap(),
name: "Blue steel".into(),
cells: vec!["Steel".into(), r#"{"name":"Custom blue","color":[40,90,186],"opacity":0.4}"#.into()],
}];
@@ -2549,12 +2552,15 @@ mod tests {
("contact_sets", json!({"id": 100, "name": "Contact", "occurrences": []})),
("studies", json!({"id": 100, "name": "Study", "steps": 10, "keys": []})),
("exploded", json!({"id": 100, "name": "View", "moves": []})),
("configs", json!({"columns": [], "rows": [{"id": 100, "name": "Row", "cells": []}]})),
];
for (field, item) in cases {
let json = edited(&Document::new("Legacy IDs"), |v| {
v.as_object_mut().unwrap().remove("next_id");
if matches!(field, "browser_groups" | "timeline_groups" | "canvases") {
v[field] = json!([item]);
} else if field == "configs" {
v[field] = item;
} else {
v["assembly"] = json!({field: [item]});
}
@@ -2565,6 +2571,7 @@ mod tests {
back.timeline_groups.clear();
back.canvases.clear();
back.assembly = Default::default();
back.configs = Default::default();
let mut reopened = read(&back.to_json()).unwrap();
assert_eq!(reopened.allocate_id().unwrap(), 101, "{field}: deleted legacy id was forgotten");
assert_eq!(reopened.allocate_id().unwrap(), 102, "{field}");
@@ -0,0 +1,151 @@
//! Configuration rows save a fixed id (`id`, #14750), from the design's id counter: a rename keeps
//! it and no other row is ever given it, so what points at a row still finds it after a rename.
//! The table still saves its active row by name. An older design's rows get their ids as it opens,
//! in row order from its saved `next_id` (past any id a row already has), and `next_id` moves past
//! them. The last release warns about the newer format number, opens the design with its whole
//! table and keeps the row ids it can't read.
use serde_json::Value;
use crate::{DocError, Result};
pub fn step(v: &mut Value) -> Result<()> {
let saved = v.get("next_id").and_then(Value::as_u64).unwrap_or(0);
let Some(rows) = v.pointer_mut("/configs/rows").and_then(Value::as_array_mut) else { return Ok(()) };
let past = |id: u64| id.checked_add(1).ok_or_else(|| DocError::Invalid("damaged design: the id space is exhausted".into()));
let mut next = saved.max(1);
if let Some(largest) = rows.iter().filter_map(|row| row.get("id")?.as_u64()).max() {
next = next.max(past(largest)?);
}
let first = next;
for row in rows.iter_mut().filter_map(Value::as_object_mut) {
if !row.contains_key("id") {
row.insert("id".into(), next.into());
next = past(next)?;
}
}
if next != first
&& let Some(design) = v.as_object_mut()
{
design.insert("next_id".into(), next.into());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::step;
use serde_json::{Value, json};
fn design(next_id: Option<u64>, rows: Value) -> Value {
let mut v = json!({"name": "Plate", "configs": {"columns": [], "rows": rows, "active": "Long"}});
if let Some(next) = next_id {
v["next_id"] = next.into();
}
v
}
fn ids(v: &Value) -> Vec<Option<u64>> {
v["configs"]["rows"].as_array().map_or(Vec::new(), |rows| rows.iter().map(|row| row["id"].as_u64()).collect())
}
/// Rows get ids in order from the saved counter, past any id a row has, and the counter moves
/// past them; the active row stays named.
#[test]
fn an_older_designs_rows_get_ids_in_order_from_its_counter() {
let rows = json!([{"name": "Short", "cells": []}, {"name": "Long", "cells": []}]);
let mut v = design(Some(7), rows.clone());
step(&mut v).unwrap();
assert_eq!((ids(&v), &v["next_id"], &v["configs"]["active"]), (vec![Some(7), Some(8)], &json!(9), &json!("Long")));
// No counter: from 1.
let mut v = design(None, rows);
step(&mut v).unwrap();
assert_eq!((ids(&v), &v["next_id"]), (vec![Some(1), Some(2)], &json!(3)));
// A row that has an id keeps it, and the others go past it.
let mut v = design(Some(2), json!([{"name": "Short", "cells": []}, {"id": 40, "name": "Long", "cells": []}]));
step(&mut v).unwrap();
assert_eq!((ids(&v), &v["next_id"]), (vec![Some(41), Some(40)], &json!(42)));
}
/// A design with no table, or whose rows all have ids, is left as it is.
#[test]
fn a_design_with_nothing_to_give_is_left_as_it_is() {
for mut v in
[json!({"name": "Plate", "next_id": 3}), design(Some(3), json!([{"id": 1, "name": "Long", "cells": []}])), design(None, json!([]))]
{
let before = v.clone();
step(&mut v).unwrap();
assert_eq!(v, before);
}
}
/// A counter at the end of the id space can give no id: the design is refused, not given a
/// repeated one.
#[test]
fn a_counter_at_the_end_is_refused() {
let mut v = design(Some(u64::MAX), json!([{"name": "Long", "cells": []}]));
assert!(step(&mut v).unwrap_err().to_string().contains("id space is exhausted"));
let mut v = design(Some(1), json!([{"id": u64::MAX, "name": "Short", "cells": []}, {"name": "Long", "cells": []}]));
assert!(step(&mut v).is_err());
}
/// The largest table a design may hold gets one id per row, in order, in one pass.
#[test]
fn the_largest_table_gets_an_id_for_every_row() {
let rows: Vec<Value> = (0..crate::config::MAX_ROWS).map(|i| json!({"name": format!("Row {i}"), "cells": []})).collect();
let mut v = design(Some(5), Value::Array(rows));
step(&mut v).unwrap();
let expected: Vec<Option<u64>> = (5..5 + crate::config::MAX_ROWS as u64).map(Some).collect();
assert_eq!(ids(&v), expected);
assert_eq!(v["next_id"], json!(5 + crate::config::MAX_ROWS as u64));
}
/// An older design opens with an id for every row, its active row still matching, and the
/// design's next id past them; rows that repeat an id are a damaged design.
#[test]
fn an_older_design_opens_with_row_ids_and_its_active_row() {
let mut d = crate::Document::new("Plate");
d.set_param("L", "25 mm", None, None).unwrap();
d.configs.columns = vec![crate::config::Column::Param("L".into())];
let ids = [d.allocate_id().unwrap(), d.allocate_id().unwrap()];
d.configs.rows = vec![
crate::config::ConfigRow { id: ids[0], name: "Short".into(), cells: vec!["25 mm".into()] },
crate::config::ConfigRow { id: ids[1], name: "Long".into(), cells: vec!["30 mm".into()] },
];
d.apply_configuration("Long").unwrap();
let mut old: Value = serde_json::from_str(&d.to_json()).unwrap();
old["format"] = Value::from("solidcraft/6");
for row in old["configs"]["rows"].as_array_mut().unwrap() {
row.as_object_mut().unwrap().remove("id");
}
let opened = crate::format::read(&old.to_string()).unwrap();
let given: Vec<u64> = opened.configs.rows.iter().map(|row| row.id).collect();
assert!(given[0] != given[1] && given.iter().all(|id| *id > 0), "{given:?}");
assert!(opened.next_id > given[0].max(given[1]));
assert_eq!(opened.configs.active_row().map(|row| row.name.as_str()), Some("Long"));
assert!(opened.configuration_matches());
let mut twice: Value = serde_json::from_str(&d.to_json()).unwrap();
twice["configs"]["rows"][1]["id"] = twice["configs"]["rows"][0]["id"].clone();
let refused = crate::format::read(&twice.to_string()).unwrap_err().to_string();
assert!(refused.contains("configuration row id"), "{refused}");
}
/// What the last release does with a row field it doesn't know, as it does with `id`: the
/// table opens whole and the field goes back into the file when it saves.
#[test]
fn a_newer_designs_unknown_row_field_keeps_the_table_whole() {
let mut d = crate::Document::new("Plate");
d.set_param("L", "25 mm", None, None).unwrap();
d.configs.columns = vec![crate::config::Column::Param("L".into())];
d.configs.rows = vec![crate::config::ConfigRow { id: d.allocate_id().unwrap(), name: "Long".into(), cells: vec!["30 mm".into()] }];
d.configs.active = Some("Long".into());
let mut newer: Value = serde_json::from_str(&d.to_json()).unwrap();
newer["format"] = Value::from(crate::format::format_string(crate::format::FORMAT + 1));
newer["configs"]["rows"][0]["revision"] = json!(3);
let opened = crate::format::read(&newer.to_string()).unwrap();
assert_eq!(opened.configs.rows.len(), 1);
assert_eq!(opened.configs.active.as_deref(), Some("Long"));
let saved: Value = serde_json::from_str(&opened.to_json()).unwrap();
assert_eq!(saved["configs"], newer["configs"], "kept for the build that reads it");
}
}
@@ -0,0 +1,47 @@
//! A hole at the points of a sketch on an offset, construction or other derived plane now starts
//! at that plane when the plane lies inside a body, instead of at the body's surface (#11538).
//! Older builds moved every such point back to the surface, so this step marks every saved hole
//! at sketch points (not a Single hole) `old_surface_start`, which keeps that reading: designs
//! saved before rebuild their holes where they were. Holes at a position, Single holes and holes
//! made or opened from now on are unchanged. The last release asks for an update instead of
//! opening a design that carries this change.
use serde_json::Value;
pub fn step(v: &mut Value) -> crate::Result<()> {
let Some(Value::Array(features)) = v.get_mut("features") else {
return Ok(());
};
for feature in features {
if feature.get("type").and_then(Value::as_str) != Some("hole") {
continue;
}
let at_points =
feature.get("points").and_then(Value::as_object).is_some_and(|points| !points.get("single").and_then(Value::as_bool).unwrap_or(false));
if let (true, Some(object)) = (at_points, feature.as_object_mut()) {
object.insert("old_surface_start".into(), Value::Bool(true));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use serde_json::json;
#[test]
fn saved_holes_at_sketch_points_keep_starting_at_the_surface() {
let mut v = json!({"features": [
{"type": "hole", "points": {"sketch": 3, "ids": ["p1"]}},
{"type": "hole", "points": {"sketch": 3, "ids": ["p1", "p2"], "flip": true}},
{"type": "hole", "points": {"sketch": 3, "ids": ["p1"], "single": true}},
{"type": "hole", "position": [0.0, 0.0, 10.0]},
{"type": "extrude", "points": {"sketch": 3, "ids": ["p1"]}}
]});
super::step(&mut v).unwrap();
let marked: Vec<bool> = v["features"].as_array().unwrap().iter().map(|f| f["old_surface_start"] == json!(true)).collect();
assert_eq!(marked, [true, true, false, false, false]);
let mut empty = json!({"name": "Untitled"});
super::step(&mut empty).unwrap();
assert_eq!(empty, json!({"name": "Untitled"}));
}
}
@@ -0,0 +1,5 @@
//! A sweep saves the path it follows when the path is a chain of a body's edges: `path_body`
//! (the body) and `path_edges` (a point on each edge of the chain), beside the curves of the
//! path sketch a sweep saved before this change has. An older design has none and keeps its
//! path of sketch curves, as it was built. The last release refuses a design that has it (it
//! asks for an update) instead of opening it without the path.
@@ -0,0 +1,9 @@
//! A joint can say which assembly constraint it was made as (`"constraint": "mate"`, `flush`,
//! `distance`, `concentric`, `insert`, `parallel`, `perpendicular`, `angle`, `tangent` or
//! `point_on_axis`), written only for a joint made with `assembly.constraint.create`. The joint
//! itself is an ordinary joint of the kind the rule needs (a mate is a planar joint), so the solver
//! never reads the field; it names the rule in the browser and the Constrain dialog, and makes the
//! joint start where its parts are when it is changed. Older designs need no transformation: their
//! joints have no constraint and are written as before. The last release opens a design with such
//! a joint with a warning and keeps the field it can't read; there the joint still solves as its
//! kind.
+4
View File
@@ -0,0 +1,4 @@
//! A design saves its notes (`notes`): text anchored to a point in model space, drawn in the
//! viewport as a small screen-facing callout. An older design has none, and opens exactly as
//! before. The last release refuses a design that has notes and asks for an update, instead of
//! opening it and losing them on the next save.
@@ -0,0 +1,5 @@
//! A sketch text's style can say where its block of lines sits up and down against its anchor
//! (`at`, or the curve it follows): `vertical_align` is `top`, `middle` or `bottom`, and is left
//! out for the first line's baseline, where every text sat before. Older designs need no rewrite:
//! without the key a text stays on its baseline. The last release opens a design that carries the
//! key with a warning, keeps what it can't read, and draws such a text on its baseline.
+86
View File
@@ -429,6 +429,75 @@ impl JointKind {
}
}
/// The assembly constraint a joint was made as (`assembly.constraint.create`): the rule in the
/// words of the Constrain dialog. The joint itself is an ordinary joint of the kind the rule needs
/// (a mate is planar, a concentric constraint cylindrical); the solver never reads this.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Constraint {
/// Two planes against each other (planar), `offset` apart.
Mate,
/// Two planes facing the same way (planar, flipped), `offset` apart.
Flush,
/// A plane `offset` from a plane (planar) or a point `offset` from a plane (point on plane).
Distance,
/// Two axes on one line (cylindrical): B slides and turns along it.
Concentric,
/// Two round edges at one centre on one axis (revolute): B only turns.
Insert,
/// Two planes or axes parallel.
Parallel,
/// Two planes or axes square to each other.
Perpendicular,
/// Two planes or axes at `angle`.
Angle,
/// A cylinder resting on a plane (line on plane, `offset` its radius).
Tangent,
/// A point on an axis (point on line).
PointOnAxis,
}
impl Constraint {
pub const ALL: [Constraint; 10] = [
Constraint::Mate,
Constraint::Flush,
Constraint::Distance,
Constraint::Concentric,
Constraint::Insert,
Constraint::Parallel,
Constraint::Perpendicular,
Constraint::Angle,
Constraint::Tangent,
Constraint::PointOnAxis,
];
/// The rule's command name (`mate`, `point_on_axis`).
pub fn name(self) -> &'static str {
match self {
Constraint::Mate => "mate",
Constraint::Flush => "flush",
Constraint::Distance => "distance",
Constraint::Concentric => "concentric",
Constraint::Insert => "insert",
Constraint::Parallel => "parallel",
Constraint::Perpendicular => "perpendicular",
Constraint::Angle => "angle",
Constraint::Tangent => "tangent",
Constraint::PointOnAxis => "point_on_axis",
}
}
/// Case, spaces, `_` and `-` are ignored; `coincident` and `axis` are also taken.
pub fn parse(s: &str) -> Option<Constraint> {
let key = s.to_ascii_lowercase().replace([' ', '_', '-'], "");
match key.as_str() {
"coincident" => Some(Constraint::Mate),
"axis" => Some(Constraint::Concentric),
_ => Constraint::ALL.into_iter().find(|c| c.name().replace('_', "") == key),
}
}
}
/// A joint between two occurrences.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Joint {
@@ -462,6 +531,17 @@ pub struct Joint {
/// files from before captured joint values: such a joint keeps its values on Revert Position.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub captured: Option<Vec<f64>>,
/// The assembly constraint it was made as; `None` for a joint made as a joint.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub constraint: Option<Constraint>,
}
impl Joint {
/// Whether its values start where its parts are when it is made or changed, so B moves no
/// further than the joint needs: a relation, or any joint made as an assembly constraint.
pub fn starts_where_it_is(&self) -> bool {
self.kind.is_relation() || self.constraint.is_some()
}
}
/// Expressions a joint's numbers follow (over the design's parameters): the offset (a length),
@@ -2527,6 +2607,11 @@ impl Document {
let t = if o.occurrence == 0 { IDENTITY } else { occurrence_world(self, o.occurrence) };
Ok(mat_mul(&t, &f))
}
/// The radius of the round face a circle origin is on (mm; an error when it is on none).
pub fn origin_radius(&self, st: &ModelState, o: &JointOrigin) -> Result<f64> {
circle_at(self, st, o).map(|c| c.radius)
}
}
#[path = "joints_cam.rs"]
@@ -2756,6 +2841,7 @@ mod tests {
suppressed: false,
exprs: Default::default(),
captured: None,
constraint: None,
})
.collect();
let links =
+1
View File
@@ -945,6 +945,7 @@ mod tests {
suppressed: false,
exprs: Default::default(),
captured: None,
constraint: None,
};
let occ = |id: u64, grounded: bool| crate::Occurrence { id, component: id, parent: 0, name: format!("O{id}"), transform: IDENTITY, grounded };
// 1 frame (grounded), 2 cam, 3-6 a rigid chain to the guide 7, 8 the piston, 9 crank, 10 rod.
+1
View File
@@ -94,6 +94,7 @@ mod tests {
suppressed: false,
exprs: Default::default(),
captured: None,
constraint: None,
}
}
+2
View File
@@ -49,6 +49,8 @@ mod lineage;
pub mod metadata;
pub use metadata::Metadata;
pub mod motion;
pub mod notes;
pub use notes::Note;
mod name_lookup;
mod names;
pub mod naming;
+15 -7
View File
@@ -148,6 +148,9 @@ impl Lineage {
pub(crate) struct Traced {
pub body: Body,
pub lineage: Option<Lineage>,
/// How the feature built it, when that alone fixes it (a primitive from its numbers, a copy
/// of one moved): what a cut by it is remembered by (see `ModelBody::cut`).
pub recipe: Option<u64>,
}
impl Traced {
@@ -155,13 +158,13 @@ impl Traced {
/// geometry, those of the bodies it is combined with by their history.
pub fn unnamed(body: Body) -> Traced {
let roles = vec![Vec::new(); body.face_count()];
Traced { lineage: Some(Lineage::of(&body, Operand::Made(roles))), body }
Traced { lineage: Some(Lineage::of(&body, Operand::Made(roles))), body, recipe: None }
}
/// A body the feature built, its faces' roles given (one per face), else not known.
pub fn made(body: Body, roles: Option<Vec<Vec<String>>>) -> Traced {
match roles.filter(|r| r.len() == body.face_count()) {
Some(roles) => Traced { lineage: Some(Lineage::of(&body, Operand::Made(roles))), body },
Some(roles) => Traced { lineage: Some(Lineage::of(&body, Operand::Made(roles))), body, recipe: None },
None => Traced::unnamed(body),
}
}
@@ -170,14 +173,19 @@ impl Traced {
/// which built it.
pub fn copied(&self, copy: Body, feature: u64, instance: usize) -> Traced {
let lineage = self.lineage.as_ref().and_then(|l| Lineage::step(&copy, &[&l.copied(feature, instance)]));
Traced { body: copy, lineage }
Traced { body: copy, lineage, recipe: None }
}
/// This tool, built by `recipe`.
pub fn with_recipe(self, recipe: Option<u64>) -> Traced {
Traced { recipe, ..self }
}
/// `result`, made by one kernel operation from `inputs` (in its operand order).
pub fn step(result: Body, inputs: &[&Traced]) -> Traced {
let lineages: Option<Vec<&Lineage>> = inputs.iter().map(|t| t.lineage.as_ref()).collect();
let lineage = lineages.and_then(|l| Lineage::step(&result, &l));
Traced { body: result, lineage }
Traced { body: result, lineage, recipe: None }
}
/// `result`, made by one kernel operation from this body alone. Where that operation's
@@ -198,7 +206,7 @@ impl Traced {
let stage = Arc::new(Operand::Stage(Box::new(Stage { body, lineage: l.clone(), names: OnceLock::new() })));
Some(Lineage { history, operands: vec![stage] })
});
Traced { body: result, lineage }
Traced { body: result, lineage, recipe: None }
}
}
@@ -571,14 +579,14 @@ impl crate::ModelBody {
Made::Traced(l) => Some(l.as_ref().clone()),
Made::Unknown => None,
};
Traced { body: self.body.clone(), lineage }
Traced { body: self.body.clone(), lineage, recipe: None }
}
/// This body, as it was before the feature, as an operand named by `operand` (a moved copy of
/// it, the cavity side of a shell grown from it); `None` once the feature has changed it.
pub(crate) fn before_as(&self, operand: Operand) -> Traced {
let lineage = matches!(self.made, Made::Before).then(|| Lineage::of(&self.body, operand));
Traced { body: self.body.clone(), lineage }
Traced { body: self.body.clone(), lineage, recipe: None }
}
/// The lineage a feature left this body with, when it made or changed it with one.
+39 -3
View File
@@ -234,6 +234,17 @@ impl Writer {
if let Some(look) = &b.imported {
let faces: Vec<Json> = look.faces.iter().map(|(name, (color, opacity))| json!([name, color, opacity])).collect();
r["imported"] = json!({"color": look.color, "opacity": look.opacity, "faces": faces});
if look.name.is_some() || !look.face_names.is_empty() {
r["imported"]["name"] = json!(look.name);
r["imported"]["face_names"] = json!(look.face_names);
}
}
// The kernel body's own encoding keeps no colour names: the file's names for its paint.
if let Some(paint) = b.body.paint() {
let faces: Vec<Json> = paint.faces.iter().filter_map(|f| Some(json!([f.face, f.name.as_ref()?]))).collect();
if paint.name.is_some() || !faces.is_empty() {
r["paint_names"] = json!({"body": paint.name, "faces": faces});
}
}
r["body"] = json!(self.body(&b.body));
self.model_bodies.push(r);
@@ -447,12 +458,34 @@ impl SavedModel {
b.bytes = b.body.retained_bytes();
if let Some(look) = r.get("imported") {
let faces: Vec<(String, [f32; 3], f32)> = serde_json::from_value(parse(look.get("faces"))?).map_err(|_| bad("saved model: look"))?;
let colour_name = |v: Option<&Json>| -> Result<Option<String>> {
let name: Option<String> = serde_json::from_value(v.cloned().unwrap_or(Json::Null)).map_err(|_| bad("saved model: look"))?;
Ok(name.as_deref().and_then(solidcraft_kernel::colour_name))
};
let face_names: BTreeMap<String, String> =
serde_json::from_value(look.get("face_names").cloned().unwrap_or_else(|| json!({}))).map_err(|_| bad("saved model: look"))?;
b.imported = Some(Arc::new(crate::appearance::ImportedLook {
color: serde_json::from_value(parse(look.get("color"))?).map_err(|_| bad("saved model: look"))?,
opacity: serde_json::from_value(parse(look.get("opacity"))?).map_err(|_| bad("saved model: look"))?,
faces: faces.into_iter().map(|(name, color, opacity)| (name, (color, opacity))).collect(),
name: colour_name(look.get("name"))?,
face_names: face_names.into_iter().filter_map(|(face, name)| Some((face, solidcraft_kernel::colour_name(&name)?))).collect(),
}));
}
if let Some(names) = r.get("paint_names")
&& let Some(paint) = b.body.paint()
{
let body: Option<String> =
serde_json::from_value(names.get("body").cloned().unwrap_or(Json::Null)).map_err(|_| bad("saved model: paint"))?;
let faces: Vec<(usize, String)> =
serde_json::from_value(names.get("faces").cloned().unwrap_or_else(|| json!([]))).map_err(|_| bad("saved model: paint"))?;
let mut paint = paint.clone();
paint.name = body.as_deref().and_then(solidcraft_kernel::colour_name);
for f in &mut paint.faces {
f.name = faces.iter().rfind(|(face, _)| *face == f.face).and_then(|(_, name)| solidcraft_kernel::colour_name(name));
}
b.body = b.body.clone().with_paint(Some(paint));
}
model_bodies.push(b);
}
let mut sheets = Vec::new();
@@ -944,8 +977,10 @@ impl Model {
before = r.state.clone();
continue;
}
let local = fingerprint(doc, 0, f, &vals, false, &model.import_digests) ^ placement_digest(doc, f, Some(&before));
cache.put(
let local = fingerprint(doc, 0, f, &vals, false, &model.import_digests)
^ placement_digest(doc, f, Some(&before))
^ construct_digest(doc, &vals, f, Some(&before));
cache.states.put(
cache_key(f, r.fingerprint, local, ctx, &before),
CachedEval {
error: None,
@@ -953,9 +988,10 @@ impl Model {
import_report: r.import_report.clone(),
repair: r.repair.clone(),
ms: r.ms,
bytes: r.state.retained_bytes(),
state: r.state.clone(),
},
r.state.retained_bytes(),
r.ms,
);
before = r.state.clone();
}
+76
View File
@@ -0,0 +1,76 @@
//! Notes: text anchored to a point in model space, drawn in the viewport as a small
//! screen-facing callout (#1631). Personal design documentation — reminders, decisions,
//! warnings — saved with the design, not collaboration.
use serde::{Deserialize, Serialize};
use solidcore_record::Vec3;
/// Longest text a note may hold (characters).
pub const MAX_NOTE_CHARS: usize = 4000;
/// A note: an anchor point in model space (mm, design coordinates) and the text shown at
/// its projection. The callout always faces the screen because it is drawn in screen space.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Note {
pub id: u64,
/// The point the note is anchored to (design coordinates, mm).
pub anchor: Vec3,
/// The note's text (may span lines).
pub text: String,
/// Hidden notes are kept but not drawn (`browser.visibility` shows and hides them).
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub hidden: bool,
}
impl Note {
/// True when the text is usable: not empty and within [`MAX_NOTE_CHARS`].
pub fn text_is_valid(text: &str) -> bool {
!text.trim().is_empty() && text.chars().count() <= MAX_NOTE_CHARS
}
}
#[cfg(test)]
mod tests {
use super::*;
fn note(id: u64) -> Note {
Note { id, anchor: Vec3::new(1.0, 2.0, 3.0), text: format!("Check clearance here.\nLine {id}"), hidden: false }
}
#[test]
fn a_design_with_notes_survives_a_round_trip() {
let mut doc = crate::Document::new("Noted");
doc.notes.push(note(1));
doc.notes.push(Note { id: 2, anchor: Vec3::new(-4.0, 0.0, 6.5), text: "Adjust after the test print".into(), hidden: true });
let json = serde_json::to_string(&doc).unwrap();
let back: crate::Document = serde_json::from_str(&json).unwrap();
assert_eq!(back, doc);
assert_eq!(back.notes.len(), 2);
assert!(back.notes[1].hidden);
}
#[test]
fn a_design_saved_before_notes_loads_with_none() {
// An older design file: no `notes` key at all.
let json = serde_json::to_string(&crate::Document::new("Old")).unwrap();
let back: crate::Document = serde_json::from_str(&json).unwrap();
assert!(back.notes.is_empty());
}
#[test]
fn hidden_notes_are_not_written_when_they_are_visible() {
let mut doc = crate::Document::new("Noted");
doc.notes.push(note(1));
let json = serde_json::to_string(&doc).unwrap();
assert!(!json.contains("hidden"));
assert!(serde_json::from_str::<crate::Document>(&json).unwrap().notes[0].text.starts_with("Check"));
}
#[test]
fn note_text_must_be_non_empty_and_bounded() {
assert!(Note::text_is_valid("Extra thickness added for strength."));
assert!(!Note::text_is_valid(" "));
assert!(!Note::text_is_valid(&"x".repeat(MAX_NOTE_CHARS + 1)));
assert!(Note::text_is_valid(&"x".repeat(MAX_NOTE_CHARS)));
}
}
+1
View File
@@ -161,6 +161,7 @@ fn same_geometry_inputs(a: &Document, b: &Document) -> bool {
named_views,
visibility,
point_clouds,
notes: _,
} = a;
(Arc::ptr_eq(render, &b.render) || *render == b.render)
&& *format == b.format
+397 -2
View File
@@ -294,10 +294,11 @@ fn set_back_restores_everything_when_states_share_bodies() {
m.evaluate(&doc);
// Room for a few whole models: enough for both timelines' new bodies, not for every state
// charged as a whole model.
m.cache.lock().unwrap().budget = 3 * m.state().retained_bytes();
m.cache.lock().unwrap().set_budget(3 * m.state().retained_bytes());
doc.set_param("h", "12 mm", None, None).unwrap();
m.evaluate(&doc);
assert_eq!(m.last_recomputed, 30);
// Only the box reading `h` is made again (#6183): the others' changes are reused.
assert_eq!(m.last_recomputed, 1);
doc.set_param("h", "10 mm", None, None).unwrap();
m.evaluate(&doc);
assert_eq!((m.last_recomputed, m.last_restored), (0, 30));
@@ -447,6 +448,400 @@ fn rolled_results_never_mask_edits_or_disabled_reuse() {
}
}
fn add_in(doc: &mut Document, component: u64, name: &str, kind: serde_json::Value) {
let kind: FeatureKind = serde_json::from_value(kind).unwrap();
let id = doc.add_feature(kind, Some(name)).unwrap();
doc.feature_mut(id).unwrap().component = component;
}
/// A plate in one component, then a rod in another, slotted by a box cut patterned round it.
fn two_parts(slot_targets: &[&str]) -> Document {
let mut doc = Document::new("two parts");
doc.set_param("w", "40 mm", None, None).unwrap();
doc.set_param("slot", "1 mm", None, None).unwrap();
let plate = doc.add_component(Some("Plate"), 0).unwrap();
let rod = doc.add_component(Some("Rod"), 0).unwrap();
add_in(
&mut doc,
plate,
"Plate",
serde_json::json!({"type": "box", "corner": ["0", "0", "0"], "length": "w", "width": "30", "height": "5", "operation": "new_body"}),
);
add_in(
&mut doc,
rod,
"Rod",
serde_json::json!({"type": "cylinder", "base": ["0", "0", "0"], "axis": [0.0, 0.0, 1.0], "radius": "4", "height": "20", "operation": "new_body"}),
);
add_in(
&mut doc,
rod,
"Slot",
serde_json::json!({"type": "box", "corner": ["3", "-0.5", "2"], "length": "2", "width": "slot", "height": "20", "operation": "cut"}),
);
for f in doc.features.iter_mut().filter(|f| f.name == "Plate" || f.name == "Rod") {
f.body_names = vec![f.name.clone()];
}
if let Some(f) = doc.features.iter_mut().find(|f| f.name == "Slot") {
f.participants = slot_targets.iter().map(|t| t.to_string()).collect();
}
let slot = doc.find_feature("Slot").map_or(0, |f| f.id);
add_in(
&mut doc,
rod,
"Slots",
serde_json::json!({"type": "pattern", "features": [slot], "pattern": {"type": "circular", "origin": [0.0, 0.0, 0.0], "axis": [0.0, 0.0, 1.0], "count": "4", "angle": "360 deg"}}),
);
doc
}
/// The bodies as an evaluation from scratch makes them: names, volumes, face names.
fn summary(st: &ModelState) -> Vec<(String, u64, i64, Vec<String>)> {
st.bodies
.iter()
.map(|b| {
let volume = measure(&b.body).unwrap().volume;
(b.name.clone(), b.component, (volume * 1e6).round() as i64, crate::naming::face_names(b).to_vec())
})
.collect()
}
fn from_scratch(doc: &Document) -> Vec<(String, u64, i64, Vec<String>)> {
let mut m = Model::new();
m.no_cache = true;
m.evaluate(doc);
summary(&m.state())
}
/// An edit in one component leaves another component's features after it as they were: they
/// are restored, not recomputed, and give what evaluating from scratch gives.
#[test]
fn edit_in_one_component_keeps_the_others_results() {
let mut doc = two_parts(&[]);
let mut m = Model::new();
m.evaluate(&doc);
assert!(m.results.iter().all(|r| r.error.is_none()), "{:?}", m.results.iter().map(|r| &r.error).collect::<Vec<_>>());
assert_eq!(m.last_recomputed, 4);
doc.set_param("w", "50 mm", None, None).unwrap();
m.evaluate(&doc);
assert_eq!((m.last_recomputed, m.last_restored), (1, 3));
assert_eq!(summary(&m.state()), from_scratch(&doc));
// An edit in the rod's own component recomputes its features.
doc.set_param("slot", "1.5 mm", None, None).unwrap();
m.evaluate(&doc);
assert_eq!((m.last_recomputed, m.last_restored), (2, 0));
assert_eq!(summary(&m.state()), from_scratch(&doc));
doc.set_param("w", "60 mm", None, None).unwrap();
m.evaluate(&doc);
assert_eq!((m.last_recomputed, m.last_restored), (1, 3));
assert_eq!(summary(&m.state()), from_scratch(&doc));
}
/// A sketch on a fixed plane, the new body extruded from it and that body moved read nothing
/// of another component either.
#[test]
fn sketch_extrude_and_move_keep_their_results() {
let mut doc = two_parts(&[]);
let rod = doc.find_component("Rod").unwrap();
let mut sk = Sketch::new();
sk.add_circle(Vec2::new(20.0, 0.0), 2.0, None, None).unwrap();
let id = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: sk }, Some("Peg sketch")).unwrap();
doc.feature_mut(id).unwrap().component = rod;
add_in(
&mut doc,
rod,
"Peg",
serde_json::json!({"type": "extrude", "sketch": id, "profiles": {"type": "all"}, "extent": {"convention": "total_distance", "direction": "positive", "distance": "8"}, "operation": "new_body"}),
);
if let Some(f) = doc.features.iter_mut().find(|f| f.name == "Peg") {
f.body_names = vec!["Peg".into()];
}
add_in(&mut doc, rod, "Peg up", serde_json::json!({"type": "move", "bodies": ["Peg"], "translate": ["0", "0", "slot * 3"]}));
let mut m = Model::new();
m.evaluate(&doc);
assert!(m.results.iter().all(|r| r.error.is_none()), "{:?}", m.results.iter().map(|r| &r.error).collect::<Vec<_>>());
doc.set_param("w", "50 mm", None, None).unwrap();
m.evaluate(&doc);
assert_eq!((m.last_recomputed, m.last_restored), (1, 6));
assert_eq!(summary(&m.state()), from_scratch(&doc));
// The move follows its own parameter.
doc.set_param("slot", "2 mm", None, None).unwrap();
m.evaluate(&doc);
assert_eq!((m.last_recomputed, m.last_restored), (3, 2));
assert_eq!(summary(&m.state()), from_scratch(&doc));
}
/// A feature that cuts a body of another component reads it: it is recomputed when that body
/// changes, and so is what follows it in its own component.
#[test]
fn a_cut_of_another_components_body_follows_it() {
let mut doc = two_parts(&["Plate", "Rod"]);
let mut m = Model::new();
m.evaluate(&doc);
assert!(m.results.iter().all(|r| r.error.is_none()), "{:?}", m.results.iter().map(|r| &r.error).collect::<Vec<_>>());
doc.set_param("w", "50 mm", None, None).unwrap();
m.evaluate(&doc);
assert_eq!((m.last_recomputed, m.last_restored), (3, 1));
assert_eq!(summary(&m.state()), from_scratch(&doc));
}
/// A pattern of a cut reads the bodies its source cuts, as the copies cut them: the source's
/// participants, though the pattern names none and that body is in another component. The plate
/// lies clear of every copy at first; moved into one by `w`, it must be cut again.
#[test]
fn a_pattern_reads_the_bodies_its_source_cuts() {
let mut doc = two_parts(&["Plate"]);
if let Some(f) = doc.features.iter_mut().find(|f| f.name == "Plate") {
f.kind = serde_json::from_value(
serde_json::json!({"type": "box", "corner": ["-20", "w", "0"], "length": "40", "width": "30", "height": "5", "operation": "new_body"}),
)
.unwrap();
}
let mut m = Model::new();
m.evaluate(&doc);
assert!(m.results.iter().all(|r| r.error.is_none()), "{:?}", m.results.iter().map(|r| &r.error).collect::<Vec<_>>());
assert_eq!(summary(&m.state()), from_scratch(&doc));
// The copy turned a quarter round now reaches the plate.
doc.set_param("w", "4 mm", None, None).unwrap();
m.evaluate(&doc);
assert_eq!(summary(&m.state()), from_scratch(&doc));
}
/// A cut of several bodies cuts again only those that changed: the rod, which `w` does not
/// move, keeps the very body its earlier cut gave, while the cap `w` sizes is cut again.
#[test]
fn a_cut_of_several_bodies_cuts_again_only_those_that_changed() {
let mut doc = Document::new("rod and cap");
doc.set_param("w", "6 mm", None, None).unwrap();
let rod = doc.add_component(Some("Rod"), 0).unwrap();
add_in(
&mut doc,
rod,
"Rod",
serde_json::json!({"type": "cylinder", "base": ["0", "0", "0"], "axis": [0.0, 0.0, 1.0], "radius": "4", "height": "20", "operation": "new_body"}),
);
add_in(
&mut doc,
rod,
"Cap",
serde_json::json!({"type": "box", "corner": ["-5", "-5", "20"], "length": "10", "width": "10", "height": "w", "operation": "new_body"}),
);
for f in doc.features.iter_mut().filter(|f| f.name == "Rod" || f.name == "Cap") {
f.body_names = vec![f.name.clone()];
}
// The slot runs through both: the component's bodies are its targets.
add_in(
&mut doc,
rod,
"Slot",
serde_json::json!({"type": "box", "corner": ["3", "-0.5", "2"], "length": "4", "width": "1", "height": "30", "operation": "cut"}),
);
let mut m = Model::new();
m.evaluate(&doc);
assert!(m.results.iter().all(|r| r.error.is_none()), "{:?}", m.results.iter().map(|r| &r.error).collect::<Vec<_>>());
let cut_rod = m.state().body("Rod").unwrap().body.clone();
doc.set_param("w", "8 mm", None, None).unwrap();
m.evaluate(&doc);
assert_eq!(m.last_recomputed, 2, "the cap and the slot");
let st = m.state();
assert!(st.body("Rod").unwrap().body.same(&cut_rod), "the rod's cut is the one made before");
assert_eq!(summary(&st), from_scratch(&doc));
}
/// A parameter that drives the first feature of the timeline, with the bodies after it each cut
/// by a combine: only the first body and its cut are recomputed, every other cut is restored.
#[test]
fn a_combine_reads_only_its_target_and_tools() {
let mut doc = Document::new("rows");
doc.set_param("w", "20 mm", None, None).unwrap();
let c = doc.add_component(Some("Rows"), 0).unwrap();
for i in 0..3 {
let x = (i * 30) as f64;
let (body, tool, cut) = (format!("Body{i}"), format!("Tool{i}"), format!("Cut{i}"));
let length = if i == 0 { "w".to_string() } else { "20".to_string() };
add_in(
&mut doc,
c,
&body,
serde_json::json!({"type": "box", "corner": [x.to_string(), "0", "0"], "length": length, "width": "15", "height": "5", "operation": "new_body"}),
);
add_in(
&mut doc,
c,
&tool,
serde_json::json!({"type": "cylinder", "base": [(x + 7.0).to_string(), "7.5", "-1"], "axis": [0.0, 0.0, 1.0], "radius": "1.5", "height": "7", "operation": "new_body"}),
);
for f in doc.features.iter_mut().filter(|f| f.name == body || f.name == tool) {
f.body_names = vec![f.name.clone()];
}
add_in(&mut doc, c, &cut, serde_json::json!({"type": "combine", "target": body, "tools": [tool], "operation": "cut"}));
}
let mut m = Model::new();
m.evaluate(&doc);
assert!(m.results.iter().all(|r| r.error.is_none()), "{:?}", m.results.iter().map(|r| &r.error).collect::<Vec<_>>());
assert_eq!(m.last_recomputed, 9);
doc.set_param("w", "32 mm", None, None).unwrap();
m.evaluate(&doc);
assert_eq!((m.last_recomputed, m.last_restored), (2, 7), "the first body and its cut");
assert_eq!(summary(&m.state()), from_scratch(&doc));
}
/// A circle sketch on `plane` in `component`, extruded by `distance` (`operation`), named
/// after `name`.
fn circle_extrude(doc: &mut Document, component: u64, name: &str, plane: PlaneRef, center: Vec2, r: f64, distance: &str, operation: &str) {
let mut sk = Sketch::new();
sk.add_circle(center, r, None, None).unwrap();
let id = doc.add_feature(FeatureKind::Sketch { plane, sketch: sk }, Some(&format!("{name} sketch"))).unwrap();
doc.feature_mut(id).unwrap().component = component;
add_in(
doc,
component,
name,
serde_json::json!({"type": "extrude", "sketch": id, "profiles": {"type": "all"}, "extent": {"convention": "total_distance", "direction": "positive", "distance": distance}, "operation": operation}),
);
if let Some(f) = doc.features.iter_mut().find(|f| f.name == name).filter(|_| operation == "new_body") {
f.body_names = vec![name.into()];
}
}
/// Three components on one timeline (#6183): A's post (height `A_h`) with the construction plane
/// `A_top` above it when `plane`; B's post, cut by a hole from a sketch on a plane given by
/// numbers and filleted; C's post, filleted twice (the last by `C_r`). Nothing in B or C reads A.
fn three_parts(plane: bool) -> Document {
let mut doc = Document::new("three parts");
doc.set_param("A_h", "10 mm", None, None).unwrap();
doc.set_param("C_r", "1 mm", None, None).unwrap();
let [a, b, c] = ["A", "B", "C"].map(|n| doc.add_component(Some(n), 0).unwrap());
let xy = || PlaneRef::Origin { name: "XY".into() };
circle_extrude(&mut doc, a, "A post", xy(), Vec2::new(0.0, 0.0), 5.0, "A_h", "new_body");
if plane {
add_in(
&mut doc,
a,
"A_top",
serde_json::json!({"type": "construction_plane", "plane": {"type": "offset", "base": {"type": "origin", "name": "XY"}, "distance": "A_h + 5"}}),
);
}
circle_extrude(&mut doc, b, "B post", xy(), Vec2::new(40.0, 0.0), 8.0, "12", "new_body");
let top = PlaneRef::Offset { base: Box::new(xy()), distance: "12".into() };
circle_extrude(&mut doc, b, "B hole", top, Vec2::new(40.0, 0.0), 3.0, "-4", "cut");
add_in(&mut doc, b, "B round", serde_json::json!({"type": "fillet", "edges": [[48.0, 0.0, 0.0]], "radius": "1"}));
circle_extrude(&mut doc, c, "C post", xy(), Vec2::new(80.0, 0.0), 6.0, "15", "new_body");
add_in(&mut doc, c, "C round", serde_json::json!({"type": "fillet", "edges": [[86.0, 0.0, 15.0]], "radius": "1.5"}));
add_in(&mut doc, c, "C foot", serde_json::json!({"type": "fillet", "edges": [[86.0, 0.0, 0.0]], "radius": "C_r"}));
doc
}
fn no_errors(m: &Model) {
assert!(m.results.iter().all(|r| r.error.is_none()), "{:?}", m.results.iter().map(|r| (&r.name, &r.error)).collect::<Vec<_>>());
}
/// An edit in component A recomputes A's features that use it and nothing in B or C (#6183):
/// sketches, cuts on a plane given by numbers and fillets of their own body are kept, and the
/// bodies match an evaluation from scratch.
#[test]
fn an_edit_in_one_component_recomputes_only_what_reads_it() {
for plane in [false, true] {
let mut doc = three_parts(plane);
let mut m = Model::new();
m.evaluate(&doc);
no_errors(&m);
for h in ["11 mm", "12 mm", "13 mm"] {
doc.set_param("A_h", h, None, None).unwrap();
m.evaluate(&doc);
no_errors(&m);
// The extrude, and the plane offset by `A_h`.
assert_eq!(m.last_recomputed, 1 + usize::from(plane), "A_h = {h}, plane: {plane}");
assert_eq!(summary(&m.state()), from_scratch(&doc));
}
// The last fillet recomputes alone, as before.
doc.set_param("C_r", "2 mm", None, None).unwrap();
m.evaluate(&doc);
assert_eq!(m.last_recomputed, 1);
assert_eq!(summary(&m.state()), from_scratch(&doc));
// Back where it was: every result comes from the cache.
doc.set_param("A_h", "10 mm", None, None).unwrap();
doc.set_param("C_r", "1 mm", None, None).unwrap();
m.evaluate(&doc);
assert_eq!(summary(&m.state()), from_scratch(&doc));
}
}
/// A construction plane's edit recomputes only the features that use it: a sketch on it and what
/// is made from that sketch, wherever they are in the timeline, and nothing else.
#[test]
fn a_construction_plane_edit_recomputes_only_its_users() {
let mut doc = three_parts(true);
let b = doc.find_component("B").unwrap();
// A peg in B on A's plane: B now reads A through it.
let a_top = doc.find_feature("A_top").unwrap().id;
circle_extrude(&mut doc, b, "B peg", PlaneRef::Construction { feature: a_top.into() }, Vec2::new(40.0, 20.0), 2.0, "3", "new_body");
let mut m = Model::new();
m.evaluate(&doc);
no_errors(&m);
let total = m.results.len();
doc.set_param("A_h", "11 mm", None, None).unwrap();
m.evaluate(&doc);
no_errors(&m);
assert_eq!(m.last_recomputed, 4, "A's extrude and plane, the peg's sketch and extrude, of {total}");
assert_eq!(summary(&m.state()), from_scratch(&doc));
let peg = m.state().body("B peg").map(|b| b.body.outline_bounds().min.z);
assert!(peg.is_some_and(|z| (z - 16.0).abs() < 1e-6), "the peg stands on the plane: {peg:?}");
// Results restored whole after the plane carry the plane as it is now.
let st = m.state();
let top = st.construct.iter().find(|c| c.name == "A_top").map(|c| &c.geom);
assert!(matches!(top, Some(crate::construct::ConstructGeom::Plane(p)) if (p.origin.z - 16.0).abs() < 1e-9), "{top:?}");
let mut scratch = Model::new();
scratch.no_cache = true;
scratch.evaluate(&doc);
assert_eq!(st.construct, scratch.state().construct);
}
/// A coil sweeping a sketch's profile is made again when that sketch changes, not restored as
/// it was (a coil of its own section reads no sketch).
#[test]
fn a_coil_follows_its_sketch() {
let section = |r: f64| {
let mut sk = Sketch::new();
sk.add_circle(Vec2::new(20.0, 0.0), r, None, None).unwrap();
FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XZ".into() }, sketch: sk }
};
let mut doc = Document::new("coil");
let sketch = doc.add_feature(section(1.0), None).unwrap();
doc.add_feature(
FeatureKind::Coil {
base: point_expr(Vec3::new(0.0, 0.0, 0.0)),
axis: Dir(Vec3::Z),
sketch: Some(crate::SketchId(sketch)),
profiles: ProfileSel::All,
diameter: None,
pitch: "8".into(),
turns: "2".into(),
section_size: None,
section: Default::default(),
position: 0,
start_angle: None,
clockwise: false,
operation: Operation::NewBody,
targets: Vec::new(),
},
None,
)
.unwrap();
let mut m = Model::new();
m.evaluate(&doc);
no_errors(&m);
let before = summary(&m.state());
if let Some(f) = doc.feature_mut(sketch) {
f.kind = section(2.0);
}
m.evaluate(&doc);
no_errors(&m);
assert_eq!(m.last_recomputed, 2, "the sketch and its coil");
assert_eq!(summary(&m.state()), from_scratch(&doc));
assert_ne!(summary(&m.state()), before);
}
#[test]
fn box_fillet_cut_timeline() {
let (mut doc, _) = plate_doc();
@@ -0,0 +1,278 @@
//! Assembly constraints (`assembly.constraint.create`, `assembly.constraint.edit`): a geometric rule
//! between a reference on each of two parts (a mate, a flush, concentric holes…), in the words of the
//! Constrain dialog.
//! Each rule is kept as a joint of the kind it needs, made where the parts are and tagged with the
//! rule (`Joint::constraint`), so the one joint solver places, counts and checks it with the
//! others: several constraints between one pair add up, and b keeps every motion they leave free.
use serde_json::{Map, Value, json};
use solidcraft_doc::joints::{Constraint, Joint, JointKind, JointOrigin, Snap};
use super::{CommandSpec, add_joint, joint_id, new_joint, origin_param, value};
use crate::params::{bad, bool_, str_};
use crate::schema::joints::{CreateConstraintParams, EditConstraintParams, EditJointResult, JointAddedResult};
use crate::schema::{TypedContract, output_schema_for};
use crate::{Result, Session};
const CREATE_CONTRACT: TypedContract<CreateConstraintParams> = TypedContract::new();
const EDIT_CONTRACT: TypedContract<EditConstraintParams> = TypedContract::new();
const TYPE_MESSAGE: &str = "`type` must be mate, flush, distance, concentric, insert, parallel, perpendicular, angle, tangent or point_on_axis";
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("assembly.constraint.create", "Constrain", crate::checked!("assembly.constraint.create", create))
.icon("joint")
.contract(&CREATE_CONTRACT)
.output(output_schema_for::<JointAddedResult>)
.params(
"type: mate|flush|distance|concentric|insert|parallel|perpendicular|angle|tangent|point_on_axis; a, b: references, as joint.create's \
origins (a face or a datum plane for a plane, a circle or an axis for an axis, a point; b moves); offset?: the gap (mm or an expression; needed for distance); \
angle?: deg or an expression (needed for angle); flip?: turn b over; name? (default: the rule and a number, Mate1); quiet?. \
Kept as a joint of the kind the rule needs (mate and flush planar, distance planar or point on plane, concentric cylindrical, \
insert revolute, tangent line on plane, point on axis point on line), made where the parts are, so b moves no further than the \
rule needs → joint, name, dof, conflicts, fixed as joint.create",
),
CommandSpec::new("assembly.constraint.edit", "Edit Constraint", crate::checked!("assembly.constraint.edit", edit))
.icon("edit")
.contract(&EDIT_CONTRACT)
.output(output_schema_for::<EditJointResult>)
.params(
"constraint: id|name (a joint made with assembly.constraint.create); type?: a rule as assembly.constraint.create takes it \
(the references it has must fit it; the gap carries over); offset?: the gap (mm or an expression); angle?: an angle \
rule's angle; flip?: true turns b over (from the new rule's way after a type change, else from how it faces now); \
name?; suppressed?; quiet?. Changed as joint.edit changes a joint: its values start again where its parts are \
→ conflicts, errors, dof, fixed as joint.edit",
),
];
/// What a reference is, for the rules that need one kind.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Shape {
/// A planar face or a frame: its plane and normal.
Plane,
/// A round face's or edge's centre and axis.
Round,
/// A point (a vertex, an edge, a sketch point): its position.
Point,
/// A datum or construction axis: a line, with no rim or radius.
Axis,
}
fn shape(o: &JointOrigin) -> Shape {
match o.snap {
Snap::FaceCenter { .. } | Snap::Frame { .. } | Snap::Plane { .. } => Shape::Plane,
Snap::Axis { .. } => Shape::Axis,
Snap::CircleCenter { .. } => Shape::Round,
Snap::Point { .. } | Snap::Edge { .. } => Shape::Point,
}
}
/// How a rule is kept: the joint's kind, whether b is turned to face along a (`flip`), and whether
/// the two references were given the other way round (the plane or axis goes on side a).
struct Kept {
kind: JointKind,
/// `Some` where the rule fixes the facing; `None` keeps the parts' facing now.
flip: Option<bool>,
swap: bool,
}
fn kept(c: Constraint, a: Shape, b: Shape, cmd: &str) -> Result<Kept> {
use Shape::*;
let fixed = |kind, flip| Ok(Kept { kind, flip: Some(flip), swap: false });
let facing = |kind| Ok(Kept { kind, flip: None, swap: false });
// A line: a round face's or edge's axis, or an axis itself.
let axial = |s: Shape| matches!(s, Round | Axis);
// A rule between one plane or axis and something else: the plane or axis on side a.
let onto = |kind, want: &dyn Fn(Shape) -> bool, other_ok: &dyn Fn(Shape) -> bool| {
if want(a) && other_ok(b) {
Ok(Kept { kind, flip: Some(false), swap: false })
} else if want(b) && other_ok(a) {
Ok(Kept { kind, flip: Some(false), swap: true })
} else {
Err(bad(cmd, format!("{} needs {}", c.name(), needs(c))))
}
};
match (c, a, b) {
(Constraint::Mate, Plane, Plane) => fixed(JointKind::Planar, false),
(Constraint::Flush, Plane, Plane) => fixed(JointKind::Planar, true),
(Constraint::Distance, Plane, Plane) => facing(JointKind::Planar),
(Constraint::Distance, ..) => onto(JointKind::PointOnPlane, &|s| s == Plane, &|s| matches!(s, Round | Point)),
(Constraint::Concentric, ..) if axial(a) && axial(b) => facing(JointKind::Cylindrical),
(Constraint::Insert, Round, Round) => facing(JointKind::Revolute),
(Constraint::Parallel, ..) => facing(JointKind::Parallel),
(Constraint::Perpendicular, ..) => fixed(JointKind::Perpendicular, false),
(Constraint::Angle, ..) => fixed(JointKind::Angle, false),
(Constraint::Tangent, ..) => onto(JointKind::LineOnPlane, &|s| s == Plane, &|s| s == Round),
(Constraint::PointOnAxis, ..) => onto(JointKind::PointOnLine, &axial, &|s| s == Point),
_ => Err(bad(cmd, format!("{} needs {}", c.name(), needs(c)))),
}
}
/// The references a rule takes, for its refusal.
fn needs(c: Constraint) -> &'static str {
match c {
Constraint::Mate | Constraint::Flush => "two planar faces",
Constraint::Distance => "two planar faces, or a planar face and a point",
Constraint::Concentric => "two round faces, round edges or axes",
Constraint::Insert => "two round edges",
Constraint::Parallel | Constraint::Perpendicular | Constraint::Angle => "two faces or axes",
Constraint::Tangent => "a planar face and a round face",
Constraint::PointOnAxis => "a round face or edge (its axis) or an axis, and a point",
}
}
/// Whether b's reference faces along a's now (true) or against it.
fn along(s: &Session, a: &JointOrigin, b: &JointOrigin, cmd: &str) -> Result<bool> {
let st = s.model.state();
let za = s.doc.origin_world(&st, a).map_err(|e| bad(cmd, e.to_string()))?;
let zb = s.doc.origin_world(&st, b).map_err(|e| bad(cmd, e.to_string()))?;
Ok((0..3).map(|i| za[2][i] * zb[2][i]).sum::<f64>() > 0.0)
}
/// The joint's `flip` for a rule: the way the rule fixes (a mate against, a flush along), or for a
/// rule that fixes neither, the way the parts face now, so b is not turned over; `turn` turns it.
fn facing(s: &Session, rule: &Kept, a: &JointOrigin, b: &JointOrigin, turn: bool, cmd: &str) -> Result<bool> {
let f = match rule.flip {
Some(f) => f,
None => along(s, a, b, cmd)?,
};
Ok(f != turn)
}
/// A cylinder resting on a plane needs its round face's radius: refused when b's reference has none.
fn check_round(s: &Session, c: Constraint, b: &JointOrigin, cmd: &str) -> Result<()> {
if c == Constraint::Tangent {
s.doc.origin_radius(&s.model.state(), b).map_err(|e| bad(cmd, e.to_string()))?;
}
Ok(())
}
/// The gap a constraint keeps when its rule changes: its offset's expression where it has one (a
/// tangent's gap always is: its offset adds the radius), else its offset.
fn gap(j: &Joint) -> Value {
j.exprs.offset.clone().map(Value::String).unwrap_or_else(|| json!(j.offset))
}
/// Change a constraint in place: its rule, gap, angle, facing, name or suppression. The new rule is
/// mapped onto a joint kind as `create` maps it, and the change goes through `joint.edit`, so the
/// values start again where the parts are and the joint is solved as any edited joint is.
fn edit(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "assembly.constraint.edit";
// Its key read as the contract reads it, then found as joint.edit finds a joint.
let id = match p.get("constraint") {
None => return Err(bad(cmd, "`constraint` is required")),
Some(v @ (Value::Number(_) | Value::String(_))) => joint_id(s, Some(v), cmd)?,
Some(_) => return Err(bad(cmd, "`constraint` must be an id or name")),
};
let j = s.doc.assembly.joints.iter().find(|j| j.id == id).cloned().ok_or_else(|| bad(cmd, "constraint"))?;
let Some(was) = j.constraint else {
return Err(bad(cmd, format!("{} was made as a joint, not as a constraint: change it with joint.edit", j.name)));
};
let c = match str_(p, "type") {
Some(t) => Some(Constraint::parse(t).ok_or_else(|| bad(cmd, TYPE_MESSAGE))?),
None => None,
};
for (key, angle) in [("offset", false), ("angle", true)] {
if let Some(v) = p.get(key) {
value(s, v, angle, cmd)?;
}
}
// joint.edit's params: what is given, and what the rule means.
let mut q = Map::new();
q.insert("joint".into(), json!(id));
for key in ["offset", "angle", "name", "suppressed", "quiet"] {
if let Some(v) = p.get(key) {
q.insert(key.into(), v.clone());
}
}
let turn = bool_(p, "flip") == Some(true);
let mut swap = false;
if let Some(c) = c {
let rule = kept(c, shape(&j.a), shape(&j.b), cmd)?;
let (a, b) = if rule.swap { (&j.b, &j.a) } else { (&j.a, &j.b) };
if c == Constraint::Angle && p.get("angle").is_none() && j.kind != JointKind::Angle {
return Err(bad(cmd, "angle needs `angle`"));
}
check_round(s, c, b, cmd)?;
let flip = facing(s, &rule, a, b, turn, cmd)?;
q.insert("type".into(), serde_json::to_value(rule.kind).map_err(|e| bad(cmd, e.to_string()))?);
q.insert("flip".into(), json!(flip));
// The gap carries over to the new rule.
if p.get("offset").is_none() {
q.insert("offset".into(), gap(&j));
}
swap = rule.swap;
} else if turn {
q.insert("flip".into(), json!(!j.flip));
}
// The rule (and the side order it wants) is written first, so joint.edit restarts and keeps the
// gap as this rule's joint. A refusal after this leaves the design as it was (the session's undo).
if let Some(jj) = s.doc_mut().assembly.joints.iter_mut().find(|j| j.id == id) {
if swap {
std::mem::swap(&mut jj.a, &mut jj.b);
}
jj.constraint = Some(c.unwrap_or(was));
}
// As joint.edit changes a joint, keeping the rule just set through a change of kind.
super::edit_joint(s, &Value::Object(q), false)
}
/// The rule's default name: `Mate1`, `Point on axis2`…, the first number no joint's name has.
fn default_name(s: &Session, c: Constraint) -> String {
let word = c.name().replace('_', " ");
let mut label = word.clone();
if let Some(first) = label.get(..1) {
label = first.to_ascii_uppercase() + label.get(1..).unwrap_or("");
}
let names: std::collections::BTreeSet<&str> = s.doc.assembly.joints.iter().map(|j| j.name.as_str()).collect();
let n = (1..=s.doc.assembly.joints.len().saturating_add(1)).find(|n| !names.contains(format!("{label}{n}").as_str())).unwrap_or(1);
format!("{label}{n}")
}
fn create(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "assembly.constraint.create";
let c = str_(p, "type").and_then(Constraint::parse).ok_or_else(|| bad(cmd, TYPE_MESSAGE))?;
let a = origin_param(s, p.get("a"), cmd)?;
let b = origin_param(s, p.get("b"), cmd)?;
// The numbers are read before the references are checked, in the order the contract reads them.
for (key, angle) in [("offset", false), ("angle", true)] {
if let Some(v) = p.get(key) {
value(s, v, angle, cmd)?;
}
}
let rule = kept(c, shape(&a), shape(&b), cmd)?;
let (a, b) = if rule.swap { (b, a) } else { (a, b) };
if c == Constraint::Distance && p.get("offset").is_none() {
return Err(bad(cmd, "distance needs `offset`"));
}
if c == Constraint::Angle && p.get("angle").is_none() {
return Err(bad(cmd, "angle needs `angle`"));
}
let flip = facing(s, &rule, &a, &b, bool_(p, "flip") == Some(true), cmd)?;
check_round(s, c, &b, cmd)?;
// The joint's own params: the kind and facing the rule needs, the rest as given.
let mut q = p.clone();
if let Some(m) = q.as_object_mut() {
m.remove("type");
m.remove("flip");
m.remove("values");
m.remove("value");
m.remove("limits");
m.insert("flip".into(), Value::Bool(flip));
// No name, `null` or blank text: the rule's own name (`Mate1`), not a joint's.
if !m.get("name").and_then(Value::as_str).is_some_and(|n| !n.trim().is_empty()) {
m.insert("name".into(), Value::String(default_name(s, c)));
}
}
let mut j = new_joint(s, &q, rule.kind, a, b, cmd)?;
if c == Constraint::Tangent {
// The gap is kept as the offset's expression; the radius is added to it whenever the joints
// are solved (`reexpress`, which `add_joint`'s solve runs), so the part follows its face.
j.exprs.offset = Some(j.exprs.offset.take().unwrap_or_else(|| format!("{} mm", j.offset)));
}
if j.offset != 0.0 && !j.offset.is_finite() {
return Err(bad(cmd, "`offset` must be finite"));
}
j.constraint = Some(c);
add_joint(s, j, &q)
}
+1 -1
View File
@@ -1118,7 +1118,7 @@ fn document_units(s: &mut Session, p: &Value) -> Result<Value> {
}
s.doc_mut().preserve_configuration_units(&old, &kept);
if let Some(row) = matched_configuration {
s.doc_mut().apply_configuration(&row)?;
s.doc_mut().apply_configuration(row.as_str())?;
}
s.settle_value_edit(&solved)?;
reply(kept)
@@ -24,6 +24,7 @@ fn joint(id: u64, a: u64, b: u64) -> Joint {
suppressed: false,
exprs: Default::default(),
captured: None,
constraint: None,
}
}
+79 -22
View File
@@ -3,7 +3,7 @@
use serde_json::{Value, json};
use solidcraft_doc::DEFAULT_MATERIAL;
use solidcraft_doc::config::{Column, ConfigRow, MAX_COLUMNS, MAX_ROWS};
use solidcraft_doc::config::{Column, MAX_COLUMNS, RowKey};
use super::CommandSpec;
use crate::params::{bad, bool_, str_, string_list};
@@ -36,7 +36,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.noundo()
.contract(&TABLE_CONTRACT)
.output(output_schema_for::<ConfigTableReply>)
.params("→ columns (param:<name> or suppress:<feature>), rows, the active row and whether the design still matches it"),
.params("→ columns (param:<name> or suppress:<feature>), rows (each with its fixed id), the active row's name and whether the design still matches it"),
CommandSpec::new("config.column", "Configuration Column", crate::checked!("config.column", column))
.icon("configuration_table")
.contract(&COLUMN_CONTRACT)
@@ -47,19 +47,19 @@ pub static COMMANDS: &[CommandSpec] = &[
.contract(&ROW_EDIT_CONTRACT)
.output(output_schema_for::<ConfigTableReply>)
.params(
"name; from? (row to copy; default current values); values?: {column key: expression, suppression, material name, appearance name or Look object}; rename?; delete?: bool",
"name (a row's name, or its id from config.table); from? (row to copy, by name or id; default current values); values?: {column key: expression, suppression, material name, appearance name or Look object}; rename?; delete?: bool",
),
CommandSpec::new("config.activate", "Activate Configuration", crate::checked!("config.activate", activate))
.icon("activate")
.contract(&ROW_CONTRACT)
.output(output_schema_for::<ConfigActivation>)
.params("row: name — sets its parameter values and suppressions"),
.params("row: a row's name, or its id from config.table — sets its parameter values and suppressions"),
CommandSpec::new("config.preview", "Preview Configuration", crate::checked!("config.preview", preview))
.icon("eye")
.noundo()
.contract(&ROW_CONTRACT)
.output(output_schema_for::<ConfigActivation>)
.params("row: name — validates a rebuild without changing the design or history"),
.params("row: a row's name, or its id from config.table — validates a rebuild without changing the design or history"),
CommandSpec::new("config.rule", "Configuration Rule", crate::checked!("config.rule", rule))
.icon("configuration_table")
.contract(&RULE_CONTRACT)
@@ -206,9 +206,8 @@ fn configure(s: &mut Session, p: &Value) -> Result<Value> {
}
if s.doc.configs.rows.is_empty() {
let cells = s.doc.configs.columns.iter().map(|c| s.doc.config_current(c).unwrap_or_default()).collect();
let t = &mut s.doc_mut().configs;
t.rows.push(ConfigRow { name: "Default".into(), cells });
t.active = Some("Default".into());
s.doc_mut().add_configuration_row("Default".into(), cells).map_err(|e| bad(cmd, e.to_string()))?;
s.doc_mut().configs.active = Some("Default".into());
}
show(s, p)
}
@@ -226,8 +225,11 @@ fn to_reply(cmd: &str, reply: impl serde::Serialize) -> Result<Value> {
fn table(s: &Session, cmd: &str) -> Result<ConfigTable> {
let t = &s.doc.configs;
let keys: Vec<String> = t.columns.iter().map(|c| s.doc.config_key(c)).collect();
let rows =
t.rows.iter().map(|r| ConfigTableRow { name: r.name.clone(), values: keys.iter().cloned().zip(r.cells.iter().cloned()).collect() }).collect();
let rows = t
.rows
.iter()
.map(|r| ConfigTableRow { id: r.id, name: r.name.clone(), values: keys.iter().cloned().zip(r.cells.iter().cloned()).collect() })
.collect();
let targets = t
.columns
.iter()
@@ -286,8 +288,18 @@ fn column(s: &mut Session, p: &Value) -> Result<Value> {
fn row(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "config.row";
let name = str_(p, "name").map(str::trim).filter(|n| !n.is_empty() && n.len() <= 128).ok_or_else(|| bad(cmd, "`name` is required"))?.to_string();
let idx = s.doc.configs.rows.iter().position(|r| r.name == name);
// By id, the row must be there; by name, a name the table doesn't have adds a row.
let (idx, name) = match p.get("name").and_then(RowKey::read) {
Some(RowKey::Id(id)) => {
let i = s.doc.configs.rows.iter().position(|r| r.id == id).ok_or_else(|| bad(cmd, format!("no row {id}")))?;
(Some(i), s.doc.configs.rows.get(i).map(|r| r.name.clone()).unwrap_or_default())
}
_ => {
let name =
str_(p, "name").map(str::trim).filter(|n| !n.is_empty() && n.len() <= 128).ok_or_else(|| bad(cmd, "`name` is required"))?.to_string();
(s.doc.configs.rows.iter().position(|r| r.name == name), name)
}
};
if bool_(p, "delete").unwrap_or(false) {
let i = idx.ok_or_else(|| bad(cmd, format!("no row `{name}`")))?;
if s.doc.configs.rows.len() == 1 && !s.doc.configs.rules.is_empty() {
@@ -300,8 +312,12 @@ fn row(s: &mut Session, p: &Value) -> Result<Value> {
}
return show(s, p);
}
let mut cells: Vec<String> = match (idx, str_(p, "from")) {
(_, Some(f)) => s.doc.configs.rows.iter().find(|r| r.name == f).map(|r| r.cells.clone()).ok_or_else(|| bad(cmd, format!("no row `{f}`")))?,
let from = match p.get("from") {
None | Some(Value::Null) => None,
Some(given) => Some(RowKey::read(given).ok_or_else(|| bad(cmd, "`from` must be a row's name or id"))?),
};
let mut cells: Vec<String> = match (idx, from) {
(_, Some(f)) => s.doc.configs.row(f).map(|r| r.cells.clone()).ok_or_else(|| bad(cmd, format!("no row `{f}`")))?,
(Some(i), None) => s.doc.configs.rows.get(i).map(|r| r.cells.clone()).unwrap_or_default(),
(None, None) => s.doc.configs.columns.iter().map(|c| s.doc.config_current(c).unwrap_or_default()).collect(),
};
@@ -379,10 +395,10 @@ fn row(s: &mut Session, p: &Value) -> Result<Value> {
let mut trial = s.doc.as_ref().clone();
trial.change_params_atomic(&changes).map_err(|e| bad(cmd, e.to_string()))?;
}
let t = &mut s.doc_mut().configs;
let validated_name = rename.clone().unwrap_or_else(|| name.clone());
match idx {
Some(i) => {
let t = &mut s.doc_mut().configs;
if let Some(r) = t.rows.get_mut(i) {
r.cells = cells;
if let Some(n) = rename {
@@ -394,10 +410,7 @@ fn row(s: &mut Session, p: &Value) -> Result<Value> {
}
}
None => {
if t.rows.len() >= MAX_ROWS {
return Err(bad(cmd, "too many rows"));
}
t.rows.push(ConfigRow { name, cells });
s.doc_mut().add_configuration_row(name, cells).map_err(|e| bad(cmd, e.to_string()))?;
}
}
if !s.doc.configs.rules.is_empty() {
@@ -413,7 +426,7 @@ fn validate_rows(s: &Session, cmd: &str) -> Result<()> {
}
for row in &s.doc.configs.rows {
let mut trial = s.scratch();
activate(&mut trial, &json!({"row":row.name})).map_err(|e| bad(cmd, format!("row `{}`: {e}", row.name)))?;
activate(&mut trial, &json!({"row": row.id})).map_err(|e| bad(cmd, format!("row `{}`: {e}", row.name)))?;
}
Ok(())
}
@@ -488,9 +501,11 @@ fn activate(s: &mut Session, p: &Value) -> Result<Value> {
/// `config.preview` return it.
fn activation(s: &mut Session, p: &Value) -> Result<ConfigActivation> {
let cmd = "config.activate";
let r = str_(p, "row").ok_or_else(|| bad(cmd, "`row` is required"))?.to_string();
let key = p.get("row").and_then(RowKey::read).ok_or_else(|| bad(cmd, "`row` is required"))?;
let mut trial = s.scratch();
let trace = trial.doc_mut().apply_configuration_with_trace(&r).map_err(|e| bad(cmd, e.to_string()))?;
let trace = trial.doc_mut().apply_configuration_with_trace(key).map_err(|e| bad(cmd, e.to_string()))?;
// The row by its name in what follows, however it was given.
let r = trial.doc.configs.active.clone().unwrap_or_default();
trial.refresh();
let new_errors: Vec<String> = trial
.model
@@ -566,6 +581,48 @@ mod tests {
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
}
/// Rows have fixed ids (#14750): `config.table` lists them, and activate, preview and row take
/// a row by its id or its name. A rename keeps the id and the active row, a row named after
/// another's id is still that name, and a new row never takes a deleted row's id.
#[test]
fn rows_are_named_by_their_id_or_their_name() {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "W", "expression": "10 mm"}));
run(&mut s, "solid.box", json!({"length": "W", "width": 10, "height": 10}));
run(&mut s, "config.configure", json!({"params": ["W"]}));
let id_of =
|t: &Value, name: &str| t["rows"].as_array().and_then(|rows| rows.iter().find(|r| r["name"] == name)).and_then(|r| r["id"].as_u64());
let t = run(&mut s, "config.row", json!({"name": "Long", "values": {"W": "30 mm"}}));
let (default, long) = (id_of(&t, "Default").unwrap(), id_of(&t, "Long").unwrap());
assert_ne!(default, long);
run(&mut s, "config.activate", json!({"row": long}));
assert!((volume(&mut s) - 3000.0).abs() < 1e-6, "{}", volume(&mut s));
assert_eq!(run(&mut s, "config.table", json!({}))["active"], json!("Long"));
let t = run(&mut s, "config.row", json!({"name": long, "rename": "Longer"}));
assert_eq!((id_of(&t, "Longer"), &t["active"]), (Some(long), &json!("Longer")));
assert_eq!(run(&mut s, "config.preview", json!({"row": long}))["preview"], true);
// A row named after Longer's id, a copy of Default: the text is its name, the number Longer's id.
run(&mut s, "config.row", json!({"name": long.to_string(), "from": default}));
run(&mut s, "config.activate", json!({"row": long.to_string()}));
assert!((volume(&mut s) - 1000.0).abs() < 1e-6, "the row named {long}");
run(&mut s, "config.activate", json!({"row": long}));
assert!((volume(&mut s) - 3000.0).abs() < 1e-6, "row {long}");
let unknown = long + 1000;
for (cmd, p) in [
("config.activate", json!({"row": unknown})),
("config.preview", json!({"row": unknown})),
("config.row", json!({"name": unknown})),
("config.row", json!({"name": "R", "from": unknown})),
("config.row", json!({"name": "R", "from": true})),
] {
assert!(s.execute(cmd, &p).is_err(), "{cmd} {p}");
}
run(&mut s, "config.row", json!({"name": long, "delete": true}));
assert_eq!(run(&mut s, "config.table", json!({}))["active"], Value::Null);
let t = run(&mut s, "config.row", json!({"name": "Again"}));
assert!(id_of(&t, "Again").unwrap() > long, "{t}");
}
#[test]
fn a_switch_parameter_alone_switches_the_features_its_rules_suppress() {
// A 10 mm cube with a 4 mm hole cut through it; `Enable` and the hole's suppression are
+36 -19
View File
@@ -402,28 +402,47 @@ fn plane_mid(s: &mut Session, p: &Value) -> Result<Value> {
}
fn plane_edges(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "construct.plane.two_edges";
let (a, b) = (geo(s, p.get("a"), cmd, "a")?, geo(s, p.get("b"), cmd, "b")?);
plane(s, p, PlaneRef::TwoEdges { a: Box::new(a), b: Box::new(b) })
plane_of_picks(s, p, "construct.plane.two_edges")
}
fn plane_axis_point(s: &mut Session, p: &Value) -> Result<Value> {
let kind = plane_axis_point_kind(s, p)?;
add_feature(s, p, kind)
}
/// Shared parser for the constructor and its world-to-authoring-frame boundary.
pub(crate) fn plane_axis_point_kind(s: &Session, p: &Value) -> Result<FeatureKind> {
let cmd = "construct.plane.axis_point";
let axis = geo(s, p.get("axis"), cmd, "axis")?;
let point = geo(s, p.get("point"), cmd, "point")?;
Ok(FeatureKind::ConstructionPlane { plane: PlaneRef::AxisPoint { axis: Box::new(axis), point: Box::new(point) } })
plane_of_picks(s, p, "construct.plane.axis_point")
}
fn plane_points(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "construct.plane.three_points";
let (a, b, c) = (geo(s, p.get("a"), cmd, "a")?, geo(s, p.get("b"), cmd, "b")?, geo(s, p.get("c"), cmd, "c")?);
plane(s, p, PlaneRef::ThreePoints { a: Box::new(a), b: Box::new(b), c: Box::new(c) })
plane_of_picks(s, p, "construct.plane.three_points")
}
fn plane_of_picks(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
let made = picked_plane(s, cmd, p)?.ok_or_else(|| bad(cmd, "not a plane made of picks"))?;
plane(s, p, made)
}
/// The construction planes made of picks alone, read from `cmd`'s parameters as that command
/// reads them (`None`: `cmd` makes no such plane). Shared by the constructors and the
/// world-to-authoring-frame boundary, which maps an edit's saved references once (`frames`).
pub(crate) fn picked_plane(s: &Session, cmd: &str, p: &Value) -> Result<Option<PlaneRef>> {
let pick = |key: &str| geo(s, p.get(key), cmd, key).map(Box::new);
Ok(Some(match cmd {
"construct.plane.two_edges" => PlaneRef::TwoEdges { a: pick("a")?, b: pick("b")? },
"construct.plane.axis_point" => PlaneRef::AxisPoint { axis: pick("axis")?, point: pick("point")? },
"construct.plane.three_points" => PlaneRef::ThreePoints { a: pick("a")?, b: pick("b")?, c: pick("c")? },
"construct.plane.perpendicular" => PlaneRef::Perpendicular { plane: pick("plane")?, line: pick("line")? },
_ => return Ok(None),
}))
}
/// A plane made of picks as its command and saved references, each under the parameter of
/// the command that takes it ([`picked_plane`] backwards; `None`: not such a plane). Edit
/// Feature fills the command's form from it.
pub fn picked_plane_refs(plane: &PlaneRef) -> Option<(&'static str, Vec<(&'static str, &GeoRef)>)> {
Some(match plane {
PlaneRef::TwoEdges { a, b } => ("construct.plane.two_edges", vec![("a", &**a), ("b", &**b)]),
PlaneRef::AxisPoint { axis, point } => ("construct.plane.axis_point", vec![("axis", &**axis), ("point", &**point)]),
PlaneRef::ThreePoints { a, b, c } => ("construct.plane.three_points", vec![("a", &**a), ("b", &**b), ("c", &**c)]),
PlaneRef::Perpendicular { plane, line } => ("construct.plane.perpendicular", vec![("plane", &**plane), ("line", &**line)]),
_ => return None,
})
}
fn plane_tangent(s: &mut Session, p: &Value) -> Result<Value> {
@@ -458,7 +477,5 @@ fn plane_along(s: &mut Session, p: &Value) -> Result<Value> {
}
fn plane_perpendicular(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "construct.plane.perpendicular";
let (base, line) = (geo(s, p.get("plane"), cmd, "plane")?, geo(s, p.get("line"), cmd, "line")?);
plane(s, p, PlaneRef::Perpendicular { plane: Box::new(base), line: Box::new(line) })
plane_of_picks(s, p, "construct.plane.perpendicular")
}
+207
View File
@@ -0,0 +1,207 @@
//! SOLID ▸ CONSTRUCT: Plane, Axis and Point. They have no methods of their own. Given
//! references, they try their family's CONSTRUCT commands (`construct.plane.*`,
//! `construct.axis.*`, `construct.point.*`, in the order the commands are listed) with the
//! references in each command's reference parameters, and build with the first that takes them.
//! A method added to a family is tried as well, with nothing to change here.
use serde_json::{Map, Value, json};
use super::CommandSpec;
use crate::params::bad;
use crate::schema::construct::{ConstructAutoParams, ConstructAutoResult};
use crate::schema::{TypedContract, output_schema_for};
use crate::{Result, Session};
const CONTRACT: TypedContract<ConstructAutoParams> = TypedContract::new();
const PARAMS: &str = "refs: 1-3 refs in any order; with?: {the method's other parameters: offset, angle, t, name…}. Builds with the first of the family's CONSTRUCT commands that takes the refs in its reference parameters (and every key of `with`). Returns method: the command used";
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("construct.plane", "Plane", crate::checked!("construct.plane", plane))
.at("SOLID", "CONSTRUCT")
.icon("plane_auto")
.contract(&CONTRACT)
.output(output_schema_for::<ConstructAutoResult>)
.params(PARAMS),
CommandSpec::new("construct.axis", "Axis", crate::checked!("construct.axis", axis))
.at("SOLID", "CONSTRUCT")
.icon("axis")
.contract(&CONTRACT)
.output(output_schema_for::<ConstructAutoResult>)
.params(PARAMS),
CommandSpec::new("construct.point", "Point", crate::checked!("construct.point", point))
.at("SOLID", "CONSTRUCT")
.icon("point")
.contract(&CONTRACT)
.output(output_schema_for::<ConstructAutoResult>)
.params(PARAMS),
];
/// Whether `id` is one of the tools that choose their method (`construct.plane`…).
pub fn is_construct_tool(id: &str) -> bool {
COMMANDS.iter().any(|c| c.id == id)
}
/// The methods of `tool` (`construct.plane` → `construct.plane.offset`, `construct.plane.midplane`
/// …), in the order the commands are listed: the order they are tried in. Planned commands are
/// left out.
pub fn construct_methods(tool: &str) -> Vec<&'static CommandSpec> {
let prefix = format!("{tool}.");
super::command_specs().into_iter().filter(|c| c.id.starts_with(&prefix) && c.planned.is_none() && c.contract.is_some()).collect()
}
/// How a reference parameter reads a reference (its schema type).
#[derive(Clone, Copy, PartialEq, Eq)]
enum Form {
/// Any `GeoRef` form, as given.
Geo,
/// `PlaneOrFace`: a plane as such (`{plane: p}` given as `p`) or `{face}`.
Plane,
/// `AxisLine`: an axis as such (`{axis: a}` given as `a`), `{edge}` or `{face}`.
Axis,
}
/// A parameter of a method that takes a reference: its key, how it reads one, and whether the
/// method needs it.
struct Slot {
key: String,
form: Form,
required: bool,
}
/// The schema type a property refers to, directly or as one of its `anyOf`/`oneOf` (an
/// optional parameter's).
fn form_of(property: &Value) -> Option<Form> {
let named = |v: &Value| match v.get("$ref").and_then(Value::as_str).and_then(|r| r.rsplit('/').next()) {
Some("GeoRef") => Some(Form::Geo),
Some("PlaneOrFace") => Some(Form::Plane),
Some("AxisLine") => Some(Form::Axis),
_ => None,
};
named(property).or_else(|| ["anyOf", "oneOf"].iter().filter_map(|k| property.get(*k).and_then(Value::as_array)).flatten().find_map(named))
}
/// The reference parameters of `spec`, read from its input schema.
fn slots(spec: &CommandSpec) -> Vec<Slot> {
let Some(contract) = spec.contract else { return Vec::new() };
let schema = contract.input_schema();
let root = schema.as_value();
let required: Vec<&str> =
root.get("required").and_then(Value::as_array).map(|r| r.iter().filter_map(Value::as_str).collect()).unwrap_or_default();
let Some(properties) = root.get("properties").and_then(Value::as_object) else { return Vec::new() };
properties
.iter()
.filter_map(|(key, p)| form_of(p).map(|form| Slot { key: key.clone(), form, required: required.contains(&key.as_str()) }))
.collect()
}
/// `r` as a parameter of `form` reads it.
fn as_form(r: &Value, form: Form) -> Value {
let key = match form {
Form::Geo => return r.clone(),
Form::Plane => "plane",
Form::Axis => "axis",
};
match r.as_object() {
Some(o) if o.len() == 1 => o.get(key).cloned().unwrap_or_else(|| r.clone()),
_ => r.clone(),
}
}
/// Every way to put each of `refs` in its own slot of `slots`, with every required slot filled:
/// the slot index of each reference. The first ways keep the references in the slots' order.
fn placements(refs: usize, slots: &[Slot]) -> Vec<Vec<usize>> {
/// Extend the placement in `at` by one reference, in every free slot, and keep each complete
/// one that fills every required slot.
fn go(refs: usize, slots: &[Slot], at: &mut Vec<usize>, out: &mut Vec<Vec<usize>>) {
if at.len() == refs {
if slots.iter().enumerate().all(|(i, s)| !s.required || at.contains(&i)) {
out.push(at.clone());
}
return;
}
for i in 0..slots.len() {
if !at.contains(&i) {
at.push(i);
go(refs, slots, at, out);
at.pop();
}
}
}
let mut out = Vec::new();
if refs <= slots.len() {
go(refs, slots, &mut Vec::with_capacity(refs), &mut out);
}
out
}
/// The parameters of `spec` with `refs` placed as `at` says, and `with`.
fn params(refs: &[Value], slots: &[Slot], at: &[usize], with: &Map<String, Value>) -> Value {
let mut p = with.clone();
for (r, i) in refs.iter().zip(at) {
if let Some(slot) = slots.get(*i) {
p.insert(slot.key.clone(), as_form(r, slot.form));
}
}
Value::Object(p)
}
/// The first method of `tool` that builds from `refs` with `with`, tried on a scratch copy of
/// `s`: its command and parameters. The error, when none does, is the first a method gave whose
/// parameters fitted, else a list of what can be built from.
pub fn construct_method(s: &Session, tool: &str, refs: &[Value], with: &Map<String, Value>) -> Result<(&'static str, Value)> {
let mut first_error = None;
for spec in construct_methods(tool) {
let Some(contract) = spec.contract else { continue };
let slots = slots(spec);
for at in placements(refs.len(), &slots) {
let p = params(refs, &slots, &at, with);
if !contract.check(&p).is_empty() {
continue;
}
let mut trial = s.scratch();
match spec.run_nested(&mut trial, &p) {
Ok(_) => return Ok((spec.id, p)),
Err(e) => {
if first_error.is_none() {
first_error = Some(e);
}
}
}
}
}
Err(first_error.unwrap_or_else(|| {
let names: Vec<&str> = construct_methods(tool).iter().map(|c| c.label).collect();
bad(tool, format!("no method takes these references; the methods are: {}", names.join(", ")))
}))
}
/// Run `tool` (`construct.plane`…): build with its first method that takes `refs` and `with`, as
/// one undo step, and name the method in the reply.
fn build(s: &mut Session, tool: &str, p: &Value) -> Result<Value> {
// The contract has checked `refs` and `with`; this reads them as given.
let refs = p.get("refs").and_then(Value::as_array).cloned().unwrap_or_default();
let with = p.get("with").and_then(Value::as_object).cloned().unwrap_or_default();
let (method, params) = construct_method(s, tool, &refs, &with)?;
let spec = super::find_command(method).ok_or_else(|| bad(tool, format!("no command `{method}`")))?;
let mut reply = spec.run_nested(s, &params)?;
if let Value::Object(m) = &mut reply {
m.insert("method".into(), json!(method));
}
Ok(reply)
}
/// `construct.plane`.
fn plane(s: &mut Session, p: &Value) -> Result<Value> {
build(s, "construct.plane", p)
}
/// `construct.axis`.
fn axis(s: &mut Session, p: &Value) -> Result<Value> {
build(s, "construct.axis", p)
}
/// `construct.point`.
fn point(s: &mut Session, p: &Value) -> Result<Value> {
build(s, "construct.point", p)
}
+8
View File
@@ -2124,6 +2124,14 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
{
parameters.entry("old_auto_size").or_insert_with(|| json!(true));
}
// A hole saved before a sketch on a derived plane started its holes at that plane keeps
// starting at the surface when it is edited (#11538).
if let FeatureKind::Hole { old_surface_start: true, .. } = &old.kind
&& cid == "solid.hole"
&& let Some(parameters) = params.as_object_mut()
{
parameters.entry("old_surface_start").or_insert_with(|| json!(true));
}
// Members keep their names through an edit, unless it gives them another name.
if let FeatureKind::FrameMember { names, body_name, .. } = &old.kind
&& cid == "frame.member"
+42 -12
View File
@@ -64,7 +64,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("sweep")
.contract(&SWEEP_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params("sketch: profile sketch; profiles?; path_sketch: sketch; path: [curve ids in order]; orientation?: perpendicular (default: square to the path, turning with it) | parallel (as drawn: the profile moves without turning; the path must keep crossing its plane) | [x,y,z] (square to the path, its turn about the path fixed by this direction); twist?: angle expr (the profile turns this much about the path over its length; within 4 turns); scale?: expr (the profile's scale at the end, linear along the path; 0.01…100); rails?: [{sketch?: sketch (default path_sketch), curves: [curve ids in order]}] (guide rails, one or two, each meeting every plane square to the path once along it, from the end nearest the path's start: one rail turns the profile so its point where the rail starts stays on the rail and scales it with the rail's distance from the path; two rails carry it between them, its points where they start staying on both; rails take the default orientation, no twist and no scale, and a path without sharp corners); rail_fit?: scale (default: the profile grows and shrinks evenly) | stretch (only across to the rail, between the rails); operation?, touching?, targets?, name?, body_name?"),
.params("sketch: profile sketch; profiles?; path_sketch: sketch; path: [curve ids in order]; path_body?: body name; path_edges?: [points on its edges] (the path is a chain of that body's edges, instead of a sketch's curves); orientation?: perpendicular (default: square to the path, turning with it) | parallel (as drawn: the profile moves without turning; the path must keep crossing its plane) | [x,y,z] (square to the path, its turn about the path fixed by this direction); twist?: angle expr (the profile turns this much about the path over its length; within 4 turns); scale?: expr (the profile's scale at the end, linear along the path; 0.01…100); rails?: [{sketch?: sketch (default path_sketch), curves: [curve ids in order]}] (guide rails, one or two, each meeting every plane square to the path once along it, from the end nearest the path's start: one rail turns the profile so its point where the rail starts stays on the rail and scales it with the rail's distance from the path; two rails carry it between them, its points where they start staying on both; rails take the default orientation, no twist and no scale, and a path without sharp corners); rail_fit?: scale (default: the profile grows and shrinks evenly) | stretch (only across to the rail, between the rails); operation?, touching?, targets?, name?, body_name?"),
CommandSpec::new("solid.loft", "Loft", loft).at("SOLID", "CREATE").icon("loft").params("sections: [{sketch, profiles?} | {sketch, point: id} (an apex)] (2…50, in order; matching hole counts, no holes at an apex); rails?: [{sketch, curves: [ids in order]}] (one or two guide rails from the first section to the last, through every section's outline: the loft follows them between the sections; not with sections that have holes); rail_fit?: scale|stretch (default scale: with one rail the sections grow evenly with the rail's distance from their middle, with two with the distance between the rails; stretch: only that way); operation?, touching?, targets? (Join excludes hidden bodies from automatic targets; explicit targets or participants may include them), name?, body_name?"),
CommandSpec::new("solid.box", "Box", crate::checked!("solid.box", prim_box))
.at("SOLID", "CREATE")
@@ -134,7 +134,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "CREATE")
.icon("hole")
.key("H")
.params("position: [x,y,z] on a face (numbers, or length expressions the hole follows) | sketch + points: [sketch point ids] (one hole each, perpendicular to the sketch, into the material on whichever side of the sketch plane it is) | sketch alone (every sketch point: standalone points and circle centres); flip?: bool (sketch points: drill to the other side); single?: bool (with one sketch point: a Single hole that follows the point, drilled into the face there); direction?: [x,y,z] (default: into the face, against its outward normal; where faces of several bodies meet there, the face of a body in `targets`; one that leaves the face is mirrored into it; ignored with sketch points unless single); diameter | thread (a tapped hole, drilled with the tap maker's tabulated drill: ISO metric (a custom metric size: major minus pitch), UNC/UNF, G, Rp, Rc 1/16 to 4, NPT 1/16 to 3; a `diameter` given with it chooses another drill, from the thread's minor diameter to 5% over the tap maker's) | clearance: \"M6\" with fit?: close|normal|loose (ISO 273); head?: \"ISO 4762\" (socket head) | \"ISO 7380-1\" (button head) (with clearance: a counterbore the screw's head sits in, so type defaults to counterbore; its diameter is the head's dk plus head_gap each side, its depth the head's k plus head_gap; a cb_diameter or cb_depth given keeps its value); head_gap?: length (default 0.5 mm; an expression with parameters is kept, so the recess follows them); depth? (default through all) | to?: [x,y,z] (To Object: drill to that face or point); references?: [{edge: [x,y,z] on a straight edge of the face, distance: expr}] (at most two; the centre goes that far from each edge's line, in the face's plane, and follows the edges; with a numeric position only); type?: simple|drilled|counterbore|countersink; tip_angle?; cb_diameter?, cb_depth?, bridge?: length (counterbore: two 3D-printing bridge layers this thick below its floor, a slot along X then a square; e.g. 0.2 mm); cs_diameter?, cs_angle?; thread?: \"M6\", \"1/4-20 UNC\", \"G 1/4\", \"Rp 1/4\", \"Rc 1/4\" or \"1/4-18 NPT\" (cosmetic unless modeled); hand?: right|left (the tapped thread's hand; default right; needs thread); thread_depth?: positive length independent of drill depth (default: full useful cylindrical wall; must fit that wall); modeled?: bool (needs thread: the tapped thread cut into the wall, the groove between its teeth, as solid.thread's modelled one; default false: listed only); ease_out?: expr|standard|short (modelled tapped threads: the inner end, towards the bottom, fades out over this length, ISO 3508's runout, standard 2.5 P, short 1.25 P; without it the groove stops square at thread_depth, or clear of a blind hole's bottom); targets?: [body] (the bodies to drill; holes at the points of a sketch on a body's face default to that body, found along the holes' direction on it alone; explicit targets or participants override, [\"*\"] every body)"),
.params("position: [x,y,z] on a face (numbers, or length expressions the hole follows) | sketch + points: [sketch point ids] (one hole each, perpendicular to the sketch, into the material on whichever side of the sketch plane it is) | sketch alone (every sketch point: standalone points and circle centres); flip?: bool (sketch points: drill to the other side); single?: bool (with one sketch point: a Single hole that follows the point, drilled into the face there); direction?: [x,y,z] (default: into the face, against its outward normal; where faces of several bodies meet there, the face of a body in `targets`; one that leaves the face is mirrored into it; ignored with sketch points unless single); diameter | thread (a tapped hole, drilled with the tap maker's tabulated drill: ISO metric (a custom metric size: major minus pitch), UNC/UNF, G, Rp, Rc 1/16 to 4, NPT 1/16 to 3; a `diameter` given with it chooses another drill, from the thread's minor diameter to 5% over the tap maker's) | clearance: \"M6\" with fit?: close|normal|loose (ISO 273); head?: \"ISO 4762\" (socket head) | \"ISO 7380-1\" (button head) (with clearance: a counterbore the screw's head sits in, so type defaults to counterbore; its diameter is the head's dk plus head_gap each side, its depth the head's k plus head_gap; a cb_diameter or cb_depth given keeps its value); head_gap?: length (default 0.5 mm; an expression with parameters is kept, so the recess follows them); depth? (default through all) | to?: [x,y,z] (To Object: drill to that face or point); references?: [{edge: [x,y,z] on a straight edge of the face, distance: expr}] (at most two; the centre goes that far from each edge's line, in the face's plane, and follows the edges; with a numeric position only); type?: simple|drilled|counterbore|countersink; tip_angle?; cb_diameter?, cb_depth?, bridge?: length (counterbore: two 3D-printing bridge layers this thick below its floor, a slot along X then a square; e.g. 0.2 mm); cs_diameter?, cs_angle?; thread?: \"M6\", \"1/4-20 UNC\", \"G 1/4\", \"Rp 1/4\", \"Rc 1/4\" or \"1/4-18 NPT\" (cosmetic unless modeled); hand?: right|left (the tapped thread's hand; default right; needs thread); thread_depth?: positive length independent of drill depth (default: full useful cylindrical wall; must fit that wall); modeled?: bool (needs thread: the tapped thread cut into the wall, the groove between its teeth, as solid.thread's modelled one; default false: listed only); ease_out?: expr|standard|short (modelled tapped threads: the inner end, towards the bottom, fades out over this length, ISO 3508's runout, standard 2.5 P, short 1.25 P; without it the groove stops square at thread_depth, or clear of a blind hole's bottom); targets?: [body] (the bodies to drill; holes at the points of a sketch on a body's face default to that body, found along the holes' direction on it alone; explicit targets or participants override, [\"*\"] every body); old_surface_start?: bool (sketch points: a point inside a body starts the hole at the body's surface whatever plane the sketch lies on, as designs saved before #11538 drilled; set on those holes, keeps them as they were when edited)"),
CommandSpec::new("solid.pipe", "Pipe", crate::checked!("solid.pipe", pipe))
.at("SOLID", "CREATE")
.icon("pipe")
@@ -3205,6 +3205,7 @@ fn hole(s: &mut Session, p: &Value) -> Result<Value> {
None | Some(Value::Null) => None,
Some(_) => Some(req_expr(cmd, p, "thread_depth")?),
};
let old_surface_start = p.get("old_surface_start").and_then(Value::as_bool).unwrap_or(false);
let modeled = p.get("modeled").and_then(Value::as_bool).unwrap_or(false);
if modeled && thread.is_none() {
return Err(bad(cmd, "`modeled` needs `thread` (a tapped hole)"));
@@ -3429,6 +3430,7 @@ fn hole(s: &mut Session, p: &Value) -> Result<Value> {
ease_out,
to: to.map(FacePick),
references,
old_surface_start,
},
)
}
@@ -3635,13 +3637,9 @@ pub(crate) fn sketch_key(v: Option<&Value>) -> Option<String> {
fn sweep(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.sweep";
let sketch = feature_sketch(s, p, cmd)?;
let path_sketch = sketch_id(s, p.get("path_sketch"), cmd, "path_sketch")?;
let path = string_list(cmd, p, "path")?;
if path.is_empty() {
return Err(bad(cmd, "`path` must list curve ids"));
}
let (path_sketch, path, path_body, path_edges) = sweep_path(s, p, cmd)?;
let (orientation, twist, scale) = sweep_options(s, p, cmd)?;
let (rails, rail_fit) = sweep_rails(s, p, cmd, path_sketch)?;
let (rails, rail_fit) = sweep_rails(s, p, cmd, path_body.is_none().then_some(path_sketch))?;
let operation = operation(p, cmd)?;
let targets = face_join_targets(s, p, operation, &[sketch], cmd)?;
add_feature(
@@ -3652,6 +3650,8 @@ fn sweep(s: &mut Session, p: &Value) -> Result<Value> {
profiles: profiles(p, cmd)?,
path_sketch: SketchId(path_sketch),
path,
path_body,
path_edges,
orientation,
twist,
scale,
@@ -3663,6 +3663,35 @@ fn sweep(s: &mut Session, p: &Value) -> Result<Value> {
)
}
/// A sweep's path (#2216): a chain of a body's edges (`path_body` and `path_edges`) or the
/// curves of a sketch (`path_sketch` and `path`). The body's edges are a point on each edge of
/// the chain; the path runs from the end nearest the profile.
pub(super) fn sweep_path(s: &Session, p: &Value, cmd: &str) -> Result<(u64, Vec<String>, Option<BodyName>, Vec<Position>)> {
if p.get("path_body").is_some_and(|b| !b.is_null()) {
let body = str_(p, "path_body").ok_or_else(|| bad(cmd, "`path_body` must be a body name"))?;
if s.model.state().body(body).is_none() {
return Err(bad(cmd, format!("no body `{body}`")));
}
let edges = path_edge_points(p, cmd)?;
return Ok((0, Vec::new(), Some(BodyName::from(body)), edges));
}
let path_sketch = sketch_id(s, p.get("path_sketch"), cmd, "path_sketch")?;
let path = string_list(cmd, p, "path")?;
if path.is_empty() {
return Err(bad(cmd, "`path` must list curve ids"));
}
Ok((path_sketch, path, None, Vec::new()))
}
/// The points on the edges of a sweep's path (`path_edges`), one on each edge of the chain.
fn path_edge_points(p: &Value, cmd: &str) -> Result<Vec<Position>> {
let a = p.get("path_edges").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`path_edges` must be a list of points"))?;
if a.is_empty() || a.len() > 1000 {
return Err(bad(cmd, "the path needs 1…1000 edges"));
}
a.iter().map(|e| vec3(e).map(Position::from).ok_or_else(|| bad(cmd, "each `path_edges` entry must be a point [x,y,z]"))).collect()
}
/// A sweep's `orientation`, `twist` and `scale` (solid.sweep and surface.sweep), checked: an
/// orientation the sweep knows, a twist within four turns, an end scale within 0.01…100.
pub(super) fn sweep_options(s: &Session, p: &Value, cmd: &str) -> Result<(SweepOrientation, Option<Expr>, Option<Expr>)> {
@@ -3715,10 +3744,11 @@ pub(crate) fn sweep_orientation(v: &Value) -> std::result::Result<SweepOrientati
}
/// A sweep's guide `rails` and `rail_fit` (solid.sweep and surface.sweep), checked: one or two
/// rails, each a sketch (default: the path's) and 1…1000 of its curves; a fit of scale or
/// stretch; and no orientation, twist or scale besides, since the rails set those.
pub(super) fn sweep_rails(s: &Session, p: &Value, cmd: &str, path_sketch: u64) -> Result<(Vec<SweepRail>, RailFit)> {
let (rails, rail_fit) = guide_rails(s, p, cmd, "sweep", Some(path_sketch))?;
/// rails, each a sketch (default: the path's, when the path is a sketch's) and 1…1000 of its
/// curves; a fit of scale or stretch; and no orientation, twist or scale besides, since the
/// rails set those.
pub(super) fn sweep_rails(s: &Session, p: &Value, cmd: &str, path_sketch: Option<u64>) -> Result<(Vec<SweepRail>, RailFit)> {
let (rails, rail_fit) = guide_rails(s, p, cmd, "sweep", path_sketch)?;
if !rails.is_empty() {
for key in ["orientation", "twist", "scale"] {
if p.get(key).is_some_and(|v| !v.is_null()) {
@@ -2784,3 +2784,31 @@ fn align_by_a_frame_picked_on_another_copy_moves_the_part_in_its_own_coordinates
assert_bounds(&mut s, "Peg", [4., 4., 10.], [6., 6., 12.]);
assert_bounds(&mut s, "Peg (Board:2/Header:1)", [104., 4., 10.], [106., 6., 12.]);
}
/// A coil cut from a sketch follows that sketch when its points move, and the body it cuts when
/// that changes upstream: its cached result is not taken back while either differs.
#[test]
fn a_sketch_coil_cut_follows_its_profile_and_target() {
use std::f64::consts::PI;
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"radius": 20, "height": 40, "body_name": "Shaft", "name": "ShaftFeature"}));
run(&mut s, "sketch.create", json!({"plane": "YZ", "name": "Groove"}));
// A 1 x 1 mm square, 18..19 mm from the axis.
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [18, 1], "p1": [19, 2]}));
run(&mut s, "sketch.finish", json!({}));
let before = volume(&mut s);
run(&mut s, "solid.coil", json!({"sketch": "Groove", "revolutions": 3, "pitch": 8, "operation": "cut", "targets": ["Shaft"]}));
let removed = before - volume(&mut s);
assert!(rel(removed, 2.0 * PI * 3.0 * 18.5) < 1e-3, "{removed}");
// Widen the square inward to 17..19 mm: twice the area, centroid at 18 mm.
run(&mut s, "sketch.edit", json!({"sketch": "Groove"}));
run(&mut s, "sketch.move_point", json!({"point": "p1", "to": [17, 1]}));
run(&mut s, "sketch.move_point", json!({"point": "p4", "to": [17, 2]}));
run(&mut s, "sketch.finish", json!({}));
let removed = before - volume(&mut s);
assert!(rel(removed, 2.0 * PI * 3.0 * 2.0 * 18.0) < 1e-3, "the groove kept the old profile: {removed}");
// A thinner shaft upstream (radius 18): the groove now cuts only 17..18 mm of it.
run(&mut s, "timeline.edit", json!({"feature": "ShaftFeature", "set": {"radius": "18"}}));
let removed = PI * 18.0 * 18.0 * 40.0 - volume(&mut s);
assert!(rel(removed, 2.0 * PI * 3.0 * 17.5) < 1e-3, "the groove kept the old shaft: {removed}");
}
+1
View File
@@ -425,6 +425,7 @@ fn add_rigid_joints(s: &mut Session, pairs: &[(u64, u64, Mat)]) -> Result<Vec<u6
suppressed: false,
exprs: Default::default(),
captured: None,
constraint: None,
})
})
.collect::<Result<_>>()?;
+48 -22
View File
@@ -11,13 +11,14 @@ use solidcraft_sketch::CurveKind;
use super::CommandSpec;
use crate::params::{bad, bool_, str_, string_list};
use crate::schema::inspect::{BodiesDetail, DocumentBodies, TimelineDetail, TimelineRow};
use crate::schema::inspect::{
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,
SketchWire, TimelineEntry, TimelineSummary, WireArcInfo,
};
use crate::schema::{TypedContract, output_schema_for};
use crate::{EngineError, Result, Session};
@@ -58,7 +59,11 @@ pub static COMMANDS: &[CommandSpec] = &[
.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)"),
.params(
"measure?: bool (include body measurements); sketch_counts?: bool (each sketch also gives its plane and its number of points and constraints); \
timeline?: true (default) | \"summary\" (each feature as id, name, type, with error, warning, suppressed and rolled_back only when set) | false (left out); \
bodies?: true (default) | \"names\" (only the body names, nothing measured) | false (left out)",
),
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, \
@@ -544,6 +549,8 @@ fn revision(s: &mut Session, _p: &Value) -> Result<Value> {
}
fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
let timeline_detail = TimelineDetail::read(p.get("timeline")).map_err(|e| bad("document.inspect", e))?;
let bodies_detail = BodiesDetail::read(p.get("bodies")).map_err(|e| bad("document.inspect", e))?;
let st = s.model.state();
let (vals, perr) = s.doc.param_values();
let params: Vec<DocumentParam> = s
@@ -561,27 +568,41 @@ fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
})
.collect();
let marker = s.doc.marker.unwrap_or(s.doc.features.len());
let timeline: Vec<TimelineEntry> = s
.doc
.features
.iter()
.enumerate()
.map(|(i, f)| {
let r = s.model.result(f.id);
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 timeline: Option<Vec<TimelineRow>> = (timeline_detail != TimelineDetail::Left).then(|| {
s.doc
.features
.iter()
.enumerate()
.map(|(i, f)| {
let r = s.model.result(f.id);
let (error, warning) = (r.and_then(|r| r.error.clone()), r.and_then(|r| r.warning.clone()));
if timeline_detail == TimelineDetail::Summary {
return TimelineRow::Summary(TimelineSummary {
id: f.id,
name: f.name.clone(),
kind: f.kind.type_name().to_string(),
error,
warning,
suppressed: f.suppressed.then_some(true),
rolled_back: (i >= marker).then_some(true),
});
}
TimelineRow::Full(TimelineEntry {
id: f.id,
name: f.name.clone(),
kind: f.kind.type_name().to_string(),
suppressed: f.suppressed,
rolled_back: i >= marker,
error,
warning,
ms: r.map(|r| r.ms),
})
})
.collect()
});
let definitions = s.model.state();
let with_measure = bool_(p, "measure").unwrap_or(false);
// Only bodies given in full are measured.
let with_measure = bool_(p, "measure").unwrap_or(false) && bodies_detail == BodiesDetail::Full;
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 =
@@ -601,6 +622,11 @@ fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
DocumentBody { detail, visible: s.body_visible(&b.name), removed_later: s.body_removed_later(&b.name).then_some(true) }
})
.collect();
let bodies = match bodies_detail {
BodiesDetail::Full => Some(DocumentBodies::Full(bodies)),
BodiesDetail::Names => Some(DocumentBodies::Names(st.bodies.iter().map(|b| b.name.clone()).collect())),
BodiesDetail::Left => None,
};
let sketches: Vec<DocumentSketch> = st
.sketches
.iter()
+69 -11
View File
@@ -47,6 +47,9 @@ const CAM_CONTRACT: TypedContract<CamParams> = TypedContract::new();
#[path = "joints_inspect.rs"]
mod inspect;
#[path = "assembly_constraint.rs"]
pub(crate) mod constraint;
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("joint.origin", "Joint Origin", crate::checked!("joint.origin", joint_origin))
.at("SOLID", "CREATE")
@@ -236,9 +239,12 @@ pub fn place(s: &mut Session) -> joints::Solution {
/// Work out the offsets, angles and values joints were given as expressions again (a parameter
/// may have changed). One that can't be worked out (its parameter gone) keeps its numbers; the
/// solve leaves that joint out and says why.
/// solve leaves that joint out and says why. A tangent constraint's offset is its gap plus the
/// radius of its round face as the face is now, so the part keeps resting on the plane when the
/// radius changes; a face that can't be found keeps the numbers it has.
fn reexpress(s: &mut Session) {
let params = joints::Params::new(&s.doc);
let st = s.model.state();
let changed: Vec<(usize, (f64, f64, Vec<f64>))> = s
.doc
.assembly
@@ -247,7 +253,10 @@ fn reexpress(s: &mut Session) {
.enumerate()
.filter(|(_, j)| !j.exprs.is_empty())
.filter_map(|(i, j)| {
let (o, a, mut q) = joints::expressed(&params, j).ok()?;
let (mut o, a, mut q) = joints::expressed(&params, j).ok()?;
if j.constraint == Some(joints::Constraint::Tangent) {
o += s.doc.origin_radius(&st, &j.b).ok()?;
}
joints::clamp(j, &mut q);
((o, a) != (j.offset, j.angle) || q != j.values).then_some((i, (o, a, q)))
})
@@ -1152,14 +1161,14 @@ fn add_joint(s: &mut Session, mut j: Joint, p: &Value) -> Result<Value> {
return Err(EngineError::Other("too many joints".into()));
}
check_sides(&j.a, &j.b, "joint.create")?;
// Relations and loop-closing typed joints start where their parts are. A typed joint in
// Relations, constraints and loop-closing typed joints start where their parts are. A typed joint in
// an open chain keeps its usual initial values; explicitly supplied values also keep theirs.
// A joint on a plane or axis side anchors its part to that geometry: it starts where its
// parts are too, so the part keeps its place on the plane (or along the axis) instead of
// jumping to the geometry's origin.
if p.get("values").is_none()
&& p.get("value").is_none()
&& (j.kind.is_relation() || closes_loop(s, &j) || datum_sided(&j))
&& (j.starts_where_it_is() || closes_loop(s, &j) || datum_sided(&j))
&& let Some(q) = joints::relation_values(&s.doc, &s.model.state(), &j)
{
j.values = q;
@@ -1345,6 +1354,13 @@ fn joint(s: &mut Session, p: &Value) -> Result<Value> {
let kind = kind_param(p, cmd)?;
let a = origin_param(s, p.get("a"), cmd)?;
let b = origin_param(s, p.get("b"), cmd)?;
let j = new_joint(s, p, kind, a, b, cmd)?;
add_joint(s, j, p)
}
/// A joint of `kind` between origins `a` and `b`, with the values, limits, flip, offset, angle and
/// name `p` gives (as joint.create reads them), not yet added.
fn new_joint(s: &mut Session, p: &Value, kind: JointKind, a: JointOrigin, b: JointOrigin, cmd: &str) -> Result<Joint> {
let id = s.doc_mut().allocate_id()?;
let name = new_joint_name(s, p, kind);
let offset = match p.get("offset") {
@@ -1369,13 +1385,14 @@ fn joint(s: &mut Session, p: &Value) -> Result<Value> {
suppressed: false,
exprs: JointExprs { offset: typed(p.get("offset")), angle: typed(p.get("angle")), values: value_exprs(p) },
captured: None,
constraint: None,
};
// No angle given: B keeps its turn about the joint's axis where a half turn would otherwise
// come only from how its origin's x was chosen. (An angle relation's angle is its tilt.)
if angle.is_none() && !j.kind.is_relation() {
j.angle = joints::least_turn_angle(&s.doc, &s.model.state(), &j);
}
add_joint(s, j, p)
Ok(j)
}
fn as_built(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1415,6 +1432,7 @@ fn as_built(s: &mut Session, p: &Value) -> Result<Value> {
suppressed: false,
exprs: Default::default(),
captured: None,
constraint: None,
};
add_joint(s, j, p)
}
@@ -1636,9 +1654,26 @@ fn reach(s: &mut Session, p: &Value) -> Result<Value> {
let before = super::motion::positions(s);
let st = s.model.state();
let r = joints::reach(&s.doc, &st, &joints::Reach { occurrence: occ, point: at, to, axis, joints: only });
let mut stopped_by_contact = None;
let reached = match &r {
// Only a reach that moves something touches the design (and so adds an undo step).
Ok(x) if !x.values.is_empty() && s.doc.assembly.contact_enabled => {
// With contact sets on, the joints go toward the values found as joint.drive moves
// them, stopping where two parts of a set would pass into each other (#14682). No
// value is held on the way: the loops close from each step, so a loop the values
// close at both ends doesn't stop the drag in between.
let want: Vec<(u64, Vec<f64>)> = x.values.iter().map(|(id, q)| (*id, q.clone())).collect();
let (out, _) = super::motion::drive_with_contact(s, &want, &BTreeSet::new())?;
stopped_by_contact = out
.get("stopped_by_contact")
.filter(|v| v.is_object())
.cloned()
.map(serde_json::from_value::<ContactStop>)
.transpose()
.map_err(|e| bad(cmd, e.to_string()))?;
x.clone()
}
Ok(x) => {
// Only a reach that moves something touches the design (and so adds an undo step).
if !x.values.is_empty() {
let doc = s.doc_mut();
for (id, q) in &x.values {
@@ -1673,7 +1708,10 @@ fn reach(s: &mut Session, p: &Value) -> Result<Value> {
.filter_map(|id| name(*id))
.map(|j| ReachedJoint { joint: j.id, name: j.name.clone(), values: named_value_list(j, effective.get(&j.id).unwrap_or(&j.values)) })
.collect();
let stopped_by_limit = (!reached.limits.is_empty()).then(|| {
// A contact that stopped it came first: the limits and loop the reach found further on are
// not where it rests.
let contact_stopped = stopped_by_contact.is_some();
let stopped_by_limit = (!contact_stopped && !reached.limits.is_empty()).then(|| {
reached
.limits
.iter()
@@ -1697,7 +1735,8 @@ fn reach(s: &mut Session, p: &Value) -> Result<Value> {
values,
moved: super::motion::moved(&before, &super::motion::positions(s)).next().is_some(),
stopped_by_limit,
stopped_by_loop: reached.stopped_by_loop.and_then(name).map(|j| ReachLoopStop { joint: j.name.clone() }),
stopped_by_loop: reached.stopped_by_loop.filter(|_| !contact_stopped).and_then(name).map(|j| ReachLoopStop { joint: j.name.clone() }),
stopped_by_contact,
reason: r.err(),
continuous: (bool_(p, "continuous") == Some(true)).then_some(true),
param_warnings: Vec::new(),
@@ -2032,6 +2071,12 @@ fn limits(s: &mut Session, p: &Value) -> Result<Value> {
}
fn edit(s: &mut Session, p: &Value) -> Result<Value> {
edit_joint(s, p, true)
}
/// joint.edit's change, also run by `assembly.constraint.edit`. `plain`: a change of kind makes a
/// constraint a plain joint (joint.edit); the constraint command keeps the rule it has just set.
pub(super) fn edit_joint(s: &mut Session, p: &Value, plain: bool) -> Result<Value> {
let cmd = "joint.edit";
let id = joint_id(s, p.get("joint"), cmd)?;
let kind = match str_(p, "type") {
@@ -2069,6 +2114,14 @@ fn edit(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(k) = kind {
let was = j.kind;
j.kind = k;
// A constraint changed to another kind of joint is a plain joint now: its tag would keep
// restarting it, and a tangent's gap expression would leave the radius out of its offset.
if k != was && plain {
if j.constraint == Some(joints::Constraint::Tangent) {
j.exprs.offset = None;
}
j.constraint = None;
}
j.values.resize(k.dofs().len(), 0.0);
j.limits.resize(k.dofs().len(), None);
j.exprs.values.truncate(k.dofs().len());
@@ -2083,6 +2136,10 @@ fn edit(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(o) = offset {
j.offset = o;
j.exprs.offset = typed(p.get("offset"));
// A tangent constraint's offset is its gap: the radius is added when it is solved.
if j.constraint == Some(joints::Constraint::Tangent) {
j.exprs.offset.get_or_insert_with(|| format!("{o} mm"));
}
}
if let Some(a) = angle {
j.angle = a;
@@ -2120,11 +2177,11 @@ fn edit(s: &mut Session, p: &Value) -> Result<Value> {
&& kind.is_none_or(|k| k == was_kind)
&& offset.is_none_or(|o| (o - was_offset).abs() <= 1e-9)
&& bool_(p, "flip").is_none_or(|f| f == was_flip);
// Otherwise a relation whose type, offset, tilt or flip changes, or whose sides move, starts
// again from where its parts are.
// Otherwise a relation or a constraint whose type, offset, tilt or flip changes, or whose
// sides move, starts again from where its parts are.
let reseat = !tilt_only && (kind.is_some() || offset.is_some() || angle.is_some() || p.get("flip").is_some() || sides);
if reseat {
let edited = s.doc.assembly.joints.iter().find(|j| j.id == id).filter(|j| j.kind.is_relation()).cloned();
let edited = s.doc.assembly.joints.iter().find(|j| j.id == id).filter(|j| j.starts_where_it_is()).cloned();
if let Some(q) = edited.and_then(|j| joints::relation_values(&s.doc, &s.model.state(), &j))
&& let Some(j) = s.doc_mut().assembly.joints.iter_mut().find(|j| j.id == id)
{
@@ -2218,6 +2275,7 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
frame_a: world(&j.a),
frame_b: world(&j.b),
expressions: (!j.exprs.is_empty()).then(|| j.exprs.clone()),
constraint: j.constraint,
}
})
.collect();
+1
View File
@@ -64,6 +64,7 @@ fn joint_json(s: &Session, st: &ModelState, sol: &joints::Solution, j: &Joint, q
errors: why(&sol.errors),
conflicts: why(&sol.conflicts),
expressions: (!j.exprs.is_empty()).then(|| j.exprs.clone()),
constraint: j.constraint,
}
}
@@ -425,3 +425,54 @@ fn quarter_turn_reaches_keep_the_four_bar_closed_and_diagnostics_current() {
}
}
}
/// A grounded block A and a block B on a slider toward it, 10 mm clear, in one contact set:
/// `contact` turns contact sets on or off. The slider stops 10 mm past the touch.
fn blocks_on_a_rail(contact: bool) -> Session {
let mut s = Session::default();
for (name, x) in [("A", 0), ("B", 20)] {
run(&mut s, "component.activate", json!({"component": 0}));
run(&mut s, "component.create", json!({"name": name}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0]}));
}
let a = occ(&s, "A");
run(&mut s, "occurrence.ground", json!({"occurrence": a, "grounded": true}));
run(&mut s, "joint.create", json!({"type": "slider", "a": {"face": [10, 5, 5]}, "b": {"face": [20, 5, 5]}, "name": "Rail"}));
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 10}));
run(&mut s, "joint.limits", json!({"joint": "Rail", "index": 0, "min": -10, "max": 100}));
run(&mut s, "contact.create", json!({"occurrences": ["A", "B"]}));
run(&mut s, "contact.enable_sets", json!({"enabled": contact}));
s
}
/// A drag (joint.reach) stops where a contact set says two parts touch, as joint.drive does, and
/// says so, not the limit further on; with contact sets off it goes on to that limit (#14682).
#[test]
fn a_reach_stops_where_a_contact_set_touches() {
// B's near face, in its own coordinates; the drag pulls its centre 25 mm toward A, 15 mm past
// where B touches A.
let near_face = Vec3::new(20.0, 5.0, 5.0);
let pull = |s: &mut Session| {
let from = place(s, "B", Vec3::new(25.0, 5.0, 5.0));
let to = from - Vec3::new(25.0, 0.0, 0.0);
reach(s, "B", Vec3::new(25.0, 5.0, 5.0), json!({"point": pt(to)}))
};
let mut s = blocks_on_a_rail(true);
let start = place(&s, "B", near_face).x;
assert!((start - 20.0).abs() < 1e-6, "B starts 10 mm clear of A: {start}");
let r = pull(&mut s);
assert!(r["stopped_by_contact"].is_object(), "{r}");
assert!(r["stopped_by_limit"].is_null(), "the contact stopped it, not the limit: {r}");
assert_eq!(r["reached"], false, "{r}");
let x = place(&s, "B", near_face).x;
assert!((10.0 - 1e-6..10.0 + 1e-3).contains(&x), "B stops touching A, not through it: its face at x = {x}");
let collisions = run(&mut s, "contact.check", json!({}));
assert!(collisions["collisions"].as_array().is_some_and(Vec::is_empty), "{collisions}");
// Contact sets off: the same drag goes through A, to the slider's limit.
let mut s = blocks_on_a_rail(false);
let r = pull(&mut s);
assert!(r["stopped_by_contact"].is_null(), "{r}");
assert!(r["stopped_by_limit"].is_array(), "{r}");
let x = place(&s, "B", near_face).x;
assert!(x.abs() < 1e-6, "B's face at x = {x}, not at the limit, 0");
}
@@ -257,6 +257,7 @@ fn a_panel_full_of_parts_placed_by_relations_stays_solved() {
suppressed: false,
exprs: Default::default(),
captured: None,
constraint: None,
};
if let Some(q) = solidcraft_doc::joints::relation_values(&s.doc, &s.model.state(), &j) {
j.values = q;
@@ -373,3 +374,436 @@ fn an_angle_edit_while_the_joint_is_suppressed_starts_from_where_the_part_is() {
assert!(near(side(&s), moved, 1e-6), "together {together}: {:?}, moved to {moved:?}", side(&s));
}
}
// Assembly constraints (`assembly.constraint.create`): the rules in the Constrain dialog's words,
// kept as joints of the kinds they need and tagged with the rule.
fn constrain(s: &mut Session, kind: &str, a: Value, b: Value, extra: Value) -> Value {
let mut p = json!({"type": kind, "a": a, "b": b});
if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) {
p.extend(extra.clone());
}
run(s, "assembly.constraint.create", p)
}
fn dof_of(r: &Value, s: &Session, component: &str) -> Option<u64> {
let o = occ(s, component);
r["dof"].as_array()?.iter().find(|d| d["occurrence"] == json!(o))?["dof"].as_u64()
}
/// Mate, flush and a distance place the bracket fully, in the panel's words, and it follows the
/// panel as it changes size: its bottom on the top, its front flush with the panel's front, its
/// right end 40 mm in from the panel's.
#[test]
fn mate_flush_and_distance_place_a_part_and_follow_the_panel() {
let mut s = panel();
let r = constrain(&mut s, "mate", json!("Top"), json!("Bottom"), json!({}));
assert_eq!(dof(&r, &s), Some(3), "{r}");
assert_eq!(r["name"], "Mate1");
let r = constrain(&mut s, "flush", json!("Front"), json!("Face"), json!({}));
assert_eq!(dof(&r, &s), Some(1), "{r}");
let r = constrain(&mut s, "distance", json!("Right"), json!("Corner"), json!({"offset": -40}));
assert!(clean(&r), "{r}");
assert_eq!(dof(&r, &s), Some(0), "{r}");
for w in [400.0, 650.0, 250.0] {
run(&mut s, "parameters.change", json!({"name": "W", "expression": format!("{w} mm")}));
let r = run(&mut s, "joint.solve", json!({}));
assert!(clean(&r), "W {w}: {r}");
assert!(near(place(&s, "Bracket", Vec3::new(20.0, 500.0, 0.0)), Vec3::new(w - 40.0, 0.0, 2.0), 1e-6), "W {w}");
assert!(near(place(&s, "Bracket", Vec3::new(0.0, 500.0, 0.0)), Vec3::new(w - 60.0, 0.0, 2.0), 1e-6), "W {w}");
assert!(near(place(&s, "Bracket", Vec3::new(20.0, 510.0, 6.0)), Vec3::new(w - 40.0, 10.0, 8.0), 1e-6), "W {w}");
}
// Each is a joint of the kind its rule needs, tagged with the rule.
let list = run(&mut s, "joint.list", json!({}));
let kinds: Vec<(String, String)> = list["joints"]
.as_array()
.unwrap()
.iter()
.map(|j| (j["type"].as_str().unwrap().to_string(), j["constraint"].as_str().unwrap_or("").to_string()))
.collect();
assert_eq!(kinds, [("planar".into(), "mate".into()), ("planar".into(), "flush".into()), ("point_on_plane".into(), "distance".into())]);
let inspect = run(&mut s, "joint.inspect", json!({"joint": "Flush1"}));
assert_eq!(inspect["joints"][0]["constraint"], "flush", "{inspect}");
// Saved and reopened, the rules stay; a joint made as a joint has none.
let back = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
let rules: Vec<_> = back.assembly.joints.iter().map(|j| j.constraint).collect();
use solidcraft_doc::joints::Constraint;
assert_eq!(rules, [Some(Constraint::Mate), Some(Constraint::Flush), Some(Constraint::Distance)]);
relation(&mut s, "parallel", "Top", "Bottom", json!({}));
assert!(!s.doc.to_json().contains("\"constraint\":null"));
assert_eq!(s.doc.assembly.joints.last().unwrap().constraint, None);
}
/// A mate moves the part only across the gap: a bracket flat above the panel drops straight onto
/// it, not turned and not slid.
#[test]
fn a_mate_moves_the_part_only_across_the_gap() {
let mut s = Session::default();
run(&mut s, "component.create", json!({"name": "Plate"}));
run(&mut s, "solid.box", json!({"length": 100, "width": 100, "height": 10, "corner": [0, 0, 0]}));
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "component.create", json!({"name": "Block"}));
run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 20, "corner": [30, 40, 50]}));
run(&mut s, "component.activate", json!({"component": "root"}));
let plate = occ(&s, "Plate");
run(&mut s, "occurrence.ground", json!({"occurrence": plate, "grounded": true}));
let corners = [Vec3::new(30.0, 40.0, 50.0), Vec3::new(50.0, 40.0, 50.0), Vec3::new(30.0, 60.0, 70.0)];
let r = constrain(
&mut s,
"mate",
json!({"occurrence": "Plate", "face": [5, 5, 10]}),
json!({"occurrence": "Block", "face": [31, 41, 50]}),
json!({}),
);
assert!(clean(&r), "{r}");
assert_eq!(dof_of(&r, &s, "Block"), Some(3), "{r}");
for c in corners {
let got = place(&s, "Block", c);
assert!(near(got, c - Vec3::new(0.0, 0.0, 40.0), 1e-6), "{c:?}: {got:?}");
}
// A gap: 5 mm above the plate.
let r = run(&mut s, "joint.edit", json!({"joint": "Mate1", "offset": 5}));
assert!(clean(&r), "{r}");
assert!(near(place(&s, "Block", corners[0]), Vec3::new(30.0, 40.0, 15.0), 1e-6));
}
/// A part's own datum plane and axis are references too: a block mated onto the plate's XY plane
/// drops onto it, and a point on the plate's Z axis brings its corner onto that axis. Insert and
/// tangent need a rim or a radius, which an axis has not.
#[test]
fn datum_planes_and_axes_take_constraints() {
let mut s = Session::default();
run(&mut s, "component.create", json!({"name": "Plate"}));
run(&mut s, "solid.box", json!({"length": 100, "width": 100, "height": 10, "corner": [0, 0, 0]}));
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "component.create", json!({"name": "Block"}));
run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 20, "corner": [30, 40, 50]}));
run(&mut s, "component.activate", json!({"component": "root"}));
let plate = occ(&s, "Plate");
run(&mut s, "occurrence.ground", json!({"occurrence": plate, "grounded": true}));
let xy = json!({"occurrence": "Plate", "plane": "XY"});
let z = json!({"occurrence": "Plate", "axis": "Z"});
let bottom = json!({"occurrence": "Block", "face": [31, 41, 50]});
let corner = json!({"occurrence": "Block", "point": [30, 40, 50]});
let r = constrain(&mut s, "mate", xy.clone(), bottom.clone(), json!({}));
assert!(clean(&r), "{r}");
assert_eq!(dof_of(&r, &s, "Block"), Some(3), "{r}");
assert!(near(place(&s, "Block", Vec3::new(30.0, 40.0, 50.0)), Vec3::new(30.0, 40.0, 0.0), 1e-6));
assert!(near(place(&s, "Block", Vec3::new(50.0, 60.0, 70.0)), Vec3::new(50.0, 60.0, 20.0), 1e-6));
let r = constrain(&mut s, "point_on_axis", z.clone(), corner.clone(), json!({}));
assert!(clean(&r), "{r}");
assert_eq!(dof_of(&r, &s, "Block"), Some(1), "{r}");
assert!(near(place(&s, "Block", Vec3::new(30.0, 40.0, 50.0)), Vec3::ZERO, 1e-6));
for (kind, a, b, says) in [
("insert", z.clone(), z.clone(), "two round edges"),
("tangent", xy.clone(), z.clone(), "a planar face and a round face"),
("distance", xy.clone(), z.clone(), "a planar face and a point"),
] {
let p = json!({"type": kind, "a": a, "b": b, "offset": 1});
let e = s.execute("assembly.constraint.create", &p).expect_err(&p.to_string()).to_string();
assert!(e.contains(says), "{p}: {e}");
}
assert_eq!(s.doc.assembly.joints.len(), 2);
}
/// A shaft and a collar: two round faces. Concentric leaves a slide and a turn; Insert only the
/// turn, with the collar's end on the shaft's.
#[test]
fn concentric_and_insert_leave_a_slide_and_a_turn_or_a_turn() {
let shaft = || {
let mut s = Session::default();
run(&mut s, "component.create", json!({"name": "Shaft"}));
run(&mut s, "solid.cylinder", json!({"base": [0, 0, 0], "axis": [0, 0, 1], "radius": 5, "height": 20, "operation": "new"}));
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "component.create", json!({"name": "Collar"}));
run(&mut s, "solid.cylinder", json!({"base": [50, 10, 30], "axis": [0, 0, 1], "radius": 5, "height": 10, "operation": "new"}));
run(&mut s, "component.activate", json!({"component": "root"}));
let o = occ(&s, "Shaft");
run(&mut s, "occurrence.ground", json!({"occurrence": o, "grounded": true}));
s
};
let top = json!({"occurrence": "Shaft", "circle": [5, 0, 20]});
let bottom = json!({"occurrence": "Collar", "circle": [55, 10, 30]});
let mut s = shaft();
let r = constrain(&mut s, "concentric", top.clone(), bottom.clone(), json!({}));
assert!(clean(&r), "{r}");
assert_eq!(dof_of(&r, &s, "Collar"), Some(2), "{r}");
let c = place(&s, "Collar", Vec3::new(50.0, 10.0, 30.0));
assert!(c.x.abs() < 1e-6 && c.y.abs() < 1e-6, "on the shaft's axis: {c:?}");
let mut s = shaft();
let r = constrain(&mut s, "insert", top, bottom, json!({}));
assert!(clean(&r), "{r}");
assert_eq!(dof_of(&r, &s, "Collar"), Some(1), "{r}");
assert!(near(place(&s, "Collar", Vec3::new(50.0, 10.0, 30.0)), Vec3::new(0.0, 0.0, 20.0), 1e-6));
assert!(near(place(&s, "Collar", Vec3::new(50.0, 10.0, 40.0)), Vec3::new(0.0, 0.0, 30.0), 1e-6), "standing on the shaft, not through it");
}
/// The rules' remaining freedoms: parallel 4, perpendicular and angle 5, point on axis 4, tangent
/// 4 (a roller on the panel: it rolls, slides and turns), each made where the parts are.
#[test]
fn each_rule_leaves_the_freedom_it_should() {
let roller = |s: &mut Session| {
run(s, "component.create", json!({"name": "Roller"}));
run(s, "solid.cylinder", json!({"base": [0, 600, 50], "axis": [1, 0, 0], "radius": 4, "height": 30, "operation": "new"}));
run(s, "component.activate", json!({"component": "root"}));
};
for (kind, a, b, extra, part, want) in [
("parallel", json!("Top"), json!("Bottom"), json!({}), "Bracket", 4),
("perpendicular", json!("Top"), json!("Face"), json!({}), "Bracket", 5),
("angle", json!("Top"), json!("Bottom"), json!({"angle": 30}), "Bracket", 5),
("distance", json!("Top"), json!("Bottom"), json!({"offset": 12}), "Bracket", 3),
("tangent", json!("Top"), json!({"occurrence": "Roller", "circle": [0, 604, 50]}), json!({}), "Roller", 4),
("point_on_axis", json!({"occurrence": "Roller", "circle": [0, 604, 50]}), json!("Corner"), json!({}), "Bracket", 4),
] {
let mut s = panel();
roller(&mut s);
if kind == "point_on_axis" {
let o = occ(&s, "Roller");
run(&mut s, "occurrence.ground", json!({"occurrence": o, "grounded": true}));
}
let r = constrain(&mut s, kind, a, b, extra);
assert!(clean(&r), "{kind}: {r}");
assert_eq!(dof_of(&r, &s, part), Some(want), "{kind}: {r}");
if kind == "tangent" {
// Its axis a radius above the panel's top.
let p = place(&s, "Roller", Vec3::new(0.0, 600.0, 50.0));
assert!((p.z - 6.0).abs() < 1e-6, "tangent: {p:?}");
}
}
}
/// A rule given the wrong kind of reference, or without the number it needs, is refused with what
/// it needs, and nothing is added.
#[test]
fn a_rule_with_the_wrong_references_is_refused() {
let mut s = panel();
let cases = [
(json!({"type": "mate", "a": "Top", "b": "Corner"}), "two planar faces"),
(json!({"type": "insert", "a": "Top", "b": "Bottom"}), "two round edges"),
(json!({"type": "distance", "a": "Top", "b": "Bottom"}), "offset"),
(json!({"type": "angle", "a": "Top", "b": "Bottom"}), "angle"),
(json!({"type": "weld", "a": "Top", "b": "Bottom"}), "`type` must be"),
(json!({"type": "mate", "a": "Top", "b": "Bottom", "offset": "1e999"}), ""),
];
for (p, says) in cases {
let e = s.execute("assembly.constraint.create", &p).expect_err(&p.to_string()).to_string();
assert!(e.contains(says), "{p}: {e}");
}
assert!(s.doc.assembly.joints.is_empty());
}
/// A roller tangent on the panel keeps resting on it as its radius changes, and a gap edited in as
/// a number stays a gap above the face, not the axis's height.
/// A constraint given a blank or `null` name is named after its rule, as one given no name is. A
/// joint.edit to another kind of joint makes it a plain joint: the tag goes, and a former tangent's
/// offset no longer follows its face's radius.
#[test]
fn a_constraint_edited_to_another_joint_type_is_a_plain_joint() {
let mut s = panel();
run(&mut s, "parameters.add", json!({"name": "R", "expression": "4 mm"}));
run(&mut s, "component.create", json!({"name": "Roller"}));
run(&mut s, "solid.cylinder", json!({"base": [0, 600, 50], "axis": [1, 0, 0], "radius": "R", "height": 30, "operation": "new"}));
run(&mut s, "component.activate", json!({"component": "root"}));
let r = constrain(&mut s, "tangent", json!("Top"), json!({"occurrence": "Roller", "circle": [0, 604, 50]}), json!({"name": null}));
assert!(clean(&r), "{r}");
let r = constrain(&mut s, "parallel", json!("Top"), json!("Bottom"), json!({"name": " "}));
assert!(clean(&r), "{r}");
let names: Vec<&str> = s.doc.assembly.joints.iter().map(|j| j.name.as_str()).collect();
assert_eq!(names, ["Tangent1", "Parallel1"]);
run(&mut s, "joint.edit", json!({"joint": "Tangent1", "type": "revolute"}));
let j = s.doc.assembly.joints.iter().find(|j| j.name == "Tangent1").cloned().unwrap();
assert_eq!((j.kind, j.constraint, j.exprs.offset.clone()), (solidcraft_doc::joints::JointKind::Revolute, None, None));
let list = run(&mut s, "joint.list", json!({}));
let listed = list["joints"].as_array().and_then(|js| js.iter().find(|x| x["name"] == "Tangent1")).cloned().unwrap_or_default();
assert!(listed.get("constraint").is_none(), "{listed}");
// The radius no longer moves it.
run(&mut s, "parameters.change", json!({"name": "R", "expression": "7 mm"}));
run(&mut s, "joint.solve", json!({}));
let after = s.doc.assembly.joints.iter().find(|j| j.name == "Tangent1").map(|j| j.offset);
assert_eq!(after, Some(j.offset));
}
#[test]
fn a_tangent_constraint_follows_its_faces_radius() {
let mut s = panel();
run(&mut s, "parameters.add", json!({"name": "R", "expression": "4 mm"}));
run(&mut s, "component.create", json!({"name": "Roller"}));
run(&mut s, "solid.cylinder", json!({"base": [0, 600, 50], "axis": [1, 0, 0], "radius": "R", "height": 30, "operation": "new"}));
run(&mut s, "component.activate", json!({"component": "root"}));
let r = constrain(&mut s, "tangent", json!("Top"), json!({"occurrence": "Roller", "circle": [0, 604, 50]}), json!({}));
assert!(clean(&r), "{r}");
let axis = |s: &Session| place(s, "Roller", Vec3::new(0.0, 600.0, 50.0)).z;
assert!((axis(&s) - 6.0).abs() < 1e-6, "{}", axis(&s));
for (radius, gap, want) in [(7.0, None, 9.0), (2.5, None, 4.5), (2.5, Some(1.0), 5.5), (6.0, None, 9.0)] {
run(&mut s, "parameters.change", json!({"name": "R", "expression": format!("{radius} mm")}));
if let Some(g) = gap {
run(&mut s, "joint.edit", json!({"joint": "Tangent1", "offset": g}));
}
let r = run(&mut s, "joint.solve", json!({}));
assert!(clean(&r), "R {radius}: {r}");
assert!((axis(&s) - want).abs() < 1e-6, "R {radius}, gap {gap:?}: axis at {}, expected {want}", axis(&s));
}
}
// assembly.constraint.edit: a constraint's rule, gap, angle, facing or suppression changed in place.
/// A plate (100 x 100 x 10) and a 20 mm block above it at (30, 40, 50), the plate grounded; the
/// block's bottom mated to the plate's top as `Mate1`.
fn plate_and_block() -> Session {
let mut s = Session::default();
run(&mut s, "component.create", json!({"name": "Plate"}));
run(&mut s, "solid.box", json!({"length": 100, "width": 100, "height": 10, "corner": [0, 0, 0]}));
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "component.create", json!({"name": "Block"}));
run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 20, "corner": [30, 40, 50]}));
run(&mut s, "component.activate", json!({"component": "root"}));
let plate = occ(&s, "Plate");
run(&mut s, "occurrence.ground", json!({"occurrence": plate, "grounded": true}));
let r = constrain(
&mut s,
"mate",
json!({"occurrence": "Plate", "face": [5, 5, 10]}),
json!({"occurrence": "Block", "face": [31, 41, 50]}),
json!({}),
);
assert!(clean(&r), "{r}");
s
}
/// The block's lowest and highest z, from its eight corners.
fn block_z(s: &Session) -> (f64, f64) {
let mut zs = Vec::new();
for x in [30.0, 50.0] {
for y in [40.0, 60.0] {
for z in [50.0, 70.0] {
zs.push(place(s, "Block", Vec3::new(x, y, z)).z);
}
}
}
(zs.iter().copied().fold(f64::INFINITY, f64::min), zs.iter().copied().fold(f64::NEG_INFINITY, f64::max))
}
fn near_z(got: (f64, f64), want: (f64, f64)) -> bool {
(got.0 - want.0).abs() < 1e-6 && (got.1 - want.1).abs() < 1e-6
}
fn edit_rule(s: &mut Session, p: Value) -> Value {
run(s, "assembly.constraint.edit", p)
}
/// One constraint changed from Mate to Distance to Flush: each rule places the block where its
/// numbers say, the gap carries over to the next rule, and the joint is the kind its rule needs.
#[test]
fn a_constraints_rule_changes_in_place() {
let mut s = plate_and_block();
assert!(near_z(block_z(&s), (10.0, 30.0)), "mate: {:?}", block_z(&s));
// Distance 15: the bottom 15 mm above the top, still facing it.
let r = edit_rule(&mut s, json!({"constraint": "Mate1", "type": "distance", "offset": 15}));
assert!(clean(&r) && dof_of(&r, &s, "Block") == Some(3), "{r}");
assert!(near_z(block_z(&s), (25.0, 45.0)), "distance: {:?}", block_z(&s));
// Flush keeps the 15 mm gap: the bottom turned to face up, 15 mm above the top.
let r = edit_rule(&mut s, json!({"constraint": "Mate1", "type": "flush"}));
assert!(clean(&r) && dof_of(&r, &s, "Block") == Some(3), "{r}");
assert!(near_z(block_z(&s), (5.0, 25.0)), "flush, 15 mm: {:?}", block_z(&s));
// Flush at 0: the bottom in the top's plane, the block below it.
edit_rule(&mut s, json!({"constraint": "Mate1", "offset": 0}));
assert!(near_z(block_z(&s), (-10.0, 10.0)), "flush, 0: {:?}", block_z(&s));
let j = s.doc.assembly.joints.iter().find(|j| j.name == "Mate1").unwrap();
use solidcraft_doc::joints::{Constraint, JointKind};
assert_eq!((j.kind, j.flip, j.constraint), (JointKind::Planar, true, Some(Constraint::Flush)));
// Saved and reopened, the new rule stays.
let back = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
assert_eq!(back.assembly.joints.iter().find(|j| j.name == "Mate1").and_then(|j| j.constraint), Some(Constraint::Flush));
// Turned over again without a type: back to facing the top, 0 mm off it.
edit_rule(&mut s, json!({"constraint": "Mate1", "flip": true}));
assert!(near_z(block_z(&s), (10.0, 30.0)), "turned over: {:?}", block_z(&s));
}
/// A new gap moves the block straight across it: not turned, not slid.
#[test]
fn an_offset_edit_moves_the_part_only_across_the_gap() {
let mut s = plate_and_block();
let corners = [Vec3::new(30.0, 40.0, 50.0), Vec3::new(50.0, 40.0, 50.0), Vec3::new(30.0, 60.0, 70.0)];
let before = corners.map(|c| place(&s, "Block", c));
let r = edit_rule(&mut s, json!({"constraint": "Mate1", "offset": 5}));
assert!(clean(&r), "{r}");
for (c, was) in corners.into_iter().zip(before) {
let got = place(&s, "Block", c);
assert!(near(got, was + Vec3::new(0.0, 0.0, 5.0), 1e-6), "{c:?}: {got:?}, was {was:?}");
}
}
/// A rule the references don't fit, a plain joint, a missing angle: refused with why, the joint
/// as it was.
#[test]
fn an_edit_that_does_not_fit_is_refused_and_changes_nothing() {
let mut s = plate_and_block();
run(
&mut s,
"joint.create",
json!({"type": "parallel", "name": "parallel Top-Bottom", "a": {"occurrence": "Plate", "face": [5, 5, 10]}, "b": {"occurrence": "Block", "face": [31, 41, 10]}}),
);
let joints = s.doc.assembly.joints.clone();
let z = block_z(&s);
for (p, says) in [
(json!({"constraint": "Mate1", "type": "insert"}), "insert needs two round edges"),
(json!({"constraint": "Mate1", "type": "tangent"}), "tangent needs a planar face and a round face"),
(json!({"constraint": "Mate1", "type": "angle"}), "angle needs `angle`"),
(json!({"constraint": "Mate1", "type": "weld"}), "`type` must be"),
(json!({"constraint": "parallel Top-Bottom", "offset": 1}), "was made as a joint"),
(json!({"constraint": "Nothing"}), "no joint"),
] {
let e = s.execute("assembly.constraint.edit", &p).expect_err(&p.to_string()).to_string();
assert!(e.contains(says), "{p}: {e}");
assert_eq!(s.doc.assembly.joints, joints, "{p}");
assert!(near_z(block_z(&s), z), "{p}");
}
}
/// Suppressed, the constraint places nothing and the block is free; brought back, it sits on the
/// plate again.
#[test]
fn a_constraint_suppressed_and_brought_back() {
let mut s = plate_and_block();
let block = occ(&s, "Block");
run(&mut s, "occurrence.move", json!({"occurrence": block, "translate": [0, 0, 40]}));
let r = edit_rule(&mut s, json!({"constraint": "Mate1", "suppressed": true}));
assert!(s.doc.assembly.joints.iter().all(|j| j.suppressed), "{r}");
assert_eq!(dof_of(&r, &s, "Block"), Some(6), "{r}");
let r = edit_rule(&mut s, json!({"constraint": "Mate1", "suppressed": false}));
assert!(clean(&r) && dof_of(&r, &s, "Block") == Some(3), "{r}");
assert!(near_z(block_z(&s), (10.0, 30.0)), "back: {:?}", block_z(&s));
}
/// A tangent's gap carries over its radius: changed to 2 mm, then the rule mapped again, the
/// roller's axis stays a radius and 2 mm above the panel.
#[test]
fn a_tangent_keeps_its_gap_through_an_edit() {
let mut s = panel();
run(&mut s, "component.create", json!({"name": "Roller"}));
run(&mut s, "solid.cylinder", json!({"base": [0, 600, 50], "axis": [1, 0, 0], "radius": 4, "height": 30, "operation": "new"}));
run(&mut s, "component.activate", json!({"component": "root"}));
constrain(&mut s, "tangent", json!("Top"), json!({"occurrence": "Roller", "circle": [0, 604, 50]}), json!({}));
let axis = |s: &Session| place(s, "Roller", Vec3::new(0.0, 600.0, 50.0)).z;
edit_rule(&mut s, json!({"constraint": "Tangent1", "offset": 2}));
assert!((axis(&s) - 8.0).abs() < 1e-6, "{}", axis(&s));
let r = edit_rule(&mut s, json!({"constraint": "Tangent1", "type": "tangent"}));
assert!(clean(&r), "{r}");
assert!((axis(&s) - 8.0).abs() < 1e-6, "{}", axis(&s));
}
/// A rule changed to one kept as another kind of joint (a mate is planar, a parallel rule is not)
/// keeps a tag, with the new rule; the same change through joint.edit makes it a plain joint.
#[test]
fn a_rule_change_keeps_the_tag_where_joint_edit_drops_it() {
use solidcraft_doc::joints::{Constraint, JointKind};
let mut s = plate_and_block();
let r = edit_rule(&mut s, json!({"constraint": "Mate1", "type": "parallel"}));
assert!(clean(&r), "{r}");
let rule = |s: &Session| s.doc.assembly.joints.iter().find(|j| j.name == "Mate1").map(|j| (j.kind, j.constraint));
assert_eq!(rule(&s), Some((JointKind::Parallel, Some(Constraint::Parallel))));
// Started where its parts are: the block didn't move.
assert!(near_z(block_z(&s), (10.0, 30.0)), "{:?}", block_z(&s));
run(&mut s, "joint.edit", json!({"joint": "Mate1", "type": "planar"}));
assert_eq!(rule(&s), Some((JointKind::Planar, None)));
}
+6 -2
View File
@@ -42,8 +42,12 @@ fn target(s: &Session, p: &Value, cmd: &str) -> Result<String> {
fn convert(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "mesh.convert";
let state = s.model.state();
let body = target(s, p, cmd)?;
let source = state.body(&body).ok_or_else(|| bad(cmd, format!("no body `{body}`")))?;
let given = target(s, p, cmd)?;
// The selection and the Browser name a body of a further placement as the world shows it
// (`Mesh (Scan:2)`): that name means the body it shows, which the conversion edits in its
// component, as `appearance.assign` reads it (#9740).
let body = s.own_body_name(&given).ok_or_else(|| bad(cmd, format!("no body `{given}`")))?;
let source = state.body(&body).ok_or_else(|| bad(cmd, format!("no body `{given}`")))?;
if !source.body.is_mesh() {
return Err(bad(cmd, "select a mesh body"));
}
+8 -1
View File
@@ -33,6 +33,7 @@ mod component;
mod component_clone;
mod component_clone_assembly;
mod construct;
mod construct_auto;
mod delete;
pub(crate) mod diagnostics;
mod edit;
@@ -40,7 +41,9 @@ mod face;
mod features;
mod features_more;
mod join_pieces;
pub(crate) use construct::{GeoForm, GeoFormError, REF as GEO_REF_FORMS, SKETCH_PART, geo_form, is_model_axis, plane_axis_point_kind};
pub use construct::picked_plane_refs;
pub(crate) use construct::{GeoForm, GeoFormError, REF as GEO_REF_FORMS, SKETCH_PART, geo_form, is_model_axis, picked_plane};
pub use construct_auto::{construct_method, construct_methods, is_construct_tool};
pub use design_link::{LinkProblem, LinkState};
pub(crate) use face::{FaceRefForm, face_ref_form};
pub(crate) use features::profiles as feature_profiles;
@@ -86,6 +89,7 @@ mod measure_shape;
mod mesh;
mod metadata;
pub(crate) mod motion;
mod notes;
mod parts;
mod point_cloud;
mod script;
@@ -348,6 +352,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
v.extend(features::COMMANDS.iter());
v.extend(features_more::COMMANDS.iter());
v.extend(mesh::COMMANDS.iter());
v.extend(construct_auto::COMMANDS.iter());
v.extend(construct::COMMANDS.iter());
v.extend(sheet::COMMANDS.iter());
v.extend(plastic::COMMANDS.iter());
@@ -356,6 +361,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
v.extend(component::COMMANDS.iter());
v.extend(component_link::COMMANDS.iter());
v.extend(joints::COMMANDS.iter());
v.extend(joints::constraint::COMMANDS.iter());
v.extend(clearance::COMMANDS.iter());
v.extend(motion::COMMANDS.iter());
v.extend(panels::COMMANDS.iter());
@@ -364,6 +370,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
v.extend(bom::COMMANDS.iter());
v.extend(mass_properties::COMMANDS.iter());
v.extend(browser::COMMANDS.iter());
v.extend(notes::COMMANDS.iter());
v.extend(delete::COMMANDS.iter());
v.extend(edit::COMMANDS.iter());
v.extend(selection::COMMANDS.iter());
+11 -4
View File
@@ -22,6 +22,7 @@ use crate::schema::motion::{
FindLimitsResult, MotionListParams, MotionStudies, MotionStudyDeleteParams, MotionStudyDeleted, MotionStudyInfo, MotionStudyKeyInfo,
MotionStudyParams, MotionStudySaved,
};
use crate::schema::motion_animation::{AnimationExported, ExportAnimationParams};
use crate::schema::motion_play::{
DriveReport, ExportParams, PartPose, PlayParams, Pose, StudyExport, StudyExported, StudyPlayed, StudySample, UnmovedJoint,
};
@@ -67,6 +68,7 @@ const EXPLODE_LIST_CONTRACT: TypedContract<ExplodeListParams> = TypedContract::n
const EXPLODE_DELETE_CONTRACT: TypedContract<ExplodeDeleteParams> = TypedContract::new();
const PLAY_CONTRACT: TypedContract<PlayParams> = TypedContract::new();
const EXPORT_CONTRACT: TypedContract<ExportParams> = TypedContract::new();
const EXPORT_ANIMATION_CONTRACT: TypedContract<ExportAnimationParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("contact.enable_sets", "Enable Contact Sets", crate::checked!("contact.enable_sets", enable_sets))
@@ -138,8 +140,13 @@ pub static COMMANDS: &[CommandSpec] = &[
.contract(&EXPORT_CONTRACT)
.output(output_schema_for::<StudyExported>)
.params("study; samples? (default steps + 1); path? (.json or .csv) → occurrence positions per step (the design is not changed)"),
CommandSpec::new("motion.export_animation", "Export Motion Animation", animation::export).icon("export").noundo().params(
"study: id|name, or joint: id|name with from and to (degrees for angles, mm for distances, or expressions) and index?, or \
CommandSpec::new("motion.export_animation", "Export Motion Animation", crate::checked!("motion.export_animation", animation::export))
.icon("export")
.noundo()
.contract(&EXPORT_ANIMATION_CONTRACT)
.output(output_schema_for::<AnimationExported>)
.params(
"study: id|name, or joint: id|name with from and to (degrees for angles, mm for distances, or expressions) and index?, or \
view: id|name (an exploded view, coming apart from the assembled placement) with back? (default false; true: back together \
too, exploded at the middle frame of an odd count); path: a .gif file, or a folder for PNG frames (frame-0001.png, \
frame-0002.png, …); samples? (frames, 2…1000; default the study's steps + 1, 100 for a joint, 41 for a view or 81 with \
@@ -149,7 +156,7 @@ pub static COMMANDS: &[CommandSpec] = &[
axes) - draws the study as it plays, the joint as it is driven or the view as it explodes, with the headless renderer → \
path, format, frames, width, height, fps (GIF), bytes, renderer, stopped_at, stopped_by_loop, stopped_by_contact (the \
design is not changed, nor its placements)",
),
),
CommandSpec::new("motion.trace", "Motion Trace", crate::checked!("motion.trace", trace))
.at("SOLID", "ASSEMBLE")
.icon("trace")
@@ -640,7 +647,7 @@ fn closest(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Vec3 {
/// longer close: the values are moved from where they are toward `want` as far as they go. The
/// `given` values (joint, index) are driven; in a closed loop the others follow. Returns the
/// answer and the last solve.
fn drive_with_contact(s: &mut Session, want: &[(u64, Vec<f64>)], given: &BTreeSet<(u64, usize)>) -> Result<(Value, joints::Solution)> {
pub(super) fn drive_with_contact(s: &mut Session, want: &[(u64, Vec<f64>)], given: &BTreeSet<(u64, usize)>) -> Result<(Value, joints::Solution)> {
drive_with_contact_within(s, want, given, false)
}
+35 -32
View File
@@ -5,13 +5,15 @@
use std::collections::BTreeSet;
use serde_json::{Value, json};
use serde_json::{Map, Value};
use solidcore_record::Vec3;
use solidcraft_doc::Mat;
use super::{Plan, drive_with_contact, explode_moves, exploded_at, motion_plan, sample_count, view_index};
use crate::capture::{self, Renderer};
use crate::params::{bad, str_};
use crate::schema::motion_analysis::StudyStop;
use crate::schema::motion_animation::{AnimationExported, CAMERA_FIELDS, MAX_FRAMES, MAX_GIF_PIXELS, MAX_PNG_PIXELS, MAX_SIDE};
use crate::{Result, Session};
use solidcraft_viewport::Camera;
@@ -23,19 +25,6 @@ enum Motion {
Explode(Vec<(u64, Mat, Vec3)>, bool),
}
/// Most frames of one animation.
const MAX_FRAMES: usize = 1000;
/// Largest frame side, in pixels.
const MAX_SIDE: u32 = 4096;
/// Most pixels of a GIF's frames together: they are held in memory (4 bytes each) until the
/// palette is chosen, so 256 MB at most.
const MAX_GIF_PIXELS: usize = 1 << 26;
/// Most pixels of a PNG folder's frames together (each frame is written as it is drawn): about
/// 500 frames of 1920 × 1080.
const MAX_PNG_PIXELS: usize = 1 << 30;
/// The camera fields `camera` may set: those of `ui.camera`'s reply.
const FIELDS: [&str; 7] = ["target", "yaw", "pitch", "roll", "distance", "fov", "up"];
pub(super) fn export(s: &mut Session, p: &Value) -> Result<Value> {
export_with(s, p, capture::installed().as_deref())
}
@@ -128,14 +117,15 @@ pub(super) fn export_with(s: &Session, p: &Value, renderer: Option<&Renderer>) -
// Motion Study dialog shows) are not where the joints are solved, nor the assembled placement.
t.pending_moves.clear();
let (mut frames, mut bytes, mut drawn_by) = (Vec::new(), 0, "");
let mut stop = (Value::Null, Value::Null, Value::Null);
// The frame where the motion stopped short, and what stopped it: a loop, or a contact set.
let mut stop: Option<(usize, Option<Map<String, Value>>, Option<Map<String, Value>>)> = None;
for k in 0..n {
match &motion {
Motion::Drive(given, plan) => {
let want = plan(&t, k, n);
let (r, _) = drive_with_contact(&mut t, &want, given)?;
if stop.0.is_null() && (r["stopped_by_loop"].is_object() || r["stopped_by_contact"].is_object()) {
stop = (json!(k), r["stopped_by_loop"].clone(), r["stopped_by_contact"].clone());
if stop.is_none() && (r["stopped_by_loop"].is_object() || r["stopped_by_contact"].is_object()) {
stop = Some((k, r["stopped_by_loop"].as_object().cloned(), r["stopped_by_contact"].as_object().cloned()));
}
}
// Shown as explode.show shows it, the way out at a steady pace (and back).
@@ -156,24 +146,37 @@ pub(super) fn export_with(s: &Session, p: &Value, renderer: Option<&Renderer>) -
bytes += png.len();
}
}
let mut out = json!({"path": path, "format": if gif { "gif" } else { "png" }, "frames": n, "width": width, "height": height});
if gif {
let fps = if gif {
// GIF counts in hundredths of a second, and viewers slow anything under 2 down.
let delay = (100.0 / fps).round().clamp(2.0, 100.0) as u16;
let gif = crate::render::gif(width as usize, height as usize, &frames, delay).ok_or_else(|| bad(cmd, "GIF encoding failed"))?;
solidcraft_io::write_atomic(std::path::Path::new(path), &gif, false).map_err(io)?;
bytes = gif.len();
out["fps"] = json!(100.0 / f64::from(delay));
}
out["bytes"] = json!(bytes);
out["renderer"] = json!(drawn_by);
if let Some(w) = capture::warning(drawn_by) {
out["warning"] = json!(w);
}
out["stopped_at"] = stop.0;
out["stopped_by_loop"] = stop.1;
out["stopped_by_contact"] = stop.2;
Ok(out)
Some(100.0 / f64::from(delay))
} else {
None
};
let (stopped_at, stopped_by_loop, stopped_by_contact) = match stop {
Some((k, by_loop, by_contact)) => (Some(k), by_loop, by_contact),
None => (None, None, None),
};
super::typed_reply(
cmd,
AnimationExported {
path: path.to_string(),
format: if gif { "gif" } else { "png" },
frames: n,
width,
height,
fps,
bytes,
renderer: drawn_by,
warning: capture::warning(drawn_by),
stop: StudyStop { stopped_at, stopped_by_loop },
stopped_by_contact,
param_warnings: Vec::new(),
},
)
}
/// The camera the frames are drawn from: the session's (`view.apply`), or `home` framing the
@@ -187,8 +190,8 @@ fn camera(s: &Session, home: impl FnOnce() -> Result<Camera>, given: Option<&Val
let fields = given.as_object().ok_or_else(|| bad(cmd, "`camera` must be an object, such as ui.camera's reply"))?;
let mut v = serde_json::to_value(base).map_err(|e| bad(cmd, e.to_string()))?;
for (k, x) in fields {
if !FIELDS.contains(&k.as_str()) {
return Err(bad(cmd, format!("unknown camera field `{k}` (it takes {})", FIELDS.join(", "))));
if !CAMERA_FIELDS.contains(&k.as_str()) {
return Err(bad(cmd, format!("unknown camera field `{k}` (it takes {})", CAMERA_FIELDS.join(", "))));
}
if let Some(o) = v.as_object_mut() {
o.insert(k.clone(), x.clone());
+124
View File
@@ -0,0 +1,124 @@
//! Note commands (#1631): personal notes anchored to points in model space, saved with the
//! design and drawn in the viewport as screen-facing callouts. `note.add` anchors a note,
//! `note.update` edits its text, anchor, or hidden flag, `note.delete` removes it, and
//! `note.list` reads them back. Everything lives in the document, so notes are saved and
//! undone like any other design change.
use serde_json::{Value, json};
use solidcraft_doc::Note;
use super::CommandSpec;
use crate::params::{bad, bool_, req_vec3, str_};
use crate::schema::notes::{NoteAddParams, NoteDeleteParams, NoteDeleted, NoteId, NoteItem, NoteListParams, NoteUpdateParams, NotesList};
use crate::schema::{TypedContract, output_schema_for};
use crate::{Result, Session};
const ADD: TypedContract<NoteAddParams> = TypedContract::new();
const UPDATE: TypedContract<NoteUpdateParams> = TypedContract::new();
const DELETE: TypedContract<NoteDeleteParams> = TypedContract::new();
const LIST: TypedContract<NoteListParams> = TypedContract::new();
/// Notes a design may hold.
const MAX_NOTES: usize = 1000;
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("note.add", "Add Note", crate::checked!("note.add", note_add))
.icon("text")
.contract(&ADD)
.output(output_schema_for::<NoteId>)
.params("anchor: [x,y,z] (the point in model space, mm); text: the note (may span lines); hidden?: bool (saved but not drawn) → note: id"),
CommandSpec::new("note.update", "Edit Note", crate::checked!("note.update", note_update))
.icon("edit")
.contract(&UPDATE)
.output(output_schema_for::<NoteId>)
.params("note: id; text?: the new text; anchor?: [x,y,z] (move the anchor); hidden?: bool (show or hide) → note: id"),
CommandSpec::new("note.delete", "Delete Note", crate::checked!("note.delete", note_delete))
.icon("delete")
.contract(&DELETE)
.output(output_schema_for::<NoteDeleted>)
.params("note: id → deleted: id"),
CommandSpec::new("note.list", "List Notes", crate::checked!("note.list", note_list))
.icon("list")
.contract(&LIST)
.output(output_schema_for::<NotesList>)
.noundo()
.doc_only()
.params("→ notes: [{id, anchor: [x,y,z], text, hidden}] in creation order"),
];
fn note_add(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "note.add";
let anchor = req_vec3(CMD, p, "anchor")?;
let text = str_(p, "text").ok_or_else(|| bad(CMD, "`text` is required"))?;
if !Note::text_is_valid(text) {
return Err(bad(CMD, format!("`text` must not be empty and must be at most {} characters", solidcraft_doc::notes::MAX_NOTE_CHARS)));
}
if s.doc.notes.len() >= MAX_NOTES {
return Err(bad(CMD, "too many notes"));
}
let id = s.doc_mut().allocate_id()?;
let hidden = bool_(p, "hidden").unwrap_or(false);
s.doc_mut().notes.push(Note { id, anchor, text: text.to_string(), hidden });
Ok(json!({ "note": id }))
}
/// The index of a note by id.
fn note_index(s: &Session, p: &Value, cmd: &str) -> Result<usize> {
let id = p.get("note").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "`note` must be a note id"))?;
s.doc.notes.iter().position(|n| n.id == id).ok_or_else(|| bad(cmd, format!("no note {id}")))
}
fn note_update(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "note.update";
let i = note_index(s, p, CMD)?;
let text = match str_(p, "text") {
Some(t) if Note::text_is_valid(t) => Some(t.to_string()),
Some(_) => {
return Err(bad(CMD, format!("`text` must not be empty and must be at most {} characters", solidcraft_doc::notes::MAX_NOTE_CHARS)));
}
None => None,
};
let anchor = match p.get("anchor") {
Some(_) => Some(req_vec3(CMD, p, "anchor")?),
None => None,
};
let hidden = bool_(p, "hidden");
if text.is_none() && anchor.is_none() && hidden.is_none() {
return Err(bad(CMD, "give at least one of `text`, `anchor`, `hidden`"));
}
let id = {
let n = &mut s.doc_mut().notes[i];
if let Some(t) = text {
n.text = t;
}
if let Some(a) = anchor {
n.anchor = a;
}
if let Some(h) = hidden {
n.hidden = h;
}
n.id
};
Ok(json!({ "note": id }))
}
fn note_delete(s: &mut Session, p: &Value) -> Result<Value> {
let i = note_index(s, p, "note.delete")?;
let n = s.doc_mut().notes.remove(i);
Ok(json!({ "deleted": n.id }))
}
fn note_list(s: &mut Session, _p: &Value) -> Result<Value> {
let notes: Vec<NoteItem> = s
.doc
.notes
.iter()
.map(|n| NoteItem { id: n.id, anchor: [n.anchor.x, n.anchor.y, n.anchor.z], text: n.text.clone(), hidden: n.hidden })
.collect();
Ok(json!({ "notes": notes }))
}
#[cfg(test)]
#[path = "notes_tests.rs"]
mod tests;
+87
View File
@@ -0,0 +1,87 @@
//! Note commands (#1631): add, edit, delete, list, undo, and persistence.
use serde_json::{Value, json};
use crate::Session;
fn run(s: &mut Session, id: &str, params: Value) -> Value {
s.execute(id, &params).unwrap_or_else(|e| panic!("{id} {params}: {e}"))
}
#[test]
fn add_update_delete_and_list_round_trip() {
let mut s = Session::default();
let added = run(&mut s, "note.add", json!({"anchor": [10.0, 20.0, 30.0], "text": "Check clearance here."}));
let id = added["note"].as_u64().unwrap();
assert!(id > 0);
let listed = run(&mut s, "note.list", json!({}));
let notes = listed["notes"].as_array().unwrap();
assert_eq!(notes.len(), 1);
assert_eq!(notes[0]["id"], json!(id));
assert_eq!(notes[0]["text"], "Check clearance here.");
assert_eq!(notes[0]["anchor"], json!([10.0, 20.0, 30.0]));
assert_eq!(notes[0]["hidden"], json!(false));
run(&mut s, "note.update", json!({"note": id, "text": "Adjust this diameter after the test print.", "hidden": true}));
let listed = run(&mut s, "note.list", json!({}));
assert_eq!(listed["notes"][0]["text"], "Adjust this diameter after the test print.");
assert_eq!(listed["notes"][0]["hidden"], json!(true));
run(&mut s, "note.update", json!({"note": id, "anchor": [1.0, 2.0, 3.0]}));
assert_eq!(run(&mut s, "note.list", json!({}))["notes"][0]["anchor"], json!([1.0, 2.0, 3.0]));
assert_eq!(run(&mut s, "note.delete", json!({"note": id})), json!({"deleted": id}));
assert!(run(&mut s, "note.list", json!({}))["notes"].as_array().unwrap().is_empty());
}
#[test]
fn notes_survive_a_save_and_reopen() {
let mut s = Session::default();
run(&mut s, "note.add", json!({"anchor": [0.0, 0.0, 5.0], "text": "Extra thickness added for strength."}));
let saved = serde_json::to_string(&*s.doc).unwrap();
let reopened = Session::new(serde_json::from_str(&saved).unwrap());
assert_eq!(reopened.doc.notes.len(), 1);
assert_eq!(reopened.doc.notes[0].text, "Extra thickness added for strength.");
}
#[test]
fn undo_undoes_note_changes() {
let mut s = Session::default();
run(&mut s, "note.add", json!({"anchor": [0.0, 0.0, 0.0], "text": "first"}));
run(&mut s, "note.add", json!({"anchor": [1.0, 0.0, 0.0], "text": "second"}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.notes.len(), 1);
assert_eq!(s.doc.notes[0].text, "first");
let id = s.doc.notes[0].id;
run(&mut s, "note.update", json!({"note": id, "text": "renamed"}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.notes[0].text, "first");
}
#[test]
fn ids_are_never_reused() {
let mut s = Session::default();
let a = run(&mut s, "note.add", json!({"anchor": [0.0, 0.0, 0.0], "text": "a"}))["note"].as_u64().unwrap();
run(&mut s, "note.delete", json!({"note": a}));
let b = run(&mut s, "note.add", json!({"anchor": [0.0, 0.0, 0.0], "text": "b"}))["note"].as_u64().unwrap();
assert_ne!(a, b);
}
#[test]
fn bad_calls_are_refused() {
let mut s = Session::default();
// Empty or missing text.
assert!(s.execute("note.add", &json!({"anchor": [0.0, 0.0, 0.0], "text": " "})).is_err());
assert!(s.execute("note.add", &json!({"anchor": [0.0, 0.0, 0.0]})).is_err());
// A non-finite anchor.
assert!(s.execute("note.add", &json!({"anchor": [f64::NAN, 0.0, 0.0], "text": "x"})).is_err());
// Unknown note id.
assert!(s.execute("note.update", &json!({"note": 999, "text": "x"})).is_err());
assert!(s.execute("note.delete", &json!({"note": 999})).is_err());
// An update that changes nothing.
run(&mut s, "note.add", json!({"anchor": [0.0, 0.0, 0.0], "text": "x"}));
let id = s.doc.notes[0].id;
assert!(s.execute("note.update", &json!({"note": id})).is_err());
assert_eq!(s.doc.notes[0].text, "x");
}
+129 -14
View File
@@ -38,7 +38,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("fastener")
.contract(&INSERT_CONTRACT)
.output(output_schema_for::<InsertedPart>)
.params("the Insert Part dialog's command (same as parts.insert): family, size, length?, at?|point?+direction? (from the shank's tip toward the head), name?"),
.params("the Insert Part dialog's command (same as parts.insert): family, size, length?, at?|point?+direction? (from the shank's tip toward the head), flip?, name?"),
CommandSpec::new("parts.insert", "Insert Part", crate::checked!("parts.insert", insert_atomic))
.icon("fastener")
.contract(&INSERT_CONTRACT)
@@ -47,11 +47,13 @@ pub static COMMANDS: &[CommandSpec] = &[
"family (socket_head_cap_screw | button_head_cap_screw | countersunk_socket_screw | hex_bolt | hex_nut | washer | set_screw | dowel_pin | bearing | \
hex_cap_screw_inch | hex_nut_inch | washer_sae | washer_uss); size (\"M6\", \"608\" for bearings, \"1/2-13 UNC\" for inch screws and nuts, \
\"1/2\" for inch washers); length? (mm, inch parts too; screws, bolts, set screws, pins; default a standard length near 2.5 d); \
at?: [x,y,z] on a hole's rim or wall (the part seats on the hole's face with a rigid joint), or a list of them (one component, \
placed and seated once per hole) | point?: [x,y,z] with direction?: [x,y,z] (no joint: the part's seating plane, under a head or \
at?: [x,y,z] on a hole's rim or wall (the part seats on the hole's face with a rigid joint, head on the picked face and \
shank into the hole, whichever way the face points) or on a flat face (seated at that point, head out of the face), or a list of \
them (one component, placed and seated once per pick) | point?: [x,y,z] with direction?: [x,y,z] (no joint: the part's seating plane, under a head or \
the bottom of a nut, washer or bearing, the top of a set screw, pin or countersunk head, goes to point, and direction is its +z, from the shank's tip toward the head; default [0,0,1], \
head up, shank down -z); name? → component, occurrence, joint?, \
and with at: occurrences, joints (one per hole, in pick order)",
head up, shank down -z); flip?: bool (default false: turned over, head into the material with at, +z against direction \
with point); name? → component, occurrence, joint?, directions (each placement's head direction in the world, the one \
chosen), and with at: occurrences, joints (one per pick, in pick order)",
),
CommandSpec::new("parts.resize", "Change Part Size", crate::checked!("parts.resize", resize_atomic))
.icon("scale")
@@ -836,10 +838,15 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
// A pick on a hole's rim also touches its end face: step onto the wall just inside the rim.
// Each hole's occurrence is found before the part exists, so a pick off every body fails the
// insert before anything is made.
let flip = p.get("flip").and_then(Value::as_bool).unwrap_or(false);
let mut holes: Vec<(Vec3, u64)> = Vec::new();
let mut seats: Vec<(u64, HoleWall)> = Vec::new();
// Picks on a flat face, off every hole: the point and the face's outward normal (world).
let mut faces: Vec<Option<Vec3>> = Vec::new();
for a in hole_picks(p, cmd)? {
let wall = hole_wall(s, a).unwrap_or(HoleWall { at: a, seat: a, axis: None, ends: None, open: true, radius: 0.0 });
let found = hole_wall(s, a);
let face = if found.is_none() { outward_normal(s, a) } else { None };
let wall = found.unwrap_or(HoleWall { at: a, seat: a, axis: None, ends: None, open: true, radius: 0.0 });
let host = super::joints::occurrence_at(s, wall.at).ok_or_else(|| bad(cmd, "no body at `at` (pick a hole's rim or wall)"))?;
// A rim is drawn as more than one edge, and one click on it picks them all; a hole can
// also be picked at both its ends (#12191). The picks of one hole seat one part, at an
@@ -857,6 +864,7 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
None => {
holes.push((wall.at, host));
seats.push((host, wall));
faces.push(face);
}
}
}
@@ -876,8 +884,11 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
joint: None,
occurrences: None,
joints: None,
directions: Vec::new(),
param_warnings: Vec::new(),
};
// The part's −z (its shank) mates with the seat's outward z; flipped, its +z does.
let part_z = if flip { [0.0, 0.0, 1.0] } else { [0.0, 0.0, -1.0] };
if !holes.is_empty() {
// The first hole has the placement the component came with; each further hole places the
// same part again. All are made before any is seated, while each is still at the part's
@@ -891,7 +902,7 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
);
}
let mut joints = Vec::new();
for (i, ((a, h), o)) in holes.into_iter().zip(&occurrences).enumerate() {
for (i, (((a, h), o), face)) in holes.into_iter().zip(&occurrences).zip(faces).enumerate() {
// Seat it: its axis on the hole's, its seating plane on the hole's face, the shank
// going into the hole (the joint mates the part's −z with the face's outward z).
let seat = if i == 0 { format!("{name} seat") } else { format!("{name} seat {}", i + 1) };
@@ -908,19 +919,22 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
}
_ => [0.0, 0.0, 0.0],
};
let j = run(
s,
"joint.create",
json!({"type": "rigid", "a": {"occurrence": h, "circle": a}, "b": {"occurrence": o, "point": at_rim, "z": [0, 0, -1]}, "name": seat}),
)?;
// On a flat face: seated at the picked point, its z the face's outward normal.
let host = match face {
Some(n) => json!({"occurrence": h, "point": [a.x, a.y, a.z], "z": [n.x, n.y, n.z]}),
None => json!({"occurrence": h, "circle": a}),
};
let j = run(s, "joint.create", json!({"type": "rigid", "a": host, "b": {"occurrence": o, "point": at_rim, "z": part_z}, "name": seat}))?;
joints.push(j["joint"].as_u64().ok_or_else(|| bad(cmd, "the joint has no id"))?);
}
out.joint = joints.first().copied();
out.directions = occurrences.iter().map(|o| head_direction(s, *o)).collect();
out.occurrences = Some(occurrences);
out.joints = Some(joints);
} else if let Some(pt) = p.get("point").and_then(vec3) {
// Placed at a point, its +z along `direction`.
let dir = p.get("direction").and_then(vec3).and_then(|d| d.normalized()).unwrap_or(Vec3::Z);
let dir = if flip { dir * -1.0 } else { dir };
let m = solidcraft_doc::rigid(
Vec3::ZERO,
Vec3::ZERO,
@@ -936,9 +950,44 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
o.transform = m;
}
}
if out.directions.is_empty() {
out.directions = vec![head_direction(s, occ)];
}
reply(cmd, out)
}
/// The way a placed part's head points in the world: its +z.
fn head_direction(s: &Session, occ: u64) -> [f64; 3] {
let z = solidcraft_doc::apply_vector(&world_of(s, occ), Vec3::Z);
let z = z.normalized().unwrap_or(z);
// Rounding noise reads as zero.
let tidy = |v: f64| if v.abs() < 1e-12 { 0.0 } else { v };
[tidy(z.x), tidy(z.y), tidy(z.z)]
}
/// An occurrence's placement in the world (the design itself: none).
fn world_of(s: &Session, occ: u64) -> solidcraft_doc::Mat {
if occ == 0 { solidcraft_doc::IDENTITY } else { solidcraft_doc::joints::occurrence_world(&s.doc, occ) }
}
/// The outward normal (world) of the flat face at `a`, on the body the pick is on.
fn outward_normal(s: &Session, a: Vec3) -> Option<Vec3> {
use solidcraft_doc::joints::{JointOrigin, Snap};
let occurrence = super::joints::occurrence_at(s, a)?;
let t = world_of(s, occurrence);
let inv = solidcraft_doc::mat_inverse(&t)?;
let pick = solidcraft_doc::apply_point(&inv, a);
let st = s.model.state();
let o = JointOrigin::new(occurrence, Snap::FaceCenter { pick, face: None, x: None });
let m = solidcraft_doc::joints::resolve_origin(&s.doc, &st, &o).ok()?;
let (c, n) = (Vec3::new(m[3][0], m[3][1], m[3][2]), Vec3::new(m[2][0], m[2][1], m[2][2]));
// The nearest plane must be the one picked: the point lies on it.
if (pick - c).dot(n).abs() > 1e-6 {
return None;
}
solidcraft_doc::apply_vector(&t, n).normalized()
}
/// The most holes one insert seats a part in.
const MAX_HOLES: usize = 1000;
@@ -1436,11 +1485,11 @@ mod tests {
let undo = s.undo.len();
run(&mut s, "solid.combine", json!({"target":"Plate","tools":["Block"],"operation":"join","new_component":true}));
assert_eq!(s.undo.len(), undo + 1, "solid.combine new_component");
// A failed command: no inner step stays, and redo is kept.
// A failed command (a pick off every body): no inner step stays, and redo is kept.
run(&mut s, "edit.undo", json!({}));
let (undo, redo, doc) = (s.undo.len(), s.redo.len(), s.doc.clone());
assert!(redo > 0);
let failed = s.execute("parts.insert", &json!({"family":"socket_head_cap_screw","size":"M6","length":20,"at":[20,20,10]}));
let failed = s.execute("parts.insert", &json!({"family":"socket_head_cap_screw","size":"M6","length":20,"at":[200,200,200]}));
assert!(failed.is_err());
assert_eq!((s.undo.len(), s.redo.len()), (undo, redo), "a failed insert leaves the history alone");
assert_eq!(s.doc, doc);
@@ -1503,6 +1552,72 @@ mod tests {
}
}
/// #12150: a fastener picked on a flat face, off any hole, seats there with its head out of
/// the face, whichever way the face points, also on a component that is turned over; the
/// reply says which way each head points.
#[test]
fn a_fastener_picked_on_a_flat_face_seats_head_out_of_it() {
let screw = |at: Value| json!({"family": "socket_head_cap_screw", "size": "M3", "length": 8, "at": at});
// (pick, the face normal's axis, the part's extent on it: the 3 mm head outside the face,
// the 8 mm shank inside, the head's direction)
let cases: [(Value, usize, (f64, f64), [f64; 3]); 3] = [
(json!([10, 10, 10]), 2, (2.0, 13.0), [0.0, 0.0, 1.0]),
(json!([10, 10, 0]), 2, (-3.0, 8.0), [0.0, 0.0, -1.0]),
(json!([40, 10, 5]), 0, (32.0, 43.0), [1.0, 0.0, 0.0]),
];
for (at, axis, span, direction) in cases {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "body_name": "Plate"}));
let r = run(&mut s, "parts.insert", screw(at.clone()));
assert!(r["joint"].is_number(), "{at}: {r}");
assert_eq!(r["directions"], json!([direction]), "{at}: {r}");
let (lo, hi) = bbox(&s, "SHCS M3x8");
let (lo, hi) = ([lo.x, lo.y, lo.z][axis], [hi.x, hi.y, hi.z][axis]);
assert!((lo - span.0).abs() < 1e-6 && (hi - span.1).abs() < 1e-6, "{at}: {lo} {hi}");
}
// A plate component turned over: its top face now faces down, and the head goes below it.
let mut s = Session::default();
let plate = run(&mut s, "component.create", json!({"name": "P"}))["occurrence"].clone();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10}));
run(&mut s, "component.activate", json!({"component": 0}));
run(
&mut s,
"occurrence.move",
json!({"occurrence": plate, "axis": [1, 0, 0], "angle": "180 deg", "origin": [0, 0, 0], "move_grounded": true}),
);
let r = run(&mut s, "parts.insert", screw(json!([10, -10, -10])));
assert_eq!(r["directions"], json!([[0.0, 0.0, -1.0]]), "{r}");
let (lo, hi) = bbox(&s, "SHCS M3x8");
assert!((lo.z + 13.0).abs() < 1e-6 && (hi.z + 2.0).abs() < 1e-6, "{lo:?} {hi:?}");
}
/// #12150: `flip` turns a seated part over (head into the hole, shank out of the face) and a
/// part placed at a point against `direction`; the reply reports the direction chosen.
#[test]
fn flip_turns_an_inserted_part_over() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "body_name": "Plate"}));
run(&mut s, "solid.hole", json!({"position": [20, 15, 10], "diameter": 6.4}));
let seat = |flip: bool| json!({"family": "socket_head_cap_screw", "size": "M6", "length": 20, "at": [23.2, 15, 10], "flip": flip});
let r = run(&mut s, "parts.insert", seat(false));
assert_eq!(r["directions"], json!([[0.0, 0.0, 1.0]]), "{r}");
run(&mut s, "edit.undo", json!({}));
let r = run(&mut s, "parts.insert", seat(true));
assert_eq!(r["directions"], json!([[0.0, 0.0, -1.0]]), "{r}");
// The 6 mm head down in the hole under the top face, the 20 mm shank standing out of it.
let (lo, hi) = bbox(&s, "SHCS M6x20");
assert!((lo.z - 4.0).abs() < 1e-6 && (hi.z - 30.0).abs() < 1e-6, "{lo:?} {hi:?}");
let mut s = Session::default();
let r = run(
&mut s,
"parts.insert",
json!({"family": "socket_head_cap_screw", "size": "M6", "length": 20, "point": [5, 5, 10], "direction": [0, 0, 1], "flip": true}),
);
assert_eq!(r["directions"], json!([[0.0, 0.0, -1.0]]), "{r}");
let (lo, hi) = bbox(&s, "SHCS M6x20");
assert!((lo.z - 4.0).abs() < 1e-6 && (hi.z - 30.0).abs() < 1e-6, "{lo:?} {hi:?}");
}
/// Several holes in one insert: one component placed and seated once per hole, in one undo
/// step (#9341); two picks of one rim are one hole; a pick off every body fails the whole
/// insert.
+49
View File
@@ -1296,6 +1296,55 @@ fn circular_pattern_of_a_moved_copy_adds_one_body_per_instance() {
assert!(far > 20.0, "the repeats follow the redefined Move: {far}");
}
/// #12199: a path whose curves are listed out of chain order is the same path as in chain order.
#[test]
fn pattern_on_path_orders_the_curves_into_one_chain() {
// Four lines: (10,10) → (30,10) → (30,30) → (60,30) → (60,10); 90 mm long.
let copies = |order: [&str; 4]| {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 2, "width": 2, "height": 2, "corner": [9, 9, 0], "body_name": "Pin"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Path"}));
for (id, a, b) in [("l1", [10, 10], [30, 10]), ("l2", [30, 10], [30, 30]), ("l3", [30, 30], [60, 30]), ("l4", [60, 30], [60, 10])] {
run(&mut s, "sketch.line", json!({"points": [a, b], "connect": false, "ids": [id]}));
}
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.pattern.path", json!({"bodies": ["Pin"], "path_sketch": "Path", "path": order, "count": 4, "spacing": 25}));
let m = run(&mut s, "inspect.measure", json!({}));
let mut at: Vec<(i64, i64)> = m["bodies"]
.as_array()
.unwrap()
.iter()
.map(|b| ((b["bbox"]["min"][0].as_f64().unwrap() * 1e3).round() as i64, (b["bbox"]["min"][1].as_f64().unwrap() * 1e3).round() as i64))
.collect();
at.sort();
at
};
// The copies sit 0, 25, 50 and 75 mm along the path, as displacements from its start.
let want = vec![(9_000, 9_000), (29_000, 14_000), (39_000, 29_000), (59_000, 24_000)];
assert_eq!(copies(["l1", "l2", "l3", "l4"]), want);
assert_eq!(copies(["l1", "l3", "l2", "l4"]), want);
assert_eq!(copies(["l4", "l3", "l2", "l1"]), copies(["l4", "l2", "l3", "l1"]));
}
/// #12199: putting the listed curves in chain order does not join curves with a gap between
/// them: such a path is still refused.
#[test]
fn pattern_on_path_with_a_gap_is_still_refused() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 2, "width": 2, "height": 2, "corner": [9, 9, 0], "body_name": "Pin"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Path"}));
// (10,10) → (30,10), then a 20 mm gap, then (30,30) → (60,30).
for (id, a, b) in [("l1", [10, 10], [30, 10]), ("l3", [30, 30], [60, 30])] {
run(&mut s, "sketch.line", json!({"points": [a, b], "connect": false, "ids": [id]}));
}
run(&mut s, "sketch.finish", json!({}));
for order in [["l1", "l3"], ["l3", "l1"]] {
let p = json!({"bodies": ["Pin"], "path_sketch": "Path", "path": order, "count": 3, "spacing": 10});
let error = s.execute("solid.pattern.path", &p).unwrap_err().to_string();
assert!(error.contains("not connected end to end"), "{order:?}: {error}");
}
}
/// #12585: a pattern or mirror of an Offset Face repeats it at the copied faces instead of
/// failing with "cannot be patterned or mirrored yet". A 40 x 30 x 20 box whose top is split into
/// strips at x = 10 and x = 30 has its left strip raised 5; the copy raises the right strip.
+147
View File
@@ -3859,6 +3859,153 @@ fn a_hole_on_a_flange_panel_unfolds_with_the_flange() {
assert!(!contains(&flat_first, [30.0, 1.25, 12.0]) && contains(&flat_first, [33.0, 1.25, 12.0]));
}
/// A hat bent from one contour (#11728), 40 deep, of 1.5 mm sheet (R 1.5, K 0.44): feet from x ±21.55
/// out to ±29 at z 0…1.5, walls 56 high, the top at z 56…57.5. The flat lays the five panels and four
/// bends end to end: the feet 4.45 long, the walls 51.5, the top 40.1, each bend (π/2)(R + K·T).
fn hat(s: &mut Session) {
run(s, "sheet.manage_rules", json!({"name": "Al 1.5", "thickness": "1.5 mm", "bend_radius": "1.5 mm", "k_factor": "0.44", "active": true}));
run(s, "sketch.create", json!({"plane": {"origin": [0, 20, 0], "x_dir": [1, 0, 0], "y_dir": [0, 0, 1]}, "name": "Profile"}));
run(s, "sketch.line", json!({"points": [[-29, 0], [-21.55, 0], [-21.55, 56], [21.55, 56], [21.55, 0], [29, 0]]}));
run(s, "sketch.finish", json!({}));
run(
s,
"sheet.flange",
json!({"type": "contour", "sketch": "Profile", "curves": ["l1", "l2", "l3", "l4", "l5"], "distance": "40 mm", "flip": true, "body_name": "Hat"}),
);
}
/// The hat's holes: a Ø22.4 bore and four M3 clearance holes (Ø3.4) in the top, and one M3 hole in
/// each foot at x ±`foot`, y 5.
fn hat_holes(s: &mut Session, foot: f64) {
run(s, "solid.hole", json!({"position": [0, 0, 57.5], "diameter": 22.4, "targets": ["Hat"]}));
for (x, y, z) in [(15.5, 15.5, 57.5), (-15.5, 15.5, 57.5), (15.5, -15.5, 57.5), (-15.5, -15.5, 57.5), (foot, 5.0, 1.5), (-foot, 5.0, 1.5)] {
run(s, "solid.hole", json!({"position": [x, y, z], "clearance": "M3", "fit": "normal", "targets": ["Hat"]}));
}
}
/// Holes in the end panels of a contour flange (#11728) land in its flat pattern like the holes in
/// the panels between. Inside a foot, an M3 clearance hole is a cut-out and a DXF CIRCLE. At x ±27.5
/// it is 1.5 from the foot's free edge at ±29, and its radius is 1.7: it breaks out 0.2 past the
/// edge, so the flat has it as a notch in its outline (DXF ARCs), not as a cut-out. Places are read
/// from the flat's corner: u along the unrolled profile from the left foot's end, v = y + 20 across.
#[test]
fn holes_in_a_contour_flanges_end_panels_are_cut_outs_or_notches_of_its_flat_pattern() {
let (t, k, r) = (1.5, 0.44, 1.7);
let ba = FRAC_PI_2 * (1.5 + k * t);
let length = 2.0 * 4.45 + 2.0 * 51.5 + 40.1 + 4.0 * ba;
// Where x = 0 of the top lies: past a foot, a wall and two bends, and the top's 20.05 to its middle.
let mid = 4.45 + 51.5 + 2.0 * ba + 20.05;
// What the flat's corner is, and a place (u, v) from it.
let corner = |f: &Value| {
let points = f["outline"].as_array().into_iter().flatten().flat_map(|l| l.as_array().into_iter().flatten());
points.fold([f64::MAX, f64::MAX], |m, p| [m[0].min(p[0].as_f64().unwrap_or(f64::MAX)), m[1].min(p[1].as_f64().unwrap_or(f64::MAX))])
};
let at = |c: [f64; 2], u: f64, v: f64| [c[0] + u, c[1] + v];
let top_holes = |c: [f64; 2]| {
let mut holes = vec![(at(c, mid, 20.0), 11.2)];
holes.extend([(15.5, 15.5), (-15.5, 15.5), (15.5, -15.5), (-15.5, -15.5)].map(|(x, y)| (at(c, mid + x, 20.0 + y), r)));
holes
};
let circles = |s: &mut Session| -> Vec<(solidcore_record::Vec2, f64)> {
let dxf = run(s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap_or_default().to_string();
let geoms = solidcraft_io::read_dxf(&dxf).unwrap_or_default();
geoms.into_iter().filter_map(|g| if let solidcraft_io::Geom2::Circle(c, radius) = g { Some((c, radius)) } else { None }).collect()
};
// The part alone: the flat's area times the thickness, plus (π/2)·T²·(½ − K) for each 40 of bend.
let mut s = Session::default();
hat(&mut s);
let v0 = volume(&mut s);
let want = t * length * 40.0 + 4.0 * FRAC_PI_2 * t * t * (0.5 - k) * 40.0;
assert!(rel(v0, want) < 1e-9, "{v0} vs {want}");
// Inside the feet (x ±26.8: 0.55 from the bends, 0.5 from the edges): seven cut-outs, seven circles.
let mut inside = Session::default();
hat(&mut inside);
hat_holes(&mut inside, 26.8);
let removed = t * PI * (11.2 * 11.2 + 6.0 * r * r);
assert!((v0 - volume(&mut inside) - removed).abs() < 0.05, "{}", v0 - volume(&mut inside));
let f = flat(&mut inside);
let c = corner(&f);
assert_eq!(f["cutouts"].as_array().map(Vec::len), Some(7), "{}", f["cutouts"]);
let mut holes = top_holes(c);
holes.extend([(at(c, 2.2, 25.0), r), (at(c, length - 2.2, 25.0), r)]);
let drawn = circles(&mut inside);
assert_eq!(drawn.len(), 7, "{drawn:?}");
for (centre, radius) in holes {
assert!(has_round_cutout(&f, centre, radius), "{centre:?}: {}", f["cutouts"]);
assert!(
drawn.iter().any(|(p, rr)| p.dist(solidcore_record::Vec2::new(centre[0], centre[1])) < 1e-5 && (rr - radius).abs() < 1e-5),
"{centre:?}"
);
}
// The issue's feet holes, at x ±27.5: each cuts all of its circle but the segment past the edge.
hat_holes(&mut s, 27.5);
let past = r * r * (1.5 / r).acos() - 1.5 * (r * r - 1.5 * 1.5).sqrt();
let removed = t * (PI * 11.2 * 11.2 + 4.0 * PI * r * r + 2.0 * (PI * r * r - past));
let v1 = volume(&mut s);
assert!((v0 - v1 - removed).abs() < 0.05, "{} vs {removed}", v0 - v1);
let f = flat(&mut s);
let size = &f["flat_size_mm"];
assert!((size[0].as_f64().unwrap_or(f64::NAN) - length).abs() < 1e-6 && (size[1].as_f64().unwrap_or(f64::NAN) - 40.0).abs() < 1e-6, "{size}");
let c = corner(&f);
// Five cut-outs and five circles: the top's.
assert_eq!(f["cutouts"].as_array().map(Vec::len), Some(5), "{}", f["cutouts"]);
let drawn = circles(&mut s);
assert_eq!(drawn.len(), 5, "{drawn:?}");
for (centre, radius) in top_holes(c) {
assert!(has_round_cutout(&f, centre, radius), "{centre:?}: {}", f["cutouts"]);
assert!(
drawn.iter().any(|(p, rr)| p.dist(solidcore_record::Vec2::new(centre[0], centre[1])) < 1e-5 && (rr - radius).abs() < 1e-5),
"{centre:?}"
);
}
// The feet holes are notches: the outline runs round their circles, and the DXF draws them as
// arcs ending on the end edge, ±0.8 (√(1.7² − 1.5²)) either side of v 25.
let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap_or_default().to_string();
let arcs: Vec<_> = solidcraft_io::read_dxf(&dxf)
.unwrap_or_default()
.into_iter()
.filter_map(|g| if let solidcraft_io::Geom2::Arc { c, a, b } = g { Some((c, a, b)) } else { None })
.collect();
let outline: Vec<[f64; 2]> = f["outline"]
.as_array()
.into_iter()
.flatten()
.flat_map(|l| l.as_array().into_iter().flatten())
.map(|p| [p[0].as_f64().unwrap_or(f64::NAN), p[1].as_f64().unwrap_or(f64::NAN)])
.collect();
for (u, edge) in [(1.5, 0.0), (length - 1.5, length)] {
let centre = at(c, u, 25.0);
let centre = solidcore_record::Vec2::new(centre[0], centre[1]);
let on_circle = outline.iter().filter(|p| (solidcore_record::Vec2::new(p[0], p[1]).dist(centre) - r).abs() < 1e-3).count();
assert!(on_circle >= 3, "the outline does not run round the notch at u {u}: {on_circle} points");
let notch: Vec<_> = arcs.iter().filter(|(ac, a, _)| ac.dist(centre) < 1e-5 && (a.dist(centre) - r).abs() < 1e-5).collect();
let ends: Vec<solidcore_record::Vec2> = notch.iter().flat_map(|(_, a, b)| [*a, *b]).collect();
for v in [25.0 - 0.8, 25.0 + 0.8] {
let p = at(c, edge, v);
assert!(ends.iter().any(|e| e.dist(solidcore_record::Vec2::new(p[0], p[1])) < 1e-5), "no notch arc ends at {p:?}: {notch:?}");
}
}
// Unfolded, the sheet is its flat less the holes and the notches; refolded, the part again.
run(&mut s, "sheet.unfold", json!({}));
let unfolded = t * (length * 40.0 - PI * 11.2 * 11.2 - 4.0 * PI * r * r - 2.0 * (PI * r * r - past));
assert!((volume(&mut s) - unfolded).abs() < 0.05, "{} vs {unfolded}", volume(&mut s));
run(&mut s, "sheet.refold", json!({}));
assert!((volume(&mut s) - v1).abs() < 0.05, "{} vs {v1}", volume(&mut s));
// A cut that reaches a foot's bend (x 23.3…26.7, the bend ending at 24.55) is refused.
let mut bend = Session::default();
hat(&mut bend);
run(&mut bend, "sketch.create", json!({"plane": "XY", "name": "Bend"}));
run(&mut bend, "sketch.circle.center", json!({"center": [25, 5], "radius": r}));
run(&mut bend, "sketch.finish", json!({}));
let error = bend.execute("solid.extrude", &json!({"sketch": "Bend", "through_all": true, "operation": "cut", "direction": "symmetric"}));
assert!(error.as_ref().is_err_and(|e| e.to_string().contains("bend")), "{error:?}");
}
/// On a folded sheet a counterbore is the same pocket around the same hole, on the base and on a
/// flange, and stays through unfold and refold.
#[test]
+16 -2
View File
@@ -4,12 +4,12 @@
use serde_json::{Value, json};
use solidcore_record::Vec2;
use solidcraft_geom::prelude::*;
use solidcraft_sketch::{ConstraintKind, CurveKind, LinkKind, LinkSource, Shape, Sketch, TextAlign, TextExprs, TextStyle};
use solidcraft_sketch::{ConstraintKind, CurveKind, LinkKind, LinkSource, Shape, Sketch, TextAlign, TextExprs, TextStyle, TextVerticalAlign};
/// The style parameters of `sketch.text` and `sketch.edit_text`.
macro_rules! text_style_doc {
() => {
"bold?, italic?: bool (an installed family's bold or italic face; italic without one is slanted), align?: left|center|right (the lines against `at`, or along the path), letter_spacing?: extra mm between letters, line_spacing?: times the font's line distance (default 1), path?: a curve id of the sketch the baseline follows (\"\": none; `at` and `angle` then do not place the text; the curve becomes construction, a guide the letters stand on), reverse?: bool (the other way along the path)"
"bold?, italic?: bool (an installed family's bold or italic face; italic without one is slanted), align?: left|center|right (the lines against `at`, or along the path), vertical_align?: baseline|top|middle|bottom (the block of lines against `at` or the path: the first line's baseline (default), its capitals' tops, halfway between those and the last line's baseline, or the bottom of the last line's descenders), letter_spacing?: extra mm between letters, line_spacing?: times the font's line distance (default 1), path?: a curve id of the sketch the baseline follows (\"\": none; `at` and `angle` then do not place the text; the curve becomes construction, a guide the letters stand on), reverse?: bool (the other way along the path)"
};
}
@@ -246,6 +246,20 @@ fn text_style(p: &Value, cmd: &str, old: TextStyle) -> Result<TextStyle> {
}
Some(_) => return Err(bad(cmd, "`align` must be left, center or right")),
}
const VERTICAL_MUST: &str = "`vertical_align` must be baseline, top, middle or bottom";
match p.get("vertical_align") {
None | Some(Value::Null) => {}
Some(Value::String(a)) => {
style.vertical_align = match a.trim().to_ascii_lowercase().as_str() {
"baseline" => TextVerticalAlign::Baseline,
"top" => TextVerticalAlign::Top,
"middle" | "center" | "centre" => TextVerticalAlign::Middle,
"bottom" => TextVerticalAlign::Bottom,
_ => return Err(bad(cmd, VERTICAL_MUST)),
}
}
Some(_) => return Err(bad(cmd, VERTICAL_MUST)),
}
if p.get("letter_spacing").is_some_and(|v| !v.is_null()) {
style.letter_spacing =
num(p, "letter_spacing").filter(|v| v.is_finite()).ok_or_else(|| bad(cmd, "`letter_spacing` must be a length in mm"))?;
+116 -13
View File
@@ -6,12 +6,12 @@ use std::collections::HashMap;
use serde_json::{Value, json};
use solidcore_record::Vec2;
use solidcraft_io::{DxfEntity, DxfLayer, Geom2, MAX_DRAWING_BYTES};
use solidcraft_sketch::{Analysis, CurveKind, LinkKind, LinkSource, Sketch};
use solidcraft_sketch::{Analysis, ConstraintKind, CurveKind, LinkKind, LinkSource, Sketch};
use super::CommandSpec;
use super::sketch::{Left, edit_solved, ids_of};
use super::sketch_store;
use crate::params::{bad, num, str_, vec2};
use crate::params::{bad, bool_, num, str_, vec2};
use crate::schema::sketch_import::{DrawingInserted, DxfExported, ExportDxfParams, ExportDxfResult, InsertDxfParams, InsertSvgParams, SketchSolve};
use crate::schema::{TypedContract, output_schema_for};
use crate::{EngineError, Result, Session};
@@ -32,17 +32,17 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("sketch_dxf")
.contract(&INSERT_DXF_CONTRACT)
.output(output_schema_for::<DrawingInserted>)
.params(concat!("path | text: ASCII DXF (blocks expanded); at?: [x,y] offset, scale? | width? | height?: mm, the drawing's size before rotation, aspect kept (win over scale); angle?: degrees, about the drawing's origin (or its centre); align?: \"origin\" (default: `at` is where the file's origin goes) | \"center\" (`at` is where the drawing's middle goes and it turns and scales about it); tolerance?: mm within which ends join (default 0.0001); plane?: (when no sketch is active, a new sketch on it; default XY)", created_doc!())),
.params(concat!("path | text: ASCII DXF (blocks expanded); at?: [x,y] offset, scale? | width? | height?: mm, the size of the drawn geometry's bounds before rotation, aspect kept (win over scale); angle?: degrees, about the drawing's origin (or its centre); align?: \"origin\" (default: `at` is where the file's origin goes) | \"center\" (`at` is where the drawing's middle goes and it turns and scales about it); tolerance?: mm within which ends join (default 0.0001); fixed?: bool (a Fix constraint on every point the drawing adds, as sketch.constraint.fix puts one; a circle's or ellipse's radius stays free; default false); plane?: (when no sketch is active, a new sketch on it; default XY)", created_doc!())),
CommandSpec::new("sketch.insert_svg", "Insert SVG", crate::checked!("sketch.insert_svg", insert_svg))
.at("SKETCH", "INSERT")
.icon("svg")
.contract(&INSERT_SVG_CONTRACT)
.output(output_schema_for::<DrawingInserted>)
.params(concat!("path | text: SVG; at?: [x,y] offset, scale? | width? | height?: mm, the drawing's size before rotation, aspect kept (win over scale); angle?: degrees, about the drawing's origin (or its centre); align?: \"origin\" (default: `at` is where the file's origin goes) | \"center\" (`at` is where the drawing's middle goes and it turns and scales about it); tolerance?: mm within which ends join (default 0.0001); plane?: (when no sketch is active, a new sketch on it; default XY)", created_doc!())),
.params(concat!("path | text: SVG; at?: [x,y] offset, scale? | width? | height?: mm, the drawing's size before rotation, aspect kept (win over scale); size_from?: \"geometry\" (default: width and height are the bounds of the drawn geometry) | \"viewbox\" (the SVG sheet: its viewBox, else its width and height; a drawing smaller than its sheet then comes out smaller than width or height say); angle?: degrees, about the drawing's origin (or its centre); align?: \"origin\" (default: `at` is where the file's origin goes) | \"center\" (`at` is where the drawing's (or sheet's) middle goes and it turns and scales about it); tolerance?: mm within which ends join (default 0.0001); fixed?: bool (every point it adds gets a Fix constraint, so the drawing stays where it is put); plane?: (when no sketch is active, a new sketch on it; default XY)", created_doc!())),
CommandSpec::new("sketch.drawing_bounds", "Drawing Size", drawing_bounds)
.icon("svg")
.noundo()
.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"),
.params("path | text: SVG or DXF (by `format`: \"svg\" | \"dxf\", default by the file name, else SVG); returns min, max, width and height of the drawn geometry in the file's own size, and for an SVG with a viewBox (or width and height) its sheet: {min, max, width, height}"),
CommandSpec::new("sketch.export_dxf", "Save As DXF", crate::checked!("sketch.export_dxf", export_dxf))
.icon("sketch_dxf")
.noundo()
@@ -206,14 +206,18 @@ fn bounds_of(geom: &[Geom2]) -> Option<(Vec2, Vec2)> {
/// The drawing's geometry and the box around it; a drawing with none is an error that says why
/// (#9641).
fn read_drawing(p: &Value, cmd: &str, svg: bool) -> Result<(Vec<Geom2>, (Vec2, Vec2))> {
fn read_drawing(p: &Value, cmd: &str, svg: bool) -> Result<(Vec<Geom2>, (Vec2, Vec2), String)> {
let text = source_text(p, cmd, svg)?;
let geom = if svg { solidcraft_io::read_svg(&text) } else { solidcraft_io::read_dxf(&text) }.map_err(|e| drawing_error(p, svg, e.to_string()))?;
let Some(bounds) = bounds_of(&geom) else {
let why = if svg { solidcraft_io::svg_nothing_to_import(&text) } else { "the DXF has no entities SolidCraft reads".to_string() };
return Err(drawing_error(p, svg, why));
};
Ok((geom, bounds))
Ok((geom, bounds, text))
}
fn sheet_json((lo, hi): (Vec2, Vec2)) -> Value {
json!({"min": [lo.x, lo.y], "max": [hi.x, hi.y], "width": hi.x - lo.x, "height": hi.y - lo.y})
}
/// How big a drawing is in its own file, to size it before it is inserted.
@@ -224,12 +228,23 @@ fn drawing_bounds(_s: &mut Session, p: &Value) -> Result<Value> {
Some(f) => !f.eq_ignore_ascii_case("dxf"),
None => !name_is_dxf,
};
let (_, (lo, hi)) = read_drawing(p, cmd, svg)?;
Ok(json!({"min": [lo.x, lo.y], "max": [hi.x, hi.y], "width": hi.x - lo.x, "height": hi.y - lo.y}))
let (_, bounds, text) = read_drawing(p, cmd, svg)?;
let mut out = sheet_json(bounds);
if svg && let Some(sheet) = solidcraft_io::svg_sheet(&text) {
out["sheet"] = sheet_json(sheet);
}
Ok(out)
}
fn insert(s: &mut Session, p: &Value, cmd: &str, svg: bool) -> Result<Value> {
let (geom, (lo, hi)) = read_drawing(p, cmd, svg)?;
let (geom, (lo, hi), text) = read_drawing(p, cmd, svg)?;
// What `width`, `height` and `align: center` refer to: the drawn geometry, or the SVG sheet.
let (lo, hi) = match str_(p, "size_from") {
None | Some("geometry") => (lo, hi),
Some("viewbox") if !svg => return Err(bad(cmd, "`size_from: viewbox` is for SVG drawings; a DXF has no sheet")),
Some("viewbox") => solidcraft_io::svg_sheet(&text).ok_or_else(|| bad(cmd, "the SVG has no viewBox or width and height to size by"))?,
Some(_) => return Err(bad(cmd, "`size_from` must be \"geometry\" or \"viewbox\"")),
};
// A size in millimetres wins over a factor; the aspect ratio is always kept.
let natural = (hi.x - lo.x, hi.y - lo.y);
let sized = |key: &str, of: fn(&(f64, f64)) -> f64| -> Result<Option<f64>> {
@@ -273,11 +288,26 @@ fn insert(s: &mut Session, p: &Value, cmd: &str, svg: bool) -> Result<Value> {
if geom.len() > solidcraft_sketch::limits::max_points() {
return Err(solidcraft_sketch::SketchError::TooLarge.into());
}
let fixed = bool_(p, "fixed").unwrap_or(false);
// `sketch::edit`, but keeping the solve as a typed value for the reply.
let target = sketch_store::target(s, p, cmd)?;
let ((curves, texts), report) = edit_solved(s, target, false, Analysis::Freedom, |sk, _| {
let ((curves, texts, n_fixed), report) = edit_solved(s, target, false, Analysis::Freedom, |sk, _| {
let first = sk.points.len();
let (cs, texts) = add_geometry(sk, &geom, &f, k, tol)?;
Ok(((ids_of(sk, &cs), texts), Left::Unsolved))
// Fixed where it is put: a Fix constraint on every point the drawing added (a circle's
// or ellipse's radius stays free). Refused as a whole when the constraints would not fit.
let mut n_fixed = 0;
if fixed {
let limit = solidcraft_sketch::limits::max_constraints();
if sk.constraints.len() + (sk.points.len() - first) > limit {
return Err(bad(cmd, format!("fixing the drawing's points needs more than the sketch's {limit} constraints")));
}
for q in first..sk.points.len() {
sk.add_constraint(ConstraintKind::Fix { p: q }, None)?;
n_fixed += 1;
}
}
Ok(((ids_of(sk, &cs), texts, n_fixed), Left::Unsolved))
})?;
let info = report.map(|rep| SketchSolve { solved: rep.keeps_orientation(), dof: rep.dof });
// Text becomes sketch text.
@@ -304,7 +334,18 @@ fn insert(s: &mut Session, p: &Value, cmd: &str, svg: bool) -> Result<Value> {
}
}
}
reply(cmd, DrawingInserted { sketch: id, created_sketch: created, curves: curves.len(), texts: n_text, info, param_warnings: Vec::new() })
reply(
cmd,
DrawingInserted {
sketch: id,
created_sketch: created,
curves: curves.len(),
texts: n_text,
fixed: fixed.then_some(n_fixed),
info,
param_warnings: Vec::new(),
},
)
}
fn insert_dxf(s: &mut Session, p: &Value) -> Result<Value> {
@@ -759,6 +800,68 @@ mod tests {
}
}
/// A 20 × 10 rectangle drawn at (10, 10) on a 100 × 50 sheet.
const ON_SHEET: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="100mm" height="50mm" viewBox="0 0 100 50"><rect x="10" y="10" width="20" height="10"/></svg>"#;
/// `width` and `height` size the drawn geometry unless `size_from: viewbox` makes them the
/// sheet's, so a drawing that does not fill its sheet keeps its place and size on it.
#[test]
fn an_svg_can_be_sized_by_its_sheet() {
let near = |a: (f64, f64, f64, f64), e: (f64, f64, f64, f64)| {
assert!((a.0 - e.0).abs() < 1e-6 && (a.1 - e.1).abs() < 1e-6 && (a.2 - e.2).abs() < 1e-6 && (a.3 - e.3).abs() < 1e-6, "{a:?} vs {e:?}")
};
let inserted = |params: serde_json::Value| {
let mut s = Session::default();
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
let mut p = json!({"text": ON_SHEET});
p.as_object_mut().unwrap().extend(params.as_object().unwrap().clone());
s.execute("sketch.insert_svg", &p).unwrap();
span(&s)
};
// By the geometry (as before): the rectangle itself becomes 200 wide, placed at the origin.
near(inserted(json!({"width": 200})), (100.0, -200.0, 300.0, -100.0));
// By the sheet: the sheet becomes 200 wide, so the rectangle is 40 × 20 at (20, −40).
near(inserted(json!({"width": 200, "size_from": "viewbox"})), (20.0, -40.0, 60.0, -20.0));
near(inserted(json!({"size_from": "viewbox"})), (10.0, -20.0, 30.0, -10.0));
// Centred on `at` by the sheet's middle (50, −25): the rectangle sits above and left of it.
near(inserted(json!({"size_from": "viewbox", "align": "center", "at": [50, 50]})), (10.0, 55.0, 30.0, 65.0));
let mut s = Session::default();
let b = s.execute("sketch.drawing_bounds", &json!({"text": ON_SHEET})).unwrap();
assert_eq!((b["width"].as_f64(), b["sheet"]["width"].as_f64(), b["sheet"]["height"].as_f64()), (Some(20.0), Some(100.0), Some(50.0)), "{b}");
let b = s.execute("sketch.drawing_bounds", &json!({"text": r#"<svg><rect width="20" height="10"/></svg>"#})).unwrap();
assert!(b.get("sheet").is_none(), "no viewBox, no width: {b}");
// No sheet to size by, a DXF, and a wrong word are refused.
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
let bare = json!({"text": r#"<svg><rect width="20" height="10"/></svg>"#, "size_from": "viewbox", "width": 50});
assert!(s.execute("sketch.insert_svg", &bare).unwrap_err().to_string().contains("viewBox"));
let dxf = json!({"text": "0\nSECTION\n2\nENTITIES\n0\nLINE\n8\n0\n10\n0\n20\n0\n11\n10\n21\n0\n0\nENDSEC\n0\nEOF\n", "size_from": "viewbox"});
assert!(s.execute("sketch.insert_dxf", &dxf).unwrap_err().to_string().contains("DXF"));
let word = json!({"text": ON_SHEET, "size_from": "sheet"});
assert!(s.execute("sketch.insert_svg", &word).unwrap_err().to_string().contains("size_from"));
}
/// `fixed: true` fixes every point the drawing adds, so the sketch has nothing left to move.
#[test]
fn an_inserted_drawing_can_come_in_fixed() {
let mut s = Session::default();
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
let r = s.execute("sketch.insert_svg", &json!({"text": PLATE, "fixed": true})).unwrap();
assert_eq!((r["curves"].as_u64(), r["fixed"].as_u64()), (Some(4), Some(4)), "{r}");
assert_eq!(r["created"]["constraints"].as_array().map(Vec::len), Some(4), "{r}");
let solved = s.execute("sketch.solve", &json!({})).unwrap();
assert_eq!(solved["dof"], json!(0), "{solved}");
// Without it, as before: nothing fixed, four free points.
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
let r = s.execute("sketch.insert_svg", &json!({"text": PLATE})).unwrap();
assert!(r.get("fixed").is_none() && r["created"]["constraints"].as_array().is_some_and(Vec::is_empty), "{r}");
// A circle: its centre is fixed, its radius stays free.
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
let circle = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><circle cx="10" cy="10" r="5"/></svg>"#;
let r = s.execute("sketch.insert_svg", &json!({"text": circle, "fixed": true})).unwrap();
assert_eq!(r["fixed"], json!(1), "{r}");
assert_eq!(s.execute("sketch.solve", &json!({})).unwrap()["dof"], json!(1), "the radius");
}
#[test]
fn export_dxf_default_is_byte_identical_to_before() {
let mut s = Session::default();
+60 -4
View File
@@ -128,7 +128,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SKETCH", "MODIFY")
.icon("sketch_scale")
.enabled(in_sketch)
.params("entities: [curve/point ids], base: [x,y] or point ref, factor: number"),
.params("entities: [curve/point ids], base: [x,y] or point ref, factor: number; the driving length and radius dimensions wholly inside the selection scale too (a formula becomes `(formula) * factor`)"),
CommandSpec::new("sketch.transform", "Transform", transform)
.at("SKETCH", "MODIFY")
.icon("sketch_move")
@@ -141,7 +141,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.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!())),
.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; returns `released`: the constraints tying the moved geometry to what stays that the move let go, as {id, kind}", created_doc!())),
CommandSpec::new("sketch.mirror", "Mirror", crate::checked!("sketch.mirror", mirror))
.contract(&MIRROR_CONTRACT)
.output(output_schema_for::<MirrorResult>)
@@ -3792,6 +3792,7 @@ fn move_copy(s: &mut Session, p: &Value) -> Result<Value> {
let rotate_constraints = bool_(p, "rotate_constraints").unwrap_or(true);
let selection = string_list(cmd, p, "entities")?;
let (planar, drawn) = if copy { drawn_copy_selection(s, p, selection)? } else { (selection, Vec::new()) };
let mut released = Vec::new();
let (out, info) = edit(s, p, cmd, false, |sk, doc| {
let (mut cs, mut ps) =
if planar.is_empty() && !drawn.is_empty() { (Vec::new(), Vec::new()) } else { entities(sk, &json!({"entities": planar}), cmd)? };
@@ -3823,16 +3824,18 @@ fn move_copy(s: &mut Session, p: &Value) -> Result<Value> {
return Ok(ids);
}
with_ellipse_axes(sk, &mut cs);
let held = holding(sk, &involved_points(sk, &cs, &ps));
transform_in_place(sk, &cs, &ps, &f, 1.0, cmd)?;
if rotate_constraints {
let moved = involved_points(sk, &cs, &ps);
rotate_axis_constraints(sk, doc, &moved, ang)?;
}
released = release_broken(sk, &held);
cs.extend(text_curves);
Ok(ids_of(sk, &cs))
})?;
s.doc_mut().prune_model_params();
reply(cmd, MoveResult { curves: out, sketch: info, param_warnings: Vec::new() })
reply(cmd, MoveResult { curves: out, released, sketch: info, param_warnings: Vec::new() })
}
/// Scale, turn and move the entities in one step: scaled about `center`, then turned about it,
@@ -3919,17 +3922,70 @@ fn scale_dimensions(sk: &mut Sketch, doc: &mut solidcraft_doc::Document, curves:
Ok(())
}
/// The constraints that tie the points about to move to points that stay (a corner on the
/// origin, a distance to a line left behind) and hold now: their ids and type names. Constraints
/// among the moved points alone (a rectangle's sides, a line's typed angle) move with them and
/// are kept.
fn holding(sk: &Sketch, moved: &[usize]) -> Vec<(String, &'static str)> {
sk.constraints
.iter()
.enumerate()
.filter(|(_, c)| {
let touched = sk.constraint_points(&c.kind);
touched.iter().any(|q| moved.contains(q)) && touched.iter().any(|q| !moved.contains(q))
})
.filter(|(ci, c)| solidcraft_sketch::constraint_residual(sk, *ci).is_some_and(|r| r <= broken_at(sk, &c.kind)))
.map(|(_, c)| (c.id.clone(), c.kind.name()))
.collect()
}
/// After a move: the constraints of `held` that no longer hold are let go. Kept, the solve
/// would pull the moved geometry back to what stays and bend it (#2980). Returns every
/// constraint of `held` the move let go, these and the origin holds the move itself dropped
/// (#5034), as `{id, kind}` for the status line to name them.
fn release_broken(sk: &mut Sketch, held: &[(String, &'static str)]) -> Vec<Value> {
let broken: Vec<String> = sk
.constraints
.iter()
.enumerate()
.filter(|(_, c)| held.iter().any(|(id, _)| *id == c.id))
.filter(|(ci, c)| solidcraft_sketch::constraint_residual(sk, *ci).is_some_and(|r| r > broken_at(sk, &c.kind)))
.map(|(_, c)| c.id.clone())
.collect();
sk.constraints.retain(|c| !broken.contains(&c.id));
held.iter().filter(|(id, _)| !sk.constraints.iter().any(|c| c.id == *id)).map(|(id, kind)| json!({"id": id, "kind": kind})).collect()
}
/// A residual above this is a constraint that doesn't hold. A distance is judged against the size
/// of the geometry the constraint itself touches, so a far-away point elsewhere in the sketch can't
/// hide a broken one; the sine or cosine between two lines has no length to scale by.
fn broken_at(sk: &Sketch, kind: &ConstraintKind) -> f64 {
let direction_only = match *kind {
ConstraintKind::Parallel { .. } | ConstraintKind::Perpendicular { .. } => true,
ConstraintKind::Horizontal { l } | ConstraintKind::Vertical { l } => sk.curves.get(l).is_some_and(|c| c.infinite),
_ => false,
};
if direction_only {
return 1e-7;
}
1e-7 * sk.constraint_points(kind).iter().filter_map(|&q| sk.points.get(q)).map(|p| p.pos.len()).fold(1.0, f64::max)
}
fn scale(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.scale";
let k = num(p, "factor").filter(|k| *k > 1e-9 && *k < 1e6).ok_or_else(|| bad(cmd, "`factor` must be a positive number"))?;
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let (out, info) = edit(s, p, cmd, false, |sk, doc| {
let (mut cs, ps) = entities(sk, p, cmd)?;
with_ellipse_axes(sk, &mut cs);
let b = if p.get("base").is_some() { point_or_ref(sk, p, "base", cmd)? } else { Vec2::ZERO };
let f = move |q: Vec2| b + (q - b) * k;
transform_in_place(sk, &cs, &ps, &f, k, cmd)?;
// The dimensions go with the geometry, else the solve pulls it back to the old sizes (#14411).
let moved = involved_points(sk, &cs, &ps);
scale_dimensions(sk, doc, &cs, &moved, k)?;
Ok(ids_of(sk, &cs))
})?;
s.doc_mut().prune_model_params();
reply(cmd, ScaleResult { curves: out, sketch: info, param_warnings: Vec::new() })
}
@@ -1621,6 +1621,33 @@ fn transform_scales_dimension_formulas_once_and_keeps_unselected_dimensions() {
assert!(close(s.doc.eval(height["param"].as_str().unwrap(), length).unwrap(), 60.0), "dependent dimensions scale only once");
}
/// #14411: Scale takes the driving dimensions of what it scales with it, as Transform does: a
/// 40 × 20 rectangle filleted r5, scaled 2× about the origin, is 80 × 40 with r10 corners, not a
/// skewed shape the solver bent to keep the old radius.
#[test]
fn scale_scales_the_dimensions_it_takes_with_it() {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[0, 0], [40, 0], [40, 20], [0, 20]], "closed": true}));
run(&mut s, "sketch.fillet", json!({"points": ["l1.end", "l2.end", "l3.end", "l4.end"], "radius": 5}));
let curves = ["l1", "l2", "l3", "l4", "a1", "a2", "a3", "a4"];
let radius = sketch(&s).constraints.iter().find_map(|c| c.param.clone()).unwrap();
let out = run(&mut s, "sketch.scale", json!({"entities": curves, "base": [0, 0], "factor": 2}));
assert_eq!(out["sketch"]["solved"], true, "{out}");
assert!(close(s.doc.eval(&radius, solidcraft_doc::expr::Kind::Length).unwrap(), 10.0), "{:?}", s.doc.param(&radius));
let sk = sketch(&s);
for (id, a, b) in [("l1", [10.0, 0.0], [70.0, 0.0]), ("l2", [80.0, 10.0], [80.0, 30.0])] {
let (p, q) = (
sk.point(sk.resolve_point(&format!("{id}.start")).unwrap()).unwrap(),
sk.point(sk.resolve_point(&format!("{id}.end")).unwrap()).unwrap(),
);
assert!(p.dist(Vec2::new(a[0], a[1])) < 1e-6 && q.dist(Vec2::new(b[0], b[1])) < 1e-6, "{id}: {p:?} {q:?}");
}
let area = 80.0 * 40.0 - (4.0 - std::f64::consts::PI) * 100.0;
assert!((profiles(&s)[0] - area).abs() < 1e-6, "{:?}", profiles(&s));
run(&mut s, "edit.undo", json!({}));
assert!(close(s.doc.eval(&radius, solidcraft_doc::expr::Kind::Length).unwrap(), 5.0), "one undo restores the radius");
}
/// #1485: after a circle is trimmed between two construction lines, clicking the trimmed end
/// picks the construction line's end lying there, not the arc's own; the tangent arc still starts
/// from the trimmed arc, tangent to it.
@@ -5740,6 +5767,47 @@ fn split_failures_name_the_public_command() {
assert!(!text.contains("sketch.modify"), "{text}");
}
/// #2980: a rectangle drawn from the origin and a circle, all moved: they move as drawn. The
/// corner's coincidence with the origin is let go instead of pulling that corner back and
/// bending the rectangle; its own horizontals and verticals stay.
#[test]
fn moving_shapes_held_to_the_origin_moves_them_whole() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, -30]}));
run(&mut s, "sketch.circle.center", json!({"center": [-5, 20], "radius": 12}));
let sk = sketch(&s);
let at_origin = sk.constraints.iter().find(|c| matches!(c.kind, ConstraintKind::Coincident { .. })).map(|c| c.id.clone());
assert!(at_origin.is_some(), "the rectangle's corner is held on the origin");
let before: Vec<(String, Vec2)> = sk.points.iter().filter(|p| p.id != "origin").map(|p| (p.id.clone(), p.pos)).collect();
let ids: Vec<String> = sk.curves.iter().map(|c| c.id.clone()).collect();
let r = run(&mut s, "sketch.move", json!({"entities": ids, "translate": [15.5, 0]}));
assert_eq!(r["released"], json!([{"id": at_origin.clone().unwrap(), "kind": "Coincident"}]), "{r}");
let after = sketch(&s);
for (id, p) in &before {
let q = after.points.iter().find(|q| &q.id == id).map(|q| q.pos).unwrap();
assert!(q.dist(*p + Vec2::new(15.5, 0.0)) < 1e-9, "{id} went from {p:?} to {q:?}");
}
assert_eq!(after.constraints.len(), sk.constraints.len() - 1, "only the origin's coincidence is let go");
run(&mut s, "edit.undo", json!({}));
assert_eq!(sketch(&s).constraints.len(), sk.constraints.len(), "undo brings it back");
}
/// A point far from the move says nothing about how exactly the moved line has to stay level with
/// the line that stays: a short move still breaks that relation, so it is still let go.
#[test]
fn a_far_away_point_does_not_keep_a_constraint_the_move_broke() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
let stays = ids(&run(&mut s, "sketch.line", json!({"points": [[5, 5], [15, 5]], "connect": false}))["curves"])[0].clone();
let moved = ids(&run(&mut s, "sketch.line", json!({"points": [[30, 5], [30, 20]], "connect": false}))["curves"])[0].clone();
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"points": [format!("{moved}.start"), format!("{stays}.end")], "mode": "horizontal"}));
let level = sketch(&s).constraints.iter().find(|c| matches!(c.kind, ConstraintKind::HorizontalPoints { .. })).map(|c| c.id.clone()).unwrap();
run(&mut s, "sketch.point", json!({"point": [1e8, 0], "id": "far"}));
let r = run(&mut s, "sketch.move", json!({"entities": [moved], "translate": [0, 1]}));
assert_eq!(r["released"], json!([{"id": level, "kind": "Horizontal"}]), "{r}");
}
/// #12294: a spline offset together with a line is trimmed and unlinked, so a distance that names a
/// parameter drives nothing. The reply says so; a linked offset of the same parameter does not.
#[test]
@@ -226,6 +226,8 @@ fn text_style_parameters_are_checked() {
(json!({"link":link,"bold":true}), "no bold face"),
(json!({"link":link,"bold":"yes"}), "`bold` must be true or false"),
(json!({"link":link,"align":"justify"}), "`align` must be left, center or right"),
(json!({"link":link,"vertical_align":"above"}), "`vertical_align` must be baseline, top, middle or bottom"),
(json!({"link":link,"vertical_align":1}), "`vertical_align` must be baseline, top, middle or bottom"),
(json!({"link":link,"line_spacing":0}), "`line_spacing` must be a positive factor"),
(json!({"link":link,"letter_spacing":"wide"}), "`letter_spacing` must be a length"),
(json!({"link":link,"path":"no-such-curve"}), "not a curve of this sketch"),
@@ -235,14 +237,19 @@ fn text_style_parameters_are_checked() {
assert!(e.contains(error), "{params}: {e}");
assert_eq!(s.doc.to_json(), before);
}
run(&mut s, "sketch.edit_text", json!({"link":link,"italic":true,"align":"right","letter_spacing":1.5,"line_spacing":1.2}));
run(
&mut s,
"sketch.edit_text",
json!({"link":link,"italic":true,"align":"right","vertical_align":"middle","letter_spacing":1.5,"line_spacing":1.2}),
);
let LinkSource::Text { style, text, .. } = &s.doc.sketch(1).unwrap().link(&link).unwrap().source else { panic!() };
assert_eq!(text, "II");
assert!(style.italic && style.align == solidcraft_sketch::TextAlign::Right && style.letter_spacing == 1.5 && style.line_spacing == 1.2);
assert_eq!(style.vertical_align, solidcraft_sketch::TextVerticalAlign::Middle);
// Left out, a style stays.
run(&mut s, "sketch.edit_text", json!({"link":link,"text":"I"}));
let LinkSource::Text { style, .. } = &s.doc.sketch(1).unwrap().link(&link).unwrap().source else { panic!() };
assert!(style.italic && style.letter_spacing == 1.5);
assert!(style.italic && style.letter_spacing == 1.5 && style.vertical_align == solidcraft_sketch::TextVerticalAlign::Middle);
}
/// `base` with the keys of `extra` set (a null `extra` value is sent as null).
+3 -1
View File
@@ -2504,7 +2504,9 @@ fn editing_a_mid_timeline_sketch_rebuilds_later_features_once_at_finish() {
run(&mut s, "sketch.edit", json!({"sketch": id, "restore_marker": false}));
assert_eq!(s.doc.marker, None);
run(&mut s, "sketch.line", json!({"points": [[0, 20], [10, 20]]}));
assert!(s.model.last_recomputed > 1, "in place, the later features are rebuilt");
// Rebuilt, or made again from what they changed when what they read is as it was (#6183).
let evaluated = s.model.last_recomputed + s.model.last_restored;
assert!(evaluated > 1, "in place, the later features are evaluated: {evaluated}");
run(&mut s, "sketch.finish", json!({}));
assert_eq!(s.doc.marker, None);
assert!(s.execute("sketch.edit", &json!({"sketch": id, "restore_marker": true})).is_err());
+6 -5
View File
@@ -83,7 +83,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("surface_sweep")
.contract(&SURFACE_SWEEP_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params("sketch?: id|name; curves?: [curve ids] and/or profiles? (as surface.extrude); path_sketch: sketch; path: [curve ids in order] (a path with corners, or 3D curves, gives ruled walls mitred at the corners); orientation?, twist?, scale?, rails?, rail_fit? (as solid.sweep); name?, body_name? (a surface body, no caps)"),
.params("sketch?: id|name; curves?: [curve ids] and/or profiles? (as surface.extrude); path_sketch: sketch; path: [curve ids in order] (a path with corners, or 3D curves, gives ruled walls mitred at the corners); path_body?: body name; path_edges?: [points on its edges] (the path is a chain of that body's edges, instead of a sketch's curves); orientation?, twist?, scale?, rails?, rail_fit? (as solid.sweep); name?, body_name? (a surface body, no caps)"),
CommandSpec::new("surface.loft", "Surface Loft", crate::checked!("surface.loft", surface_loft))
.at("SURFACE", "CREATE")
.icon("surface_loft")
@@ -424,13 +424,12 @@ fn surface_sweep(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "surface.sweep";
let sketch = feature_sketch(s, p, cmd)?;
let (profiles, curves) = sheet_inputs(p, cmd)?;
let path_sketch = sketch_id(s, p.get("path_sketch"), cmd, "path_sketch")?;
let path = string_list(cmd, p, "path")?;
if path.is_empty() || path.len() > 1000 {
let (path_sketch, path, path_body, path_edges) = super::features::sweep_path(s, p, cmd)?;
if path_body.is_none() && (path.is_empty() || path.len() > 1000) {
return Err(bad(cmd, "`path` must list 1…1000 curve ids"));
}
let (orientation, twist, scale) = super::features::sweep_options(s, p, cmd)?;
let (rails, rail_fit) = super::features::sweep_rails(s, p, cmd, path_sketch)?;
let (rails, rail_fit) = super::features::sweep_rails(s, p, cmd, path_body.is_none().then_some(path_sketch))?;
add_feature(
s,
p,
@@ -440,6 +439,8 @@ fn surface_sweep(s: &mut Session, p: &Value) -> Result<Value> {
curves,
path_sketch: SketchId(path_sketch),
path,
path_body,
path_edges,
orientation,
twist,
scale,

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