Compare commits

..

66 Commits

Author SHA1 Message Date
Brennen Herbruck 54b4b8324c Format pending test: cleanup retries and never changes the check's result, and the temp repo runs no gc
The 'Design format pending changes' step passed its check and then failed on cleanup ('rm: cannot remove .git: Directory not empty'), failing a queue batch: a background git process was still writing into .git. The temp repo now runs with gc.auto, maintenance, auto-detach and fsmonitor off, and the cleanup trap retries the removal for two seconds and keeps the script's own exit status whatever the removal does.

Changes a default: no

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 05:40:07 -05: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
436 changed files with 21755 additions and 5001 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
+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();
@@ -1,26 +1,34 @@
{
"en": {
"cmd.assembly_constraint_create": "Constrain"
"cmd.assembly_constraint_create": "Constrain",
"cmd.assembly_constraint_edit": "Edit Constraint"
},
"de": {
"cmd.assembly_constraint_create": "Abhängigkeit"
"cmd.assembly_constraint_create": "Abhängigkeit",
"cmd.assembly_constraint_edit": "Abhängigkeit bearbeiten"
},
"ja": {
"cmd.assembly_constraint_create": "拘束"
"cmd.assembly_constraint_create": "拘束",
"cmd.assembly_constraint_edit": "拘束を編集"
},
"zh": {
"cmd.assembly_constraint_create": "约束"
"cmd.assembly_constraint_create": "约束",
"cmd.assembly_constraint_edit": "编辑约束"
},
"ko": {
"cmd.assembly_constraint_create": "구속"
"cmd.assembly_constraint_create": "구속",
"cmd.assembly_constraint_edit": "구속 편집"
},
"vi": {
"cmd.assembly_constraint_create": "Ràng buộc"
"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_create": "تقييد",
"cmd.assembly_constraint_edit": "تحرير التقييد"
},
"he": {
"cmd.assembly_constraint_create": "אילוץ"
"cmd.assembly_constraint_create": "אילוץ",
"cmd.assembly_constraint_edit": "עריכת אילוץ"
}
}
@@ -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": "拖动由球关节固定的零件时,它绕关节以最小的转动旋转,就像滚动一个与零件同样大小的球。关闭:抓取的点精确跟随指针,零件还可能绕该点自旋。"
}
}
@@ -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})。"
}
}
+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": "约束冲突",
+3
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)
}
+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() });
+21 -4
View File
@@ -1087,13 +1087,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 +1821,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,
@@ -3649,6 +3661,7 @@ impl Document {
.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()))?;
@@ -3904,10 +3917,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));
}
+1005 -139
View File
File diff suppressed because it is too large Load Diff
+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,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.
+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();
}
+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();
+124 -34
View File
@@ -1,22 +1,26 @@
//! Assembly constraints (`assembly.constraint.create`): a geometric rule between a reference on
//! each of two parts (a mate, a flush, concentric holes…), in the words of the Constrain dialog.
//! 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::Value;
use solidcraft_doc::joints::{Constraint, JointKind, JointOrigin, Snap};
use serde_json::{Map, Value, json};
use solidcraft_doc::joints::{Constraint, Joint, JointKind, JointOrigin, Snap};
use super::{CommandSpec, add_joint, new_joint, origin_param, value};
use super::{CommandSpec, add_joint, joint_id, new_joint, origin_param, value};
use crate::params::{bad, bool_, str_};
use crate::schema::joints::{CreateConstraintParams, JointAddedResult};
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();
pub static COMMANDS: &[CommandSpec] =
&[CommandSpec::new("assembly.constraint.create", "Constrain", crate::checked!("assembly.constraint.create", create))
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>)
@@ -27,7 +31,19 @@ pub static COMMANDS: &[CommandSpec] =
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)]
@@ -105,6 +121,102 @@ fn needs(c: Constraint) -> &'static str {
}
}
/// 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('_', " ");
@@ -119,9 +231,7 @@ fn default_name(s: &Session, c: Constraint) -> String {
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` must be mate, flush, distance, concentric, insert, parallel, perpendicular, angle, tangent or point_on_axis")
})?;
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.
@@ -138,28 +248,8 @@ fn create(s: &mut Session, p: &Value) -> Result<Value> {
if c == Constraint::Angle && p.get("angle").is_none() {
return Err(bad(cmd, "angle needs `angle`"));
}
let flip = {
let st = s.model.state();
// The way b faces a now: along (true) or against. A rule that fixes neither keeps it, so b is
// not turned over.
let along = || -> Result<bool> {
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)
};
let mut flip = match rule.flip {
Some(f) => f,
None => along()?,
};
if bool_(p, "flip") == Some(true) {
flip = !flip;
}
// A cylinder resting on a plane needs its round face's radius (refused when it has none).
if c == Constraint::Tangent {
s.doc.origin_radius(&st, &b).map_err(|e| bad(cmd, e.to_string()))?;
}
flip
};
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() {
+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)
+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")
}
+39 -11
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")
@@ -3635,13 +3635,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 +3648,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 +3661,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 +3742,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}");
}
+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()
+31 -4
View File
@@ -1654,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 {
@@ -1691,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()
@@ -1715,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(),
@@ -2050,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") {
@@ -2089,7 +2116,7 @@ fn edit(s: &mut Session, p: &Value) -> Result<Value> {
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 {
if k != was && plain {
if j.constraint == Some(joints::Constraint::Tangent) {
j.exprs.offset = None;
}
@@ -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");
}
@@ -644,3 +644,166 @@ fn a_tangent_constraint_follows_its_faces_radius() {
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)));
}
+2 -1
View File
@@ -41,7 +41,8 @@ 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};
+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());
+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.
+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]
+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]
+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,
+61 -1
View File
@@ -3,7 +3,7 @@
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::f64::consts::PI;
use std::f64::consts::{FRAC_1_SQRT_2, PI};
use serde_json::{Value, json};
@@ -193,6 +193,66 @@ fn a_sweep_round_a_closed_path_starts_where_the_profile_is() {
}
}
/// #2216: a sweep whose path is a closed loop of a body's edges is followed from the profile,
/// like a closed sketch path: the tube stays on the loop, within the profile's half width of
/// it, instead of standing off it by the distance between the profile and the vertex the
/// first picked edge happened to be on. A body's top face is a closed loop of eight edges; a
/// 4 x 3 profile at the middle of one of them, square to it, swept round the whole loop.
#[test]
fn a_sweep_round_a_closed_body_edge_path_starts_where_the_profile_is() {
let mut s = Session::default();
// A rounded rectangle 60 x 40, corners r8, extruded 10: its top face is a closed loop of
// eight edges, smooth all round.
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Loop"}));
run(&mut s, "sketch.line", json!({"points": [[-22, -20], [22, -20]], "infer": false, "ids": ["l1"]}));
run(&mut s, "sketch.arc.center_point", json!({"center": [22, -12], "start": [22, -20], "end": [30, -12], "id": "a1"}));
run(&mut s, "sketch.line", json!({"points": [[30, -12], [30, 12]], "infer": false, "ids": ["l2"]}));
run(&mut s, "sketch.arc.center_point", json!({"center": [22, 12], "start": [30, 12], "end": [22, 20], "id": "a2"}));
run(&mut s, "sketch.line", json!({"points": [[22, 20], [-22, 20]], "infer": false, "ids": ["l3"]}));
run(&mut s, "sketch.arc.center_point", json!({"center": [-22, 12], "start": [-22, 20], "end": [-30, 12], "id": "a3"}));
run(&mut s, "sketch.line", json!({"points": [[-30, 12], [-30, -12]], "infer": false, "ids": ["l4"]}));
run(&mut s, "sketch.arc.center_point", json!({"center": [-22, -12], "start": [-30, -12], "end": [-22, -20], "id": "a4"}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "Loop", "distance": 10, "operation": "new", "body_name": "Slab"}));
// A 4 x 3 profile at the middle of the loop's bottom edge, square to it.
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, -20, 10], "x_dir": [0, 1, 0], "y_dir": [0, 0, 1]}, "name": "Prof"}));
run(&mut s, "sketch.rectangle.center", json!({"center": [0, 0], "corner": [2, 1.5]}));
run(&mut s, "sketch.finish", json!({}));
// The path: the loop's eight edges, one point on each, in order round it.
let c = 8.0 * FRAC_1_SQRT_2;
run(
&mut s,
"solid.sweep",
json!({
"sketch": "Prof",
"path_body": "Slab",
"path_edges": [
[-11.0, -20.0, 10.0],
[22.0 + c, -12.0 - c, 10.0],
[30.0, 0.0, 10.0],
[22.0 + c, 12.0 + c, 10.0],
[-11.0, 20.0, 10.0],
[-22.0 - c, 12.0 + c, 10.0],
[-30.0, 0.0, 10.0],
[-22.0 - c, -12.0 - c, 10.0]
],
"operation": "new",
"body_name": "Tube"
}),
);
let m = measure(&s, "Tube");
// Pappus: the profile's area (4 x 3) times the loop's length.
let want = 12.0 * (2.0 * 44.0 + 2.0 * 24.0 + 4.0 * (PI / 2.0) * 8.0);
assert!((m.volume - want).abs() < want * 1e-3, "{} vs {want}", m.volume);
// The tube stays on the loop: within the profile's half width (2) of it, the loop's own
// bounds (-30..30 by -20..20) grown by it.
for (got, want) in
[(m.bbox.min.x, -32.0), (m.bbox.max.x, 32.0), (m.bbox.min.y, -22.0), (m.bbox.max.y, 22.0), (m.bbox.min.z, 8.5), (m.bbox.max.z, 11.5)]
{
assert!((got - want).abs() < 0.08, "{:?}", m.bbox);
}
}
/// #3604: a path whose first curve is a spline of several exact pieces, the next curve meeting
/// it at its start, follows the spline back from its end: a curve is turned round whole, not
/// piece by piece. A 2 x 2 profile at the spline's end (20, 0), swept along the spline through
+21
View File
@@ -1077,3 +1077,24 @@ fn plane_axis_and_point_refuse_references_no_method_takes() {
assert_eq!(*s.doc, before, "{id} {p}: a refusal changes nothing");
}
}
/// #12022: a plane made of picks, made in a moved component, goes through its picks in the
/// world, whichever constructor makes it (Plane Through Axis and Point left its point unmapped).
#[test]
fn planes_made_of_picks_in_a_moved_component_go_through_their_world_picks() {
let cases = [
("construct.plane.axis_point", json!({"axis":{"axis":"Mast"},"point":{"point":[60,5,7]},"name":"F"})),
("construct.plane.three_points", json!({"a":{"point":[50,0,0]},"b":{"point":[50,0,10]},"c":{"point":[60,5,7]},"name":"F"})),
];
for (cmd, params) in cases {
let mut s = Session::default();
run(&mut s, "component.create", json!({"name":"Part","grounded":false}));
run(&mut s, "occurrence.move", json!({"occurrence":"Part:1","translate":[50,0,3]}));
run(&mut s, "construct.axis.two_points", json!({"a":{"point":[50,0,0]},"b":{"point":[50,0,10]},"name":"Mast"}));
run(&mut s, cmd, params);
let pl = plane(&s, "F");
for p in [Vec3::new(50.0, 0.0, 0.0), Vec3::new(50.0, 0.0, 10.0), Vec3::new(60.0, 5.0, 7.0)] {
assert!(on_plane(&pl, p), "{cmd}: {p:?} on {pl:?}");
}
}
}
+16 -10
View File
@@ -52,6 +52,11 @@ use K::*;
/// What `timeline.redefine` maps for a surface revolve, which no command is called.
const SURFACE_REVOLVE_EDIT: &str = "surface.revolve (edit)";
/// The construction planes made of picks alone (`cmd::picked_plane`): their references are
/// parsed and mapped as a whole, saved forms included, rather than key by key (#12022).
const PICKED_PLANES: [&str; 4] =
["construct.plane.axis_point", "construct.plane.two_edges", "construct.plane.three_points", "construct.plane.perpendicular"];
/// The parameters each command takes in world coordinates.
fn table(id: &str) -> &'static [(&'static str, K)] {
match id {
@@ -474,7 +479,7 @@ impl Nested {
});
if outer {
let inner = if id == "timeline.redefine" { None } else { Some(id) };
MAPPED.with(|m| m.set(inner.is_none_or(|i| !table(i).is_empty() || i == "construct.plane.axis_point")));
MAPPED.with(|m| m.set(inner.is_none_or(|i| !table(i).is_empty())));
OUTER_MOVE.with(|m| m.set(id == "solid.move"));
}
(Nested, outer)
@@ -660,18 +665,19 @@ fn map_local(s: &Session, world: &solidcraft_doc::ModelState, id: &str, params:
{
return None;
}
if id == "construct.plane.axis_point" && placed(s) {
// Construction references include tagged saved forms as well as pick wrappers.
// Map the parsed definition once at the boundary, including a nested Edit Feature.
if PICKED_PLANES.contains(&id) && placed(s) {
// An Edit Feature (`timeline.redefine`) of such a plane: its references include tagged
// saved forms as well as pick wrappers, so the parsed definition is mapped once here.
// The constructor can then name local model picks and retain their exact references.
let mut kind = crate::cmd::plane_axis_point_kind(s, params).ok()?;
// (Run directly, the plane goes into the active frame as a whole: `to_active_frame`.)
let plane = crate::cmd::picked_plane(s, id, params).ok()??;
let mut kind = solidcraft_doc::FeatureKind::ConstructionPlane { plane };
kind.transform_geometry(&mat_inverse(&s.active_component_frame())?);
let solidcraft_doc::FeatureKind::ConstructionPlane { plane: solidcraft_doc::PlaneRef::AxisPoint { axis, point } } = kind else {
return None;
};
let solidcraft_doc::FeatureKind::ConstructionPlane { plane } = &kind else { return None };
let mut out = params.clone();
out["axis"] = serde_json::to_value(axis).ok()?;
out["point"] = serde_json::to_value(point).ok()?;
for (key, reference) in crate::cmd::picked_plane_refs(plane)?.1 {
out[key] = serde_json::to_value(reference).ok()?;
}
return Some(out);
}
let keys = table(id);
+60 -1
View File
@@ -52,6 +52,33 @@ pub fn extract(s: &Session) -> Result<ProductScene> {
Ok(scene)
}
/// For each of `scene`'s meshes, the shared mesh it is drawn from and the placement that moves
/// it there (column-major, as the document's placements): a part placed by several occurrences
/// is the same definition mesh moved, and any other body is its own mesh, unmoved. A renderer
/// that draws one copy of the triangles at many placements can use it; the scene's own
/// world-space meshes stay authoritative. `None` for a mesh that is not, or no longer, the
/// body's mesh.
pub fn placements(s: &Session, scene: &ProductScene) -> Vec<Option<(std::sync::Arc<crate::geom::Mesh>, crate::doc::Mat)>> {
// A model that cannot be placed has no bodies whose meshes the scene could share.
let Ok(world) = s.world_state() else {
return scene.meshes.iter().map(|_| None).collect();
};
let bodies: std::collections::HashMap<&str, &crate::doc::ModelBody> = world.bodies.iter().map(|b| (b.name.as_str(), b)).collect();
scene
.meshes
.iter()
.map(|m| {
let body = bodies.get(m.name.as_str())?;
// Only the mesh this body has (made once and kept): a scene from another state
// names other meshes.
if !body.cached_mesh().is_some_and(|mesh| std::sync::Arc::ptr_eq(&mesh, &m.mesh)) {
return None;
}
Some(body.mesh_in_place())
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -59,9 +86,41 @@ mod tests {
use crate::render::*;
use serde_json::json;
use solidcore_record::Vec3;
use solidcraft_geom::Vec3Geom;
use solidcraft_viewport::{Camera, UpAxis};
use std::sync::Arc;
/// A part placed twice is two product meshes moved from one shared mesh; a body of the root
/// is its own mesh, unmoved, and a mesh from another state names nothing.
#[test]
fn placements_name_the_shared_mesh_of_placed_copies() {
let mut s = Session::default();
let part = s.execute("component.create", &json!({"name":"Part","grounded":false})).unwrap();
s.execute("solid.box", &json!({"length":10,"width":10,"height":10,"body_name":"Block"})).unwrap();
s.execute("component.activate", &json!({"component":0})).unwrap();
s.execute("occurrence.copy", &json!({"component":part["component"],"translate":[30,0,0]})).unwrap();
s.execute("occurrence.move", &json!({"occurrence":part["occurrence"],"translate":[10,0,0]})).unwrap();
s.execute("solid.box", &json!({"length":5,"width":5,"height":5,"corner":[0,40,0],"body_name":"Root"})).unwrap();
let scene = extract(&s).unwrap();
let placed = placements(&s, &scene);
assert_eq!(placed.len(), scene.meshes.len());
let copies: Vec<_> = scene.meshes.iter().zip(&placed).filter(|(m, _)| !m.name.starts_with("Root")).collect();
assert_eq!(copies.len(), 2);
let sources: Vec<_> = copies.iter().map(|(_, p)| p.as_ref().map(|(source, _)| source.clone()).expect("a placed copy")).collect();
assert!(Arc::ptr_eq(&sources[0], &sources[1]), "both placements share one mesh");
for (m, p) in &copies {
let (source, transform) = p.as_ref().unwrap();
let moved = crate::doc::apply_point(transform, source.positions[0]);
assert!(moved.dist(m.mesh.positions[0]) < 1e-9, "the shared mesh moved is the product mesh");
}
let root = scene.meshes.iter().position(|m| m.name.starts_with("Root")).unwrap();
let (own, unmoved) = placed[root].as_ref().unwrap();
assert!(Arc::ptr_eq(own, &scene.meshes[root].mesh) && crate::doc::is_identity(unmoved), "a root body is its own mesh, unmoved");
let mut other = scene.clone();
other.meshes[0].mesh = Arc::new((*other.meshes[0].mesh).clone());
assert!(placements(&s, &other)[0].is_none(), "another mesh under the same name");
}
#[test]
fn product_scene_keeps_placed_appearances_and_meshes_without_design_overlays() {
let mut s = Session::default();
@@ -179,7 +238,7 @@ mod tests {
let body = crate::kernel::box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0))
.unwrap()
.with_color(Some([0.2, 0.6, 0.8]))
.with_paint(Some(crate::kernel::Paint { opacity: 0.4, faces: Vec::new() }));
.with_paint(Some(crate::kernel::Paint { opacity: 0.4, faces: Vec::new(), name: None }));
let step = crate::kernel::step_export_bodies(
&[crate::kernel::ExportBody { name: "Imported glass".into(), body: &body, color: body.color() }],
&Default::default(),
+66 -20
View File
@@ -15,7 +15,7 @@ use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
use serde::de::Error as _;
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::{Map, Value};
use solidcraft_doc::config::{MAX_COLUMNS, MAX_RULES};
use solidcraft_doc::config::{MAX_COLUMNS, MAX_RULES, RowKey};
use super::check::whole;
use crate::params::{self, MAX_STRING_LIST, json_brief};
@@ -32,19 +32,46 @@ pub struct ConfigTableParams {}
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct ConfigRowParams {
/// The row, by name.
/// The row, by name or by id.
#[serde(deserialize_with = "row")]
pub row: String,
pub row: RowRef,
}
/// The row's name, with the handler's message for anything that isn't text.
fn row<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> {
match Value::deserialize(d)? {
Value::String(s) => Ok(s),
_ => Err(D::Error::custom(whole("`row` is required"))),
/// A configuration row, as the commands name one: its name (text) or its fixed id (a whole
/// number, as `config.table` lists it). A row named `"17"` is not row 17.
#[derive(Clone, Debug, PartialEq)]
pub enum RowRef {
Name(String),
Id(u64),
}
impl RowRef {
/// As the handlers read it: [`RowKey::read`], the one rule.
fn read(v: &Value) -> Option<Self> {
RowKey::read(v).map(|key| match key {
RowKey::Id(id) => RowRef::Id(id),
RowKey::Name(name) => RowRef::Name(name.to_string()),
})
}
}
impl JsonSchema for RowRef {
fn schema_name() -> Cow<'static, str> {
"RowRef".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "A configuration row: its name, or its id as `config.table` lists it.",
"anyOf": [{"type": "string"}, {"type": "integer", "minimum": 0}]
})
}
}
/// The row, by name or id, with the handler's message for anything else.
fn row<'de, D: Deserializer<'de>>(d: D) -> Result<RowRef, D::Error> {
RowRef::read(&Value::deserialize(d)?).ok_or_else(|| D::Error::custom(whole("`row` is required")))
}
/// Add columns to the configuration table: parameters, feature suppressions, and bodies' and
/// components' materials and looks, each filled in every row with the design's current value. A
/// column the table has is left as it is. With no rows yet, a "Default" row takes the current
@@ -119,15 +146,16 @@ pub struct ConfigColumnParams {
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct ConfigRowEditParams {
/// The row, by name: 1 to 128 bytes once spaces at either end are dropped. A name the table
/// doesn't have adds a row (at most 2000).
/// The row, by name (1 to 128 bytes once spaces at either end are dropped) or by id. A name the
/// table doesn't have adds a row (at most 2000); an id must be one of its rows.
#[serde(deserialize_with = "row_name")]
#[schemars(length(min = 1, max = 128))]
pub name: String,
/// A row to copy the cells of, by name. Default: the row's own cells, or the design's current
/// values for a new row.
#[serde(default)]
pub from: Option<String>,
#[schemars(schema_with = "row_name_schema")]
pub name: RowRef,
/// A row to copy the cells of, by name or id. Default: the row's own cells, or the design's
/// current values for a new row.
#[serde(default, deserialize_with = "from_row", skip_serializing_if = "Option::is_none")]
#[schemars(with = "Option<RowRef>")]
pub from: Option<RowRef>,
#[serde(default, deserialize_with = "row_values", skip_serializing_if = "Option::is_none")]
#[schemars(schema_with = "row_values_schema")]
pub values: Option<Map<String, Value>>,
@@ -349,12 +377,28 @@ fn exactly_one_target(schema: &mut Schema) {
schema.insert("oneOf".into(), Value::Array(one));
}
/// A row's name as `config.row` reads `name`: text that, without spaces at either end, is 1 to
/// A row as `config.row` reads `name`: an id, or text that, without spaces at either end, is 1 to
/// 128 bytes, with the handler's message for anything else.
fn row_name<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> {
fn row_name<'de, D: Deserializer<'de>>(d: D) -> Result<RowRef, D::Error> {
match RowRef::read(&Value::deserialize(d)?) {
Some(RowRef::Name(name)) if !is_row_name(&name) => Err(D::Error::custom(whole("`name` is required"))),
Some(row) => Ok(row),
None => Err(D::Error::custom(whole("`name` is required"))),
}
}
fn row_name_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "A row: its name, or its id as `config.table` lists it.",
"anyOf": [{"type": "string", "minLength": 1, "maxLength": 128}, {"type": "integer", "minimum": 0}]
})
}
/// `from`: a row by name or id; `null` is left out, as the handler reads it.
fn from_row<'de, D: Deserializer<'de>>(d: D) -> Result<Option<RowRef>, D::Error> {
match Value::deserialize(d)? {
Value::String(name) if is_row_name(&name) => Ok(name),
_ => Err(D::Error::custom(whole("`name` is required"))),
Value::Null => Ok(None),
v => RowRef::read(&v).map(Some).ok_or_else(|| D::Error::custom(whole("`from` must be a row's name or id"))),
}
}
@@ -534,6 +578,8 @@ pub struct ConfigTarget {
/// One row of the table.
#[derive(Debug, Serialize, JsonSchema)]
pub struct ConfigTableRow {
/// Its fixed id: a rename keeps it.
pub id: u64,
pub name: String,
/// Its cell for each column, by column key.
pub values: BTreeMap<String, String>,
+5
View File
@@ -43,6 +43,8 @@ fn strict_keeps_todays_errors() {
assert_strict_keeps_todays_errors(&[
("config.activate", json!({})),
("config.activate", json!({"row": 5})),
("config.activate", json!({"row": -1})),
("config.activate", json!({"row": 1.5})),
("config.activate", json!({"row": "Nope"})),
("config.configure", json!({"params": 5})),
("config.configure", json!({"params": ["W", 1]})),
@@ -78,6 +80,9 @@ fn strict_keeps_todays_errors() {
("config.row", json!({"name": "R", "values": {"param:W": "1".repeat(4097)}})),
("config.row", json!({"name": "R", "values": {"param:W": "1"}})),
("config.row", json!({"name": "R", "from": "Nope"})),
("config.row", json!({"name": "R", "from": 99_999})),
("config.row", json!({"name": "R", "from": true})),
("config.row", json!({"name": 99_999, "delete": true})),
("config.row", json!({"name": "R", "rename": null})),
("config.row", json!({"name": "R", "rename": "S"})),
("config.row", json!({"name": "R", "delete": true})),
+78 -8
View File
@@ -453,10 +453,21 @@ pub struct SweepParams {
/// whole drawn profile. The dialogs send `true`: they highlight the regions they pick.
#[serde(default)]
pub exact_regions: Option<bool>,
/// The sketch holding the path, by id or name.
pub path_sketch: SketchRef,
/// The path's curve ids, in order along the path.
pub path: StringList,
/// The sketch holding the path, by id or name. Not needed when the path is a body's edges.
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "SketchRef")]
pub path_sketch: Option<SketchRef>,
/// The path's curve ids, in order along the path. Not needed when the path is a body's edges.
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "StringList")]
pub path: Option<StringList>,
/// The name of the body whose edges are the path, instead of a sketch's curves.
#[serde(default, deserialize_with = "path_body_opt", skip_serializing_if = "Option::is_none")]
pub path_body: Option<String>,
/// The points on that body's edges that are the path, 1 to 1000.
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "PathEdgePoints")]
pub path_edges: Option<PathEdgePoints>,
#[serde(flatten, deserialize_with = "feature_common")]
pub shape: SweepShape,
/// How the sweep combines with existing bodies. Default: new.
@@ -499,10 +510,22 @@ pub struct SurfaceSweepParams {
/// whole drawn profile. The dialogs send `true`: they highlight the regions they pick.
#[serde(default)]
pub exact_regions: Option<bool>,
/// The sketch holding the path, by id or name.
pub path_sketch: SketchRef,
/// The path's curve ids, in order along the path: 1 to 1000.
pub path: StringList,
/// The sketch holding the path, by id or name. Not needed when the path is a body's edges.
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "SketchRef")]
pub path_sketch: Option<SketchRef>,
/// The path's curve ids, in order along the path: 1 to 1000. Not needed when the path is a
/// body's edges.
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "StringList")]
pub path: Option<StringList>,
/// The name of the body whose edges are the path, instead of a sketch's curves.
#[serde(default, deserialize_with = "path_body_opt", skip_serializing_if = "Option::is_none")]
pub path_body: Option<String>,
/// The points on that body's edges that are the path, 1 to 1000.
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "PathEdgePoints")]
pub path_edges: Option<PathEdgePoints>,
#[serde(flatten, deserialize_with = "feature_common")]
pub shape: SweepShape,
#[serde(flatten, deserialize_with = "feature_common")]
@@ -620,6 +643,53 @@ fn target_body<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> {
}
}
/// `path_body`: the name of the body whose edges are the path, as the handler's `sweep_path`
/// reads it: a body name, or `null` (which the handler reads as absent). Anything else is
/// refused with the handler's message.
fn path_body_opt<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
match Value::deserialize(d)? {
Value::Null => Ok(None),
Value::String(name) => Ok(Some(name)),
_ => Err(D::Error::custom(whole("`path_body` must be a body name"))),
}
}
/// `path_edges`: the points on the edges of the path, as the handler's `path_edge_points` reads
/// them: 1 to 1000 points, each `[x, y, z]` or `{x, y, z}` of finite numbers.
#[derive(Clone, Debug, PartialEq)]
pub struct PathEdgePoints(pub Vec<Point3>);
impl<'de> Deserialize<'de> for PathEdgePoints {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let v = Value::deserialize(d)?;
let items = v.as_array().ok_or_else(|| D::Error::custom(whole("`path_edges` must be a list of points")))?;
if items.is_empty() || items.len() > 1000 {
return Err(D::Error::custom(whole("the path needs 1…1000 edges")));
}
items
.iter()
.map(|item| Point3::deserialize(item).map_err(|_| D::Error::custom(whole("each `path_edges` entry must be a point [x,y,z]"))))
.collect::<Result<_, _>>()
.map(PathEdgePoints)
}
}
impl JsonSchema for PathEdgePoints {
fn schema_name() -> std::borrow::Cow<'static, str> {
"PathEdgePoints".into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let item = generator.subschema_for::<Point3>();
json_schema!({
"description": "The points on the body's edges that are the path, one on each edge of the chain: 1 to 1000.",
"type": "array",
"items": item,
"minItems": 1,
"maxItems": 1000
})
}
}
/// The handler's message for no tools.
const NO_TOOLS: &str = "`tools` must list bodies";
+70 -1
View File
@@ -383,6 +383,8 @@ fn sweep_schemas_and_the_key_check_agree() {
"operation",
"participants",
"path",
"path_body",
"path_edges",
"path_sketch",
"profiles",
"sketch",
@@ -392,12 +394,79 @@ fn sweep_schemas_and_the_key_check_agree() {
solid.extend(shape);
solid.sort_unstable();
assert_keys(input_schema_for::<SweepParams>(), &solid);
let mut surface = vec!["body_name", "body_names", "curves", "exact_regions", "name", "participants", "path", "path_sketch", "profiles", "sketch"];
let mut surface = vec![
"body_name",
"body_names",
"curves",
"exact_regions",
"name",
"participants",
"path",
"path_body",
"path_edges",
"path_sketch",
"profiles",
"sketch",
];
surface.extend(shape);
surface.sort_unstable();
assert_keys(input_schema_for::<SurfaceSweepParams>(), &surface);
}
/// #2216: a sweep's path may be a chain of a body's edges (`path_body` and `path_edges`), so
/// the contracts take those keys: a call with them is not refused, and a key that is not one
/// of the command's still is.
#[test]
fn sweep_path_on_a_body_is_accepted_and_unknown_keys_are_not() {
let p = json!({"path_body": "Body1", "path_edges": [[0, 0, 0], [10, 0, 0]]});
let solid = crate::schema::TypedContract::<SweepParams>::new();
let surface = crate::schema::TypedContract::<SurfaceSweepParams>::new();
assert!(crate::schema::Contract::check(&solid, &p).is_empty(), "{p}");
assert!(crate::schema::Contract::check(&surface, &p).is_empty(), "{p}");
// A key that is not one of the command's is still refused.
let typo = json!({"path_body": "Body1", "path_edges": [[0, 0, 0]], "path_boddy": "Body1"});
for issues in [crate::schema::Contract::check(&solid, &typo), crate::schema::Contract::check(&surface, &typo)] {
assert_eq!(issues.len(), 1, "{typo}");
assert_eq!(issues[0].path, "path_boddy", "{typo}");
assert_eq!(issues[0].message, "unknown key `path_boddy`; did you mean `path_body`?", "{typo}");
}
// `path_body` is a body name, or `null` (which the handler reads as absent).
for p in [json!({"path_body": null}), json!({"path_body": "Body1", "path_edges": [[0, 0, 0]]})] {
assert!(crate::schema::Contract::check(&solid, &p).is_empty(), "{p}");
assert!(crate::schema::Contract::check(&surface, &p).is_empty(), "{p}");
}
for p in [json!({"path_body": 5}), json!({"path_body": true}), json!({"path_body": []})] {
for issues in [crate::schema::Contract::check(&solid, &p), crate::schema::Contract::check(&surface, &p)] {
assert_eq!(issues.len(), 1, "{p}");
assert_eq!(issues[0].path, "path_body", "{p}");
assert_eq!(issues[0].message, "`path_body` must be a body name", "{p}");
}
}
// `path_edges` is a list of 1 to 1000 points, as the handler's `path_edge_points` reads them.
for (p, message) in [
(json!({"path_edges": 5}), "`path_edges` must be a list of points"),
(json!({"path_edges": []}), "the path needs 1…1000 edges"),
(json!({"path_edges": [[0, 0, 0], [1]]}), "each `path_edges` entry must be a point [x,y,z]"),
] {
for issues in [crate::schema::Contract::check(&solid, &p), crate::schema::Contract::check(&surface, &p)] {
assert_eq!(issues.len(), 1, "{p}");
assert_eq!(issues[0].path, "path_edges", "{p}");
assert_eq!(issues[0].message, message, "{p}");
}
}
// The schemas the commands publish list both keys.
for schema in [input_schema_for::<SweepParams>(), input_schema_for::<SurfaceSweepParams>()] {
let schema = schema.as_value();
assert_eq!(schema["properties"]["path_body"]["type"], json!(["string", "null"]), "{schema}");
// `path_edges` is published as a `$ref` to its own definition, as `path` is.
let name = schema["properties"]["path_edges"]["$ref"].as_str().unwrap_or_default().strip_prefix("#/$defs/").unwrap_or_default().to_string();
let edges = &schema["$defs"][name.clone()];
assert_eq!(edges["type"], "array", "{schema}");
assert_eq!(edges["minItems"], 1, "{schema}");
assert_eq!(edges["maxItems"], 1000, "{schema}");
}
}
/// The calls both sweeps refuse today, as `sweep` and `surface_sweep` read them, each run on
/// `sketch_rectangle`'s sketch (`Sketch1`, which is the path sketch too: the path's curves are
/// only looked up when the feature is evaluated).
+137 -4
View File
@@ -342,6 +342,98 @@ pub struct DocumentInspectParams {
/// Give each sketch also its plane and its number of points and constraints. Default: false.
#[serde(default)]
pub sketch_counts: Option<bool>,
/// How much of the timeline to give: true (the default) each feature with its state and
/// timing; `"summary"` each as its id, name and type, with `error`, `warning`, `suppressed`
/// and `rolled_back` only when it has them (a reply a large design keeps small); false leaves
/// `timeline` out.
#[serde(default)]
pub timeline: Option<TimelineDetail>,
/// How much of each body to give: true (the default) as `measure` says; `"names"` only their
/// names, nothing measured; false leaves `bodies` out.
#[serde(default)]
pub bodies: Option<BodiesDetail>,
}
/// `document.inspect`'s `timeline`: the whole timeline, a summary of it, or none.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TimelineDetail {
Full,
Summary,
Left,
}
const TIMELINE_FORMS: &str = "`timeline` must be true, false or \"summary\"";
impl TimelineDetail {
/// `timeline` as the handler reads it: absent or `null` is the whole timeline.
pub fn read(v: Option<&Value>) -> Result<Self, &'static str> {
match v {
None | Some(Value::Null) | Some(Value::Bool(true)) => Ok(TimelineDetail::Full),
Some(Value::Bool(false)) => Ok(TimelineDetail::Left),
Some(Value::String(s)) if s == "summary" => Ok(TimelineDetail::Summary),
Some(_) => Err(TIMELINE_FORMS),
}
}
}
impl<'de> Deserialize<'de> for TimelineDetail {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let v = Value::deserialize(d)?;
TimelineDetail::read(Some(&v)).map_err(|e| D::Error::custom(whole(e)))
}
}
impl JsonSchema for TimelineDetail {
fn schema_name() -> Cow<'static, str> {
"TimelineDetail".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "true: every feature with its state and timing; \"summary\": id, name and type, with error, warning, suppressed and rolled_back only when set; false: no timeline.",
"anyOf": [{"type": "boolean"}, {"type": "string", "enum": ["summary"]}]
})
}
}
/// `document.inspect`'s `bodies`: each body in full, only the names, or none.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum BodiesDetail {
Full,
Names,
Left,
}
const BODIES_FORMS: &str = "`bodies` must be true, false or \"names\"";
impl BodiesDetail {
/// `bodies` as the handler reads it: absent or `null` is every body in full.
pub fn read(v: Option<&Value>) -> Result<Self, &'static str> {
match v {
None | Some(Value::Null) | Some(Value::Bool(true)) => Ok(BodiesDetail::Full),
Some(Value::Bool(false)) => Ok(BodiesDetail::Left),
Some(Value::String(s)) if s == "names" => Ok(BodiesDetail::Names),
Some(_) => Err(BODIES_FORMS),
}
}
}
impl<'de> Deserialize<'de> for BodiesDetail {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let v = Value::deserialize(d)?;
BodiesDetail::read(Some(&v)).map_err(|e| D::Error::custom(whole(e)))
}
}
impl JsonSchema for BodiesDetail {
fn schema_name() -> Cow<'static, str> {
"BodiesDetail".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "true: each body as `measure` says; \"names\": only the body names, nothing measured; false: no bodies.",
"anyOf": [{"type": "boolean"}, {"type": "string", "enum": ["names"]}]
})
}
}
/// What `document.inspect` returns.
@@ -365,10 +457,13 @@ pub struct DocumentInspectResult {
pub marker: usize,
/// The design's parameters.
pub params: Vec<DocumentParam>,
/// Its features, in timeline order.
pub timeline: Vec<TimelineEntry>,
/// Its bodies.
pub bodies: Vec<DocumentBody>,
/// Its features, in timeline order (in short with `timeline: "summary"`); left out with
/// `timeline: false`.
#[serde(skip_serializing_if = "Option::is_none")]
pub timeline: Option<Vec<TimelineRow>>,
/// Its bodies (only their names with `bodies: "names"`); left out with `bodies: false`.
#[serde(skip_serializing_if = "Option::is_none")]
pub bodies: Option<DocumentBodies>,
/// Its sketches.
pub sketches: Vec<DocumentSketch>,
/// What is selected, each as the selection commands take it.
@@ -420,6 +515,44 @@ pub struct TimelineEntry {
pub ms: Option<f64>,
}
/// A feature of `document.inspect`'s timeline: in full, or in short with `timeline: "summary"`.
#[derive(Debug, Serialize, JsonSchema)]
#[serde(untagged)]
pub enum TimelineRow {
Full(TimelineEntry),
Summary(TimelineSummary),
}
/// A feature in short: what it is, and only the states it has.
#[derive(Debug, Serialize, JsonSchema)]
pub struct TimelineSummary {
pub id: u64,
pub name: String,
/// The feature's kind, such as `Extrude`.
#[serde(rename = "type")]
pub kind: String,
/// Why it failed; present only when it did.
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
/// Present only when it has one.
#[serde(skip_serializing_if = "Option::is_none")]
pub warning: Option<String>,
/// Present, and true, only when it is suppressed.
#[serde(skip_serializing_if = "Option::is_none")]
pub suppressed: Option<bool>,
/// Present, and true, only when the timeline marker has rolled it back.
#[serde(skip_serializing_if = "Option::is_none")]
pub rolled_back: Option<bool>,
}
/// `document.inspect`'s bodies: each in full, or only their names with `bodies: "names"`.
#[derive(Debug, Serialize, JsonSchema)]
#[serde(untagged)]
pub enum DocumentBodies {
Full(Vec<DocumentBody>),
Names(Vec<String>),
}
/// A body of the design: measured with `measure`, else with the feature that made it.
#[derive(Debug, Serialize, JsonSchema)]
pub struct DocumentBody {
+96 -2
View File
@@ -99,7 +99,7 @@ fn describe_returns_both_schemas() {
#[test]
fn inspect_schemas_and_the_key_check_agree() {
assert_keys(input_schema_for::<DocumentInspectParams>(), &["measure", "sketch_counts"]);
assert_keys(input_schema_for::<DocumentInspectParams>(), &["bodies", "measure", "sketch_counts", "timeline"]);
assert_keys(input_schema_for::<MeasureParams>(), &["bodies", "items", "names_only"]);
assert_keys(input_schema_for::<CommandsParams>(), &[]);
assert_keys(input_schema_for::<MaterialListParams>(), &[]);
@@ -182,6 +182,15 @@ fn the_types_read_each_key_as_the_handler_does() {
// document.inspect's flags are plain booleans, `null` is left out.
assert_eq!(issues("document.inspect", json!({"measure": true, "sketch_counts": null})), none);
assert_eq!(issues("document.inspect", json!({"measure": "yes"})), ["`measure` must be true or false"]);
for ok in
[json!({"timeline": "summary", "bodies": "names"}), json!({"timeline": false, "bodies": false}), json!({"timeline": true, "bodies": null})]
{
assert_eq!(issues("document.inspect", ok.clone()), none, "{ok}");
}
for bad in [json!("full"), json!(1), json!(["Box"])] {
assert_eq!(issues("document.inspect", json!({ "timeline": bad.clone() })), ["`timeline` must be true, false or \"summary\""], "{bad}");
assert_eq!(issues("document.inspect", json!({ "bodies": bad.clone() })), ["`bodies` must be true, false or \"names\""], "{bad}");
}
// Neither list command takes a key.
assert_eq!(issues("engine.commands", json!({"x": 1})), ["unknown key `x`"]);
assert_eq!(issues("material.list", json!({"x": 1})), ["unknown key `x`"]);
@@ -234,7 +243,17 @@ fn the_schemas_take_what_the_handlers_read() {
(json!({"sketch": true}), false),
],
);
takes(input_schema_for::<DocumentInspectParams>(), &[(json!({"measure": true, "sketch_counts": false}), true), (json!({"measure": 1}), false)]);
takes(
input_schema_for::<DocumentInspectParams>(),
&[
(json!({"measure": true, "sketch_counts": false}), true),
(json!({"measure": 1}), false),
(json!({"timeline": "summary", "bodies": "names"}), true),
(json!({"timeline": false, "bodies": true}), true),
(json!({"timeline": "names"}), false),
(json!({"bodies": ["Block"]}), false),
],
);
takes(input_schema_for::<CommandsParams>(), &[(json!({}), true), (json!({"x": 1}), false)]);
}
@@ -325,6 +344,81 @@ fn document_inspect_replies_match_its_schema() {
assert!(r["bodies"].as_array().is_some_and(|b| b.iter().any(|b| b["removed_later"] == true)), "{r}");
}
/// `timeline` and `bodies` make a large design's reply small (#14162): a summary row has only
/// what the feature is and the states it has, `"names"` lists only body names and measures
/// nothing, `false` leaves either out. The default reply is as it was; a bad value is refused
/// by the handler too, with the check off.
#[test]
fn document_inspect_gives_a_short_timeline_and_body_names_when_asked() {
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
design(&mut s);
// A feature that fails: a box whose length goes to zero.
run(&mut s, "parameters.change", json!({"name": "t", "expression": "5 mm"}));
run(&mut s, "solid.box", json!({"length": "t", "width": 5, "height": 5, "corner": [80, 0, 0], "body_name": "Broken"}));
run(&mut s, "parameters.change", json!({"name": "t", "expression": "0 mm"}));
let keys = |row: &Value| -> Vec<String> {
let mut k: Vec<String> = row.as_object().map(|o| o.keys().cloned().collect()).unwrap_or_default();
k.sort();
k
};
let full = run(&mut s, "document.inspect", json!({"measure": true}));
for row in full["timeline"].as_array().unwrap() {
assert_eq!(keys(row), ["error", "id", "ms", "name", "rolled_back", "suppressed", "type", "warning"], "the default row: {row}");
}
assert_eq!(run(&mut s, "document.inspect", json!({"measure": true, "timeline": true, "bodies": true})), full);
let failing = full["timeline"].as_array().unwrap().iter().filter(|f| !f["error"].is_null()).count();
assert_eq!(failing, 1, "the zero-length box fails: {full}");
let short = run(&mut s, "document.inspect", json!({"measure": true, "timeline": "summary", "bodies": "names"}));
matches::<DocumentInspectResult>("document.inspect", &short, false);
let rows = short["timeline"].as_array().unwrap();
assert_eq!(rows.len(), full["timeline"].as_array().unwrap().len(), "{short}");
for (row, was) in rows.iter().zip(full["timeline"].as_array().unwrap()) {
let mut want = vec!["id", "name", "type"];
if !was["error"].is_null() {
want.push("error");
}
if !was["warning"].is_null() {
want.push("warning");
}
want.sort();
assert_eq!(keys(row), want, "{row} from {was}");
assert_eq!(
(&row["id"], &row["name"], &row["type"], &row.get("error")),
(&was["id"], &was["name"], &was["type"], &was.get("error").filter(|e| !e.is_null()))
);
}
let names: Vec<&Value> = full["bodies"].as_array().unwrap().iter().map(|b| &b["name"]).collect();
assert_eq!(short["bodies"].as_array().unwrap().iter().collect::<Vec<_>>(), names, "names only, nothing measured: {short}");
let size = |r: &Value| r["timeline"].to_string().len() + r["bodies"].to_string().len();
assert!(size(&short) * 3 < size(&full), "timeline and bodies: {} vs {} bytes", size(&short), size(&full));
// Everything else is as it was.
for key in ["params", "sketches", "undo", "marker", "selection"] {
assert_eq!(short[key], full[key], "{key}");
}
let none = run(&mut s, "document.inspect", json!({"timeline": false, "bodies": false}));
matches::<DocumentInspectResult>("document.inspect", &none, false);
assert!(none.get("timeline").is_none() && none.get("bodies").is_none(), "{none}");
assert_eq!(none["params"], full["params"]);
// A rolled-back or suppressed feature says so in its summary row.
run(&mut s, "timeline.roll_to", json!({"feature": "Shapes"}));
let rolled = run(&mut s, "document.inspect", json!({"timeline": "summary"}));
assert!(rolled["timeline"].as_array().unwrap().iter().any(|f| f["rolled_back"] == true), "{rolled}");
assert!(rolled["timeline"].as_array().unwrap().iter().all(|f| f.get("rolled_back").is_none_or(|r| r == true)), "{rolled}");
// The handler refuses a bad value itself (with the check off as well).
let mut off = Session { param_check: ParamCheck::Off, ..Session::default() };
for (p, want) in [
(json!({"timeline": "full"}), "`timeline` must be true, false or \"summary\""),
(json!({"bodies": ["Block"]}), "`bodies` must be true, false or \"names\""),
] {
let e = off.execute("document.inspect", &p).err().map(|e| e.to_string()).unwrap_or_default();
assert!(e.contains(want), "{p}: {e}");
}
}
/// `inspect.measure` in each form: bodies (with sheet metal and a material), names only, one
/// item and two, in Warn without warnings. Everything it ignores with `items` is ignored.
#[test]
+35 -1
View File
@@ -413,7 +413,7 @@ macro_rules! read_with {
)*};
}
read_with!(JointKey, OccurrenceKey, JointTypeParam, JointValue, JointValues, JointLimits, JointOriginArg);
read_with!(JointKey, OccurrenceKey, JointTypeParam, ConstraintTypeParam, JointValue, JointValues, JointLimits, JointOriginArg);
/// The call's keys as an object. (`TypedContract::check` has refused anything else already.)
fn keys<'de, D: Deserializer<'de>>(d: D) -> Result<Map<String, Value>, D::Error> {
@@ -641,6 +641,37 @@ impl<'de> Deserialize<'de> for CreateConstraintParams {
}
}
/// Change a constraint (a joint made with `assembly.constraint.create`) in place: its rule, gap,
/// angle, facing, name or suppression. A new rule must fit the references the constraint has; the gap
/// carries over. The change goes through joint.edit: the values start again where the parts are.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct EditConstraintParams {
/// The constraint, by id or name.
pub constraint: IdOrName,
/// The new rule, as assembly.constraint.create takes it. Default: the rule it has.
#[serde(default, rename = "type", deserialize_with = "present")]
pub kind: Option<ConstraintTypeParam>,
/// The gap: a length, or an expression the constraint follows. Default: the gap it has.
#[serde(default, deserialize_with = "present")]
pub offset: Option<LengthExpr>,
/// An angle rule's angle: an angle, or an expression. Needed to change to angle.
#[serde(default, deserialize_with = "present")]
pub angle: Option<AngleExpr>,
/// True turns b over: from the new rule's way after a type change, else from how it faces now.
#[serde(default, deserialize_with = "present")]
pub flip: Option<bool>,
/// The constraint's new name.
#[serde(default, deserialize_with = "present")]
pub name: Option<String>,
/// Suppress the constraint (true) or bring it back (false).
#[serde(default, deserialize_with = "present")]
pub suppressed: Option<bool>,
/// Leave out the degrees of freedom and the fixed occurrences in the reply.
#[serde(default, deserialize_with = "present")]
pub quiet: Option<bool>,
}
/// Add a joint between two occurrences that stay where they are: its frame is a joint origin
/// (`at`, default b's origin), and the joint starts with the origins coinciding as they are. An
/// origin on a or b is kept on its face or hole there, so the joint follows it as the design
@@ -2360,6 +2391,9 @@ pub struct ReachResult {
pub stopped_by_limit: Option<Vec<LimitStop>>,
/// The closed loop that stopped it (its chain alone gets closer); `null` when none.
pub stopped_by_loop: Option<ReachLoopStop>,
/// With contact sets on: the pair of a set that stopped it where they touch, as `joint.drive`
/// says; `null` when none.
pub stopped_by_contact: Option<ContactStop>,
/// Why nothing can move. Left out when the reach worked.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
+26 -3
View File
@@ -4,9 +4,9 @@ use solidcraft_doc::joints::JointKind;
use super::{
AsBuiltJointParams, CamParams, CamResult, CouplingResult, CreateConstraintParams, CreateJointParams, DeleteJointParams, DeleteJointResult,
DriveJointParams, DriveJointResult, EditJointParams, GearParams, InspectJointParams, InspectJointResult, JointAddedResult, JointKindName,
JointLimitsParams, ListJointsParams, ListJointsResult, MakeJointOriginParams, MakeJointOriginResult, MotionLinkParams, RackPinionParams,
ReachParams, ReachResult, RigidGroupParams, RigidGroupResult, ScrewParams, SolveJointsParams, SolveResult,
DriveJointParams, DriveJointResult, EditConstraintParams, EditJointParams, EditJointResult, GearParams, InspectJointParams, InspectJointResult,
JointAddedResult, JointKindName, JointLimitsParams, ListJointsParams, ListJointsResult, MakeJointOriginParams, MakeJointOriginResult,
MotionLinkParams, RackPinionParams, ReachParams, ReachResult, RigidGroupParams, RigidGroupResult, ScrewParams, SolveJointsParams, SolveResult,
};
use crate::Session;
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
@@ -19,6 +19,7 @@ fn schemas_and_the_key_check_agree() {
input_schema_for::<CreateJointParams>(),
&["a", "angle", "b", "flip", "limits", "name", "offset", "quiet", "type", "value", "values"],
);
assert_keys(input_schema_for::<EditConstraintParams>(), &["angle", "constraint", "flip", "name", "offset", "quiet", "suppressed", "type"]);
assert_keys(input_schema_for::<CreateConstraintParams>(), &["a", "angle", "b", "flip", "name", "offset", "quiet", "type"]);
assert_keys(input_schema_for::<AsBuiltJointParams>(), &["a", "at", "b", "limits", "name", "quiet", "type", "value", "values"]);
assert_keys(input_schema_for::<DriveJointParams>(), &["continuous", "index", "joint", "quiet", "stop_at_budget", "value", "values"]);
@@ -44,6 +45,7 @@ fn schemas_and_the_key_check_agree() {
fn the_commands_publish_their_schemas() {
for id in [
"assembly.constraint.create",
"assembly.constraint.edit",
"joint.create",
"joint.as_built",
"joint.drive",
@@ -206,6 +208,10 @@ fn strict_keeps_todays_errors() {
("joint.screw", json!({"a": true, "b": 1, "pitch": 1})),
("joint.cam", json!({})),
("joint.cam", json!({"a": false, "b": 1})),
("assembly.constraint.edit", json!({})),
("assembly.constraint.edit", json!({"constraint": true})),
("assembly.constraint.edit", json!({"constraint": null})),
("assembly.constraint.edit", json!({"constraint": [1]})),
("assembly.constraint.create", json!({})),
("assembly.constraint.create", json!({"type": "weld"})),
("assembly.constraint.create", json!({"type": 5})),
@@ -531,6 +537,18 @@ fn the_schemas_take_what_the_handlers_read() {
(json!({"type": "mate", "a": "A", "b": "B", "values": [1]}), false),
],
);
takes(
input_schema_for::<EditConstraintParams>(),
&[
(json!({"constraint": "Mate1"}), true),
(json!({"constraint": 4, "type": "flush", "offset": "gap", "flip": true}), true),
(json!({"constraint": "Mate1", "angle": 30, "name": "M", "suppressed": false, "quiet": true}), true),
(json!({}), false),
(json!({"constraint": "Mate1", "type": 5}), false),
(json!({"constraint": "Mate1", "flip": 1}), false),
(json!({"constraint": "Mate1", "joint": "Mate1"}), false),
],
);
takes(input_schema_for::<ListJointsParams>(), &[(json!({}), true), (json!({"quiet": false}), true), (json!({"quiet": 1}), false)]);
takes(input_schema_for::<SolveJointsParams>(), &[(json!({}), true), (json!({"quiet": false}), true), (json!({"quiet": 1}), false)]);
}
@@ -589,6 +607,11 @@ fn replies_match_the_output_schema() {
let inspected = run("joint.inspect", json!({"joint": "Rule"}));
matches(output_schema_for::<InspectJointResult>(), "joint.inspect", &inspected);
assert_eq!(inspected["joints"][0]["constraint"], "parallel", "{inspected}");
// assembly.constraint.edit answers as joint.edit does.
let edited = run("assembly.constraint.edit", json!({"constraint": "Rule", "type": "perpendicular"}));
matches(output_schema_for::<EditJointResult>(), "assembly.constraint.edit", &edited);
let edited = run("assembly.constraint.edit", json!({"constraint": "Rule", "suppressed": true, "quiet": true}));
matches(output_schema_for::<EditJointResult>(), "assembly.constraint.edit", &edited);
run("joint.delete", json!({"joint": "Rule"}));
let built = run("joint.as_built", json!({"type": "slider", "a": "LeafB", "b": "LeafC", "name": "Slide", "limits": [[0, 10]], "quiet": false}));
matches(output_schema_for::<JointAddedResult>(), "joint.as_built", &built);
+1
View File
@@ -86,6 +86,7 @@ pub mod mesh;
pub mod metadata;
pub mod motion;
pub mod motion_analysis;
pub mod motion_animation;
pub mod motion_play;
pub mod motion_trace;
pub mod new_design;
@@ -0,0 +1,396 @@
//! Contract and reply type for Export Motion Animation (`motion.export_animation`, #13310): a
//! study played, one joint driven or an exploded view coming apart, drawn frame by frame and
//! written as an animated GIF or a folder of PNG frames. The design is not changed.
//!
//! The handler (`cmd/motion_animation.rs`) reads its parameters from the raw JSON one after the
//! other, so the contract reads the whole call in the same order and with the same messages:
//! `path`, `width` and `height`, `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. A key given as `null` is given, as the
//! handler reads it. Keys the handler doesn't read in a case aren't read here either: `back` only
//! goes with a `view`, and `index`, `from` and `to` with a `joint`.
//!
//! The camera's fields are tried on the default camera: a field the handler refuses is wrong
//! whatever camera it completes, so the message is the handler's. What only the session can tell
//! stays the handler's to check: whether the view, the study and the joint exist, whether the
//! joint moves, `index` (its message names the joint's value count), what `from` and `to`
//! evaluate to, whether a renderer is installed and where the file may be written. A call with
//! several problems may get a different one first in strict than with the check off.
use std::borrow::Cow;
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
use serde::de::Error as _;
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::{Map, Value};
use super::check::whole;
use super::motion_analysis::StudyStop;
use super::types::not_blank_pattern;
use solidcraft_viewport::Camera;
/// The most frames of one animation. The handler (`cmd/motion_animation.rs`) uses these limits
/// too, so the schema, the strict check and the handler cannot disagree.
pub(crate) const MAX_FRAMES: usize = 1000;
/// The largest frame side, in pixels.
pub(crate) const MAX_SIDE: u32 = 4096;
/// The 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.
pub(crate) const MAX_GIF_PIXELS: usize = 1 << 26;
/// The most pixels of a PNG folder's frames together (each frame is written as it is drawn):
/// about 500 frames of 1920 × 1080.
pub(crate) const MAX_PNG_PIXELS: usize = 1 << 30;
/// The camera fields `camera` may set: those of `ui.camera`'s reply.
pub(crate) const CAMERA_FIELDS: [&str; 7] = ["target", "yaw", "pitch", "roll", "distance", "fov", "up"];
/// The handler's message for a camera that isn't finite or is out of range.
const BAD_CAMERA: &str = "the camera must be finite, with a distance up to 1e15 mm, a pitch within ±π/2 and a fov of 0 (orthographic) to 3 radians";
/// Draw a motion as an animation, frame by frame with the headless renderer: a motion study
/// played, one joint driven from `from` to `to`, or an exploded view coming apart (and back
/// together with `back`). Written as an animated GIF, or as a folder of PNG frames. The design
/// is not changed, nor its placements.
#[derive(Debug)]
pub struct ExportAnimationParams {
/// The `.gif` file, or the folder for the PNG frames.
pub path: String,
/// The frame size, in pixels.
pub width: u32,
pub height: u32,
/// A GIF's frames per second.
pub fps: f64,
/// Whether the ground grid and the origin's axes are drawn.
pub grid: bool,
pub axes: bool,
/// What moves.
pub motion: AnimatedMotion,
/// The camera fields given, if any.
pub camera: Option<Map<String, Value>>,
/// The frames asked for, if any.
pub samples: Option<usize>,
}
/// What an animation shows.
#[derive(Debug)]
pub enum AnimatedMotion {
/// An exploded view, by id or name, coming apart (and back together with `back`).
View { view: Value, back: bool },
/// A motion study, by id or name, played from start to end.
Study(Value),
/// A joint, by id or name, driven from `from` to `to`: its value `index` (as given; the
/// handler checks it against the joint's value count).
Joint { joint: Value, index: Option<Value>, from: Value, to: Value },
}
impl ExportAnimationParams {
/// The keys the handler reads, in its order and in the cases it reads them.
fn read(o: &Map<String, Value>) -> Result<ExportAnimationParams, String> {
let path = o
.get("path")
.and_then(Value::as_str)
.filter(|x| !x.trim().is_empty() && x.len() <= 4096 && !x.chars().any(char::is_control))
.ok_or("`path` must name a .gif file, or a folder for PNG frames")?;
let lower = path.to_ascii_lowercase();
if lower.ends_with(".png") {
return Err("`path` is a .gif file or a folder for the PNG frames (frame-0001.png, …), not one .png".into());
}
let gif = lower.ends_with(".gif");
if !gif && cfg!(target_arch = "wasm32") {
return Err("a folder of PNG frames is not available in the browser: give a .gif path".into());
}
let (width, height) = (side(o, "width", 800)?, side(o, "height", 500)?);
let fps = match o.get("fps") {
None => 20.0,
Some(v) => v.as_f64().filter(|n| n.is_finite() && (1.0..=50.0).contains(n)).ok_or("`fps` must be 1…50")?,
};
let (grid, axes) = (flag(o, "grid", true)?, flag(o, "axes", true)?);
let motion = motion(o)?;
let camera = o.get("camera").map(camera_fields).transpose()?;
let max = MAX_FRAMES.min(if gif { MAX_GIF_PIXELS } else { MAX_PNG_PIXELS } / (width as usize * height as usize));
let samples = match o.get("samples") {
None => None,
Some(v) => Some(
v.as_f64()
.filter(|n| n.is_finite() && *n >= 2.0 && *n <= max as f64)
.map(|n| n.round() as usize)
.ok_or_else(|| format!("`samples` must be 2…{max} for {width} × {height} frames"))?,
),
};
Ok(ExportAnimationParams { path: path.to_string(), width, height, fps, grid, axes, motion, camera, samples })
}
}
/// `width` or `height`: 16 to 4096 pixels, rounded, or `default` left out.
fn side(o: &Map<String, Value>, key: &str, default: u32) -> Result<u32, String> {
match o.get(key) {
None => Ok(default),
Some(v) => v
.as_f64()
.filter(|n| n.is_finite() && (16.0..=f64::from(MAX_SIDE)).contains(n))
.map(|n| n.round() as u32)
.ok_or_else(|| format!("`{key}` must be 16…{MAX_SIDE} pixels")),
}
}
/// True or false, or `default` left out.
fn flag(o: &Map<String, Value>, key: &str, default: bool) -> Result<bool, String> {
match o.get(key) {
None => Ok(default),
Some(Value::Bool(b)) => Ok(*b),
Some(_) => Err(format!("`{key}` must be true or false")),
}
}
/// An id (a number) or a name, or `message`.
fn id_or_name(v: &Value, message: &str) -> Result<Value, String> {
match v {
Value::Number(_) | Value::String(_) => Ok(v.clone()),
_ => Err(message.into()),
}
}
/// What moves, as the handler picks it: an exploded `view` alone, else `study` or `joint`
/// (through `motion_plan` in `cmd/motion.rs`).
fn motion(o: &Map<String, Value>) -> Result<AnimatedMotion, String> {
let picked = "give a `study`, a `joint` with `from` and `to`, or an exploded `view`";
match (o.get("view"), o.get("study").or(o.get("joint"))) {
(Some(_), Some(_)) => Err("give an exploded `view`, a `study` or a `joint`: one of them".into()),
(Some(view), None) => {
let back = flag(o, "back", false)?;
Ok(AnimatedMotion::View { view: id_or_name(view, "`view` must be an id or a name")?, back })
}
(None, None) => Err(picked.into()),
(None, Some(_)) => {
if o.contains_key("back") {
return Err("`back` goes with an exploded `view`".into());
}
match (o.get("study"), o.get("joint")) {
(Some(_), Some(_)) => Err("give `study` or `joint`, not both".into()),
(Some(study), None) => Ok(AnimatedMotion::Study(id_or_name(study, "`study` must be an id or a name")?)),
(None, Some(joint)) => {
let joint = id_or_name(joint, "`joint` must be an id or a name")?;
// The handler looks the joint up and reads `index` between the two.
let end = |key: &str| match o.get(key) {
Some(v) => joint_value(v),
None => Err(format!("give `from` and `to`: the joint's values the motion runs between (no `{key}`)")),
};
let (from, to) = (end("from")?, end("to")?);
Ok(AnimatedMotion::Joint { joint, index: o.get("index").cloned(), from, to })
}
(None, None) => Err(picked.into()),
}
}
}
}
/// `from` or `to`, as `joints::value` reads it: a number between -1e9 and 1e9, or text (an
/// expression, which the handler evaluates), with its message for anything else.
fn joint_value(v: &Value) -> Result<Value, String> {
match v {
Value::Number(n) if n.as_f64().is_some_and(|x| x.is_finite() && x.abs() < 1e9) => Ok(v.clone()),
Value::String(_) => Ok(v.clone()),
_ => Err(format!("a joint value {}", crate::params::expr_problem(Some(v)))),
}
}
/// `camera`: an object of `ui.camera`'s fields, read as the handler reads it, on the default
/// camera instead of the session's (each check is on the fields given).
fn camera_fields(given: &Value) -> Result<Map<String, Value>, String> {
let fields = given.as_object().ok_or("`camera` must be an object, such as ui.camera's reply")?;
let mut v = serde_json::to_value(Camera::default()).map_err(|e| e.to_string())?;
for (k, x) in fields {
if !CAMERA_FIELDS.contains(&k.as_str()) {
return Err(format!("unknown camera field `{k}` (it takes {})", CAMERA_FIELDS.join(", ")));
}
if let Some(o) = v.as_object_mut() {
o.insert(k.clone(), x.clone());
}
}
let cam: Camera = serde_json::from_value(v).map_err(|e| format!("bad camera: {e}"))?;
if !cam.is_valid() || cam.distance > 1e15 || cam.pitch.abs() > std::f64::consts::FRAC_PI_2 || !(0.0..3.0).contains(&cam.fov) {
return Err(BAD_CAMERA.into());
}
Ok(fields.clone())
}
impl<'de> Deserialize<'de> for ExportAnimationParams {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let o = Map::<String, Value>::deserialize(d)?;
ExportAnimationParams::read(&o).map_err(|why| D::Error::custom(whole(why)))
}
}
impl JsonSchema for ExportAnimationParams {
fn schema_name() -> Cow<'static, str> {
"ExportAnimationParams".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
// `index`, `from` and `to` are read only with a `joint`: their shapes are in the `anyOf` below,
// and the properties only describe them.
let end_description = |end: &str| {
format!(
"With `joint`: the value the motion {end} at, a number (degrees for an angle, mm for a distance, between -1e9 and 1e9) \
or an expression such as \"width / 2\" or \"30 deg\". Not read without a joint."
)
};
let index = "With `joint`: which of its values to drive, below its value count. Default: 0. Not read without a joint.";
let side_schema = |what: &str, default: u32| {
json_schema!({
"description": format!("The frames' {what} in pixels, 16…{MAX_SIDE} (rounded). Default: {default}."),
"type": "number",
"minimum": 16,
"maximum": MAX_SIDE,
"default": default
})
};
json_schema!({
"description": "Draw a motion as an animation, frame by frame with the headless renderer: a motion study played (`study`), \
one joint driven from `from` to `to` (`joint`), or an exploded view coming apart (`view`, and back together with \
`back`). Written as an animated GIF, or as a folder of PNG frames. The design is not changed, nor its placements.",
"type": "object",
"properties": {
"path": {
"description": "A .gif file, or a folder for the PNG frames (frame-0001.png, frame-0002.png, …), made where \
missing; frames of an earlier export there are removed first. Not one .png file. At most 4096 bytes (the \
schema counts characters), with no control characters.",
"type": "string",
"minLength": 1,
"maxLength": 4096,
"pattern": not_blank_pattern()
},
"width": side_schema("width", 800),
"height": side_schema("height", 500),
"fps": {
"description": "A GIF's frames per second, 1…50. A GIF counts in hundredths of a second, so the reply's `fps` \
is the rate it plays at. Default: 20.",
"type": "number",
"minimum": 1,
"maximum": 50,
"default": 20
},
"grid": {"description": "Draw the ground grid. Default: true.", "type": "boolean", "default": true},
"axes": {"description": "Draw the origin's axes. Default: true.", "type": "boolean", "default": true},
"view": {
"description": "An exploded view, by id (a number) or name: the parts coming apart from where they are \
assembled. Instead of `study` or `joint`.",
"type": ["number", "string"]
},
"back": {
"description": "With `view`: true comes back together too, exploded at the middle frame of an odd count. \
Default: false.",
"type": "boolean",
"default": false
},
"study": {
"description": "A motion study, by id (a number) or name, played from start to end. A number is looked up as \
an id first, text as a name first. Instead of `view` or `joint`.",
"type": ["number", "string"]
},
"joint": {
"description": "A joint, by id (a number) or name, driven from `from` to `to`; a rigid joint doesn't move. \
Instead of `view` or `study`.",
"type": ["number", "string"]
},
"index": {"description": index},
"from": {"description": end_description("starts")},
"to": {"description": end_description("ends")},
"camera": {
"description": "The camera the frames are drawn from: fields of ui.camera's reply. Fields left out keep the \
default view: view.apply's camera, or the home view framing the model (for an exploded view, both \
assembled and exploded). Angles in radians.",
"type": "object",
"properties": {
"target": {
"description": "The point looked at, [x, y, z] in mm.",
"type": "array",
"items": {"type": "number"},
"minItems": 3,
"maxItems": 3
},
"yaw": {"description": "Turn about the up axis; 0 looks along +Y, from the front.", "type": "number"},
"pitch": {
"description": "Elevation of the eye above the ground plane.",
"type": "number",
"minimum": -std::f64::consts::FRAC_PI_2,
"maximum": std::f64::consts::FRAC_PI_2
},
"roll": {"description": "Turn about the viewing direction.", "type": "number"},
"distance": {
"description": "From the target to the eye, in mm.",
"type": "number",
"exclusiveMinimum": 0,
"maximum": 1e15
},
"fov": {
"description": "Vertical field of view; 0 is orthographic.",
"type": "number",
"minimum": 0,
"exclusiveMaximum": 3
},
"up": {"description": "The world's up axis.", "enum": ["z", "y"]}
},
"additionalProperties": false
},
"samples": {
"description": format!(
"How many frames, 2…{MAX_FRAMES}, with at most 2^26 pixels in all for a GIF and 2^30 for PNG frames, so \
fewer for big frames (4 at 4096 × 4096 for a GIF). Default: the study's steps + 1, 100 for a joint, 41 for \
an exploded view (81 with `back`)."
),
"type": "number",
"minimum": 2,
"maximum": MAX_FRAMES
}
},
"required": ["path"],
"oneOf": [{"required": ["view"]}, {"required": ["study"]}, {"required": ["joint"]}],
"anyOf": [
{"not": {"required": ["joint"]}},
{
"properties": {
"index": {"type": "integer", "minimum": 0, "default": 0},
"from": {"type": ["number", "string"]},
"to": {"type": ["number", "string"]}
}
}
],
"dependentRequired": {"back": ["view"], "joint": ["from", "to"]},
"additionalProperties": false
})
}
}
/// What `motion.export_animation` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct AnimationExported {
/// The file or the folder written, as given.
pub path: String,
/// `gif` for an animated GIF, `png` for a folder of PNG frames.
pub format: &'static str,
/// How many frames were drawn.
pub frames: usize,
/// The frame size, in pixels.
pub width: u32,
pub height: u32,
/// A GIF's frames per second: the rate it plays at, which a GIF counts in hundredths of a
/// second. Absent for PNG frames.
#[serde(skip_serializing_if = "Option::is_none")]
pub fps: Option<f64>,
/// The bytes written: the GIF's, or all the PNG frames'.
pub bytes: usize,
/// What drew the frames: `cpu` for the CPU rasterizer.
pub renderer: &'static str,
/// What frames drawn by the CPU rasterizer lack. Absent otherwise.
#[serde(skip_serializing_if = "Option::is_none")]
pub warning: Option<&'static str>,
#[serde(flatten)]
pub stop: StudyStop,
/// Where two parts in a contact set touched and stopped the motion, or `null`.
pub stopped_by_contact: Option<Map<String, Value>>,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
#[cfg(test)]
#[path = "motion_animation_tests.rs"]
mod tests;
@@ -0,0 +1,253 @@
use std::collections::BTreeSet;
use serde_json::{Value, json};
use super::{AnimationExported, ExportAnimationParams};
use crate::Session;
use crate::schema::tests::{assert_keys, conforms};
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
const CMD: &str = "motion.export_animation";
/// Cube B (x 20…30) slides on joint `Rail` toward cube A (x 0…10), as in
/// `cmd/motion_tests.rs`: study `Push` takes it from 10 mm away until it touches A, study
/// `Through` 5 mm into A, and the exploded view `Apart` pulls the two apart.
fn rail() -> Session {
let mut s = Session::default();
let run = |s: &mut Session, id: &str, p: Value| {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
};
for (name, x) in [("A", 0), ("B", 20)] {
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "component.create", json!({"name": name}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0]}));
}
run(&mut s, "component.activate", json!({"component": "root"}));
let a = s.doc.find_component("A").and_then(|c| s.doc.occurrence_of(c)).map(|o| o.id).unwrap_or(0);
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, "motion.study", json!({"name": "Push", "steps": 15, "keys": [{"joint": "Rail", "points": [[0, 10], [15, 0]]}]}));
run(&mut s, "motion.study", json!({"name": "Through", "steps": 15, "keys": [{"joint": "Rail", "points": [[0, 10], [15, -5]]}]}));
run(&mut s, "explode.create", json!({"name": "Apart", "scale": 1}));
s
}
/// A folder of its own for one test's files.
fn out_dir(test: &str) -> std::path::PathBuf {
let dir = solidcraft_io::vfs::temp_dir().join(format!("solidcraft-anim-schema-{test}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap_or_else(|e| panic!("{e}"));
dir
}
#[test]
fn schemas_and_the_key_check_agree() {
let keys = ["axes", "back", "camera", "fps", "from", "grid", "height", "index", "joint", "path", "samples", "study", "to", "view", "width"];
assert_keys(input_schema_for::<ExportAnimationParams>(), &keys);
let schema = input_schema_for::<ExportAnimationParams>();
let schema = schema.as_value();
assert_eq!(schema["required"], json!(["path"]));
assert_eq!(schema["dependentRequired"], json!({"back": ["view"], "joint": ["from", "to"]}));
assert_eq!(schema["oneOf"], json!([{"required": ["view"]}, {"required": ["study"]}, {"required": ["joint"]}]));
}
/// #13310: calls that fail today get the same message in strict: the handler's for `path`, the
/// frame size, `fps`, `grid` and `axes`, what moves (none, two of `view`, `study` and `joint`,
/// `back` without a view, a view, study or joint of the wrong shape, `from` or `to` left out or
/// of the wrong shape), `camera` (not an object, an unknown field, a field of the wrong shape or
/// out of range) and `samples` (also past what the frame size allows), and the handler's own for
/// what only the design can tell (a view, study or joint it lacks, `index` past the joint's
/// values, `to` that is no expression).
#[test]
fn strict_keeps_todays_errors() {
let dir = out_dir("strict");
let gif = dir.join("x.gif").to_string_lossy().to_string();
let frames = dir.join("frames").to_string_lossy().to_string();
let push = |extra: Value| {
let mut p = json!({"study": "Push", "path": gif});
if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) {
p.extend(extra.clone());
}
p
};
let camera = |c: Value| push(json!({"camera": c}));
for p in [
json!({}),
json!({"study": "Push", "path": null}),
json!({"study": "Push", "path": ""}),
json!({"study": "Push", "path": " "}),
json!({"study": "Push", "path": 5}),
json!({"study": "Push", "path": "x".repeat(4097)}),
json!({"study": "Push", "path": "a\u{1}b.gif"}),
json!({"study": "Push", "path": "one.png"}),
json!({"study": "Push", "path": "One.PNG"}),
push(json!({"width": 15})),
push(json!({"width": "800"})),
push(json!({"width": null})),
push(json!({"height": 1e9})),
push(json!({"fps": 0})),
push(json!({"fps": "20"})),
push(json!({"grid": "no"})),
push(json!({"axes": null})),
json!({"path": gif}),
json!({"path": gif, "view": "Apart", "study": "Push"}),
json!({"path": gif, "view": "Apart", "joint": "Rail", "from": 0, "to": 1}),
json!({"path": gif, "view": "Apart", "study": null}),
json!({"path": gif, "view": "Apart", "back": "yes"}),
json!({"path": gif, "view": [1]}),
json!({"path": gif, "view": null}),
json!({"path": gif, "view": "Nope"}),
push(json!({"back": true})),
push(json!({"back": false})),
json!({"path": gif, "joint": "Rail", "from": 0, "to": 1, "back": null}),
push(json!({"joint": "Rail"})),
push(json!({"study": null})),
push(json!({"study": [1]})),
push(json!({"study": "Nope"})),
json!({"path": gif, "joint": null}),
json!({"path": gif, "joint": true, "from": 0, "to": 1}),
json!({"path": gif, "joint": "Nope", "from": 0, "to": 1}),
json!({"path": gif, "joint": "Rail"}),
json!({"path": gif, "joint": "Rail", "from": 0}),
json!({"path": gif, "joint": "Rail", "from": true, "to": 1}),
json!({"path": gif, "joint": "Rail", "from": 0, "to": 2e9}),
json!({"path": gif, "joint": "Rail", "from": 0, "to": null}),
json!({"path": gif, "joint": "Rail", "from": 0, "to": "1 +"}),
json!({"path": gif, "joint": "Rail", "index": 1, "from": 0, "to": 1}),
json!({"path": gif, "joint": "Rail", "index": -1, "from": 0, "to": 1}),
json!({"path": gif, "joint": "Rail", "index": "0", "from": 0, "to": 1}),
camera(json!("iso")),
camera(json!(null)),
camera(json!({"fit": true})),
camera(json!({"yaw": 1, "zoom": 2})),
camera(json!({"target": [1, 2]})),
camera(json!({"yaw": "a"})),
camera(json!({"up": "x"})),
camera(json!({"distance": -1})),
camera(json!({"distance": 1e300})),
camera(json!({"pitch": 4})),
camera(json!({"fov": 3})),
camera(json!({"fov": -0.1})),
push(json!({"samples": 1})),
push(json!({"samples": 1001})),
push(json!({"samples": "10"})),
push(json!({"samples": null})),
// 800 × 500 frames: a GIF holds 167 of them; 4096 × 4096: 4, and a folder of PNG 64.
push(json!({"samples": 168})),
push(json!({"width": 4096, "height": 4096, "samples": 5})),
json!({"study": "Push", "path": frames, "width": 4095.6, "height": 4096, "samples": 65}),
] {
let error = |level: ParamCheck| {
let mut s = rail();
s.param_check = level;
s.execute(CMD, &p).err().map(|e| e.to_string())
};
let off = error(ParamCheck::Off);
assert!(off.is_some(), "{p} should fail today");
assert_eq!(error(ParamCheck::Strict), off, "{p}");
}
let _ = std::fs::remove_dir_all(&dir);
}
/// #13310: the contract takes what the input schema allows: a call's check finds no issue
/// exactly when it matches the schema. Left out of this on purpose, as JSON Schema (or the
/// schema check here) can't tell them: what moves (one of `view`, `study` and `joint`, `back`
/// only with a view and `from` and `to` with a joint: `oneOf` and `dependentRequired`), a blank
/// or `.png` path, `samples` past what the frame size allows, and `index`, which the handler
/// checks against the joint's value count.
#[test]
fn the_check_takes_what_the_schema_allows() {
let schema = input_schema_for::<ExportAnimationParams>();
let schema = schema.as_value();
let full_camera = json!({"target": [0, 0, 0], "yaw": 1, "pitch": -1.5, "roll": 0.2, "distance": 100, "fov": 0.5, "up": "y"});
for (p, takes) in [
(json!({"path": "a.gif", "study": "Push"}), true),
(json!({"path": "frames", "study": 1, "width": 16, "height": 4096, "fps": 50, "grid": false, "axes": true, "samples": 2}), true),
(json!({"path": "a.gif", "joint": "Rail", "from": 0, "to": "10 mm", "index": 0}), true),
(json!({"path": "a.gif", "view": "Apart", "back": true, "samples": 81}), true),
(json!({"path": "a.gif", "study": "Push", "camera": full_camera}), true),
(json!({"path": "a.gif", "study": "Push", "camera": {}}), true),
// Read only with a joint, as the handler reads them.
(json!({"path": "a.gif", "study": "Push", "index": null, "from": [], "to": false}), true),
(json!({"path": "", "study": "Push"}), false),
(json!({"path": 5, "study": "Push"}), false),
(json!({"path": "a.gif", "study": "Push", "width": 15}), false),
(json!({"path": "a.gif", "study": "Push", "height": 4097}), false),
(json!({"path": "a.gif", "study": "Push", "width": null}), false),
(json!({"path": "a.gif", "study": "Push", "fps": 0.5}), false),
(json!({"path": "a.gif", "study": "Push", "grid": 1}), false),
(json!({"path": "a.gif", "study": [1]}), false),
(json!({"path": "a.gif", "view": null}), false),
(json!({"path": "a.gif", "view": "Apart", "back": "yes"}), false),
(json!({"path": "a.gif", "joint": "Rail", "from": true, "to": 1}), false),
(json!({"path": "a.gif", "study": "Push", "camera": "iso"}), false),
(json!({"path": "a.gif", "study": "Push", "camera": {"fit": true}}), false),
(json!({"path": "a.gif", "study": "Push", "camera": {"target": [1, 2]}}), false),
(json!({"path": "a.gif", "study": "Push", "camera": {"yaw": "a"}}), false),
(json!({"path": "a.gif", "study": "Push", "camera": {"distance": 0}}), false),
(json!({"path": "a.gif", "study": "Push", "camera": {"distance": 2e15}}), false),
(json!({"path": "a.gif", "study": "Push", "camera": {"pitch": 2}}), false),
(json!({"path": "a.gif", "study": "Push", "camera": {"fov": 3}}), false),
(json!({"path": "a.gif", "study": "Push", "camera": {"up": "x"}}), false),
(json!({"path": "a.gif", "study": "Push", "samples": 1}), false),
(json!({"path": "a.gif", "study": "Push", "samples": 1001}), false),
(json!({"path": "a.gif", "study": "Push", "samples": null}), false),
(json!({"path": "a.gif", "study": "Push", "colour": "red"}), false),
] {
let issues = crate::schema::issues(CMD, &p);
assert_eq!(issues.is_empty(), takes, "{p}: {issues:?}");
assert_eq!(conforms(schema, schema, &p), takes, "{p}");
}
}
/// The keys of a reply.
fn keys(r: &Value) -> BTreeSet<&str> {
r.as_object().into_iter().flatten().map(|(k, _)| k.as_str()).collect()
}
/// An export through the registry, in Warn: it replies as its output schema says, without
/// warnings.
fn exported(s: &mut Session, p: Value) -> Value {
let schema = output_schema_for::<AnimationExported>();
let schema = schema.as_value();
let r = s.execute(CMD, &p).unwrap_or_else(|e| panic!("{p}: {e}"));
assert!(conforms(schema, schema, &r), "{r} doesn't match {schema}");
assert!(r.get("param_warnings").is_none(), "{r}");
r
}
/// #13310: the reply keeps the keys it had: a GIF of a study played (with its `fps`), a folder of
/// PNG frames of a joint driven (without), an exploded view coming apart and back, and a study
/// that a contact set stops short. The design is not changed.
#[test]
fn an_animation_replies_with_its_schema() {
crate::capture::install_test_renderer();
let mut s = rail();
s.param_check = ParamCheck::Warn;
let dir = out_dir("replies");
let gif = dir.join("push.gif").to_string_lossy().to_string();
let frames = dir.join("frames").to_string_lossy().to_string();
let before = s.doc.clone();
let plain: BTreeSet<&str> =
["path", "format", "frames", "width", "height", "bytes", "renderer", "stopped_at", "stopped_by_loop", "stopped_by_contact"].into();
let played = exported(&mut s, json!({"study": "Push", "path": gif, "samples": 3, "width": 16, "height": 16, "fps": 25}));
assert_eq!(keys(&played), plain.iter().copied().chain(["fps"]).collect::<BTreeSet<_>>(), "{played}");
assert_eq!((&played["format"], &played["frames"], &played["fps"]), (&json!("gif"), &json!(3), &json!(25.0)), "{played}");
assert_eq!((&played["stopped_at"], &played["stopped_by_contact"]), (&Value::Null, &Value::Null), "{played}");
let driven = exported(&mut s, json!({"joint": "Rail", "from": 10, "to": 0, "path": frames, "samples": 2, "width": 16, "height": 16}));
assert_eq!(keys(&driven), plain, "{driven}");
assert_eq!((&driven["format"], &driven["frames"]), (&json!("png"), &json!(2)), "{driven}");
let exploded = exported(&mut s, json!({"view": "Apart", "back": true, "path": gif, "samples": 3, "width": 16, "height": 16}));
assert_eq!(keys(&exploded), plain.iter().copied().chain(["fps"]).collect::<BTreeSet<_>>(), "{exploded}");
assert_eq!(*s.doc, *before, "the design is not changed");
// B stops where it touches A, once the two are a contact set.
s.execute("contact.create", &json!({"occurrences": ["A", "B"]})).unwrap_or_else(|e| panic!("{e}"));
let stopped = exported(&mut s, json!({"study": "Through", "path": gif, "width": 16, "height": 16}));
assert!(stopped["stopped_at"].is_u64() && stopped["stopped_by_contact"].is_object(), "{stopped}");
let _ = std::fs::remove_dir_all(&dir);
}
+8
View File
@@ -49,6 +49,10 @@ pub struct PartsInsertParams {
/// The component's name (default: the part's, such as `Socket Head Cap Screw M6x20`).
#[serde(default)]
pub name: Option<String>,
/// Turn the part over from the way it is placed: with `at`, head into the material and shank
/// out of the face; with `point`, its +Z against `direction` (default false).
#[serde(default)]
pub flip: Option<bool>,
}
/// Change an inserted standard part's size or length, rebuilding it in place; its joints keep it
@@ -215,6 +219,10 @@ pub struct InsertedPart {
/// With `at`: a joint per hole, in pick order.
#[serde(skip_serializing_if = "Option::is_none")]
pub joints: Option<Vec<u64>>,
/// The way each placement's head points (its +Z, from the shank's tip toward the head), in
/// world coordinates and in pick order: the direction that was chosen.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub directions: Vec<[f64; 3]>,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
+1 -1
View File
@@ -21,7 +21,7 @@ fn plate(s: &mut Session) -> [[f64; 3]; 2] {
#[test]
fn schemas_and_the_key_check_agree() {
assert_keys(input_schema_for::<PartsLibraryParams>(), &["family"]);
assert_keys(input_schema_for::<PartsInsertParams>(), &["at", "direction", "family", "length", "name", "point", "size"]);
assert_keys(input_schema_for::<PartsInsertParams>(), &["at", "direction", "family", "flip", "length", "name", "point", "size"]);
assert_keys(input_schema_for::<PartsResizeParams>(), &["component", "length", "size"]);
}
+46
View File
@@ -73,10 +73,20 @@ pub struct InsertDrawingKeys {
/// then also turns and scales about). Default: origin.
#[serde(default)]
pub align: Option<DrawingAlign>,
/// What `width`, `height` and the middle of `align` "center" refer to: the bounds of the
/// drawn geometry ("geometry") or the SVG sheet ("viewbox": its viewBox, else its width and
/// height). Default: geometry. "viewbox" is for SVG drawings that have a sheet; a DXF has none.
#[serde(default)]
pub size_from: Option<DrawingSizeFrom>,
/// The distance in mm within which ends join into one point, 1e-9 to 10 (a value outside is
/// brought inside; one that is not above 0 reads as the default). Default: 0.0001.
#[serde(default)]
pub tolerance: Option<f64>,
/// `true` puts a Fix constraint on every point the drawing adds (as `sketch.constraint.fix`
/// does), so it stays where it is put; a circle's or ellipse's radius stays free, and text and
/// the points the sketch already had are not touched. Default: false.
#[serde(default)]
pub fixed: Option<bool>,
/// Read only when no sketch is being edited: the plane of the new sketch, as `sketch.create`
/// takes it. Default: XY. Ignored (and not checked) while a sketch is being edited.
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -301,6 +311,39 @@ impl<'de> Deserialize<'de> for DrawingAlign {
}
}
/// What an insert's `width` and `height` size, as `size_from` is read (the words are
/// case-sensitive).
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum DrawingSizeFrom {
/// The bounds of the drawn geometry.
Geometry,
/// The SVG sheet: its viewBox, else its width and height.
Viewbox,
}
impl JsonSchema for DrawingSizeFrom {
fn schema_name() -> Cow<'static, str> {
"DrawingSizeFrom".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "What `width` and `height` size: \"geometry\" (the bounds of the drawn geometry) or \"viewbox\" (the SVG sheet: its viewBox, else its width and height). Lower case only.",
"type": "string",
"enum": ["geometry", "viewbox"]
})
}
}
impl<'de> Deserialize<'de> for DrawingSizeFrom {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
match Value::deserialize(d)?.as_str() {
Some("geometry") => Ok(DrawingSizeFrom::Geometry),
Some("viewbox") => Ok(DrawingSizeFrom::Viewbox),
_ => Err(D::Error::custom("must be \"geometry\" or \"viewbox\"")),
}
}
}
/// What `sketch.insert_dxf` and `sketch.insert_svg` return.
#[derive(Debug, Serialize, JsonSchema)]
pub struct DrawingInserted {
@@ -312,6 +355,9 @@ pub struct DrawingInserted {
pub curves: usize,
/// How many sketch texts were added.
pub texts: usize,
/// With `fixed: true`: how many points got a Fix constraint. Left out otherwise.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fixed: Option<usize>,
/// The sketch's solve afterwards; null when it was not solved.
pub info: Option<SketchSolve>,
/// In the warn parameter-check mode: what the call's parameters got wrong.
@@ -19,7 +19,7 @@ fn temp_dir(tag: &str) -> std::path::PathBuf {
#[test]
fn schemas_and_the_key_check_agree() {
let insert = ["align", "angle", "at", "height", "name", "path", "plane", "scale", "sketch", "text", "tolerance", "width"];
let insert = ["align", "angle", "at", "fixed", "height", "name", "path", "plane", "scale", "size_from", "sketch", "text", "tolerance", "width"];
assert_keys(input_schema_for::<InsertDxfParams>(), &insert);
assert_keys(input_schema_for::<InsertSvgParams>(), &insert);
assert_keys(input_schema_for::<ExportDxfParams>(), &["join", "layer", "layers", "path", "sketch", "units"]);
@@ -133,6 +133,9 @@ fn the_types_read_like_the_handler() {
assert_eq!(issues(json!({"text": "x", "height": -1})), ["`height` must be a positive number (mm)"]);
assert_eq!(issues(json!({"text": "x", "angle": "1"})), ["`angle` must be a number (degrees)"]);
assert_eq!(issues(json!({"text": "x", "align": "left"})), ["`align` must be \"origin\" or \"center\""]);
assert_eq!(issues(json!({"text": "x", "size_from": "sheet"})), ["`size_from` must be \"geometry\" or \"viewbox\""]);
assert!(issues(json!({"text": "x", "size_from": "viewbox", "fixed": true})).is_empty());
assert_eq!(issues(json!({"text": "x", "fixed": "yes"})).len(), 1);
assert_eq!(issues(json!({"path": " "})), ["give `path` or `text`"]);
assert_eq!(issues(json!({"text": "x", "sketch": true})), ["`sketch` must be an id or a name"]);
assert_eq!(issues(json!({"text": "x", "wdth": 5})), ["unknown key `wdth`; did you mean `width`?"]);
@@ -226,6 +229,11 @@ fn replies_match_the_output_schema() {
};
let insert_keys: std::collections::BTreeSet<String> = ["created_sketch", "curves", "info", "sketch", "texts"].map(String::from).into();
// A drawing fixed where it is put: the reply says how many points, and matches the schema.
let r = run(&mut s, "sketch.insert_svg", json!({"text": SVG, "fixed": true, "size_from": "geometry"}));
matches(output_schema_for::<DrawingInserted>(), &r);
assert_eq!(r["fixed"], json!(4), "{r}");
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
// With no sketch being edited: a new one is made, on the plane and with the name given.
let r = run(&mut s, "sketch.insert_dxf", json!({"text": DXF, "plane": "XZ", "name": "Plate", "at": [5, 5]}));
matches(output_schema_for::<DrawingInserted>(), &r);
@@ -1482,6 +1482,9 @@ pub struct MirrorResult {
pub struct MoveResult {
/// The curves moved, or with `copy` the copies made, by id.
pub curves: Vec<String>,
/// The constraints tying the moved geometry to what stays that the move let go, as
/// `{id, kind}`.
pub released: Vec<Value>,
/// The sketch's solve: `{solved, dof}`.
pub sketch: Value,
/// In the warn parameter-check mode: what the call's parameters got wrong.
-1
View File
@@ -4,7 +4,6 @@
# per line.
face.delete
file.insert_step
motion.export_animation
render.aging.describe
render.backends
render.effects.describe
@@ -216,6 +216,7 @@ fn comparison_frame(scene: GpuScene, cam: &Camera, width: u32, height: u32, key:
pv_hide: false,
preview_bounds: None,
view_proj: cam.view_proj(f64::from(width) / f64::from(height), 200.0).to_f32(),
turned_depth: None,
back: cam.back().to_f32(),
size_px: [width as f32, height as f32],
clip: [0.0; 4],
+31 -1
View File
@@ -1478,6 +1478,36 @@ mod tests {
/// #9170: a mesh body (an imported STL) is kept in the model cache with the rest of the
/// model, as its own triangles, colour and paint. One mesh body left the whole cache out, so
/// opening the design evaluated every feature again.
/// The names a STEP file gives its colours survive saving a design with its model and opening
/// it from that cache: the kernel bodies' own encoding keeps no names, the records do.
#[test]
fn model_cache_keeps_the_names_of_imported_colours() {
use solidcore_record::Vec3;
let part = solidcraft_kernel::box_solid(Vec3::ZERO, Vec3::new(10., 20., 30.)).unwrap();
let faces = vec![solidcraft_kernel::FacePaint { face: 0, color: [0., 0., 1.], opacity: 1., name: Some("Accent".into()) }];
let named =
part.with_color(Some([0.5, 0.5, 0.5])).with_paint(Some(solidcraft_kernel::Paint { opacity: 1., faces, name: Some("Body grey".into()) }));
let step = solidcraft_kernel::step_export(&[&named], "named").unwrap();
let mut doc = Document::new("Named colours");
doc.add_feature(FeatureKind::Import { file: "named.step".into(), step: step.into(), components: Vec::new(), skip: Vec::new() }, None)
.unwrap();
let mut model = solidcraft_doc::Model::new();
model.evaluate(&doc);
let names = |model: &solidcraft_doc::Model| {
let state = model.state();
let paint = state.bodies[0].body.paint().cloned().unwrap();
(paint.name, paint.faces.iter().map(|f| f.name.clone()).collect::<Vec<_>>())
};
let evaluated = names(&model);
assert_eq!(evaluated, (Some("Body grey".into()), vec![Some("Accent".into())]));
let saved = model.saved(&doc).unwrap();
let bytes = write_design_container_with_cache(&doc, DesignCompression::Fast, None, Some(&saved)).unwrap();
let read = DesignContainerReader::new(Cursor::new(&bytes)).unwrap().geometry().expect("the cache reads back");
assert!(read.bodies.iter().all(|b| b.paint().is_none_or(|p| p.name.is_none())), "the body encoding has no names");
let reopened = solidcraft_doc::Model::adopt(&doc, &read).unwrap();
assert_eq!(names(&reopened), evaluated);
}
#[test]
fn model_cache_keeps_mesh_bodies_with_the_rest_of_the_model() {
use solidcore_record::Vec3;
@@ -1485,7 +1515,7 @@ mod tests {
let tetra = solidcraft_kernel::mesh_body(&corners, &[[0, 2, 1], [0, 1, 3], [0, 3, 2], [1, 2, 3]])
.unwrap()
.with_color(Some([0.25, 0.5, 0.75]))
.with_paint(Some(solidcraft_kernel::Paint { opacity: 0.5, faces: Vec::new() }));
.with_paint(Some(solidcraft_kernel::Paint { opacity: 0.5, faces: Vec::new(), name: None }));
let block = solidcraft_kernel::box_solid(Vec3::new(40., 0., 0.), Vec3::new(60., 20., 20.)).unwrap();
let model = saved_model(vec![block, tetra.clone()]);
let doc = document();
+1 -1
View File
@@ -6,7 +6,7 @@
use super::*;
use solidcraft_doc::{ModelState, SavedModel};
const FORMAT: &str = "solidcraft-model-cache/9";
const FORMAT: &str = "solidcraft-model-cache/10";
const PRODUCER: &str = "solidcraft-truck-adapter/4";
const MANIFEST: &str = "cache/model.json";
const PAYLOAD: &str = "cache/bodies.bin";
+1 -1
View File
@@ -47,7 +47,7 @@ pub use dxf_out::{
pub use legacy_dir::{APP_ID, LEGACY_APP_ID, PREVIOUS_APP_IDS, adopt_legacy_dir, legacy_flatpak_paths};
pub use safe_write::{backup_path, write_atomic};
pub use sketch2d::{Geom2, MAX_DRAWING_BYTES};
pub use svg::{decode_svg, read_svg, svg_nothing_to_import};
pub use svg::{decode_svg, read_svg, svg_nothing_to_import, svg_sheet};
mod gltf_in;
pub use gltf_in::{GltfImport, Loader, MAX_GLTF_BYTES, MeshPlan, PlanPlace, glb_container, is_gltf_path, read_gltf};
mod obj;
+61 -8
View File
@@ -424,6 +424,51 @@ fn not_drawn(name: &str, a: &[(String, String)]) -> bool {
.is_some_and(|s| s.split(';').filter_map(|d| d.split_once(':')).any(|(k, v)| k.trim().eq_ignore_ascii_case("display") && none(v)))
}
/// The root element's transformation (user units to millimetres, y flipped) and its sheet in
/// user units (`viewBox`, else `width` and `height` as a box at the origin), when it has one.
fn root_transform(a: &[(String, String)]) -> (Tf, Option<[f64; 4]>) {
let vb = attr(a, "viewBox").map(numbers).filter(|v| v.len() == 4);
let (w, h) = (attr(a, "width").and_then(length_mm), attr(a, "height").and_then(length_mm));
let (sx, sy, ox, oy, sheet) = match (vb, w, h) {
(Some(v), Some(w), Some(h)) if v[2] > 0.0 && v[3] > 0.0 => (w / v[2], h / v[3], v[0], v[1], Some([v[0], v[1], v[2], v[3]])),
(Some(v), _, _) => (PX_MM, PX_MM, v[0], v[1], (v[2] > 0.0 && v[3] > 0.0).then_some([v[0], v[1], v[2], v[3]])),
(None, Some(w), Some(h)) if w > 0.0 && h > 0.0 => (PX_MM, PX_MM, 0.0, 0.0, Some([0.0, 0.0, w / PX_MM, h / PX_MM])),
_ => (PX_MM, PX_MM, 0.0, 0.0, None),
};
(Tf([sx, 0.0, 0.0, -sy, -ox * sx, oy * sy]), sheet)
}
/// The sheet of an SVG drawing in millimetres, in the coordinates its geometry comes out in: the
/// root's `viewBox` (else its `width` and `height`) through the same transformation. None when
/// the file gives no sheet.
pub fn svg_sheet(text: &str) -> Option<(Vec2, Vec2)> {
if text.len() > MAX_DRAWING_BYTES {
return None;
}
let mut rd = Reader::from_reader(text.as_bytes());
rd.config_mut().trim_text(true);
let mut buf = Vec::new();
let mut elements = 0;
while elements < MAX_ENTITIES {
match rd.read_event_into(&mut buf) {
Ok(Event::Start(e) | Event::Empty(e)) => {
elements += 1;
if e.local_name().as_ref() == b"svg" {
let (tf, sheet) = root_transform(&attrs(&e));
let [x, y, w, h] = sheet?;
let (a, b) = (tf.apply(Vec2::new(x, y)), tf.apply(Vec2::new(x + w, y + h)));
let (lo, hi) = (Vec2::new(a.x.min(b.x), a.y.min(b.y)), Vec2::new(a.x.max(b.x), a.y.max(b.y)));
return (lo.is_finite() && hi.is_finite() && hi.x > lo.x && hi.y > lo.y).then_some((lo, hi));
}
}
Ok(Event::Eof) | Err(_) => return None,
_ => {}
}
buf.clear();
}
None
}
/// Read the geometry of an SVG document (millimetres, y up). `scale` multiplies the result.
pub fn read_svg(text: &str) -> Result<Vec<Geom2>> {
if text.len() > MAX_DRAWING_BYTES {
@@ -490,14 +535,7 @@ pub fn read_svg(text: &str) -> Result<Vec<Geom2>> {
None => parent,
};
if name == "svg" && root.is_none() {
// Root: user units to millimetres, y flipped.
let vb = attr(&a, "viewBox").map(numbers).filter(|v| v.len() == 4);
let (sx, sy, ox, oy) = match (vb, attr(&a, "width").and_then(length_mm), attr(&a, "height").and_then(length_mm)) {
(Some(v), Some(w), Some(h)) if v[2] > 0.0 && v[3] > 0.0 => (w / v[2], h / v[3], v[0], v[1]),
(Some(v), _, _) => (PX_MM, PX_MM, v[0], v[1]),
_ => (PX_MM, PX_MM, 0.0, 0.0),
};
let r = Tf([sx, 0.0, 0.0, -sy, -ox * sx, oy * sy]);
let (r, _) = root_transform(&a);
root = Some(r);
tf = r;
}
@@ -638,6 +676,21 @@ pub fn svg_nothing_to_import(text: &str) -> String {
mod tests {
use super::*;
/// The sheet: the viewBox in the millimetres `width` and `height` give, else in pixels; only
/// `width` and `height` make a sheet at the origin; without either there is none.
#[test]
fn the_sheet_follows_the_root_like_the_geometry() {
let close = |a: Vec2, b: Vec2| (a.x - b.x).abs() < 1e-9 && (a.y - b.y).abs() < 1e-9;
let both = svg_sheet(r#"<svg xmlns="http://www.w3.org/2000/svg" width="100mm" height="50mm" viewBox="0 0 200 100"/>"#).unwrap();
assert!(close(both.0, Vec2::new(0.0, -50.0)) && close(both.1, Vec2::new(100.0, 0.0)), "{both:?}");
let vb = svg_sheet(r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="10 20 200 100"/>"#).unwrap();
assert!(close(vb.0, Vec2::new(0.0, -100.0 * PX_MM)) && close(vb.1, Vec2::new(200.0 * PX_MM, 0.0)), "{vb:?}");
let wh = svg_sheet(r#"<svg xmlns="http://www.w3.org/2000/svg" width="30mm" height="20mm"/>"#).unwrap();
assert!(close(wh.0, Vec2::new(0.0, -20.0)) && close(wh.1, Vec2::new(30.0, 0.0)), "{wh:?}");
assert!(svg_sheet(r#"<svg xmlns="http://www.w3.org/2000/svg"><rect width="3" height="4"/></svg>"#).is_none());
assert!(svg_sheet(r#"<svg viewBox="0 0 0 10"/>"#).is_none(), "a sheet without area is none");
}
/// The parser recurses over groups and reads numbers out of a document the user did not write,
/// so none of these may panic: `catch_unwind` turns one into a failed test, the way
/// `hostile_files_never_panic` does for STEP (solidcraft#469).
+394 -34
View File
@@ -278,8 +278,97 @@ fn fillet_turning(body: &Body, edges: &[Vec3], radius: f64, turn: FilletTurn) ->
pub fn fillet_removing(body: &Body, edges: &[Vec3], radius: f64, turn: FilletTurn) -> Result<(Body, Vec<Vec3>)> {
let _span = solidcraft_profile::span("kernel", "fillet");
body.require_brep("fillet")?;
let (b, gone) = crate::blend_smlib::fillet_removing(body, edges, radius, turn)?;
Ok((closed(body, b)?, gone))
match crate::blend_smlib::fillet_removing(body, edges, radius, turn) {
Ok((b, gone)) => Ok((closed(body, b)?, gone)),
Err(KernelError::TooLarge(why)) => match edges {
[pick] => match concave_overflow_round(body, *pick, radius, turn) {
Some(b) => Ok((b, Vec::new())),
None => Err(KernelError::TooLarge(why)),
},
_ => Err(KernelError::TooLarge(why)),
},
Err(e) => Err(e),
}
}
/// A concave round on one straight edge whose contact line runs off the flat face it rolls on
/// (the face's outline turns away there, into a rounded corner or an opening), which SMLib
/// refuses (solidcore#3252): the round of the edge on the body with that face carried on as a
/// slab behind its plane, over the edge's length, the material that round adds kept within the
/// face's outline (its sheet thickened along its normal) and joined to the body. So the round
/// ends where the face's outline ends, on the surface of whatever the face turns into there,
/// carried on square to the face.
///
/// Only where it gives the round SMLib would build on an unbounded face: the edge is straight,
/// one of its faces is planar and it is concave (material in front of that face on the other
/// face's side), and at each end of the edge a planar face square to it ends the round, so the
/// slab's square ends add no material the round could run into. `None` where it does not apply
/// or a step fails: the caller keeps SMLib's refusal. Decided on exact geometry.
fn concave_overflow_round(body: &Body, pick: Vec3, radius: f64, turn: FilletTurn) -> Option<Body> {
use solidcore_record::{Loop2, Plane, Region2, Vec2};
if !(radius.is_finite() && radius > 0.0) {
return None;
}
let rec = Faces::of(body).ok()?;
let (picked, _) = crate::blend_smlib::picked_edges(body, &[pick]).ok()?;
let [e] = picked.as_slice() else { return None };
let e = *e;
if !rec.straight(e) {
return None;
}
let (a, b) = rec.ends(e)?;
let length = a.dist(b);
let d = (b - a).normalized()?;
let size = body.size();
if !(length > 0.0 && size.is_finite() && size > 0.0) {
return None;
}
let eps = (size * 1e-4).max(1e-4).min(radius * 0.1);
let middle = (a + b) * 0.5;
let [vs, ve] = rec.rec.edges.get(e)?.vertices;
// A planar face square to the edge, other than its own, at vertex v.
let squared_at = |v: u32| {
rec.rec.edges.iter().enumerate().filter(|(_, x)| x.vertices.contains(&v)).any(|(i, _)| {
rec.faces_of(i)
.into_iter()
.any(|f| !rec.faces_of(e).contains(&f) && rec.plane_normal(f as usize).is_some_and(|n| n.dot(d).abs() > 1.0 - 1e-9))
})
};
if !(squared_at(vs) && squared_at(ve)) {
return None;
}
for f in rec.faces_of(e) {
let f = f as usize;
let Some(n) = rec.plane_normal(f) else { continue };
if n.dot(d).abs() > 1e-9 {
continue;
}
let u = n.cross(d);
// concave: material in front of the plane beside the edge, on the other face's side
let inside = |p: Vec3| matches!(body.classify_point(p), Ok(crate::PointClass::Inside));
if !(inside(middle + n * eps + u * eps) || inside(middle + n * eps - u * eps)) {
continue;
}
let far = 4.0 * (size + radius);
let plane = Plane { origin: a, x: d, y: u };
let rect = Loop2::polygon(&[Vec2::new(0.0, -far), Vec2::new(length, -far), Vec2::new(length, far), Vec2::new(0.0, far)]);
let slab = crate::build::extrude(&plane, &[Region2 { outer: rect, holes: vec![] }], -far, 0.0).ok()?.into_iter().next()?;
let carried = crate::boolean(body, &slab, crate::BoolOp::Union).ok()??;
let rounded = crate::blend_smlib::fillet(&carried, &[pick], radius, turn);
let rounded = rounded.ok()?;
let fill = crate::boolean(&rounded, &carried, crate::BoolOp::Cut).ok()??;
// the face itself, by its index, for its sheet
let sheet = crate::surfaces::taken(body, &rec.rec, &[(f, false)]).ok()?;
let prism = crate::thicken(&sheet, 4.0 * (radius + size * 1e-3 + 1.0), true).ok()?;
let kept = crate::boolean(&fill, &prism, crate::BoolOp::Intersect).ok()??;
let joined = crate::boolean(body, &kept, crate::BoolOp::Union).ok()??;
let joined = closed(body, joined).ok()?;
let grew = |x: &Body| crate::measure(x).ok().map(|m| m.volume);
if grew(&joined)? > grew(body)? {
return Some(over_body_alone(joined, body, e));
}
}
None
}
/// What a refusal of a setback too short for its corner says before the least that builds (in
@@ -301,6 +390,61 @@ pub fn fillet_setback(body: &Body, edges: &[Vec3], radius: f64, setbacks: &[(Vec
crate::blend_smlib::fillet_setback(body, edges, radius, setbacks).and_then(|b| closed(body, b))
}
/// `joined` (the union of `body` and the material a round of its edge `edge` adds, its history
/// over those two) with its history over `body` alone, as a fillet reports it: a face or an
/// edge of the body stays that; a face only the added material brought is made in place of
/// the edge, and an edge where such a face meets a face of the body is where the edge's round
/// meets that face. Without a history where the union reported none or the rewritten one does
/// not describe the body: a result is never given a guessed history.
fn over_body_alone(joined: Body, body: &Body, edge: usize) -> Body {
use crate::history::{Change, EdgeOrigin, EdgeProv, FaceOrigin, FaceProv, History, Side};
let Some(union) = joined.history().filter(|h| h.operands == 2).cloned() else { return joined.without_history() };
let round = FaceOrigin::Edge { operand: 0, edge };
// An origin in the body stays; one in the added material is the round.
let of_body = |o: &FaceOrigin| match *o {
FaceOrigin::Face { operand, .. } | FaceOrigin::Edge { operand, .. } | FaceOrigin::Offset { operand, .. } => operand == 0,
FaceOrigin::Profile { .. } | FaceOrigin::Cap { .. } => false,
};
let faces = union
.faces
.iter()
.map(|f| {
let origins: Vec<FaceOrigin> = f.origins.iter().filter(|o| of_body(o)).copied().collect();
if origins.is_empty() {
return FaceProv { origins: vec![round], change: Change::Generated };
}
// A face of the body the added material's face was joined to is that face, grown.
let change = if f.change == Change::Merged && origins.len() == 1 { Change::Modified } else { f.change };
FaceProv { origins, change }
})
.collect();
let side = |s: &Side| Side::new(s.origins().iter().map(|o| if of_body(o) { *o } else { round }));
let edges = union
.edges
.iter()
.map(|e| {
let origins = e
.origins
.iter()
.filter_map(|o| match o {
EdgeOrigin::Edge { operand: 0, .. } => Some(o.clone()),
EdgeOrigin::Edge { .. } => None,
EdgeOrigin::Meet { a, b } => match (side(a), side(b)) {
(Some(a), Some(b)) if a != b => Some(EdgeOrigin::meet(a, b)),
_ => None,
},
})
.collect();
EdgeProv { faces: e.faces.clone(), origins }
})
.collect();
let history = History { operands: 1, faces, edges };
match history.check_counts((joined.face_count(), joined.edge_count()), &crate::record::counts(&[body])) {
Ok(()) => joined.clone().with_history_counts(history, &crate::record::counts(&[body])).unwrap_or_else(|_| joined.without_history()),
Err(_) => joined.without_history(),
}
}
/// Refuses a setback that does not reach past where its corner's rounds meet without one
/// ([`setback_minimum`]), naming the least it must exceed, at a corner where every edge is one
/// of `edges` (the others are SMLib's to refuse). SMLib fails there with its own status
@@ -541,31 +685,41 @@ fn radius_step(radius: f64) -> f64 {
/// The largest radius below `radius` (to three significant digits) at which the fillet of
/// these edges works, with its result; `Ok(None)` when none does. Each candidate is a real fillet
/// of the edges, so the radius returned is one that was built; the search runs up to 16 of
/// them (a refusal of a fillet feature's exact fit runs it to name the largest that fits, and
/// so does `fit: reduce`). Only a refusal as too large
/// ([`KernelError::TooLarge`]) narrows the search: any other failure of a candidate is the
/// them, plus up to 6 led by the sizes the refusals name (a refusal of a fillet feature's exact
/// fit runs it to name the largest that fits, and so does `fit: reduce`). Only a refusal as too
/// large ([`KernelError::TooLarge`]) narrows the search: any other failure of a candidate is the
/// search's error, since a smaller radius would not be the largest that fits. The rounds turn at
/// side edges as `turn` says.
pub fn fillet_max(body: &Body, edges: &[Vec3], radius: f64, turn: FilletTurn) -> Result<Option<(f64, Body)>> {
fillet_max_why(body, edges, radius, turn).map(|search| search.found)
fillet_max_why(body, edges, radius, turn, None).map(|search| search.found)
}
/// [`fillet_max`], with what the search found when nothing builds ([`FitSearch`]).
pub fn fillet_max_why(body: &Body, edges: &[Vec3], radius: f64, turn: FilletTurn) -> Result<FitSearch> {
/// [`fillet_max`], with what the search found when nothing builds ([`FitSearch`]). `hint`: the
/// largest radius the refusal of `radius` named ([`named_fit`]), tried first.
pub fn fillet_max_why(body: &Body, edges: &[Vec3], radius: f64, turn: FilletTurn, hint: Option<f64>) -> Result<FitSearch> {
let _span = solidcraft_profile::span("kernel", "fillet_max");
largest_that_fits(radius, |r| fillet_turning(body, edges, r, turn))
largest_that_fits(radius, hint, |r| fillet_turning(body, edges, r, turn))
}
/// [`fillet_max`] for an equal-distance chamfer of these edges: the largest distance below
/// `distance` (to three significant digits) at which it works, with its result.
pub fn chamfer_max(body: &Body, edges: &[Vec3], distance: f64) -> Result<Option<(f64, Body)>> {
chamfer_max_why(body, edges, distance).map(|search| search.found)
chamfer_max_why(body, edges, distance, None).map(|search| search.found)
}
/// [`chamfer_max`], with what the search found when nothing builds ([`FitSearch`]).
pub fn chamfer_max_why(body: &Body, edges: &[Vec3], distance: f64) -> Result<FitSearch> {
/// [`chamfer_max`], with what the search found when nothing builds ([`FitSearch`]). `hint` as
/// for [`fillet_max_why`].
pub fn chamfer_max_why(body: &Body, edges: &[Vec3], distance: f64, hint: Option<f64>) -> Result<FitSearch> {
let _span = solidcraft_profile::span("kernel", "chamfer_max");
largest_that_fits(distance, |d| chamfer(body, edges, d))
largest_that_fits(distance, hint, |d| chamfer(body, edges, d))
}
/// The largest size in mm a refusal names ([`largest_fit_note`]): 3 for "...; the largest radius
/// that fits is 3 mm". `None` where it names none, or names a scale ("2 times the distances").
pub fn named_fit(why: &str) -> Option<f64> {
let (_, rest) = why.split_once("that fits is ")?;
let (number, _) = rest.split_once(" mm")?;
number.parse::<f64>().ok().filter(|v| v.is_finite() && *v > 0.0)
}
/// What [`largest_that_fits`] found: the largest size it built, with its result, or (none built)
@@ -576,17 +730,29 @@ pub struct FitSearch {
pub found: Option<(f64, Body)>,
/// When nothing built: the smallest size tried and how it failed.
pub smallest_refused: Option<(f64, KernelError)>,
/// How many candidates were built or refused (the search's work).
pub tried: usize,
}
/// The largest size n·q below `size` (q: three significant digits of it) at which `blend` works,
/// by bisection over a bounded number of blends. A candidate refused as too large or failing as an
/// operation (its check refusing the result, say) narrows the search: a size that does not build is
/// not one that fits, so the size found is one that was built. With none built, the smallest size
/// tried and its failure are kept ([`FitSearch::smallest_refused`]). Bad input and the kernel's own
/// faults are errors, as before.
fn largest_that_fits(size: f64, blend: impl Fn(f64) -> Result<Body>) -> Result<FitSearch> {
/// over a bounded number of blends. A candidate refused as too large or failing as an operation
/// (its check refusing the result, say) narrows the search: a size that does not build is not one
/// that fits, so the size found is one that was built, and (unless the cap stops the search) the
/// next size up, n·q + q, was refused. With none built, the smallest size tried and its failure are
/// kept ([`FitSearch::smallest_refused`]). Bad input and the kernel's own faults are errors.
///
/// The search brackets the largest size between the largest built (`lo`) and the smallest refused
/// (`hi`), and each candidate narrows the bracket, by bisection where nothing better is known. A
/// refusal as too large names the largest size it estimates fits ([`named_fit`]; `hint` for the
/// request's own refusal), usually within a step or two of it: that size is tried next (unless it
/// is within 1% of the size refused, which says nothing of where the largest lies), and once
/// it builds the sizes above it are tried 1, 2, 4, ... steps up until one is refused, so a
/// close estimate settles the search in two or three blends where bisection takes eleven or
/// more. Where building is monotone in the size (everything below the largest that fits builds),
/// the size found is the one bisection finds; it never depends on how the bracket narrowed.
fn largest_that_fits(size: f64, hint: Option<f64>, blend: impl Fn(f64) -> Result<Body>) -> Result<FitSearch> {
if !(size.is_finite() && size > 1e-6) {
return Ok(FitSearch { found: None, smallest_refused: None });
return Ok(FitSearch { found: None, smallest_refused: None, tried: 0 });
}
let q = radius_step(size);
// Sizes n·q for n in [0, hi): hi is too large (it is the request, or above it), lo works.
@@ -594,22 +760,64 @@ fn largest_that_fits(size: f64, blend: impl Fn(f64) -> Result<Body>) -> Result<F
let mut lo = 0u64;
let mut best: Option<Body> = None;
let mut smallest_refused: Option<(f64, KernelError)> = None;
// log2(10⁴) steps reach the step size; the cap keeps a slow body from stalling.
for _ in 0..16 {
if hi - lo <= 1 {
break;
}
let mid = lo + (hi - lo) / 2;
// The step on the grid at or below an estimate (a hair over, so 1.24 / 0.001 is 1240), for an
// estimate that says where the largest size lies: one at least 1% below the size refused. An
// estimate a margin below it (2.99 for 3: solidcore names the size less a margin where the
// ball is as wide as a face it rolls on) says only that the size was refused, and the blend
// just under such a limit can be the slowest to try: on the engine fuzzer's seed-2 design a
// 2.99 mm round ran 834 s before it failed, where bisection's 1.5 mm took 2 s.
let lead = |estimate: f64, refused: f64| (estimate < 0.99 * refused).then(|| (estimate / q * (1.0 + 1e-12)).floor() as u64);
let mut next_hint = hint.and_then(|e| lead(e, size));
// Steps up from `lo` while climbing from a built estimate.
let mut climb: Option<u64> = None;
// log2(10⁴) bisection steps reach the step size, and the estimates lead at most 6 more; the
// caps keep a slow body from stalling.
let (mut bisections, mut led, mut tried) = (0, 0, 0);
while hi - lo > 1 {
let guided = if led < 6 {
match next_hint.take().filter(|h| lo < *h && *h < hi) {
Some(h) => Some((h, true)),
None => climb.map(|step| (lo.saturating_add(step).min(hi - 1), false)),
}
} else {
None
};
let (n, from_hint) = match guided {
Some(g) => {
led += 1;
g
}
None if bisections < 16 => {
bisections += 1;
(lo + (hi - lo) / 2, false)
}
None => break,
};
let was_guided = guided.is_some();
tried += 1;
// A candidate fits only as a body SMLib's check accepts: each is checked as it is built,
// whatever the evaluation's validation mode (`crate::validation`).
match crate::validation::validating(crate::Validation::EachStep, || blend(mid as f64 * q)).0 {
match crate::validation::validating(crate::Validation::EachStep, || blend(n as f64 * q)).0 {
Ok(b) => {
lo = mid;
let step = n - lo;
lo = n;
best = Some(b);
// Climb from a built estimate, doubling the step each time one builds.
climb = if from_hint {
Some(1)
} else if was_guided {
Some(step.saturating_mul(2))
} else {
None
};
}
Err(e @ (KernelError::TooLarge(_) | KernelError::Failed(_))) => {
hi = mid;
smallest_refused = Some((mid as f64 * q, e));
hi = n;
climb = None;
if let KernelError::TooLarge(why) = &e {
next_hint = named_fit(why).and_then(|est| lead(est, n as f64 * q));
}
smallest_refused = Some((n as f64 * q, e));
}
// Not a size the blend could not take: bad input, or the kernel's own fault.
Err(e) => return Err(e),
@@ -617,7 +825,7 @@ fn largest_that_fits(size: f64, blend: impl Fn(f64) -> Result<Body>) -> Result<F
}
let found = best.map(|b| (lo as f64 * q, b));
let smallest_refused = if found.is_some() { None } else { smallest_refused };
Ok(FitSearch { found, smallest_refused })
Ok(FitSearch { found, smallest_refused, tried })
}
/// [`fillet_max`] for each group of the edges that may limit each other's radius (sharing a
@@ -1202,12 +1410,80 @@ mod tests {
use super::*;
use solidcore_record::{Loop2, Plane, Region2, Vec2};
/// The plate with its r16 corner, boss and cut (SolidCraft's filletfile.sc).
fn boss_on_rounded_plate() -> Body {
let plate = crate::box_solid(Vec3::new(0.0, -10.5, -100.0), Vec3::new(48.5, 0.0, 0.0)).unwrap();
let plate = fillet(&plate, &[Vec3::new(48.5, -5.25, 0.0)], 16.0).unwrap();
let boss = crate::cylinder(Vec3::new(14.388009991673611, -14.038301415494109, -24.0), Vec3::Z, 12.90348790056394, 24.0).unwrap();
let body = crate::boolean(&plate, &boss, crate::BoolOp::Union).unwrap().unwrap();
let cut = crate::box_solid(Vec3::new(-1.0, -11.0, -64.0), Vec3::new(26.796894836538296, 1.0, -24.0)).unwrap();
crate::boolean(&body, &cut, crate::BoolOp::Cut).unwrap().unwrap()
}
/// The boss's concave base edge on the rounded plate: rounded 5 (its contact line on the
/// plate's front face stays short of x 32.5, where the face's top edge turns into the r16
/// corner's arc) SMLib builds it; rounded 6 and 8 the line runs past there and SMLib
/// refuses it (solidcore#3252), and the round is built by carrying the face on: its
/// material is the round's section between the plane and the boss (exact, by integration
/// below) over the boss's 24 mm, less the sliver past the arc. At 5 the carried-on build
/// gives SMLib's volume. Closed, one shell, inside the plate's box.
#[test]
fn a_concave_round_runs_on_past_where_its_face_turns_into_a_round() {
let body = boss_on_rounded_plate();
let before = crate::measure(&body).unwrap();
let pick = Vec3::new(26.796894836538296, -10.5, -12.0);
// the round's section between the plate's plane, the boss's circle and the round's arc
let section = |r: f64| {
let (cx, cy, big, yp) = (14.388009991673611, -14.038301415494109, 12.90348790056394, -10.5);
let (bx, by) = (cx + ((big + r).powi(2) - (yp - r - cy).powi(2)).sqrt(), yp - r);
let k = big / (big + r);
let (tx, ty) = (cx + (bx - cx) * k, cy + (by - cy) * k);
let ex = cx + (big * big - (yp - cy).powi(2)).sqrt();
let mut pts = vec![(ex, yp), (bx, yp)];
let n = 4000;
let (a0, a1) = ((yp - by).atan2(0.0), (ty - by).atan2(tx - bx));
pts.extend((1..=n).map(|i| {
let t = a0 + (a1 - a0) * f64::from(i) / f64::from(n);
(bx + r * t.cos(), by + r * t.sin())
}));
let (c0, c1) = ((ty - cy).atan2(tx - cx), (yp - cy).atan2(ex - cx));
pts.extend((1..n).map(|i| {
let t = c0 + (c1 - c0) * f64::from(i) / f64::from(n);
(cx + big * t.cos(), cy + big * t.sin())
}));
let twice: f64 = pts.iter().zip(pts.iter().cycle().skip(1)).map(|(p, q)| p.0 * q.1 - q.0 * p.1).sum();
twice.abs() / 2.0
};
let smlib = crate::measure(&fillet(&body, &[pick], 5.0).unwrap()).unwrap().volume;
let (carried, _) = fillet_removing(&body, &[pick], 5.0, FilletTurn::Mitre).unwrap();
assert!((crate::measure(&carried).unwrap().volume - smlib).abs() < 1e-6);
assert!((smlib - before.volume - section(5.0) * 24.0).abs() < 1e-3, "{}", smlib - before.volume);
for r in [6.0, 8.0] {
assert!(matches!(fillet(&body, &[pick], r), Err(KernelError::TooLarge(_))), "SMLib refuses r {r}");
let (rounded, gone) = fillet_removing(&body, &[pick], r, FilletTurn::Mitre).unwrap_or_else(|e| panic!("r {r}: {e}"));
assert!(gone.is_empty());
// its history is a fillet's, over the body alone: a face made in place of the edge
let (picked, _) = crate::blend_smlib::picked_edges(&body, &[pick]).unwrap();
let h = rounded.history().expect("a history");
assert_eq!(h.operands, 1);
assert!(h.faces.iter().any(|f| f.origins == [crate::history::FaceOrigin::Edge { operand: 0, edge: picked[0] }]), "r {r}");
// and every face of the body is still there, under its own origin
let still = |i: usize| h.faces.iter().any(|f| f.origins.contains(&crate::history::FaceOrigin::Face { operand: 0, face: i }));
assert!((0..body.face_count()).all(still), "r {r}");
let m = crate::measure(&rounded).unwrap();
let added = m.volume - before.volume;
let full = section(r) * 24.0;
assert!(m.shells == 1 && (full - added).abs() < 0.05, "r {r}: added {added} of {full}");
assert!(m.bbox.min.dist(before.bbox.min) < 1e-9 && m.bbox.max.dist(before.bbox.max) < 1e-9, "r {r}: {:?}", m.bbox);
}
}
/// A search where every candidate is refused as too large keeps the smallest size it tried
/// and that candidate's own refusal, and builds nothing: the caller can say why nothing
/// smaller builds instead of repeating the first refusal's estimate.
#[test]
fn a_search_that_builds_nothing_keeps_its_smallest_refusal() {
let search = largest_that_fits(0.3, |d| Err(KernelError::TooLarge(format!("refused at {d}")))).unwrap();
let search = largest_that_fits(0.3, None, |d| Err(KernelError::TooLarge(format!("refused at {d}")))).unwrap();
assert!(search.found.is_none());
let (at, e) = search.smallest_refused.expect("a refusal");
assert!(at > 0.0 && at < 0.001, "the bisection goes down to the step: {at}");
@@ -1218,7 +1494,8 @@ mod tests {
#[test]
fn a_search_that_builds_reports_what_it_built() {
let body = crate::build::box_solid(Vec3::ZERO, Vec3::new(1.0, 1.0, 1.0)).unwrap();
let search = largest_that_fits(0.3, |d| if d <= 0.1 { Ok(body.clone()) } else { Err(KernelError::TooLarge("too large".into())) }).unwrap();
let search =
largest_that_fits(0.3, None, |d| if d <= 0.1 { Ok(body.clone()) } else { Err(KernelError::TooLarge("too large".into())) }).unwrap();
let (at, _) = search.found.expect("built");
assert!((0.099..=0.1).contains(&at), "{at}");
assert!(search.smallest_refused.is_none());
@@ -1230,11 +1507,94 @@ mod tests {
fn a_candidate_failing_the_check_narrows_the_search() {
let body = crate::build::box_solid(Vec3::ZERO, Vec3::new(1.0, 1.0, 1.0)).unwrap();
let search =
largest_that_fits(1.0, |r| if r <= 0.95 { Ok(body.clone()) } else { Err(KernelError::Failed("the check refuses it".into())) }).unwrap();
largest_that_fits(1.0, None, |r| if r <= 0.95 { Ok(body.clone()) } else { Err(KernelError::Failed("the check refuses it".into())) })
.unwrap();
let (at, _) = search.found.expect("built");
assert!((0.949..=0.95).contains(&at), "{at}");
}
/// A refusal that names an estimate within a step of the largest size that fits settles the
/// search in two blends (the estimate builds, the next step up is refused) where bisection
/// takes eleven, and finds the same size. Work counter: `FitSearch::tried`.
#[test]
fn a_close_estimate_settles_the_search_in_two_blends() {
let body = crate::build::box_solid(Vec3::ZERO, Vec3::new(1.0, 1.0, 1.0)).unwrap();
// Builds up to 1.904 mm (q = 0.001 for a 2 mm request); a refusal estimates 1.904762.
// Sizes are n·q, as the search builds them.
let blend = |r: f64| {
if r <= 1.9045 { Ok(body.clone()) } else { Err(KernelError::TooLarge(format!("too large; {}", largest_fit_note("radius", 1.904762)))) }
};
// Bisection alone: the same refusals, naming no estimate.
let unnamed = |r: f64| if r <= 1.9045 { Ok(body.clone()) } else { Err(KernelError::TooLarge("too large".into())) };
let plain = largest_that_fits(2.0, None, unnamed).unwrap();
let led = largest_that_fits(2.0, Some(1.904762), blend).unwrap();
assert_eq!(plain.found.as_ref().map(|f| f.0), Some(1904.0 * 0.001));
assert_eq!(led.found.as_ref().map(|f| f.0), Some(1904.0 * 0.001));
assert!(plain.tried >= 11, "bisection alone: {}", plain.tried);
assert_eq!(led.tried, 2);
// With no estimate of its own, the first candidate refused leads with its estimate.
let after_one = largest_that_fits(2.0, None, blend).unwrap();
assert_eq!(after_one.found.as_ref().map(|f| f.0), Some(1904.0 * 0.001));
assert!(after_one.tried < plain.tried, "{} against {}", after_one.tried, plain.tried);
}
/// The seed-2 design of the engine's fuzzer refused a 3 mm round naming 2.99 mm (the size less
/// a margin: the ball was as wide as a face it rolls on), while its candidates' refusals named
/// 1.24 mm against 1.25 that builds. The 2.99 is not followed (a 2.99 mm round there ran 834 s
/// before it failed): the search bisects to 1.5 first, then follows 1.24 up a step, then 2,
/// 4, ... and finds 1.25 as bisection does, in fewer blends. Over every estimate, above or
/// below the size, the answer is bisection's.
#[test]
fn estimates_below_or_above_the_size_find_what_bisection_finds() {
let body = crate::build::box_solid(Vec3::ZERO, Vec3::new(1.0, 1.0, 1.0)).unwrap();
// A refusal naming `names` (none where it is NaN), of a round that builds up to `fits`.
let refusing = |fits: f64, names: f64| {
let body = body.clone();
move |r: f64| {
if r <= fits + 1e-9 {
Ok(body.clone())
} else if names.is_nan() {
Err(KernelError::TooLarge("too large".into()))
} else {
Err(KernelError::TooLarge(format!("too large; {}", largest_fit_note("radius", names))))
}
}
};
let plain = largest_that_fits(3.0, None, refusing(1.25, f64::NAN)).unwrap();
let tried = std::cell::RefCell::new(Vec::new());
let seed2 = refusing(1.25, 1.24);
let led = largest_that_fits(3.0, Some(2.99), |r| {
tried.borrow_mut().push(r);
seed2(r)
})
.unwrap();
assert_eq!(tried.borrow().first().copied(), Some(1500.0 * 0.001), "{:?}", tried.borrow());
assert!(tried.borrow().iter().all(|r| *r < 2.0), "{:?}", tried.borrow());
assert_eq!(plain.found.as_ref().map(|f| f.0), Some(1250.0 * 0.001));
assert_eq!(led.found.as_ref().map(|f| f.0), Some(1250.0 * 0.001));
assert!(led.tried < plain.tried, "{} against {}", led.tried, plain.tried);
// Every size the estimates may point at, over and under: the same answer as bisection.
for fits in [0.5, 1.0, 1.25, 1.999, 2.5, 2.998] {
let plain = largest_that_fits(3.0, None, refusing(fits, f64::NAN)).unwrap().found.map(|f| f.0);
for names in [0.001, 0.4, fits - 0.01, fits - 0.001, fits, fits + 0.001, fits + 0.2, 2.99, 5.0] {
for hint in [None, Some(names), Some(2.99)] {
let led = largest_that_fits(3.0, hint, refusing(fits, names)).unwrap();
assert_eq!(led.found.map(|f| f.0), plain, "fits {fits}, refusals name {names}, hint {hint:?}");
assert!(led.tried <= 22, "{}", led.tried);
}
}
}
}
/// A refusal names its estimate as the largest size that fits, and only then.
#[test]
fn a_refusal_names_its_estimate() {
assert_eq!(named_fit("too large, at the edge at (1, 2, 3); the largest radius that fits is 1.904762 mm (tangent chain)"), Some(1.904762));
assert_eq!(named_fit(&format!("x; {}", largest_fit_note("distance", 0.25))), Some(0.25));
assert_eq!(named_fit("too large; the largest that fits is 2 times the distances given"), None);
assert_eq!(named_fit("the trim leaks"), None);
}
/// The largest-fit clause comes out of a refusal with its separator, and nothing else does.
#[test]
fn the_fit_note_comes_out_with_its_separator() {
+12
View File
@@ -481,6 +481,18 @@ mod imp {
KernelError::TooLarge(format!("the {what} is larger than the faces beside its edges allow{beside}; {fits}"))
}
SmError::Unported(why) => KernelError::Invalid(format!("not supported yet: {why}")),
// Not too large: a longer setback. Named as building only where solidcore's fill
// is the corner; where another patch replaces it (a G2 corner) it is what
// solidcore's corner needs, which that patch may not fit.
SmError::SetbackTooShort { least, at, builds } => {
let at = at.map(|[x, y, z]| format!(" at ({x:.3}, {y:.3}, {z:.3})")).unwrap_or_default();
let least = crate::blend::trimmed_mm(least);
KernelError::Invalid(if builds {
format!("the setback{at} of the {what} is too short for its corner's fill; {}{least} mm", crate::blend::SETBACK_AT_LEAST)
} else {
format!("the setback{at} of the {what} is too short for its corner's fill: solidcore's corner needs a setback of at least {least} mm")
})
}
e => failed(what, "operation", e),
})?;
// Checked here unless the evaluation's validation mode leaves it to the feature's end
+25 -4
View File
@@ -240,14 +240,32 @@ pub struct Paint {
/// 1 = opaque.
pub opacity: f32,
pub faces: Vec<FacePaint>,
/// The body colour's name in the file it came from (a STEP `COLOUR_RGB` name), if it had one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
}
/// A face's own colour: the B-rep face index (`face_iter` order), sRGB as 0..1, opacity.
/// A face's own colour: the B-rep face index (`face_iter` order), sRGB as 0..1, opacity, and
/// its name in the file it came from, if it had one.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct FacePaint {
pub face: usize,
pub color: [f32; 3],
pub opacity: f32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
}
/// The most characters a colour name read from a file keeps (as a design look's name).
pub const COLOUR_NAME_MAX: usize = 128;
/// A colour name read from a file, made safe to show and keep: control characters dropped,
/// surrounding space trimmed, at most [`COLOUR_NAME_MAX`] characters. `None` when nothing is left.
pub fn colour_name(raw: &str) -> Option<String> {
let cleaned: String = raw.chars().filter(|c| !c.is_control()).collect();
let name: String = cleaned.trim().chars().take(COLOUR_NAME_MAX).collect();
let name = name.trim_end();
(!name.is_empty()).then(|| name.to_string())
}
/// A B-rep edge as seen by the rest of the application.
@@ -980,21 +998,24 @@ impl Body {
self.paint.as_deref()
}
/// The same body with opacity and face colours for export (`None` or opaque with no face
/// colours clears them).
/// The same body with opacity, face colours and colour names for export (`None`, or opaque
/// with no face colours and no name, clears them).
pub fn with_paint(mut self, p: Option<Paint>) -> Body {
let clean = |x: f32| if x.is_finite() { x.clamp(0.0, 1.0) } else { 1.0 };
self.paint = p
.map(|mut p| {
p.opacity = clean(p.opacity);
p.name = p.name.as_deref().and_then(colour_name);
p.faces.retain(|f| f.color.iter().all(|x| x.is_finite()));
for f in &mut p.faces {
f.color = f.color.map(|x| x.clamp(0.0, 1.0));
f.opacity = clean(f.opacity);
f.name = f.name.as_deref().and_then(colour_name);
}
p
})
.filter(|p| p.opacity < 1.0 || !p.faces.is_empty())
// An opaque body colour's name is kept with it: the paint holds it.
.filter(|p| p.opacity < 1.0 || !p.faces.is_empty() || p.name.is_some())
.map(std::sync::Arc::new);
self.regularization_protected |= self.paint().is_some_and(|paint| !paint.faces.is_empty());
self
+17
View File
@@ -372,3 +372,20 @@ fn curvature_rounds_meet_at_a_g2_corner() {
let (a, b) = (measure(&g2).unwrap().volume, measure(&smlib_corner).unwrap().volume);
assert!((a - b).abs() < 7.5f64.powi(3), "{a} vs {b}");
}
/// fillets/every-box-corner-three-edges-r3-curvature-setback-3-5 (tangency-labs/solidcraft#13913):
/// the 40 x 30 x 20 box's corner (40, 30, 20), its three edges rounded r3, set back 3.5. The
/// corner's fill is certified convex only from a setback of 3.5595 (solidcore's
/// `SetbackTooShort`, from the fill's convex test), so 3.5 is refused naming it. The G2 patch
/// replaces solidcore's fill here, so 3.5595 is named as what solidcore's corner needs, never as
/// a setback that builds: the patch does not fit there (it misses the rounds' curvature by
/// 2.09e-3 against 1.67e-3, measured).
#[test]
fn a_setback_too_short_for_the_fill_names_what_solidcores_corner_needs() {
let body = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)).unwrap();
let edges = [Vec3::new(20.0, 30.0, 20.0), Vec3::new(40.0, 15.0, 20.0), Vec3::new(40.0, 30.0, 10.0)];
let corner = Vec3::new(40.0, 30.0, 20.0);
let short = fillet_g2_corners(&body, &edges, 3.0, &[(corner, 3.5)]).unwrap_err().to_string();
assert!(short.contains("is too short for its corner's fill: solidcore's corner needs a setback of at least 3.5595 mm"), "{short}");
assert!(!short.contains("builds"), "{short}");
}
+19
View File
@@ -74,3 +74,22 @@ fn a_setback_not_past_the_radius_is_refused_naming_the_least() {
assert!(least.is_some_and(|l| (l - 5.0).abs() < 1e-9), "{least:?}");
assert_eq!(crate::fillet_least_setback(&b, Vec3::new(30.0, 20.0, 10.0), 5.0).unwrap(), None);
}
/// tangency-labs/solidcraft#13913: the 40 x 30 x 20 box's corner (40, 30, 20), its three edges
/// rounded r3, set back 3.5. The corner's fill (solidcore's own here, no patch replaces it) is
/// certified convex only from a setback of 3.5595 (solidcore's `SetbackTooShort`, from the fill's
/// convex test), so 3.5 is refused naming it as the smallest that builds; set back that far, it
/// builds, a valid body.
#[test]
fn a_setback_too_short_for_the_fill_is_refused_naming_the_one_that_builds() {
let b = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)).unwrap();
let edges = [Vec3::new(20.0, 30.0, 20.0), Vec3::new(40.0, 15.0, 20.0), Vec3::new(40.0, 30.0, 10.0)];
let corner = Vec3::new(40.0, 30.0, 20.0);
let why = match fillet_setback(&b, &edges, 3.0, &[(corner, 3.5)]) {
Err(KernelError::Invalid(why)) => why,
other => panic!("setback 3.5: {:?}", other.map(|b| b.face_count())),
};
assert!(why.contains(&format!("{SETBACK_AT_LEAST}3.5595 mm")), "{why}");
let out = fillet_setback(&b, &edges, 3.0, &[(corner, 3.5595)]).unwrap();
assert!(out.validity().is_empty(), "{:?}", out.validity());
}
+3 -3
View File
@@ -159,13 +159,13 @@ pub mod watch;
pub use blend::{
ChamferSide, FilletTurn, FitSearch, SETBACK_AT_LEAST, blend_edges, chamfer, chamfer_max, chamfer_max_why, chamfer_sides, fillet, fillet_each,
fillet_g2, fillet_g2_corners, fillet_g2_with_g2_corners, fillet_least_setback, fillet_max, fillet_max_each, fillet_max_why, fillet_removing,
fillet_setback, largest_fit_note, names_largest_fit, tangent_chain, with_fit_note, without_fit_note,
fillet_setback, largest_fit_note, named_fit, names_largest_fit, tangent_chain, with_fit_note, without_fit_note,
};
pub use blend_corner::fillet_radii;
pub use blend_variable::{RadiusSpline, Taper, fillet_chord, fillet_tapered, fillet_variable, fillet_variable_points, fillet_variable_spline, taper};
pub use body::{
Body, CapSignature, DISPLAY_ANGLE, DisplayShare, EdgeInfo, FaceInfo, FacePaint, FaceSource, MeshTolerance, Paint, PointClass, Refusal,
SurfaceKind,
Body, COLOUR_NAME_MAX, CapSignature, DISPLAY_ANGLE, DisplayShare, EdgeInfo, FaceInfo, FacePaint, FaceSource, MeshTolerance, Paint, PointClass,
Refusal, SurfaceKind, colour_name,
};
pub use boundary::{Boundary, boundary_patch};
pub use boundary_network::{BoundaryCurve, CurveSource, MAX_CHAIN_CURVES, MAX_EDGE_PICKS, boundary_join, boundary_surface};
+5 -1
View File
@@ -457,7 +457,11 @@ fn mirrored_copy_keeps_every_face_and_edge() {
/// The block with a face painted: an authored boundary, so the cuts leave the faces they split
/// across their slabs in pieces (none of them joined on their common supports).
fn painted_cube() -> Body {
cube().with_paint(Some(crate::Paint { opacity: 1., faces: vec![crate::FacePaint { face: 0, color: [1., 0., 0.], opacity: 1. }] }))
cube().with_paint(Some(crate::Paint {
opacity: 1.,
faces: vec![crate::FacePaint { face: 0, color: [1., 0., 0.], opacity: 1., name: None }],
name: None,
}))
}
/// The block `b` with box cuts of each common shape: a notch through the back, a pocket, a
+7 -4
View File
@@ -331,7 +331,8 @@ mod tests {
let extruded = crate::extrude(&Plane::XY, &[region], 0., 5.).unwrap().remove(0);
assert!(extruded.face_sources().is_some_and(|s| s.iter().any(Option::is_some)), "an extrusion carries its face sources");
assert!(cut.history().is_some(), "a cut carries its history");
let paint = crate::Paint { opacity: 0.5, faces: vec![crate::FacePaint { face: 1, color: [0.9, 0.1, 0.2], opacity: 0.75 }] };
let paint =
crate::Paint { opacity: 0.5, faces: vec![crate::FacePaint { face: 1, color: [0.9, 0.1, 0.2], opacity: 0.75, name: None }], name: None };
let painted = cube.clone().with_color(Some([0.1, 0.5, 0.9])).with_paint(Some(paint));
let top = (0..cube.face_count()).find(|f| cube.planar_face(*f).is_some_and(|p| p.normal().z > 0.5 && p.origin.z > 9.)).unwrap();
let split =
@@ -452,9 +453,11 @@ mod tests {
#[test]
fn native_preserves_face_appearance_and_authored_boundaries() {
let mut body = crate::box_solid(Vec3::new(1., 2., 3.), Vec3::new(10., 20., 30.))
.unwrap()
.with_paint(Some(crate::Paint { opacity: 0.7, faces: vec![crate::FacePaint { face: 2, color: [0.25, 0.5, 0.75], opacity: 0.6 }] }));
let mut body = crate::box_solid(Vec3::new(1., 2., 3.), Vec3::new(10., 20., 30.)).unwrap().with_paint(Some(crate::Paint {
opacity: 0.7,
faces: vec![crate::FacePaint { face: 2, color: [0.25, 0.5, 0.75], opacity: 0.6, name: None }],
name: None,
}));
body.splits = Some(Arc::new(vec![vec![Vec3::new(1., 2., 3.), Vec3::new(11., 2., 3.)]]));
body.regularization_protected = true;
let restored = decode_native_body(&encode_native_body(&body).unwrap()).unwrap();
+3 -1
View File
@@ -274,7 +274,9 @@ pub(super) fn apply(body: &mut Body, saved: w::Metadata) -> Result<()> {
body.paint = saved.paint.map(|p| {
std::sync::Arc::new(crate::Paint {
opacity: p.opacity,
faces: p.faces.into_iter().map(|f| crate::FacePaint { face: f.face as usize, color: f.color, opacity: f.opacity }).collect(),
faces: p.faces.into_iter().map(|f| crate::FacePaint { face: f.face as usize, color: f.color, opacity: f.opacity, name: None }).collect(),
// Colour names are not part of this record: the model cache keeps them beside it.
name: None,
})
});
body.splits = saved.boundaries.map(|s| std::sync::Arc::new(s.into_iter().map(|line| line.into_iter().map(p).collect()).collect()));
@@ -14,7 +14,11 @@ fn stamped_shell_keeps_authored_painted_and_split_boundaries() {
let stock = stamped_stock();
let ordinary = cup(&stock);
assert!(!ordinary.regularization_protected);
let painted = stock.clone().with_paint(Some(Paint { opacity: 1., faces: vec![FacePaint { face: 0, color: [1., 0., 0.], opacity: 1. }] }));
let painted = stock.clone().with_paint(Some(Paint {
opacity: 1.,
faces: vec![FacePaint { face: 0, color: [1., 0., 0.], opacity: 1., name: None }],
name: None,
}));
let side = (0..stock.face_count()).find(|i| stock.planar_face(*i).is_some_and(|p| p.normal().dot(Vec3::X) > 0.999 && p.origin.x == 20.)).unwrap();
let split = crate::split_faces(&stock, &[side], &crate::SplitTool::Plane(Plane { origin: Vec3::new(0., 0., 8.), ..Plane::XY })).unwrap();
assert!(split.split_lines().is_some());
@@ -49,7 +53,11 @@ fn stamped_shell_keeps_authored_painted_and_split_boundaries() {
#[test]
fn protection_is_identity_and_survives_tool_operand_and_compounds() {
let a = stamped_stock();
let protected = a.clone().with_paint(Some(Paint { opacity: 1., faces: vec![FacePaint { face: 0, color: [0., 1., 0.], opacity: 1. }] }));
let protected = a.clone().with_paint(Some(Paint {
opacity: 1.,
faces: vec![FacePaint { face: 0, color: [0., 1., 0.], opacity: 1., name: None }],
name: None,
}));
let cleared = protected.with_paint(None);
assert!(!a.same(&cleared));
let tool = crate::box_solid(Vec3::new(-5., -5., 23.), Vec3::new(5., 5., 30.)).unwrap().inherit_regularization_protection(&cleared);
+67 -2
View File
@@ -151,7 +151,8 @@ mod imp {
}
/// The pieces as SMLib's curves: one each, but one B-spline for each run of pieces that join
/// the one before them ([`Piece::joins_previous`]).
/// the one before them ([`Piece::joins_previous`]). A Bézier piece whose control points turn
/// back on themselves goes in as its halves ([`unfolded`]): the same curve.
fn curves(plane: &Plane, pieces: &[Piece]) -> Result<Vec<Curve>> {
let mut out = Vec::new();
let mut run = Vec::new();
@@ -159,7 +160,7 @@ mod imp {
if !x.joins_previous && !run.is_empty() {
out.push(joined(std::mem::take(&mut run))?);
}
run.push(curve(plane, &x.seg)?);
run.extend(unfolded(curve(plane, &x.seg)?, UNFOLD_DEPTH));
}
if !run.is_empty() {
out.push(joined(run)?);
@@ -167,6 +168,70 @@ mod imp {
Ok(out)
}
/// How sharply a Bézier piece's control polygon may turn back between neighbouring steps
/// before the piece goes in as its halves (cosine of the turn, 160°). SMLib's check refuses
/// a swept wall whose control net turns back by more than 170° ("Bad NonMonotonic CPts",
/// #13243, #12107), though the curve itself may be smooth: a lettering outline drawn with
/// one handle pulled past the other. Its halves are the same curve on control polygons
/// closer to it, which turn less; 160° leaves the halves room below SMLib's 170°.
const UNFOLD_TURN: f64 = -0.939_692_620_785_908_4;
/// How many times a piece is halved at most: a curve that really turns back on itself (a
/// cusp) has a control polygon that does at any depth, and goes in as it is.
const UNFOLD_DEPTH: usize = 4;
/// `c` as it goes into a sweep: a Bézier piece whose control polygon turns back by more than
/// [`UNFOLD_TURN`] as its two halves at the middle of its parameter (de Casteljau, in
/// homogeneous points, so a rational piece is split exactly too), each again so up to
/// `depth` times; anything else as it is. [`joined`] takes the halves back as one B-spline:
/// one curve, one wall.
fn unfolded(c: Curve, depth: usize) -> Vec<Curve> {
let Curve::Nurbs { degree, points, knots } = &c else { return vec![c] };
if depth == 0 || points.len() != degree + 1 || knots.len() != 2 * (degree + 1) || !turns_back(points) {
return vec![c];
}
let (left, right) = halves(points);
let piece = |points: Vec<[f64; 4]>| Curve::Nurbs { degree: *degree, points, knots: knots.clone() };
let mut out = unfolded(piece(left), depth - 1);
out.extend(unfolded(piece(right), depth - 1));
out
}
/// Whether the control polygon `points` (`[x, y, z, weight]`) turns back by more than
/// [`UNFOLD_TURN`] between two steps, a step too short to have a direction passed over.
fn turns_back(points: &[[f64; 4]]) -> bool {
let at = |q: &[f64; 4]| Vec3::new(q[0], q[1], q[2]);
let size = points.iter().map(|q| at(q).len()).fold(1.0, f64::max);
let mut last: Option<Vec3> = None;
for w in points.windows(2) {
let [a, b] = w else { continue };
let d = at(b) - at(a);
let Some(step) = d.normalized().filter(|_| d.len() > size * 1e-12) else { continue };
if last.is_some_and(|l| l.dot(step) < UNFOLD_TURN) {
return true;
}
last = Some(step);
}
false
}
/// The control points of the two halves of the Bézier `points` (`[x, y, z, weight]`) split
/// at the middle of its parameter, by de Casteljau's construction on the homogeneous points.
fn halves(points: &[[f64; 4]]) -> (Vec<[f64; 4]>, Vec<[f64; 4]>) {
let hom = |q: &[f64; 4]| [q[0] * q[3], q[1] * q[3], q[2] * q[3], q[3]];
let euclid = |h: [f64; 4]| if h[3] != 0.0 { [h[0] / h[3], h[1] / h[3], h[2] / h[3], h[3]] } else { h };
let mut row: Vec<[f64; 4]> = points.iter().map(hom).collect();
let (mut left, mut right) = (Vec::with_capacity(row.len()), Vec::with_capacity(row.len()));
while let (Some(first), Some(last)) = (row.first().copied(), row.last().copied()) {
left.push(euclid(first));
right.push(euclid(last));
let mid = |a: &[f64; 4], b: &[f64; 4]| [0.5 * (a[0] + b[0]), 0.5 * (a[1] + b[1]), 0.5 * (a[2] + b[2]), 0.5 * (a[3] + b[3])];
row = row.iter().zip(row.iter().skip(1)).map(|(a, b)| mid(a, b)).collect();
}
right.reverse();
(left, right)
}
/// Bézier curves of one degree, each starting where the one before ends, as the one B-spline
/// they are, exactly: their control points in a row (each joint once) on knots repeated the
/// degree times at each joint. A rational piece's weights are scaled (which leaves it the same
+36
View File
@@ -458,3 +458,39 @@ fn a_controlled_sweep_takes_a_profile_drawn_part_way_along_its_path_as_its_start
assert!(b.min.dist(w.min) < 1e-6 && b.max.dist(w.max) < 1e-6, "{what}: {b:?} vs {w:?}");
}
}
#[test]
fn an_extrusion_of_a_cubic_whose_control_points_fold_back_passes_the_check() {
// #13243, #12107: a smooth cubic whose control polygon runs 6 left, steps 0.02 back to the
// right and runs 4 left again (the handles of an imported outline pulled past each other).
// Its wall's control net turned back by 174 degrees and SMLib's check refused the extrusion
// ("Bad NonMonotonic CPts"); the curve itself has no cusp.
let top = Seg2::Cubic { p0: Vec2::new(10.0, 10.0), p1: Vec2::new(4.0, 10.0), p2: Vec2::new(4.02, 10.002), p3: Vec2::new(0.0, 10.0) };
let line = |a: Vec2, b: Vec2| Seg2::Line { a, b };
let segs = vec![
line(Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0)),
line(Vec2::new(10.0, 0.0), Vec2::new(10.0, 10.0)),
top,
line(Vec2::new(0.0, 10.0), Vec2::new(0.0, 0.0)),
];
let r = region(Loop2 { segs });
let b = smlib_sweeps::extrude(&Plane::XY, &pieces_of(&r), Some(&r), 0.0, 3.0, 1.0, None).unwrap();
assert_eq!(b.validity(), Vec::<String>::new());
// The area under the cubic, by Green's theorem on a fine polyline of it.
let at = |t: f64| {
let s = 1.0 - t;
Vec2::new(10.0, 10.0) * (s * s * s)
+ Vec2::new(4.0, 10.0) * (3.0 * s * s * t)
+ Vec2::new(4.02, 10.002) * (3.0 * s * t * t)
+ Vec2::new(0.0, 10.0) * (t * t * t)
};
let n = 20_000;
let mut pts: Vec<Vec2> = (0..=n).map(|i| at(f64::from(i) / f64::from(n))).collect();
pts.extend([Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0)]);
let area = 0.5 * pts.iter().zip(pts.iter().cycle().skip(1)).map(|(a, b)| a.x * b.y - b.x * a.y).sum::<f64>();
close(volume(&b), 3.0 * area, 1e-3, "folded cubic prism");
// One wall for the cubic, as for any other piece.
let mut want = vec![cap(false), cap(true), profile(0), profile(1), profile(2), profile(3)];
want.sort();
assert_eq!(origins(&b), want);
}
+47 -16
View File
@@ -281,6 +281,9 @@ struct Reader {
placements: HashMap<u64, u64>,
/// Styled item → colour.
colors: HashMap<u64, [f32; 3]>,
/// Styled item → its colour's name in the file (`COLOUR_RGB`'s name, or a pre-defined
/// colour's), when the file names it.
color_names: HashMap<u64, String>,
/// Opacity of styled items with a transparency (1 = opaque).
opacity: HashMap<u64, f32>,
/// Items that gave no part: reported once when the file could not be read, else (the
@@ -337,6 +340,7 @@ impl Reader {
children: HashMap::new(),
placements: HashMap::new(),
colors: HashMap::new(),
color_names: HashMap::new(),
opacity: HashMap::new(),
unreadable: HashMap::new(),
once: HashSet::new(),
@@ -455,8 +459,13 @@ impl Reader {
}
"STYLED_ITEM" | "OVER_RIDING_STYLED_ITEM" => {
if let Some(item) = rf(2)
&& let Some(c) = self.find_colour(p.get(1))
&& let Some((c, name)) = self.find_colour(p.get(1))
{
if !self.colors.contains_key(&item)
&& let Some(name) = name
{
self.color_names.insert(item, name);
}
self.colors.entry(item).or_insert(c);
if let Some(t) = self.find_transparency(p.get(1)) {
self.opacity.entry(item).or_insert((1.0 - t).clamp(0.0, 1.0));
@@ -558,7 +567,8 @@ impl Reader {
None
}
fn find_colour(&self, styles: Option<&Param>) -> Option<[f32; 3]> {
/// The surface colour a style list gives, with its name in the file when it has one.
fn find_colour(&self, styles: Option<&Param>) -> Option<([f32; 3], Option<String>)> {
let mut queue: Vec<u64> = styles?.as_list()?.iter().filter_map(Param::as_ref_id).collect();
let mut seen = HashSet::new();
while let Some(id) = queue.pop() {
@@ -588,19 +598,25 @@ impl Reader {
if e.name() == "COLOUR_RGB" {
let p = e.params();
let c = |i: usize| p.get(i).and_then(Param::as_f64).map(|x| x.clamp(0.0, 1.0) as f32);
return Some([c(1)?, c(2)?, c(3)?]);
let name = p.first().and_then(Param::as_str).and_then(crate::body::colour_name);
return Some(([c(1)?, c(2)?, c(3)?], name));
}
if e.name() == "DRAUGHTING_PRE_DEFINED_COLOUR" {
return Some(match e.params().first().and_then(Param::as_str).unwrap_or("") {
"red" => [1.0, 0.0, 0.0],
"green" => [0.0, 1.0, 0.0],
"blue" => [0.0, 0.0, 1.0],
"yellow" => [1.0, 1.0, 0.0],
"magenta" => [1.0, 0.0, 1.0],
"cyan" => [0.0, 1.0, 1.0],
"black" => [0.0, 0.0, 0.0],
_ => [1.0, 1.0, 1.0],
});
let named = e.params().first().and_then(Param::as_str).unwrap_or("");
let name = crate::body::colour_name(named);
return Some((
match named {
"red" => [1.0, 0.0, 0.0],
"green" => [0.0, 1.0, 0.0],
"blue" => [0.0, 0.0, 1.0],
"yellow" => [1.0, 1.0, 0.0],
"magenta" => [1.0, 0.0, 1.0],
"cyan" => [0.0, 1.0, 1.0],
"black" => [0.0, 0.0, 0.0],
_ => [1.0, 1.0, 1.0],
},
name,
));
}
fn walk(p: &Param, out: &mut Vec<u64>, depth: usize) {
match p {
@@ -743,6 +759,11 @@ impl Reader {
for piece in solidcore_step::topology::items(&self.ex, item) {
if piece != item {
if let Some(c) = self.colors.get(&item).copied() {
if !self.colors.contains_key(&piece)
&& let Some(name) = self.color_names.get(&item).cloned()
{
self.color_names.insert(piece, name);
}
self.colors.entry(piece).or_insert(c);
}
if let Some(o) = self.opacity.get(&item).copied() {
@@ -899,16 +920,26 @@ impl Reader {
*slot = Some(self.bodies.len());
}
let color = self.colors.get(&item).copied();
// See-through bodies and faces with colours of their own.
// See-through bodies and faces with colours of their own, and the names the
// file gives those colours.
let faces = face_origin
.iter()
.enumerate()
.filter_map(|(i, f)| {
let c = self.colors.get(f)?;
Some(crate::FacePaint { face: i, color: *c, opacity: self.opacity.get(f).copied().unwrap_or(1.0) })
Some(crate::FacePaint {
face: i,
color: *c,
opacity: self.opacity.get(f).copied().unwrap_or(1.0),
name: self.color_names.get(f).cloned(),
})
})
.collect();
let paint = crate::Paint { opacity: self.opacity.get(&item).copied().unwrap_or(1.0), faces };
let paint = crate::Paint {
opacity: self.opacity.get(&item).copied().unwrap_or(1.0),
faces,
name: color.and_then(|_| self.color_names.get(&item).cloned()),
};
let body = body.with_color(color).with_paint(Some(paint));
let identities = face_origin
.iter()
+136 -2
View File
@@ -351,8 +351,11 @@ fn parallel_curved_import_keeps_face_colours_and_exact_display_meshes() {
let bodies: Vec<_> = (0..8)
.map(|i| {
let body = cylinder(Vec3::new(i as f64 * 20.0, 0.0, 0.0), Vec3::Z, 5.0, 12.0).unwrap();
body.with_color(Some([0.2, 0.4, 0.6]))
.with_paint(Some(crate::Paint { opacity: 0.7, faces: vec![crate::FacePaint { face: 0, color: [0.8, 0.1, 0.3], opacity: 0.5 }] }))
body.with_color(Some([0.2, 0.4, 0.6])).with_paint(Some(crate::Paint {
opacity: 0.7,
faces: vec![crate::FacePaint { face: 0, color: [0.8, 0.1, 0.3], opacity: 0.5, name: None }],
name: None,
}))
})
.collect();
let text = step_export(&bodies.iter().collect::<Vec<_>>(), "coloured cylinders").unwrap();
@@ -2578,6 +2581,67 @@ fn a_refused_solid_comes_in_as_a_surface_body_of_the_faces_that_read() {
assert!(rel(measure(&back).unwrap().volume, 250.0 * pi) < 1e-6, "{}", measure(&back).unwrap().volume);
}
/// The can of [`can_with_its_lid_at`] with a cavity, a 1 mm ball at its middle, written as a
/// BREP_WITH_VOIDS (the void's closed shell used through an ORIENTED_CLOSED_SHELL `.F.`).
fn hollow_can_with_its_lid_at(lid_z: f64) -> String {
let renumber = |text: &str, by: u64| {
let mut out = String::new();
let mut chars = text.chars().peekable();
while let Some(c) = chars.next() {
out.push(c);
if c == '#' {
let mut n = String::new();
while let Some(d) = chars.next_if(char::is_ascii_digit) {
n.push(d);
}
out += &(n.parse::<u64>().unwrap() + by).to_string();
}
}
out
};
let ball = seam_sphere_step(Vec3::new(0.0, 0.0, 5.0), 1.0, Vec3::Z, Vec3::X, 0.0, false, std::f64::consts::PI);
let ball = &ball[ball.find("#1=").unwrap()..ball.find("#21=").unwrap()];
let brep = "#22=MANIFOLD_SOLID_BREP('Can',#21);\n";
let can = can_with_its_lid_at(lid_z);
assert!(can.contains(brep));
let voids = format!("{}#1200=ORIENTED_CLOSED_SHELL('',*,#1020,.F.);\n#22=BREP_WITH_VOIDS('Can',#21,(#1200));\n", renumber(ball, 1000));
can.replacen(brep, &voids, 1)
}
/// A solid with a cavity no B-rep can be made of comes in as surfaces, as a solid without one
/// does ([`a_refused_solid_comes_in_as_a_surface_body_of_the_faces_that_read`]), never dropped:
/// the hollow can whose lid lies 1.5 mm off its rim is its bottom, wall and cavity, the lid left
/// out and named. An engine block and a cylinder head of an assembly, both written with voids,
/// were lost whole on one face each.
#[test]
fn a_refused_solid_with_a_cavity_comes_in_as_surfaces() {
let pi = std::f64::consts::PI;
let whole = step_import(&hollow_can_with_its_lid_at(10.0)).unwrap();
assert_eq!(whole.bodies.len(), 1, "{:?}", whole.warnings);
assert!(whole.bodies[0].closed && whole.bodies[0].refused.is_none(), "{:?}", whole.warnings);
let want = 250.0 * pi - 4.0 / 3.0 * pi;
let v = measure(&whole.bodies[0].body).unwrap().volume;
assert!(rel(v, want) < 1e-3, "{v} vs {want}");
let imp = step_import(&hollow_can_with_its_lid_at(11.5)).unwrap();
assert_eq!(imp.bodies.len(), 1, "{:?}", imp.warnings);
let can = &imp.bodies[0];
assert!(!can.body.is_mesh() && can.body.is_surface() && !can.closed, "{:?}", imp.warnings);
let why = can.refused.as_deref().unwrap_or_default();
assert!(why.contains("face #45"), "{why}");
assert_eq!(can.file_faces, 4);
assert_eq!(can.body.face_count(), 3, "{:?}", imp.warnings);
assert!(!can.face_origin.contains(&45));
assert!(imp.warnings.iter().any(|w| w.contains("'Can': imported as surfaces: ")), "{:?}", imp.warnings);
assert!(imp.warnings.iter().any(|w| w.contains("'Can': left out: ") && w.contains("face #45")), "{:?}", imp.warnings);
assert!(!imp.warnings.iter().any(|w| w.contains("not imported")), "{:?}", imp.warnings);
// The bottom, the wall and the cavity's ball.
let mut kept = can.face_origin.clone();
kept.sort_unstable();
assert_eq!(kept[..2], [15, 20], "{kept:?}");
assert!(kept[2] > 1000, "the cavity's face: {kept:?}");
}
/// A sector of a cylinder (radius 5, height 10) about z, `sweep` radians from `start` on the
/// wall's frame: a pie slice of the cylinder, its two sides through the axis.
fn cylinder_sector(start: f64, sweep: f64) -> String {
@@ -3096,6 +3160,76 @@ fn a_torus_face_round_most_of_its_tube_is_formed_within_one_turn_of_it() {
}
}
/// A colour comes in with its name in the file: `COLOUR_RGB`'s own, or a pre-defined colour's,
/// decoded and made safe as a design keeps names (no control characters, at most 128
/// characters); an empty or blank name is none. A face's over-riding style names the face's
/// colour, a see-through style keeps its name, and an export writes the names back.
#[test]
fn colour_names_come_in_with_their_colours_and_go_out_again() {
let style = |id: u64, item: &str, colour: &str, extra: &str| {
format!(
"#{id}=STYLED_ITEM('',(#{}),{item});\n#{}=PRESENTATION_STYLE_ASSIGNMENT((#{}));\n#{}=SURFACE_STYLE_USAGE(.BOTH.,#{});\n#{}=SURFACE_SIDE_STYLE('',(#{}{extra}));\n#{}=SURFACE_STYLE_FILL_AREA(#{});\n#{}=FILL_AREA_STYLE('',(#{}));\n#{}=FILL_AREA_STYLE_COLOUR('',#{});\n#{}={colour};\n",
id + 1,
id + 1,
id + 2,
id + 2,
id + 3,
id + 3,
id + 4,
id + 4,
id + 5,
id + 5,
id + 6,
id + 6,
id + 7,
id + 7,
)
};
let import = |styles: &str| {
let mut t = String::from(HEAD);
t += &cube_entities();
t += &context(20, "(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.))");
t += "#60=ADVANCED_BREP_SHAPE_REPRESENTATION('',(#101),#20);\n";
t += styles;
t += TAIL;
let imp = step_import(&t).unwrap();
assert_eq!(imp.bodies.len(), 1, "{:?}", imp.warnings);
imp.bodies.into_iter().next().unwrap()
};
// An opaque colour without a name needs no paint; with one, the paint holds it.
let named = |name: &str| import(&style(70, "#101", &format!("COLOUR_RGB('{name}',1.,0.5,0.)"), "")).body.paint().and_then(|p| p.name.clone());
assert_eq!(named("Steel - Satin").as_deref(), Some("Steel - Satin"));
assert_eq!(named("Opaque(97,69,55)").as_deref(), Some("Opaque(97,69,55)"));
assert_eq!(named(""), None);
assert_eq!(named(" "), None);
assert_eq!(named(" Blue ").as_deref(), Some("Blue"));
assert_eq!(named(r"Bl\X2\00E9\X0\").as_deref(), Some("Blé"), "escapes are decoded");
assert_eq!(named(r"a\X\0Ab\X\09c").as_deref(), Some("abc"), "control characters are dropped");
assert_eq!(named(&"x".repeat(300)).map(|n| n.chars().count()), Some(crate::COLOUR_NAME_MAX));
// A pre-defined colour is called by its name.
let red = import(&style(70, "#101", "DRAUGHTING_PRE_DEFINED_COLOUR('red')", ""));
assert_eq!((red.color, red.body.paint().unwrap().name.as_deref()), (Some([1.0, 0.0, 0.0]), Some("red")));
// A see-through colour keeps its name.
let glass = import(&(style(70, "#101", "COLOUR_RGB('Smoked',0.4,0.4,0.4)", ",#90") + "#90=SURFACE_STYLE_TRANSPARENT(0.5);\n"));
let paint = glass.body.paint().unwrap();
assert_eq!((paint.opacity, paint.name.as_deref()), (0.5, Some("Smoked")));
// A face over-ridden by a style of its own: its colour is named as the file names it.
let cube = cube_entities();
let face = cube.split('\n').find(|l| l.contains("=ADVANCED_FACE(")).and_then(|l| l.split('=').next()).unwrap().to_string();
let styles = style(70, "#101", "COLOUR_RGB('Body grey',0.5,0.5,0.5)", "") + &style(80, &face, "COLOUR_RGB('Accent',0.,0.,1.)", "");
let painted = import(&styles);
let paint = painted.body.paint().unwrap();
assert_eq!(paint.name.as_deref(), Some("Body grey"));
assert_eq!(paint.faces.iter().map(|f| (f.color, f.name.as_deref())).collect::<Vec<_>>(), [([0.0, 0.0, 1.0], Some("Accent"))]);
// Written out and read back, the names come back with their colours.
let text = step_export(&[&painted.body.clone().with_color(painted.color)], "named").unwrap();
assert!(text.contains("COLOUR_RGB('Body grey'") && text.contains("COLOUR_RGB('Accent'"), "{text}");
let back = step_import(&text).unwrap().bodies.remove(0);
let back_paint = back.body.paint().unwrap();
assert_eq!(back_paint.name.as_deref(), Some("Body grey"));
assert_eq!(back_paint.faces.iter().map(|f| f.name.as_deref()).collect::<Vec<_>>(), [Some("Accent")]);
}
/// #12291: a part styled with a surface colour and, beside it, a curve style (its edges) is the
/// surface colour, whichever style the file lists first or last. The edge's colour once painted
/// the whole solid, because the first colour found was taken.
+8 -5
View File
@@ -209,14 +209,15 @@ pub fn step_export_products(products: &[ExportProduct], root: usize, header: &St
let opacity = b.body.paint().map_or(1.0, |p| p.opacity);
let items: Vec<u64> = written.solids.iter().copied().chain(written.sheets).collect();
if let Some(c) = b.color {
let name = b.body.paint().and_then(|p| p.name.as_deref());
for item in &items {
styled.push(style(&mut w, *item, c, opacity));
styled.push(style(&mut w, *item, c, opacity, name));
}
}
// Faces with colours of their own.
for f in b.body.paint().map(|p| p.faces.as_slice()).unwrap_or(&[]) {
if let Some(&face) = written.faces.get(f.face).filter(|id| **id != 0) {
styled.push(style(&mut w, face, f.color, f.opacity));
styled.push(style(&mut w, face, f.color, f.opacity, f.name.as_deref()));
}
}
solids.extend(written.solids);
@@ -279,9 +280,11 @@ pub fn step_export_products(products: &[ExportProduct], root: usize, header: &St
Ok(text)
}
/// A surface colour for an item (STYLED_ITEM chain; the styles are shared).
fn style(w: &mut Writer, item: u64, c: [f32; 3], opacity: f32) -> u64 {
let rgb = w.add("COLOUR_RGB", &[s(""), Param::Real(c[0] as f64), Param::Real(c[1] as f64), Param::Real(c[2] as f64)]);
/// A surface colour for an item (STYLED_ITEM chain; the styles are shared), named as its look is
/// (`COLOUR_RGB`'s name, read back on import) when it has a name.
fn style(w: &mut Writer, item: u64, c: [f32; 3], opacity: f32, name: Option<&str>) -> u64 {
let name = name.and_then(crate::body::colour_name).unwrap_or_default();
let rgb = w.add("COLOUR_RGB", &[s(&name), Param::Real(c[0] as f64), Param::Real(c[1] as f64), Param::Real(c[2] as f64)]);
let fasc = w.add("FILL_AREA_STYLE_COLOUR", &[s(""), r(rgb)]);
let fas = w.add("FILL_AREA_STYLE", &[s(""), refs(&[fasc])]);
let ssfa = w.add("SURFACE_STYLE_FILL_AREA", &[r(fas)]);
+166 -2
View File
@@ -962,6 +962,138 @@ fn check_parameters_tries_values_and_puts_the_design_back() {
assert!(d["timeline"].as_array().unwrap().iter().all(|f| f["error"].is_null()), "{d}");
}
/// `bodies` and `summary` keep `check_parameters`'s reply small on a large design (#14162): only
/// the bodies named are measured and their cylinders listed, and `measure` is the measure
/// tool's summary. Without them the reply is as before; a bad `bodies` changes nothing.
#[test]
fn check_parameters_reports_only_the_bodies_asked_for_in_summary() {
let mut s = server();
parametric_box_with_cylinder(&mut s);
tool(&mut s, "execute", json!({"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10, "corner": [100, 0, 0]}}));
tool(&mut s, "execute", json!({"command": "solid.cylinder", "params": {"radius": 2, "height": 10, "base": [200, 0, 0]}}));
let names: Vec<String> =
tool(&mut s, "measure", json!({}))["bodies"].as_array().unwrap().iter().map(|b| b["name"].as_str().unwrap().to_string()).collect();
assert_eq!(names.len(), 3, "{names:?}");
let before = tool(&mut s, "inspect_design", json!({"measure": false, "sketches": false}));
// The default: every body measured in full, every cylinder.
let all = tool(&mut s, "check_parameters", json!({"set": {"len": 60}, "report": ["measure", "cylinders"]}));
assert_eq!(all["measure"]["body_count"], 3, "{all}");
assert!(all["measure"]["bodies"][0]["area_mm2"].is_number() && all["measure"]["bodies"][0]["kernel"].is_object(), "full measure: {all}");
assert_eq!(all["cylinders"].as_array().map(Vec::len), Some(2), "{all}");
let one = tool(&mut s, "check_parameters", json!({"set": {"len": 60}, "report": ["measure", "cylinders"], "bodies": ["Body1"], "summary": true}));
assert_eq!(one["restored"], true, "{one}");
let m = &one["measure"];
assert_eq!(m["bodies"].as_array().map(Vec::len), Some(1), "{one}");
let mut keys: Vec<&String> = m["bodies"][0].as_object().unwrap().keys().collect();
keys.sort();
assert_eq!(keys, ["bbox", "closed", "faces", "name", "volume_mm3"], "the measure tool's summary: {one}");
assert_eq!(m["bodies"][0]["name"], "Body1");
let v = m["total"]["volume_mm3"].as_f64().unwrap();
assert!(rel(v, box_with_cylinder_volume(60.0)) < 5e-4, "{v}");
assert_eq!(m["bodies"][0]["bbox"], all["measure"]["bodies"][0]["bbox"], "{one}");
let cyl = one["cylinders"].as_array().unwrap();
assert!(cyl.len() == 1 && cyl[0]["body"] == "Body1", "{one}");
assert!(one.to_string().len() * 3 < all.to_string().len(), "{} vs {} bytes", one.to_string().len(), all.to_string().len());
// `summary` on its own summarises every body.
let sum = tool(&mut s, "check_parameters", json!({"set": {"len": 60}, "report": ["measure"], "summary": true}));
assert_eq!(sum["measure"]["bodies"].as_array().map(Vec::len), Some(3), "{sum}");
assert!(sum["measure"]["bodies"][0].get("area_mm2").is_none(), "{sum}");
// A body the design doesn't have: an error, and the design is put back.
for report in [json!(["measure"]), json!(["cylinders"])] {
let r = raw_tool(&mut s, "check_parameters", json!({"set": {"len": 60}, "report": report, "bodies": ["Nope"]}));
assert_eq!(r["result"]["isError"], json!(true), "{report}: {r}");
let body: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
assert_eq!(body["restored"], true, "{body}");
let e = body["error"].as_str().unwrap();
assert!(e.contains("no body `Nope`") && e.contains("Body1"), "{body}");
}
// A `bodies` that is not a list of names, or a `summary` that is not a flag: refused first.
for args in [json!({"set": {"len": 60}, "bodies": "Body1"}), json!({"set": {"len": 60}, "summary": "yes"})] {
let r = raw_tool(&mut s, "check_parameters", args.clone());
assert_eq!(r["error"]["code"], -32602, "{args}: {r}");
}
for args in [json!({"set": {"len": 60}, "bodies": []}), json!({"set": {"len": 60}, "bodies": [5]}), json!({"set": {"len": 60}, "bodies": [""]})] {
let r = raw_tool(&mut s, "check_parameters", args.clone());
assert_eq!(r["result"]["isError"], json!(true), "{args}: {r}");
assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("`bodies` must be a list of body names"), "{args}: {r}");
}
assert!(rel(volume(&mut s), box_with_cylinder_volume(40.0) + 1000.0 + PI * 4.0 * 10.0) < 5e-4);
let after = tool(&mut s, "inspect_design", json!({"measure": false, "sketches": false}));
assert_eq!(after["undo"], before["undo"], "{after}");
}
/// `timeline` and `bodies` keep `inspect_design` small on a large design (#14162), with and
/// without compact output and as a batch step; left out, the call and its reply are as before.
#[test]
fn inspect_design_gives_a_timeline_summary_and_body_names_when_asked() {
for opts in [crate::Options::default(), crate::Options { compact: true }] {
let mut s = Server::new(Box::new(Headless::default())).with_options(opts);
parametric_box_with_cylinder(&mut s);
tool(&mut s, "execute", json!({"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10, "corner": [100, 0, 0]}}));
let base = json!({"measure": true, "sketches": false, "joints": false});
let with = |extra: Value| {
let mut a = base.clone();
a.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
a
};
let full = tool(&mut s, "inspect_design", base.clone());
assert_eq!(tool(&mut s, "inspect_design", with(json!({"timeline": true, "bodies": true}))), full, "{opts:?}");
assert!(full["timeline"][0]["ms"].is_number() && full["bodies"][0]["volume_mm3"].is_number(), "{full}");
let short = tool(&mut s, "inspect_design", with(json!({"timeline": "summary", "bodies": "names"})));
let rows = short["timeline"].as_array().unwrap();
assert_eq!(rows.len(), full["timeline"].as_array().unwrap().len(), "{short}");
for row in rows {
let mut keys: Vec<&String> = row.as_object().unwrap().keys().collect();
keys.sort();
assert_eq!(keys, ["id", "name", "type"], "{opts:?}: {row}");
}
assert_eq!(short["bodies"], json!(["Body1", "Body2"]), "{opts:?}: {short}");
assert_eq!(short["params"], full["params"]);
let gone = tool(&mut s, "inspect_design", with(json!({"timeline": false, "bodies": false})));
assert!(gone.get("timeline").is_none() && gone.get("bodies").is_none(), "{opts:?}: {gone}");
// A value the tool doesn't take is refused before anything is sent.
for bad in [json!({"timeline": "full"}), json!({"timeline": 1})] {
let r = raw_tool(&mut s, "inspect_design", bad.clone());
assert_eq!(r["error"]["code"], -32602, "{opts:?} {bad}: {r}");
}
// `bodies` takes a list with compact output, so its check names every form.
let r = raw_tool(&mut s, "inspect_design", json!({"bodies": "all"}));
if opts.compact {
assert_eq!(r["result"]["isError"], json!(true), "{r}");
assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("list of body names, true, false or \"names\""), "{r}");
} else {
assert_eq!(r["error"]["code"], -32602, "{r}");
}
// As a batch step.
let r = tool(
&mut s,
"batch",
json!({"commands": [{"command": "inspect_design", "params": {"measure": false, "timeline": "summary", "bodies": "names"}}]}),
);
let step = &r["results"][0];
assert_eq!(step["bodies"], json!(["Body1", "Body2"]), "{opts:?}: {r}");
assert!(step["timeline"][0].get("ms").is_none(), "{opts:?}: {r}");
}
// Without the new arguments, document.inspect is asked exactly as before, so an app from
// before them answers as it always did.
let mut b = Answers { calls: Vec::new(), answer: Box::new(|_, _| Ok(json!({"timeline": [], "bodies": [], "sketches": []}))) };
crate::tools::call_tool(&mut b, "inspect_design", &json!({"sketches": false, "joints": false}));
crate::tools::call_tool_with(&mut b, "inspect_design", &json!({"joints": false}), crate::Options { compact: true });
crate::tools::call_tool_with(&mut b, "inspect_design", &json!({"joints": false, "bodies": ["B"]}), crate::Options { compact: true });
let sent: Vec<&Value> = b.calls.iter().filter(|(m, _)| m == "document.inspect").map(|(_, p)| p).collect();
assert_eq!(
sent,
[&json!({"measure": true}), &json!({"measure": true, "sketch_counts": true}), &json!({"measure": false, "sketch_counts": true})]
);
}
/// An unknown parameter name, a bad expression or a bad report list is an error that leaves the
/// design untouched; `set` that is not an object is refused by argument checking; the tool is
/// annotated as an edit (it changes the design and puts it back) and is not a batch step.
@@ -2438,6 +2570,38 @@ fn compact_batch_reply_keeps_changed_bodies_and_created_ids() {
assert!(rect["sides"].is_object(), "results that are not repeated stay: {r}");
}
/// A drawing of hundreds of curves comes back with hundreds of ids: a compact reply leaves each
/// long `created` list out and counts it, the full reply has every id; 20 ids are still a list.
#[test]
fn compact_reply_counts_long_created_lists() {
let mut s = server();
let polyline = |n: usize| {
let points: Vec<String> = (0..=n).map(|i| format!("{i},{}", i % 2)).collect();
format!(r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 10"><polyline points="{}"/></svg>"#, points.join(" "))
};
tool(&mut s, "execute", json!({"command": "sketch.create", "params": {"plane": "XY"}}));
let full = tool(&mut s, "execute", json!({"command": "sketch.insert_svg", "params": {"text": polyline(30)}}));
assert_eq!(full["created"]["curves"].as_array().map(Vec::len), Some(30), "{full}");
assert_eq!(full["created"]["points"].as_array().map(Vec::len), Some(31), "{full}");
tool(&mut s, "execute", json!({"command": "sketch.create", "params": {"plane": "XY"}}));
let short = tool(&mut s, "execute", json!({"command": "sketch.insert_svg", "params": {"text": polyline(30)}, "verbose": false}));
assert!(short["created"].get("curves").is_none() && short["created"].get("points").is_none(), "{short}");
assert_eq!((&short["created"]["curve_count"], &short["created"]["point_count"]), (&json!(30), &json!(31)), "{short}");
assert_eq!(short["curves"], json!(30), "the count outside `created` stays: {short}");
// Twenty curves are still listed.
tool(&mut s, "execute", json!({"command": "sketch.create", "params": {"plane": "XY"}}));
let twenty = tool(&mut s, "execute", json!({"command": "sketch.insert_svg", "params": {"text": polyline(20)}, "verbose": false}));
assert_eq!(twenty["created"]["curves"].as_array().map(Vec::len), Some(20), "{twenty}");
// The same in a batch.
let b = tool(
&mut s,
"batch",
json!({"commands": [{"command": "sketch.create", "params": {"plane": "XY"}}, {"command": "sketch.insert_svg", "params": {"text": polyline(30)}}], "verbose": false}),
);
let step = &b["results"][1];
assert!(step["created"].get("curves").is_none() && step["created"]["curve_count"] == json!(30), "{b}");
}
/// #531: a product model keeps an optional variant hidden in its default presentation. Hiding
/// it must survive saving and reopening the design (suppressing it would change the design).
#[test]
@@ -4074,7 +4238,7 @@ fn inspect_design_is_unchanged_without_compact_output() {
let defs = tool_definitions();
let def = defs.iter().find(|d| d["name"] == "inspect_design").unwrap();
assert_eq!(def["inputSchema"]["properties"]["sketches"]["type"], "boolean", "{def}");
assert!(def["inputSchema"]["properties"].get("bodies").is_none(), "{def}");
assert_eq!(def["inputSchema"]["properties"]["bodies"]["type"], json!(["boolean", "string"]), "no list of names: {def}");
let listed = call(&mut s, 1, "tools/list", json!({}));
assert!(listed["result"]["tools"].as_array().unwrap().iter().any(|t| t == def), "tools/list serves the default definition");
@@ -4169,7 +4333,7 @@ impl crate::Backend for AppWithPrefs {
fn inspect_design_is_compact(s: &mut Server) -> bool {
let listed = call(s, 1, "tools/list", json!({}));
let def = listed["result"]["tools"].as_array().and_then(|t| t.iter().find(|d| d["name"] == "inspect_design")).cloned().unwrap_or_default();
def["inputSchema"]["properties"]["bodies"].is_object()
def["inputSchema"]["properties"]["bodies"]["type"].as_array().is_some_and(|t| t.contains(&json!("array")))
}
/// The app's Preferences turn compact output on for a bridged server; it asks until the app
+123 -20
View File
@@ -122,7 +122,7 @@ pub fn tool_definitions_with(opts: Options) -> Vec<Value> {
json!({
"command": string("Command id (see list_commands)"),
"params": {"type": "object", "description": "The command's parameters"},
"verbose": {"type": "boolean", "description": "false: omit the full feature body list and sketch ids repeated outside `created`; keep created_bodies, changed_bodies and removed_bodies. When a sketch command creates more points than its new curves have ends (spline poles), `created.points` becomes `created.point_count` (default true)"},
"verbose": {"type": "boolean", "description": "false: omit the full feature body list and sketch ids repeated outside `created`; keep created_bodies, changed_bodies and removed_bodies. When a sketch command creates more points than its new curves have ends (spline poles), `created.points` becomes `created.point_count`. A `created` list of more than 20 ids (a drawing of many curves) is left out and counted instead: `curve_count`, `constraint_count`, `dimension_count` (and `point_count`); `sketch.inspect` lists every id (default true)"},
"expect": expect_schema(),
}),
&["command"],
@@ -160,7 +160,7 @@ pub fn tool_definitions_with(opts: Options) -> Vec<Value> {
"coalesce_parameters": {"type": "boolean", "description": "Set each run of consecutive plain parameter edits (parameters.change or set_parameter with a name and a value, or changes) together, rebuilding the design once for the run instead of once per edit; a run that is refused goes step by step, naming the refused step. Other steps still see the design up to date. Each step keeps its own result; the run is one undo step (default false)"},
"timings": {"type": "boolean", "description": "Add `timings`: [{index, command, ms}] for every step run (the failing one included) and `total_ms` (default false)"},
"profile": {"type": "boolean", "description": "Like timings, and each step's `profile`: {boolean_ms, tessellate_ms, other_ms}, its time in kernel booleans, in tessellation and the rest (default false)"},
"verbose": {"type": "boolean", "description": "false: a compact reply. Feature steps leave out the full body list (created_bodies, changed_bodies and removed_bodies stay), sketch steps the ids they repeat outside `created`, and `created.points` becomes `created.point_count` when there are more points than the new curves have ends (default true)"},
"verbose": {"type": "boolean", "description": "false: a compact reply. Feature steps leave out the full body list (created_bodies, changed_bodies and removed_bodies stay), sketch steps the ids they repeat outside `created`, and `created.points` becomes `created.point_count` when there are more points than the new curves have ends; a `created` list of more than 20 ids is left out and counted instead (`curve_count` and the like); `sketch.inspect` lists every id (default true)"},
"expect": expect_schema(),
}),
&[],
@@ -237,7 +237,8 @@ pub fn tool_definitions_with(opts: Options) -> Vec<Value> {
kernel splits one cylinder into merged (origin is the axis point closest to the world origin). The reply ends with \
`restored: true`; if the design could not be put back the call is an error with `restored: false` and the values \
still applied. An unknown parameter name is an error and nothing changes. Replaces the loop set_parameter, \
measure, body_topology, set_parameter back.",
measure, body_topology, set_parameter back. On a large design keep the reply small with `bodies` (only those \
bodies are measured and their cylinders listed) and `summary: true` (measure as the measure tool's summary).",
object(
json!({
"set": {
@@ -250,6 +251,8 @@ pub fn tool_definitions_with(opts: Options) -> Vec<Value> {
"items": {"type": "string", "enum": REPORTS},
"description": "What to report: measure, cylinders, errors (default all three)",
},
"bodies": {"type": "array", "items": {"type": "string"}, "description": "Only these bodies, by name, for measure and cylinders (default: all)"},
"summary": {"type": "boolean", "description": "measure as the measure tool's `summary: true`: per body only name, volume_mm3, bbox {min, max}, faces and closed, and total.volume_mm3 (default false: the full reply)"},
}),
&["set"],
),
@@ -452,12 +455,15 @@ fn inspect_design_tool(opts: Options) -> Value {
counts), sketches (curves with ids and coordinates, points, constraints, profiles, degrees of freedom, solve \
status), assembly joints (what each origin is attached to: occurrence, the feature, face, edge or sketch \
curve its face names refer to, whether it resolves and why not; `execute` `joint.inspect {joint}` gives one), \
the active sketch, selection and undo depth.",
the active sketch, selection and undo depth. On a large design keep the reply small with `timeline: \"summary\"` \
(each feature as id, name, type, and error or warning only when it has one) or false, and `bodies: \"names\"` or false.",
object(
json!({
"measure": {"type": "boolean", "description": "Measure bodies (default true)"},
"sketches": {"type": "boolean", "description": "Full sketch contents (default true; false = summary only)"},
"joints": {"type": "boolean", "description": "Joint definitions as joint.inspect gives them, when the design has joints (default true)"},
"timeline": timeline_arg(),
"bodies": {"type": ["boolean", "string"], "enum": [true, false, "names"], "description": BODIES_ARG},
}),
&[],
),
@@ -473,7 +479,9 @@ fn inspect_design_tool(opts: Options) -> Value {
plane, counts of points, curves, constraints and profiles, degrees of freedom, solve status); their full contents \
(curves with ids and coordinates, points, constraints, profiles) only for the sketches named in `sketches`, or \
all with `sketches: true` (large: a 720-point sketch is some 200 KB). Assembly joints: what each origin is \
attached to (`execute` `joint.inspect {joint}` gives one). Arguments: measure, sketches, bodies, joints.",
attached to (`execute` `joint.inspect {joint}` gives one). On a large design keep the reply small with \
`timeline: \"summary\"` (each feature as id, name, type, and error or warning only when it has one) or false, and \
`bodies: \"names\"` or false. Arguments: measure, sketches, bodies, timeline, joints.",
object(
json!({
"measure": {"type": "boolean", "description": "Measure bodies (default true)"},
@@ -482,8 +490,13 @@ fn inspect_design_tool(opts: Options) -> Value {
"items": {"type": ["string", "integer"]},
"description": "Which sketches in full: a list of sketch names or ids, true (all of them), or false / \"summary\" (none: summaries only, the default)",
},
"bodies": {"type": "array", "items": {"type": "string"}, "description": "Only these bodies, by name (default: all)"},
"bodies": {
"type": ["array", "boolean", "string"],
"items": {"type": "string"},
"description": "Only these bodies, by name (default: all); or \"names\" (only the body names, nothing measured) or false (no bodies)",
},
"joints": {"type": "boolean", "description": "Joint definitions as joint.inspect gives them, when the design has joints (default true)"},
"timeline": timeline_arg(),
}),
&[],
),
@@ -491,6 +504,18 @@ fn inspect_design_tool(opts: Options) -> Value {
)
}
/// `inspect_design`'s `timeline` argument, as `document.inspect` takes it.
fn timeline_arg() -> Value {
json!({
"type": ["boolean", "string"],
"enum": [true, false, "summary"],
"description": "true (default): every feature with its state and timing; \"summary\": each feature as id, name and type, with error, warning, suppressed and rolled_back only when set; false: no timeline",
})
}
/// `inspect_design`'s `bodies` argument without compact output.
const BODIES_ARG: &str = "true (default): every body as `measure` says; \"names\": only the body names, nothing measured; false: no bodies";
/// A tool result: content blocks plus the error flag. JSON is pretty-printed, or compact with
/// compact output.
fn text_result(v: &Value, is_error: bool, opts: Options) -> Value {
@@ -849,6 +874,14 @@ fn measure_params(a: &Map<String, Value>) -> Value {
p
}
/// `inspect_design`'s arguments as `document.inspect` takes them: `measure`, and `keys` only when
/// given, so an app from before them is asked as it always was.
fn inspect_params(a: &Map<String, Value>, measure: bool, keys: &[&str]) -> Value {
let mut p = pick(a, keys);
p["measure"] = json!(measure);
p
}
/// `summary` narrows measured bodies; a `names_only` list has nothing to narrow.
fn measure_summary_wanted(a: &Map<String, Value>) -> bool {
let flag = |k: &str| a.get(k).and_then(Value::as_bool).unwrap_or(false);
@@ -875,7 +908,7 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Map<String, Value>) -> Result<V
}
"inspect_design" => {
let measure = a.get("measure").and_then(Value::as_bool).unwrap_or(true);
let mut d = b.call("document.inspect", json!({"measure": measure}))?;
let mut d = b.call("document.inspect", inspect_params(a, measure, &["timeline", "bodies"]))?;
if a.get("sketches").and_then(Value::as_bool).unwrap_or(true) {
let ids: Vec<Value> = d["sketches"].as_array().map(|v| v.iter().map(|s| s["id"].clone()).collect()).unwrap_or_default();
let full: Vec<Value> = ids
@@ -1121,7 +1154,10 @@ fn batch_step(step: &Value) -> Result<(String, Value, Option<Narrow>), String> {
}
mapped("inspect.compare_picture", p)
}
"inspect_design" => mapped("document.inspect", json!({"measure": a.get("measure").and_then(Value::as_bool).unwrap_or(true)})),
"inspect_design" => {
let keys: &[&str] = if a.get("bodies").is_some_and(Value::is_array) { &["timeline"] } else { &["timeline", "bodies"] };
mapped("document.inspect", inspect_params(a, a.get("measure").and_then(Value::as_bool).unwrap_or(true), keys))
}
"undo" => mapped("edit.undo", json!({})),
"redo" => mapped("edit.redo", json!({})),
"export" => mapped("file.export", pick(a, &["path", "format", "bodies", "mesh_prefix", "effort", "velocity"])),
@@ -1184,7 +1220,13 @@ fn name_list(names: &[String]) -> String {
/// sketches are asked for in full (by name or id, or all), and only the bodies asked for.
fn inspect_design_compact(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<Value, String> {
let measure = a.get("measure").and_then(Value::as_bool).unwrap_or(true);
let bodies: Option<Vec<String>> = match a.get("bodies").filter(|v| !v.is_null()) {
// `bodies` as a list keeps only those bodies; true, false or "names" go to document.inspect.
if let Some(Value::String(form)) = a.get("bodies")
&& form != "names"
{
return Err("inspect_design: `bodies` must be a list of body names, true, false or \"names\"".into());
}
let bodies: Option<Vec<String>> = match a.get("bodies").filter(|v| v.is_array()) {
None => None,
Some(v) => Some(
v.as_array()
@@ -1193,7 +1235,10 @@ fn inspect_design_compact(b: &mut dyn Backend, a: &Map<String, Value>) -> Result
),
};
// With `bodies`, only those are measured.
let mut d = b.call("document.inspect", json!({"measure": measure && bodies.is_none(), "sketch_counts": true}))?;
let keys: &[&str] = if bodies.is_some() { &["timeline"] } else { &["timeline", "bodies"] };
let mut p = inspect_params(a, measure && bodies.is_none(), keys);
p["sketch_counts"] = json!(true);
let mut d = b.call("document.inspect", p)?;
if let Some(want) = bodies {
let all: Vec<Value> = d["bodies"].as_array().cloned().unwrap_or_default();
let names: Vec<String> = all.iter().filter_map(|x| x["name"].as_str().map(str::to_string)).collect();
@@ -1834,9 +1879,14 @@ fn topology_cylinders(faces: &Value, edges: &Value) -> Value {
.collect()
}
/// A `created` list longer than this (a drawing of hundreds of curves) is left out of a compact
/// reply and counted instead (`curve_count` and the like, as `point_count`): the ids are no
/// sequence an agent could derive, each kind of curve counts on its own. `sketch.inspect` has them.
const COMPACT_LIST: usize = 20;
/// A command's result without what the design already had (`execute` or `batch` with `verbose: false`): a
/// feature's full body list (its created, changed and removed bodies stay), and the sketch ids
/// a sketch command repeats outside `created`.
/// a sketch command repeats outside `created`. Long `created` lists are counted instead of listed.
fn compact(mut v: Value) -> Value {
let Some(o) = v.as_object_mut() else { return v };
if o.contains_key("created_bodies") {
@@ -1859,6 +1909,15 @@ fn compact(mut v: Value) -> Value {
created.insert("point_count".into(), json!(points));
}
}
if let Some(created) = o.get_mut("created").and_then(Value::as_object_mut) {
for (k, count) in
[("points", "point_count"), ("curves", "curve_count"), ("constraints", "constraint_count"), ("dimensions", "dimension_count")]
{
let Some(n) = created.get(k).and_then(Value::as_array).map(Vec::len).filter(|n| *n > COMPACT_LIST) else { continue };
created.remove(k);
created.insert(count.into(), json!(n));
}
}
v
}
@@ -1868,7 +1927,7 @@ fn compact(mut v: Value) -> Value {
/// changed a parameter to see its effect spent three to five calls on this loop, and one saved
/// the design with the test values still in it.
fn check_parameters(b: &mut dyn Backend, a: &Map<String, Value>, opts: Options) -> Value {
let (changes, reports) = match check_parameters_args(a) {
let (changes, reports, narrow) = match check_parameters_args(a) {
Ok(x) => x,
Err(e) => return text_result(&Value::String(e), true, opts),
};
@@ -1881,7 +1940,7 @@ fn check_parameters(b: &mut dyn Backend, a: &Map<String, Value>, opts: Options)
let e = "check_parameters: this app has no edit.checkpoint to put the design back with; use set_parameter and measure, and set the values back yourself";
return text_result(&json!(e), true, opts);
};
let outcome = try_parameters(b, &changes, &reports);
let outcome = try_parameters(b, &changes, &reports, &narrow);
let restore = exec(b, "edit.restore_checkpoint", json!({"checkpoint": id}));
match (outcome, restore) {
(Ok(mut reply), Ok(_)) => {
@@ -1904,9 +1963,17 @@ fn check_parameters(b: &mut dyn Backend, a: &Map<String, Value>, opts: Options)
}
}
/// How small `check_parameters`'s reports are: only these bodies (`None`: all), and `measure`
/// summarised as the measure tool's `summary: true`.
struct ReportScope {
bodies: Option<Vec<String>>,
summary: bool,
}
/// `check_parameters`'s arguments, checked before anything is sent: the values to try (a
/// number or an expression each, as `set_parameter` takes its `value`) and what to report.
fn check_parameters_args(a: &Map<String, Value>) -> Result<(Map<String, Value>, Vec<&'static str>), String> {
/// number or an expression each, as `set_parameter` takes its `value`), what to report and how
/// small.
fn check_parameters_args(a: &Map<String, Value>) -> Result<(Map<String, Value>, Vec<&'static str>, ReportScope), String> {
let set = a.get("set").and_then(Value::as_object).ok_or("check_parameters: `set` must be an object of parameter name: value")?;
if set.is_empty() {
return Err("check_parameters: `set` is empty; give at least one parameter name: value".into());
@@ -1939,7 +2006,22 @@ fn check_parameters_args(a: &Map<String, Value>) -> Result<(Map<String, Value>,
out
}
};
Ok((changes, reports))
let bodies = match a.get("bodies").filter(|v| !v.is_null()) {
None => None,
Some(v) => {
let names = v
.as_array()
.and_then(|l| l.iter().map(|n| n.as_str().map(str::trim).filter(|n| !n.is_empty()).map(str::to_string)).collect::<Option<Vec<_>>>())
.filter(|l| !l.is_empty())
.ok_or("check_parameters: `bodies` must be a list of body names")?;
Some(names)
}
};
let summary = match a.get("summary").filter(|v| !v.is_null()) {
None => false,
Some(v) => v.as_bool().ok_or("check_parameters: `summary` must be true or false")?,
};
Ok((changes, reports, ReportScope { bodies, summary }))
}
/// Refuse a name the design has no parameter of, before anything changes. An app without
@@ -1960,14 +2042,32 @@ fn known_parameters(b: &mut dyn Backend, changes: &Map<String, Value>) -> Result
/// Apply the values and read back what was asked for: what each parameter is now, the
/// features rebuilt, and the reports.
fn try_parameters(b: &mut dyn Backend, changes: &Map<String, Value>, reports: &[&str]) -> Result<Value, String> {
fn try_parameters(b: &mut dyn Backend, changes: &Map<String, Value>, reports: &[&str], scope: &ReportScope) -> Result<Value, String> {
let (values, recomputed) = apply_parameters(b, changes)?;
let mut reply = json!({"set": changes, "values": values, "recomputed": recomputed});
// The bodies and each feature's state: one cheap read serves `errors` and `cylinders`.
let design = if reports.iter().any(|r| *r != "measure") { Some(b.call("document.inspect", json!({"measure": false}))?) } else { None };
let design = if reports.iter().any(|r| *r != "measure") || scope.bodies.is_some() {
Some(b.call("document.inspect", json!({"measure": false}))?)
} else {
None
};
// A body named in `bodies` that the design doesn't have (now) is an error that names them.
if let (Some(want), Some(d)) = (&scope.bodies, &design) {
let names: Vec<String> = d["bodies"].as_array().into_iter().flatten().filter_map(|body| body["name"].as_str()).map(str::to_string).collect();
if let Some(missing) = want.iter().find(|n| !names.contains(n)) {
return Err(format!("check_parameters: no body `{missing}` (bodies: {})", name_list(&names)));
}
}
for r in reports {
match *r {
"measure" => reply["measure"] = exec(b, "inspect.measure", json!({}))?,
"measure" => {
let p = match &scope.bodies {
Some(names) => json!({"bodies": names}),
None => json!({}),
};
let m = exec(b, "inspect.measure", p)?;
reply["measure"] = if scope.summary { measure_summary(m) } else { m };
}
"errors" => {
if let Some(d) = &design {
let pick = |key: &str| -> Value {
@@ -1982,8 +2082,11 @@ fn try_parameters(b: &mut dyn Backend, changes: &Map<String, Value>, reports: &[
}
"cylinders" => {
if let Some(d) = &design {
let bodies: Vec<String> =
let mut bodies: Vec<String> =
d["bodies"].as_array().into_iter().flatten().filter_map(|body| body["name"].as_str()).map(str::to_string).collect();
if let Some(want) = &scope.bodies {
bodies.retain(|n| want.contains(n));
}
reply["cylinders"] = Value::Array(cylinders(b, &bodies));
}
}
+21 -15
View File
@@ -80,8 +80,7 @@ impl ImageExecutor for CpuExecutor {
#[cfg(all(test, feature = "raytrace-cpu", not(target_arch = "wasm32")))]
impl ImageHost {
pub(super) fn join_worker_for_test(&mut self) -> Result<()> {
self.0.shared.stop.store(true, Ordering::Release);
self.0.shared.wake.notify_all();
self.0.shared.stop_worker();
let inner = Arc::get_mut(&mut self.0).ok_or_else(|| JobError::new(ErrorCode::Internal, "test must own its host"))?;
if let Some(worker) = inner._worker.take() {
worker.join().map_err(|_| JobError::new(ErrorCode::Internal, "test worker panicked"))?;
@@ -311,8 +310,7 @@ mod light_union_lease_tests {
assert_eq!(job.wait_timeout(std::time::Duration::from_secs(60)).unwrap().progress.phase, Phase::Completed);
// Synchronize this test's completed worker before asserting final Arc release;
// publication wakes waiters just before Running drops its temporary handle.
host.0.shared.stop.store(true, Ordering::Release);
host.0.shared.wake.notify_all();
host.0.shared.stop_worker();
Arc::get_mut(&mut host.0).unwrap()._worker.take().unwrap().join().unwrap();
let retained = job.0.request.raytrace.as_ref().unwrap();
assert_eq!(
@@ -1709,6 +1707,19 @@ struct Shared {
wake: Condvar,
}
impl Shared {
/// Tell the native worker to stop and wake it. The worker checks `stop` and starts waiting
/// while it holds the queue lock, so both happen under that lock: a wake sent between the
/// check and the wait would be lost, and the worker would wait for good (#14500).
#[cfg(not(target_arch = "wasm32"))]
fn stop_worker(&self) {
// A poisoned lock still hands back its guard; holding it is all that matters here.
let _queue = self.queue.lock();
self.stop.store(true, Ordering::Release);
self.wake.notify_all();
}
}
struct Owner {
executor: Option<Arc<dyn ImageExecutor>>,
shared: Arc<Shared>,
@@ -1739,7 +1750,7 @@ impl Drop for Owner {
terminal(&work.handle, JobError::new(ErrorCode::Cancelled, "render host closed"));
}
#[cfg(not(target_arch = "wasm32"))]
self.shared.wake.notify_all();
self.shared.stop_worker();
// Detaching cannot stall the UI; the bounded worker observes stop/cancel at its next unit.
}
}
@@ -2840,8 +2851,7 @@ mod retained_request_tests {
assert_eq!(slot.0.capacity() as u64 * std::mem::size_of::<JobOptions>() as u64, expected);
}
assert_eq!(job.wait_timeout(std::time::Duration::from_secs(60)).unwrap().progress.phase, Phase::Completed);
host.0.shared.stop.store(true, Ordering::Release);
host.0.shared.wake.notify_all();
host.0.shared.stop_worker();
Arc::get_mut(&mut host.0).unwrap()._worker.take().unwrap().join().unwrap();
assert!(job.work_released());
let frame = job.frame().unwrap().unwrap();
@@ -2910,8 +2920,7 @@ mod retained_request_tests {
received.recv_timeout(std::time::Duration::from_secs(60)).unwrap();
job.cancel().unwrap();
assert_eq!(job.wait_timeout(std::time::Duration::from_secs(60)).unwrap().progress.phase, Phase::Cancelled);
host.0.shared.stop.store(true, Ordering::Release);
host.0.shared.wake.notify_all();
host.0.shared.stop_worker();
Arc::get_mut(&mut host.0).unwrap()._worker.take().unwrap().join().unwrap();
assert!(job.work_released());
assert!(job.frame().unwrap().is_none());
@@ -2944,8 +2953,7 @@ mod retained_request_tests {
let presentation = super::super::presentation::Presentation { exposure_ev: 1.0, ..Default::default() };
let derivative = host.submit_regrade(JobId { session: 903, serial: 2, generation: 2 }, &original, presentation).unwrap();
assert_eq!(derivative.wait_timeout(std::time::Duration::from_secs(60)).unwrap().progress.phase, Phase::Completed);
host.0.shared.stop.store(true, Ordering::Release);
host.0.shared.wake.notify_all();
host.0.shared.stop_worker();
Arc::get_mut(&mut host.0).unwrap()._worker.take().unwrap().join().unwrap();
let frame = derivative.frame().unwrap().unwrap();
assert_eq!(frame.linear_exr().unwrap(), original_frame.linear_exr().unwrap());
@@ -3041,8 +3049,7 @@ mod retained_request_tests {
assert_eq!(job.wait_timeout(std::time::Duration::from_secs(60)).unwrap().progress.phase, Phase::Completed);
// Completion publication precedes the worker's last temporary handle release.
// Join this test's worker before asserting exact lease ownership, as in the union test.
host.0.shared.stop.store(true, Ordering::Release);
host.0.shared.wake.notify_all();
host.0.shared.stop_worker();
Arc::get_mut(&mut host.0).unwrap()._worker.take().unwrap().join().unwrap();
let frame = job.frame().unwrap().unwrap();
let held = job.clone();
@@ -3414,8 +3421,7 @@ mod surface_request_metadata_tests {
let job = host.submit(id(40, 2), 1, scene, request).unwrap();
assert_eq!(job.wait_timeout(Duration::from_secs(10)).unwrap().progress.phase, Phase::Completed);
// Wait for this host's last temporary worker handle before exact lease checks.
host.0.shared.stop.store(true, Ordering::Release);
host.0.shared.wake.notify_all();
host.0.shared.stop_worker();
Arc::get_mut(&mut host.0).unwrap()._worker.take().unwrap().join().unwrap();
let frame = job.frame().unwrap().unwrap();
let png_bytes = frame._reservation.bytes;
+36
View File
@@ -2627,6 +2627,42 @@ fn actual_cpu_texture_worker_releases_sources_after_completion_or_cancel_while_s
}
}
// The native worker checks `stop` and starts waiting while it holds the queue lock. A stop sent
// in between must still wake it, else joining the worker waits for good (#14500).
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_stop_sent_while_the_worker_is_about_to_wait_still_wakes_it() {
use std::sync::mpsc::sync_channel;
use std::time::Duration;
let shared = ImageHost::shared(MEMORY_LIMIT);
let (checked, on_checked) = sync_channel(1);
let (stopped, on_stopped) = sync_channel(1);
let worker = {
let shared = Arc::clone(&shared);
std::thread::spawn(move || {
// Hold the lock between the check and the wait, as native_worker does.
let queue = shared.queue.lock().unwrap();
assert!(!shared.stop.load(Ordering::Acquire));
checked.send(()).unwrap();
// A stop that takes the lock cannot finish here; one that doesn't finishes now.
let _ = on_stopped.recv_timeout(Duration::from_millis(500));
// One wait, without checking `stop` again, as native_worker's wait.
let (_queue, waited) = shared.wake.wait_timeout(queue, Duration::from_secs(10)).unwrap();
!waited.timed_out()
})
};
on_checked.recv().unwrap();
let stopper = {
let shared = Arc::clone(&shared);
std::thread::spawn(move || {
shared.stop_worker();
let _ = stopped.send(());
})
};
assert!(worker.join().unwrap(), "the worker missed the stop and waited until its timeout");
stopper.join().unwrap();
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn registered_preview_and_final_profiles_keep_capture_identity_and_completed_mode() {
+13 -11
View File
@@ -305,7 +305,7 @@ enum LCurve {
Spline(usize, usize, usize, usize, bool, u8, bool, Option<crate::Nurbs>),
}
fn layout(geom: &[LinkGeom]) -> Result<Layout> {
fn layout(geom: &[LinkGeom], kind: LinkKind) -> Result<Layout> {
if geom.len() > MAX_LINK_ENTITIES {
return Err(SketchError::TooLarge);
}
@@ -382,8 +382,9 @@ fn layout(geom: &[LinkGeom]) -> Result<Layout> {
let curves = l.curves.clone();
l.lone.retain(|i| !used(*i, &curves));
// A face seen edge-on: every edge falls on one line, and one line end to end stands for
// them all (its vertices can be projected one by one).
if l.lone.is_empty() && l.curves.len() > 1 && flat(&l.curves, &l.pts) {
// them all (its vertices can be projected one by one). A section is not a projection: pieces
// on one line with a gap between them (a ring cut through its hole) stay apart (#14282).
if kind != LinkKind::Intersect && l.lone.is_empty() && l.curves.len() > 1 && flat(&l.curves, &l.pts) {
let (a, b) = extent(&l.pts);
l = Layout { pts: vec![a, b], lone: Vec::new(), curves: vec![LCurve::Line(0, 1)] };
}
@@ -700,7 +701,7 @@ impl Sketch {
/// and ends; other curves, and points on their own (the origin, a vertex), never count as
/// present.
pub fn link_geometry_present(&self, geom: &[LinkGeom]) -> bool {
self.geometry_present(geom, false)
self.geometry_present(geom, LinkKind::Project, false)
}
/// [`Sketch::link_geometry_present`], where a point on its own counts too when a linked
@@ -708,11 +709,12 @@ impl Sketch {
/// cutting the same one again would stack a second point on the first. The end of a linked
/// curve (a corner of the face's border) does not count.
pub fn link_geometry_or_points_present(&self, geom: &[LinkGeom]) -> bool {
self.geometry_present(geom, true)
self.geometry_present(geom, LinkKind::Intersect, true)
}
fn geometry_present(&self, geom: &[LinkGeom], points: bool) -> bool {
let Ok(lay) = layout(geom) else { return false };
/// `kind` lays `geom` out as a new link of that kind would be (see [`layout`]).
fn geometry_present(&self, geom: &[LinkGeom], kind: LinkKind, points: bool) -> bool {
let Ok(lay) = layout(geom, kind) else { return false };
if lay.curves.is_empty() && lay.lone.is_empty() {
return false;
}
@@ -744,7 +746,7 @@ impl Sketch {
/// Add linked geometry. Returns the link id; the new curve and point ids are
/// [`Sketch::link_curves`] / [`Sketch::link_points`] of it.
pub fn add_link(&mut self, kind: LinkKind, source: LinkSource, geom: &[LinkGeom]) -> Result<String> {
let lay = layout(geom)?;
let lay = layout(geom, kind)?;
if lay.curves.is_empty() && lay.lone.is_empty() {
return Err(SketchError::Invalid("the reference projects to nothing on this sketch plane".into()));
}
@@ -815,10 +817,10 @@ impl Sketch {
/// sketch's own curves and constraints on it move to the new entities that match the old
/// ones (`rebuild_link`). Returns `true` when it was rebuilt.
pub fn update_link(&mut self, id: &str, geom: &[LinkGeom]) -> Result<bool> {
if self.link(id).is_none() {
let Some(kind) = self.link(id).map(|l| l.kind) else {
return Err(SketchError::Unknown(id.into()));
}
let lay = layout(geom)?;
};
let lay = layout(geom, kind)?;
// A projected face boundary is a closed loop: crossings and touching points make its
// identity ambiguous, and the link keeps the geometry it had. A face taken with
// `Intersect` is a section: an open chain, which no closed boundary describes.
+31
View File
@@ -361,6 +361,37 @@ fn edge_on_face_lays_out_as_one_silhouette_line_and_stays_current() {
assert!(!sk.link(&link).unwrap().lost);
}
/// A face with a hole cut through the hole by the sketch plane: its section is two pieces on
/// one line, one each side of the hole. Only a projection seen edge-on is one line end to end;
/// a section keeps the gap (#14282).
#[test]
fn intersected_face_with_a_hole_keeps_both_pieces_on_one_line() {
// A pipe's top ring (bore 10, outside 22) cut through its axis.
let section = vec![LinkGeom::Line(Vec2::new(-22.0, 40.0), Vec2::new(-10.0, 40.0)), LinkGeom::Line(Vec2::new(10.0, 40.0), Vec2::new(22.0, 40.0))];
let ends = |sk: &Sketch, link: &str| -> Vec<[Vec2; 2]> {
sk.link_curves(link)
.iter()
.map(|c| match sk.curves[*c].kind {
CurveKind::Line { a, b } => [sk.points[a].pos, sk.points[b].pos],
_ => panic!("a line"),
})
.collect()
};
let mut sk = Sketch::new();
let link = sk.add_link(LinkKind::Intersect, face(), &section).unwrap();
let pieces = [[Vec2::new(-22.0, 40.0), Vec2::new(-10.0, 40.0)], [Vec2::new(10.0, 40.0), Vec2::new(22.0, 40.0)]];
assert_eq!(ends(&sk, &link), pieces, "one line each side of the hole");
assert!(!sk.update_link(&link, &section).unwrap(), "the same section keeps its curves");
assert_eq!(ends(&sk, &link), pieces);
assert!(!sk.link(&link).unwrap().lost);
// Intersecting the same face again finds both pieces already there, so nothing is added.
assert!(sk.link_geometry_or_points_present(&section), "the section is already linked");
// Projected, the same ring seen edge-on is still one line end to end.
let projected = sk.add_link(LinkKind::Project, face(), &section).unwrap();
let [[a, b]] = ends(&sk, &projected)[..] else { panic!("one line") };
assert!([a, b].contains(&Vec2::new(-22.0, 40.0)) && [a, b].contains(&Vec2::new(22.0, 40.0)), "{a:?} {b:?}");
}
/// A conic that comes with a link (projected geometry) takes the curve prefix `n` like a drawn
/// one, not the constraints' `k` (#6173).
#[test]

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