Compare commits

...

70 Commits

Author SHA1 Message Date
bherbruck c820d0048f geom::krawczyk: the one Krawczyk operator, the certificate's (sm_GnEncloseOver) order, extracted (#15140)
The fillet certificate's feet enclosure forms Krawczyk's contraction C = I - Y F' with its q, and K = mid - Y G + C (X - mid), inline at its two sites (the step and the tightening). #15140 wants one operator in the crate, each caller keeping its own strategy (centre, first box, preconditioner, widening). This moves the two computations into geom::krawczyk as contraction() and k_operator(), in the certificate's own order (the fork's): C_ij from delta_ij, subtracting y_ik F'_kj in k's order, q a running rounded-up row sum; K_i from mid_i, subtracting y_ij g_j, then adding c_ij (x_j - mid_j), in j's order. The certificate calls them; q < 1 stays in its own test. Same expressions in the same order, so the certificate's boxes are bit for bit; the dead f_at helper goes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 05:29:24 -05:00
MarkusRe 90bb901ed1 Fillet and Chamfer: an edge saved under a joined tab's old face name is found again at its pick, on the exact edge (#14959)
* claim: exact edge lookup for an edge saved under a joined tab's name (refs #11286)

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

* Fillet: an old edge name that no edge answers to keeps asking unless its pick decides

The reopened part of
a_join_that_makes_the_body_bigger_leaves_round_walls_it_never_touched_alone
saves a fillet whose edge name holds a joined tab's face, as a design saved
before that wall was named after the plate. Only the pick point can say
which edge such a name meant. Four more cases check that it never guesses,
each still asking for the edge again: a point on the round wall but on no
edge; the corner where the wall's top edge meets the plate's straight
side; a name whose feature made nothing on the body; and a name whose
other face (the plate's bottom) does not bound the edge at the point.

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

* Fillet and Chamfer: an edge saved under a joined tab's old face name is found again at its pick

Older builds named some round walls after a face of a tab joined later,
and a fillet on such a wall's edge stored that name
(`F2:top|F4:side:l4#[F2:side:l1]`). The wall is the plate's now, so no
edge's faces answer to the name, and opening the design asked for the edge
again. #11286 found it on the display mesh; the SMLib merge left that out.

locate_edge now lets the pick point decide when no edge's faces answer to
the name even loosely, one of its two faces is still found exactly, the
other finds no face although its feature still made faces of the body
(renamed, not removed), and the point lies inside an edge of the face
still found. The point is tested against the edges' exact curves
(solidcraft_kernel::comb_edge_near), within the usual pick reach and clear
of the edge's ends, where the point is on more than one edge. The mesh's
edge index is the B-rep's; the edge's faces are compared before the index
is used.

a_join_that_makes_the_body_bigger_leaves_round_walls_it_never_touched_alone
passes and comes off tests/KNOWN_FAILING_TESTS.

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

* Joints: a corner's other edge is never the edge the corner was picked from

A joint origin at a corner finds the corner's other edge by its name, with
the point picked on the first edge. With the pick now able to decide an
edge name that no edge answers to, a stale name of the other edge could
find the first edge itself, and the joint would sit at one of its ends.
That edge is not the other one: the corner is gone and asks again, as it
did before. a_corner_whose_other_edge_is_gone_asks_again checks it.

The reopened fillet in
a_join_that_makes_the_body_bigger_leaves_round_walls_it_never_touched_alone
now also has every face of the body filleted fresh, where it was: the
plate is symmetric, so its volume alone would pass a round on a mirrored
edge. renamed_edge's comment says what it checks: the face is gone while
its feature stays, whether renamed or removed, and the point must be the
reference's own pick.

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

* Fillet and Chamfer: a face its feature replaced at the pick is not taken for the old edge

A sketch line swapped for another leaves its feature in place and puts a face of that same
feature at the pick: the reference asks for the edge again, as
`tests::naming::a_removed_segment_fails_the_fillet_asking_for_the_edge_again` expects.

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

* Construct and Joints: two names of a vertex never find one edge; the corner test names a boss's face

A name the pick decides can find the edge another name of the same vertex found, where the body
grew so the vertex lies inside that edge; such a vertex is worked out from its point as before.
The corner test's lost name is a face of a joined boss, so only the corner check refuses it.

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

* Joints: rustfmt's wrap of the corner test's boss

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

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
2026-11-12 08:39:31 +00:00
Daniel Dimitrov e81520bcfc Fillet: a small round on a large part no longer spends a minute checking for hollows behind its edge (#15312)
Before building a round, the fillet checks that it would not cut through the
wall into a hollow behind the edge. That check classified a point against the
body for every edge and cylinder running parallel to the round's edge, and
each classification copied the whole body and rebuilt its search structures.
On a large part that was over a thousand classifications of ~54 ms each.

Now the check works out, by arithmetic alone, the radius at which each
boundary point would open. Only points that open below the round's own radius
can refuse it, so only those are classified, all through one classifier
(smlib's new Brep::classify_points, Body::classify_points in the kernel).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 08:39:15 +00:00
dudedagger42 8d90a2c89f Sketch fillet: lines that already meet need no end to move (#11523) (#13025)
* Sketch fillet: test that lines already meeting need no end to move (#11523)

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

* Sketch fillet: lines that already meet need no end to move (#11523)

meet_lines refused any line whose nearer end was held, even when that end
was already at the meeting point and had nothing to move. Skip those ends,
and merge the two end points with what held either of them.

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

* Sketch fillet: merge only the constraints the corner merge changed (#11523)

The duplicate check covered every constraint in the sketch. Limit it to the
ones the b-to-a point remap changed, and assert that the lengths and the
fixed point of the lines stay.

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

* Sketch fillet: a corner merge keeps the origin and refuses two placements (#11523)

When the end that already sits at the meeting point is the sketch origin,
the other line's end merges into the origin, not the origin into it, so
other curves ending at the origin still share it. Two ends at one place
placed by different expressions or references are refused instead of the
second one's being dropped. Also formats the merge and its test.

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

* Sketch fillet: refuse merging an expression end with a linked end (#11523)

Two ends at one place, one placed by expressions and the other linked to
a model vertex, would make one corner carrying both, and the expressions
would overwrite where the vertex moves it. The fillet is refused.

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

* Sketch fillet test: let-else instead of unreachable! (clippy::unreachable)

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 08:39:13 +00:00
dudedagger42 d37bd9dfef Face pick on a mesh body: the error says it has no faces (#11469) (#12911)
* Test: a tangent plane picked on a mesh body names the mesh (#11469)

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

* Construct: a pick on a mesh body says it has no faces (#11469)

A mesh body has no faces, so a Tangent Plane (or any face pick) on an inserted STL
reported only "no face at ...", as if the pick had missed. When the pick lies on a mesh
body, the error now names it and points to Convert Mesh.

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

* Test: a tangent plane builds on a facet of a converted mesh (#11469)

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

* rustfmt

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

* Mesh pick message: only a mesh body's own triangles are read, listed as a mesh-body use

lies_on_mesh now returns early for a B-rep body and is listed in the
mesh-decision gate's SKIPPED_FUNCTIONS as a mesh-body use.

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 08:39:10 +00:00
Frank e68c50a213 Construct: snap points for points, axes and planes (#11228) (#11233)
* Snap points for Move and Measure (#5912)

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

* Construct: snap points for points, axes and planes (#11228)

Construct Point, Axis Through Two Points, Plane Through Three Points, Axis
Perpendicular to Face (Point) and Offset Plane's Through point take snap
points. A snapped centre or middle is saved as a reference on its edge or
face, so it follows the model; an end point is saved as the vertex or sketch
point there.

Stacked on #5913 (Snap points for Move and Measure).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 08:39:07 +00:00
fkirill fa1bd45ac0 fix: refuse sheet extensions below kernel resolution (#13530)
Co-authored-by: Kirill Frolov <k.a.frolov@vk.team>
2026-11-12 08:34:50 +00:00
bherbruck 829ce9c005 geom::krawczyk: reach/net.rs's Krawczyk step, Newton root and small inverses, moved to a shared home (#15140) (#15310)
#15009 put the crate's most generic Krawczyk step in offset_executive/reach/net.rs: krawczyk<const N> (rounded intervals, F and F' as closures, a first box of twice Newton's step, epsilon-inflation, at most KRAWCZYK_STEPS steps), newton<const N> for its centre, and the per-size inverses. The blend code inlines the same operator three more times (#15140). This moves the step, Newton, the inverses and their constants to geom/krawczyk.rs, unchanged, so those copies can be folded onto it one PR at a time; net.rs imports them. The moved code is the same text in the same order, so net.rs's callers run the same arithmetic, bit for bit.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 08:22:25 +00:00
bherbruck 8ebd56d334 blend certificate: a box the one solve forms across a knot line below C^2 goes cell by cell (#15183) (#15309)
The C1-knot path (#15220) took a pointwise feet enclosure across a knot line below C^2 cell by cell only where the guesses' first box crossed the line. Where the root sits on the line just outside that box (wr698's edge 46: the pipe's foot on its quarter knot, the first box 2.0e-10 past it), Krawczyk's contraction formed a box across the line, which the one solve can never prove (knot_cuts refuses its partials), and the piece stayed not enclosed. Now the loop stops at the first box it forms across such a line, for a use of order 1 or less, before anything is evaluated over it (Inner::Crosses), and the cell path starts from the hull of the first box and that one, each face's cells read from the hull. The trigger is knot_cuts' own refusal (one shared predicate, tested to agree across multiplicities); every box the loop proves today is unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 08:22:19 +00:00
bherbruck b46bbe0735 Kernel rig: sketch.spline.reset_handles is exempt, tested in the engine's sketch tests (#15323)
#6511 added the command without a rig case or EXEMPT line, so the rig's "Every command has a case"
check went red on main. It is a 2D sketch command with no kernel operation, tested by
crates/engine/src/cmd/sketch_create.rs: blend_tests::reset_tangent_handles_gives_back_the_spline_through_its_fit_points.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 07:48:39 +00:00
bherbruck 2220e049bc Web app: mouse buttons follow the browser's, so a chord or a release outside the window no longer leaves one held (#15321)
eframe's web runner turns each pointerdown/pointerup into one egui button event from event.button.
A mouse chord doesn't work that way: the second button and the first release fire pointermove, and
only the last release fires pointerup. Press right then left and let go of right then left, and
eframe reports right down, left up: egui keeps right held and every mouse move orbits. A release
the page never hears (outside the window, alt-tab) left a button held the same way.

The web app now notes every mouse/pen pointer event's buttons bitmask (window capture listeners,
so it is noted before eframe runs a frame from the same event) and adds the button events egui is
missing before each frame. The canvas captures the pointer on pointerdown; pointercancel,
lostpointercapture, blur and a hidden page release everything. Presses are only added for
buttons that went down on the canvas. A shim in our crate, no eframe patch.

Tests: unit tests for the reconciliation; cargo xtask web-api-test checks chords, a drag let go
outside the window, a lost release and blur in headless Chrome (window.solidcraft.buttons()).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 07:40:48 +00:00
bherbruck 9fd103a0f3 Web offline test: give the real build's copies a shared.js, so the update step can check it (#15316)
The step "the open v3 page was given another build's file after the update" asks the open page
for shared.js and checks it is v3's. Only the stand-in app has a shared.js; a real build (the Pages
deploy runs the test with --site dist/web) has none, so the worker rightly falls through to the
network for a file the page's build doesn't keep, the server answers 404 "not found", and the
step fails every time. Since #14593 added the step, every Pages deploy that reached it failed.

Each copy of the real build now gets a shared.js with its own build's content, as the stand-in
has, so the step checks the worker the same way in both modes.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 07:30:27 +00:00
Homero Thompson 64a994f165 UI: the 3D mouse picture shows the device, not the user's inversion (#15251)
The picture follows the Source assignment and the driver's mapping, and
leaves the inversion to the view: the picture shows the device, the view
applies the user's taste to it. The comments said the opposite, claiming
the picture and the view read the device the same way, which is true of
the axes they share and false of the inversion. They now say what the
code does.

A test holds it in place: a driver that turns a row over turns the view
over with it, but not the picture, which still shows the motion the way
the device moved it, on the row the Source assignment gives it.

Co-authored-by: vdirienzo <vdirienzo@users.noreply.github.com>
2026-11-12 07:30:19 +00:00
MarkusRe 09d64fd4dc solidcore: a cusp or a point contact keeps its vertex by default (#14246 batch B55) (#15242)
* claim: #14246 B55

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

* solidcore: is_topological_vertex tests run on the default kernel (#14246 B55)

The cusp, tangent-error and seam point-contact tests now expect the fixed
behaviour from Kernel::default(): a 180 degree cusp is not topological, a
failed start tangent is an error, and a point contact inside a seam edge
keeps its vertex. They fail on main, where both fixes are opt-in flags.

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

* solidcore: retire the two is_topological_vertex flags (#14246 B55)

is_topological_vertex_rejects_cusps_and_tangent_errors (WR-92) and
topological_vertex_has_only_edge_uses (WR-727) become the only path: a
cusp or a vertex with a non-edge use is not topological, and a failed
start tangent is an error. Fields, the upstream path, the SolidCraft
preset lines, the fixes.json entry, the test opt-ins and the flag's
lookup test go.

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

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
2026-11-12 07:23:10 +00:00
Brett Lawson a1b6fcf788 solidcore: one way to find a region's outer shell, so to_record keeps voids the kernel lists first; signed-volume tests for the record writer (#13797) (#15268)
* solidcore-step: signed-volume tests for the record writer's STEP files

Ports step-export's brep_to_model orientation tests (the cases and the
ISO 10303-42 divergence measure from #15175, cb040e168) onto
solidcore-step's record writer, the writer that stays when the two merge
(RFC 0003 item 1.7). Each body goes through to_record and
write::topology::brep, and the signed volume is read from the written
file's Part 21 entities alone, by loops and by same_sense.

The measure is generalised to the record writer's entities: exact
B-spline PCURVEs, faces written without PCURVEs (edges lifted into the
analytic surface's (u, v)), Green's integral split at a B-spline
surface's knots, VERTEX_LOOPs and ORIENTED_FACEs. No expected value or
tolerance changes.

9 of 10 pass. The cup-and-lid case fails before writing: on the default
kernel to_record takes the material region's first shell (the cavity's)
as its outer shell and the record refuses the body.

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

* solidcore: one way to find a region's outer shell; to_record no longer takes the list head

A union that seals a void can leave the void's shell first in the solid
region's list (the default kernel, WR-265). step-export's brep_to_model
already walked the region tree from the infinite region through the
shells' mates to find each region's outer shell; to_record took the
kernel's first shell, so the record made the cavity's shell the outer
shell of two regions and refused the body.

The walk moves into solidcore as topology::brep::outer_shells (the shell
mated to the region enclosing it, by region; deterministic, topology
only, no kernel marks). brep_to_model and to_record both use it: to_record
lists each region's shells with that shell first. Bodies whose outer shell
was already first give the same record as before.

BrepData::from_brep's region order is now the public data_regions, so
to_record can map the data's regions back to the kernel's.

Tests: outer_shells on the cup and lid (both operand orders) and a box;
to_record round-trips the cup and lid on the default kernel with the
outer shell first, and gives the same record with the kernel's list
turned round; solidcore-step's write_signed_volume cup-and-lid case now
passes (10/10).

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-12 07:19:45 +00:00
Brett Lawson 5c6e753309 SolidCraft's blend refusals come from solidcore_ops, not a second mapping of kernel internals (#15033) (#15161)
* Claim: SolidCraft's blend refusals come from solidcore_ops (#15033)

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

* SolidCraft's blend refusals come from solidcore_ops, not a second mapping of kernel internals (#15033)

ops builds a fillet session's refusal once, in solidcore_ops::blend::session_refusal
(TooLarge with its kind and RoomReason, SetbackTooShort, NoRoom, CornerUnsupported, the
rail-leaves-face Unsupported, an equal chamfer's fit as its distance). ops' own blend uses
it; SolidCraft's blend driver now calls it and takes the refusal's fields as they are.

- ops: Error::SetbackTooShort gains the corner's point and whether the least setback
  builds (SolidCraft's fields); RoomReason::words and FitKind::word move to ops.
- smlib: RoomReason and FitKind are ops' (re-exported); the NoRoomReason/ErrorInfo/
  BlendRefusal mapping, sized_on and the chamfer ratio are deleted, with six solidcore
  paths (BlendRefusal, SetbackTooShortWhy, ErrorInfo, FitKind, NoRoomReason, FilletObject).
- Tests: one SolidCraft blend per reason it reaches with boxes and cylinders, and every
  RoomReason through the mapping.

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

* Blend refusals: knife-edge chamfers are sized, one setback-too-short mapping, tests for each change (#15033)

Review of the first commit:
- A knife edge's equal chamfer has no chamfer definition, so chamfer_fit gave None and the
  refusal was an unsized NoRoom. The session now records its knife-edge chamfers; their
  offsets are their distance (the chord between the contacts of the ball of that radius),
  so a radius fit is the distance that fits. ops and SolidCraft both name it.
- Every BlendRefusal::SetbackTooShort goes through session_refusal (the private refused()
  no longer maps it): builds only where the fill's test named it; a rails-cross setback is
  sized with builds: false. ops' SetbackTooShort vertex becomes Option (a vertex the body no
  longer holds no longer drops the refusal).
- A kernel Unsupported/Unported is its own cause over an earlier no-room or corner note:
  the docs say so, and an ops test pins it on a real no-room session.
- The point of a sized refusal is read at Interval::evaluate(0.5), as before.
- Tests that fail without each change: a knife edge chamfered 200 (names 20.99, which builds
  the closed form), a 45-degree distance-angle chamfer with equal setbacks (names 2, builds,
  2.01 refused), a setback short of the rails' crossing (least 5, builds false). The
  tautological RoomReason test is dropped.

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

* Knife-edge chamfer test: the named size is the wall contact's limit, not the chamfer's (#15033)

The refusal names the radius fit (20.99, as main's SolidCraft did); the chamfer's largest
buildable size (184.033 by construction) is not computed yet: tangency-labs/solidcraft#15250.

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

* ops: session_refusal returns exactly the refusals it gives, so SolidCraft maps them with no catch-all (#15033)

solidcore_ops::blend::Refusal holds only TooLarge, SetbackTooShort, NoRoom,
CornerUnsupported and RailLeavesFace, each converting to the ops Error variant of the same
name (From). SolidCraft's refusal() matches it exhaustively; the Error::Internal string
fallback over ops refusals is gone (#15035's guardrail).

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-12 07:14:03 +00:00
bherbruck 4b3ce9dfd4 solidcore: cancel tests count the token's polls, not wall time (#14877)
CancelToken::after_polls(n) makes a token that counts its polls and is
cancelled from the n-th on; CancelToken::polls() reads the count (None for
a token from new(), which counts nothing and pays one None test per poll).
A token made by either() counts as the token it was made from, and each
poll of it is counted by every counting token in its chain.

The two cancel tests that failed under machine load, the coil cut's check
(ops tests/boolean.rs) and the long fillet (ops tests/blend.rs), now
cancel at a fixed poll and assert Err(Cancelled) and exactly one poll after
the cancelling one (the session's, as the call returns), in place of a
thread, a sleep and a 500 ms bound.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 07:13:29 +00:00
SilentWolf75 3d9725dc82 Linux: native Wayland on Hyprland so fractional scale matches the desktop (#10480) (#10869)
* claim: #10480

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

* Linux: native Wayland on Hyprland for fractional scale (#10480)

Automatic window system already kept niri on Wayland; Hyprland's force_zero_scaling plus GDK_SCALE makes XWayland UI ~2.5x oversized, so prefer the compositor scale there too.

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

* chore: re-trigger Defaults check after PR body fix

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 07:04:55 +00:00
bherbruck 4acb6aa1a4 solidcore-step: a face's box decides its poles by the exact test at the face's tolerance (#14025) (#15303)
The STEP reader's face box (kernel.rs form::extent, topology.rs
face_extent) took a boundary point as a pole by the pcurves' speed rule
at reach 0, so a cone's apex or a sphere's pole a file places a few um
off was an ordinary point, and its arbitrary u moved the box. The box
now comes from form::extent, the kernel's exact collapse test, at the
face's tolerance: the largest of its edges' and vertices' tolerances,
never under the file's.

On nist_ctc_04's AP203 export, the speed rule's box built two cone faces
whose pcurves lie 10.2 mm off their edges; with the exact test they lie
within 7e-9, and the body's certified volume encloses the estimate.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 07:02:37 +00:00
dudedagger42 12f5bc9252 Thread: the ISO metric sizes it lacked (M33, M39, M45, M3.5, M7…) (#4153) (#5128)
* Thread: the other ISO 261 metric sizes (M33, M39, M45, M3.5, M7…) are known by name and listed (#4153)

ISO_COARSE has the first-choice sizes and M27 only, so M33, M39, M45, M3.5 and M7 were
refused by name. The other ISO 261 coarse sizes, with their ISO 262 pitches, are now in
ThreadForm::sizes: known by name, offered in the Thread window and written without the pitch.
The automatic size still comes from ISO_COARSE (fit_sizes), so no design changes size.

Refs #11901

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

* rustfmt

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-12 06:56:06 +00:00
bherbruck 892c6370d1 tess: the mesher's lookup maps hash with one fixed fast hasher (#15294)
The mesher's lookup maps (mesher/sag.rs, mesher/poles.rs) and simplify's welding index hashed
their keys with std's SipHash. They now use BitsMap (solidcore-tess/src/hash.rs): simplify's
welding hasher, moved, with whole-word u32/usize writes, bytes taken a word at a time, and a
SplitMix64 finaliser in finish, since a round parameter's float bits end in zeros and the bucket
comes from the low bits. Every one of these maps is only looked up; the one walk of one, the flip
queue in sag.rs, is sorted afterwards, so no output depends on the hash. brep-bench (callgrind):
SipHash writes 2,439,228 -> 139,583 on spline/rational-cap; STL bytes identical on all 211 cases.
Part of #14830.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 06:47:50 +00:00
dudedagger42 20c15af4e7 Chamfer and fillet picks: a tangent join beside the pick is not a second edge (#12215) (#12929)
* Chamfer and fillet picks: a tangent join beside the pick is not a second edge (#12215)

A point where a line meets a tangent arc on a rim named three edges: the line, the arc
and the wall edge between a flat face and a round one. The line and arc carry on through
the point; the wall edge is only tangent (G1), which the smooth-edge flag does not cover,
so it counted as a second edge and the pick was refused. Edges whose faces meet within 2
degrees (as tangent chains read them) no longer count; real corners are still refused.

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

* Pick guard: read the mesh's edges instead of cloning them (#12215)

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

* Ladder: a pick between two tangent seams is refused as nothing to round (#12215)

Cases 07-fillets/016 and 018: after the r3 tangent chain rounds the vertical
edge, the pick (60, 40, 8) lies 3 mm from the two lines where that round meets
the side faces tangentially. Those are seams now, so the refusal says the edge
there is smooth with nothing to round, where it asked for a point along one
edge before. Still a clean refusal; the status in golden.json is unchanged.

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 06:47:30 +00:00
Chemiseblanc 7da403034d Window title: show the active design and unsaved changes (#14791) (#14833)
* claim: #14791

* Window title: show the active design and unsaved changes (#14791)

* Window title: make design-aware titles opt-in

* Window title: translate preference text into five locales

* Window title: show design names automatically without a preference
2026-11-12 06:38:08 +00:00
Homero Thompson 23d61d7023 UI: the face highlight stays on while the view is panned (#2130) (#11886)
* test: reproduce #2130 - the face highlight must stay on while the view is panned

* test: reproduce #2130 - the face highlight must stay on while the view is panned

* UI: the face highlight stays on while the view is panned (#2130)

* The face keeps its highlight while the view moves (#2130)

* Viewport: the lit face stays on while the view is panned or rotated (#2130)

The maintainer asked what the highlight does when the cursor passes over other
faces while the view moves: does it jump between faces or flash? The answer needs
two identities per frame, and they are not the same thing: the geometric face under
the pointer, which does change as the view moves under a still pointer, and the lit
face, which must not.

Two tests, each with its own scene, measure both. In the pan the camera target
moves and the pointer travels over another face, yet the lit face stays the same
one for every frame of the gesture. The same holds for a rotation, which with the
default fusion navigation does not go through the pan path at all: Shift and two
fingers orbit the camera under the pointer.

They go next to the other #2130 tests in this file, not in a file of their own.

* Viewport: build the camera with the viewport crate in the hover tests (#2130)

Upstream moved Camera out of solidcraft_engine::render and into
solidcraft_viewport, so the three test scenes that built one by hand stopped
compiling: the type no longer exists at the old path. The import was already
there, so the scenes now use it directly.

* Settings profiles: count the new preference in the profile key tests (#2130)

The new keep_hover_while_navigating preference joins the profile keys, so the
two tests that count them have to count it too: they still expected 105 and
the suite failed on both.

---------

Co-authored-by: Hermes Agent <agent@local>
Co-authored-by: Hermes Agent <hermes@localhost>
2026-11-12 06:38:05 +00:00
Frank 7e0e63504e Spline tangent handles at every fit point (#6502) (#6511)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 06:30:01 +00:00
Homero Thompson 14a9cf73f3 web: the Korean UI loads its own Hangul face (#15208) (#15259)
* web: the Korean UI loads its own Hangul face (#15208)

* web: the Korean UI loads its own Hangul face (#15208)

The Korean interface language was broken in the browser build: every Korean
label rendered as an empty tofu box, because the web shell gave a Korean UI
the Japanese face, which has no Hangul glyphs.

Ship a Hangul, kana and Latin subset of Noto Sans KR as CraftUI-KR.otf and
fetch it on demand for a Korean UI, the way the Chinese faces are fetched
for a Chinese one. The face leads the sans families for a Korean UI, so
Korean text typed into a design draws as well.

The subset is renamed Craft UI KR under the OFL, and the attribution rows go
in attribution/pending/korean-web-font.md, so ATTRIBUTION.md is not edited.

---------

Co-authored-by: SolidCraft Contributor <contrib@vdirienzo.local>
Co-authored-by: vdirienzo <vdirienzo@users.noreply.github.com>
2026-11-12 06:29:45 +00:00
Steve Jason 7d48e91b39 Parameters: a length typed as 2,5 or 2 1/8 gets the design unit (#15300)
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-11-12 06:29:13 +00:00
Frank f84ef28205 Show saved filenames in document tabs (#7267) (#7298)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 06:21:21 +00:00
Brett Lawson ef542c2fe6 solidcore: blend failures carry a typed reason (part 7: fillet corners) (#14953)
* solidcore: claim part 7 of the blend typed errors (fillet corners)

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

* solidcore: fillet corners fail with a typed blend reason instead of bare statuses

The corner code (blend/fillet_corner and blend/fillet_corner/topology)
returns BlendFailure::Failed / Missing / InvalidValue in place of bare
SM_ERR, SM_ERR_NULL_POINTER and SM_ERR_INVALID_INPUT statuses, each with a
phrase for what went wrong. Every site keeps its operation and status, so
the decisions that read upstream_status() behave as before. The corner
files leave the WAITING ratchet; the corner p1 goldens runner reads
blend_status.

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-12 06:20:50 +00:00
CWallyMan b532aa94b6 Render workspace: can be opened while Isocurve Analysis is on (#14993) (#15248)
* claim: #14993

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

* Render workspace: open while a look-only Inspect tool is running (#14993)

Isocurve Analysis leaves its face-picking tool running, and the Render
workspace refused to open while any tool ran. A running Inspect tool that
only picks what an analysis shows no longer locks the workspace switcher:
choosing Render ends the tool as Esc does. Sketches, dialogs and modelling
tools still lock it.

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

---------

Co-authored-by: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 06:18:23 +00:00
jyviko d5c63b1577 solidcore: the inverse of an affine map, and ops::affine::inverse (#15292)
Transform::inverse (math.rs, not upstream): p = (p' - t) A^-1, the linear
part the adjugate over the determinant (row i the i-th components of
b x c, c x a, a x b for A's rows a, b, c, over a . (b x c)) and the
translation -t A^-1. InvalidInput ("math::Transform") for a map
is_regular does not prove regular at ratio 0. A mirror in a plane square
to an axis is its own inverse exactly.

solidcore-ops::affine::inverse(matrix) forwards it, Argument for a matrix
not proved regular at REGULAR_RATIO (as Body::transform refuses it). The
ban list keeps inverse for ops beside the other maps.

Tests: kernel, a turn off the origin then a move and a scale, after its
inverse either way, is the identity within 1e-15 of its size; an axis
mirror's inverse is itself exactly; a flattening and a projective map are
refused. Ops, then(m, inverse(m)) and then(inverse(m), m) are the identity
for a turn and move, with a scale, and a slanted mirror; an axis mirror is
its own inverse exactly; a flattened and a NaN matrix are refused.

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

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
2026-11-12 05:39:46 +00:00
jyviko 9bdb116cea Fillet definitions through the record: Surface::Blend (#15010)
* solidcore-record: a face's surface may be a blend by its definition

`Surface::Blend { definition }`: a procedural blend's definition S, or a round of an exact
class's (`brep::blend::BlendDefinition`), with the face's `nurbs` its net B, stored once there.
The records are the kernel's plain data, mirrored here as `eval::geometry` mirrors the record's
surfaces: `BlendDefinitionRecord` and the records it holds, `ProceduralBlendRecord` for a support
that is itself a blend (no face, so it keeps its net), and `ExactRoundDefinition`, the kernel's
`ExactRoundRecord` without the net's form. Validation is structural, as the kernel's records':
every curve and surface, finite numbers, nesting at most three definitions deep, the items
counted against the record's budget, and a blend label needs its NURBS form.

Tests: a blend face validates and round-trips number for number, and is refused without its
net, nested too deep or with a number not finite; a reader whose surface kinds predate the label
refuses it by name ("unknown variant `blend`") rather than misreading it.

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

* solidcore: a face's blend definition read off for its record, and given back certified

`convert::face_definition` reads the definition a face's surface carries, without its net (the
face's surface is the net): a procedural blend's, or a round of an exact class's whose net has
no analytic label of its own (a surface of revolution, the rational classes' B-spline). A round
on an analytic surface or a ball patch keeps that label in a record, as before, and a definition
whose supports a record cannot hold goes as its net. `FaceDefinition` and
`ExactRoundDefinition` (an `ExactRoundRecord` without its net's form) are the plain data.

`convert::adopt_definitions` gives faces their definitions back: the net is the face's NURBS
surface as built, the definition is made from the record exactly and the net certified against
it again before the face's surface becomes the blend. A net its definition does not certify
refuses the face with the new `SolidError::DefinitionNotCertified { face, eps }`, never kept with
a larger bound; a cancel passes through.

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

* solidcore-convert: a blend face's definition crosses between the record and the kernel

`blend::blend` and `blend::record_blend` copy a face's blend definition field by field between
the record's `BlendDefinition` and the kernel's `FaceDefinition`. A body written as its record
(`to_record`) writes a blend face as its definition with its net as the NURBS form, wherever the
kernel reads one off it; a record built as the kernel's body (`to_kernel`) builds a blend face on
its net, then gives it its definition back, rebuilt and the net certified again
(`adopt_definitions`), refusing the record for a net its definition does not certify. The
record's blend label has no kernel surface of its own: `surface` refuses it by name, as the
curves and surfaces holding one do.

Test: a procedural definition with a nested blend support, and an exact round's, cross both ways
unchanged.

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

* solidcore-tess, solidcore-step: a blend face is meshed and written as its net

A blend label is the face's definition; its geometry is its NURBS form. The mesher names the kind
"blend" in its errors, and the STEP writer writes the face as its NURBS form, pcurves and all, as
it writes a ball patch: STEP has no entity for the definition.

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

* solidcore-ops: a blend face's geometry is its form

A blend label is the face's definition, with no surface or normal of its own: the record's
geometry check reads such a face by its form (its definition is checked structurally and
certified by the kernel when the face is built), and `label::form_turns` says its form turns
nothing, as for a face with no form.

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

* solidcore-ops: a body's blend faces between its record and the kernel

`blend_definition::record_definition` reads a face's definition off its surface as a record keeps
it, and `blend_definition::adopt` gives the faces of a body built from a record their definitions
back, rebuilt and their nets certified again: the modelling layer's calls for a client that
builds bodies its own way (through flat arrays) and needs its blend faces to cross with their
definitions, through the kernel and solidcore-convert.

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

* smlib: a body's blend faces cross to and from its record with their definitions

The flat arrays still carry a blend face as its net alone; the definition crosses beside them
through the modelling layer (`solidcore_ops::blend_definition`). Exporting a body
(`to_native_report`, `to_native_ordered`) writes each face whose surface carries a definition a
record keeps (a procedural blend, a round of an exact class whose net has no analytic label) as
`Surface::Blend` with its net as its form. Importing a record (`from_record`,
`from_record_keeping_splines`) builds a blend face on its net, then gives it its definition
back, rebuilt and the net certified again; a net its definition does not certify refuses the
body (`DefinitionNotCertified`), and so does a body the build gave another number of faces. The
record → arrays mapping sends a blend face as its NURBS form, and record SSI refuses a blend
label as it refuses a ball patch's.

Tests: the round on a bore's rim is written by its definition with its net, survives save and
reopen number for number, comes back from a rebuild with the same definitions, and the rebuilt
body takes a shell.

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

* geom, kernel, cli: a blend face is its definition on its net

A blend label is the face's definition; its geometry is its NURBS form.
- The record's rigid motion (`placement::moved`) refuses a record with one (`None`): moving a
  definition's supports, leader and seeds is the kernel's, and `transform_matrix` then moves the
  body through the kernel's transform, which moves the definition with its net. The affine map
  (`mapped`) refuses it as it refuses every labelled face.
- Hidden lines draw a blend face as its net: the definition is dropped there, for the drawing
  only (`hlr::shifted`).
- The record evaluator, iso curves, the face kinds (freeform) and the offset's analytic surfaces
  (none) read it as its form, as a ball patch's label is read.
- The rig names it `blend` in `surface_types` (tests/kernel-cases/README.md).

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

* Native B-rep: the blend face, and its design-format note

docs/native-brep.md gains the `blend` {definition} surface row and its paragraph: the face's
`nurbs` is its net, stored once; the kernel builds the face on its net and certifies it against
the definition before it is an operand. A pending design-format change says what an older design
and the last release do with it: as the ball patch before it, no SCBREP bump (an older reader
refuses the unknown kind by name, a model-cache miss; solidcore-record's test shows it).

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

* smlib, solidcore-ops: a body's blend definitions through body-level ops calls only

The application reaches a body's blend faces through the modelling layer alone, adding no kernel
path: `solidcore_ops::blend_definition::definitions(k, body)` walks the body's faces in their
order and reads each face's definition, and `adopt(k, body, &[(index, definition)])` gives faces
named by that index their definitions back, an index past the faces refused as
`Error::Argument`. A net its definition does not certify reaches the application as the new
`solidcore_ops::Error::DefinitionNotCertified { face, eps }`, the face by its index; other kernel
refusals as the operation's. crates/smlib/src/core/definition.rs now calls ops alone and imports
nothing from `solidcore::`.

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

* Blend definition record: CI's compile error and clippy findings

- geom `placement::moved`: a face's or edge's NURBS form is moved by a match (the motion of a
  form is an `Option`, so `map(..).transpose()` was the wrong tool).
- The definition enums box their variants (`brep::blend::BlendDefinition` in the record,
  `FaceDefinition` in the kernel): the definitions are large and of different sizes.
- solidcore-record `brep::blend`: the weight checks are one `if let` chain each.
- solidcore `procedural_blend::convert`: an unused operation name goes.

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

* Kernel cases: a blend face is a blend, its net's degrees read on its NURBS form

A round of the exact class is now recorded as its definition
(Surface::Blend) on its net, so the `surface_types` check names it
`blend`, and the `splines` check reads the degrees on the face's NURBS
form (its net). The eight cases that named these rounds `bspline` now
name them `blend`; their spline degrees are unchanged:
cov-fillet-exact-coaxial-cones-r2, -cone-cylinder-sharing-a-ball-r1,
-cylinders-touching-at-a-point-r1, -oblique-plane-cone-rim-r1-5,
-oblique-plane-cylinder-rim-r2, -sphere-coaxial-cone-r3,
-sphere-plane-rim-r2 and cov-fillet-tee-pipe-r8-into-pipe-r20-r3
(surface_types: bspline -> blend).

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

* solidcore: a variable round's leader is its edge's curve cut to its stations

An edge on an unbounded line (a box's) is held as a two-point NURB a
million times the part's size (control points at y = +-24691340), and a
variable round took that curve whole as its leader. Its control points
round at an ulp of 2.5e7, about 1e-9 mm, and recertification, which
takes the round in its own frame (the blend translated by its centre),
carried that rounding into the leader: leader(1) moved from y = 20 to
19.999999998137355 (2^-29), the re-solved feet moved with it, and the
equal-radius round of blend_variable::variable_radius_on_a_box_edge,
whose net is 7.36e-11 from its definition, was refused against its
certified 2.2e-10 (delta 1.86e-9). Recertified unmoved it passes, so the
bound was right and the leader was not.

The leader is now the edge's curve cut to the stations' parameters
(within its natural interval), its control points of the part's size;
the round is built on the cut curve, so build and recertificate solve
the same stations. Measured: the equal-radius round recertifies at
7.36e-11 against 2.2e-10 after the record round trip; the 2 -> 5 round's
certified bound is 3.16e-6 (it was 5.06e-7 on the uncut leader) and
recertifies at 3.159e-6.

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

* solidcore: a derived cylinder's and cone's numbers come from the form's rows

The NURBS form is a blend definition's record's one truth for its
supports, and AnalyticSupport::derive must give back the support the
form is. It took a cylinder's and a cone's numbers from the recognisers
(IsNurbSurfaceCone), whose chained computation tilts the axis by about
1e-13 (an ulp of the origin's size): the raked wedge's cylinder supports
(|origin| ~ 70) came back tilted 1.3e-13 and build refused them as not
the form's parametrisation, so the body read back from its record was
refused (boolean_regression_tests::box_intersect_splits_the_end_off_a_
raked_wedge_after_its_end_rounds).

derive now reads them off the form's control structure. Each circle row
is a rational quadratic arc p0 p1 p2 whose tangents meet at p1, so its
centre is c = p1 + (m - p1) |p0 - p1|^2 / |m - p1|^2 (m the chord's
midpoint) and its radius the ends' distance from it. The axis is the
line through the first and last rows' centres, the origin its point
nearest the recogniser's, the x axis the recogniser's made square to
it; a cylinder is a cylinder by its kind, a cone's angle the radii's
change along the axis. The recogniser gives only its conventions (frame
turn, sweep, generator range, swap); build and evaluates_as judge the
result unchanged. Sphere and torus still derive through the recognisers
(a follow-up).

Tests (derive_revolved_supports): the raked wedge's ten cylinder support
forms, form -> derive -> build, within 64 ulps; a tilted quarter cone
at |origin| 1e3 and 1e5, and a tilted quarter cylinder at 1e3.

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

* solidcore: derive's positive checks in the crate's NaN-refusing form

The circle row's and the axis's positive checks were `!(x > 0.0)`, which
solidcore's clippy refuses (neg_cmp_op_on_partial_ord). They are
`x.partial_cmp(&0.0) != Some(Ordering::Greater)` now, as the file writes
its other NaN-refusing comparisons: a NaN still refuses.

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

* solidcore: a support is built as its own kind, the recogniser giving conventions only

AnalyticSupport::build's on_form took the kind from the recogniser and
refused a support of another: a cylinder about 1e5 from the world origin
(a tilted quarter cylinder, radius 1.5, at (6e4, -4.8e4, 6.4e4)) is read
by Cone::is_nurb_surface_cone as a cone of semi-angle -1.1e-10 degrees,
so its derived cylinder was refused before evaluates_as was asked. As
derive now does, on_form takes only the recognised surface's
conventions (frame turn, sweep, generator range, swap) and builds the
support's own kind with its own numbers: a cylinder sets its angle to 0
and is a cylinder. evaluates_as judges the result, unchanged. The
recogniser is untouched (its misread is a follow-up issue). The tilted
cylinder at 1e5 is back in derive_revolved_supports, which passes, with
wr698 and the raked-wedge regression.

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

* solidcore: derive turns its axis with *= (clippy's assign_op_pattern)

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

* solidcore: derive reads a cone's apex row as its axis point of radius 0

AnalyticSupport::revolved_from_rows took each row of a cone's form as a
circle and dropped a row collapsed to one point: its arc has no chord, so
circle() returned None. A cone up to its tip has two rows, the base circle
and the apex, so one circle was left, the axis had no length, and derive
refused the support as not of its kind. The fillet rig's
cov-fillet-exact-cone-cylinder-sharing-a-ball-r1 (a cone to its apex,
rounded where a cylinder meets it) built its round, then the body read
back from its record was refused (SM_ERR_INVALID_INPUT on Hill).

A row whose control points are one point, apart only by the rounding of
dividing each homogeneous point by its weight (gamma_4 of its size, as
euclidean_net makes them), is now the axis's point of radius 0: the
apex. The axis runs from the base circle's centre to it, and the angle
and base radius follow as for any two rows.

Checked locally (owner-approved for this PR): the case fails at
449e72c1f3 with the reported refusal and passes with this change. Test:
derive_revolved_supports' an_off_axis_tilted_cone_up_to_its_apex_round_trips,
a tilted quarter cone up to its apex (apex row last and first) at about
1e3 and 1e5 from the origin, form -> derive -> build within 64 ulps.

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

* solidcore: a re-certificate stops its levels on the certified bound's least value

A round's span over its target is halved at construction, and each half is
certified from its parent's finest level (gn_certify_span_reusing's inherit
path). Recertification takes each half fresh from level 0, to the target,
and close_level's least-value stop ended it after level 0: that least value
took the uv curves' term in the fork's form, which steers the levels but is
not under the bound certified in each parameter's own form (#2690). On
cov-junction-tee-offset-round the half [14.92, 15.31] stopped at 4.27e-6
(fork 5.39e-6) against its target 3.97e-6; level 1, the pieces construction
used, certifies it at 3.83e-6. The oblique unequal tee's span 103 likewise
(4.48e-6 -> 2.68e-7).

To the target (SpanReuse::to_target, recertification's), the stop now takes
each parameter's own least uv term (LevelAcc::uv4_lo, folded as the fork's
is), a lower bound on every level's certified bound. Construction's levels
are steered as before. No tolerance or expected value changes.

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

* solidcore: derive takes a cylinder's axis from its columns

AnalyticSupport::revolved_from_rows took every axis as the line through
the first and last rows' circumcentres. A circumcentre of a shallow arc
carries its control points' rounding amplified by the radius over the
sagitta: on the shelled case of the engine's
the_pleats_join_at_every_wall_from_2_4_to_2_6_mm, a 20 mm arc of radius
77.6 mm extruded along Z got an axis tilted by 1.3e-14. build refused it
as not the form's parametrisation, face_surface kept the face as plain
NURBS, and FilletFrontInner and FilletRearInner failed with
SM_ERR_INVALID_INPUT at a 2.4 mm wall.

A cylinder's generators are its form's columns, and each column's first
and last control points differ by the stored extrusion, exactly for an
extruded circle. The axis is now the sum of those differences, through
the first row's centre. A cone's generators meet at its apex, so its
axis stays its centres' line.

Measured locally (the pleat test, approved narrow run): old (recognised)
and new numbers agree within 3.2e-12 in origin and radius on all 31
distinct cylinder forms; the failing form's old axis is exactly Z and the
centres' axis is off by 1.27e-14; with this change all 288 derives build
and the test passes. Test: derive_revolved_supports'
the_pleated_cases_cylinder_supports_round_trip, the 31 forms as derive
met them (data/pleated_case_supports.jsonl), form -> derive -> build
within 64 ulps; it fails with the centres' axis and passes with this.

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

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 05:39:44 +00:00
Awais Akbar cf5df700fb Sheet metal: contour flanges accept tangent arcs (#15287)
A contour flange's chain may now hold circular arcs tangent to the lines
beside them. An arc is a bend of its own radius: the profile surface when
the bend turns toward the material, or the inner surface when it turns away.
A profile that starts with an arc has no base panel.

The flat length follows the bend allowance, and the folded solid is one
closed body.
2026-11-12 05:38:59 +00:00
Awais Akbar dacbb52a76 Sheet metal: a sharp bend keeps only its outer face (#15288)
A bend with radius 0 has no inner cylindrical surface: the inner faces of
the two panels meet along a line. The folded solid now leaves that collapsed
face out and closes the bend zone's end walls at the point, instead of
building a zero-area inner cylinder. A sharp fold is one valid closed body
with one outer cylindrical face per bend.
2026-11-12 05:38:31 +00:00
Brett Lawson 386da25848 solidcore: certified stitch gap bounds for ellipses, conics and other curve kinds (#13724) (#15254)
* Claim: certified stitch gap enclosures for ellipses, conics and other curve kinds (#13724)

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

* solidcore: certified stitch gap bounds for ellipses, circles, hyperbolas, parabolas and planar conics (#13724)

The stitch's gap report refused every closure on a curve other than a line or
a B-spline, so stitching cylinder faces along a circle, or planar faces along
a conic arc, returned Unsupported.

Ellipses, circles, hyperbolas and parabolas are evaluated through their stored
rational B-spline, so the existing interval de Boor enclosure and the shared
control-net difference now cover them exactly. Planar conics (Conic) are
enclosed by interval evaluation of their rational parametrisation, each
ellipse branch separately; two conics of one kind and rotation at the same
parameters are bounded through the difference of their coefficients, so the
bound follows the gap rather than the curves' size.

Hermite, iso, offset, projected, on-surface, in-volume, tangent-field and
composite curves stay Unsupported.

Tests: stacked half-cylinder faces (circle edges) with a gap and with an exact
match, and planar faces split by a hyperbola, parabola, ellipse and planar
conic arc, each checked against the closed-form translation; all six fail on
main with Unsupported. Unit tests check the conic enclosures cell by cell and
the coefficient-difference bound against its closed form.

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-12 05:07:40 +00:00
bherbruck 8bb806564c solidcore: retire three nurbs UpstreamFixes flags (#14246, batch A9) (#15255)
WR-37 (bspline_simple_offset_matches_parameterization), WR-668
(curve_check_zero_speed_tangent_end) and WR-502
(fillet_corner_sphere_from_four_arcs) become the only paths; the flags,
preset lines, fixes.json entries and the tests' off halves go. Each
removed path was upstream's, so each header gains a Deviations entry.
edge_ends returns EdgeEnds rather than an Option. WR-37's unmatched-mode
test is converted to check the offset against the exact locus; the
four-arc test under the centre check now expects the sphere. No goldens
change (283/283).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 04:49:36 +00:00
bherbruck 3096171e65 Pages: build the site on Blacksmith with a 120-minute limit, so the web app deploys again (#15295)
The web app's release build now takes more than the build job's 60 minutes on
ubuntu-latest, so every Pages run since 2026-11-10 was cancelled at the limit and
the site and app.solidcraft.io stayed on the old build. The build job moves to the
same Blacksmith runners CI uses, with a 120-minute limit as a margin; the deploy
job is unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 00:26:26 -05:00
Jackson Oursland a2dd5a3baf Save: progress and Cancel until the file is replaced (#9560)
A save written beside the window had no way to stop and said nothing of
how far it was: its row in the operations panel had a disabled X and a
moving stripe. Saves already write a temporary file beside the target
and replace it in one rename, so a save can stop safely up to that
point and leave the file as it was.

- Preparing the file: the design container is built a piece at a time
  under the kernel's watch, and stops with "save cancelled" between
  pieces (a large mesh or model cache being compressed) and before the
  model cache is encoded, rather than only once all of it is built.
- Writing it: the atomic write (io::safe_write) writes a piece at a
  time, reports the bytes written, and when cancelled stops, removes
  its temporary file and returns "save cancelled". Its last check is
  before anything is replaced: before the backup (<file>.bak) when one
  is kept, so a cancelled save never refreshes the backup either.
- The save command checks for Cancel once the design is prepared.
- The save worker runs under a watched progress. Its row shows how far
  the file is written, in per cent, and its X cancels it; the row says
  "Cancelling…" until the worker stops. A save already replacing its
  file goes through and ends done. A cancelled save is not an error:
  the status says it was cancelled, the design keeps its unsaved
  changes, and a close or quit waiting for it stays open.
- Rows whose steps are bytes (a save written, a STEP file read) show a
  percentage instead of a step count; ui.operations says `percent`.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 04:21:16 +00:00
bherbruck 2b1cc1d77a skin: trace the walls when the sections' knots differ, and for open profiles (tangency-labs/solidcraft#13632) (#15244)
`create_skin_primitive_with_sources` now traces two cases it refused before.

Sections whose knots differ: making the sections compatible (`make_compatible_knot_tol`) can rescale a section's knots to [0, 1], merge knots within its tolerance and insert the others'. It now records, per section, where each of its knots went (`KnotMap`), read off the steps' own knot vectors: the rescaling and the merge rewrite a curve's knots index for index, a degree elevation keeps the distinct values (checked), and refinement only inserts. The map is handed out through `create_skin_srf_boundary_continuity_mapped` and `create_skinned_surface_mapped`; the plain functions call them without maps, so the default path only gains observations. Each segment's span is mapped onto the surface's knots before the walls are assigned. New refusals by name: `SegmentMerged` (a curve shorter than the merge tolerance collapses onto one knot) and `KnotMapBroken`; `SectionsNotKnotIdentical` and `JoinedCurveUnreadable` are gone.

Open profiles: `build_composites_from_curves` also returns each composite's segment sources (the input curve, or none for a gap line), and the skin's open path records each profile's breaks and segment curves, so open loops trace as closed ones do.

The `SkinSources::Traced` doc no longer claims a face holds one curve of each section: two curves meeting straight on can share one.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 04:18:10 +00:00
nedawson f5a74ee7d4 Windows: the format step of cargo xtask ci runs instead of failing on a too-long command line (#15236) (#15237)
* claim: #15236

* xtask: the format step runs on Windows, rustfmt gets the files a few at a time (#15236)

`cargo fmt --all` hands the root file of every target, of the workspace
and of its path dependencies (solidcore's crates), to one rustfmt
process per edition. On main that is a 68,409 character command line
for edition 2021, and Windows starts none longer than 32,767: `cargo
xtask ci` failed at its first step with os error 206 before reading a
file. `cargo fmt -p` is no way round it, the solidcore package alone
has 492 targets.

`cargo xtask fmt [--check]` collects the same files the way
`cargo fmt --all` does (`cargo metadata --no-deps`, then each path
dependency's manifest) and runs rustfmt on as many at a time as fit,
on every platform. `cargo xtask ci` uses it. An argument other than
--check is an error, so a mistyped --check does not rewrite files.

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

---------

Co-authored-by: nedawson <177795407+nedawson@users.noreply.github.com>
Co-authored-by: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 04:13:45 +00:00
dudedagger42 ff166a3401 Pattern of bodies: each copy keeps its source's appearance and material (#12115) (#12975)
* Pattern of bodies: each copy keeps its source's appearance and material (#12115)

A Pattern of bodies made copies with no appearance and no material, so a steel
box's copies weighed the default and a red post's copies were grey. A Move copy
already carries both (#6423); the pattern command now does the same, sharing that
helper, which takes each source's copies together.

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

* Body pattern: a pattern with no or uneven copies is still added (#12115)

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 04:13:43 +00:00
dudedagger42 47ef5cb5b6 Checkbox grid: fewer columns when a translated label would be cut (#11089) (#13093)
* Checkbox grid: test that columns give way to labels that do not fit (#11089)

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

* Checkbox grid: fewer columns when a translated label would be cut (#11089)

checkbox_grid split the row into fixed columns whatever the labels, so a long
translation was cut to "...". Measure the labels and give up columns, down to
one, while the widest does not fit.

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

* Checkbox grid: cargo fmt

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

* checkbox_grid: translate labels through solidcraft_ui_text after the ui-kit move (#11089)

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 04:13:13 +00:00
dudedagger42 3c93d21ca4 Profiles: a circle drawn twice is one hole of the ring around it (#10521) (#13002)
* Profiles: test that a circle drawn twice is one hole of the ring around it (#10521)

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

* Profiles: a loop drawn twice is one hole of the loop around it (#10521)

assemble made every loop nested in another a hole of it, so two circles on
top of each other cut the ring twice and the ring lost the area of one disc.
Of holes with the same boundary, only the first stays.

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 04:13:10 +00:00
fkirill 6754aa1a0e Fix spherical revolve cuts that fail after radius changes (#15218)
* fix: preserve tiny contacts in spherical revolve cuts

Restore the kernel fix from #13550 onto the vendored SolidCore tree.
Preserve exact knot data in ellipse query copies and bound clipped analytic
intervals by their whole physical extent before representing point contacts.

Use a radius-69, centre-z-72 synthetic hemisphere cut that reproduces status
1001 on parent f6d044089. The closed-form expected volume is
72904.37954157148 mm3, with less than 4e-7 mm3 from the fixed X offset.

Six plane goldens become point contacts at their existing caller tolerance:
adv_tc_segment_inside_tiny (1e-7 < 1e-6), adv_tc_big_tol (3 < 3.5),
trim_circle_tiny (diameter 2e-12 < 1e-6), gen0411 (10.5972 < 50000),
gen0951 (10.3687 < 100), and gen1708 (11.3861 < 50000).
Each entire curve fits inside the supplied tolerance. The big-tolerance
case produces a 13-unit line and a point contact for its three-unit tail.

Fixes #13612
Fixes #13526
Replaces #13550

Co-Authored-By: Codex <noreply@openai.com>

* fix: enclose rounding when classifying analytic contacts

Reuse the kernel's Rust-1.85-compatible next-up implementation in
geom::certified and share it with interval arithmetic. Bound the line norm,
parameter spans, products and angle conversion outward before accepting a
clipped analytic interval as a point contact.

A line with scale 1.3 and interval [-0.3, 0.4] computes an extent just below
0.91 even though its exact extent exceeds that tolerance. Keep the analytic
curve when the rounded upper bound reaches the tolerance. Cover that case,
circle diameter equality and the float helper's boundary values in the
existing unit-test modules.

Co-Authored-By: Codex <noreply@openai.com>

---------

Co-authored-by: Codex <noreply@openai.com>
2026-11-12 04:12:54 +00:00
MarkusRe b1ebc2c2da Orbit: the default pivot is the middle of the visible view, where Home puts the model (#14651) (#14667)
* claim: #14651

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

* Test: an orbit straight after Home turns the model about the free view's middle (#14651)

In the default orbit mode, Home moves the model to the middle of the part
of the view the Browser leaves free, but orbit turns about the centre of
the whole window, so the model swings off to one side. Fails on main.

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

* Orbit: the default pivot is the middle of the view the panels leave free (#14651)

Orbit around the view's centre now turns about the point at the camera
target's depth under the middle of the free part of the view
(Layout::middle), the middle Home and Fit move the model to. It is chosen
once when a drag, a modifier orbit or a spin begins. Orbit around the
cursor or the origin, the 3D mouse and the Render workspace are unchanged.

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

* Orbit: a pan or zoom in the same gesture picks the view-centre pivot again (#14651)

The point under the free view's middle is kept only while it is still
there; after a pan or a zoom inside one trackpad gesture or held drag,
the orbit turns about the point under the middle now. Comments say when
the pivot is the camera target.

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

* Tests: trackpad, Shift-drag and touchpad orbits turn about the free view's middle (#14651)

Seven input tests checked that an orbit in the default mode keeps the
camera target, the point that was the window's centre. With the Browser
open in the test harness, the orbit now turns about the point under the
free view's middle, so the tests check that this point keeps its place
in the camera's frame, and expect the orbit about it.

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

* Scenarios: Alias and Creo orbit steps check the turn, not an unmoved target (#14651)

Both pinned Orbit around to the view centre so that the camera target
stayed put during the orbit step. The view-centre pivot is now the middle
of the free view, which moves the target as the view turns, like the
other pivots. The orbit steps keep checking yaw, pitch and zoom; the pan
and zoom steps keep checking panned.

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

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
2026-11-12 03:38:22 +00:00
Sam e21c78b940 Split Face dialog: editing a split by sketch curves keeps its sketch and loops (#15252)
The dialog has no box for a sketch. On OK it sent only the faces and the
plane shown in its tool box, so a split by sketch curves was rebuilt as a
split by that plane. It now keeps the stored sketch and loops in the
dialog's extra parameters and sends them back unchanged.
2026-11-12 03:25:57 +00:00
lior 330c859f11 Extrude: placing the holes of a flat face takes work that grows linearly, not quadratically, with the hole count (part of #11574) (#15232)
* test: count the loop walks of a plate's holes placed into its face (fails: N^2)

Adds `WorkCounters::loop_walks`, one per `SmLoop::ContainsUVPoint` walk
(`query::loop::contains_uv_point_with_topology`), and two count-gated tests in
the perforated plate's work tests:

- the plate with n x n holes made in one `create_planar_faces` call;
- the same plate extruded with `prism::extrude::extrude_region`, as a sketch
  and an extrude make it.

Each hole's loop goes into the face its first point classifies in, and the
plate's classification walks every hole placed before it: per cap
3N + N(N - 1)/2 walks, 2208 for 64 holes; the extrusion walks both caps and
four more loops, 4420. The tests assert the work without the quadratic term
(3N per cap, 2 x 3N + 4 for the extrusion), so they fail until the
classification stops walking holes that cannot hold the point.

The extrusion's topology is (N + 6, 3N + 12, 3N + 6, 2N + 8) faces, edges,
loops and vertices: each hole's wall is one face closed by a seam edge
(V - E + F - (L - F) = 2 (1 - genus) with genus N).

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

* perf(query): a face's point classification leaves out the inner loops that cannot hold the point

`SmFace::PointClassify` walks every loop of the face (`SmLoop::ContainsUVPoint`), and
an inner loop's walk only decides the classification when it answers inside or on the
loop. Placing a plate's holes into its face one by one (`create_faces_with_curves`, a
cap of `extrude_region`) classified each hole's first point against the plate and
walked every hole placed before: 3N + N(N-1)/2 walks a cap, 2208 for 64 holes.

`query::loop::WalkExclusion` decides, before an inner loop's walk, whether the walk
provably answers neither inside nor on the loop, cannot fail and cannot edit the
B-rep; `point_classify_with_topology` (every loop, under `memoize_curve_caches` like
face test 37's hulls) then leaves it out. The certificate:

- the loop has a `WindingHull` on this face (no failure, no edit, not inside);
- the surface point, the walk's UV tolerance and the natural domain are computed as
  the walk computes them, on the same surface copy, and succeed; `uv` is off every
  side of the domain (no pole answer);
- `uv` lies outside the hull in u or v by more than the floored UV tolerance and the
  hull's rounding pad: every trim distance the walk tests (`dPrevDist`, the chord
  between edge uses, a step's point, a local solve's value, a Euclidean distance at a
  parameter of the solve's interval) is to a point in the hull;
- the surface point lies farther than the edge's tolerance (and a pad) from every
  edge curve's certified box over the parameters its drop can measure at, and from
  `Vec3::UNDEF` (a failed evaluation's point): a line's endpoint box (its drop clamps
  into the interval), an ellipse's NURB control-point box over the interval widened by
  the solves' slack (positive weights, no out-of-bounds evaluation). Edge curves of
  other kinds keep the walk: a B-spline's drop may answer from an analytic stand-in
  the box does not hold.

`WindingHull` is made stricter so its box holds every point the walk evaluates: a
line trim is boxed over its interval widened by the solves' `SM_EFF_ZERO` slack (a
line extrapolates), and a B-spline trim must not evaluate out of bounds and must have
no positive knot gap short enough for the evaluators' divisor guards (so no
evaluation fails and reads an undefined point). Test 37 only leaves out fewer walks.

Counts: one cap 3N walks (192 for 64 holes, 768 for 256); the extrusion 6N + 4 (388,
1540). On a 4 x 4 hole plate 3385 point classifications are identical field for field
with the shortcut on and off (4757 walks against 39566).

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

* fix(query): no winding hull for a loop with an edge the walk does not step along

`SmLoop::ContainsUVPoint` steps along an edge use only while its parameters are
more than SM_EFF_ZERO apart. An edge whose interval is within that is not
stepped along: the walk carries the previous `this_uv` (on the first edge use,
the undefined point) into the next edge use's chord test. That point lies
outside the trims' box, so neither `WindingHull`'s "not inside" (face test 37)
nor `WalkExclusion`'s "not on the loop" held for such a loop.

Reproduced in review: a clockwise square [-20, -10]^2 whose first 10-long side
is parameterised over [0, 5e-13]; the walk finds (-5, -5) on its first edge,
the exclusion left the walk out, and `point_classify` of a face holding it as an
inner loop answered Face with the shortcut and Edge without.

`WindingHull::of` now refuses a loop with such an edge (the walk's own test,
computed as it computes it), so its walk is always made; the regression test
checks the hull, the exclusion, the walk and the public classification on and
off. Goldens: 283 units unchanged against main on the same machine.

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

* fix(query): a winding hull only over the knots where the convex hull property holds

A B-spline is a nonnegative combination of its control points only over
[U[p], U[n]] (n its number of control points); an unclamped one extrapolates past
its control points' box outside that range. `WindingHull::of` checked its edge's
interval against the knot range [U[0], U[m]] instead, so a loop of unclamped trims
could have a box that does not hold its points: face test 37 could miss an inner
loop holding another's sample point, and `WalkExclusion` could leave out a walk that
answers inside.

Reproduced in review: a diamond hole whose degree-1 trims have knots
[-1, 0.25, 0.75, 2] over edge intervals [0, 1]; (-11, -15), inside the hole and
outside the control points' box, classified as Face with the shortcut and outside
the face without it.

`in_hull_domain` now requires every parameter the walk or a drop may evaluate
(the interval widened by the solves' slack, clamped into the knot range as the
evaluators clamp it) to lie in [U[p], U[n]], for the trims of a hull and for an
ellipse edge's NURB box. The regression test is the review's diamond (on a plane
turned so the 3D edges' boxes do not reach the point): no hull, the walk inside,
public classification identical on and off; it fails without the check. Counts
unchanged (192 / 768 walks a cap, 388 / 1540 an extrusion).

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

* fix(query): the drop exclusion boxes an edge from its own control points

`drop_cannot_reach` trusted `CalculateBoundingBox` for an edge's enclosure. Its
B-spline override refines a single quadratic or cubic span as a Bezier, which
holds only for a clamped span: an ellipse whose NURB is unclamped (as a file can
hold one) got a box that does not contain the curve.

Reproduced in review: a circle arc read back with a degree-2 rational NURB on
knots [-2, -1, 0, 1, 2, 3], edge interval [0.1, 0.9], reaching x = 1 while its box
ended at x = 0.7071; (1.00001, 0), within the edge's tolerance 2e-5 of the circle,
classified as Face with the shortcut and on the edge without it.

The boxes are now built here from the curve's own data: an ellipse's from all its
NURB control points (positive weights, parameters in [U[p], U[n]]: the convex hull
property), a line's from its points at the widened interval's ends. The
regression test is the review's arc: the walk is not left out and is on the edge,
and the public classification is identical on and off; it fails with the old
box. Counts unchanged (192 / 768 a cap, 388 / 1540 an extrusion).

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

* fix(query): a line edge excludes only points off its infinite line

A line's drop (`DropPointFast`) measures the distance to its infinite line and
keeps it when the foot's parameter, converted from STEP to NURB, lies in the
interval. That conversion need not invert the evaluation's: a line whose NURB
domain is within SM_EFF_ZERO maps NURB parameters to themselves but STEP
parameters back with its scale. So a drop can accept a point on the line past
the evaluated segment, and the segment's endpoint box did not bound it.

Reproduced in review: a line from (-5, -5) along X, scale 10, analytic domain
[0, 1], NURB knots [0, 0, 1e-13, 1e-13]; it evaluates between x = -5 and 5, its
drop accepts (8, -5) at distance 0, and that point classified as Face with the
shortcut and on the edge without it.

`drop_cannot_reach` now requires the point to lie farther than the tolerance from
the whole infinite line, from the same point and unit vector and with the same
`line_point_distance` the drop uses; a clamped foot's point lies on that line, no
nearer. The regression test is the review's line: the drop accepts the point, the
loop is walked and the point is on the edge, public classification identical on
and off; it fails with the endpoint box. Counts unchanged (192 / 768 a cap,
388 / 1540 an extrusion: the plate's holes are circles).

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

* fix(query): a line edge bounds the drop by its true perpendicular distance

`line_point_distance` takes a vector shorter than SM_EFF_ZERO for a point and
reports the distance to the line's point, while the line's drop can still clamp
its foot and evaluate a point of the line elsewhere. Bounding the drop by that
helper's value let a point on the line be excluded.

Reproduced in review: a line from (-5, -5) with direction (1e-13, 0, 0), scale
1.3e14, analytic domain [1, 2]; the helper reported 13 from (8, -5), the drop
found the point at distance 0, and the shortcut classified it as Face where the
walk puts it on the edge.

The bound is now the perpendicular distance to the infinite line with the
direction normalised; every distance the drop can accept (the helper's, or the
distance to the line's point in its degenerate branch, or to a clamped foot's
point on the line) is at least that. A zero or non-finite direction keeps the
walk. The regression test is the review's line and fails with the old bound.
Counts unchanged.

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

* fix(query): a line edge's exclusion mirrors its drop exactly

Geometric bounds on a line's drop kept failing on degenerate records: a
direction shorter than SM_EFF_ZERO (which `line_point_distance` takes for a
point), then directions of 1e-160 and 1e160, whose norm underflows or
overflows, so the normalised perpendicular distance was wrong and a point on
the edge classified as Face with the shortcut, on the edge without it.

A line's intersect drop is `Line::drop_point_fast` alone (it never errors, so
the global solve returns its answer; with no inward vector the snap changes
nothing), and its distance is one of three values: its `line_point_distance`
from the line's point and vector when the foot's parameter is not clamped, or
the distance to its point at the interval's start or end (`clamp_value` gives
an end exactly). It succeeds only with that distance below the tolerance.
`drop_cannot_reach` now computes the three values with the same functions on
the same inputs and requires each to be at least the tolerance: exact by
identity, with no rounding argument.

The regression test sweeps the review's directions (1e-13, 1e-160, 1e-161,
1e160, either orientation, through a file round trip): wherever the exclusion
holds the drop fails, and public classification is identical on and off; it
fails with the normalised bound. Counts unchanged.

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

* test(query): the degenerate line's test no longer pins the walk's answer

Since #13596 the walk drops a point on an edge curve only where the edge's
box reaches it (`curve_box_reaches`), so for the line whose NURB domain is
within SM_EFF_ZERO the walk answers Outside at (8, -5) where it answered
OnBoundary. The test keeps what it is for: the drop accepts the point, the
exclusion does not leave the loop out, and the face classifies the point the
same with the shortcut on and off.

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

* fix(query): no hull domain for knots that decrease; the count tests set the memo themselves

Two review findings on the walk exclusion:

- `in_hull_domain` read only the knot range, so an ellipse edge whose NURB
  knots decrease (a file can hold them) kept its control points' box as a
  bound although the convex hull property is stated for nondecreasing knots.
  It now refuses such a knot vector, for edges and trims alike. Regression
  test: a circle loop whose edge's knots are swapped out of order is walked
  for a point that is left out with the knots in order.
- The two count tests relied on `memoize_curve_caches` being on by default,
  which the `curve-memo-off` feature turns off. They set it explicitly.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 03:21:07 +00:00
Brett Lawson 3453963cf3 STEP export: exported solids are no longer inside out (#15168) (#15175)
* STEP export: write each face from outside the material (#15168)

brep_to_model wrote each face from the faceuse in the material region's
shell, which faces into the material, so every exported solid read as
inside out under ISO 10303-42 (box -6000). It now writes each face from
that faceuse's mate, the outer side.

Two more defects surfaced once the files were measured face by face:
- A UV trim curve runs along the edge use that holds it, but when an
  edge use held none (it reads its mate's) the pcurve was reversed by
  the use's own orientation, so a seam's two pcurves ran opposite ways
  and the loops did not join in (u, v).
- same_sense was corrected for swapped and inside-out cones only; a
  swapped sphere, torus, surface of revolution or extrusion, and an
  inside-out sphere or torus, turn the normal over the same way.

The new tests compute the ISO signed volume from the written file's
entities alone (planar faces by their loops' moment, curved faces by
Green's theorem over their PCURVEs) and check each face's same_sense
against its loops and against the kernel's normal geometrically.

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

* STEP export: write a region's inner shells as BREP_WITH_VOIDS voids (#15168)

brep_to_model wrote one MANIFOLD_SOLID_BREP per distinct shell, so a
solid with a cavity came out as two solids (the cavity a separate
-27 solid once faces point out of the material). It now writes a solid
per material region: its first shell (the kernel keeps the outer one
first) as the outer shell, and each other shell as a void, its faces
pointing out of the void's own volume under an ORIENTED_CLOSED_SHELL
of orientation .F. The writer emits BREP_WITH_VOIDS for such a body.

The tests now join each curved face's pcurves along its edges (a seam
used twice takes both its pcurves; a step across a pole lifts to one
point; a periodic wrap is refused) instead of by the smallest gap, move
every solid off the origin so each face's p.n counts, and add a cavity
(973), a sphere, a cone, a torus and a lofted B-spline solid, each
measured from the file.

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

* STEP export: find a region's outer shell by its mates, not list order (#15168)

A region's shell list puts the outer shell first only with the opt-in
boolean_outer_shell_first fix; on the default kernel a union that seals
a void leaves the cavity's shell first, and the file's outer shell was
the cavity, inside out, with the cube as its void. brep_to_model now
walks the region tree from the infinite region through the shells'
mates: a region's outer shell is the one mated to the region it was
reached from.

The tests now check each shell's own signed volume (outer positive,
each void negative after its turn) and add the cup and lid on the
default kernel, in both operand orders (488000).

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-12 03:21:04 +00:00
bherbruck c678287d5f blend certificate: a pointwise feet enclosure across a knot line below C^2 goes cell by cell (#14284) (#15220)
A feet's box across a face's knot line below C^2 by count (a rational circle's doubled quarter knot is C^0 by count, C^1 only to rounding) was refused outright, so wr698's round root was Undecided at the pipe's quarter knot. For a use of order 1 or less (the tangent-edge margin; the definition box passes its caller's order, since its centred form asks one more), such a box is now taken cell by cell: each polynomial piece the box touches, continued over the box, is the face exactly on its own closed span, and those spans cover the box, so every foot is a root of some cell's smooth continued system. A first pass solves each cell from the guesses; a second decides every cell over one common box H (the first box and the proved boxes hulled): K inside H proves a unique root, K off H excludes the cell, K straddling H with q < 1 widens H over that K (the Krawczyk loop's own widening, at most the loop's 8 rounds). Roots off their own cell are dropped; kept roots whose boxes meet are hulled (every foot enclosed, uniqueness not claimed); apart roots, an undecided cell, a box spanning a whole piece (more than 2 pieces a direction, so at most 16 solves a pass) are refused by name, which every caller takes as "not proved at this width". Past a net's end a cell continues exactly as the one solve does (#14284: a direction the kind continues, its end span, a cone's part off its apex through the now shared back_to_the_end_off_the_axis), else FootPastDomainEnd. Uses of order 2 or more, and enclosures with definitions, keep the one solve and its C^2 rule unchanged.

#15005's two tests are re-pinned: edge 35 of wr698's round is now Tangent with every part enclosed; edge 46 stays Undecided at s = 87.2234, pinned exactly, where the pipe's foot sits on the quarter knot at a piece's start and the one solve's box grows across the line from a first box that misses it (#15183).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 03:20:46 +00:00
bherbruck e9158d51fb query::tangent_edge: g2_all_along, a certified whole-edge G2 verdict (#15014)
Whether an edge's two face uses bend alike across it all along it: tangent within max_angle (tangent_all_along, decided first, so a crease is NotTangent at its cost) and normal curvatures across within max_jump of the larger, or of 1/L. Continuous, NotTangent, Jumps with a certified witness, or Undecided. Each use's curvature is polynomial in homogeneous terms along the pcurve, bounded by Chebyshev nodes and the Lebesgue constant, cell by cell across knot lines; a blend's definition margin is added. Where the tangency is undecided the same search runs (a part whose jump bound is within max_jump holds no witness and is dropped), but the verdict is never Continuous. Each piece's point budget is checked before every evaluation.

Fixes tangency-labs/solidcraft#13914.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 03:20:44 +00:00
MarkusRe a88a5b782c STEP export test: check pcurves on a surface of revolution and an extrusion again (#14687) (#15047)
* step-export: the pcurve test covers a surface of revolution and an extrusion again

exported_pcurves_lie_on_their_edges lost its revolved-line and extruded-circle
cases when step-brep's importer, which built them, was removed (#14038). Both
come back as one-face sheets built by the kernel itself: a SurfOfRevolution of
a line and a SurfOfExtrusion of a circle, each on its whole natural box. Each
case first checks that the face exports as a revolution or an extrusion, so
the lift check goes through those branches of step_angles and iso_point.

Closes #14687

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

* step-export: give the test sheets' edge uses their UV curves

sheet_on bounds its face by the box's sides without UV trim curves, so the
exporter wrote no pcurves for the two sheets. Each edge use now gets its own
before the export, and the revolved line is made a NURB first, as
SurfOfRevolution::new needs.

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

* step-export: rustfmt the pcurve test's sheet cases

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

* step-export: say why the pcurve test makes the line a NURB and which UV curves it asks for

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

* step-export: check each test sheet's surface kind with one matches! (clippy type_complexity)

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

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
2026-11-12 03:09:50 +00:00
Brett Lawson af078d1867 solidcore-ops: say which solid test a body failed, and name a broken port invariant (#15035) (#15164)
* solidcore-ops: say which solid test a body failed, and name a broken port invariant (#15035)

The kernel's solid test now names the test a body fails (`SolidTest`:
not manifold, not closed solids, pointer check, AssertValid, volume
negative, volume undecided) through `outward_solid_failure`,
`outward_touching_solids_failure` and `valid_and_outward_failure`; the
bool forms read them, with the same tests in the same order.

solidcore-ops carries it as `Error::NotSolidBecause(SolidTest)`, which
the new `Body::check_solid` returns; `Error::NotSolid` stays for every
call that returns it today. A kernel `PortInvariant` is now
`Error::Invariant { operation, reason }` instead of `Exception`, which
ops' own consistency checks and a missing preparation still give.

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

* solidcore-ops: NotClosedSolids message says what the test allows

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

* solidcore-ops: a broken kernel invariant carries its class and is marked a defect

Error::Invariant now carries the kernel's class (SolidError::kind, PortInvariant) and
defect: true beside the operation and reason, following the class/defect/phrase shape
of OffsetRefusal::Phrase. Invariant, NotSolidBecause and solidcore's SolidTest are
#[non_exhaustive]; SolidTest says itself through a Display of its own, which ops uses.

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-12 03:05:58 +00:00
Brett Lawson 30c28c11ea solidcraft-control: build the client for wasm too, so solidcraft-mcp compiles there again (#15260)
#15125 put `pub mod client` behind cfg(not(target_arch = "wasm32")), but
crates/mcp/src/backend.rs imports it without a gate, so the Compile WebAssembly
job (cargo xtask wasm) fails on main with E0432. The client compiles for
wasm32-unknown-unknown as it is, as mcp's own client did before #15125.


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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 23:06:42 -05:00
jyviko 01c86d9cb1 solidcore: affine maps from an application's numbers, and ops::affine (#15239)
The kernel builds the maps (math.rs, not upstream), in Transform's
row-vector convention (rows 0-2 the images of the axes, row 3 the
translation):
- Transform::rotation(point, axis, angle): Rodrigues' formula about the
  line through point along axis (made unit), transposed for rows;
- Transform::uniform_scaling(centre, factor), keeping its centre;
- Transform::reflection(origin, normal): I - 2 n n^T and 2 (origin.n) n,
  every entry exact for an axis normal, so twice is the identity exactly.
Each refuses numbers not finite, an axis or normal of no length, and a
zero factor (InvalidInput, "math::Transform").

solidcore-ops::affine forwards them as the matrices Body::transform and
curve::transform take: translation, rotation, scaling (a positive factor;
a mirror is reflection), reflection and then (first, then second, through
Transform::concat; a result not finite is refused), each Argument for what
it refuses. The ban list keeps the new constructors and concat for ops:
the kernel computes, ops calls (ban_list.py, clippy.toml).

Tests: kernel, a quarter turn about z takes x to y and a turn keeps its
line and lengths, a scale keeps its centre, an axis mirror maps exactly
and twice is the identity, a slanted mirror keeps its plane, refusals.
Ops, the mirror in y = 0 is the y-mirror matrix, axis mirrors twice are
the identity exactly, x to y under a quarter turn, a slanted turn keeps
its line, a scale keeps its centre, two moves stack to their sum exactly,
every result regular at REGULAR_RATIO, refusals and an overflowing stack.

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

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
2026-11-11 21:43:36 -05:00
jyviko e64c6af4d4 solidcore: a line skew to the revolve axis sweeps its hyperboloid, not a cone (#15209)
* solidcore: a line skew to the axis revolves onto its hyperboloid, not a cone

sm_CreateAnalyticRotationalSweep (the whole turn's one-curve special
case) made a line neither parallel nor square to the axis the cone
through its end circles: their radii and axial positions, without
asking whether the line and the axis lie in one plane. A skew line
sweeps a hyperboloid of one sheet, whose radius is the root of a
quadratic in the axial coordinate; the cone meets it only at the ends.
The line [600,0,0]-[700,0,200] about [500,-50,0] along [40,80,300]
came out 3.85 mm inside it, 44% along (WR-776).

New primitive_creation::p1::coplanar_with_axis(axis_base, axis, start,
end, tol): parallel lines, or a common perpendicular no longer than tol.
The cone branch returns SM_ERR before any edit for a line it fails at the
B-rep's tolerance, so the whole turn's per-curve rule gives the line its
SurfOfRevolution (with revolve_analytic_profile_faces, analytic_revolution
first, which already refuses a curve outside one half-plane through the
axis). The result changes only for skew lines; no flag.

Test: a_line_skew_to_the_axis_revolves_onto_its_hyperboloid, fix off
and on: at 25 x 25 parameters of every face, the point's distance from
the axis equals the profile point's at the same axial coordinate within
1e-6, both measured from the profile's and axis's numbers. Upstream
record WR-776, with the related perpendicular-line ring left open.

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

* solidcore: drop the unused coplanar_with_axis re-export

It is used only inside primitives.rs.

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

* solidcore: a skew line's refusal in the one-curve revolve is a described failure

The cone branch of analytic_rotational_sweep refused a line skew to the
axis with a bare SM_ERR, which the primitive routines no longer build
(error::primitive::tests::the_primitive_routines_build_no_bare_statuses).
It is now SolidError::primitive_failed with what is wrong, as the branch's
neighbours (a line on the axis, a planar face not built) describe theirs.
The caller ignores it the same way and gives the line its surface of
revolution; the status it stands for is unchanged.

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

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 21:43:16 -05:00
jyviko 295a5e1410 solidcraft-control: the control client, shared; the MCP bridge on top of it (#15125)
client::Connection is the app-independent half of the MCP bridge's
Remote: a connection to an app's loopback control port with the app's
token (auth::App::client_token) on every request, one write and one
bounded read (MAX_REPLY) per request, a fresh connection after a lost
one (a request sent once more, or never twice for Request::once), and
the wait for a request the app says is still running, through
control.pending, with the waits as parameters. CallError tells an app
that is not running (with how to start it, from the App's name and the
port) from one lost during the call, a request still running, the app's
refusal and a bad reply.

Remote keeps only what is the MCP bridge's: the agent's folders on every
request, the engine's names and effects for requests, the param-check
handshake and engine.batch sent once. Its messages are unchanged, except
the "did not answer" one, which lost a run of spaces in the middle.

Tests against a server::Hub on an ephemeral port: a round trip with the
token, a wrong or missing token refused, not running, lost mid-call and
reached again once back, a connection closed without a reply, and a
request still running waited for through control.pending.

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: bherbruck <brennen.herbruck@gmail.com>
2026-11-11 21:42:52 -05:00
jyviko 81afe5b90d solidcore: n-sided boundary patches and soft CV moves on sheets (#15106)
* solidcore: loft_rails' skin-and-certificate loop as one crate function

The loop that places stations at span middles, skins the wires, bounds
each rail against the skin on every span (certificate::span_bound),
refines unproved spans at their middles and refuses past the wire cap
moves, unchanged, into loft_rails::skin_and_certify, which loft_rails
calls with its own station placement. Plain, Wire and StationAt become
crate-visible for it. No behaviour change; a two-rail sweep will place
its stations through the same loop.

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

* solidcore: a certified n-sided fill of a loop of curves

nurbs::n_sided runs the kernel's fill (N four-sided patches round a centre) and
certifies it: each half of each boundary curve against the patch side it is
(certified_distance plus split and extraction rounding, in the loop's own
frame), and every inner join between two patches (G0). Tangency across the
inner curves is the fill's aim within its angle tolerance and is stated as
not certified. Rational curves refused; equal weights taken as polynomial
(gordon's helpers now shared within the crate). Tests: a regular pentagon of
lines fills flat within 1e-9; a rational side is refused.

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

* solidcore: boundary patches of five or more curves, the certified n-sided fill

surface::n_sided(curves) -> (Brep, bound): the loop's curves turned end to end
as boundary takes them, the kernel's certified n-sided fill (nurbs::n_sided),
its N faces stitched into one sheet; refused for a rational curve
(Unsupported) or a boundary or inner join past the zone tolerance
(PastTolerance). surface::boundary now takes five or more curves through it.
Tests: a regular pentagon of lines fills flat with five faces joined along five
inner edges; five cubics on a saddle bound their patch within the bound; five
arcs on a sphere are refused.

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

* Move a sheet's CV rows, columns or points, with a soft-select falloff

solidcore_ops::surface_cvs::move_net moves picked rows (u indices),
columns (v indices) or control points by a delta. With a Falloff (a
radius in index steps and a Linear or Smooth shape) the points around
the picks move too, each by its weight: 1 at a pick, falling with its
index distance (along u for rows, along v for columns, the square ring
for points), 0 past the radius. net_weights gives the table, for a
preview; an index distance and a weight are no geometric quantity.

The move itself is the kernel's: srf_basic::Surface::displace_cpts
moves each listed point by its fraction of the delta (a rational
point's weighted coordinates by w t delta, its weight kept), all or
nothing.

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

* solidcore: the soft-move falloff weight is the kernel's

solidcore-ops denies float arithmetic outside tests, and falloff_weight
computed s = d / (radius + 1) and the linear and smoothstep weights itself.
The weight is now nurbs::srf_basic::falloff_weight with FalloffShape, beside
displace_cpts, which takes it; ops maps its Shape and calls it. Same formula,
same values.

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

* solidcore: the proven loft drain is in skin_and_certify now

The full-range drain of the pending stations moved from loft_rails into
skin_and_certify with the loop; its panic-sites row follows it, same proof.

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 21:42:31 -05:00
MarkusRe 937722290a solidcore-ops: every kernel offset refusal has a named reason, no text catch-all (#15035) (#15160)
* claim: #15035

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

* solidcore-ops: test that every kernel offset refusal arrives named, not as text (#15035)

The kernel's offset routines fail with a phrase of their own (`OffsetFailure::Failed`,
`Missing`, `InvalidValue`, `Inconsistent`). Ops has no variant for them, so they reach the
app as `OffsetRefusal::Other { message }`, the kernel's text. The table of
`every_other_offset_refusal_says_why_and_where` now expects a named variant for each,
keeping the kernel's phrase. On main it does not build: `OffsetRefusal` has no such
variants.

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

* solidcore-ops: name every kernel offset refusal, delete the text catch-all (#15035)

`OffsetRefusal::Other { message }` carried any kernel offset reason ops did not list as
the kernel's text, and a match arm `other => unnamed(&other)` sent it there. The reasons
that took it were the offset routines' own failures: `Failed`, `Missing`, `InvalidValue`
and `Inconsistent`, each a fixed phrase.

- `OffsetRefusal` gets a variant for each of the four, keeping the kernel's phrase, and
  says it as the kernel does. `Other` and `unnamed` are gone.
- `offset_refused` names every `OffsetFailure`, with no wildcard arm. `Folds`, `WallsTouch`
  and `EdgeVanishes` go to `Error::WallFolds`, `Error::WallsTouch` and
  `Error::Unsupported` there, as before; `status` sends any offset reason to it and maps
  only reasonless errors by kind, so the mapping lives in one place.
- The kernel's `OffsetFailure` is no longer `#[non_exhaustive]`: across crates that
  attribute forces a wildcard arm, so a new kernel reason would still compile.
- The test that read `unnamed` is replaced by a check in the table test that the four
  phrases are said in the kernel's words, as they were as text.
- docs/offset-and-shell.md said `Collapses` reached the caller as a bare status; it has
  had its own refusal since #14783.

No result changes: every refusal keeps its error, and the four keep their text.

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

* solidcore-ops: an offset refusal names the failure's face, and a draft's too (#15035)

- `status` takes the face from the failure (`Failure::entity`), not only from the reason:
  the two agree unless the kernel set the face itself (`Failure::at`), as a draft does for
  a face without a surface, whose reason names none.
- The surface a draft gives a face: a kernel failure there goes through `status` as the
  local operation's does, so an offset reason reaches the caller named with its face
  instead of as the kernel's status.
- docs/offset-and-shell.md: the sheet offset and the symmetric thicken do not reach the
  mapping yet, since `offset_brep` wraps its executive's failure in one of its own.

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

* solidcore: test that an offset failure keeps its class, and a phrase arrives as one (#15035)

Review on #15160: `OffsetFailure` stays `#[non_exhaustive]`, so ops has to match it with a
wildcard. What keeps a new reason from reaching ops as text alone is then the kernel's own
classification. `every_offset_failure_keeps_its_class` pins every reason's class and whether it
is a phrase (a routine's failure without a cause of its own yet) or a defect of the kernel
(`Inconsistent`); its exhaustive `row` does not compile without a row for a new reason.

In ops, the phrase causes arrive as one transitional `OffsetRefusal::Phrase` that carries the
class, the defect flag and the kernel's words, instead of four named refusals, and only a
phrase arrives as one. The wildcard helper `unnamed` keeps its test, now expecting the class.

Neither builds at the previous head: `OffsetFailure` has no `is_phrase`/`is_defect` and
`OffsetRefusal` no `Phrase`.

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

* solidcore-ops: a kernel phrase arrives as one transitional refusal with its class (#15035)

Review on #15160 (bherbruck), lining offset up with the blend failure record (#15090):

- `OffsetFailure` keeps `#[non_exhaustive]`: solidcore is a general kernel, and a new reason
  must not break downstream crates. Completeness comes from inside the crate instead: the
  exhaustive `is_phrase()` and `is_defect()` next to `kind()`, pinned by
  `every_offset_failure_keeps_its_class`.
- The four phrase causes are not frozen as named public refusals. They arrive, with any reason
  ops does not name yet, as `OffsetRefusal::Phrase { class, defect, phrase }` (transitional),
  through the wildcard arm (`unnamed`). `Inconsistent` is a defect, not a refusal.
- The face is read through `SolidError::entity()`, not the `Failure.entity` field, which #15090
  makes crate-private.
- `OffsetRefusal` is `#[non_exhaustive]`, so ops can name more reasons without breaking callers.

The doc paragraph says so, and now also that `DistanceUnresolved` and the merge check's
`Unsound` reach ops with their reasons in the sheet offset and the symmetric thicken; only the
executive's own failures are wrapped by `collapse_offset_status`.

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

* solidcore-ops: a reason ops does not name keeps the error its class has (#15035)

The wildcard arm of `offset_refused` sent every unnamed reason to `OffsetRefusal::Phrase`.
On main, `status` looked at the error's kind first, so a new reason of the walls-touch, fold or
vanishing-edge kind (such as the `SectionOrderLost` that #14683 and #11776 add, of kind
`OffsetWallsTouch`) became `Error::WallsTouch`, `WallFolds` or `Unsupported`. `by_kind` now holds
that mapping, and both `status` and the wildcard arm use it, so such a reason keeps its error
until ops names it. The unnamed-reason test checks the mapping.

The kernel test's doc says what forces a new reason to be classed (the exhaustive `kind`,
`is_phrase` and `is_defect`), `is_defect` says it is per cause until offset has classes of its
own, `draft` lists `Error::OffsetRefused`, and §4.2 of the doc follows.

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

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
2026-11-12 02:04:34 +00:00
Sam fdc13eaef5 Split Face: a loops option splits a face along a sketch's closed shapes (#15225)
With loops: true, the sketch's curves are joined end to end before the
split, so a closed shape of several curves (a triangle, a slot) splits
the face along itself instead of each side being extended across the
face. Off by default; open curves are extended as before.
2026-11-12 02:03:15 +00:00
bherbruck fa3d016da8 solidcore: offset reach: a meeting's distance takes the trace's |u| (#14830) (#15201)
The trace's direction u is unit only to rounding (d / sqrt(|d|^2)), and a
point x0 + tau u is |tau| |u| from the foot, not |tau|. planned's bounds
(Roots, Touch, Cover) now take foot^2 + tau^2 (u . u), u . u enclosed with
its rate, so an upper bound cannot sit an ulp below the distance. The
kept straight edge's meeting with a plane (now line_meets_plane) encloses
|x0 + tau u - p| directly, since its x0 is the foot only for |u| = 1, and
its curve-deviation slack takes the sine |u.nu| / (|u| |nu|).


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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 01:48:18 +00:00
bherbruck 185667c004 geom: the global solver adds a solution without copying the ones it holds (#15200)
GlobalSolve::add_sorted_solution compared against the stored solutions by copying each one,
and its pass that combines overlapping solutions copied both of every pair and wrote both back
(about a million pairs on a two-cylinder cut). SolutionArray::at borrows what get_at copies and
pair_mut hands out two distinct solutions (i < j, bounds-checked), so both work in place.
combine_min_max changes the pair only on its split path, after its only fallible call, so the
result on success and the untouched pair on error are as with the copies. Order and logic are
unchanged. brep-bench boolean/tube-flush-ends (callgrind): instructions 3,966,547,083 ->
3,440,780,489; memcpy calls from add_sorted_solution 7,106,860 -> 996,945; STL byte-identical.
Part of #14830.


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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 01:48:10 +00:00
bherbruck b935118729 Release builds run on Blacksmith runners (#15227)
Linux, Flatpak, Arch, snap, FreeBSD, macOS, web and the release job move to Blacksmith, as CI
did. The version and Windows jobs stay on GitHub's runners: SignPath Foundation only signs a
request when every job leading up to it ran on a GitHub-hosted runner.

The icons job is gone: make_icons has only copied the committed icon files since #833, so its
15-minute CLI build held up the Windows jobs for nothing. package.ps1 already falls back to the
same committed solidcraft.ico.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 01:47:04 +00:00
MarkusRe 5cd9b8ffc6 solidcore: six fillet solver fixes become the only path (#14246 batch B27) (#15045)
* claim: #14246 batch B27 (six fillet solver flags)

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

* solidcore: B27 tests run on the default kernel (test first)

The tests of six fillet solver flags (WR-301 fillet_rail_rim_on_face,
WR-422 fillet_torus_center_on_axis, WR-568 fillet_torus_span_from_start_section,
WR-592 fillet_torus_rail_on_axis, WR-603 fillet_trace_from_tangent_point_toward_next;
WR-595 fillet_tangent_point_one_sided_step is shown by the goldens) now
assert the fixed result on Kernel::default() and drop their off halves.
On main's code they fail; the next commit makes the fixed paths the
only ones.

Part of #14246 (batch B27).

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

* solidcore: six fillet solver fixes become the default (retire their flags, B27)

The fixed paths of six UpstreamFixes flags of the fillet solver are now
the only ones, and the fields, the off paths and every preset line go:

- fillet_rail_rim_on_face (WR-301): the rollover joins a rail's
  neighbouring intervals on its face before it runs (p5.rs).
- fillet_torus_center_on_axis (WR-422), fillet_torus_rail_on_axis
  (WR-592), fillet_torus_span_from_start_section (WR-568): the torus
  round's section on the arc's axis, its rail point on the axis and its
  span from the start section (p2.rs).
- fillet_trace_from_tangent_point_toward_next (WR-603) and
  fillet_tangent_point_one_sided_step (WR-595): a trace from a tangent
  point starts toward the next point and takes its tangent from the
  forward step (p4.rs).

Fixtures 1080 and 4055 no longer opt in; each WR record gets an
addendum.

Part of #14246 (batch B27).

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

* goldens: bless the 25 rows the six fillet solver fixes move (B27)

blend.fillet_executive_p3 (14 rows) and blend.fillet_solver_p4 (11 rows)
move; no other unit does. Two rounds along a boolean's arc are now built
(WR-568) and match the same box turned 90 degrees, which already built;
five rows move by roundoff with a rail point on the axis (WR-422,
WR-592); seven stub rounds traced from a tangent point move by at most
1.4e-7 of their volume (WR-595, WR-603); eleven generated tangent points
take the forward step (WR-595). Every row, with the flag that moves it:
plan/findings/blend.fillet_solver--b27-six-flags-retired-2026-11-11.md.

Part of #14246 (batch B27).

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

* solidcore: B27 tests and notes after review

no_room_refusals_are_named took the holed cube's round of 5 on the
default kernel as its refusal under another code; with WR-301 the default
that round builds, so the test now takes a chamfer of 25 on the cube of
20 (SM_FILERR_BAD_INT). The WR-603 peg test pins its face count and the
added volume again. Module and doc comments name what is opt-in and what
is the default now; c3_trace_start drops a length check the pattern
already makes; the findings file's numbers are recomputed from the rows.

Part of #14246 (batch B27).

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

* solidcore: WR-512 arc round test allows the default torus span (B27)

the_flag_builds_the_arc_round compares the default kernel with WR-512 to
the pinned C++ with the fork's WR-512 line at 1e-9. With WR-568 the
default, the arc round's torus span comes from its start section, which
the pinned C++ does not do: 9531.3100487 against 9531.3100143, 3.6e-9
relative (with upstream's span they agree to 1e-12; none of the other
five B27 fixes moves it). The test now allows 1e-8, as its neighbour
with every fix does.

Part of #14246 (batch B27).

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

* plan: B27 findings name every moved golden row with its values

Part of #14246 (batch B27).

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

* solidcore: no_room_refusals_are_named keeps the chamfer of 5, now built (B27 review)

The holed cube's chamfer of 5 was the test's refusal under another code;
with WR-301 the default it builds, to the closed form 4000 - 12.5 * 20.
The test now asserts that, and keeps the chamfer of 25 on the cube as the
refusal that names no NoRoom reason.

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

* solidcore: WR-512 arc round test pins the C++ volume with WR-568's span (B27 review)

Instead of widening the_flag_builds_the_arc_round to 1e-8, compare at the
old 1e-9 with a new reference: the pinned C++ with the fork's WR-512 line
and WR-568's span line (smlib 985faae) both linked ahead, 9531.3100486849.
The default kernel gives 9531.3100486743, 1.1e-12 relative.

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

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
2026-11-12 01:43:06 +00:00
Daniel 02afa23329 Preferences: the Placement handles row is "Placed features", and its tip is true for the primitives (#14766)
* Preferences: the Placement handles row is "Placed features", and its tip is true for the primitives

Box, Cylinder, Cone, Sphere, Torus and Coil have the placement marker
too, but the Preferences row was labelled "Rest, Boss, Snap Fit", and 22
translations of the tooltip still named those three features only. The
tooltip also promised a ring to turn the feature, which the primitives
do not have.

The label reads "Placed features" and the tooltip "A marker to drag the
feature across the model and, where the feature can turn, a ring to turn
it." in English and in every catalog that has the message. The setting
and its default are unchanged.

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

* Preferences search finds the Placed features row by each feature with the marker

With the row labelled "Placed features", searching Preferences for Rest,
Boss or Snap Fit no longer found Placement handles, as the old label did.
The row now gives the search the names of every feature that has the
marker (Rest, Boss, Snap Fit, Box, Cylinder, Cone, Sphere, Torus, Coil),
from their existing name keys, so it is found by any of them in the
current language.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 01:30:46 +00:00
MarkusRe db742b4a8f Native body files: re-record the box-and-round wire record fixture, whose topology is only renumbered (#15057)
* claim: box-and-round native wire record fixture

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

* Native body files: re-bless the box-and-round wire record fixture

The rounded box's record numbers its vertices, edges and faces in a new
order since the solidcore update to 8ddce9016. Renumbered by geometry,
the old fixture matches the new record in every value within the test's
own tolerance (1e-10 relative): surfaces, domains, loops and pcurves,
curves, intervals, tolerances, uses, shells and history. The plain box's
record is unchanged byte for byte. Re-blessed with NATIVE_BREP_BLESS=1;
the test leaves tests/KNOWN_FAILING_TESTS.

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

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
2026-11-12 01:30:11 +00:00
N3MI-DG 0371e7d2d1 STL and 3MF export: Optionally export file on print face (backend) (#15204) (#15205)
* claim: #15204

* STL and 3MF export: a script can write each body turned onto its print face, without a slicer (#15204)

file.export, file.export_stl, file.export_3mf and file.save_mesh take
lay_on_print_faces: true. Each body with a print face is written laid on
it, as Send to Slicer lays it: the face down at Z = 0, the centre of mass
over the origin. The design does not move and nothing is added to undo.
The reply names the laid bodies in print_faces, with a warning for each
print face that can't be used. The default is false, so existing exports
are unchanged; formats other than STL and 3MF refuse true.

The slicer path's laying code is now one function every mesh export
shares, built for the web too. file.export_stl gets its own input
contract, since it shared one with file.export_obj.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 01:16:59 +00:00
infield 270573eb0b Opening older designs: inserted STEP parts keep their place and shape (#13382) (#14211)
* claim: #13382

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

* Opening older designs: inserted STEP parts keep their place and shape (#13382)

An Import feature keeps its body names and skipped bodies by the body
numbers of the STEP reader that inserted the file. The 0.40 reader numbers
some files' bodies otherwise (it reads bodies 0.30.1 left out), so on
opening each body took the next one's name, and the name put it in another
part's component: parts moved and took other parts' shapes.

When a design is evaluated on opening, match each body through the stored
component tree (same product, same position among its bodies) and move the
stored names and skip entries to today's numbering. A body only today's
reader has gets the reader's name and is skipped when a fresh insert would
skip it. Trees that disagree about their products are left as they are.
Designs that open from their saved model cache are not read again.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 01:07:12 +00:00
ancient_astronaut_theorist 1155cdd918 Booleans: a join that fills a hollow completely gives a solid instead of an export error (#14414) (#14733)
* claim: #14414

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

* solidcore: a join that fills a void exactly gives a solid body (#14414)

A join whose tool exactly fills a body's internal void deleted the
void's faces correctly, but the joined region kept the void's flag:
merge data is recorded only when a face of the other body is copied in,
and when the tool's faces all lie on the void's faces nothing is
copied. The result was a box with an empty inside, which the export
refused ("a face between two material regions or two empty ones").

A face that a regularized operation deletes has the same class on both
sides: material for a union, empty for an intersection or a difference.
A region joined across a deleted face, where neither region has merge
data, now takes that class (deleted_face_sides, joined_without_data in
merge/p4.rs). Regression tests in solidcore-ops/tests/boolean.rs cover
cube and sphere voids filled exactly and by larger tools, a partial
fill, and the difference and intersection of a cube on the void.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 01:06:55 +00:00
ancient_astronaut_theorist 21c7836995 Hole tests: a hole across the part that touches none of its sides keeps the rest silently (#15119)
* claim: hole test off a tangency

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

* Hole test: the hole that crosses the plate no longer sits on a tangency

a_hole_wider_than_the_part_says_it_removes_all_of_it drilled its 40 mm
hole at (10, 10) in a 40 x 30 plate, so the hole's edge touched the side
y = 30 along the line x = 10. The corner it leaves at (0, 30) meets the
rest only along that line, and the kernel rightly parts it into 2 bodies
(4031.96 mm3 in all; exact 4031.95), where the test wanted 1.

The test is about what the hole says, so the hole moves to (10, 15): it
still crosses the plate's whole width but touches no side, and one body
remains (3623.44 mm3; exact 3623.43). The test leaves the known-failing
list.

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

* Hole tests: keep the touching case listed; a hole clear of the sides is its own test

The hole wider than the part goes back to touching the plate's side along a
line, with its known-failing line, as the record of that bug. The same hole
moved clear of the sides is a new test beside it: it keeps the rest in one
piece and silently, and says so once a parameter widens it past the part.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 00:58:52 +00:00
bherbruck b4e1178bdd solidcore: offset local reach bounds each edge once (#14830) (#15182)
local_extension_reach meets every edge from both its faces, and each
asks edge_interval_maximum for the same quantity on the same curve,
interval and tolerance, so the same bits. The offset executive keeps the
bounds one local operation has found (EdgeBounds, by edge and quantity:
a moved face's shift or a plane-quadric meeting), and the second face
takes the first's where everything the bound is a function of has the
same bits: the curve, interval, tolerance and the surfaces' exact
classes. They are cleared when the local surfaces are set and when the
relations are cleared, never kept across operations.


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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 00:48:19 +00:00
421 changed files with 26121 additions and 4566 deletions
+6
View File
@@ -9,3 +9,9 @@ self-hosted-runner:
- blacksmith-16vcpu-ubuntu-2404
- blacksmith-32vcpu-ubuntu-2404
- blacksmith-8vcpu-windows-2025
# Release builds (workflows/release.yml).
- blacksmith-16vcpu-ubuntu-2204
- blacksmith-16vcpu-ubuntu-2204-arm
- blacksmith-4vcpu-ubuntu-2404-arm
- blacksmith-6vcpu-macos-15
- blacksmith-12vcpu-macos-15
+3 -2
View File
@@ -34,8 +34,9 @@ env:
jobs:
build:
if: ${{ !github.event.repository.private }}
runs-on: ubuntu-latest
timeout-minutes: 60
# Blacksmith, as CI: the web app's release build outgrew 60 minutes on GitHub's 4-core runner.
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 120
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@1.98.0
+27 -54
View File
@@ -8,6 +8,9 @@
# The macOS app is signed and notarized when the APPLE_* secrets exist. The Windows binaries and
# MSI are Authenticode-signed through SignPath Foundation when the SIGNPATH_API_TOKEN secret exists
# (see the windows job). Nothing else is signed yet.
# Runners: Blacksmith (blacksmith-<N>vcpu-<os>), as CI uses, except the `version` and `windows`
# jobs, which stay GitHub-hosted: SignPath Foundation only signs a request when every job leading
# up to it ran on a GitHub-hosted runner.
#
# Linux x86_64, aarch64 AppImage (+ .zsync), .deb, .rpm, .flatpak, .snap, .tar.gz packaging/linux
# Arch Linux x86_64, aarch64 .pkg.tar.zst, repackaged from the .tar.gz the arch job
@@ -114,40 +117,18 @@ jobs:
{ echo "version=$VERSION"; echo "date=$(date -u +%Y-%m-%d)"; echo "prerelease=$pre"; } >>"$GITHUB_OUTPUT"
echo "### SolidCraft v$VERSION (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
# The app icon in every size, drawn by solidcraft-cli (no image files are committed), for the
# Windows jobs: the ARM64 build is cross-compiled and can't run there to draw its own.
icons:
needs: version
if: needs.version.outputs.build == 'true'
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@1.98.0
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-icons
- run: cargo build --release --locked -p solidcraft-cli
- run: |
. packaging/env.sh
make_icons target/release/solidcraft-cli icons
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: icons
path: icons
if-no-files-found: error
linux:
name: Linux ${{ matrix.arch }}
needs: version
if: needs.version.outputs.build == 'true'
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35.
# Ubuntu 22.04, the oldest image, so the binaries only need glibc >= 2.35.
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- { arch: x86_64, runner: ubuntu-22.04, deb: amd64 }
- { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 }
- { arch: x86_64, runner: blacksmith-16vcpu-ubuntu-2204, deb: amd64 }
- { arch: aarch64, runner: blacksmith-16vcpu-ubuntu-2204-arm, deb: arm64 }
env:
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
@@ -203,8 +184,8 @@ jobs:
fail-fast: false
matrix:
include:
- { arch: x86_64, runner: ubuntu-24.04 }
- { arch: aarch64, runner: ubuntu-24.04-arm }
- { arch: x86_64, runner: blacksmith-4vcpu-ubuntu-2404 }
- { arch: aarch64, runner: blacksmith-4vcpu-ubuntu-2404-arm }
env:
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
@@ -233,7 +214,7 @@ jobs:
needs: [version, linux]
if: needs.version.outputs.build == 'true'
# makepkg only repackages the tarball's prebuilt binaries, so both architectures build here.
runs-on: ubuntu-24.04
runs-on: blacksmith-2vcpu-ubuntu-2404
# A pre-release or nightly without an Arch package is still worth having; a stable release isn't.
continue-on-error: ${{ contains(needs.version.outputs.version, '-') }}
strategy:
@@ -303,8 +284,8 @@ jobs:
fail-fast: false
matrix:
include:
- { arch: x86_64, runner: ubuntu-24.04 }
- { arch: aarch64, runner: ubuntu-24.04-arm }
- { arch: x86_64, runner: blacksmith-4vcpu-ubuntu-2404 }
- { arch: aarch64, runner: blacksmith-4vcpu-ubuntu-2404-arm }
env:
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
@@ -349,8 +330,8 @@ jobs:
name: FreeBSD x86_64
needs: version
if: needs.version.outputs.build == 'true'
# GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM.
runs-on: ubuntu-latest
# There are no FreeBSD runners: build in a FreeBSD 14.3 VM (Blacksmith's x64 runners have KVM).
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 180
env:
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
@@ -363,8 +344,8 @@ jobs:
with:
release: "14.3"
usesh: true
cpu: 4
mem: 12288
cpu: 14
mem: 40960
# Passed into the VM by name; never templated into the script below.
envs: "SOLIDCRAFT_VERSION SOLIDCRAFT_BUILD_DATE"
prepare: |
@@ -374,9 +355,9 @@ jobs:
set -e
. "$HOME/.cargo/env"
git config --global --add safe.directory "$PWD"
# Build outside the checkout so only dist/ is copied back out of the VM. 4 jobs: more
# runs the VM out of memory.
export CARGO_TARGET_DIR=/var/tmp/solidcraft-target CARGO_BUILD_JOBS=4
# Build outside the checkout so only dist/ is copied back out of the VM. About 3 GB of
# VM memory per job: 4 jobs in 12 GB was the most that fit before.
export CARGO_TARGET_DIR=/var/tmp/solidcraft-target CARGO_BUILD_JOBS=12
bash packaging/freebsd/package.sh
tar -tzf dist/release/solidcraft-*-freebsd-x86_64.tar.gz \
| grep -E '/bin/solidcraft$|/bin/solidcraft-cli$|/share/applications/io\.tangencylabs\.solidcraft\.desktop$|/share/doc/solidcraft/THIRD-PARTY-LICENSES\.txt$'
@@ -388,11 +369,12 @@ jobs:
# The two halves of the universal binary build in parallel on their own runners (each alone
# took about half of the old single job's 45 minutes); `macos` below joins and packages them.
# Blacksmith's macOS runners are Apple silicon: the x86_64 half is cross-compiled.
macos-build:
name: macOS build ${{ matrix.arch }}
needs: version
if: needs.version.outputs.build == 'true'
runs-on: macos-15
runs-on: blacksmith-12vcpu-macos-15
strategy:
fail-fast: false
matrix:
@@ -426,7 +408,7 @@ jobs:
name: macOS universal
needs: [version, macos-build]
if: needs.version.outputs.build == 'true'
runs-on: macos-15
runs-on: blacksmith-6vcpu-macos-15
env:
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
@@ -466,10 +448,11 @@ jobs:
windows:
name: Windows ${{ matrix.arch }}
needs: [version, icons]
needs: version
if: needs.version.outputs.build == 'true'
# Must stay a GitHub-hosted runner, like every job before it: SignPath's open-source policy
# only signs builds whose jobs all ran on GitHub-hosted runners.
# Must stay a GitHub-hosted runner, like the `version` job before it: SignPath's open-source
# policy only signs requests whose jobs leading up to them all ran on GitHub-hosted runners.
# The installer icon is the committed assets/icons/app/windows/exports/solidcraft.ico.
runs-on: windows-latest
# actions: read lets the SignPath action read this job's details and fetch the uploaded
# artifact with GITHUB_TOKEN (the workflow default above grants contents: read only).
@@ -498,10 +481,6 @@ jobs:
SIGNPATH_PROJECT_SLUG: solidcraft
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: icons
path: icons
- uses: dtolnay/rust-toolchain@1.98.0
with:
targets: ${{ matrix.target }}
@@ -516,8 +495,6 @@ jobs:
- name: Build and package (msi, portable zip)
if: env.SIGN_WINDOWS != 'true'
shell: pwsh
env:
SOLIDCRAFT_ICONS: ${{ github.workspace }}/icons
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
# ---- signed path ---------------------------------------------------------------------------
@@ -531,8 +508,6 @@ jobs:
- name: Build and stage the binaries for signing
if: env.SIGN_WINDOWS == 'true'
shell: pwsh
env:
SOLIDCRAFT_ICONS: ${{ github.workspace }}/icons
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }} -Phase stage
- name: Upload the unsigned binaries
id: unsigned-exes
@@ -576,8 +551,6 @@ jobs:
- name: Package the signed binaries (msi, portable zip)
if: env.SIGN_WINDOWS == 'true'
shell: pwsh
env:
SOLIDCRAFT_ICONS: ${{ github.workspace }}/icons
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }} -Phase package
- name: Upload the unsigned MSI
id: unsigned-msi
@@ -627,7 +600,7 @@ jobs:
name: Web (wasm)
needs: version
if: needs.version.outputs.build == 'true'
runs-on: ubuntu-latest
runs-on: blacksmith-8vcpu-ubuntu-2404
env:
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
TRUNK_VERSION: 0.21.14
@@ -656,7 +629,7 @@ jobs:
needs: [version, linux, flatpak, arch, snap, freebsd, macos, windows, web]
# Not for the release gate (packages_only): it builds and checks the packages, nothing more.
if: needs.version.outputs.build == 'true' && !inputs.packages_only
runs-on: ubuntu-latest
runs-on: blacksmith-2vcpu-ubuntu-2404
permissions:
contents: write
env:
+10 -3
View File
@@ -480,7 +480,7 @@ fn output_checks(c: &mut Checks, want: &Value, out: &str, results: &[Value], rel
/// The word a `surface_types` check names a face's surface by, as the record holds it: an
/// analytic kind, `ball_patch` (a corner's patch on a sphere), `bspline` (a B-spline or NURBS
/// surface) or `revolution` (a revolved curve).
/// surface), `revolution` (a revolved curve) or `blend` (a round by its definition, on its net).
fn surface_word(s: &solidcore_record::brep::Surface) -> &'static str {
use solidcore_record::brep::Surface as S;
match s {
@@ -492,12 +492,14 @@ fn surface_word(s: &solidcore_record::brep::Surface) -> &'static str {
S::Torus { .. } => "torus",
S::Spline { .. } | S::RationalSpline { .. } => "bspline",
S::PlacedRevolution { .. } => "revolution",
S::Blend { .. } => "blend",
}
}
/// A B-spline face's two directions as `[degree, spans]` (spans: the distinct knot intervals of
/// the modelled range), the lower first, so a surface parameterised the other way round reads the
/// same. `None` for a surface that is not a B-spline.
/// same. `None` for a surface that is not a B-spline. A blend face is read on its net, its NURBS
/// form.
fn spline_shape(s: &solidcore_record::brep::Surface) -> Option<[[u64; 2]; 2]> {
use solidcore_record::brep::Surface as S;
let (u, v, nu, nv) = match s {
@@ -815,7 +817,12 @@ fn check_case_in(
let record = b.body.record();
for f in &record.faces {
kinds.insert(surface_word(&f.surface));
spline_faces.extend(spline_shape(&f.surface));
// A blend face's net is its NURBS form: the spline check reads its degrees there.
let net = match &f.surface {
solidcore_record::brep::Surface::Blend { .. } => f.nurbs.as_ref(),
_ => None,
};
spline_faces.extend(spline_shape(net.unwrap_or(&f.surface)));
}
boundary += record.edges.iter().filter(|e| e.uses.len() == 1).count() as u64;
}
+1
View File
@@ -45,4 +45,5 @@ web-sys = { version = "0.3", features = [
"IdbFactory", "IdbDatabase", "IdbObjectStore", "IdbOpenDbRequest", "IdbRequest", "IdbTransaction", "IdbTransactionMode",
"Headers", "ReadableStream", "ReadableStreamDefaultReader",
"BeforeUnloadEvent", "Event", "EventTarget", "KeyboardEvent", "Response", "FormData", "RequestInit", "MediaQueryList", "Gamepad", "Performance",
"PointerEvent", "Node", "VisibilityState",
] }
+2
View File
@@ -26,6 +26,8 @@
<link data-trunk rel="copy-file" href="../../assets/fonts/craft-ui-sc/OFL.txt" data-target-path="fonts/craft-ui-sc" />
<link data-trunk rel="copy-file" href="../../assets/fonts/craft-ui-tc/CraftUI-TC.otf" data-target-path="fonts" />
<link data-trunk rel="copy-file" href="../../assets/fonts/craft-ui-tc/OFL.txt" data-target-path="fonts/craft-ui-tc" />
<link data-trunk rel="copy-file" href="../../assets/fonts/craft-ui-kr/CraftUI-KR.otf" data-target-path="fonts" />
<link data-trunk rel="copy-file" href="../../assets/fonts/craft-ui-kr/OFL.txt" data-target-path="fonts/craft-ui-kr" />
<link data-trunk rel="css" href="../../assets/styles/web/loading.css" />
<link data-trunk rel="copy-file" href="../../assets/styles/web/loading-logo.svg" />
<!-- Installable as an app (manifest.webmanifest); the icons are the Linux exports of the app mark. -->
+11
View File
@@ -48,6 +48,7 @@ impl Api {
let execute = api.clone();
let commands = api.clone();
let inspect = api.clone();
let buttons = api.ctx.clone();
for (name, function) in [
(
"request",
@@ -69,6 +70,16 @@ impl Api {
Closure::<dyn Fn(JsValue) -> js_sys::Promise>::new(move |value: JsValue| inspect.request("document.inspect", params(&value)))
.into_js_value(),
),
// The mouse buttons egui holds now, by bit as in a pointer event's `buttons`
// (1 primary, 2 secondary, 4 middle, 8 back, 16 forward): for checking that they
// follow the browser's (`mouse_buttons`).
(
"buttons",
Closure::<dyn Fn() -> u32>::new(move || {
crate::mouse_buttons::egui_down(&buttons).iter().enumerate().filter(|(_, down)| **down).map(|(bit, _)| 1 << bit).sum()
})
.into_js_value(),
),
] {
js_sys::Reflect::set(&object, &name.into(), &function)?;
}
+30 -4
View File
@@ -24,6 +24,8 @@ mod keep;
#[cfg(target_arch = "wasm32")]
mod linked;
#[cfg(any(target_arch = "wasm32", test))]
mod mouse_buttons;
#[cfg(any(target_arch = "wasm32", test))]
mod space_mouse;
/// The browser switches tabs with Ctrl+Tab and Ctrl+Shift+Tab, so the page leaves those keys to
@@ -341,6 +343,7 @@ mod web {
const JAPANESE_FONT_PATH: &str = "./fonts/BIZUDPGothic-Regular.ttf";
const CHINESE_SC_FONT_PATH: &str = "./fonts/CraftUI-SC.otf";
const CHINESE_TC_FONT_PATH: &str = "./fonts/CraftUI-TC.otf";
const KOREAN_FONT_PATH: &str = "./fonts/CraftUI-KR.otf";
type FontLoadResult = Rc<std::cell::RefCell<Option<Result<(String, Vec<u8>), String>>>>;
@@ -369,12 +372,16 @@ mod web {
chinese_bytes: Option<Vec<u8>>,
/// Which Chinese face `chinese_bytes` is: `"zh"` or `"zh-TW"`.
chinese_kind: Option<&'static str>,
/// Kind requested by the in-flight fetch (`"ja"`, `"zh"`, or `"zh-TW"`).
/// The fetched Korean font's bytes: kept so a language change can re-order the fallbacks.
korean_bytes: Option<Vec<u8>>,
/// Kind requested by the in-flight fetch (`"ja"`, `"zh"`, `"zh-TW"` or `"ko"`).
font_fetch_kind: Option<&'static str>,
/// The language the fonts were last set up for (a CJK UI leads with the CJK face).
font_language: String,
space_mouse: SpaceMouse,
wheel_gestures: solidcraft_ui_egui::WebWheelGestures,
/// The mouse buttons the browser holds, which egui's are kept to (`mouse_buttons`).
mouse_buttons: Rc<std::cell::RefCell<crate::mouse_buttons::Tracker>>,
/// The settings this tab wrote or loaded last, and those another tab wrote since (#8644).
prefs_writer: crate::PrefsWriter,
prefs_warning: Option<String>,
@@ -1021,6 +1028,7 @@ mod web {
Some("zh") => self.chinese_bytes.is_some() && self.chinese_kind == Some("zh"),
Some("zh-TW") => self.chinese_bytes.is_some() && self.chinese_kind == Some("zh-TW"),
Some("ja") => self.cjk_bytes.is_some(),
Some("ko") => self.korean_bytes.is_some(),
_ => true,
}
}
@@ -1042,6 +1050,9 @@ mod web {
self.chinese_bytes = Some(bytes);
self.chinese_kind = Some("zh-TW");
}
Some("ko") => {
self.korean_bytes = Some(bytes);
}
_ => {
self.cjk_bytes = Some(bytes);
}
@@ -1053,6 +1064,7 @@ mod web {
let path = match kind {
Some("zh") => CHINESE_SC_FONT_PATH,
Some("zh-TW") => CHINESE_TC_FONT_PATH,
Some("ko") => KOREAN_FONT_PATH,
_ => JAPANESE_FONT_PATH,
};
log::warn!("failed to fetch script font from {path}: {err}");
@@ -1080,13 +1092,20 @@ mod web {
}
/// Set up the UI fonts for the live language, keeping fetched script fonts as fallbacks
/// (Chinese leads a Chinese UI; see [`solidcraft_ui_egui::fonts::install_web_script_fonts`]).
/// (Chinese leads a Chinese UI, Korean a Korean one; see
/// [`solidcraft_ui_egui::fonts::install_web_script_fonts`]).
fn apply_fonts(&mut self, ctx: &egui::Context) {
let effective = solidcraft_ui_text::effective_language(&self.app.preferences.language);
if self.cjk_bytes.is_none() && self.chinese_bytes.is_none() {
if self.cjk_bytes.is_none() && self.chinese_bytes.is_none() && self.korean_bytes.is_none() {
solidcraft_ui_egui::fonts::setup_fonts(ctx);
} else {
solidcraft_ui_egui::fonts::install_web_script_fonts(ctx, self.cjk_bytes.clone(), self.chinese_bytes.clone(), effective);
solidcraft_ui_egui::fonts::install_web_script_fonts(
ctx,
self.cjk_bytes.clone(),
self.chinese_bytes.clone(),
self.korean_bytes.clone(),
effective,
);
}
self.font_language = effective.to_owned();
}
@@ -1103,6 +1122,7 @@ mod web {
let path = match kind {
"zh" => CHINESE_SC_FONT_PATH,
"zh-TW" => CHINESE_TC_FONT_PATH,
"ko" => KOREAN_FONT_PATH,
_ => JAPANESE_FONT_PATH,
};
self.font_fetch_kind = Some(kind);
@@ -1130,6 +1150,8 @@ mod web {
DESIGN_SHOWN.with(|d| d.set(solidcraft_ui_egui::toolbar::design_shown(&self.app)));
}
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
// First, so everything after sees the buttons the browser holds.
crate::mouse_buttons::reconcile(&self.mouse_buttons, ctx, raw);
let now = raw.time.unwrap_or_else(|| ctx.input(|i| i.time));
if let Some(delay) = self.wheel_gestures.update(raw, now) {
ctx.request_repaint_after(delay);
@@ -1270,6 +1292,7 @@ mod web {
{
create.instance_descriptor.backends = eframe::wgpu::Backends::GL;
}
let mouse_canvas = canvas.clone();
let runner = eframe::WebRunner::new();
let result = runner
.start(
@@ -1354,6 +1377,7 @@ mod web {
let rev = app.session.revision;
app.home.show(rev);
}
let mouse_buttons = crate::mouse_buttons::install(&window, &mouse_canvas, &cc.egui_ctx);
let api = Api::install(sender, cc.egui_ctx.clone()).map_err(|e| format!("SolidCraft browser API: {e:?}"))?;
let font_language = app.preferences.language.clone();
let linked = crate::linked::Download::start(launch.open.as_deref(), &cc.egui_ctx);
@@ -1375,10 +1399,12 @@ mod web {
cjk_bytes: None,
chinese_bytes: None,
chinese_kind: None,
korean_bytes: None,
font_fetch_kind: None,
font_language,
space_mouse: SpaceMouse::default(),
wheel_gestures: solidcraft_ui_egui::WebWheelGestures::default(),
mouse_buttons,
prefs_writer: crate::PrefsWriter::default(),
prefs_warning: None,
other_tab_prefs,
+341
View File
@@ -0,0 +1,341 @@
//! Mouse buttons as the browser holds them, not as eframe's per-button events report them.
//!
//! eframe 0.37's web runner turns each `pointerdown` and `pointerup` into one egui button event
//! from `event.button`. Pointer events don't work that way for a mouse: a second button pressed
//! while one is held (a chord) fires `pointermove`, not `pointerdown`, and so does releasing one
//! of two held buttons; only the last release fires `pointerup`. Press right, then left, let go
//! of right, then left: eframe reports "right down", then "left up", and egui keeps the right
//! button held for good. A release the page never hears of (outside the window, an alt-tab, a
//! cancelled pointer) leaves a button held the same way.
//!
//! So every mouse or pen pointer event's `buttons` bitmask (what the browser holds right now)
//! is noted here, and before each frame [`corrections`] adds the button events egui is missing
//! for its state to match it. The canvas also captures the pointer on `pointerdown`, so the
//! release still arrives when it happens outside the canvas or the window; `pointercancel`,
//! `lostpointercapture`, the window losing focus and the page being hidden release everything.
//! (A shim here, not a patch to eframe: its listeners stay as they are, this only adds what
//! they miss.)
use egui::{Event, Modifiers, NUM_POINTER_BUTTONS, PointerButton, Pos2};
/// egui's buttons by their bit in the browser's `buttons` bitmask: bit 0 primary, 1 secondary,
/// 2 auxiliary (middle), 3 back, 4 forward. egui numbers its buttons in the same order.
const BUTTONS: [PointerButton; NUM_POINTER_BUTTONS] =
[PointerButton::Primary, PointerButton::Secondary, PointerButton::Middle, PointerButton::Extra1, PointerButton::Extra2];
/// What the browser last said about the mouse buttons.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Observed {
/// The `buttons` bitmask: the buttons held now.
pub buttons: u16,
/// The buttons went down on the canvas, so a press egui missed may be added. A drag that
/// started elsewhere on the page and came over the canvas must not become a press in the app.
pub on_canvas: bool,
}
/// What happened to the pointer, as the page's listeners see it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Happened {
/// `pointerdown`, `pointermove` or `pointerup` from a mouse or pen, with its `buttons`;
/// `down_on_canvas` when it is a `pointerdown` aimed at the canvas.
Pointer { buttons: u16, down_on_canvas: bool },
/// `pointercancel`, `lostpointercapture`, the window losing focus or the page being hidden:
/// whatever was held won't be heard released.
Lost,
}
/// The listeners' state: the latest observation, waiting for the next frame.
#[derive(Debug, Default)]
pub struct Tracker {
/// The buttons held now went down on the canvas.
on_canvas: bool,
pending: Option<Observed>,
}
impl Tracker {
/// Note what happened. Returns true when the held buttons changed, so a frame should run.
pub fn note(&mut self, happened: Happened) -> bool {
let before = self.pending.map(|o| o.buttons);
let buttons = match happened {
Happened::Pointer { buttons, down_on_canvas } => {
self.on_canvas |= down_on_canvas;
buttons
}
Happened::Lost => 0,
};
let observed = Observed { buttons, on_canvas: self.on_canvas };
if buttons == 0 {
self.on_canvas = false;
}
self.pending = Some(observed);
before != Some(buttons)
}
/// The observation for this frame, once.
pub fn take(&mut self) -> Option<Observed> {
self.pending.take()
}
}
/// The button events to add after `events` (this frame's input so far) so that egui, whose
/// buttons were `down` at the end of the last frame, holds what the browser holds. Releases are
/// always added; presses only for buttons that went down on the canvas.
pub fn corrections(down: [bool; NUM_POINTER_BUTTONS], events: &[Event], observed: Observed, pos: Pos2, modifiers: Modifiers) -> Vec<Event> {
let mut down = down;
let mut pos = pos;
for event in events {
match event {
Event::PointerButton { button, pressed, pos: at, .. } => {
down[*button as usize] = *pressed;
pos = *at;
}
Event::PointerMoved(at) => pos = *at,
_ => {}
}
}
let mut out = Vec::new();
for (bit, button) in BUTTONS.into_iter().enumerate() {
let held = observed.buttons & (1 << bit) != 0;
let pressed = match (down[bit], held) {
(true, false) => false,
(false, true) if observed.on_canvas => true,
_ => continue,
};
out.push(Event::PointerButton { pos, button, pressed, modifiers });
}
out
}
#[cfg(target_arch = "wasm32")]
/// egui's buttons held at the end of the last frame.
pub fn egui_down(ctx: &egui::Context) -> [bool; NUM_POINTER_BUTTONS] {
ctx.input(|i| BUTTONS.map(|b| i.pointer.button_down(b)))
}
#[cfg(target_arch = "wasm32")]
/// Add the button events this frame needs (see [`corrections`]).
pub fn reconcile(tracker: &std::cell::RefCell<Tracker>, ctx: &egui::Context, raw: &mut egui::RawInput) {
let Some(observed) = tracker.borrow_mut().take() else { return };
let pos = ctx.input(|i| i.pointer.interact_pos().or(i.pointer.latest_pos())).unwrap_or_default();
let modifiers = ctx.input(|i| i.modifiers);
let fixes = corrections(egui_down(ctx), &raw.events, observed, pos, modifiers);
if !fixes.is_empty() {
log::debug!("mouse buttons: {fixes:?} to match the browser's {:#07b}", observed.buttons);
}
raw.events.extend(fixes);
}
#[cfg(target_arch = "wasm32")]
pub use web::install;
#[cfg(target_arch = "wasm32")]
mod web {
use std::cell::RefCell;
use std::rc::Rc;
use wasm_bindgen::JsCast;
use wasm_bindgen::closure::Closure;
use super::{Happened, Tracker};
/// Listen on the window in the capture phase, so each observation is noted before eframe's
/// own listeners (on the canvas and the document) run a frame from the same event.
pub fn install(window: &web_sys::Window, canvas: &web_sys::HtmlCanvasElement, ctx: &egui::Context) -> Rc<RefCell<Tracker>> {
let tracker = Rc::new(RefCell::new(Tracker::default()));
let listen = |name: &str, on: Box<dyn FnMut(web_sys::Event)>| {
let on = Closure::<dyn FnMut(web_sys::Event)>::new(on);
let _ = window.add_event_listener_with_callback_and_bool(name, on.as_ref().unchecked_ref(), true);
// The listeners live as long as the page.
on.forget();
};
for name in ["pointerdown", "pointermove", "pointerup"] {
let (tracker, canvas, ctx) = (tracker.clone(), canvas.clone(), ctx.clone());
listen(
name,
Box::new(move |e: web_sys::Event| {
let Some(e) = e.dyn_ref::<web_sys::PointerEvent>() else { return };
// Touches reach egui through touch events; their buttons aren't tracked here.
if e.pointer_type() == "touch" {
return;
}
let mut down_on_canvas = false;
if name == "pointerdown" && e.target().is_some_and(|t| canvas.is_same_node(t.dyn_ref::<web_sys::Node>())) {
down_on_canvas = true;
// The release then comes here wherever it happens, even outside the window.
let _ = canvas.set_pointer_capture(e.pointer_id());
}
if tracker.borrow_mut().note(Happened::Pointer { buttons: e.buttons(), down_on_canvas }) {
ctx.request_repaint();
}
}),
);
}
let lost = {
let (tracker, ctx) = (tracker.clone(), ctx.clone());
move || {
if tracker.borrow_mut().note(Happened::Lost) {
ctx.request_repaint();
}
}
};
for name in ["pointercancel", "lostpointercapture", "blur"] {
let lost = lost.clone();
listen(name, Box::new(move |_| lost()));
}
let document = window.document();
listen(
"visibilitychange",
Box::new(move |_| {
if document.as_ref().is_some_and(|d| d.visibility_state() == web_sys::VisibilityState::Hidden) {
lost();
}
}),
);
tracker
}
}
#[cfg(test)]
mod tests {
use super::*;
const NONE: [bool; NUM_POINTER_BUTTONS] = [false; NUM_POINTER_BUTTONS];
const P: Pos2 = Pos2::new(10.0, 20.0);
fn press(button: PointerButton, pressed: bool) -> Event {
Event::PointerButton { pos: P, button, pressed, modifiers: Modifiers::NONE }
}
fn held(buttons: &[PointerButton]) -> [bool; NUM_POINTER_BUTTONS] {
let mut down = NONE;
for b in buttons {
down[*b as usize] = true;
}
down
}
/// Run one browser event through the tracker, then a frame through `corrections`, the way
/// the page does; returns egui's buttons after the frame and the events added.
fn frame(
tracker: &mut Tracker,
down: [bool; NUM_POINTER_BUTTONS],
eframe: &[Event],
happened: Happened,
) -> ([bool; NUM_POINTER_BUTTONS], Vec<Event>) {
tracker.note(happened);
let mut events = eframe.to_vec();
if let Some(observed) = tracker.take() {
let fixes = corrections(down, &events, observed, P, Modifiers::NONE);
events.extend(fixes.clone());
let mut after = down;
for e in &events {
if let Event::PointerButton { button, pressed, .. } = e {
after[*button as usize] = *pressed;
}
}
return (after, fixes);
}
(down, Vec::new())
}
fn pointer(buttons: u16, down_on_canvas: bool) -> Happened {
Happened::Pointer { buttons, down_on_canvas }
}
/// Right, then left, held together; right let go, then left. The browser fires pointerdown
/// (right), pointermove (buttons 3), pointermove (buttons 1), pointerup (left), so eframe
/// says only "right down" and "left up": egui kept right held for good.
#[test]
fn a_chord_ends_with_no_button_held() {
let mut t = Tracker::default();
let (down, fixes) = frame(&mut t, NONE, &[press(PointerButton::Secondary, true)], pointer(0b10, true));
assert!(fixes.is_empty(), "eframe's own press matches the browser: {fixes:?}");
let (down, fixes) = frame(&mut t, down, &[], pointer(0b11, false));
assert_eq!(fixes, vec![press(PointerButton::Primary, true)], "the chord's second button is pressed");
assert_eq!(down, held(&[PointerButton::Primary, PointerButton::Secondary]));
let (down, fixes) = frame(&mut t, down, &[], pointer(0b01, false));
assert_eq!(fixes, vec![press(PointerButton::Secondary, false)], "right is let go while left stays");
let (down, fixes) = frame(&mut t, down, &[press(PointerButton::Primary, false)], pointer(0, false));
assert!(fixes.is_empty(), "eframe's own release matches: {fixes:?}");
assert_eq!(down, NONE, "nothing stays held");
}
/// Both pressed at once and both let go at once: one pointerup with buttons 0 that names
/// only one button. The other is released too.
#[test]
fn one_pointerup_for_two_buttons_releases_both() {
let mut t = Tracker::default();
let (down, _) = frame(&mut t, NONE, &[press(PointerButton::Primary, true)], pointer(0b01, true));
let (down, _) = frame(&mut t, down, &[], pointer(0b11, false));
assert_eq!(down, held(&[PointerButton::Primary, PointerButton::Secondary]));
let (down, fixes) = frame(&mut t, down, &[press(PointerButton::Primary, false)], pointer(0, false));
assert_eq!(fixes, vec![press(PointerButton::Secondary, false)]);
assert_eq!(down, NONE);
}
/// A drag let go outside the window: no pointerup was heard, and the next pointer event
/// (back over the page) has no buttons held.
#[test]
fn a_release_outside_is_caught_by_the_next_event() {
let mut t = Tracker::default();
let (down, _) = frame(&mut t, NONE, &[press(PointerButton::Secondary, true)], pointer(0b10, true));
let (down, fixes) = frame(&mut t, down, &[Event::PointerMoved(Pos2::new(5.0, 6.0))], pointer(0, false));
assert_eq!(
fixes,
vec![Event::PointerButton { pos: Pos2::new(5.0, 6.0), button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE }]
);
assert_eq!(down, NONE);
}
/// The window loses focus (alt-tab) or the pointer is cancelled with buttons held.
#[test]
fn blur_and_cancel_release_every_button() {
let mut t = Tracker::default();
let (down, _) = frame(&mut t, NONE, &[press(PointerButton::Middle, true)], pointer(0b100, true));
let (down, _) = frame(&mut t, down, &[], pointer(0b110, false));
let (down, fixes) = frame(&mut t, down, &[], Happened::Lost);
assert_eq!(fixes, vec![press(PointerButton::Secondary, false), press(PointerButton::Middle, false)]);
assert_eq!(down, NONE);
// Held from before this tracker heard anything (a stale state) is released too.
let (down, _) = frame(&mut t, held(&[PointerButton::Extra1]), &[], Happened::Lost);
assert_eq!(down, NONE);
}
/// A drag that started elsewhere on the page and came over the canvas isn't a press in the app.
#[test]
fn a_drag_from_outside_the_canvas_presses_nothing() {
let mut t = Tracker::default();
let (down, fixes) = frame(&mut t, NONE, &[], pointer(0b01, false));
assert!(fixes.is_empty() && down == NONE, "{fixes:?}");
let (down, fixes) = frame(&mut t, down, &[], pointer(0, false));
assert!(fixes.is_empty() && down == NONE);
// The next press on the canvas counts again.
let (down, _) = frame(&mut t, down, &[press(PointerButton::Primary, true)], pointer(0b01, true));
let (down, fixes) = frame(&mut t, down, &[], pointer(0b11, false));
assert_eq!(fixes, vec![press(PointerButton::Secondary, true)]);
assert_eq!(down, held(&[PointerButton::Primary, PointerButton::Secondary]));
}
/// A quick click inside one frame (down and up both from eframe) needs nothing, and no frame
/// runs corrections without a new observation (touch input is left alone).
#[test]
fn matching_input_is_left_alone() {
let mut t = Tracker::default();
let (down, fixes) = frame(&mut t, NONE, &[press(PointerButton::Primary, true), press(PointerButton::Primary, false)], pointer(0, true));
assert!(fixes.is_empty() && down == NONE, "{fixes:?}");
assert_eq!(t.take(), None, "an observation is used once");
let touch_held = held(&[PointerButton::Primary]);
assert!(corrections(touch_held, &[], Observed { buttons: 1, on_canvas: false }, P, Modifiers::NONE).is_empty());
}
/// The tracker asks for a frame only when the held buttons change.
#[test]
fn repaint_only_on_change() {
let mut t = Tracker::default();
assert!(t.note(pointer(0, false)), "the first observation");
assert!(!t.note(pointer(0, false)), "hovering changes nothing");
assert!(t.note(pointer(0b10, true)));
assert!(!t.note(pointer(0b10, false)));
assert!(t.note(Happened::Lost));
}
}
+24 -8
View File
@@ -1595,9 +1595,11 @@ fn window_system_override(preference: Option<&str>) -> Option<&'static str> {
/// Whether to run through XWayland rather than native Wayland. winit has no file drag-and-drop
/// on Wayland (only on X11), so dropping files from the file manager showed a "no" cursor: use
/// XWayland when it's there. Except on niri, whose XWayland (xwayland-satellite) breaks window
/// management (maximize/restore), so it stays on native Wayland and gives up file drops.
/// `SOLIDCRAFT_WAYLAND=1` keeps native Wayland, `SOLIDCRAFT_WAYLAND=0` forces XWayland.
/// XWayland when it's there. Except on compositors where XWayland is a worse default: niri
/// (xwayland-satellite breaks maximize/restore) and Hyprland (fractional scale with
/// `force_zero_scaling` plus `GDK_SCALE` makes the UI ~2.5× oversized, #10480). Those stay on
/// native Wayland and give up file drops. `SOLIDCRAFT_WAYLAND=1` keeps native Wayland,
/// `SOLIDCRAFT_WAYLAND=0` forces XWayland.
#[cfg(all(unix, not(target_os = "macos")))]
fn prefer_x11(display: bool, wayland_display: bool, desktop: Option<&str>, wayland_override: Option<&str>) -> bool {
match wayland_override.map(str::trim) {
@@ -1605,8 +1607,18 @@ fn prefer_x11(display: bool, wayland_display: bool, desktop: Option<&str>, wayla
Some(_) => return false,
None => {}
}
let niri = desktop.is_some_and(|d| d.split(':').any(|name| name.trim().eq_ignore_ascii_case("niri")));
display && !(niri && wayland_display)
display && !(native_wayland_desktop(desktop) && wayland_display)
}
/// Desktops that keep Automatic on native Wayland when a Wayland socket is present.
#[cfg(all(unix, not(target_os = "macos")))]
fn native_wayland_desktop(desktop: Option<&str>) -> bool {
desktop.is_some_and(|d| {
d.split(':').any(|name| {
let name = name.trim();
name.eq_ignore_ascii_case("niri") || name.eq_ignore_ascii_case("Hyprland")
})
})
}
#[cfg(all(unix, not(target_os = "macos")))]
@@ -1655,20 +1667,24 @@ mod backend_tests {
use super::prefer_x11;
#[test]
fn xwayland_by_default_native_wayland_on_niri_and_the_override_wins() {
fn xwayland_by_default_native_wayland_on_niri_hyprland_and_the_override_wins() {
// XWayland whenever there's an X display, for file drag-and-drop.
assert!(prefer_x11(true, true, Some("GNOME"), None));
assert!(prefer_x11(true, false, None, None));
assert!(!prefer_x11(false, true, Some("KDE"), None));
// niri (alone or in a desktop list) under Wayland stays native.
// niri / Hyprland (alone or in a desktop list) under Wayland stays native.
assert!(!prefer_x11(true, true, Some("niri"), None));
assert!(!prefer_x11(true, true, Some("niri:GNOME"), None));
// niri without a Wayland socket (nested X session) still uses X11.
assert!(!prefer_x11(true, true, Some("Hyprland"), None));
assert!(!prefer_x11(true, true, Some("hyprland"), None));
// Without a Wayland socket (nested X session) still uses X11.
assert!(prefer_x11(true, false, Some("niri"), None));
assert!(prefer_x11(true, false, Some("Hyprland"), None));
// SOLIDCRAFT_WAYLAND: anything but 0 keeps Wayland, 0 forces XWayland (when present).
assert!(!prefer_x11(true, true, Some("GNOME"), Some("1")));
assert!(!prefer_x11(true, true, Some("GNOME"), Some("")));
assert!(prefer_x11(true, true, Some("niri"), Some("0")));
assert!(prefer_x11(true, true, Some("Hyprland"), Some("0")));
assert!(!prefer_x11(false, true, Some("niri"), Some("0")));
}
Binary file not shown.
+96
View File
@@ -0,0 +1,96 @@
Copyright 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'.
Copyright 2014-2021 Google Inc. with Reserved Font Name 'Noto'.
Subset for SolidCraft web UI (Hangul, kana and Latin glyphs); renamed Craft UI KR (no Reserved Font Name).
This Font Software is licensed under the SIL Open Font License,
Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font
creation efforts of academic and linguistic communities, and to
provide a free and open framework in which fonts may be shared and
improved in partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply to
any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software
components as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to,
deleting, or substituting -- in part or in whole -- any of the
components of the Original Version, by changing formats or by porting
the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed,
modify, redistribute, and sell modified and unmodified copies of the
Font Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components, in
Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the
corresponding Copyright Holder. This restriction only applies to the
primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created using
the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+2 -2
View File
@@ -2653,7 +2653,7 @@
"prefs.design_tools.imported_files": "الملفات المستوردة",
"prefs.design_tools.note": "ملاحظة",
"prefs.design_tools.off": "إيقاف",
"prefs.design_tools.placement_group": "مسند، نتوء، تعشيق مرن",
"prefs.design_tools.placement_group": "الميزات الموضوعة",
"prefs.design_tools.rows": "صفوف",
"prefs.devices": "الفأرة ثلاثية الأبعاد",
"prefs.dialog_buttons": "أزرار مربعات الحوار",
@@ -6070,7 +6070,7 @@
"ui.pitch": "الخطوة",
"ui.placement": "الموضع",
"ui.placement_handles": "مقابض الموضع",
"ui.placement_handles_tip": "علامة لسحب الميزة عبر النموذج وحلقة لتدويرها.",
"ui.placement_handles_tip": "علامة لسحب الميزة عبر النموذج، وحلقة لتدويرها حيث يمكن تدوير الميزة.",
"ui.plane": "المستوى",
"ui.plane_at_angle": "مستوى بزاوية",
"ui.plugins": "الإضافات",
+1 -1
View File
@@ -3384,7 +3384,7 @@
"ui.pitch": "Pitch",
"ui.placement": "Placement",
"ui.placement_handles": "Placement handles",
"ui.placement_handles_tip": "Handles on the preview of a Rest, Boss or Snap Fit: drag the marker to move it across the model, turn it by its ring",
"ui.placement_handles_tip": "Маркер за влачене на елемента по модела и, където елементът може да се завърта, пръстен за завъртането му.",
"ui.plane": "Plane",
"ui.plane_at_angle": "Plane at Angle",
"ui.plugins": "Plugins",
+1 -1
View File
@@ -4907,7 +4907,7 @@
"ui.pitch": "Korak",
"ui.placement": "Postavljanje",
"ui.placement_handles": "Ručice za postavljanje",
"ui.placement_handles_tip": "Ručice na pregledu oslonca, ispusta ili škljocajućeg spoja: povucite oznaku da je pomjerite po modelu, okrenite je njenim prstenom",
"ui.placement_handles_tip": "Oznaka za povlačenje operacije po modelu i, gdje se operacija može okretati, prsten za njeno okretanje.",
"ui.plane": "Ravan",
"ui.plane_at_angle": "Ravan pod uglom",
"ui.plugins": "Proširenja",
+2 -2
View File
@@ -2653,7 +2653,7 @@
"prefs.design_tools.imported_files": "Fitxers importats",
"prefs.design_tools.note": "Nota",
"prefs.design_tools.off": "Desactivat",
"prefs.design_tools.placement_group": "Repòs, Resalt, Encaix",
"prefs.design_tools.placement_group": "Operacions col·locades",
"prefs.design_tools.rows": "Files",
"prefs.devices": "Ratolí 3D",
"prefs.dialog_buttons": "Botons de diàleg",
@@ -6070,7 +6070,7 @@
"ui.pitch": "Inclinació",
"ui.placement": "Col·locació",
"ui.placement_handles": "Tiradors de col·locació",
"ui.placement_handles_tip": "Un marcador per arrossegar l'operació pel model i un anell per girar-la.",
"ui.placement_handles_tip": "Un marcador per arrossegar l'operació pel model i, on l'operació es pot girar, un anell per girar-la.",
"ui.plane": "Pla",
"ui.plane_at_angle": "Pla amb angle",
"ui.plugins": "Complements",
+2 -2
View File
@@ -2562,7 +2562,7 @@
"prefs.design_tools.imported_files": "Importované soubory",
"prefs.design_tools.note": "Poznámka",
"prefs.design_tools.off": "Vypnuto",
"prefs.design_tools.placement_group": "Opěra, Nálitek, Západka",
"prefs.design_tools.placement_group": "Umisťované prvky",
"prefs.design_tools.rows": "Řádky",
"prefs.devices": "3D myš",
"prefs.dialog_buttons": "Tlačítka dialogu",
@@ -5824,7 +5824,7 @@
"ui.pitch": "Stoupání",
"ui.placement": "Umístění",
"ui.placement_handles": "Úchyty umístění",
"ui.placement_handles_tip": "Úchyty na náhledu opěry, nálitku nebo západky: tažením značky ji přesunete po modelu, prstencem ji otočíte",
"ui.placement_handles_tip": "Značka pro přetažení prvku po modelu a tam, kde se prvek může otáčet, kroužek pro jeho otočení.",
"ui.plane": "Rovina",
"ui.plane_at_angle": "Rovina pod úhlem",
"ui.plugins": "Moduly plug-in",
+1 -1
View File
@@ -4916,7 +4916,7 @@
"ui.pitch": "Stigning",
"ui.placement": "Placering",
"ui.placement_handles": "Placeringshåndtag",
"ui.placement_handles_tip": "Håndtag på forhåndsvisningen af en Understøtning, Skruebøsning eller Snapforbindelse: Træk i markøren for at flytte den hen over modellen, og drej den ved dens ring",
"ui.placement_handles_tip": "En markør til at trække featuren hen over modellen og, hvor featuren kan drejes, en ring til at dreje den.",
"ui.plane": "Plan",
"ui.plane_at_angle": "Plan i vinkel",
"ui.plugins": "Plugins",
+2 -2
View File
@@ -2424,7 +2424,7 @@
"prefs.design_tools.imported_files": "Importierte Dateien",
"prefs.design_tools.note": "Hinweis",
"prefs.design_tools.off": "Aus",
"prefs.design_tools.placement_group": "Auflage, Zapfen, Schnappverbindung",
"prefs.design_tools.placement_group": "Platzierte Funktionen",
"prefs.design_tools.rows": "Zeilen",
"prefs.devices": "3D-Maus",
"prefs.dialog_buttons": "Dialogschaltflächen",
@@ -5758,7 +5758,7 @@
"ui.pitch": "Steigung",
"ui.placement": "Platzierung",
"ui.placement_handles": "Platzierungsgriffe",
"ui.placement_handles_tip": "Eine Markierung zum Verschieben des Features über das Modell und ein Ring zum Drehen.",
"ui.placement_handles_tip": "Eine Markierung zum Verschieben des Features über das Modell und, wo sich das Feature drehen lässt, ein Ring zum Drehen.",
"ui.plane": "Ebene",
"ui.plane_at_angle": "Ebene im Winkel",
"ui.plugins": "Plugins",
+2 -2
View File
@@ -2653,7 +2653,7 @@
"prefs.design_tools.imported_files": "Imported files",
"prefs.design_tools.note": "Note",
"prefs.design_tools.off": "Off",
"prefs.design_tools.placement_group": "Rest, Boss, Snap Fit",
"prefs.design_tools.placement_group": "Placed features",
"prefs.design_tools.rows": "Rows",
"prefs.devices": "3D mouse",
"prefs.dialog_buttons": "Dialog buttons",
@@ -6070,7 +6070,7 @@
"ui.pitch": "Pitch",
"ui.placement": "Placement",
"ui.placement_handles": "Placement handles",
"ui.placement_handles_tip": "A marker to drag the feature across the model and a ring to turn it.",
"ui.placement_handles_tip": "A marker to drag the feature across the model and, where the feature can turn, a ring to turn it.",
"ui.plane": "Plane",
"ui.plane_at_angle": "Plane at Angle",
"ui.plugins": "Plugins",
+2 -2
View File
@@ -2653,7 +2653,7 @@
"prefs.design_tools.imported_files": "Archivos importados",
"prefs.design_tools.note": "Nota",
"prefs.design_tools.off": "Desactivado",
"prefs.design_tools.placement_group": "Apoyo, Saliente, Encaje",
"prefs.design_tools.placement_group": "Operaciones colocadas",
"prefs.design_tools.rows": "Filas",
"prefs.devices": "Ratón 3D",
"prefs.dialog_buttons": "Botones de diálogo",
@@ -6070,7 +6070,7 @@
"ui.pitch": "Inclinación",
"ui.placement": "Colocación",
"ui.placement_handles": "Tiradores de colocación",
"ui.placement_handles_tip": "Tiradores en la vista previa de un Refuerzo, Saliente o Encaje a presión: arrastra el marcador para moverlo por el modelo, gíralo por su anillo.",
"ui.placement_handles_tip": "Un marcador para arrastrar la operación por el modelo y, donde la operación puede girar, un anillo para girarla.",
"ui.plane": "Plano",
"ui.plane_at_angle": "Plano en ángulo",
"ui.plugins": "Complementos",
+2 -2
View File
@@ -2581,7 +2581,7 @@
"prefs.design_tools.imported_files": "Tuodut tiedostot",
"prefs.design_tools.note": "Huomautus",
"prefs.design_tools.off": "Pois",
"prefs.design_tools.placement_group": "Tuki, Tappi, Napsautusliitos",
"prefs.design_tools.placement_group": "Sijoitettavat piirteet",
"prefs.design_tools.rows": "Rivit",
"prefs.devices": "3D-hiiri",
"prefs.dialog_buttons": "Valintaikkunan painikkeet",
@@ -5850,7 +5850,7 @@
"ui.pitch": "Nousu",
"ui.placement": "Sijoitus",
"ui.placement_handles": "Sijoituskahvat",
"ui.placement_handles_tip": "Kahvat tuen, tapin tai napsautusliitoksen esikatselussa: siirrä merkkiä mallin pinnalla vetämällä ja käännä sitä renkaasta",
"ui.placement_handles_tip": "Merkki piirteen vetämiseen mallin yli ja, jos piirrettä voi kääntää, rengas sen kääntämiseen.",
"ui.plane": "Taso",
"ui.plane_at_angle": "Taso kulmassa",
"ui.plugins": "Laajennukset",
+2 -2
View File
@@ -2343,7 +2343,7 @@
"prefs.design_tools.imported_files": "Fichiers importés",
"prefs.design_tools.note": "Remarque",
"prefs.design_tools.off": "Désactivé",
"prefs.design_tools.placement_group": "Appui, Bossage, Clip",
"prefs.design_tools.placement_group": "Fonctions placées",
"prefs.design_tools.rows": "Lignes",
"prefs.devices": "Souris 3D",
"prefs.dialog_buttons": "Boutons OK",
@@ -5287,7 +5287,7 @@
"ui.pitch": "Pas",
"ui.placement": "Placement",
"ui.placement_handles": "Poignées de placement",
"ui.placement_handles_tip": "Poignées sur l'aperçu d'un appui, d'un bossage ou d'un clip : faire glisser le repère pour le déplacer sur le modèle, le tourner par son anneau",
"ui.placement_handles_tip": "Un repère pour faire glisser la fonction sur le modèle et, là où la fonction peut pivoter, un anneau pour la faire pivoter.",
"ui.plane": "Plan",
"ui.plane_at_angle": "Plan incliné",
"ui.plugins": "Modules externes",
+1 -1
View File
@@ -4915,7 +4915,7 @@
"ui.pitch": "Paso",
"ui.placement": "Colocación",
"ui.placement_handles": "Asas de colocación",
"ui.placement_handles_tip": "Asas na vista previa dun Apoio, un Saínte ou un Encaixe a presión: arrastra o marcador para movelo polo modelo, xírao polo seu anel",
"ui.placement_handles_tip": "Un marcador para arrastrar a operación polo modelo e, onde a operación pode xirar, un anel para xirala.",
"ui.plane": "Plano",
"ui.plane_at_angle": "Plano en ángulo",
"ui.plugins": "Complementos",
+2 -2
View File
@@ -2653,7 +2653,7 @@
"prefs.design_tools.imported_files": "קבצים מיובאים",
"prefs.design_tools.note": "הערה",
"prefs.design_tools.off": "כבוי",
"prefs.design_tools.placement_group": "משען, בליטה, חיבור נצמד",
"prefs.design_tools.placement_group": "פיצ'רים ממוקמים",
"prefs.design_tools.rows": "שורות",
"prefs.devices": "עכבר תלת־ממדי",
"prefs.dialog_buttons": "לחצני תיבות דו־שיח",
@@ -6070,7 +6070,7 @@
"ui.pitch": "פסיעה",
"ui.placement": "מיקום",
"ui.placement_handles": "ידיות מיקום",
"ui.placement_handles_tip": "סמן לגרירת התכונה על המודל וטבעת לסיבובה.",
"ui.placement_handles_tip": "סמן לגרירת התכונה על המודל, וטבעת לסיבובה במקום שבו אפשר לסובב את התכונה.",
"ui.plane": "מישור",
"ui.plane_at_angle": "מישור בזווית",
"ui.plugins": "תוספים",
+1 -1
View File
@@ -4907,7 +4907,7 @@
"ui.pitch": "Korak",
"ui.placement": "Postavljanje",
"ui.placement_handles": "Ručice za postavljanje",
"ui.placement_handles_tip": "Ručice na pregledu oslonca, ispusta ili škljocajućeg spoja: povucite oznaku kako biste je pomaknuli po modelu, okrenite je njezinim prstenom",
"ui.placement_handles_tip": "Oznaka za povlačenje značajke po modelu i, gdje se značajka može okretati, prsten za njezino okretanje.",
"ui.plane": "Ravnina",
"ui.plane_at_angle": "Ravnina pod kutom",
"ui.plugins": "Proširenja",
+1 -1
View File
@@ -4915,7 +4915,7 @@
"ui.pitch": "Emelkedés",
"ui.placement": "Elhelyezés",
"ui.placement_handles": "Elhelyezési fogantyúk",
"ui.placement_handles_tip": "Fogantyúk egy Támasz, Dudor vagy Pattintós illesztés előnézetén: húzza a jelölőt a modell fölött, a gyűrűjénél fogva forgassa el",
"ui.placement_handles_tip": "Egy jelölő az elem modellen való húzásához és, ahol az elem elforgatható, egy gyűrű az elforgatásához.",
"ui.plane": "Sík",
"ui.plane_at_angle": "Szögben álló sík",
"ui.plugins": "Bővítmények",
+1 -1
View File
@@ -4915,7 +4915,7 @@
"ui.pitch": "Pitch",
"ui.placement": "Penempatan",
"ui.placement_handles": "Handle penempatan",
"ui.placement_handles_tip": "Handle pada pratinjau Rest, Boss, atau Snap Fit: seret penanda untuk memindahkannya di model, putar lewat cincinnya",
"ui.placement_handles_tip": "Penanda untuk menyeret fitur di atas model dan, jika fitur dapat diputar, cincin untuk memutarnya.",
"ui.plane": "Bidang",
"ui.plane_at_angle": "Bidang Bersudut",
"ui.plugins": "Plugin",
+2 -2
View File
@@ -2588,7 +2588,7 @@
"prefs.design_tools.imported_files": "File importati",
"prefs.design_tools.note": "Avvisa",
"prefs.design_tools.off": "Disattivato",
"prefs.design_tools.placement_group": "Appoggio, Risalto, Aggancio a scatto",
"prefs.design_tools.placement_group": "Lavorazioni posizionate",
"prefs.design_tools.rows": "Righe",
"prefs.devices": "Mouse 3D",
"prefs.dialog_buttons": "Pulsanti OK",
@@ -5873,7 +5873,7 @@
"ui.pitch": "Passo",
"ui.placement": "Posizionamento",
"ui.placement_handles": "Maniglie di posizionamento",
"ui.placement_handles_tip": "Maniglie sull'anteprima di un Appoggio, Risalto o Aggancio a scatto: trascina il marcatore per spostarlo sul modello, ruotalo dal suo anello",
"ui.placement_handles_tip": "Un indicatore per trascinare la lavorazione sul modello e, dove la lavorazione può ruotare, un anello per ruotarla.",
"ui.plane": "Piano",
"ui.plane_at_angle": "Piano inclinato",
"ui.plugins": "Plugin",
+2 -2
View File
@@ -2653,7 +2653,7 @@
"prefs.design_tools.imported_files": "インポートしたファイル",
"prefs.design_tools.note": "通知",
"prefs.design_tools.off": "オフ",
"prefs.design_tools.placement_group": "レスト、ボス、スナップフィット",
"prefs.design_tools.placement_group": "配置するフィーチャー",
"prefs.design_tools.rows": "行数",
"prefs.devices": "3D マウス",
"prefs.dialog_buttons": "ダイアログのボタン",
@@ -6070,7 +6070,7 @@
"ui.pitch": "ピッチ",
"ui.placement": "配置",
"ui.placement_handles": "配置ハンドル",
"ui.placement_handles_tip": "モデル上でフィーチャーをドラッグするマーカーと、回転させるリング。",
"ui.placement_handles_tip": "モデル上でフィーチャーをドラッグするマーカーと、フィーチャーを回転できる場合は回転させるリング。",
"ui.plane": "平面",
"ui.plane_at_angle": "傾斜平面",
"ui.plugins": "プラグイン",
+1 -1
View File
@@ -5073,7 +5073,7 @@
"ui.pitch": "ბიჯი",
"ui.placement": "განთავსება",
"ui.placement_handles": "განთავსების სახელურები",
"ui.placement_handles_tip": "სახელურები საყრდენის, ბოსის ან ჩამკეტის გადახედვაზე: მარკერი გადაათრიეთ მოდელზე მის გადასაადგილებლად, ხოლო რგოლით შეაბრუნეთ",
"ui.placement_handles_tip": "მარკერი ოპერაციის მოდელზე გადასატანად და, სადაც ოპერაციის მობრუნება შეიძლება, რგოლი მის მოსაბრუნებლად.",
"ui.plane": "სიბრტყე",
"ui.plane_at_angle": "სიბრტყე კუთხით",
"ui.plugins": "პლაგინები",
+2 -2
View File
@@ -1996,7 +1996,7 @@
"prefs.design_tools.imported_files": "가져온 파일",
"prefs.design_tools.note": "알림",
"prefs.design_tools.off": "끄기",
"prefs.design_tools.placement_group": "레스트, 보스, 스냅 핏",
"prefs.design_tools.placement_group": "배치 피처",
"prefs.design_tools.rows": "행",
"prefs.devices": "3D 마우스",
"prefs.dialog_buttons": "대화 상자 버튼",
@@ -5195,7 +5195,7 @@
"ui.pipe": "파이프",
"ui.pitch": "피치",
"ui.placement": "배치",
"ui.placement_handles_tip": "모델 위로 피처를 드래그하는 마커와 회전하는 링입니다.",
"ui.placement_handles_tip": "모델 위로 피처를 드래그하는 마커와, 피처를 회전할 수 있는 경우 회전하는 링입니다.",
"ui.plane": "평면",
"ui.plane_at_angle": "각도 평면",
"ui.plugins": "플러그인",
+2 -2
View File
@@ -2582,7 +2582,7 @@
"prefs.design_tools.imported_files": "Importētie faili",
"prefs.design_tools.note": "Piezīme",
"prefs.design_tools.off": "Izslēgts",
"prefs.design_tools.placement_group": "Atbalsts, izcilnis, aizspiedsavienojums",
"prefs.design_tools.placement_group": "Izvietojamās operācijas",
"prefs.design_tools.rows": "Rindas",
"prefs.devices": "3D pele",
"prefs.dialog_buttons": "Dialoglodziņu pogas",
@@ -5903,7 +5903,7 @@
"ui.pitch": "Solis",
"ui.placement": "Izvietojums",
"ui.placement_handles": "Izvietošanas turi",
"ui.placement_handles_tip": "Marķieris operācijas vilkšanai pa modeli un gredzens tās pagriešanai.",
"ui.placement_handles_tip": "Marķieris operācijas vilkšanai pa modeli un, kur operāciju var pagriezt, gredzens tās pagriešanai.",
"ui.plane": "Plakne",
"ui.plane_at_angle": "Plakne leņķī",
"ui.plugins": "Spraudņi",
+1 -1
View File
@@ -4915,7 +4915,7 @@
"ui.pitch": "Pic",
"ui.placement": "Penempatan",
"ui.placement_handles": "Pemegang penempatan",
"ui.placement_handles_tip": "Pemegang pada pratonton Penyokong, Bonggol atau Sambungan Sentap: seret penanda untuk menggerakkannya merentasi model, putarkannya melalui cincinnya",
"ui.placement_handles_tip": "Penanda untuk menyeret ciri merentasi model dan, jika ciri boleh dipusingkan, gelang untuk memusingkannya.",
"ui.plane": "Satah",
"ui.plane_at_angle": "Satah Bersudut",
"ui.plugins": "Pemalam",
+1 -1
View File
@@ -4915,7 +4915,7 @@
"ui.pitch": "Stigning",
"ui.placement": "Plassering",
"ui.placement_handles": "Plasseringshåndtak",
"ui.placement_handles_tip": "Håndtak på forhåndsvisningen av et anlegg, skruefeste eller snap-fit: dra markøren for å flytte den over modellen, drei den med ringen",
"ui.placement_handles_tip": "En markør for å dra funksjonen over modellen og, der funksjonen kan dreies, en ring for å dreie den.",
"ui.plane": "Plan",
"ui.plane_at_angle": "Plan i vinkel",
"ui.plugins": "Programtillegg",
+1 -1
View File
@@ -4929,7 +4929,7 @@
"ui.pitch": "Steek",
"ui.placement": "Plaatsing",
"ui.placement_handles": "Plaatsingsgrepen",
"ui.placement_handles_tip": "Grepen op het voorbeeld van een Rustvlak, Nok of Snapverbinding: sleep de markering om hem over het model te verplaatsen, draai hem aan de ring",
"ui.placement_handles_tip": "Een markering om het kenmerk over het model te slepen en, waar het kenmerk kan draaien, een ring om het te draaien.",
"ui.plane": "Vlak",
"ui.plane_at_angle": "Vlak onder hoek",
"ui.plugins": "Plug-ins",
@@ -0,0 +1,34 @@
{
"ar": {
"prefs.keep_hover_while_navigating": "أبقِ التمييز أثناء تحرك العرض",
"prefs.keep_hover_while_navigating_note": "سطح المؤشر يحتفظ بتمييزه أثناء تحرك العرض تحته (إيقاف: يختفي التمييز أثناء تحرك العرض كما كان سابقًا، #2130)"
},
"de": {
"prefs.keep_hover_while_navigating": "Hervorhebung beim Bewegen der Ansicht beibehalten",
"prefs.keep_hover_while_navigating_note": "Die Fläche unter dem Zeiger behält ihre Hervorhebung, während sich die Ansicht darunter bewegt (aus: die Hervorhebung erlischt beim Bewegen der Ansicht wie zuvor, #2130)"
},
"en": {
"prefs.keep_hover_while_navigating": "Keep the highlight while the view moves",
"prefs.keep_hover_while_navigating_note": "The face under the pointer keeps its highlight while the view moves under it (off: the highlight goes off while the view moves, as before, #2130)"
},
"he": {
"prefs.keep_hover_while_navigating": "שמור על ההדגשה בזמן שהתצוגה זזה",
"prefs.keep_hover_while_navigating_note": "הפאה שמתחת למצביע שומרת על ההדגשה בזמן שהתצוגה זזה מתחתיה (כבוי: ההדגשה נעלמת בזמן שהתצוגה זזה כמו קודם, #2130)"
},
"ja": {
"prefs.keep_hover_while_navigating": "ビューが動く間もハイライトを維持",
"prefs.keep_hover_while_navigating_note": "ポインターが指している面は、ビューがその下を動く間もハイライトを維持します(オフ:以前のように、ビューが動く間はハイライトが消えます #2130)"
},
"ko": {
"prefs.keep_hover_while_navigating": "뷰가 움직일 때도 하이라이트 유지",
"prefs.keep_hover_while_navigating_note": "포인터 아래의 면은 그 아래에서 뷰가 움직이는 동안에도 하이라이트를 유지합니다 (끄면: 이전처럼 뷰가 움직일 때 하이라이트가 사라집니다, #2130)"
},
"vi": {
"prefs.keep_hover_while_navigating": "Giữ điểm sáng khi khung nhìn di chuyển",
"prefs.keep_hover_while_navigating_note": "Mặt dưới con trỏ giữ điểm sáng khi khung nhìn di chuyển bên dưới nó (tắt: điểm sáng tắt khi khung nhìn di chuyển như trước đây #2130)"
},
"zh": {
"prefs.keep_hover_while_navigating": "视图移动时保持高亮",
"prefs.keep_hover_while_navigating_note": "指针下的面在视图于其下方移动时保持高亮(关闭:与以前一样,视图移动时高亮会消失 #2130)"
}
}
@@ -0,0 +1,23 @@
{
"en": {
"settings.names_from_file": "Name tabs and the Browser's top row after the design's file"
},
"de": {
"settings.names_from_file": "Tabs und oberste Browserzeile nach der Datei des Designs benennen"
},
"ar": {
"settings.names_from_file": "تسمية علامات التبويب والصف العلوي في المستعرض باسم ملف التصميم"
},
"he": {
"settings.names_from_file": "לקרוא ללשוניות ולשורה העליונה בדפדפן בשם קובץ העיצוב"
},
"ja": {
"settings.names_from_file": "タブとブラウザーの最上行にデザインのファイル名を表示"
},
"vi": {
"settings.names_from_file": "Đặt tên thẻ và hàng trên cùng của Trình duyệt theo tệp của thiết kế"
},
"zh": {
"settings.names_from_file": "以设计的文件名命名标签页和浏览器顶行"
}
}
+29
View File
@@ -0,0 +1,29 @@
{
"en": {
"settings.snap_points": "Snap to center, middle and end points when picking a point"
},
"en-GB": {
"settings.snap_points": "Snap to centre, middle and end points when picking a point"
},
"de": {
"settings.snap_points": "Beim Auswählen eines Punkts an Mittel- und Endpunkten fangen"
},
"ja": {
"settings.snap_points": "点を選択するときに中心点、中点、端点にスナップ"
},
"ko": {
"settings.snap_points": "점을 선택할 때 중심점, 중간점, 끝점에 스냅"
},
"zh": {
"settings.snap_points": "拾取点时捕捉中心点、中点和端点"
},
"ar": {
"settings.snap_points": "الالتقاط إلى نقاط المركز والمنتصف والنهاية عند اختيار نقطة"
},
"he": {
"settings.snap_points": "הצמדה לנקודות מרכז, אמצע וקצה בעת בחירת נקודה"
},
"vi": {
"settings.snap_points": "Bắt dính vào điểm tâm, điểm giữa và điểm đầu mút khi chọn một điểm"
}
}
@@ -0,0 +1,9 @@
{
"ar": {"cmd.sketch_spline_handles": "إضافة مقابض المماس", "cmd.sketch_spline_reset_handles": "إعادة تعيين مقابض المماس"},
"de": {"cmd.sketch_spline_handles": "Tangentengriffe hinzufügen", "cmd.sketch_spline_reset_handles": "Tangentengriffe zurücksetzen"},
"en": {"cmd.sketch_spline_handles": "Add Tangent Handles", "cmd.sketch_spline_reset_handles": "Reset Tangent Handles"},
"he": {"cmd.sketch_spline_handles": "הוסף ידיות משיק", "cmd.sketch_spline_reset_handles": "אפס ידיות משיק"},
"ja": {"cmd.sketch_spline_handles": "接線ハンドルを追加", "cmd.sketch_spline_reset_handles": "接線ハンドルをリセット"},
"vi": {"cmd.sketch_spline_handles": "Thêm tay nắm tiếp tuyến", "cmd.sketch_spline_reset_handles": "Đặt lại tay nắm tiếp tuyến"},
"zh": {"cmd.sketch_spline_handles": "添加切线控制柄", "cmd.sketch_spline_reset_handles": "重置切线控制柄"}
}
+2 -2
View File
@@ -2653,7 +2653,7 @@
"prefs.design_tools.imported_files": "Zaimportowane pliki",
"prefs.design_tools.note": "Uwaga",
"prefs.design_tools.off": "Wyłączone",
"prefs.design_tools.placement_group": "Podparcie, Występ, Zatrzask",
"prefs.design_tools.placement_group": "Umieszczane operacje",
"prefs.design_tools.rows": "Wiersze",
"prefs.devices": "Mysz 3D",
"prefs.dialog_buttons": "Przyciski okien dialogowych",
@@ -6070,7 +6070,7 @@
"ui.pitch": "Skok",
"ui.placement": "Umiejscowienie",
"ui.placement_handles": "Uchwyty umieszczania",
"ui.placement_handles_tip": "Uchwyty na podglądzie podparcia, występu lub zatrzasku: przeciągnij znacznik, aby przesunąć element po modelu, obróć go pierścieniem",
"ui.placement_handles_tip": "Znacznik do przeciągania operacji po modelu i, tam gdzie operację można obracać, pierścień do jej obracania.",
"ui.plane": "Płaszczyzna",
"ui.plane_at_angle": "Płaszczyzna pod kątem",
"ui.plugins": "Wtyczki",
+2 -2
View File
@@ -2588,7 +2588,7 @@
"prefs.design_tools.imported_files": "Arquivos importados",
"prefs.design_tools.note": "Observação",
"prefs.design_tools.off": "Desativado",
"prefs.design_tools.placement_group": "Apoio, Ressalto, Encaixe por pressão",
"prefs.design_tools.placement_group": "Recursos posicionados",
"prefs.design_tools.rows": "Linhas",
"prefs.devices": "Mouse 3D",
"prefs.dialog_buttons": "Botões das caixas de diálogo",
@@ -5927,7 +5927,7 @@
"ui.pitch": "Passo",
"ui.placement": "Posicionamento",
"ui.placement_handles": "Alças de posicionamento",
"ui.placement_handles_tip": "Um marcador para arrastar o recurso pelo modelo e um anel para girá-lo.",
"ui.placement_handles_tip": "Um marcador para arrastar o recurso pelo modelo e, onde o recurso pode girar, um anel para girá-lo.",
"ui.plane": "Plano",
"ui.plane_at_angle": "Plano em ângulo",
"ui.plugins": "Plug-ins",
+1 -1
View File
@@ -4915,7 +4915,7 @@
"ui.pitch": "Pas",
"ui.placement": "Plasare",
"ui.placement_handles": "Mânere de plasare",
"ui.placement_handles_tip": "Mânere pe previzualizarea unui Reazem, Bosaj sau a unei Prinderi cu clichet: trageți marcajul pentru a-l muta pe model, rotiți-l cu inelul său",
"ui.placement_handles_tip": "Un marcaj pentru a trage operația pe model și, acolo unde operația se poate roti, un inel pentru a o roti.",
"ui.plane": "Plan",
"ui.plane_at_angle": "Plan în unghi",
"ui.plugins": "Pluginuri",
+1 -1
View File
@@ -4870,7 +4870,7 @@
"ui.pitch": "Шаг",
"ui.placement": "Размещение",
"ui.placement_handles": "Манипуляторы размещения",
"ui.placement_handles_tip": "Манипуляторы на предпросмотре «Опоры», «Бобышки» или «Защёлки»: перетащите маркер, чтобы переместить его по модели, поверните за кольцо",
"ui.placement_handles_tip": "Маркер для перетаскивания операции по модели и, где операцию можно поворачивать, кольцо для её поворота.",
"ui.plane": "Плоскость",
"ui.plane_at_angle": "Плоскость под углом",
"ui.plugins": "Плагины",
+1 -1
View File
@@ -4907,7 +4907,7 @@
"ui.pitch": "Корак",
"ui.placement": "Постављање",
"ui.placement_handles": "Ручице за постављање",
"ui.placement_handles_tip": "Ручице на прегледу ослонца, испуста или шкљоцајућег споја: превуците ознаку да је померите по моделу, окрените је њеним прстеном",
"ui.placement_handles_tip": "Ознака за превлачење операције по моделу и, где се операција може окретати, прстен за њено окретање.",
"ui.plane": "Раван",
"ui.plane_at_angle": "Раван под углом",
"ui.plugins": "Проширења",
+1 -1
View File
@@ -4916,7 +4916,7 @@
"ui.pitch": "Stigning",
"ui.placement": "Placering",
"ui.placement_handles": "Placeringshandtag",
"ui.placement_handles_tip": "Handtag på förhandsvisningen av ett stöd, nav eller snäppfäste: dra markören för att flytta det över modellen, vrid det med dess ring",
"ui.placement_handles_tip": "En markör för att dra funktionen över modellen och, där funktionen kan vridas, en ring för att vrida den.",
"ui.plane": "Plan",
"ui.plane_at_angle": "Plan i vinkel",
"ui.plugins": "Insticksmoduler",
+1 -1
View File
@@ -4916,7 +4916,7 @@
"ui.pitch": "Adım",
"ui.placement": "Yerleşim",
"ui.placement_handles": "Yerleşim tutamaçları",
"ui.placement_handles_tip": "Bir Dayama, Kabarıklık veya Kilit Geçme önizlemesindeki tutamaçlar: işaretçiyi model üzerinde hareket ettirmek için sürükleyin, halkasıyla döndürün",
"ui.placement_handles_tip": "Özelliği model üzerinde sürüklemek için bir işaretçi ve, özellik döndürülebiliyorsa, döndürmek için bir halka.",
"ui.plane": "Düzlem",
"ui.plane_at_angle": "Açılı düzlem",
"ui.plugins": "Eklentiler",
+2 -2
View File
@@ -2623,7 +2623,7 @@
"prefs.design_tools.imported_files": "Імпортовані файли",
"prefs.design_tools.note": "Примітка",
"prefs.design_tools.off": "Вимк.",
"prefs.design_tools.placement_group": "Упор, Бобишка, Защіпкове з'єднання",
"prefs.design_tools.placement_group": "Розміщувані операції",
"prefs.design_tools.rows": "Рядки",
"prefs.devices": "3D-миша",
"prefs.dialog_buttons": "Кнопки діалогу",
@@ -5989,7 +5989,7 @@
"ui.pitch": "Крок",
"ui.placement": "Розміщення",
"ui.placement_handles": "Ручки розміщення",
"ui.placement_handles_tip": "Маркер, щоб перетягнути елемент по моделі, і кільце, щоб його повернути.",
"ui.placement_handles_tip": "Маркер, щоб перетягнути елемент по моделі, і, де елемент можна повернути, кільце, щоб його повернути.",
"ui.plane": "Площина",
"ui.plane_at_angle": "Площина під кутом",
"ui.plugins": "Плагіни",
+2 -2
View File
@@ -2653,7 +2653,7 @@
"prefs.design_tools.imported_files": "Tệp đã nhập",
"prefs.design_tools.note": "Thông báo",
"prefs.design_tools.off": "Tắt",
"prefs.design_tools.placement_group": "Gối đỡ, Trụ, Khớp gài",
"prefs.design_tools.placement_group": "Các tính năng đặt vị trí",
"prefs.design_tools.rows": "Hàng",
"prefs.devices": "Chuột 3D",
"prefs.dialog_buttons": "Nút hộp thoại",
@@ -6070,7 +6070,7 @@
"ui.pitch": "Bước xoắn",
"ui.placement": "Vị trí đặt",
"ui.placement_handles": "Tay nắm định vị",
"ui.placement_handles_tip": "Dấu để kéo đặc trưng trên mô hình và vòng để xoay.",
"ui.placement_handles_tip": "Dấu để kéo đặc trưng trên mô hình và, ở nơi đặc trưng có thể xoay, vòng để xoay.",
"ui.plane": "Mặt phẳng",
"ui.plane_at_angle": "Mặt phẳng theo góc",
"ui.plugins": "Plugin",
+2 -2
View File
@@ -2588,7 +2588,7 @@
"prefs.design_tools.imported_files": "導入的檔案",
"prefs.design_tools.note": "提示",
"prefs.design_tools.off": "關閉",
"prefs.design_tools.placement_group": "支座、凸台、卡扣",
"prefs.design_tools.placement_group": "放置的特徵",
"prefs.design_tools.rows": "行數",
"prefs.devices": "3D 游標",
"prefs.dialog_buttons": "對話框按鈕",
@@ -5929,7 +5929,7 @@
"ui.pitch": "螺距",
"ui.placement": "放置",
"ui.placement_handles": "放置手柄",
"ui.placement_handles_tip": "用於在模型上拖動特徵的標記和用於旋轉的圓環。",
"ui.placement_handles_tip": "用於在模型上拖動特徵的標記,以及在特徵可旋轉時用於旋轉的圓環。",
"ui.plane": "平面",
"ui.plane_at_angle": "角度平面",
"ui.plugins": "插件",
+2 -2
View File
@@ -2653,7 +2653,7 @@
"prefs.design_tools.imported_files": "导入的文件",
"prefs.design_tools.note": "提示",
"prefs.design_tools.off": "关闭",
"prefs.design_tools.placement_group": "支座、凸台、卡扣",
"prefs.design_tools.placement_group": "放置的特征",
"prefs.design_tools.rows": "行数",
"prefs.devices": "3D 鼠标",
"prefs.dialog_buttons": "对话框按钮",
@@ -6070,7 +6070,7 @@
"ui.pitch": "螺距",
"ui.placement": "放置",
"ui.placement_handles": "放置手柄",
"ui.placement_handles_tip": "用于在模型上拖动特征的标记和用于旋转的圆环。",
"ui.placement_handles_tip": "用于在模型上拖动特征的标记,以及在特征可旋转时用于旋转的圆环。",
"ui.plane": "平面",
"ui.plane_at_angle": "角度平面",
"ui.plugins": "插件",
+4
View File
@@ -0,0 +1,4 @@
| File | Author | Source | Licence |
|---|---|---|---|
| assets/fonts/craft-ui-kr/CraftUI-KR.otf | Adobe / Google Inc. (Noto Sans KR); SolidCraft subset | https://github.com/notofonts/noto-cjk Sans/SubsetOTF/KR Regular, glyph-subset for the Hangul syllable block and renamed Craft UI KR (OFL reserved-name); licence text in assets/fonts/craft-ui-kr/OFL.txt; web UI Korean | SIL OFL 1.1 |
| assets/fonts/craft-ui-kr/OFL.txt | Adobe / Google Inc. (Noto CJK); SolidCraft header | Accompanying OFL for CraftUI-KR.otf | SIL OFL 1.1 |
+1
View File
@@ -0,0 +1 @@
| `assets/locales/pending/names-from-file.json` | SolidCraft contributors | Original label for the names-from-file preference | MIT OR Apache-2.0 |
+1
View File
@@ -0,0 +1 @@
| `assets/locales/pending/snap-points.json` | SolidCraft contributors | Original snap point labels and messages | MIT OR Apache-2.0 |
+1
View File
@@ -20,4 +20,5 @@ from an `App` value the app defines. No app types and no engine.
| `auth::tokens_match`, `auth::remove_token_file`, `auth::TokenFile`, `auth::pid_path`, `auth::MIN_TOKEN_LEN` | Constant-time compare and token file housekeeping |
| `Effect` | What a request still running does when it finishes: reads only, writes a file, or changes; `when_done(document)` says it |
| `server::Hub`, `server::Protocol`, `server::Summary`, `server::Waits`, `server::new_token` | The loopback JSON-lines server behind the token (desktop only); the app says what its requests mean |
| `client::Connection`, `client::Request`, `client::CallError`, `client::Waits` | The client (desktop only): the app's token on every request, one bounded round trip, a fresh connection after a lost one, the wait for a request still running (`control.pending`); "not running" told from "lost during the call" |
| `ControlRequest`, `ControlResponse`, `DocumentWatch`, `DOCUMENT_WATCH` | A request as the window gets it; the document id and revision subscribed connections wait on |
+616
View File
@@ -0,0 +1,616 @@
//! The control channel's client (docs/control-protocol.md): a connection to an app's loopback
//! control port, with the app's token ([`App::client_token`]) on every request, one bounded
//! round trip per request, a fresh connection after one is lost, and the wait for a request the
//! app says is still running (#1618), through `control.pending`. Its errors tell an app that is
//! not running from one that went away during a call ([`CallError`]). What a request means, and
//! anything an app adds to its parameters, is the caller's.
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpStream, ToSocketAddrs};
use std::time::{Duration, Instant};
use serde_json::{Value, json};
use crate::Effect;
use crate::auth::App;
/// Longest reply line accepted from the app.
pub const MAX_REPLY: u64 = 64 << 20;
/// How long a reply may take: kernel operations (booleans, fillets) on big parts can take a while.
pub const READ_TIMEOUT: Duration = Duration::from_secs(300);
/// How long the app may take to answer a request sent [`Request::once`]: a little longer than its
/// control server waits for a long request (3600 s), so its "still running" reply arrives first.
pub const ONCE_TIMEOUT: Duration = Duration::from_secs(3720);
/// How long connecting to the app may take.
const CONNECT_TIMEOUT: Duration = Duration::from_millis(800);
/// How often a long wait is reported ([`Connection::with_progress`]).
const PROGRESS_EVERY: Duration = Duration::from_secs(30);
/// How an error starts when a request is still running in the app after the wait: the request
/// was not cancelled and its reply waits on the connection; [`Connection::pending`] gets it.
pub const STILL_RUNNING: &str = "still running:";
/// How long to wait for a request the app says is still running.
#[derive(Clone, Copy, Debug)]
pub struct Waits {
/// How long a call goes on waiting, asking `control.pending`, after the app said its request
/// is still running (the app says so after 60 s), before it says so itself.
pub pending: Duration,
/// How long a later call, or [`Connection::pending`], waits for a request still running from
/// an earlier call.
pub settle: Duration,
/// How often `control.pending` is asked while waiting.
pub poll: Duration,
}
impl Default for Waits {
fn default() -> Self {
Waits { pending: Duration::from_secs(240), settle: Duration::from_secs(60), poll: Duration::from_millis(500) }
}
}
/// Why a call has no result. Each holds the message for the caller (an agent reads it).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CallError {
/// Nothing listens at the address: the app is not running, or it quit before the call was
/// sent. The message says how to start it.
NotRunning(String),
/// The app went away or did not answer during the call, after running it or not.
Lost(String),
/// The request is still running in the app after the wait (it starts with [`STILL_RUNNING`]),
/// or an earlier one is and this one was not sent.
StillRunning(String),
/// The app answered with an error (a refused token says which token file it came from).
Refused(String),
/// The reply was not a control reply.
BadReply(String),
}
impl CallError {
/// The message.
pub fn message(&self) -> &str {
match self {
CallError::NotRunning(m) | CallError::Lost(m) | CallError::StillRunning(m) | CallError::Refused(m) | CallError::BadReply(m) => m,
}
}
}
impl std::fmt::Display for CallError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.message())
}
}
impl std::error::Error for CallError {}
impl From<CallError> for String {
fn from(e: CallError) -> String {
e.message().to_string()
}
}
/// A request: its method and parameters, how messages name it and what it does if it is still
/// running when its call returns.
#[derive(Clone, Debug)]
pub struct Request {
pub method: String,
pub params: Value,
/// The request in messages ("`engine.execute` (solid.fillet)").
pub what: String,
/// What it still does when it finishes after its wait.
pub effect: Effect,
/// Never sent twice: on a fresh connection, waiting up to [`ONCE_TIMEOUT`], and once it is
/// being written any error is final, as the app may have run it. Otherwise a request that
/// finds its connection lost is sent once more on a fresh one (the app may have restarted).
pub once: bool,
}
impl Request {
/// `method` with `params`, named "`method`" in messages, taken to change the document, sent
/// again once on a fresh connection when its connection was lost.
pub fn new(method: &str, params: Value) -> Self {
Request { method: method.to_string(), params, what: format!("`{method}`"), effect: Effect::Changes, once: false }
}
}
/// A control reply as a call's result: its `result`, or its `error`.
pub fn outcome(v: &Value) -> Result<Value, String> {
if v.get("ok").and_then(Value::as_bool) == Some(true) {
Ok(v.get("result").cloned().unwrap_or(Value::Null))
} else {
Err(v.get("error").and_then(Value::as_str).unwrap_or("error").to_string())
}
}
/// A request the app said is still running. Its reply will come on this connection, through
/// `control.pending`, so the connection is kept for it.
struct Outstanding {
what: String,
/// What it does when it finishes; `None`: not known (a request this client did not send).
effect: Option<Effect>,
since: Instant,
}
impl Outstanding {
fn secs(&self) -> u64 {
self.since.elapsed().as_secs()
}
}
/// What became of a request still running when its call returned, once a later call waited it
/// out: kept for [`Connection::pending`].
enum Settled {
Finished { what: String, reply: Value },
Lost { what: String },
}
/// The end of a wait for an outstanding request.
enum Waited {
/// It finished: its own reply.
Done(Value),
/// The app holds nothing (any more) for this connection.
Nothing,
/// Still running when the wait ran out.
Running,
}
/// A connection to the app listening at an address. It connects when a call needs it, so a
/// client started before the app reaches it once it runs.
pub struct Connection {
app: App,
/// What the app calls its document in messages ("design").
document: &'static str,
addr: String,
port: u16,
conn: Option<(BufReader<TcpStream>, TcpStream)>,
/// Counts the connections made, so a caller can tell a fresh one from the one it asked.
serial: u64,
next_id: u64,
/// The token given, when one was. Otherwise each request looks the token up again, since
/// every launch of the app makes a new one.
explicit_token: Option<String>,
/// A request still running in the app; its reply waits on `conn`.
outstanding: Option<Outstanding>,
/// An outstanding request's end, once a later call waited for it (for `pending`).
settled: Option<Settled>,
waits: Waits,
/// Where a long wait is reported.
progress: fn(&str),
}
impl Connection {
/// A connection to `app` at `addr` (`host:port`), with `token` when given, else the app's
/// token found the usual way ([`App::client_token`]); not connected yet.
pub fn new(app: App, addr: &str, token: Option<&str>) -> Self {
let port = addr.rsplit(':').next().and_then(|p| p.parse::<u16>().ok()).unwrap_or(0);
Self {
app,
document: "design",
addr: addr.to_string(),
port,
conn: None,
serial: 0,
next_id: 1,
explicit_token: token.map(str::to_string),
outstanding: None,
settled: None,
waits: Waits::default(),
progress: |_| {},
}
}
/// What the app calls its document in messages ("design", the default).
#[must_use]
pub fn with_document(mut self, document: &'static str) -> Self {
self.document = document;
self
}
/// Report a long wait for a request still running to `progress`, every 30 s.
#[must_use]
pub fn with_progress(mut self, progress: fn(&str)) -> Self {
self.progress = progress;
self
}
/// How long to wait for a request still running.
pub fn set_waits(&mut self, waits: Waits) {
self.waits = waits;
}
/// The address it connects to.
pub fn addr(&self) -> &str {
&self.addr
}
/// The port of the address (0 when it names none).
pub fn port(&self) -> u16 {
self.port
}
/// The token given, if one was.
pub fn explicit_token(&self) -> Option<&str> {
self.explicit_token.as_deref()
}
/// Whether a connection is open now.
pub fn is_connected(&self) -> bool {
self.conn.is_some()
}
/// The number of connections made so far: the same number while the same connection stays
/// open.
pub fn serial(&self) -> u64 {
self.serial
}
/// Whether a request of this connection is still running in the app.
pub fn has_outstanding(&self) -> bool {
self.outstanding.is_some()
}
/// Close the connection. A request still running loses its reply with it.
pub fn disconnect(&mut self) {
self.conn = None;
if let Some(o) = self.outstanding.take() {
self.settled = Some(Settled::Lost { what: o.what });
}
}
/// Connect afresh. Failing to reach the app at all is `NotConnected`, so [`Connection::explain`]
/// can tell it from a connection lost during a call.
///
/// # Errors
/// `NotConnected` when nothing listens at the address (or it does not resolve).
pub fn connect(&mut self) -> std::io::Result<()> {
self.disconnect();
let not_connected = |e: std::io::Error| std::io::Error::new(std::io::ErrorKind::NotConnected, e);
let mut last = std::io::Error::new(std::io::ErrorKind::NotFound, format!("cannot resolve {}", self.addr));
for sa in self.addr.to_socket_addrs().map_err(not_connected)? {
match TcpStream::connect_timeout(&sa, CONNECT_TIMEOUT) {
Ok(s) => {
s.set_nodelay(true).ok();
s.set_read_timeout(Some(READ_TIMEOUT)).ok();
let read = s.try_clone()?;
self.conn = Some((BufReader::new(read), s));
self.serial += 1;
return Ok(());
}
Err(e) => last = e,
}
}
Err(not_connected(last))
}
/// See whether the app is there now; the error says why not. The connection doesn't stay
/// open: the app closes one that shows no token within a few seconds, and the first call may
/// be long away. That call connects again.
///
/// # Errors
/// As [`Connection::connect`].
pub fn try_connect(&mut self) -> std::io::Result<()> {
self.connect()?;
self.disconnect();
Ok(())
}
/// The error for a failed exchange: whether the app is not running (and how to start it) or
/// went away during the call.
pub fn explain(&self, e: &std::io::Error) -> CallError {
let (addr, port, name, document) = (&self.addr, self.port, self.app.name, self.document);
let start = format!("{} --control {port}", name.to_lowercase());
if e.kind() == std::io::ErrorKind::NotConnected {
CallError::NotRunning(format!(
"no {name} app is listening at {addr} ({e}): it is not running, or it quit. Start it with `{start}` (or ask the user to); the \
next call connects to it"
))
} else {
CallError::Lost(format!(
"the {name} app at {addr} did not answer ({e}): it may have quit or crashed during this call, after running it or not. Inspect \
the {document} before sending a change again; if the app quit, start it with `{start}`"
))
}
}
/// The request line for `method`: a fresh id and the app's token (looked up again for every
/// request: an app restarted mid-session has a new one).
pub fn request_line(&mut self, method: &str, params: Value) -> String {
let id = self.next_id;
self.next_id += 1;
let mut msg = json!({"id": id, "method": method, "params": params});
if let Some(t) = self.app.client_token(self.port, self.explicit_token.as_deref()) {
msg["token"] = json!(t.token);
}
msg.to_string()
}
/// One request line out and one reply line back, at most [`MAX_REPLY`] long, connecting
/// first when not connected. Any failure closes the connection (a timed-out read leaves a
/// late reply on the stream).
fn roundtrip(&mut self, line: &str) -> std::io::Result<String> {
if self.conn.is_none() {
self.connect()?;
}
let Some((reader, writer)) = self.conn.as_mut() else { return Err(std::io::Error::other("not connected")) };
// One write: a request that loses its newline still reaches the app as a request.
let sent = writer.write_all(format!("{line}\n").as_bytes()).and_then(|()| writer.flush());
if let Err(e) = sent {
self.disconnect();
return Err(e);
}
let mut reply = String::new();
let n = match reader.by_ref().take(MAX_REPLY).read_line(&mut reply) {
Ok(n) => n,
Err(e) => {
self.disconnect();
return Err(e);
}
};
if n == 0 || !reply.ends_with('\n') {
self.disconnect();
return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "connection closed"));
}
Ok(reply)
}
fn set_read_timeout(&self, t: Duration) {
if let Some((_, writer)) = &self.conn {
writer.set_read_timeout(Some(t)).ok();
}
}
/// A reply line as JSON.
fn parse(reply: &str) -> Result<Value, CallError> {
serde_json::from_str(reply).map_err(|e| CallError::BadReply(format!("bad reply from the app: {e}")))
}
/// A control reply as a call's result; a refused token says which token file it came from
/// (and whether it is stale).
///
/// # Errors
/// [`CallError::Refused`] with the app's error.
pub fn outcome(&self, v: &Value) -> Result<Value, CallError> {
outcome(v).map_err(|error| {
CallError::Refused(
self.app.client_token(self.port, self.explicit_token.as_deref()).map_or_else(|| error.clone(), |t| t.explain_error(&error)),
)
})
}
/// `method` with `params`, at once and once: not behind a request still running on this
/// connection, no second try. The app's whole reply (`ok`, `result` or `error`, and anything
/// it adds), for a request the app answers at once on any connection
/// (`control.capabilities`, `control.running`).
///
/// # Errors
/// As [`Connection::explain`]; [`CallError::BadReply`].
pub fn ask(&mut self, method: &str, params: Value) -> Result<Value, CallError> {
let line = self.request_line(method, params);
let reply = self.roundtrip(&line).map_err(|e| self.explain(&e))?;
Self::parse(&reply)
}
/// `method` with `params`, waiting at most `timeout` for the reply and never behind a request
/// still running: for a request the app answers within a frame, asked again later when the
/// app is busy. One try.
///
/// # Errors
/// [`CallError::StillRunning`] when a request of this connection is still running; as
/// [`Connection::ask`] and [`Connection::outcome`].
pub fn quick(&mut self, method: &str, params: Value, timeout: Duration) -> Result<Value, CallError> {
if self.outstanding.is_some() {
return Err(CallError::StillRunning("a request is still running in the app".into()));
}
if self.conn.is_none() {
self.connect().map_err(|e| self.explain(&e))?;
}
let line = self.request_line(method, params);
self.set_read_timeout(timeout);
let reply = self.roundtrip(&line);
self.set_read_timeout(READ_TIMEOUT);
let reply = reply.map_err(|e| self.explain(&e))?;
let v = Self::parse(&reply)?;
self.outcome(&v)
}
/// Ask the app, on this connection only, about the request it still runs for it.
fn ask_pending(&mut self) -> Result<Value, CallError> {
if self.conn.is_none() {
self.disconnect();
return Err(CallError::Lost("the connection to the app was lost".into()));
}
let v = self.ask("control.pending", json!({}))?;
outcome(&v).map_err(CallError::Refused).inspect_err(|_| self.disconnect())
}
/// `control.cancel` for the request still running on this connection. Sent at once, without
/// waiting for that request first, and never on a fresh connection, which has nothing to
/// cancel. Once the app no longer holds it (removed, stopped or finished), later calls don't
/// wait for it.
///
/// # Errors
/// As [`Connection::ask`]; [`CallError::Refused`].
pub fn cancel(&mut self, params: Value) -> Result<Value, CallError> {
if self.conn.is_none() {
return Ok(json!({"cancelled": null, "note": "nothing to cancel: no request of this client is running in the app"}));
}
let v = self.ask("control.cancel", params)?;
let r = outcome(&v).map_err(CallError::Refused);
if r.is_ok() {
self.outstanding = None;
}
r
}
/// Wait up to `limit` for the outstanding request, asking `control.pending` now and then and
/// reporting how long it takes. An error: the connection, and so the reply, was lost.
fn wait_outstanding(&mut self, limit: Duration) -> Result<Waited, CallError> {
let start = Instant::now();
let mut told = start;
loop {
let Some(o) = &self.outstanding else { return Ok(Waited::Nothing) };
let what = o.what.clone();
let r = self.ask_pending()?;
if r.get("pending").is_none_or(Value::is_null) {
self.outstanding = None;
return Ok(Waited::Nothing);
}
if r.get("done").and_then(Value::as_bool) == Some(true) {
self.outstanding = None;
return Ok(Waited::Done(r.get("reply").cloned().unwrap_or(Value::Null)));
}
let waited = start.elapsed();
if waited >= limit {
return Ok(Waited::Running);
}
if told.elapsed() >= PROGRESS_EVERY {
let secs = self.outstanding.as_ref().map_or(0, Outstanding::secs);
(self.progress)(&format!("{what} is still running in the app ({secs} s); waiting for it"));
told = Instant::now();
}
std::thread::sleep(self.waits.poll.min(limit.saturating_sub(waited)));
}
}
/// The error for the outstanding request still running after its wait.
fn still_running(&self, earlier: bool) -> CallError {
let Some(o) = &self.outstanding else { return CallError::StillRunning(format!("{STILL_RUNNING} a request is still running in the app")) };
let document = self.document;
let effect =
o.effect.map_or_else(|| format!("it was not cancelled and may still change the {document} when it finishes"), |e| e.when_done(document));
let settle = self.waits.settle.as_secs();
CallError::StillRunning(if earlier {
format!(
"{STILL_RUNNING} an earlier request, {}, is still running in the app ({} s; {effect}), so this call was not sent. Call the \
`pending` tool to wait for its result, then send this call again",
o.what,
o.secs()
)
} else {
format!(
"{STILL_RUNNING} {} is still running in the app after {} s ({effect}); don't send it again. Call the `pending` tool to \
wait for its result (up to {settle} s per call); other calls wait for it first",
o.what,
o.secs()
)
})
}
/// Before a new request: wait for one still running from an earlier call, keeping its end for
/// `pending`. Still running after the wait: the new request is not sent.
fn settle_earlier(&mut self) -> Result<(), CallError> {
let Some(what) = self.outstanding.as_ref().map(|o| o.what.clone()) else { return Ok(()) };
match self.wait_outstanding(self.waits.settle) {
Ok(Waited::Done(reply)) => self.settled = Some(Settled::Finished { what, reply }),
Ok(Waited::Nothing) => {}
Ok(Waited::Running) => return Err(self.still_running(true)),
// The connection was lost, and the earlier reply with it (`pending` says so): the new
// request goes on a fresh connection.
Err(_) => {}
}
Ok(())
}
/// `control.pending` for the client: the end of the request still running from an earlier
/// call, waiting for it up to the settle wait: `{pending: what | null, done?, reply?, lost?,
/// running_s?}`. Never sent on a fresh connection, which has nothing pending.
pub fn pending(&mut self) -> Value {
if let Some(s) = self.settled.take() {
return match s {
Settled::Finished { what, reply } => json!({"pending": what, "done": true, "reply": reply}),
Settled::Lost { what } => json!({"pending": what, "lost": true}),
};
}
let Some(what) = self.outstanding.as_ref().map(|o| o.what.clone()) else { return json!({"pending": null}) };
match self.wait_outstanding(self.waits.settle) {
Ok(Waited::Done(reply)) => json!({"pending": what, "done": true, "reply": reply}),
Ok(Waited::Nothing) => json!({"pending": null}),
Ok(Waited::Running) => json!({"pending": what, "done": false, "running_s": self.outstanding.as_ref().map_or(0, Outstanding::secs)}),
Err(_) => {
self.settled = None;
json!({"pending": what, "lost": true})
}
}
}
/// Send `request` and wait for its reply: the app's whole reply (`ok`, `result` or `error`,
/// and anything it adds). First a request still running from an earlier call is waited for
/// (except for `control.running`, which the app answers at once); a request the app says is
/// still running is waited for through `control.pending`; a request refused unrun as busy
/// behind one this client did not know of is sent once more after that one, when
/// `retry_busy`.
///
/// # Errors
/// As [`Connection::explain`]: a request whose connection is lost is sent once more on a fresh
/// one (unless [`Request::once`]), and when nothing listens any more the app went away, maybe
/// while running it ([`CallError::Lost`]); [`CallError::StillRunning`]; [`CallError::BadReply`].
pub fn exchange(&mut self, request: Request, retry_busy: bool) -> Result<Value, CallError> {
let Request { method, params, what, effect, once } = request;
if method != "control.running" {
self.settle_earlier()?;
}
let again = retry_busy.then(|| params.clone());
let sent = Instant::now();
let line = self.request_line(&method, params);
let reply = if once {
// A fresh connection (an idle one may have been closed by an app restart; connecting
// again sends nothing).
self.connect().or_else(|_| self.connect()).and_then(|()| {
self.set_read_timeout(ONCE_TIMEOUT);
let r = self.roundtrip(&line);
self.set_read_timeout(READ_TIMEOUT);
r
})
} else {
self.roundtrip(&line).or_else(|lost| {
self.roundtrip(&line).map_err(|e| if e.kind() == std::io::ErrorKind::NotConnected && lost.kind() != e.kind() { lost } else { e })
})
}
.map_err(|e| self.explain(&e))?;
let v = Self::parse(&reply)?;
// Timed out in the app, still running: wait for its result on this connection.
if v.get("running").and_then(Value::as_bool) == Some(true) {
self.outstanding = Some(Outstanding { what: what.clone(), effect: Some(effect), since: sent });
(self.progress)(&format!(
"{what} is still running in the app after {} s; waiting up to {} s more for it",
sent.elapsed().as_secs(),
self.waits.pending.as_secs()
));
let document = self.document;
return match self.wait_outstanding(self.waits.pending)? {
Waited::Done(reply) => Ok(reply),
Waited::Nothing => Err(CallError::Lost(format!(
"{what} timed out in the app, which kept no result for it: inspect the {document} before sending it again"
))),
Waited::Running => Err(self.still_running(false)),
};
}
// Refused unrun behind a request this client did not know of: wait for that one, then
// send this one once more.
if v.get("busy").and_then(Value::as_bool) == Some(true)
&& let Some(params) = again
{
self.outstanding = Some(Outstanding { what: "an earlier request".into(), effect: None, since: Instant::now() });
self.settle_earlier()?;
return self.exchange(Request { method, params, what, effect, once }, false);
}
Ok(v)
}
/// [`Connection::exchange`] (retrying a busy refusal once) and the reply's result.
///
/// # Errors
/// As [`Connection::exchange`] and [`Connection::outcome`].
pub fn call(&mut self, request: Request) -> Result<Value, CallError> {
let v = self.exchange(request, true)?;
self.outcome(&v)
}
}
#[cfg(test)]
#[path = "client_tests.rs"]
mod tests;
+190
View File
@@ -0,0 +1,190 @@
//! The client against the shared server ([`crate::server::Hub`]) on an ephemeral loopback port.
use super::*;
use crate::ControlRequest;
use crate::server::{Hub, Protocol, Stop, Summary, Waits as ServerWaits};
use std::ffi::OsString;
use std::net::TcpListener;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::mpsc::channel;
const TOKEN: &str = "0123456789abcdef0123456789abcdef";
fn no_dir() -> Option<PathBuf> {
None
}
fn no_env(_: &str) -> Option<OsString> {
None
}
fn utc(secs: u64) -> String {
secs.to_string()
}
/// An app with no token files and no environment: a client sends only the token it is given.
const APP: App = App { name: "Probe", data_dir: no_dir, env: no_env, utc, default_port: 0 };
/// An app whose requests all may change its document.
struct Probe;
impl Protocol for Probe {
fn summary(&self, method: &str, _: &Value) -> Summary {
Summary { what: format!("`{method}`"), effect: Effect::Changes, command: None, steps: None, long: false }
}
fn document(&self) -> &'static str {
"design"
}
fn change_event(&self) -> &'static str {
"design.changed"
}
fn revision_method(&self) -> &'static str {
"document.revision"
}
fn docs(&self) -> &'static str {
"docs/control-protocol.md"
}
fn capabilities(&self) -> Value {
json!({})
}
}
/// The app on `port` (0: an ephemeral one) behind [`TOKEN`]: its window answers each request after
/// `delay` with the request's method and parameters, and the server says a request is still
/// running after `wait`.
fn serve(port: u16, delay: Duration, wait: Duration) -> Stop {
// A port just closed may take a moment to be free again on a loaded machine.
let started = Instant::now();
let mut bound = TcpListener::bind(("127.0.0.1", port));
while bound.is_err() && started.elapsed() < Duration::from_secs(2) {
std::thread::sleep(Duration::from_millis(10));
bound = TcpListener::bind(("127.0.0.1", port));
}
let listener = bound.unwrap();
let (tx, rx) = channel::<ControlRequest>();
std::thread::spawn(move || {
for req in rx {
std::thread::sleep(delay);
let _ = req.reply.send(json!({"ok": true, "result": {"method": req.method, "params": req.params}}));
}
});
let waits = ServerWaits { short: wait, long: wait, token: Duration::from_secs(5) };
let hub = Arc::new(Hub::new(Probe, || {}, 1 << 20).with_waits(waits));
hub.listen_stoppable(listener, TOKEN.into(), tx, false).unwrap()
}
fn addr(port: u16) -> String {
format!("127.0.0.1:{port}")
}
/// A port nothing listens on.
fn free_port() -> u16 {
TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port()
}
#[test]
fn a_call_is_one_round_trip_with_the_token() {
let app = serve(0, Duration::ZERO, Duration::from_secs(10));
let mut c = Connection::new(APP, &addr(app.port()), Some(TOKEN));
assert!(!c.is_connected());
let r = c.call(Request::new("document.inspect", json!({"a": 1}))).unwrap();
assert_eq!(r, json!({"method": "document.inspect", "params": {"a": 1}}));
assert!(c.is_connected());
let serial = c.serial();
// the next call goes on the same connection
c.call(Request::new("document.inspect", json!({}))).unwrap();
assert_eq!(c.serial(), serial);
// `try_connect` sees the app there and leaves no connection open
let mut fresh = Connection::new(APP, &addr(app.port()), Some(TOKEN));
fresh.try_connect().unwrap();
assert!(!fresh.is_connected());
}
#[test]
fn a_wrong_token_or_none_is_refused() {
let app = serve(0, Duration::ZERO, Duration::from_secs(10));
for token in [Some("fedcba9876543210fedcba9876543210"), None] {
let mut c = Connection::new(APP, &addr(app.port()), token);
match c.call(Request::new("document.inspect", json!({}))) {
Err(CallError::Refused(e)) => assert!(e.starts_with("unauthorized"), "{token:?}: {e}"),
other => panic!("{token:?}: {other:?}"),
}
}
}
#[test]
fn an_app_not_running_says_how_to_start_it() {
let port = free_port();
let mut c = Connection::new(APP, &addr(port), Some(TOKEN));
let e = c.call(Request::new("document.inspect", json!({}))).unwrap_err();
let CallError::NotRunning(m) = &e else { panic!("{e:?}") };
assert!(m.starts_with(&format!("no Probe app is listening at 127.0.0.1:{port}")), "{m}");
assert!(m.contains(&format!("`probe --control {port}`")), "{m}");
assert_eq!(c.try_connect().unwrap_err().kind(), std::io::ErrorKind::NotConnected);
}
/// An app that goes away during a call is lost, not "not running"; the next call says it is not
/// running, and once it runs again the next call reaches it.
#[test]
fn an_app_gone_during_a_call_is_lost_and_reached_again_once_back() {
let app = serve(0, Duration::ZERO, Duration::from_secs(10));
let port = app.port();
let mut c = Connection::new(APP, &addr(port), Some(TOKEN));
c.call(Request::new("document.inspect", json!({}))).unwrap();
app.stop();
let e = c.call(Request::new("document.inspect", json!({}))).unwrap_err();
let CallError::Lost(m) = &e else { panic!("{e:?}") };
assert!(m.contains("may have quit") && m.contains("Inspect the design") && m.contains(&format!("`probe --control {port}`")), "{m}");
assert!(matches!(c.call(Request::new("document.inspect", json!({}))), Err(CallError::NotRunning(_))));
let _back = serve(port, Duration::ZERO, Duration::from_secs(10));
assert_eq!(c.call(Request::new("ui.view", json!({}))).unwrap()["method"], json!("ui.view"));
}
/// A server that reads a request and closes the connection without a reply: the call is lost
/// (after one more try on a fresh connection), and the reply is never taken for "not running".
#[test]
fn a_connection_closed_without_a_reply_is_lost() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
let mut line = String::new();
let _ = BufReader::new(stream).read_line(&mut line);
}
});
let mut c = Connection::new(APP, &addr(port), Some(TOKEN));
let e = c.call(Request::new("document.inspect", json!({}))).unwrap_err();
assert!(matches!(e, CallError::Lost(_)), "{e:?}");
assert!(!c.is_connected());
}
/// A request the app says is still running is waited for through `control.pending`, on the same
/// connection, and its own reply is the call's result.
#[test]
fn a_request_still_running_is_waited_for_through_pending() {
let app = serve(0, Duration::from_millis(400), Duration::from_millis(50));
let mut c = Connection::new(APP, &addr(app.port()), Some(TOKEN));
c.set_waits(Waits { pending: Duration::from_secs(10), settle: Duration::from_secs(10), poll: Duration::from_millis(5) });
let r = c.call(Request::new("solid.slow", json!({"n": 2}))).unwrap();
assert_eq!(r, json!({"method": "solid.slow", "params": {"n": 2}}));
assert!(!c.has_outstanding());
assert_eq!(c.pending(), json!({"pending": null}));
}
/// Past the wait, the call says the request is still running, and `pending` later has its end.
#[test]
fn a_request_still_running_past_the_wait_is_reported_and_its_end_kept() {
let app = serve(0, Duration::from_millis(400), Duration::from_millis(20));
let mut c = Connection::new(APP, &addr(app.port()), Some(TOKEN));
c.set_waits(Waits { pending: Duration::from_millis(30), settle: Duration::from_secs(10), poll: Duration::from_millis(5) });
let mut slow = Request::new("solid.slow", json!({}));
slow.what = "`solid.slow` (now)".into();
let e = c.call(slow).unwrap_err();
let CallError::StillRunning(m) = &e else { panic!("{e:?}") };
assert!(m.starts_with(STILL_RUNNING) && m.contains("`solid.slow` (now)") && m.contains("will still apply"), "{m}");
assert!(c.has_outstanding());
let end = c.pending();
assert_eq!(end["done"], json!(true), "{end}");
assert_eq!(end["reply"]["result"]["method"], json!("solid.slow"), "{end}");
}
+6 -4
View File
@@ -1,14 +1,16 @@
//! The control channel any app may share (docs/control-protocol.md): the per-launch token and its
//! files ([`auth`]), the loopback server that admits a connection by the token and hands each
//! request to the window ([`server`]), the request the window answers ([`ControlRequest`]), the
//! document watch subscribed connections wait on ([`DocumentWatch`]), and what a request still running
//! does when it finishes ([`Effect`]). It needs no UI toolkit; the window's own requests over egui
//! (`ui.screenshot`, `ui.resize`) are `solidcraft-control-egui`.
//! request to the window ([`server`]), the client that talks to it ([`client`]), the request the
//! window answers ([`ControlRequest`]), the document watch subscribed connections wait on
//! ([`DocumentWatch`]), and what a request still running does when it finishes ([`Effect`]). It
//! needs no UI toolkit; the window's own requests over egui (`ui.screenshot`, `ui.resize`) are
//! `solidcraft-control-egui`.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
pub mod auth;
mod cancel;
pub mod client;
mod effect;
mod request;
#[cfg(not(target_arch = "wasm32"))]
+1
View File
@@ -123,6 +123,7 @@ fn remap_geo(
}
GeoRef::Plane { plane } => remap_plane(plane, ids, bodies, depth + 1, occurrences, doc)?,
GeoRef::Datum { datum } => remap_datum_path(datum, occurrences, doc)?,
GeoRef::Snap { of, .. } => remap_geo(of, ids, bodies, depth + 1, occurrences, doc)?,
_ => {}
}
Ok(())
+118 -2
View File
@@ -59,6 +59,27 @@ pub enum GeoRef {
SketchPoint { sketch: u64, point: String },
/// A sketch curve (by sketch feature id and curve id).
SketchCurve { sketch: u64, curve: String },
/// A snap point of an edge, face or sketch curve (#11228): its centre, its middle or a flat
/// face's centre, found again on `of` as it is now. Where `of` has more than one (a
/// cylinder's two end centres), `end` says which along the axis (0 the low end, 1 the
/// high one, set when the reference is made), else the one nearest `at`, where it was picked.
Snap {
of: Box<GeoRef>,
kind: SnapAt,
at: Vec3,
#[serde(default, skip_serializing_if = "Option::is_none")]
end: Option<usize>,
},
}
/// Which snap point of an edge, face or sketch curve a [`GeoRef::Snap`] is.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SnapAt {
/// The centre of a circle or arc, a sphere or torus, or one end of a cylinder or cone.
Centre,
/// The middle of an edge or sketch curve, along its length.
Middle,
}
/// How a construction axis is defined.
@@ -293,9 +314,27 @@ fn face_of<'a>(st: &'a ModelState, at: Vec3, name: &Option<String>) -> Result<(&
return Ok((b, fi, at));
}
}
// A mesh body has no faces to pick: say so, rather than let the pick read as a miss (#11469).
if let Some(m) = st.bodies.iter().find(|b| lies_on_mesh(b, at)) {
return Err(bad(format!("no face at {at:?}: `{}` is a mesh body, which has no faces; use Convert Mesh to make it a solid first", m.name)));
}
Err(bad(format!("no face at {at:?}")))
}
/// Does `at` lie on the surface of a mesh body, within the pick tolerance? Only mesh bodies:
/// their triangles are the body itself.
fn lies_on_mesh(b: &ModelBody, at: Vec3) -> bool {
if !b.body.is_mesh() {
return false;
}
let Some((positions, triangles)) = b.body.triangle_mesh() else { return false };
let tol = crate::appearance::pick_tolerance(b);
triangles.iter().any(|t| {
let [a, bb, c] = t.map(|i| positions[i as usize]);
crate::appearance::closest_on_triangle(at, a, bb, c).dist(at) <= tol
})
}
/// The edge picked, as a polyline.
fn edge_points(st: &ModelState, at: Vec3, name: &Option<String>) -> Result<Vec<Vec3>> {
edge_pick(st, at, name).map(|(_, _, pts)| pts)
@@ -384,7 +423,7 @@ fn named_vertex(st: &ModelState, at: Vec3, names: &[String]) -> Option<Vec3> {
if names.len() < 2 || names.len() > MAX_VERTEX_EDGES {
return None;
}
let ends: Vec<[Vec3; 2]> = names
let found: Vec<(*const ModelBody, usize, [Vec3; 2])> = names
.iter()
.map(|n| {
// The edge with that name itself, never the one nearest the old pick, and its ends
@@ -395,10 +434,16 @@ fn named_vertex(st: &ModelState, at: Vec3, names: &[String]) -> Option<Vec3> {
let hit = crate::name_lookup::locate_edge(b, n, at).ok()?;
let m = b.mesh();
let pts = m.edges.get(hit.edge)?;
Some([*pts.first()?, *pts.last()?])
Some((std::ptr::from_ref(b), hit.edge, [*pts.first()?, *pts.last()?]))
})
})
.collect::<Option<_>>()?;
// Two names of a vertex name two edges: a name the point decides (where the body grew so the
// vertex lies inside another edge) can find an edge another name found.
if found.iter().enumerate().any(|(i, (b, e, _))| found.iter().take(i).any(|(c, f, _)| std::ptr::eq(*b, *c) && e == f)) {
return None;
}
let ends: Vec<[Vec3; 2]> = found.into_iter().map(|(_, _, ends)| ends).collect();
let (first, rest) = ends.split_first()?;
let tol = |p: Vec3| 1e-6 * (1.0 + p.len());
first
@@ -487,6 +532,7 @@ pub(crate) fn point(ctx: &Ctx, g: &GeoRef) -> Result<Vec3> {
}
// A round edge or face: its centre.
GeoRef::Edge { .. } | GeoRef::Face { .. } | GeoRef::SketchCurve { .. } => centre(ctx, g),
GeoRef::Snap { of, kind, at, end } => snap_point(ctx.st, of, *kind, *at, *end).or_else(|_| snap_point_ctx(ctx, of, *kind, *at)),
GeoRef::Datum { datum } => match resolve_datum_in(ctx.doc, datum, ctx.component)? {
ConstructGeom::Point(p) => Ok(p),
_ => Err(bad("the datum is not a point (pick the O of a component)")),
@@ -495,6 +541,65 @@ pub(crate) fn point(ctx: &Ctx, g: &GeoRef) -> Result<Vec3> {
}
}
/// The centres of the circular edges that end a cylinder or cone face, from its B-rep (low, high
/// along its axis), if it is one.
fn face_axis_ends(st: &ModelState, of: &GeoRef) -> Option<Vec<Vec3>> {
let GeoRef::Face { at, name } = of else { return None };
let (b, fi, _) = face_of(st, *at, name).ok()?;
let (origin, axis) = match kernel::face_surface(&b.body, fi) {
Some(FaceSurface::Cylinder { origin, axis, .. }) => (origin, axis),
Some(FaceSurface::Cone { apex, axis, .. }) => (apex, axis),
_ => return None,
};
let tol = 1e-6 * (1.0 + b.body.size());
let mut ends: Vec<(f64, Vec3)> = kernel::face_edge_indices(&b.body, fi)
.into_iter()
.filter_map(|e| kernel::edge_circle(&b.body, e))
.filter(|c| (c.center - origin).cross(axis).len() <= tol)
.map(|c| ((c.center - origin).dot(axis), c.center))
.collect();
ends.sort_by(|p, q| p.0.total_cmp(&q.0));
ends.dedup_by(|p, q| (p.0 - q.0).abs() <= tol);
match ends.as_slice() {
[] => None,
[one] => Some(vec![one.1]),
[lo, .., hi] => Some(vec![lo.1, hi.1]),
}
}
/// The one of `found` nearest `at`.
fn nearest(found: Vec<Vec3>, at: Vec3) -> Option<(usize, Vec3)> {
found.into_iter().enumerate().filter(|(_, p)| p.is_finite()).min_by(|(_, p), (_, q)| p.dist(at).total_cmp(&q.dist(at)))
}
/// A snap point found from the model alone: a cylinder's or cone's end centre (the `end` one,
/// else the one nearest `at`).
fn snap_point(st: &ModelState, of: &GeoRef, kind: SnapAt, at: Vec3, end: Option<usize>) -> Result<Vec3> {
match (kind, of) {
(SnapAt::Centre, GeoRef::Face { .. }) => {
let ends = face_axis_ends(st, of).ok_or_else(|| bad("the face is not a cylinder or cone"))?;
match end {
Some(i) => ends.get(i).copied(),
None => nearest(ends, at).map(|(_, p)| p),
}
.ok_or_else(|| bad("the snap point is gone"))
}
_ => Err(bad("not a face's snap point")),
}
}
/// A snap point of an edge or sketch curve (its middle or centre), or a sphere's or torus's centre.
fn snap_point_ctx(ctx: &Ctx, of: &GeoRef, kind: SnapAt, at: Vec3) -> Result<Vec3> {
let found: Vec<Vec3> = match (kind, of) {
(SnapAt::Middle, GeoRef::Edge { .. } | GeoRef::SketchCurve { .. }) => {
vec![along(ctx, of, 0.5)?.0]
}
(SnapAt::Centre, GeoRef::Edge { .. } | GeoRef::SketchCurve { .. } | GeoRef::Face { .. }) => vec![centre(ctx, of)?],
_ => return Err(bad("a snap point is on an edge, a face or a sketch curve")),
};
nearest(found, at).map(|(_, p)| p).ok_or_else(|| bad("the snap point is gone"))
}
/// Resolve a planar point or a drawn 3D wire's `wire:index` node in the evaluated state's frame.
/// Wire coordinates have already been placed; applying the sketch plane again would move them twice.
pub fn sketch_point_position(st: &ModelState, sketch: u64, point: &str) -> Option<Vec3> {
@@ -833,6 +938,7 @@ pub(crate) fn source_bodies(ctx: &Ctx, refs: &[&GeoRef]) -> Vec<String> {
GeoRef::Vertex { .. } => {
point(ctx, g).ok().and_then(|p| vertex_body(ctx.st, p)).map(|b| vec![b.definition_name().to_string()]).unwrap_or_default()
}
GeoRef::Snap { of, .. } => source_bodies(ctx, &[of]),
GeoRef::Axis { name } | GeoRef::ConstructPoint { name } => {
ctx.st.construct.iter().find(|c| c.name.as_str() == AsRef::<str>::as_ref(name)).map(|c| c.bodies.clone()).unwrap_or_default()
}
@@ -1041,6 +1147,10 @@ impl GeoRef {
GeoRef::Point { p } => pt(p),
GeoRef::Vertex { at, .. } | GeoRef::Edge { at, .. } | GeoRef::Face { at, .. } => pt(at),
GeoRef::Plane { plane: p } => plane(p),
GeoRef::Snap { of, at, .. } => {
pt(at);
of.map_points(pt, plane);
}
_ => {}
}
}
@@ -1080,6 +1190,12 @@ impl GeoRef {
}
}
GeoRef::Plane { plane } => plane.name_picks(st),
GeoRef::Snap { of, kind: SnapAt::Centre, at, end: end @ None } => {
// Which end of a cylinder or cone: it stays that end when the face grows.
*end = face_axis_ends(st, of).and_then(|ends| nearest(ends, *at)).map(|(i, _)| i);
of.name_picks(st);
}
GeoRef::Snap { of, .. } => of.name_picks(st),
_ => {}
}
}
+5 -1
View File
@@ -1197,6 +1197,10 @@ pub enum FeatureKind {
tool: Option<FaceAt>,
#[serde(default, skip_serializing_if = "Option::is_none")]
sketch: Option<SketchId>,
/// With `sketch`: its curves joined end to end first, so a closed loop cuts along itself
/// only. Off (each curve on its own, an open one extended past its ends) in older designs.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
loops: bool,
},
/// Scale bodies about a point (uniform, or per axis).
Scale {
@@ -1436,7 +1440,7 @@ pub enum FeatureKind {
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
flip: bool,
},
/// Sheet metal contour flange: a sheet bent along an open chain of sketch lines.
/// Sheet metal contour flange: a sheet bent along an open chain of sketch lines and tangent arcs.
SheetContour {
sketch: SketchId,
curves: Vec<String>,
+13 -7
View File
@@ -9715,7 +9715,7 @@ fn eval_feature(
}
apply_traced(doc, st, f, made, *operation, &[])
}
FeatureKind::SplitFace { faces, body, plane, tool, sketch } => {
FeatureKind::SplitFace { faces, body, plane, tool, sketch, loops } => {
let i = body_at(st, body, &crate::plain(faces))?;
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
let mut picked: Vec<usize> = Vec::new();
@@ -9735,12 +9735,18 @@ fn eval_feature(
}
(None, Some(sid)) => {
let ss = st.sketch(sid.0).ok_or_else(|| DocError::Unknown(format!("sketch {sid}")))?;
// Each curve's exact segments (lines and arcs split by their own plane and cylinder).
let curves: Vec<Vec<solidcore_record::Seg2>> = (0..ss.sketch.curves.len())
.filter(|ci| ss.sketch.curves.get(*ci).is_some_and(|c| !c.construction))
.map(|ci| ss.sketch.segs(ci))
.filter(|segs| !segs.is_empty())
.collect();
// Each curve's exact segments (lines and arcs split by their own plane and cylinder),
// or with `loops` the curves joined end to end: a closed chain is not extended.
let curves: Vec<Vec<solidcore_record::Seg2>> = if *loops {
let ids: Vec<String> = ss.sketch.curves.iter().filter(|c| !c.construction).map(|c| c.id.clone()).collect();
surface::curve_chains(ss, &ids)?
} else {
(0..ss.sketch.curves.len())
.filter(|ci| ss.sketch.curves.get(*ci).is_some_and(|c| !c.construction))
.map(|ci| ss.sketch.segs(ci))
.filter(|segs| !segs.is_empty())
.collect()
};
if curves.is_empty() {
return Err(DocError::Invalid(format!("sketch `{}` has no curves to split with", ss.name)));
}
+41 -9
View File
@@ -295,6 +295,33 @@ fn chain_points(ss: &SolvedSketch, ids: &[String]) -> Result<Vec<Vec2>> {
Ok(pts)
}
/// The curves of a chain as connected segments in sketch coordinates, in order, with lines
/// and arcs kept exact (see [`chain_points`] for the polyline form).
fn chain_segs(ss: &SolvedSketch, ids: &[String]) -> Result<Vec<Seg2>> {
let mut segs: Vec<Seg2> = Vec::new();
for id in ids {
let ci = ss.sketch.curve_index(id).ok_or_else(|| DocError::Unknown(format!("curve `{id}`")))?;
segs.extend(ss.sketch.segs(ci));
}
let Some(first) = segs.first().copied() else { return Err(DocError::Invalid("no curves".into())) };
// Orient the first segment toward the second, then chain.
let mut chain = vec![first];
if let Some(n) = segs.get(1)
&& (first.start().dist(n.start()) < 1e-6 || first.start().dist(n.end()) < 1e-6)
{
chain[0] = first.reversed();
}
for s in segs.iter().skip(1) {
let prev = chain.last().map(Seg2::end).unwrap_or_default();
let s = if s.end().dist(prev) < s.start().dist(prev) { s.reversed() } else { *s };
if s.start().dist(prev) > 1e-5 {
return Err(DocError::Invalid("the contour curves are not connected end to end".into()));
}
chain.push(s);
}
Ok(chain)
}
/// A rib wall: the band swept from the curves across the plane by `depth` (or to the body),
/// `t` thick about the sketch plane.
fn rib_tool(st: &ModelState, ss: &SolvedSketch, ids: &[String], t: f64, depth: Option<f64>, flip: bool) -> Result<Body> {
@@ -587,13 +614,7 @@ pub(super) fn sheet_eval(
}
FeatureKind::SheetContour { sketch, curves, distance, rule, flip, reverse } => {
let ss = st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?.clone();
for id in curves {
let ci = ss.sketch.curve_index(id).ok_or_else(|| DocError::Unknown(format!("curve `{id}`")))?;
if !ss.sketch.segs(ci).iter().all(|s| matches!(s, Seg2::Line { .. })) {
return Err(DocError::Invalid("not supported yet: arcs in a contour flange (use lines; the bends are made for you)".into()));
}
}
let pts = chain_points(&ss, curves)?;
let segs = chain_segs(&ss, curves)?;
let w = val(vals, distance, Kind::Length)?;
if !(w > 0.0) {
return Err(DocError::Invalid("the contour flange distance must be positive".into()));
@@ -601,10 +622,21 @@ pub(super) fn sheet_eval(
let r = doc.sheet_rule(rule.as_deref());
let rv = doc.rule_values(vals, &r)?;
let plane = if *reverse { Plane { origin: ss.plane.origin, x: ss.plane.x, y: ss.plane.y * -1.0 } } else { ss.plane };
let pts: Vec<Vec2> = if *reverse { pts.iter().map(|p| Vec2::new(p.x, -p.y)).collect() } else { pts };
let segs: Vec<Seg2> = if *reverse {
segs.iter()
.map(|s| match *s {
Seg2::Arc { center, radius, start, sweep } => {
Seg2::Arc { center: Vec2::new(center.x, -center.y), radius, start: -start, sweep: -sweep }
}
other => other.map_controls(|p| Vec2::new(p.x, -p.y)),
})
.collect()
} else {
segs
};
// Mirroring the sketch swaps the material side too.
let name = new_name(st, f, 0);
let sh = sheet::contour_flange(&name, &r.name, &rv, &plane, &pts, w, *flip != *reverse)?;
let sh = sheet::contour_flange_path(&name, &r.name, &rv, &plane, &segs, w, *flip != *reverse)?;
let solid = sh.solid()?;
st.bodies.push(ModelBody::new(name, solid, f.id));
st.sheets.push(sh);
+1 -1
View File
@@ -824,7 +824,7 @@ const BLEND_STATIONS: usize = 41;
/// The sketch curves `ids` (free-form ones exactly where they can be) grouped into chains of segments that meet end to end, each oriented
/// to run one way. Curves are joined greedily at their free ends: where three meet, one of them
/// starts a chain of its own.
fn curve_chains(ss: &SolvedSketch, ids: &[String]) -> Result<Vec<Vec<Seg2>>> {
pub(super) fn curve_chains(ss: &SolvedSketch, ids: &[String]) -> Result<Vec<Vec<Seg2>>> {
if ids.len() > MAX_CHAIN_CURVES {
return Err(DocError::Invalid(format!("at most {MAX_CHAIN_CURVES} curves")));
}
+4 -3
View File
@@ -631,11 +631,12 @@ pub fn is_literal(s: &str) -> bool {
}
}
/// Is the expression a bare number without a unit, like `20` or `-1.5`? A leading `=` only marks
/// Is the expression a bare number without a unit, like `20`, `-1.5` or `2,5`? A leading `=` only marks
/// a formula (#4681), so `=20` is the same bare number.
pub fn is_bare_number(s: &str) -> bool {
let number = s.trim().strip_prefix('=').unwrap_or(s);
is_literal(s) && number.trim().trim_start_matches(['-', '+']).trim().parse::<f64>().is_ok()
// One number token, as the lexer reads it, so a decimal comma (`2,5`) and a mixed number
// (`2 1/8`) count like `2.5` and `2.125`.
is_literal(s) && lex(s).is_ok_and(|toks| matches!(toks.as_slice(), [Tok::Num(_)] | [Tok::Op('-' | '+'), Tok::Num(_)]))
}
/// Is `s` shaped like a parameter name: a letter or `_`, then letters, digits and `_`? The shape
+20 -1
View File
@@ -263,6 +263,7 @@ impl From<solidcraft_format::FormatError> for DocError {
fn from(e: solidcraft_format::FormatError) -> Self {
match e {
solidcraft_format::FormatError::Invalid(why) => DocError::Invalid(why),
solidcraft_format::FormatError::Cancelled => DocError::Invalid("cancelled".into()),
}
}
}
@@ -693,6 +694,7 @@ pub(crate) fn decode(mut v: Value, version: u32, applied: Option<&[String]>) ->
sketch.validate_patterns().map_err(|e| DocError::Invalid(format!("damaged design: {e}")))?;
sketch.validate_periodic_splines().map_err(|e| DocError::Invalid(format!("damaged design: {e}")))?;
sketch.validate_spline_knots().map_err(|e| DocError::Invalid(format!("damaged design: {e}")))?;
sketch.validate_spline_handles().map_err(|e| DocError::Invalid(format!("damaged design: {e}")))?;
}
if let crate::FeatureKind::BodyTransfer { from, to, .. } = &feature.kind
&& [from, to].into_iter().any(|id| !component_ids.contains(id))
@@ -749,6 +751,23 @@ fn validate_sketch_sizes(value: &Value) -> Result<()> {
{
return Err(invalid());
}
if let Some(curves) = sketch.get("curves").and_then(Value::as_array) {
let mut point_references = 0usize;
for curve in curves {
for key in ["pts", "handles"] {
if let Some(points) = curve.get(key) {
let Some(points) = points.as_array() else { return Err(invalid()) };
point_references = point_references.saturating_add(points.len());
if points.len() > solidcraft_sketch::MAX_SPLINE_POINTS || point_references > 1_000_000 {
return Err(invalid());
}
}
}
if curve.get("spline_handle_of").and_then(Value::as_str).is_some_and(|id| id.len() > 512) {
return Err(invalid());
}
}
}
if let Some(patterns) = sketch.get("patterns").and_then(Value::as_array) {
let mut points = 0usize;
let mut curves = 0usize;
@@ -1984,7 +2003,7 @@ mod tests {
let first = pts[0];
let neighbor = pts[1];
let closing = pts[3];
sketch.add_curve(CurveKind::Spline { pts, control: false, degree: 3, periodic: true, nurbs: None }, None).unwrap();
sketch.add_curve(CurveKind::Spline { pts, control: false, degree: 3, periodic: true, nurbs: None, handles: Vec::new() }, None).unwrap();
let mut document = Document::new("periodic file validation");
document.add_feature(crate::FeatureKind::Sketch { plane: crate::PlaneRef::Origin { name: "XY".into() }, sketch }, None).unwrap();
assert_eq!(read(&document.to_json()).unwrap(), document);
@@ -0,0 +1,5 @@
//! A body's face may save `surface` as a `blend` {definition}: a round by its definition (a
//! procedural blend's, or a round of an exact class's whose net has no analytic label), always
//! with its `nurbs`, the net. An older design has these faces as plain NURBS faces and keeps them
//! so. The last release does not read a model cache with this kind (a cache miss: the design is
//! evaluated again) and warns about the newer format number.
@@ -0,0 +1,6 @@
//! A construction point, axis or plane can be built from a snap point (#11228): a reference of
//! type `snap` saves the edge, face or sketch curve it is on (`of`), which point it is (`kind`:
//! `centre` or `middle`) and where it was picked (`at`), so it follows that edge
//! or face when the model changes. An older design has no such reference and opens as before.
//! The last release refuses a design that has one (it asks for an update) instead of failing to
//! read the reference.
@@ -0,0 +1,34 @@
//! A fit point spline whose tangent handles were dragged saves the handle points and their
//! construction lines. A spline stores them only once one is dragged: older designs open
//! unchanged, their splines without handles. The last release warns about the newer format
//! number and opens the spline from its fit points.
#[cfg(test)]
mod tests {
use serde_json::{Value, json};
use solidcore_record::Vec2;
use solidcraft_sketch::{CurveKind, Sketch};
#[test]
fn older_fit_splines_open_without_handles() {
let mut sketch = Sketch::new();
let points = [Vec2::new(0.0, 0.0), Vec2::new(12.0, 5.0), Vec2::new(24.0, 1.0), Vec2::new(37.0, 9.0)]
.into_iter()
.map(|point| sketch.add_point(point, None).unwrap())
.collect::<Vec<_>>();
sketch
.add_curve(CurveKind::Spline { pts: points, control: false, degree: 3, periodic: false, nurbs: None, handles: Vec::new() }, None)
.unwrap();
let mut doc = crate::Document::new("Spline handles");
doc.add_feature(crate::FeatureKind::Sketch { plane: crate::PlaneRef::Origin { name: "XY".into() }, sketch: sketch.clone() }, None)
.unwrap();
let mut old: Value = serde_json::from_str(&doc.to_json()).unwrap();
old["format"] = json!(crate::format::format_string(crate::format::FORMAT - 1));
old.as_object_mut().unwrap().remove("format_pending");
let opened = crate::format::read(&old.to_string()).unwrap();
let crate::FeatureKind::Sketch { sketch: opened, .. } = &opened.features[0].kind else { panic!("expected sketch feature") };
assert_eq!(opened.curves, sketch.curves, "the splines open as they were saved");
assert!(opened.curves.iter().all(|c| c.spline_handle_of.is_none()));
}
}
@@ -0,0 +1,5 @@
//! A Split Face by sketch curves may save `loops: true`: the sketch's curves are joined end to end
//! before the split, so a closed loop of lines and arcs cuts the face along the loop only. An older
//! design has none and splits by each curve on its own, an open one extended past its ends, as
//! before. The last release warns about the newer format number and opens the design, splitting
//! that feature curve by curve (its sides extended across the face).
+6 -1
View File
@@ -135,7 +135,12 @@ pub(super) fn edge_frame(doc: &Document, st: &ModelState, comp: u64, snap: &Snap
frame(mid, z, Some(y.cross(z))).ok_or_else(bad)
}
Some(other) => {
let j = crate::name_lookup::locate_edge(b, other, pick).map_err(|_| gone(&format!("the edge `{other}`")))?.edge;
// The pick lies on edge `i`, not on this one: a name the pick would decide finds `i`.
let j = crate::name_lookup::locate_edge(b, other, pick)
.ok()
.map(|hit| hit.edge)
.filter(|j| *j != i)
.ok_or_else(|| gone(&format!("the edge `{other}`")))?;
let f = m.edges.get(j).ok_or_else(bad)?;
let t = tol(b);
let at = meeting(e, f, t).ok_or_else(|| DocError::Invalid(format!("joint origin: its edges no longer meet at a corner (`{other}`)")))?;
+94 -9
View File
@@ -7,7 +7,9 @@
//! face, when only a face of the same stem is left, when the name is on several bodies, or when
//! the name was saved before format 14 ([`LEGACY_MARK`]), the name finds nothing and the feature
//! that uses it fails, asking for the reference to be picked again
//! ([`crate::DocError::Reselect`]). A face whose name was worked out from geometry, found where
//! ([`crate::DocError::Reselect`]). An edge name has one more way: when no edge's faces answer
//! to it, and one of its faces is gone from a feature still on the body, the reference's point
//! decides (see [`renamed_edge`]). A face whose name was worked out from geometry, found where
//! the reference's point no longer lies, is reported: only geometry decided.
//! Saved numbered Project links also have a compatibility check for a whole face that
//! contains the saved pick and comes directly from its original construction feature.
@@ -52,9 +54,11 @@ pub struct EdgeHit {
/// The edge of a body a stored edge name finds: the edge with that name; else the one edge
/// between the faces it names exactly (see [`exactly`]). A numbered name (`~k`) was one of
/// several edges between its faces, told apart only by where they lay, so the one edge left
/// between them is it only where the reference's point still lies on it. `Err` explains when
/// there is none: no such edge, the named edge split into several, one of several edges whose
/// faces now bound one edge elsewhere, or a name saved before format 14.
/// between them is it only where the reference's point still lies on it. A name whose faces no
/// edge's faces answer to at all, one of them gone from a feature that is still there, is the edge
/// of the other face the reference's point lies on, clear of its ends (see [`renamed_edge`]).
/// `Err` explains when there is none: no such edge, the named edge split into several, one of
/// several edges whose faces now bound one edge elsewhere, or a name saved before format 14.
pub fn locate_edge(b: &ModelBody, name: &str, near: Vec3) -> Result<EdgeHit, String> {
if legacy_name(name).is_some() {
return Err(format!("edge `{name}` was named by a build before format 14"));
@@ -77,13 +81,22 @@ pub fn locate_edge(b: &ModelBody, name: &str, near: Vec3) -> Result<EdgeHit, Str
}
// By the faces it separates: the best way any edge matches.
let wanted = strip_edge_number(name);
let mut best: Option<(Match, Vec<usize>)> = None;
let (mut best, mut loose): (Option<(Match, Vec<usize>)>, bool) = (None, false);
for (i, fs) in m.edge_faces.iter().enumerate() {
let [fa, fb] = fs.as_slice() else { continue };
let Some(level) = edge_match(&faces, *fa as usize, *fb as usize, wanted).filter(|level| exactly(*level)) else { continue };
keep_best(&mut best, level, i);
let Some(level) = edge_match(&faces, *fa as usize, *fb as usize, wanted) else { continue };
if exactly(level) {
keep_best(&mut best, level, i);
} else {
loose = true;
}
}
let (_, cands) = best.ok_or_else(|| format!("edge `{name}` no longer exists"))?;
let Some((_, cands)) = best else {
let picked = if loose { None } else { renamed_edge(b, &m, &faces, wanted, near) };
return picked
.map(|i| EdgeHit { edge: i, point: near, verified: false, on_point: true })
.ok_or_else(|| format!("edge `{name}` no longer exists"));
};
// When the faces of several numbered edges bound one edge now, every one of their names
// finds the faces of that edge: taking it for each would put them all on one edge (#13332).
let numbered = wanted.len() != name.len();
@@ -144,10 +157,82 @@ fn exactly(level: Match) -> bool {
matches!(level, Match::Exact | Match::Piece)
}
/// How far from an edge a picked point may lie and still be on it.
fn pick_reach(b: &ModelBody) -> f64 {
(b.body.size() * 2e-3).max(1e-3)
}
/// Whether the picked point `near` lies on edge `i` of body `b` (whose mesh is `m`): then a
/// reference keeps it.
fn edge_near(b: &ModelBody, m: &Mesh, i: usize, near: Vec3) -> bool {
m.edges.get(i).is_some_and(|e| e.windows(2).any(|w| near.dist_to_segment(w[0], w[1]) < (b.body.size() * 2e-3).max(1e-3)))
m.edges.get(i).is_some_and(|e| e.windows(2).any(|w| near.dist_to_segment(w[0], w[1]) < pick_reach(b)))
}
/// The edge of body `b` whose exact curve passes through the picked point `near`, by its index
/// in the body's mesh `m` (the mesh's edges are the B-rep's, in its order: the edge's faces are
/// checked too). A point at an end of the edge is on the edges that meet there as well, so it
/// names none. `None` too for a mesh body, a point that is not finite, or a point a pick's reach
/// from every edge but a periodic face's seam or a pole.
fn edge_through(b: &ModelBody, m: &Mesh, near: Vec3) -> Option<usize> {
let reach = pick_reach(b);
let (d, i) = solidcraft_kernel::comb_edge_near(&b.body, near).ok().flatten()?;
let record = b.body.record();
let edge = record.edges.get(i)?;
let sorted = |mut fs: Vec<u32>| {
fs.sort_unstable();
fs.dedup();
fs
};
let same_faces = sorted(edge.uses.iter().map(|u| u.face).collect()) == sorted(m.edge_faces.get(i)?.clone());
let clear_of_ends = edge
.vertices
.iter()
.all(|v| record.vertices.get(*v as usize).is_some_and(|v| Vec3::new(v.point[0], v.point[1], v.point[2]).dist(near) > reach));
(d <= reach && same_faces && clear_of_ends).then_some(i)
}
/// The edge a stored edge name `wanted` (without its number) meant when no edge's faces answer to
/// it at all: one of its two faces is still found exactly, the other finds no face although the
/// feature that named it still made faces of body `b`, and the reference's point `near` lies on an
/// edge of the face still found, clear of its ends (see [`edge_through`]). Then the point says
/// which edge this is: that feature may have named the face differently once (a design saved when
/// a round wall took the name of a later join's face), and the face may also be gone while the
/// feature stays (a shorter tab); either way the edge the pick lies on is taken. Not where that
/// edge's other face is one the same feature made: its face there was replaced (a sketch line
/// swapped for another), and the reference asks again. A face whose feature made nothing on the
/// body was removed with it, and so is the name. The point must be the
/// reference's own pick: a caller that passes a point on another edge cannot take what this finds.
fn renamed_edge(b: &ModelBody, m: &Mesh, faces: &Names, wanted: &str, near: Vec3) -> Option<usize> {
let count = faces.faces.len();
let found = |x: &str| {
let mut hits = (0..count).filter(|f| crate::history_names::matches(faces, *f, x).is_some_and(exactly));
hits.next().filter(|_| hits.next().is_none())
};
let lost = |y: &str| {
crate::persistent_name::made_by(y)
.filter(|id| faces.faces.iter().any(|f| crate::persistent_name::made_by(f) == Some(*id)))
.filter(|_| (0..count).all(|f| crate::history_names::matches(faces, f, y).is_none()))
};
// Every way the name splits into two face names (a face name may hold `|` itself); they must
// agree on the face still found.
let mut kept: Vec<(usize, u64)> = Vec::new();
for (i, _) in wanted.match_indices('|') {
let (Some(x), Some(y)) = (wanted.get(..i), wanted.get(i + 1..)) else { continue };
for (x, y) in [(x, y), (y, x)] {
if let Some(hit) = found(x).filter(|f| kept.iter().all(|(k, _)| k != f)).zip(lost(y)) {
kept.push(hit);
}
}
}
let &[(face, lost_by)] = kept.as_slice() else { return None };
let edge = edge_through(b, m, near)?;
let uses = &b.body.record().edges.get(edge)?.uses;
// The edge's other face is not one of the lost face's feature: where that feature made the
// face at the pick, its face was replaced there (a sketch line swapped for another), not renamed.
let made_by = |f: usize| faces.faces.get(f).and_then(|name| crate::persistent_name::made_by(name));
let bounds_face = uses.iter().any(|u| u.face as usize == face);
let replaced = uses.iter().any(|u| u.face as usize != face && made_by(u.face as usize) == Some(lost_by));
(bounds_face && !replaced).then_some(edge)
}
/// The point of face `face` of a mesh nearest `p`, and how far it is.
+19
View File
@@ -1295,6 +1295,25 @@ mod design_length_tests {
assert!((inches - 127.0).abs() < 1e-9, "{inches}");
}
/// A bare number written with a decimal comma (`2,5`) or as a mixed number (`2 1/8`) means the
/// design's units like `2.5` does, not millimetres.
#[test]
fn a_decimal_comma_or_mixed_number_gets_the_design_unit() {
let mut doc = Document::new("Units");
doc.units = "in".into();
let length = crate::expr::Kind::Length;
assert_eq!(doc.with_design_unit("2,5", length), "2,5 in");
assert_eq!(doc.with_design_unit("-,5", length), "-,5 in");
assert_eq!(doc.with_design_unit("=2 1/8", length), "=2 1/8 in");
assert_eq!(doc.with_design_unit("w = 2,5", length), "w = 2,5 in");
let inches = doc.eval(&doc.with_design_unit("2 1/8", length), length).unwrap();
assert!((inches - 2.125 * 25.4).abs() < 1e-9, "{inches}");
// Anything that is not one bare number is left as typed.
for e in ["2,5 mm", "2 1/8\"", "(2,5)", "2,5 + 1", "1/2", "d3"] {
assert_eq!(doc.with_design_unit(e, length), e, "{e}");
}
}
#[test]
fn the_written_value_reads_back_within_a_micrometre() {
for units in ["mm", "cm", "m", "in", "ft"] {
+10 -1
View File
@@ -1342,7 +1342,16 @@ fn project_sketch_curve(sk: &Sketch, from: &Plane, ci: usize, to: &Plane) -> Vec
(Some(c), Some(m)) => vec![LinkGeom::Ellipse { c: map(c), major: map(m), minor: *r }],
_ => Vec::new(),
},
Some(CurveKind::Spline { pts, control, degree, periodic, nurbs }) if parallel => {
Some(CurveKind::Spline { pts, control: false, periodic, nurbs: None, handles, .. }) if parallel && !handles.is_empty() => {
let p: Option<Vec<Vec2>> = pts.iter().map(|q| sk.point(*q).map(map)).collect();
let tangent_handles: Option<Vec<Vec2>> = handles.iter().map(|q| sk.point(*q).map(map)).collect();
let Some((p, tangent_handles)) = p.zip(tangent_handles) else { return Vec::new() };
// One curve per span whether or not a handle has been moved, so turning a handle keeps
// the projected layout and what is constrained to it (#6502).
let Ok(pieces) = solidcraft_sketch::fit_beziers_with_handles(&p, &tangent_handles, *periodic) else { return Vec::new() };
pieces.into_iter().map(|piece| LinkGeom::Spline { pts: piece.to_vec(), control: true, degree: 3, periodic: false, nurbs: None }).collect()
}
Some(CurveKind::Spline { pts, control, degree, periodic, nurbs, handles, .. }) if parallel && handles.is_empty() => {
let p: Option<Vec<Vec2>> = pts.iter().map(|q| sk.point(*q).map(map)).collect();
p.map(|pts| vec![LinkGeom::Spline { pts, control: *control, degree: *degree, periodic: *periodic, nurbs: nurbs.clone() }])
.unwrap_or_default()
+192 -68
View File
@@ -45,6 +45,11 @@ const TOUCH_AREA: f64 = 1e-6;
const FOLD_TABS_REJOIN: &str = "not supported yet: folding tabs that join again beyond the bend";
pub const FOLD_FIXED_OFF_SHEET: &str = "the fold's fixed point is not on the sheet";
/// A bend radius below this (mm), the sheet model's outline tolerance, is a sharp bend. Its inner
/// face has collapsed to the bend's inner edge, so the folded solid has only the outer cylindrical
/// face there, as a press brake makes it with a sharp punch: no inner cylinder.
const SHARP_BEND: f64 = 1e-3;
/// The most bores one sheet keeps.
const MAX_BORES: usize = 1000;
@@ -2310,6 +2315,13 @@ impl SheetBody {
};
let mut faces = Vec::new();
for (ci, c) in cells.iter().enumerate() {
// A sharp bend's inner surface has no area: it is the line where the base's and the
// flange's inner faces meet. The outer surface and the zone's end walls close the
// solid; the end walls lose their collapsed inner edge below.
let inner_sharp = match c.piece {
Piece::Zone(i) => self.flanges.get(i).filter(|f| f.radius <= SHARP_BEND).map(|f| if f.angle >= 0.0 { z1 } else { z0 }),
Piece::Panel(_) => None,
};
// Top (z1, outward +z) and bottom (z0, outward −z, loops reversed).
let loops = |z: f64, rev: bool| -> Result<Vec<Vec<EdgeSpec>>> {
std::iter::once(&c.outer)
@@ -2325,6 +2337,9 @@ impl SheetBody {
};
let centre = c.outer.iter().map(Seg2::start).fold(Vec2::default(), |a, b| a + b) * (1.0 / c.outer.len().max(1) as f64);
for (z, rev, sign) in [(z1, false, 1.0), (z0, true, -1.0)] {
if inner_sharp == Some(z) {
continue;
}
let surface = match c.piece {
Piece::Panel(_) => SurfSpec::Plane,
Piece::Zone(i) => {
@@ -2381,13 +2396,30 @@ impl SheetBody {
(Piece::Zone(_), _) if matches!(bottom, EdgeSpec::Chain { .. }) => SurfSpec::Ruled { a: bottom.clone(), b: top_ab.clone() },
_ => SurfSpec::Plane,
};
let top = rev_edge(top_ab);
// A sharp bend's inner edge is a point on the zone's end wall: drop that
// collapsed edge and let the wall end at the point (a three-edge loop). The inner
// face is z1 when the bend turns up and z0 when it turns down.
let collapsed = |e: &EdgeSpec| match e {
EdgeSpec::Arc { a, b, .. } | EdgeSpec::Line { a, b } => a.dist(*b) < LINEAR_TOL,
EdgeSpec::Chain { .. } => false,
};
let inner_bottom = inner_sharp == Some(z0) && collapsed(&bottom);
let inner_top = inner_sharp == Some(z1) && collapsed(&top_ab);
let mut edges = Vec::new();
if !inner_bottom {
edges.push(bottom);
}
edges.push(up_b);
if !inner_top {
edges.push(rev_edge(top_ab));
}
edges.push(down_a);
let mid = e.point_at(0.5);
let tdir = e.point_at(0.51) - e.point_at(0.49);
let right = Vec2::new(tdir.y, -tdir.x);
let pm = self.map3(c.piece, mid, (z0 + z1) / 2.0);
let outward = self.map3(c.piece, mid + right * 1e-3, (z0 + z1) / 2.0) - pm;
faces.push(FaceSpec { loops: vec![vec![bottom, up_b, top, down_a]], surface, probe: Some((pm, outward)) });
faces.push(FaceSpec { loops: vec![edges], surface, probe: Some((pm, outward)) });
}
}
Ok(faces)
@@ -3018,7 +3050,10 @@ impl SheetBody {
if self.flanges.len().saturating_mul(2).saturating_add(1) > MAX_FLAT_PIECES {
return Err(flat_budget_error());
}
let mut polys: Vec<Vec<Vec2>> = vec![dedup(&flat_polyline(&self.base.outer.ccw())?).into_vec()];
let mut polys: Vec<Vec<Vec2>> = Vec::new();
if encloses_area(&self.base) {
polys.push(dedup(&flat_polyline(&self.base.outer.ccw())?).into_vec());
}
for f in &self.flanges {
polys.push(dedup(&flat_polyline(&self.zone(f).outer.ccw())?).into_vec());
if f.leg > 1e-9 {
@@ -3351,9 +3386,13 @@ impl SheetBody {
Ok((size.x, size.y))
}
/// Panels with their regions and world placements: (flange index or None, region).
/// Panels with their regions and world placements: (flange index or None, region). A
/// profile that starts with an arc has no base panel.
fn panels(&self) -> Vec<(Option<usize>, Region2)> {
let mut v = vec![(None, self.base.clone())];
let mut v = Vec::new();
if encloses_area(&self.base) {
v.push((None, self.base.clone()));
}
for (i, f) in self.flanges.iter().enumerate() {
if f.leg > 1e-9 {
v.push((Some(i), self.panel(f)));
@@ -6590,36 +6629,115 @@ pub fn contour_flange(body: &str, rule: &str, rv: &RuleValues, plane: &Plane, pt
if pts.len() < 2 {
return Err(DocError::Invalid("a contour flange needs an open chain of lines".into()));
}
let segs: Vec<Seg2> = pts.windows(2).map(|w| Seg2::Line { a: w[0], b: w[1] }).collect();
contour_flange_path(body, rule, rv, plane, &segs, width, flip)
}
/// A sheet from an open chain of lines and circular arcs (contour flange): `segs` in `plane`,
/// the material on the right of the travel (or the left when `flip`), `width` along the plane
/// normal. A line is a flat panel; an arc is a bend of its own radius. The arc is the profile
/// surface: when the bend turns toward the material the sheet is outside that surface, so the
/// bend's inner radius is the arc's less the thickness; when it turns away the arc is the inner
/// surface and its radius is the bend's. A line-line corner is a sharp corner and gets a bend of
/// the rule's radius, as a chain of lines always did.
pub fn contour_flange_path(body: &str, rule: &str, rv: &RuleValues, plane: &Plane, segs: &[Seg2], width: f64, flip: bool) -> Result<SheetBody> {
if segs.is_empty() {
return Err(DocError::Invalid("a contour flange needs an open profile".into()));
}
if !(width.is_finite() && width > 0.0) {
return Err(DocError::Invalid("the contour flange distance must be positive".into()));
}
let (t, r) = (rv.thickness, rv.bend_radius);
let seg_dir = |i: usize| -> Result<(Vec2, f64)> {
let (Some(a), Some(b)) = (pts.get(i), pts.get(i + 1)) else { return Err(DocError::Invalid("chain".into())) };
let l = a.dist(*b);
Ok(((*b - *a).normalized().ok_or_else(|| DocError::Invalid("zero-length line in the contour".into()))?, l))
};
// Material side in the sketch: right of travel, or left when flipped.
let side = if flip { -1.0 } else { 1.0 };
// Material side in the sketch: right of travel.
let (d0, _) = seg_dir(0)?;
let first = segs.first().copied().ok_or_else(|| DocError::Invalid("a contour flange needs an open profile".into()))?;
let start = first.start();
let d0 = first.start_tangent();
let l0 = d0.len();
if !(l0.is_finite() && l0 > 1e-12) {
return Err(DocError::Invalid("the contour starts with a curve of no direction".into()));
}
let d0 = d0 / l0;
let m0 = Vec2::new(d0.y, -d0.x) * side;
let start = pts.first().copied().unwrap_or_default();
let (x, z) = (plane.dir_to_world(d0), plane.dir_to_world(m0));
let frame = Plane::new(plane.to_world(start), x, z.cross(x)).ok_or_else(|| DocError::Invalid("contour frame".into()))?;
// Width along the sketch normal: +y or −y of the frame.
let ysign = if frame.y.dot(plane.normal()) >= 0.0 { 1.0 } else { -1.0 };
let n = pts.len() - 1;
// Turn at each corner: angle and whether it bends toward the material (up).
let mut corners = Vec::new();
for i in 0..n.saturating_sub(1) {
let (da, _) = seg_dir(i)?;
let (db, _) = seg_dir(i + 1)?;
let cross = da.cross(db) * side;
let ang = da.dot(db).clamp(-1.0, 1.0).acos();
if ang < 1e-9 {
return Err(DocError::Invalid("straight corners in a contour flange".into()));
}
// Right-hand material: turning right (negative cross) bends toward it.
corners.push((ang, cross < 0.0));
}
let (y0, y1) = if ysign > 0.0 { (0.0, width) } else { (-width, 0.0) };
let setback = |ang: f64, up: bool| if up { (r + t) * (ang / 2.0).tan() } else { r * (ang / 2.0).tan() };
// The junction after each segment: a sharp line-line corner (its unsigned turn and whether
// it bends toward the material), or none where a line meets an arc tangentially.
let mut sharp: Vec<Option<(f64, bool)>> = Vec::with_capacity(segs.len().saturating_sub(1));
for i in 0..segs.len().saturating_sub(1) {
let (Some(a), Some(b)) = (segs.get(i), segs.get(i + 1)) else { continue };
if a.end().dist(b.start()) > 1e-5 {
return Err(DocError::Invalid("the contour curves are not connected end to end".into()));
}
if matches!(a, Seg2::Line { .. }) && matches!(b, Seg2::Line { .. }) {
let (Some(da), Some(db)) = (a.end_tangent().normalized(), b.start_tangent().normalized()) else {
return Err(DocError::Invalid("zero-length line in the contour".into()));
};
let ang = da.dot(db).clamp(-1.0, 1.0).acos();
if ang < 1e-9 {
return Err(DocError::Invalid("straight corners in a contour flange".into()));
}
sharp.push(Some((ang, da.cross(db) * side < 0.0)));
} else {
let (Some(ta), Some(tb)) = (a.end_tangent().normalized(), b.start_tangent().normalized()) else {
return Err(DocError::Invalid("zero-length curve in the contour".into()));
};
if ta.dot(tb) < 1.0 - 1e-6 {
return Err(DocError::Invalid(
"the contour arcs must be tangent to the curves beside them; a sharp corner beside an arc is not a bend of that arc's radius"
.into(),
));
}
sharp.push(None);
}
}
// Panels and bends in path order: every line is a panel, every arc a bend, every sharp
// line-line corner a bend of the rule's radius. A panel loses the tangent setback at each
// sharp corner it ends on.
enum Piece {
Panel { len: f64 },
Bend { angle: f64, radius: f64 },
}
let mut pieces: Vec<Piece> = Vec::with_capacity(segs.len().saturating_mul(2));
for (i, s) in segs.iter().enumerate() {
match *s {
Seg2::Line { a, b } => {
let l = a.dist(b);
if !(l.is_finite() && l > 1e-12) {
return Err(DocError::Invalid("zero-length line in the contour".into()));
}
let before = if i == 0 { 0.0 } else { sharp.get(i - 1).copied().flatten().map(|(ang, up)| setback(ang, up)).unwrap_or(0.0) };
let after = sharp.get(i).copied().flatten().map(|(ang, up)| setback(ang, up)).unwrap_or(0.0);
let len = l - before - after;
if !len.is_finite() || len < -1e-9 {
return Err(DocError::Invalid("a contour line is shorter than its bends".into()));
}
pieces.push(Piece::Panel { len: len.max(0.0) });
}
Seg2::Arc { center: _, radius, start: _, sweep } => {
if !(radius.is_finite() && radius > 0.0) {
return Err(DocError::Invalid("a contour arc has no radius".into()));
}
if !(sweep.is_finite() && sweep.abs() > 1e-9 && sweep.abs() < std::f64::consts::TAU - 1e-9) {
return Err(DocError::Invalid("a contour arc must be shorter than a full turn".into()));
}
let angle = -sweep * side;
let up = angle >= 0.0;
let inner = if up { radius - t } else { radius };
if !(inner.is_finite() && inner > 1e-9) {
return Err(DocError::Invalid("a contour arc's radius is no larger than the sheet is thick".into()));
}
pieces.push(Piece::Bend { angle, radius: inner });
}
_ => return Err(DocError::Invalid("not supported yet: a contour flange profile is lines and circular arcs".into())),
}
if let Some((ang, up)) = sharp.get(i).copied().flatten() {
pieces.push(Piece::Bend { angle: if up { ang } else { -ang }, radius: r });
}
}
let mut sheet = SheetBody {
body: body.into(),
rule: rule.into(),
@@ -6642,48 +6760,54 @@ pub fn contour_flange(body: &str, rule: &str, rv: &RuleValues, plane: &Plane, pt
base_curves: Vec::new(),
altered_by: None,
};
let (y0, y1) = if ysign > 0.0 { (0.0, width) } else { (-width, 0.0) };
// First segment: the base panel.
let (_, l0) = seg_dir(0)?;
let end0 = l0 - corners.first().map(|(a, u)| setback(*a, *u)).unwrap_or(0.0);
if end0 <= 0.0 {
return Err(DocError::Invalid("a contour line is shorter than its bends".into()));
}
sheet.base = rect(Vec2::new(0.0, y0), Vec2::new(end0, y0), Vec2::new(end0, y1), Vec2::new(0.0, y1));
let mut parent: Option<usize> = None;
let mut at = end0;
for (i, (ang, up)) in corners.iter().enumerate() {
let (_, l) = seg_dir(i + 1)?;
let before = setback(*ang, *up);
let after = corners.get(i + 1).map(|(a, u)| setback(*a, *u)).unwrap_or(0.0);
let leg = l - before - after;
if leg < -1e-9 {
// The first panel is the base; a profile that starts with an arc has no base panel.
let first_is_panel = matches!(pieces.first(), Some(Piece::Panel { .. }));
let mut at = 0.0;
if let Some(Piece::Panel { len }) = pieces.first() {
if *len <= 1e-9 {
return Err(DocError::Invalid("a contour line is shorter than its bends".into()));
}
// The material flips sides relative to the panel when the bend goes down.
let f = SheetFlange {
parent,
p: Vec2::new(at, y0),
d: Vec2::new(0.0, 1.0),
len: y1 - y0,
n: Vec2::new(1.0, 0.0),
angle: if *up { *ang } else { -*ang },
radius: r,
leg: leg.max(0.0),
ext: (0.0, 0.0),
slope: (0.0, 0.0),
tear: (false, false),
tear_curved: (false, false),
tear_ease: (0.0, 0.0),
shape: None,
exact: None,
zone_shape: None,
tag: String::new(),
};
let ba = sheet.allowance(&f);
sheet.flanges.push(f);
parent = Some(sheet.flanges.len() - 1);
at += ba + leg.max(0.0);
sheet.base = rect(Vec2::new(0.0, y0), Vec2::new(*len, y0), Vec2::new(*len, y1), Vec2::new(0.0, y1));
at = *len;
}
let mut parent: Option<usize> = None;
let mut pending: Option<usize> = None;
for piece in pieces.iter().skip(usize::from(first_is_panel)) {
match piece {
Piece::Panel { len } => {
let Some(i) = pending.take() else {
return Err(DocError::Invalid("a contour panel has no bend before it".into()));
};
let Some(f) = sheet.flanges.get_mut(i) else { return Err(DocError::Invalid("contour bend".into())) };
f.leg = *len;
at += *len;
}
Piece::Bend { angle, radius } => {
let f = SheetFlange {
parent,
p: Vec2::new(at, y0),
d: Vec2::new(0.0, 1.0),
len: y1 - y0,
n: Vec2::new(1.0, 0.0),
angle: *angle,
radius: *radius,
leg: 0.0,
ext: (0.0, 0.0),
slope: (0.0, 0.0),
tear: (false, false),
tear_curved: (false, false),
tear_ease: (0.0, 0.0),
shape: None,
exact: None,
zone_shape: None,
tag: String::new(),
};
at += sheet.allowance(&f);
sheet.flanges.push(f);
parent = Some(sheet.flanges.len() - 1);
pending = parent;
}
}
}
Ok(sheet)
}
+11 -1
View File
@@ -1925,7 +1925,17 @@ fn an_extruded_ellipse_or_spline_is_one_wall_named_by_its_curve() {
[(0., 0.), (10., -3.), (20., 4.), (30., 0.)].iter().map(|&(x, y)| sk.add_point(Vec2::new(x, y), None).unwrap()).collect();
let (a, b) = (sk.add_point(Vec2::new(30., 20.), None).unwrap(), sk.add_point(Vec2::new(0., 20.), None).unwrap());
let curve = sk
.add_curve(solidcraft_sketch::CurveKind::Spline { pts: pts.clone(), control: false, degree: 3, periodic: false, nurbs: None }, None)
.add_curve(
solidcraft_sketch::CurveKind::Spline {
pts: pts.clone(),
control: false,
degree: 3,
periodic: false,
nurbs: None,
handles: Vec::new(),
},
None,
)
.unwrap();
sk.add_line_pts(pts[3], a, None).unwrap();
sk.add_line_pts(a, b, None).unwrap();
+41 -5
View File
@@ -122,6 +122,27 @@ pub(crate) const ISO_COARSE: &[(f64, f64)] = &[
(56.0, 5.5),
];
/// The other ISO 261 coarse sizes (second and third choice), with their ISO 262 coarse pitches:
/// known by name (M33 is M33x3.5) and offered in the Thread window, but not chosen for a face
/// automatically, which still picks from [`ISO_COARSE`] (#4153, #11901).
const ISO_COARSE_MORE: &[(f64, f64)] = &[
(1.0, 0.25),
(1.1, 0.25),
(1.2, 0.25),
(1.4, 0.3),
(1.8, 0.35),
(2.2, 0.45),
(3.5, 0.6),
(4.5, 0.75),
(7.0, 1.0),
(33.0, 3.5),
(39.0, 4.0),
(45.0, 4.5),
(52.0, 5.0),
(60.0, 5.5),
(64.0, 6.0),
];
/// The ISO 2902 diameter-pitch series' middle pitch per diameter, which ISO 2904 (trapezoidal)
/// and DIN 513-2 (buttress, from 10 mm) share: (nominal diameter, pitch).
const ISO_2902_MEDIUM: &[(f64, f64)] = &[
@@ -343,11 +364,15 @@ impl ThreadForm {
}
}
/// The standard sizes, (nominal diameter, pitch) in mm; none for ASME buttress (its pitch is
/// always given) and the square thread.
/// The standard sizes, (nominal diameter, pitch) in mm, by diameter; none for ASME buttress
/// (its pitch is always given) and the square thread.
pub fn sizes(self) -> Vec<(f64, f64)> {
match self {
ThreadForm::Metric => ISO_COARSE.to_vec(),
ThreadForm::Metric => {
let mut all: Vec<(f64, f64)> = ISO_COARSE.iter().chain(ISO_COARSE_MORE).copied().collect();
all.sort_by(|a, b| a.0.total_cmp(&b.0));
all
}
ThreadForm::Trapezoidal => ISO_2902_MEDIUM.to_vec(),
ThreadForm::Buttress => ISO_2902_MEDIUM.iter().copied().filter(|(d, _)| *d >= 10.0).collect(),
ThreadForm::Acme => ACME_SIZES.iter().map(|(_, d, n)| (d * INCH, INCH / n)).collect(),
@@ -553,6 +578,16 @@ impl ThreadForm {
}
}
/// The sizes [`fit`](Self::fit) chooses from: [`sizes`](Self::sizes), less the metric sizes
/// that are only known by name ([`ISO_COARSE_MORE`]), so a face is sized as it was before
/// they were added.
fn fit_sizes(self) -> Vec<(f64, f64)> {
match self {
ThreadForm::Metric => ISO_COARSE.to_vec(),
_ => self.sizes(),
}
}
/// The standard size a cylinder of this diameter takes: a shaft is the major diameter, a hole
/// its nominal diameter, else the drill ([`fit_with`](Self::fit_with)). None when no size is within 15% (or the form has no size table).
pub fn fit(self, diameter: f64, internal: bool) -> Option<(f64, f64)> {
@@ -564,13 +599,14 @@ impl ThreadForm {
/// as a size's nominal diameter (within 1% and 0.03 mm) is that size, a 12 mm hole is M12,
/// and only a hole near no nominal diameter is matched by tap drill.
pub fn fit_with(self, diameter: f64, internal: bool, old_rule: bool) -> Option<(f64, f64)> {
let sizes = self.fit_sizes();
if internal && !old_rule {
let near = |n: f64| (n - diameter).abs() <= 0.01 * diameter + 0.03;
if let Some((n, p)) = self.sizes().into_iter().find(|(n, _)| near(*n)) {
if let Some((n, p)) = sizes.iter().copied().find(|(n, _)| near(*n)) {
return Some((n, p));
}
}
self.sizes()
sizes
.into_iter()
.map(|(n, p)| (n, p, if internal { self.drill(n, p) } else { n }))
.min_by(|a, b| (a.2 - diameter).abs().total_cmp(&(b.2 - diameter).abs()))
+2
View File
@@ -490,6 +490,7 @@ impl GeoRef {
match self {
Self::Axis { name } | Self::ConstructPoint { name } => f(Ref::FeatureName(name)),
Self::Plane { plane } => plane.walk_feature_refs(f, depth + 1),
Self::Snap { of, .. } => of.walk_feature_refs(f, depth + 1),
Self::Point { .. }
| Self::Datum { .. }
| Self::Origin
@@ -509,6 +510,7 @@ impl GeoRef {
match self {
Self::Axis { name } | Self::ConstructPoint { name } => f(Ref::FeatureName(name)),
Self::Plane { plane } => plane.walk_feature_refs_mut(f, depth + 1),
Self::Snap { of, .. } => of.walk_feature_refs_mut(f, depth + 1),
Self::Point { .. }
| Self::Datum { .. }
| Self::Origin
+47 -20
View File
@@ -191,7 +191,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.contract(&SPLIT_FACE_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params(
"faces: [[x,y,z] points on the faces to split, or face names from model.faces]; body?; and the splitting tool: plane: XY|XZ|YZ|plane name|{origin, normal} — or tool: {body, point} (a face of a body, its surface extended) — or sketch: id or name (its curves, along the sketch's normal)",
"faces: [[x,y,z] points on the faces to split, or face names from model.faces]; body?; and the splitting tool: plane: XY|XZ|YZ|plane name|{origin, normal} — or tool: {body, point} (a face of a body, its surface extended) — or sketch: id or name (its curves, along the sketch's normal; each curve on its own, an open one extended past its ends) and loops?: bool (default false: true joins the curves end to end first, so a closed loop cuts along itself only)",
),
CommandSpec::new("solid.split_body", "Split Body", crate::checked!("solid.split_body", split_body))
.at("SOLID", "MODIFY")
@@ -1642,7 +1642,7 @@ struct RowHit {
const NAMED_BODIES: usize = 8;
/// A body's size, its edges' polylines and which of them are seams.
type EdgeShape = (f64, Vec<Vec<Vec3>>, Vec<bool>);
type EdgeShape = (f64, Vec<Vec<Vec3>>, Vec<bool>, Option<solidcraft_geom::Mesh>);
/// How far a polyline is from `q`.
fn polyline_dist(points: &[Vec3], q: Vec3) -> f64 {
@@ -1722,10 +1722,11 @@ fn edge_points(s: &Session, p: &Value, cmd: &str) -> Result<EdgePicks> {
// meeting) can't be blended either: nobody means it by a pick.
Ok(m) => Some((
size,
m.edges,
Vec::new(),
m.seams.iter().zip(&m.smooth_edges).map(|(s, t)| *s || *t).chain(m.seams.iter().skip(m.smooth_edges.len()).copied()).collect(),
Some(m),
)),
Err(_) => b.body.edges(tol).ok().map(|edges| (size, edges.into_iter().map(|e| e.points).collect(), Vec::new())),
Err(_) => b.body.edges(tol).ok().map(|edges| (size, edges.into_iter().map(|e| e.points).collect(), Vec::new(), None)),
};
if let Some(shape) = shape {
shapes.insert(i, shape);
@@ -1790,14 +1791,19 @@ fn edge_points(s: &Session, p: &Value, cmd: &str) -> Result<EdgePicks> {
let mut hits: Vec<RowHit> = Vec::new();
for (k, q) in culled {
let Some(r) = rows.get(k) else { continue };
let (Some((size, edges, seams)), Some(b)) = (shapes.get(&r.body), st.bodies.get(r.body)) else { continue };
let (Some((size, listed, seams, mesh)), Some(b)) = (shapes.get(&r.body), st.bodies.get(r.body)) else { continue };
// The display mesh's polylines, else the body's own edge list.
let edges = mesh.as_ref().map_or(listed, |m| &m.edges);
let chord = (size * 2e-3).max(1e-3);
let mut near = Vec::new();
let mut candidates = Vec::new();
for (i, points) in edges.iter().enumerate() {
let d = polyline_dist(points, q);
if d <= edge_reach(*size) {
let seam = seams.get(i).copied().unwrap_or(false);
// Where two faces meet tangentially (a flat wall running into a round one)
// there is nothing to blend, so nobody means the edge by the pick (#12215).
// Decided only for the edges near the pick: it reads the faces' normals.
let seam = seams.get(i).copied().unwrap_or(false) || mesh.as_ref().is_some_and(|m| m.tangent_join(i, TANGENT_JOIN));
near.push((d, seam, leaving(points, q)));
candidates.push((i, d, seam));
}
@@ -1826,7 +1832,8 @@ fn edge_points(s: &Session, p: &Value, cmd: &str) -> Result<EdgePicks> {
for r in &rows {
let Some(q) = q_of(r) else { continue };
shape_of(r.body, &mut shapes);
let Some((_, edges, _)) = shapes.get(&r.body) else { continue };
let Some((_, listed, _, mesh)) = shapes.get(&r.body) else { continue };
let edges = mesh.as_ref().map_or(listed, |m| &m.edges);
nearest = edges.iter().map(|points| polyline_dist(points, q)).fold(nearest, f64::min);
}
let far = if nearest.is_finite() { format!("the nearest edge is {nearest:.3} mm away") } else { "there is no edge".to_string() };
@@ -1843,7 +1850,7 @@ fn edge_points(s: &Session, p: &Value, cmd: &str) -> Result<EdgePicks> {
let mut groups: Vec<&RowHit> = Vec::new();
for h in hits.iter().filter(|h| h.d <= 2.0 * d_min + 1e-3) {
let Some(r) = rows.get(h.row) else { continue };
let size = shapes.get(&r.body).map_or(1.0, |(size, _, _)| *size);
let size = shapes.get(&r.body).map_or(1.0, |(size, _, _, _)| *size);
let same = |g: &&RowHit| {
rows.get(g.row).is_some_and(|o| o.body == r.body) && ((g.edge.is_some() && g.edge == h.edge) || g.q.dist(h.q) <= 1e-6 * size.max(1.0))
};
@@ -1919,7 +1926,8 @@ fn edge_points(s: &Session, p: &Value, cmd: &str) -> Result<EdgePicks> {
continue;
}
shape_of(other.body, &mut shapes);
let Some((size, edges, seams)) = shapes.get(&other.body) else { continue };
let Some((size, listed, seams, mesh)) = shapes.get(&other.body) else { continue };
let edges = mesh.as_ref().map_or(listed, |m| &m.edges);
// The exact curve is no nearer than the polyline less its chord tolerance.
let chord = (size * 2e-3).max(1e-3);
let ghost = edges.iter().enumerate().filter(|(k, _)| !seams.get(*k).copied().unwrap_or(false)).any(|(k, points)| {
@@ -1991,6 +1999,9 @@ fn edge_foot(body: &solidcraft_kernel::Body, index: usize, q: Vec3) -> Option<(f
d.is_finite().then_some((d, foot))
}
/// Faces meeting at less than this angle (radians, 2°) meet tangentially, as a tangent chain reads them.
const TANGENT_JOIN: f64 = 2.0 * std::f64::consts::PI / 180.0;
/// The direction an edge's polyline leaves `pt` in, when `pt` is one of its ends; `None` when
/// the edge runs on through `pt`.
fn leaving(points: &[Vec3], pt: Vec3) -> Option<Vec3> {
@@ -2496,19 +2507,24 @@ fn move_body_feature(s: &mut Session, p: &Value) -> Result<Value> {
}
/// A copy keeps the look and the material of the body it came from (#6423): the copy is the same
/// part in another place, so it looks the same.
/// part in another place, so it looks the same. A feature makes the same number of copies of each
/// source (a Move copy one, a Pattern its count less one), the sources' copies together and in
/// the sources' order.
fn copy_keeps_appearance(s: &mut Session, reply: &Value, sources: &[String], cmd: &str) -> Result<()> {
let id = reply.get("feature").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "the copy has no feature id"))?;
let copies: Vec<String> = s.model.state().bodies.iter().filter(|b| b.feature == id).map(|b| b.name.clone()).collect();
if copies.len() != sources.len() {
return Err(bad(cmd, "the copy did not make one body per body it copied"));
}
for (source, copy) in sources.iter().zip(copies.iter()) {
if let Some(look) = s.doc.appearances.bodies.get(source).cloned() {
s.doc_mut().appearances.bodies.insert(copy.clone(), look);
}
if let Some(material) = s.doc.materials.get(source).cloned() {
s.doc_mut().materials.insert(copy.clone(), material);
let each = copies.len().checked_div(sources.len()).filter(|each| *each > 0 && each * sources.len() == copies.len());
let Some(each) = each else {
return Err(bad(cmd, "the copy did not make the same number of bodies for each body it copied"));
};
for (source, copies) in sources.iter().zip(copies.chunks(each)) {
for copy in copies {
if let Some(look) = s.doc.appearances.bodies.get(source).cloned() {
s.doc_mut().appearances.bodies.insert(copy.clone(), look);
}
if let Some(material) = s.doc.materials.get(source).cloned() {
s.doc_mut().materials.insert(copy.clone(), material);
}
}
}
Ok(())
@@ -2586,7 +2602,15 @@ fn finish_pattern(
) -> Result<Value> {
let comps = string_list(UNKNOWN_COMMAND, p, "components")?;
if comps.is_empty() {
return add_feature(s, p, FeatureKind::Pattern { features, pattern, bodies: bodies.into_iter().map(BodyName).collect() });
let copied = bodies.clone();
let reply = add_feature(s, p, FeatureKind::Pattern { features, pattern, bodies: bodies.into_iter().map(BodyName).collect() })?;
// The pattern is added either way: when it made no copies (the feature failed to
// evaluate) or an uneven number of them, there is nothing to tell the copies apart by
// and they keep no look, as before.
if !copied.is_empty() {
let _ = copy_keeps_appearance(s, &reply, &copied, "pattern");
}
return Ok(reply);
}
let cmd = "pattern (components)";
let mats = solidcraft_doc::pattern_matrices(&s.doc, &s.model.state(), &pattern)?;
@@ -2946,6 +2970,7 @@ fn split_face(s: &mut Session, p: &Value) -> Result<Value> {
plane: xy(),
tool: Some(solidcraft_doc::FaceAt { body: BodyName(tb), point: Position(point) }),
sketch: None,
loops: false,
}
} else if p.get("sketch").is_some_and(|v| !v.is_null()) {
let sketch = sketch_id(s, p.get("sketch"), cmd, "sketch")?;
@@ -2955,6 +2980,7 @@ fn split_face(s: &mut Session, p: &Value) -> Result<Value> {
plane: xy(),
tool: None,
sketch: Some(SketchId(sketch)),
loops: bool_(p, "loops").unwrap_or(false),
}
} else {
let plane = plane_param(s, p.get("plane"), cmd)?;
@@ -2964,6 +2990,7 @@ fn split_face(s: &mut Session, p: &Value) -> Result<Value> {
plane,
tool: None,
sketch: None,
loops: false,
}
};
add_face_feature(&picked, s, p, kind)
@@ -2603,6 +2603,64 @@ fn an_imported_assembly_with_a_repeated_part_moves_as_one() {
let _ = std::fs::remove_dir_all(&dir);
}
/// #13382: a design saved by a version whose STEP reader numbered the file's bodies otherwise
/// opens with each body where it was and with its own shape. Here the older reader left the
/// file's first body out, so every stored name and tree index after it is one lower. The names
/// went by index: each body took the next one's name, and that name put it in the next part's
/// component.
#[test]
fn an_import_saved_by_an_older_step_reader_opens_with_its_bodies_in_place() {
use solidcraft_doc::FeatureKind;
use solidcraft_doc::kernel::StepNode;
let dir = std::env::temp_dir().join(format!("solidcraft-13382-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("parts.step").to_string_lossy().to_string();
let mut src = Session::default();
// Three parts of different sizes, each in its own component and placed apart.
for (k, (name, size)) in [("Small", [10, 10, 10]), ("Long", [40, 6, 6]), ("Tall", [8, 8, 30])].into_iter().enumerate() {
let made = run(&mut src, "component.create", json!({"name": name}));
run(&mut src, "solid.box", json!({"length": size[0], "width": size[1], "height": size[2], "body_name": name}));
run(&mut src, "component.activate", json!({"component": 0}));
// The first part is grounded where it is made.
if k > 0 {
run(&mut src, "occurrence.move", json!({"occurrence": made["occurrence"], "translate": [100 * k, 0, 0], "space": "world"}));
}
}
run(&mut src, "file.export", json!({"path": path}));
let mut s = Session::default();
run(&mut s, "file.insert_step", json!({"path": path}));
let names: Vec<String> = s.world_state().unwrap().bodies.iter().map(|b| b.name.clone()).collect();
assert_eq!(names.len(), 3, "{names:?}");
let before: Vec<_> = names.iter().map(|n| placed_bounds(&s, n)).collect();
// The design as the older reader saved it: no body 0, the others numbered from 0.
let mut doc = (*s.doc).clone();
let f = doc.features.iter_mut().find(|f| matches!(f.kind, FeatureKind::Import { .. })).unwrap();
let first = f.body_names.remove(0);
fn renumber(nodes: &mut [StepNode]) {
for n in nodes {
n.bodies = n.bodies.iter().filter(|b| **b != 0).map(|b| b - 1).collect();
renumber(&mut n.children);
}
}
if let FeatureKind::Import { components, skip, .. } = &mut f.kind {
renumber(components);
assert!(skip.is_empty(), "no part is placed twice: {skip:?}");
}
let opened = Session::opened(doc, None);
let now: Vec<String> = opened.world_state().unwrap().bodies.iter().map(|b| b.name.clone()).collect();
assert_eq!(now.len(), 3, "the body the older reader left out is built too: {now:?}");
for (n, b) in names.iter().zip(&before) {
if *n == first {
continue;
}
let got = placed_bounds(&opened, n);
assert!(shifted(*b, got, [0.0; 3]), "{n} opened at {got:?}, was at {b:?}");
}
let _ = std::fs::remove_dir_all(&dir);
}
/// A 20 × 30 × 8 plate made in its own component and stood up by its placement (turned
/// `angle` degrees about X), the way an imported part set upright is: in the world it lies
/// flat at `x0`..`x0 + 20`, y 0..30, z 0..8. The design is active again afterwards.
@@ -2714,6 +2772,44 @@ fn an_emboss_that_works_has_no_warning() {
}
}
/// A 40 x 10 stadium (two lines, two tangent arcs) extruded 10 mm. The top rim runs on smoothly
/// through the points where a line meets an arc, so picking one of those points names the rim,
/// not an ambiguous corner (#12215).
fn stadium_block() -> Session {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane":"XY"}));
run(&mut s, "sketch.line", json!({"points":[[5.0,0.0],[35.0,0.0]],"closed":false}));
run(&mut s, "sketch.arc.center_point", json!({"center":[35.0,5.0],"start":[35.0,0.0],"end":[35.0,10.0]}));
run(&mut s, "sketch.line", json!({"points":[[35.0,10.0],[5.0,10.0]],"closed":false}));
run(&mut s, "sketch.arc.center_point", json!({"center":[5.0,5.0],"start":[5.0,10.0],"end":[5.0,0.0]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance":10.0}));
s
}
#[test]
fn chamfer_picked_where_a_line_meets_a_tangent_arc_takes_the_whole_rim() {
let mut s = stadium_block();
let before = volume(&mut s);
run(&mut s, "solid.chamfer", json!({"edges": [[35.0, 0.0, 10.0]], "distance": 0.2}));
let rim = 2.0 * 30.0 + 2.0 * std::f64::consts::PI * 5.0;
let removed = before - volume(&mut s);
let want = 0.5 * 0.2 * 0.2 * rim;
assert!((removed - want).abs() < 0.02 * want, "removed {removed} vs {want}");
}
/// A box corner is not a tangent join: a pick there still says several edges meet (#5173).
#[test]
fn chamfer_picked_at_a_box_corner_is_still_refused() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane":"XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0.0,0.0],"p1":[20.0,10.0]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance":10.0}));
let err = s.execute("solid.chamfer", &json!({"edges": [[20.0, 0.0, 10.0]], "distance": 0.2})).unwrap_err().to_string();
assert!(err.contains("edges meet"), "{err}");
}
/// #9969: a cut whose tool lies clear of the body reported "operation complete" and changed
/// nothing, with no warning. It now says it removed no material; a cut that works says nothing.
#[test]
+91 -22
View File
@@ -31,7 +31,6 @@ use crate::schema::insert_mesh::{InsertMeshParams, MeshInserted};
use crate::schema::insert_step::{InsertStepParams, StepInserted};
use crate::schema::new_design::{NewDesignParams, NewDesignResult};
use crate::schema::save::{SaveAsParams, SaveParams, SaveResult};
#[cfg(not(target_arch = "wasm32"))]
use crate::schema::save_mesh::{SaveMeshResult, SlicerDelivery};
use crate::schema::slicers::{SlicerInfo, SlicersParams, SlicersResult};
use crate::schema::{TypedContract, output_schema_for};
@@ -102,7 +101,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.noundo()
.contract(&EXPORT_CONTRACT)
.output(crate::schema::output_schema_for::<crate::schema::file::FileExportResult>)
.params("path; format?: stl|stla|obj|step|3mf|3mf-tagged|glb|urdf|json (default from extension; 3mf-tagged: 3MF whose triangles carry their B-rep face, at full precision, for meshing and analysis tools; docs/formats/3mf-face-tags.md; obj: the colours of the bodies go in a material library (`.mtl`) written next to the mesh file; glb: binary glTF in metres with Y up, each body placed and named, its colours, metallic and roughness as materials; a transparent body or face, or a body a render setup gives an image texture or optics, is refused; urdf: the whole assembly as a robot, with STL meshes written next to it; docs/urdf.md); bodies?: [names] (JSON and URDF export the complete design); urdf only: mesh_prefix? (before each mesh file name, e.g. package://robot/meshes/), effort? (default 1000), velocity? (default 10); quality?: standard|fine|finest (STL, OBJ, 3MF and GLB: how closely the mesh follows curved faces, a chord tolerance of 2e-4, 5e-5 or 1e-5 of each body's size; default standard); objects?: body|component (3MF only: one object per body, the default, or one object for each placed component with two or more bodies, with those bodies as its parts, so a slicer loads them as one object with parts)"),
.params("path; format?: stl|stla|obj|step|3mf|3mf-tagged|glb|urdf|json (default from extension; 3mf-tagged: 3MF whose triangles carry their B-rep face, at full precision, for meshing and analysis tools; docs/formats/3mf-face-tags.md; obj: the colours of the bodies go in a material library (`.mtl`) written next to the mesh file; glb: binary glTF in metres with Y up, each body placed and named, its colours, metallic and roughness as materials; a transparent body or face, or a body a render setup gives an image texture or optics, is refused; urdf: the whole assembly as a robot, with STL meshes written next to it; docs/urdf.md); bodies?: [names] (JSON and URDF export the complete design); urdf only: mesh_prefix? (before each mesh file name, e.g. package://robot/meshes/), effort? (default 1000), velocity? (default 10); quality?: standard|fine|finest (STL, OBJ, 3MF and GLB: how closely the mesh follows curved faces, a chord tolerance of 2e-4, 5e-5 or 1e-5 of each body's size; default standard); objects?: body|component (3MF only: one object per body, the default, or one object for each placed component with two or more bodies, with those bodies as its parts, so a slicer loads them as one object with parts); lay_on_print_faces?: bool (STL and 3MF only, default false: each body with a print face (body.print_face) is written laid on it, as a slicer export lays it → print_faces: the bodies laid, warnings: each body that has a print face but was written as it lies)"),
// One command per format, so each File › Export item can have a key of its own; started
// from a key or command search, the app opens the save picker (the mesh dialog for STL and
// 3MF). Each takes `file.export`'s parameters for its format, without `format`.
@@ -117,13 +116,13 @@ pub static COMMANDS: &[CommandSpec] = &[
.noundo()
.contract(&EXPORT_STL_CONTRACT)
.output(crate::schema::output_schema_for::<crate::schema::file::ExportResult>)
.params("path; bodies?: [names] (default: every body); quality?: standard|fine|finest (default standard) → {path, bytes, format}: binary STL, whatever the path's extension"),
.params("path; bodies?: [names] (default: every body); quality?: standard|fine|finest (default standard); lay_on_print_faces?: bool (default false: as file.export) → {path, bytes, format, print_faces?, warnings?}: binary STL, whatever the path's extension"),
CommandSpec::new("file.export_3mf", "Export 3MF", crate::checked!("file.export_3mf", export_3mf))
.icon("mesh_export")
.noundo()
.contract(&EXPORT_3MF_CONTRACT)
.output(crate::schema::output_schema_for::<crate::schema::file::ExportResult>)
.params("path; bodies?: [names] (default: every body); quality?: standard|fine|finest (default standard); objects?: body|component (as file.export) → {path, bytes, format}: 3MF, whatever the path's extension"),
.params("path; bodies?: [names] (default: every body); quality?: standard|fine|finest (default standard); objects?: body|component (as file.export); lay_on_print_faces?: bool (default false: as file.export) → {path, bytes, format, print_faces?, warnings?}: 3MF, whatever the path's extension"),
CommandSpec::new("file.export_obj", "Export OBJ", crate::checked!("file.export_obj", export_obj_cmd))
.icon("export")
.noundo()
@@ -142,7 +141,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.noundo()
.contract(&SAVE_MESH_CONTRACT)
.output(crate::schema::output_schema_for::<crate::schema::save_mesh::SaveMeshResult>)
.params("path; format?: stl|3mf|3mf-tagged (default from the extension, else stl; 3mf-tagged: docs/formats/3mf-face-tags.md); bodies?: [names] (default: all); ascii?: bool (STL); slicer?: id or name from file.slicers, the program path it lists, or a program the user added under Custom… (send the saved file there, through the same steps as file.send_to_slicer: a slicer that has to be started first is waited for; may be deprecated later in favour of file.send_to_slicer or a Send to App workflow, kept for scripts); single_instance?: bool (default true; see file.send_to_slicer); quality?: standard|fine|finest (STL, OBJ and 3MF: how closely the mesh follows curved faces, a chord tolerance of 2e-4, 5e-5 or 1e-5 of each body's size; default standard); objects?: body|component (3MF only: one object per body, the default, or one object for each placed component with two or more bodies, with those bodies as its parts, so a slicer loads them as one object with parts)"),
.params("path; format?: stl|3mf|3mf-tagged (default from the extension, else stl; 3mf-tagged: docs/formats/3mf-face-tags.md); bodies?: [names] (default: all); ascii?: bool (STL); slicer?: id or name from file.slicers, the program path it lists, or a program the user added under Custom… (send the saved file there, through the same steps as file.send_to_slicer: a slicer that has to be started first is waited for; may be deprecated later in favour of file.send_to_slicer or a Send to App workflow, kept for scripts); single_instance?: bool (default true; see file.send_to_slicer); quality?: standard|fine|finest (STL, OBJ and 3MF: how closely the mesh follows curved faces, a chord tolerance of 2e-4, 5e-5 or 1e-5 of each body's size; default standard); objects?: body|component (3MF only: one object per body, the default, or one object for each placed component with two or more bodies, with those bodies as its parts, so a slicer loads them as one object with parts); lay_on_print_faces?: bool (default false: each body with a print face (body.print_face) is saved laid on it; a file sent to `slicer` is laid on them whatever this says)"),
CommandSpec::new("file.send_to_slicer", "Send to Slicer", send_to_slicer)
.enabled(starts_programs)
.at("INSERT", "FILES")
@@ -179,7 +178,7 @@ const EXPORT_CONTRACT: crate::schema::TypedContract<crate::schema::file::ExportP
const SAVE_CONTRACT: TypedContract<SaveParams> = TypedContract::new();
const SAVE_AS_CONTRACT: TypedContract<SaveAsParams> = TypedContract::new();
const EXPORT_STEP_CONTRACT: crate::schema::TypedContract<crate::schema::file::ExportStepParams> = crate::schema::TypedContract::new();
const EXPORT_STL_CONTRACT: crate::schema::TypedContract<crate::schema::file::ExportMeshParams> = crate::schema::TypedContract::new();
const EXPORT_STL_CONTRACT: crate::schema::TypedContract<crate::schema::file::ExportStlParams> = crate::schema::TypedContract::new();
const EXPORT_3MF_CONTRACT: crate::schema::TypedContract<crate::schema::file::Export3mfParams> = crate::schema::TypedContract::new();
const EXPORT_OBJ_CONTRACT: crate::schema::TypedContract<crate::schema::file::ExportMeshParams> = crate::schema::TypedContract::new();
const SLICERS_CONTRACT: TypedContract<SlicersParams> = TypedContract::new();
@@ -905,7 +904,12 @@ fn write_bytes(
"json" => solidcraft_io::write_design(&s.design_at(Some(path))),
_ => return Err(bad("file.save", "format must be container or json")),
};
// Atomic, keeping the version being replaced as `<file>.bak`.
// Cancelled while the design was being prepared (a watched save): nothing is written.
if solidcraft_kernel::watch::current().is_some_and(|w| w.cancelled()) {
return Err(EngineError::Other(format!("{path}: save cancelled")));
}
// Atomic, keeping the version being replaced as `<file>.bak`; a watched save reports how far
// the write is and stops on Cancel until the file is replaced.
solidcraft_io::write_atomic(std::path::Path::new(path), &bytes, true).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
s.path = Some(path.to_string());
s.disk = solidcraft_io::design_lock::stamp(path);
@@ -1223,8 +1227,7 @@ fn export_to(
)?
};
solidcraft_io::vfs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let reply =
ExportResult { path: path.to_string(), bytes: bytes.len(), format: format!("{format:?}"), files: Vec::new(), param_warnings: Vec::new() };
let reply = ExportResult { path: path.to_string(), bytes: bytes.len(), format: format!("{format:?}"), ..ExportResult::default() };
serde_json::to_value(reply).map_err(|e| EngineError::Other(e.to_string()))
}
@@ -1233,8 +1236,60 @@ fn export_thumbnail(s: &Session, format: Format, bodies: &[String]) -> Option<Ve
matches!(format, Format::ThreeMf | Format::ThreeMfTagged).then(|| thumbnail::exported(s, bodies)).flatten()
}
/// [`export_to`] for a mesh file a slicer is sent (STL or 3MF): each body laid on its print face.
#[cfg(not(target_arch = "wasm32"))]
/// `lay_on_print_faces` as the mesh exports read it: true writes each body with a print face laid
/// on it, as a slicer export does. Only STL and 3MF take it; `false` is accepted for any format.
fn lay_on_print_faces(p: &Value, cmd: &str, format: Format) -> Result<bool> {
match p.get("lay_on_print_faces") {
None | Some(Value::Null) | Some(Value::Bool(false)) => Ok(false),
Some(Value::Bool(true)) if matches!(format, Format::StlBinary | Format::StlAscii | Format::ThreeMf | Format::ThreeMfTagged) => Ok(true),
Some(Value::Bool(true)) => Err(bad(cmd, "`lay_on_print_faces` is for STL and 3MF: other formats are not printed")),
Some(_) => Err(bad(cmd, "`lay_on_print_faces` must be true or false")),
}
}
/// Write the bodies as a mesh file (STL or 3MF), each laid on its print face: the file's size, and
/// which bodies were laid and which were written as they lie.
fn write_laid(
s: &Session,
path: &str,
format: Format,
bodies: &[String],
parts: &[(String, Vec<String>)],
quality: solidcraft_io::MeshQuality,
) -> Result<(usize, print_face::Laid)> {
let name = std::path::Path::new(path).file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| s.doc.name.clone());
let mut state = s.doc.painted(&*s.world_state()?);
let laid = print_face::lay_on_print_faces(s, &mut state, bodies, parts);
let thumbnail = export_thumbnail(s, format, bodies);
let bytes = solidcraft_io::export_with_parts(&state, bodies, parts, format, &name, &s.doc.metadata, quality, thumbnail.as_deref())?;
solidcraft_io::vfs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
Ok((bytes.len(), laid))
}
/// [`export_to`] for `file.export`, `file.export_stl` and `file.export_3mf` with
/// `lay_on_print_faces`: each body laid on its print face.
fn export_laid(
s: &Session,
path: &str,
format: Format,
bodies: &[String],
parts: &[(String, Vec<String>)],
quality: solidcraft_io::MeshQuality,
) -> Result<Value> {
let (bytes, laid) = write_laid(s, path, format, bodies, parts, quality)?;
let reply = ExportResult {
path: path.to_string(),
bytes,
format: format!("{format:?}"),
print_faces: laid.bodies,
warnings: laid.warnings,
..ExportResult::default()
};
serde_json::to_value(reply).map_err(|e| EngineError::Other(e.to_string()))
}
/// [`export_to`] for a mesh file a slicer is sent, or `file.save_mesh` with `lay_on_print_faces`
/// (STL or 3MF): each body laid on its print face.
fn export_laid_to(
s: &Session,
path: &str,
@@ -1243,15 +1298,10 @@ fn export_laid_to(
parts: &[(String, Vec<String>)],
quality: solidcraft_io::MeshQuality,
) -> Result<Value> {
let name = std::path::Path::new(path).file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| s.doc.name.clone());
let mut state = s.doc.painted(&*s.world_state()?);
let laid = print_face::lay_on_print_faces(s, &mut state, bodies, parts);
let thumbnail = export_thumbnail(s, format, bodies);
let bytes = solidcraft_io::export_with_parts(&state, bodies, parts, format, &name, &s.doc.metadata, quality, thumbnail.as_deref())?;
solidcraft_io::vfs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let (bytes, laid) = write_laid(s, path, format, bodies, parts, quality)?;
let reply = SaveMeshResult {
path: path.to_string(),
bytes: bytes.len(),
bytes,
format: format!("{format:?}"),
print_faces: laid.bodies,
warnings: laid.warnings,
@@ -1273,7 +1323,11 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
}
let quality = mesh_quality(p, cmd)?;
let parts = mesh_parts(s, p, cmd, format)?;
export_to(s, cmd, path, format, &string_list(cmd, p, "bodies")?, &parts, quality)
let bodies = string_list(cmd, p, "bodies")?;
if lay_on_print_faces(p, cmd, format)? {
return export_laid(s, path, format, &bodies, &parts, quality);
}
export_to(s, cmd, path, format, &bodies, &parts, quality)
}
fn export_step(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1285,14 +1339,22 @@ fn export_step(s: &mut Session, p: &Value) -> Result<Value> {
fn export_stl(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "file.export_stl";
let path = path_arg(p, cmd)?;
export_to(s, cmd, path, Format::StlBinary, &string_list(cmd, p, "bodies")?, &[], mesh_quality(p, cmd)?)
let (bodies, quality) = (string_list(cmd, p, "bodies")?, mesh_quality(p, cmd)?);
if lay_on_print_faces(p, cmd, Format::StlBinary)? {
return export_laid(s, path, Format::StlBinary, &bodies, &[], quality);
}
export_to(s, cmd, path, Format::StlBinary, &bodies, &[], quality)
}
fn export_3mf(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "file.export_3mf";
let path = path_arg(p, cmd)?;
let parts = mesh_parts(s, p, cmd, Format::ThreeMf)?;
export_to(s, cmd, path, Format::ThreeMf, &string_list(cmd, p, "bodies")?, &parts, mesh_quality(p, cmd)?)
let (bodies, quality) = (string_list(cmd, p, "bodies")?, mesh_quality(p, cmd)?);
if lay_on_print_faces(p, cmd, Format::ThreeMf)? {
return export_laid(s, path, Format::ThreeMf, &bodies, &parts, quality);
}
export_to(s, cmd, path, Format::ThreeMf, &bodies, &parts, quality)
}
fn export_obj_cmd(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1433,6 +1495,9 @@ fn save_mesh(s: &mut Session, p: &Value) -> Result<Value> {
// a possibility, not a plan.
let quality = mesh_quality(p, cmd)?;
let parts = mesh_parts(s, p, cmd, format)?;
// Read before `slicer`, so a wrong value is refused there too; a file sent to a slicer is laid
// on its print faces whatever this says.
let lay = lay_on_print_faces(p, cmd, format)?;
if let Some(spec) = str_(p, "slicer") {
#[cfg(target_arch = "wasm32")]
{
@@ -1456,7 +1521,11 @@ fn save_mesh(s: &mut Session, p: &Value) -> Result<Value> {
},
);
}
export_to(s, cmd, path, format, &string_list(cmd, p, "bodies")?, &parts, quality)
let bodies = string_list(cmd, p, "bodies")?;
if lay {
return export_laid_to(s, path, format, &bodies, &parts, quality);
}
export_to(s, cmd, path, format, &bodies, &parts, quality)
}
/// The browser build can't start a program.
+101
View File
@@ -186,6 +186,107 @@ fn node_key(imp: &StepImport, node: &StepNode, depth: usize) -> u64 {
h.finish()
}
/// For each body `now` gives, the index `stored` gave the same body, or `None` for a body
/// `stored` doesn't have. A body is the same one when it has the same place in both trees: the
/// same product at the same position among its siblings, and the same position among that
/// product's bodies. `None` when the trees disagree about their products (a name or a number of
/// children), or a body index is out of range or used twice: then nothing can be matched.
fn matched_bodies(stored: &[StepNode], now: &[StepNode], count: usize) -> Option<Vec<Option<usize>>> {
let mut matched = vec![None; count];
let mut taken = std::collections::HashSet::new();
let mut pending: Vec<(&[StepNode], &[StepNode])> = vec![(stored, now)];
while let Some((was, is)) = pending.pop() {
if was.len() != is.len() {
return None;
}
for (a, b) in was.iter().zip(is) {
if a.name != b.name {
return None;
}
for (old, new) in a.bodies.iter().zip(&b.bodies) {
let slot = matched.get_mut(*new)?;
if slot.is_some() || !taken.insert(*old) {
return None;
}
*slot = Some(*old);
}
pending.push((&a.children, &b.children));
}
}
Some(matched)
}
/// An Import feature written against `imp`'s numbering of the file's bodies (#13382). The
/// feature keeps its body names, skipped bodies and linked face names by body index, as the
/// STEP reader that inserted the file numbered them. A later reader can number the same file's
/// bodies differently, for example when it reads a body an earlier one left out. Each name
/// then went to the wrong body, which its name put in another component: bodies opened moved
/// and with another body's shape. The stored tree says which body each index was, so each
/// stored value moves to the index `imp` gives that body. A body only `imp` has gets the
/// reader's name, and is skipped when a new insert would skip it. Returns whether anything
/// changed; the feature is left as it is when the trees can't be matched.
pub(crate) fn realign_import(f: &mut solidcraft_doc::Feature, imp: &StepImport) -> bool {
let FeatureKind::Import { components, skip, .. } = &f.kind else { return false };
if components.is_empty() || *components == imp.tree {
return false;
}
let count = imp.bodies.len();
let Some(matched) = matched_bodies(components, &imp.tree, count) else { return false };
let stored_count = {
let mut all = Vec::new();
for n in components {
collect_bodies(n, &mut all, 0);
}
all.len()
};
if stored_count == count && matched.iter().enumerate().all(|(k, m)| *m == Some(k)) {
// The same numbering: only placements or names that don't move bodies differ.
return false;
}
let old_skip: std::collections::HashSet<u64> = skip.iter().copied().collect();
let new_skip: std::collections::HashSet<u64> = Plan::of(imp).skip.into_iter().collect();
let skip: Vec<u64> = (0..count as u64)
.filter(|k| match matched.get(*k as usize).copied().flatten() {
Some(old) => old_skip.contains(&(old as u64)),
None => new_skip.contains(k),
})
.collect();
f.body_names = by_new_index(&matched, &f.body_names);
if !f.link_faces.is_empty() {
f.link_faces = by_new_index(&matched, &f.link_faces);
}
if let FeatureKind::Import { components, skip: stored, .. } = &mut f.kind {
components.clone_from(&imp.tree);
*stored = skip;
}
true
}
/// `stored`, kept by the old body indices, as a list by the new ones (empty for a new body).
fn by_new_index<T: Clone + Default>(matched: &[Option<usize>], stored: &[T]) -> Vec<T> {
matched.iter().map(|m| m.and_then(|old| stored.get(old)).cloned().unwrap_or_default()).collect()
}
/// [`realign_import`] for every Import feature of `doc` that is not suppressed, read with the
/// reader that will build it. The reads are returned so the caller can hold them while it
/// evaluates `doc`: the evaluation then finds each file read already instead of reading it again.
pub(crate) fn realign_imports(doc: &mut solidcraft_doc::Document) -> Vec<std::sync::Arc<StepImport>> {
let mut reads = Vec::new();
for f in doc.features.iter_mut().filter(|f| !f.suppressed) {
let FeatureKind::Import { step, components, .. } = &f.kind else { continue };
if step.is_empty() || components.is_empty() {
continue;
}
// A file that doesn't read fails the feature when it is evaluated, which reports it.
let Ok(imp) = solidcraft_doc::kernel::step_import_shared(step) else { continue };
if realign_import(f, &imp) {
log::info!("{}: the stored body names follow this version's numbering of the file's bodies", f.name);
}
reads.push(imp);
}
reads
}
/// The import's component, as the command reports it. The reply types in `schema/insert_mesh.rs`
/// and `schema/insert_step.rs` are what the commands answer with these fields.
pub(crate) struct Placed {
+17 -2
View File
@@ -18,7 +18,7 @@ use crate::schema::inspect::{
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, TimelineSummary, WireArcInfo,
SketchWire, SplineTangentHandle, TimelineEntry, TimelineSummary, WireArcInfo,
};
use crate::schema::{TypedContract, output_schema_for};
use crate::{EngineError, Result, Session};
@@ -360,12 +360,27 @@ fn sketch_json(s: &Session, world: &solidcraft_doc::ModelState, id: u64) -> Opti
CurveKind::Ellipse { c: cc, m, r } => {
CurveShape::Ellipse { center: pid(cc), major: pid(m), center_at: at(cc), major_at: at(m), minor_radius: r }
}
CurveKind::Spline { ref pts, control, degree, periodic, ref nurbs } => CurveShape::Spline {
CurveKind::Spline { ref pts, control, degree, periodic, ref nurbs, ref handles } => CurveShape::Spline {
points: pts.iter().map(|q| pid(*q)).collect(),
points_at: pts.iter().map(|q| at(*q)).collect(),
control,
degree,
periodic,
tangent_handles: handles
.iter()
.map(|&point| SplineTangentHandle {
point: pid(point),
at: at(point),
curve: sk
.curves
.iter()
.find(|candidate| {
candidate.spline_handle_of.as_deref() == Some(c.id.as_str())
&& matches!(candidate.kind, CurveKind::Line { a, b } if a == point || b == point)
})
.map(|candidate| candidate.id.clone()),
})
.collect(),
// A control spline with its own knots (a projected free-form edge) says them.
knots: nurbs.as_ref().map(|n| n.knots.clone()),
weights: nurbs.as_ref().filter(|n| !n.weights.is_empty()).map(|n| n.weights.clone()),
+30
View File
@@ -143,3 +143,33 @@ fn an_edge_origin_on_a_body_placed_by_an_offset_lands_on_that_edge() {
let (o, z) = resolve(corner);
assert!(near(o, at(Vec3::new(0.0, 0.0, 5.0))) && near(z, up), "{o:?} {z:?}");
}
/// A corner's other edge is found by its own name. Where no edge answers to that name any more,
/// the corner is gone: the pick lies on the edge the corner was picked from, never on the other
/// edge, so it cannot say which edge that name meant.
#[test]
fn a_corner_whose_other_edge_is_gone_asks_again() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 5, "corner": [0, 0, 0], "body_name": "Plate"}));
let boss =
run(&mut s, "solid.cylinder", json!({"radius": 3, "height": 5, "base": [20, 20, 5], "operation": "join", "targets": ["Plate"]}))["feature"]
.as_u64()
.unwrap();
let st = s.model.state();
let snap = solidcraft_doc::joints::edge_snap(
&s.doc,
&st,
0,
Vec3::new(20.0, 0.0, 5.0),
Some(Vec3::new(20.0, 10.0, 5.0)),
Some(Vec3::new(1.0, 0.0, 5.0)),
)
.unwrap_or_else(|e| panic!("{e}"));
let solidcraft_doc::joints::Snap::Edge { pick, edge, face: Some(face), corner: Some(_), axis } = snap else { panic!("{snap:?}") };
// The top face, and a face of the boss (a feature with faces on the plate) that no face
// answers to: the edge the pick lies on would take that name's place.
let corner = Some(format!("{face}|F{boss}:side:l99"));
let stale = solidcraft_doc::joints::Snap::Edge { pick, edge, face: Some(face), corner, axis };
let origin = solidcraft_doc::joints::JointOrigin::new(0, stale);
assert!(solidcraft_doc::joints::resolve_origin(&s.doc, &st, &origin).is_err());
}
+1
View File
@@ -51,6 +51,7 @@ pub(crate) use features::{EdgeRefForm, PLANE_FORMS, axis_dir_form, axis_line_obj
pub use features::{auto_operation, extrude_reach, shown_scope};
pub(crate) use features::{rail_fit_form, sweep_orientation};
pub(crate) use features_more::{MAX_SKETCH_ID, SidePoint, SketchFrameId, axes_mask, side_point, sketch_frame_id};
pub(crate) use file::import::realign_imports;
pub(crate) use file::mesh_crease_angle;
pub use file::open_steps::OpenSteps;
pub(crate) use file::{NAMES_MESSAGE, UP_MESSAGE, names_form, up_form, usable_path};
+1 -2
View File
@@ -45,8 +45,7 @@ fn lines_drawn_from_offset_spline_ends_get_their_own_coincident_points() {
assert!(sk.points[*l1_end].pos.dist(sk.points[start].pos) < 1e-9);
// Moving the source moves the offset, and the lines follow its ends.
let from = sk.point(sk.resolve_point("s1.start").unwrap()).unwrap();
run(&mut s, "sketch.drag", json!({"entity":"s1.start","from":[from.x, from.y],"to":[from.x - 3.0, from.y + 2.0]}));
run(&mut s, "sketch.move", json!({"entities":["s1"],"translate":[-3.0,2.0]}));
let moved = solved(&s, id);
moved.validate_freeform_offsets().unwrap();
assert!(moved.points[start].pos.dist(sk.points[start].pos) > 1.0, "the offset followed its source");
+33
View File
@@ -1370,6 +1370,39 @@ fn circular_pattern_of_a_moved_copy_adds_one_body_per_instance() {
assert!(far > 20.0, "the repeats follow the redefined Move: {far}");
}
/// A pattern of bodies makes copies that look and weigh like their sources (#12115): the same
/// part in another place. Two bodies, three along X: each source's two copies take its own
/// appearance or material, not the other source's and not the default.
#[test]
fn a_body_pattern_keeps_each_sources_appearance_and_material() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"center":[0,0,5],"operation":"new","body_name":"Base"}));
run(&mut s, "solid.cylinder", json!({"diameter":6,"height":20,"base":[0,0,10],"operation":"new","body_name":"Post"}));
run(&mut s, "appearance.assign", json!({"bodies":["Post"],"color":[200,30,30]}));
run(&mut s, "material.assign", json!({"bodies":["Base"],"material":"Steel"}));
run(&mut s, "solid.pattern.rectangular", json!({"bodies":["Base","Post"],"dir1":[1,0,0],"count1":3,"spacing1":30}));
let red = s.doc.appearances.bodies.get("Post").cloned().expect("Post is red");
for copy in ["Base (2)", "Base (3)"] {
assert_eq!(s.doc.materials.get(copy).map(String::as_str), Some("Steel"), "{copy}");
assert!(!s.doc.appearances.bodies.contains_key(copy), "{copy} was not painted");
}
for copy in ["Post (2)", "Post (3)"] {
assert_eq!(s.doc.appearances.bodies.get(copy), Some(&red), "{copy}");
assert!(!s.doc.materials.contains_key(copy), "{copy} has no material of its own");
}
}
/// A pattern of bodies that makes no copy (a count of 1) is still added: the copies' look is
/// copied only where there are copies.
#[test]
fn a_body_pattern_with_no_copies_is_still_added() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"center":[0,0,5],"operation":"new","body_name":"Base"}));
run(&mut s, "material.assign", json!({"bodies":["Base"],"material":"Steel"}));
run(&mut s, "solid.pattern.rectangular", json!({"bodies":["Base"],"dir1":[1,0,0],"count1":1,"spacing1":30}));
assert_eq!(s.model.state().bodies.len(), 1);
}
/// #12199: a path whose curves are listed out of chain order is the same path as in chain order.
#[test]
fn pattern_on_path_orders_the_curves_into_one_chain() {
+133 -2
View File
@@ -1,4 +1,5 @@
//! Laying bodies on their print face for a slicer (#8429). The bodies being sent are copied (the
//! Laying bodies on their print face for a slicer (#8429), or for an STL or 3MF export with
//! `lay_on_print_faces`. The bodies being sent are copied (the
//! state the export writes is already a copy), and each copy whose body has a print face is
//! turned so the face points down, its centre of mass moved onto the world origin in X and Y,
//! and the face moved onto Z = 0: one rigid transform, applied once. The design and its timeline
@@ -20,7 +21,7 @@ pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("body.print_face", "Pri
.key("Ctrl+B")
.contract(&PRINT_FACE_CONTRACT)
.output(crate::schema::output_schema_for::<PrintFaceResult>)
.params("body: its name; point?: [x, y, z] on a flat face of it, in mm (omit to clear) → {body, face?}: marks the face a slicer export (file.send_to_slicer, file.save_mesh with slicer, file.open_in_easyprint) lays the body on: turned so the face points down at Z = 0, the centre of mass over the origin. One per body, replacing the last; flat faces of solid bodies only. A print face that is gone or no longer flat is reported in the export's warnings, and the body exported as it lies")];
.params("body: its name; point?: [x, y, z] on a flat face of it, in mm (omit to clear) → {body, face?}: marks the face a slicer export (file.send_to_slicer, file.save_mesh with slicer, file.open_in_easyprint), or an STL or 3MF export with lay_on_print_faces (file.export, file.export_stl, file.export_3mf, file.save_mesh), lays the body on: turned so the face points down at Z = 0, the centre of mass over the origin. One per body, replacing the last; flat faces of solid bodies only. A print face that is gone or no longer flat is reported in the export's warnings, and the body exported as it lies")];
const PRINT_FACE_CONTRACT: crate::schema::TypedContract<crate::schema::print_face::PrintFaceParams> = crate::schema::TypedContract::new();
@@ -353,6 +354,136 @@ mod tests {
assert_eq!(kept.doc.print_faces, s.doc.print_faces);
}
/// `name` in a fresh folder of the system's temporary folder, for `case`.
fn temp_path(case: &str, name: &str) -> String {
let dir = std::env::temp_dir().join(format!("solidcraft-lay-on-print-faces-{case}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("temporary folder");
dir.join(name).to_string_lossy().into_owned()
}
/// The box around every triangle of the STL file at `path`, in mm.
fn stl_box(path: &str) -> ([f64; 3], [f64; 3]) {
let triangles = solidcraft_io::read_stl(&std::fs::read(path).expect("the STL file")).expect("an STL file");
assert!(!triangles.is_empty(), "{path} has no triangles");
let (mut min, mut max) = ([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]);
for p in triangles.iter().flatten() {
for k in 0..3 {
min[k] = min[k].min(f64::from(p[k]));
max[k] = max[k].max(f64::from(p[k]));
}
}
(min, max)
}
/// Near enough for a coordinate an STL file holds as an f32.
fn near_f32(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-4
}
#[test]
fn an_stl_export_lays_the_body_on_its_print_face_only_when_asked() {
// The 10 x 5 end at X = 20 is the print face, so laid the 20 mm length stands up at Z = 0,
// centred on the origin in X and Y.
let mut s = block();
run(&mut s, "body.print_face", json!({"body": "Block", "point": [20, 5, 2.5]}));
let undo = s.undo.len();
let laid_box = |(min, max): ([f64; 3], [f64; 3])| {
near_f32(min[2], 0.)
&& near_f32(max[2], 20.)
&& near_f32(min[0] + max[0], 0.)
&& near_f32(min[1] + max[1], 0.)
&& near_f32((max[0] - min[0]) * (max[1] - min[1]), 50.)
};
for (id, name, extra) in [
("file.export_stl", "a.stl", json!({})),
("file.export", "b.stl", json!({})),
("file.export", "c.stl", json!({"format": "stla"})),
("file.save_mesh", "d.stl", json!({})),
("file.save_mesh", "e.stl", json!({"format": "stl", "ascii": true})),
] {
let path = temp_path("stl", name);
let mut p = json!({"path": path, "lay_on_print_faces": true});
p.as_object_mut().expect("object").extend(extra.as_object().expect("object").clone());
let reply = run(&mut s, id, p);
assert_eq!(reply["print_faces"], json!(["Block"]), "{id}: {reply}");
assert!(reply.get("warnings").is_none(), "{id}: {reply}");
let bbox = stl_box(&path);
assert!(laid_box(bbox), "{id} {name}: {bbox:?}");
// Without the key, or with it false, the body is written as it lies in the design.
for lay in [json!(null), json!(false)] {
let path = temp_path("stl-as-it-lies", name);
let mut p = json!({"path": path, "lay_on_print_faces": lay});
p.as_object_mut().expect("object").extend(extra.as_object().expect("object").clone());
let reply = run(&mut s, id, p);
assert!(reply.get("print_faces").is_none(), "{id}: {reply}");
let (min, max) = stl_box(&path);
assert!(near_f32(min[0], 0.) && near_f32(max[0], 20.) && near_f32(min[2], 0.) && near_f32(max[2], 5.), "{id}: {min:?} {max:?}");
}
}
// The design itself has not moved, and nothing was added to undo.
assert_eq!(s.undo.len(), undo);
let m = solidcraft_kernel::measure_mass(&s.model.state().bodies[0].body).expect("measure");
assert!(near(m.bbox.min.x, 0.) && near(m.bbox.max.x, 20.) && near(m.bbox.max.z, 5.), "{:?}", m.bbox);
}
#[test]
fn a_3mf_export_reports_the_bodies_it_laid_on_their_print_face() {
let mut s = block();
run(&mut s, "body.print_face", json!({"body": "Block", "point": [20, 5, 2.5]}));
for (id, name) in [("file.export_3mf", "a.3mf"), ("file.export", "b.3mf"), ("file.save_mesh", "c.3mf")] {
let path = temp_path("3mf", name);
let reply = run(&mut s, id, json!({"path": path, "lay_on_print_faces": true}));
assert_eq!(reply["print_faces"], json!(["Block"]), "{id}: {reply}");
assert!(std::fs::metadata(&path).is_ok_and(|m| m.len() > 0), "{id}: {path}");
}
}
#[test]
fn an_export_with_lay_on_print_faces_writes_a_body_without_one_as_it_lies() {
// A second body with no print face lies where it is; a print face cut away is reported.
let mut s = block();
run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 4, "corner": [50, 0, 0], "body_name": "Cube", "operation": "new"}));
let path = temp_path("none", "cube.stl");
let reply = run(&mut s, "file.export_stl", json!({"path": path, "bodies": ["Cube"], "lay_on_print_faces": true}));
assert!(reply.get("print_faces").is_none() && reply.get("warnings").is_none(), "{reply}");
let (min, max) = stl_box(&path);
assert!(near_f32(min[0], 50.) && near_f32(max[0], 54.) && near_f32(max[2], 4.), "{min:?} {max:?}");
run(&mut s, "body.print_face", json!({"body": "Block", "point": [10, 5, 5]}));
run(&mut s, "solid.box", json!({"length": 30, "width": 20, "height": 5, "corner": [-5, -5, 3], "operation": "cut"}));
let path = temp_path("cut", "block.stl");
let reply = run(&mut s, "file.export_stl", json!({"path": path, "bodies": ["Block"], "lay_on_print_faces": true}));
assert!(reply.get("print_faces").is_none(), "{reply}");
assert!(reply["warnings"].as_array().is_some_and(|w| w.len() == 1 && w[0].as_str().is_some_and(|w| w.contains("Block"))), "{reply}");
let (min, max) = stl_box(&path);
assert!(near_f32(min[0], 0.) && near_f32(max[0], 20.) && near_f32(max[2], 3.), "{min:?} {max:?}");
}
#[test]
fn lay_on_print_faces_is_refused_for_a_format_that_is_not_printed_or_a_value_that_is_not_true_or_false() {
let mut s = block();
run(&mut s, "body.print_face", json!({"body": "Block", "point": [20, 5, 2.5]}));
for (id, name, lay) in [
("file.export", "a.step", json!(true)),
("file.export", "a.obj", json!(true)),
("file.export", "a.glb", json!(true)),
("file.export", "a.json", json!(true)),
("file.export", "b.stl", json!("yes")),
("file.export_stl", "c.stl", json!(1)),
("file.export_3mf", "d.3mf", json!([true])),
("file.save_mesh", "e.stl", json!({})),
] {
let path = temp_path("refused", name);
let e = s.execute(id, &json!({"path": path, "lay_on_print_faces": lay})).expect_err(id).to_string();
assert!(e.contains("lay_on_print_faces"), "{id} {name}: {e}");
assert!(std::fs::metadata(&path).is_err(), "{id} {name}: a refused export writes nothing");
}
// False is accepted for any format: the file is written as the design has it.
let path = temp_path("false", "a.step");
run(&mut s, "file.export", json!({"path": path, "lay_on_print_faces": false}));
assert!(std::fs::metadata(&path).is_ok());
}
#[test]
fn renaming_the_body_keeps_its_print_face() {
let mut s = block();
+3 -3
View File
@@ -45,7 +45,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.contract(&FLANGE_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params(
"base: sketch + profiles? (rule?, flip?) | contour: sketch + curves (open chain of lines) + distance (rule?, flip?, reverse?) | \
"base: sketch + profiles? (rule?, flip?) | contour: sketch + curves (open chain of lines and tangent arcs) + distance (rule?, flip?, reverse?) | \
edge: edges: [[x,y,z] on top/bottom edges, or {point: [x,y,z], angle?, height?, segments?} for an edge with its own angle and height, \
or split into 1…64 segments [{from, to, angle?, height?, flip?}] that tile the stretch the sheet still has there (from, to are \
fractions 0…1 of it, first from 0, last to 1; consecutive segments that match make one bend)] + \
@@ -67,7 +67,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.contract(&PATH_FLANGE_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params(
"edge: [x,y,z] on a top or bottom edge of the sheet; sketch + curves: an open chain of sketch lines drawn on a plane across \
"edge: [x,y,z] on a top or bottom edge of the sheet; sketch + curves: an open chain of sketch lines and tangent arcs drawn on a plane across \
that edge, from the edge out (each line one flange as long as the line, to the outer face from the virtual sharp; each \
corner one bend, turning the way the lines turn); radius? (default: rule); body?. One feature, one body",
),
@@ -421,7 +421,7 @@ fn flange(s: &mut Session, p: &Value) -> Result<Value> {
let sketch = sketch_id(s, p.get("sketch"), cmd, "sketch")?;
let curves = string_list(cmd, p, "curves")?;
if curves.is_empty() || curves.len() > 1000 {
return Err(bad(cmd, "`curves` must list the open chain's lines"));
return Err(bad(cmd, "`curves` must list the open chain's lines and arcs"));
}
let distance = req_expr(cmd, p, "distance")?;
check_expr(s, &distance, Kind::Length, cmd, "distance")?;
+69
View File
@@ -621,6 +621,49 @@ fn contour_flange_hem_and_tray() {
assert!(rel(v, want) < 1e-4 && rel(v, 29666.234) < 1e-4, "{v} {want}");
}
#[test]
fn contour_flange_takes_tangent_arcs_and_develops_their_bends() {
// A 90° arc after a 30 mm line and before a 20 mm line. The chain turns toward the material,
// so the arc is the outer surface: radius 10 with the bend's inner radius 10 - 2 = 8.
// Developed length = 30 + (π/2)(8 + 0.5·2) + 20.
let mut s = Session::default();
run(&mut s, "sheet.manage_rules", json!({"name": "R", "thickness": 2, "k_factor": 0.5, "bend_radius": 2, "active": true}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Arc"}));
run(&mut s, "sketch.line", json!({"points": [[0, 0], [30, 0]], "ids": ["l1"]}));
let h = 10.0 * std::f64::consts::FRAC_1_SQRT_2;
run(&mut s, "sketch.arc.three_point", json!({"start": [30, 0], "end": [40, -10], "through": [30.0 + h, -10.0 + h], "id": "a1"}));
run(&mut s, "sketch.line", json!({"points": [[40, -10], [40, -30]], "ids": ["l2"]}));
run(&mut s, "sketch.finish", json!({}));
let r = run(&mut s, "sheet.flange", json!({"sketch": "Arc", "curves": ["l1", "a1", "l2"], "distance": 50, "rule": "R"}));
assert!(r.get("error").is_none(), "{r}");
let f = flat(&mut s);
let want = 50.0 + FRAC_PI_2 * (8.0 + 0.5 * 2.0);
let got = f["flat_size_mm"][0].as_f64().unwrap_or(f64::NAN);
assert!((got - want).abs() < 1e-6, "{got} vs {want}: {f}");
let bends = f["bends"].as_array().cloned().unwrap_or_default();
assert_eq!(bends.len(), 1, "{f}");
assert!((bends[0]["radius"].as_f64().unwrap_or(0.0) - 8.0).abs() < 1e-9, "{f}");
assert!((bends[0]["angle_deg"].as_f64().unwrap_or(0.0) - 90.0).abs() < 1e-9, "{f}");
assert!((bends[0]["allowance"].as_f64().unwrap_or(0.0) - FRAC_PI_2 * 9.0).abs() < 1e-9, "{f}");
// A 180° half pipe alone: outer radius 56 and 1.5 thick, with no flat panel before or
// after it.
let mut s = Session::default();
run(&mut s, "sheet.manage_rules", json!({"name": "R", "thickness": 1.5, "k_factor": 0.5, "bend_radius": 1.5, "active": true}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Trough"}));
run(&mut s, "sketch.arc.three_point", json!({"start": [56, 0], "end": [-56, 0], "through": [0, -56], "id": "a1"}));
run(&mut s, "sketch.finish", json!({}));
let r = run(&mut s, "sheet.flange", json!({"sketch": "Trough", "curves": ["a1"], "distance": 100, "rule": "R", "flip": true}));
assert!(r.get("error").is_none(), "{r}");
let want = PI / 2.0 * (56.0f64.powi(2) - 54.5f64.powi(2)) * 100.0;
assert!(rel(volume(&mut s), want) < 1e-6, "{} vs {want}", volume(&mut s));
let b = bbox(&mut s);
assert!((b["min"][1].as_f64().unwrap_or(f64::NAN) + 56.0).abs() < 1e-6, "{b}");
assert!(b["max"][1].as_f64().unwrap_or(f64::NAN).abs() < 1e-6, "{b}");
let f = flat(&mut s);
let want = PI * (54.5 + 0.5 * 1.5);
assert!((f["flat_size_mm"][0].as_f64().unwrap_or(f64::NAN) - want).abs() < 1e-6, "{f}");
}
/// A 150 x 100 base on a 2 mm rule with one 40 mm wall on its left edge: the part a chained
/// flap (#4094) is made on.
fn wall_bracket(s: &mut Session) {
@@ -1298,6 +1341,32 @@ fn fold_on_a_flange_panel() {
assert!(s.execute("sheet.fold", &json!({"points": [[50, y, 2], [50, y, 25]]})).is_err());
}
/// A sharp bend (radius 0) folds the sheet's outer face only: the inner face has collapsed to
/// the edge where the two panels meet, so the body is closed with one cylindrical face per
/// bend and no zero-area inner cylinder. Three 90° folds on a 1.2 mm sheet: each bend zone's
/// flat strip of width BA becomes a quarter disc of radius T.
#[test]
fn a_sharp_bend_has_only_its_outer_cylindrical_face() {
let (t, k, w) = (1.2, 0.44, 60.0);
let ba = FRAC_PI_2 * (k * t);
let mut s = Session::default();
run(&mut s, "sheet.manage_rules", json!({"name": "Sharp", "thickness": t, "k_factor": k, "bend_radius": 0, "active": true}));
base(&mut s, 100.0, w);
let fixed = json!([10, 30, t]);
run(&mut s, "sheet.fold", json!({"points": [[80, 0, t], [80, w, t]], "fixed": fixed, "flip": false, "radius": 0}));
run(&mut s, "sheet.fold", json!({"points": [[60, 0, t], [60, w, t]], "fixed": fixed, "flip": false, "radius": 0}));
run(&mut s, "sheet.fold", json!({"points": [[20, 0, t], [20, w, t]], "fixed": fixed, "flip": true, "radius": 0}));
// The flat strip each bend takes (BA wide) becomes a quarter disc of radius T.
let want = 100.0 * w * t + 3.0 * (FRAC_PI_2 * t * (t / 2.0) * w - ba * t * w);
assert!(rel(volume(&mut s), want) < 1e-4, "{} {want}", volume(&mut s));
let body = &s.model.state().bodies[0].body;
assert!(body.validity().is_empty() && body.shell_count() == 1, "{:?}", body.validity());
let cylinders = body.faces(1e-6).map(|f| f.iter().filter(|f| f.plane_normal.is_none()).count()).unwrap_or(0);
assert_eq!(cylinders, 3, "one outer face per sharp bend, no inner cylinders");
let bends = flat(&mut s)["bends"].clone();
assert_eq!(bends.as_array().map(Vec::len), Some(3), "{bends}");
}
#[test]
fn fold_through_cut_outs() {
let mut s = Session::default();
+198 -11
View File
@@ -20,8 +20,9 @@ use crate::params::{bad, num, req_vec2, str_, string_list};
use crate::schema::sketch_create::{
ArcSlotCenterParams, ArcSlotResult, ArcSlotThreePointParams, ArcTangentParams, BlendCurveParams, CenterlineParams, ConicParams,
ControlPointSplineParams, DrawResult, EditTextParams, EditTextResult, EllipseParams, FitPointSplineParams, InfiniteLineParams, LineTypeResult,
MidpointLineParams, SketchTextParams, SlotCenterPointParams, SplineRemovePointParams, SplineRemovePointResult, SplineReverseParams,
SplineReverseResult, TextChange, TextEditsResult, TextExpressions, TextResult, ThreeTangentCircleParams, TwoTangentCircleParams,
MidpointLineParams, SketchTextParams, SlotCenterPointParams, SplineHandlesParams, SplineHandlesResult, SplineRemovePointParams,
SplineRemovePointResult, SplineResetHandlesParams, SplineResetHandlesResult, SplineReverseParams, SplineReverseResult, TextChange,
TextEditsResult, TextExpressions, TextResult, ThreeTangentCircleParams, TwoTangentCircleParams,
};
use crate::schema::{TypedContract, output_schema_for};
use crate::{EngineError, Result, Session};
@@ -52,6 +53,8 @@ const TEXT_CONTRACT: TypedContract<SketchTextParams> = TypedContract::new();
const EDIT_TEXT_CONTRACT: TypedContract<EditTextParams> = TypedContract::new();
const SPLINE_REVERSE_CONTRACT: TypedContract<SplineReverseParams> = TypedContract::new();
const SPLINE_REMOVE_POINT_CONTRACT: TypedContract<SplineRemovePointParams> = TypedContract::new();
const SPLINE_HANDLES_CONTRACT: TypedContract<SplineHandlesParams> = TypedContract::new();
const SPLINE_RESET_HANDLES_CONTRACT: TypedContract<SplineResetHandlesParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.line.midpoint", "Midpoint Line", crate::checked!("sketch.line.midpoint", midpoint_line))
@@ -122,7 +125,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.enabled(in_sketch)
.contract(&SPLINE_FIT_CONTRACT)
.output(output_schema_for::<DrawResult>)
.params(concat!("points: [[x,y] or point refs…] (2…500): the spline passes through them, periodic?: boolean (default false); periodic cubic fit closes smoothly through at least 3 distinct samples", created_doc!())),
.params(concat!("points: [[x,y] or point refs…] (2…500): the spline passes through them; each fit point has an editable tangent handle whose direction and length control curvature, periodic?: boolean (default false); periodic cubic fit closes smoothly through at least 3 distinct samples", created_doc!())),
CommandSpec::new("sketch.spline.control_point", "Control Point Spline", crate::checked!("sketch.spline.control_point", spline_cv3))
.at("SKETCH", "CREATE")
.icon("spline_cv")
@@ -147,6 +150,18 @@ pub static COMMANDS: &[CommandSpec] = &[
.contract(&SPLINE_REMOVE_POINT_CONTRACT)
.output(output_schema_for::<SplineRemovePointResult>)
.params("curve: spline id, point: one of its points (a point id, or the curve's start or end such as s1.end): the spline runs through (or over) its other points only, and the point goes with the constraints on it unless another curve uses it. Fit point, control point and closed splines. Refused for a closed spline's closing point, for an end joined to other geometry or held by a constraint, when fewer than 2 points would be left (3 on a closed spline), and for a projected, offset or patterned spline. Returns curve, point: the point's id, deleted: whether the point went too"),
CommandSpec::new("sketch.spline.handles", "Add Tangent Handles", crate::checked!("sketch.spline.handles", spline_handles))
.icon("spline")
.enabled(in_sketch)
.contract(&SPLINE_HANDLES_CONTRACT)
.output(output_schema_for::<SplineHandlesResult>)
.params("curve: fit point spline id: gives it a tangent handle at each fit point (a free point joined to it by a construction line) where the curve already runs, so its shape does not change until a handle is moved; moving a fit point moves the handles nobody has turned with it. In the app a selected spline shows its handles and the first drag of one adds them. Refused for a spline that has handles already, a control point, projected, offset, patterned or equation spline. Returns curve, handles: the handle point ids in fit point order"),
CommandSpec::new("sketch.spline.reset_handles", "Reset Tangent Handles", crate::checked!("sketch.spline.reset_handles", spline_reset_handles))
.icon("spline")
.enabled(in_sketch)
.contract(&SPLINE_RESET_HANDLES_CONTRACT)
.output(output_schema_for::<SplineResetHandlesResult>)
.params("curve: fit point spline id: removes its tangent handles (the handle points, their construction lines and the constraints on them), so the curve runs through its fit points as if the handles had never been moved; selected, it shows them again where they start. Refused for a spline without tangent handles. Returns curve, removed: the removed handle point ids in fit point order"),
CommandSpec::new("sketch.spline.reverse", "Reverse Spline Direction", crate::checked!("sketch.spline.reverse", spline_reverse))
.icon("spline")
.enabled(in_sketch)
@@ -619,7 +634,10 @@ fn blend(s: &mut Session, p: &Value) -> Result<Value> {
let (sp, cons) = if order <= 1 {
let c1 = sk.add_point(qa + ta * (l / 3.0), None)?;
let c2 = sk.add_point(qb + tb * (l / 3.0), None)?;
let sp = sk.add_curve(CurveKind::Spline { pts: vec![pa, c1, c2, pb], control: true, degree: 3, periodic: false, nurbs: None }, None)?;
let sp = sk.add_curve(
CurveKind::Spline { pts: vec![pa, c1, c2, pb], control: true, degree: 3, periodic: false, nurbs: None, handles: Vec::new() },
None,
)?;
let cons = vec![add_c(sk, ConstraintKind::Tangent { a: ca, b: sp })?, add_c(sk, ConstraintKind::Tangent { a: cb, b: sp })?];
(sp, cons)
} else {
@@ -641,7 +659,7 @@ fn blend(s: &mut Session, p: &Value) -> Result<Value> {
});
}
let degree = u8::try_from(cps.len().saturating_sub(1)).map_err(|_| bad(cmd, "the blend curve needs too many control points"))?;
let sp = sk.add_curve(CurveKind::Spline { pts, control: true, degree, periodic: false, nurbs: None }, None)?;
let sp = sk.add_curve(CurveKind::Spline { pts, control: true, degree, periodic: false, nurbs: None, handles: Vec::new() }, None)?;
(sp, Vec::new())
};
solve_new_only(sk, first_new);
@@ -795,7 +813,7 @@ fn spline(s: &mut Session, p: &Value, cmd: &str, control: bool, degree: u8) -> R
{
pts.push(*pts.first().ok_or_else(|| bad(cmd, "missing first point"))?);
}
let c = sk.add_curve(CurveKind::Spline { pts, control, degree, periodic, nurbs: None }, None)?;
let c = sk.add_curve(CurveKind::Spline { pts, control, degree, periodic, nurbs: None, handles: Vec::new() }, None)?;
mark_construction(sk, &[c], p);
Ok((ids_of(sk, &[c]), Vec::new()))
})?;
@@ -850,6 +868,58 @@ fn spline_remove_point(s: &mut Session, p: &Value) -> Result<Value> {
serde_json::to_value(reply).map_err(|e| EngineError::Other(e.to_string()))
}
fn spline_handles(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.spline.handles";
let id = str_(p, "curve").ok_or_else(|| bad(cmd, "`curve` must be a spline id"))?.to_string();
let (handles, info) = edit(s, p, cmd, false, |sk, _| {
let c = sk.curve_index(&id).ok_or_else(|| bad(cmd, format!("unknown curve `{id}`")))?;
if matches!(sk.curves.get(c).map(|x| &x.kind), Some(CurveKind::Spline { handles, .. }) if !handles.is_empty()) {
return Err(bad(cmd, format!("`{id}` already has tangent handles")));
}
sk.add_spline_handles(c).map_err(|e| bad(cmd, e.to_string()))?;
Ok(match sk.curves.get(c).map(|x| &x.kind) {
Some(CurveKind::Spline { handles, .. }) => handles.iter().filter_map(|h| sk.points.get(*h).map(|p| p.id.clone())).collect(),
_ => Vec::new(),
})
})?;
let reply = SplineHandlesResult { curve: id, handles, sketch: info, param_warnings: Vec::new() };
serde_json::to_value(reply).map_err(|e| EngineError::Other(e.to_string()))
}
fn spline_reset_handles(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.spline.reset_handles";
let id = str_(p, "curve").ok_or_else(|| bad(cmd, "`curve` must be a spline id"))?.to_string();
let (removed, info) = edit(s, p, cmd, false, |sk, _| {
let c = sk.curve_index(&id).ok_or_else(|| bad(cmd, format!("unknown curve `{id}`")))?;
let (spline, handles) = match sk.curves.get(c) {
Some(curve) => match &curve.kind {
CurveKind::Spline { handles, .. } => (curve.id.clone(), handles.clone()),
_ => return Err(bad(cmd, format!("`{id}` is not a spline"))),
},
None => return Err(bad(cmd, format!("unknown curve `{id}`"))),
};
if handles.is_empty() {
return Err(bad(cmd, format!("`{id}` has no tangent handles")));
}
let removed: Vec<String> = handles.iter().filter_map(|h| sk.points.get(*h).map(|q| q.id.clone())).collect();
// Removing the construction lines takes the handles with them: the spline lets go of its
// handle points, which then belong to no curve and go with the constraints on them.
let guides: Vec<usize> =
sk.curves.iter().enumerate().filter(|(_, x)| x.spline_handle_of.as_deref() == Some(spline.as_str())).map(|(i, _)| i).collect();
sk.remove_curves(&guides);
let cleared = sk
.curve_index(&spline)
.and_then(|i| sk.curves.get(i))
.is_some_and(|x| matches!(&x.kind, CurveKind::Spline { handles, .. } if handles.is_empty()));
if !cleared || removed.iter().any(|h| sk.resolve_point(h).is_some()) {
return Err(bad(cmd, format!("`{id}`'s tangent handles could not be removed")));
}
Ok(removed)
})?;
let reply = SplineResetHandlesResult { curve: id, removed, sketch: info, param_warnings: Vec::new() };
serde_json::to_value(reply).map_err(|e| EngineError::Other(e.to_string()))
}
fn spline_reverse(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.spline.reverse";
let id = str_(p, "curve").ok_or_else(|| bad(cmd, "`curve` must be a spline id"))?.to_string();
@@ -934,12 +1004,21 @@ pub(super) fn leaving_dir(sk: &Sketch, ci: usize, pi: usize) -> Option<Vec2> {
None
}
}
CurveKind::Spline { ref pts, control, periodic, .. } => {
CurveKind::Spline { ref pts, control, periodic, ref handles, .. } => {
let q: Vec<Vec2> = pts.iter().filter_map(|i| sk.point(*i)).collect();
let tangent_handles: Vec<Vec2> = handles.iter().filter_map(|i| sk.point(*i)).collect();
if pts.last() == Some(&pi) {
solidcraft_sketch::spline_end_tangent_mode(&q, control, periodic, true)
if handles.is_empty() {
solidcraft_sketch::spline_end_tangent_mode(&q, control, periodic, true)
} else {
solidcraft_sketch::spline_end_tangent_with_handles(&q, &tangent_handles, periodic, true)
}
} else if pts.first() == Some(&pi) {
solidcraft_sketch::spline_end_tangent_mode(&q, control, periodic, false).map(|t| -t)
if handles.is_empty() {
solidcraft_sketch::spline_end_tangent_mode(&q, control, periodic, false).map(|t| -t)
} else {
solidcraft_sketch::spline_end_tangent_with_handles(&q, &tangent_handles, periodic, false).map(|t| -t)
}
} else {
None
}
@@ -1321,6 +1400,110 @@ mod blend_tests {
s.model.state().sketch(id).unwrap().sketch.clone()
}
/// #6502: Reset Tangent Handles takes a spline's handles, their construction lines and the
/// constraints on them away, so the curve runs through its fit points as before.
#[test]
fn reset_tangent_handles_gives_back_the_spline_through_its_fit_points() {
let mut s = new_sketch();
let spline_id =
ids(&run(&mut s, "sketch.spline.fit_point", json!({"points": [[-40, 0], [-15, 20], [15, -10], [40, 10]]}))["curves"])[0].clone();
assert!(s.execute("sketch.spline.reset_handles", &json!({"curve": spline_id})).is_err(), "nothing to reset yet");
let constraints = sketch(&s).constraints.len();
let points = sketch(&s).points.len();
let given = run(&mut s, "sketch.spline.handles", json!({"curve": spline_id}));
let turned = given["handles"][1].as_str().unwrap().to_string();
let at = {
let sk = sketch(&s);
sk.resolve_point(&turned).and_then(|i| sk.point(i)).unwrap()
};
run(&mut s, "sketch.drag", json!({"entity": turned, "from": [at.x, at.y], "to": [at.x - 3.0, at.y + 8.0]}));
run(&mut s, "sketch.drag_end", json!({}));
let guide = sketch(&s).curves.iter().find(|c| c.spline_handle_of.as_deref() == Some(spline_id.as_str())).unwrap().id.clone();
run(&mut s, "sketch.dimension", json!({"entities": [guide], "value": 6}));
assert!(sketch(&s).constraints.len() > constraints, "the guide is dimensioned");
// The dimension may move the fit point too; the reset keeps the fit points where they are.
let fit_before: Vec<Vec2> = {
let sk = sketch(&s);
let CurveKind::Spline { pts, .. } = &sk.curves[sk.curve_index(&spline_id).unwrap()].kind else { panic!("a fit spline") };
pts.iter().map(|p| sk.point(*p).unwrap()).collect()
};
let reset = run(&mut s, "sketch.spline.reset_handles", json!({"curve": spline_id}));
assert_eq!(reset["removed"].as_array().map(Vec::len), Some(4), "{reset}");
let sk = sketch(&s);
let CurveKind::Spline { pts, handles, .. } = &sk.curves[sk.curve_index(&spline_id).unwrap()].kind else { panic!("a fit spline") };
assert!(handles.is_empty(), "no handles left");
assert!(sk.curves.iter().all(|c| c.spline_handle_of.is_none()), "no tangent lines left");
assert!(reset["removed"].as_array().unwrap().iter().all(|h| sk.resolve_point(h.as_str().unwrap()).is_none()), "the handle points went");
assert_eq!(sk.points.len(), points);
assert_eq!(sk.constraints.len(), constraints, "the guide's dimension went with it");
let fit_after: Vec<Vec2> = pts.iter().map(|p| sk.point(*p).unwrap()).collect();
assert_eq!(fit_after, fit_before, "the fit points stay");
drop(sk);
assert!(s.execute("sketch.spline.reset_handles", &json!({"curve": spline_id})).is_err(), "twice is refused");
run(&mut s, "edit.undo", json!({}));
let sk = sketch(&s);
assert!(
matches!(&sk.curves[sk.curve_index(&spline_id).unwrap()].kind, CurveKind::Spline { handles, .. } if handles.len() == 4),
"undo brings them back"
);
}
#[test]
fn fit_spline_handles_are_visible_lines_and_accept_existing_constraints() {
let mut s = new_sketch();
let spline_id = ids(&run(&mut s, "sketch.spline.fit_point", json!({"points": [[0, 0], [8, 6], [17, 2], [25, 10]]}))["curves"])[0].clone();
assert!(
matches!(&sketch(&s).curves[sketch(&s).curve_index(&spline_id).unwrap()].kind, CurveKind::Spline { handles, .. } if handles.is_empty()),
"no handles until asked"
);
let given = run(&mut s, "sketch.spline.handles", json!({"curve": spline_id}));
assert_eq!(given["handles"].as_array().map(Vec::len), Some(4), "{given}");
assert!(s.execute("sketch.spline.handles", &json!({"curve": spline_id})).is_err(), "twice is refused");
let sk = sketch(&s);
let spline_index = sk.curve_index(&spline_id).unwrap();
let CurveKind::Spline { pts, handles, .. } = &sk.curves[spline_index].kind else { panic!("expected a fit spline") };
assert_eq!(handles.len(), pts.len());
let tangent_lines: Vec<_> = sk.curves.iter().filter(|curve| curve.spline_handle_of.as_deref() == Some(spline_id.as_str())).collect();
assert_eq!(tangent_lines.len(), pts.len());
assert!(tangent_lines.iter().all(|curve| curve.construction));
let handle_id = tangent_lines[0].id.clone();
let angle_handle_id = tangent_lines[1].id.clone();
let final_handle = *handles.last().unwrap();
let final_fit_point = *pts.last().unwrap();
assert!(
matches!(tangent_lines.last().map(|curve| &curve.kind), Some(CurveKind::Line { a, b }) if *a == final_handle && *b == final_fit_point)
);
drop(sk);
let before = s.doc.to_json();
assert!(
s.execute("sketch.trim", &json!({"curve": &handle_id, "at": [4, 3]})).is_err(),
"a tangent construction line cannot be trimmed independently of its spline"
);
assert_eq!(s.doc.to_json(), before);
let reference_id = ids(&run(&mut s, "sketch.line", json!({"points": [[40, 0], [40, 20]]}))["curves"])[0].clone();
run(&mut s, "sketch.constraint.perpendicular", json!({"a": handle_id, "b": reference_id}));
let solved = sketch(&s);
let handle = solved.curve_index(&handle_id).unwrap();
let reference = solved.curve_index(&reference_id).unwrap();
let direction = |index: usize| match solved.curves[index].kind {
CurveKind::Line { a, b } => solved.point(b).unwrap() - solved.point(a).unwrap(),
ref other => panic!("expected a line, got {other:?}"),
};
assert!(direction(handle).dot(direction(reference)).abs() < 1e-7);
let angle_reference = ids(&run(&mut s, "sketch.line", json!({"points": [[50, 0], [50, 20]]}))["curves"])[0].clone();
run(&mut s, "sketch.dimension", json!({"entities": [angle_handle_id, angle_reference], "type": "angle", "value": "90 deg"}));
let sketch = sketch(&s);
assert!(
sketch
.constraints
.iter()
.any(|constraint| { matches!(constraint.kind, solidcraft_sketch::ConstraintKind::Angle { .. }) && constraint.param.is_some() })
);
}
fn factorial(n: usize) -> f64 {
(1..=n).fold(1.0, |a, b| a * b as f64)
}
@@ -1610,13 +1793,17 @@ mod blend_tests {
let mut broken = solidcraft_sketch::Sketch::new();
let p0 = broken.add_point(Vec2::new(1.0, 1.0), None).unwrap();
let p1 = broken.add_point(Vec2::ZERO, None).unwrap();
let l = broken.add_curve(CurveKind::Spline { pts: vec![p1, p0], control: true, degree: 1, periodic: false, nurbs: None }, None).unwrap();
let l = broken
.add_curve(CurveKind::Spline { pts: vec![p1, p0], control: true, degree: 1, periodic: false, nurbs: None, handles: Vec::new() }, None)
.unwrap();
broken.points[p0].pos = Vec2::new(f64::NAN, 4.0);
assert!(super::joint_derivatives(&broken, l, p1, 2, true, "a", "sketch.blend_curve").is_err(), "non-finite geometry");
let mut flat = solidcraft_sketch::Sketch::new();
let q0 = flat.add_point(Vec2::new(3.0, 3.0), None).unwrap();
let q1 = flat.add_point(Vec2::new(3.0, 3.0), None).unwrap();
let d = flat.add_curve(CurveKind::Spline { pts: vec![q0, q1], control: true, degree: 1, periodic: false, nurbs: None }, None).unwrap();
let d = flat
.add_curve(CurveKind::Spline { pts: vec![q0, q1], control: true, degree: 1, periodic: false, nurbs: None, handles: Vec::new() }, None)
.unwrap();
let e = super::joint_derivatives(&flat, d, q0, 2, true, "a", "sketch.blend_curve").unwrap_err().to_string();
assert!(e.contains("no tangent"), "{e}");
assert!(solidcraft_geom::hermite_bezier(Vec2::ZERO, Vec2::X, &[Vec2::Y; 5], &[Vec2::Y; 5]).is_none(), "no order above g4");
+11
View File
@@ -171,6 +171,15 @@ pub(super) fn drag_entity(
let Some((path, hold, n, short)) = prepare(sk, e, with, from, to) else {
return (Dragged::default(), Left::Unsolved);
};
// Splines whose tangent handles nobody turned stay the natural curve through their moved fit
// points: their handles follow each step. Dragging a handle itself turns it.
let natural: Vec<usize> = {
let moved: Vec<usize> = path.points().collect();
sk.natural_handle_splines()
.into_iter()
.filter(|&c| !matches!(sk.curves.get(c).map(|x| &x.kind), Some(CurveKind::Spline { handles, .. }) if handles.iter().any(|h| moved.contains(h))))
.collect()
};
// A point dragged alone that ends tangent lines: each step first tries with their other ends
// held too, and only when that can't be solved lets them move. Unheld, the solver spread its
// correction over every free point, so the line's far end moved (towards the dragged one)
@@ -196,6 +205,7 @@ pub(super) fn drag_entity(
// Whether the sketch follows step `t` (with the tangent lines' far ends held, else free).
let held = |sk: &mut Sketch, last: &Geometry, t: f64, budget: &mut Budget| {
path.apply(sk, t);
sk.renew_natural_handles(&natural);
let kept = keep.as_ref().is_some_and(|keep| {
let ok = counted_solve(sk, keep, Some(&last.pos), Analysis::StatusOnly, Some(&mut *budget)).ok()
&& !last.sweep_wrapped(sk)
@@ -203,6 +213,7 @@ pub(super) fn drag_entity(
if !ok {
last.restore(sk);
path.apply(sk, t);
sk.renew_natural_handles(&natural);
}
ok
});
@@ -462,3 +462,44 @@ fn a_live_gesture_projects_a_face_sketchs_links_once() {
live_frame(&mut s, &point, at, 0.5);
assert_eq!(refresh_count::take(), 1);
}
/// A dragged fit point of a spline whose tangent handles nobody turned keeps it the natural
/// curve: its handles follow (#6502). A turned handle stays where it was put.
#[test]
fn untouched_spline_handles_follow_a_dragged_fit_point() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
let id = run(&mut s, "sketch.spline.fit_point", json!({"points": [[0, 0], [10, 8], [20, 0], [30, 6]], "connect": false}))["curves"][0]
.as_str()
.expect("a spline")
.to_owned();
run(&mut s, "sketch.spline.handles", json!({"curve": id}));
let fit = |s: &Session| {
let sk = sketch_of(s);
let ci = sk.curve_index(&id).expect("the spline");
match &sk.curves[ci].kind {
solidcraft_sketch::CurveKind::Spline { pts, handles, .. } => (pts.clone(), handles.clone()),
_ => panic!("a spline"),
}
};
let (pts, handles) = fit(&s);
let name = sketch_of(&s).points[pts[1]].id.clone();
run(&mut s, "sketch.drag", json!({"entity": name, "from": [10, 8], "to": [11, 10], "live": true}));
let sk = sketch_of(&s);
let at: Vec<_> = pts.iter().map(|i| sk.points[*i].pos).collect();
assert!(at[1].dist(solidcore_record::Vec2::new(11.0, 10.0)) < 1e-6, "{:?}", at[1]);
let natural = solidcraft_sketch::fit_tangent_handles(&at, false).expect("handles");
for (h, n) in handles.iter().zip(&natural) {
assert!(sk.points[*h].pos.dist(*n) < 1e-6, "an untouched handle follows the curve");
}
// Turn the last handle; dragging a fit point now leaves every handle where it is.
let turned = sk.points[handles[3]].id.clone();
let from = sk.points[handles[3]].pos;
run(&mut s, "sketch.drag", json!({"entity": turned, "from": [from.x, from.y], "to": [from.x + 2.0, from.y + 3.0]}));
let before: Vec<_> = handles.iter().map(|h| sketch_of(&s).points[*h].pos).collect();
run(&mut s, "sketch.drag", json!({"entity": name, "from": [11, 10], "to": [12, 11], "live": true}));
let sk = sketch_of(&s);
for (h, b) in handles.iter().zip(&before) {
assert!(sk.points[*h].pos.dist(*b) < 1e-6, "a turned spline keeps its handles");
}
}
+2 -2
View File
@@ -161,7 +161,7 @@ fn add_pieces(sk: &mut Sketch, segs: &[Seg2], start: Option<usize>, end: Option<
Seg2::Line { .. } => CurveKind::Line { a: prev, b: to },
Seg2::Cubic { p1, p2, .. } => {
let (c1, c2) = (sk.add_point(p1, None)?, sk.add_point(p2, None)?);
CurveKind::Spline { pts: vec![prev, c1, c2, to], control: true, degree: 3, periodic: false, nurbs: None }
CurveKind::Spline { pts: vec![prev, c1, c2, to], control: true, degree: 3, periodic: false, nurbs: None, handles: Vec::new() }
}
Seg2::Conic { apex, w, .. } => {
let x = sk.add_point(apex, None)?;
@@ -174,7 +174,7 @@ fn add_pieces(sk: &mut Sketch, segs: &[Seg2], start: Option<usize>, end: Option<
pts.push(sk.add_point(*q, None)?);
}
pts.push(to);
CurveKind::Spline { pts, control: true, degree: n, periodic: false, nurbs: None }
CurveKind::Spline { pts, control: true, degree: n, periodic: false, nurbs: None, handles: Vec::new() }
}
Seg2::Arc { .. } => return Err(bad("sketch.trim", "unexpected arc piece")),
};
+8 -1
View File
@@ -146,7 +146,14 @@ pub(super) fn add_geometry(sk: &mut Sketch, geom: &[Geom2], f: &dyn Fn(Vec2) ->
idx.push(i);
}
}
(idx.len() >= 2).then_some(CurveKind::Spline { pts: idx, control: *control, degree: *degree, periodic: false, nurbs: None })
(idx.len() >= 2).then_some(CurveKind::Spline {
pts: idx,
control: *control,
degree: *degree,
periodic: false,
nurbs: None,
handles: Vec::new(),
})
}
Geom2::Conic { a, apex, b, rho } => {
let (a, x, b) = (pt(sk, *a)?, pt(sk, *apex)?, pt(sk, *b)?);
+121 -18
View File
@@ -184,6 +184,9 @@ fn curve_arg(sk: &Sketch, p: &Value, cmd: &str) -> Result<usize> {
fn curve_by_id(sk: &Sketch, id: &str, cmd: &str) -> Result<usize> {
let c = sk.curve_index(id).ok_or_else(|| bad(cmd, format!("unknown curve `{id}`")))?;
if sk.curves.get(c).is_some_and(|curve| curve.spline_handle_of.is_some()) {
return Err(bad(cmd, "spline tangent construction lines cannot be trimmed, split, or extended"));
}
if sk.is_linked_curve(c) {
let action = match cmd {
"sketch.trim" => "Trim",
@@ -305,7 +308,17 @@ fn split_at(sk: &mut Sketch, ci: usize, ts: &[f64], cmd: &str) -> Result<(Vec<us
let mut pieces = vec![ci];
let mut push = |sk: &mut Sketch, kind: CurveKind, prefix: &str| -> Result<()> {
let id = sk.fresh(prefix)?;
sk.curves.push(Curve { id, kind, construction, reversed: false, link: None, centerline: false, fixed: false, infinite: false });
sk.curves.push(Curve {
id,
kind,
construction,
reversed: false,
link: None,
centerline: false,
fixed: false,
infinite: false,
spline_handle_of: None,
});
pieces.push(sk.curves.len() - 1);
Ok(())
};
@@ -1140,7 +1153,7 @@ fn line_end(sk: &Sketch, ci: usize, toward: Vec2) -> Result<Option<LineEnd>> {
// A neighbour running on the same way as the extension has it nowhere to go.
for (oi, c) in sk.curves.iter().enumerate() {
if oi == ci || !uses(c, end) {
if oi == ci || c.spline_handle_of.is_some() || !uses(c, end) {
continue;
}
// Arcs do not block a line: the line runs past the point they share.
@@ -1167,7 +1180,7 @@ fn line_aim(sk: &Sketch, ci: usize, at: Vec2) -> Result<Option<LineAim>> {
let mut budget = 1_000_000usize;
let mut segment_count = 0usize;
for (oi, other) in sk.curves.iter().enumerate() {
if oi == ci {
if oi == ci || other.spline_handle_of.is_some() {
continue;
}
let mut pieces = sk.exact_segs(oi).unwrap_or_else(|| sk.segs(oi));
@@ -1301,7 +1314,7 @@ fn arc_aim(sk: &Sketch, ci: usize, at: Vec2, cmd: &str) -> Result<Option<ArcAim>
let mut budget = 1_000_000usize;
let mut segment_count = 0usize;
for (oi, curve) in sk.curves.iter().enumerate() {
if oi == ci {
if oi == ci || curve.spline_handle_of.is_some() {
continue;
}
// A circle passes over arcs lying on it, so no intersection reports them: an arc on the
@@ -1411,7 +1424,7 @@ fn stop_point(sk: &Sketch, ci: usize, q: Vec2) -> Option<usize> {
sk.curves
.iter()
.enumerate()
.filter(|(i, _)| *i != ci)
.filter(|(i, c)| *i != ci && c.spline_handle_of.is_none())
.find_map(|(_, c)| {
let (a, b) = c.kind.ends()?;
[a, b].into_iter().find(|p| sk.point(*p).is_some_and(|pt| pt.dist(q) <= TOL * 10.0))
@@ -1553,7 +1566,7 @@ fn extend(s: &mut Session, p: &Value) -> Result<Value> {
fn grow_line(sk: &mut Sketch, ci: usize, aim: LineAim) -> Result<String> {
let end = aim.end;
let (q, cutter, stop) = (aim.hit, aim.cutter, aim.stop);
let neighbors: Vec<_> = sk.curves.iter().enumerate().filter(|(i, c)| *i != ci && uses(c, end)).collect();
let neighbors: Vec<_> = sk.curves.iter().enumerate().filter(|(i, c)| *i != ci && c.spline_handle_of.is_none() && uses(c, end)).collect();
// The end joined to other geometry stays where it is: add a line from it.
if !neighbors.is_empty() {
@@ -1984,7 +1997,12 @@ fn meet_lines(sk: &mut Sketch, p: &Value, cmd: &str) -> Result<()> {
// The end on the shorter side of the meeting point moves (the nearer end, for a line that
// stops short of it).
let (na, nb) = (if t >= 0.5 { a1 } else { a0 }, if u >= 0.5 { b1 } else { b0 });
// An end already at the meeting point does not move, so what holds it does not matter.
let at_meet = |sk: &Sketch, e: usize| sk.point(e).is_some_and(|q| q.dist(meet) <= 1e-9 * (1.0 + meet.len()));
for (l, e) in [(la, na), (lb, nb)] {
if at_meet(sk, e) {
continue;
}
let id = sk.curves.get(l).map(|c| c.id.clone()).unwrap_or_default();
let other_curve = sk.curves.iter().enumerate().any(|(i, c)| i != l && c.kind.uses(e));
let positioned = sk.points.get(e).is_some_and(|q| q.fixed || q.exprs.is_some() || q.link.is_some());
@@ -2004,11 +2022,64 @@ fn meet_lines(sk: &mut Sketch, p: &Value, cmd: &str) -> Result<()> {
q.pos = meet;
}
}
// One corner point: b's moved end becomes a's.
for c in sk.curves.iter_mut() {
c.kind.map_points(&|x| if x == nb { na } else { x });
// One corner point: b's moved end becomes a's, and what held b's end holds the corner. The
// sketch origin is never the one merged away: it stays the corner, so what else ends there
// keeps sharing it.
let (keep, gone) = if nb == 0 { (nb, na) } else { (na, nb) };
let placed = |sk: &Sketch, e: usize| sk.points.get(e).map(|q| (q.exprs.clone(), q.link.clone())).unwrap_or_default();
let (keep_placed, gone_placed) = (placed(sk, keep), placed(sk, gone));
// The merged-away end's placement must be one the kept end already has, or take on (the
// origin takes on none).
fn clash<T: PartialEq>(keep_is_origin: bool, k: &Option<T>, g: &Option<T>) -> bool {
g.is_some() && (keep_is_origin || k.is_some()) && k != g
}
sk.remove_points(&[nb]);
// Nor may one end bring an expression placement and the other a link: the expressions would
// overwrite where the linked model vertex moves the corner to.
let kinds = |(e, l): &(Option<Box<solidcraft_sketch::PointExprs>>, Option<String>)| (e.is_some(), l.is_some());
let mixed = |a: (bool, bool), b: (bool, bool)| a == (true, false) && b == (false, true);
let (keep_kinds, gone_kinds) = (kinds(&keep_placed), kinds(&gone_placed));
if clash(keep == 0, &keep_placed.0, &gone_placed.0)
|| clash(keep == 0, &keep_placed.1, &gone_placed.1)
|| mixed(keep_kinds, gone_kinds)
|| mixed(gone_kinds, keep_kinds)
{
return Err(bad(
cmd,
"the lines' ends at the corner are each placed by their own expressions or reference, and one corner point can't follow both",
));
}
if let Some(held) = sk.points.get(gone).map(|q| (q.fixed, q.exprs.clone(), q.link.clone()))
&& let Some(q) = sk.points.get_mut(keep)
{
q.fixed |= held.0;
if q.exprs.is_none() {
q.exprs = held.1;
}
if q.link.is_none() {
q.link = held.2;
}
}
for c in sk.curves.iter_mut() {
c.kind.map_points(&|x| if x == gone { keep } else { x });
}
// Only a constraint the move changed can now repeat another: such a repeat is said once. Equal
// constraints elsewhere in the sketch are not this fillet's to touch.
let mut moved: Vec<(solidcraft_sketch::Constraint, bool)> = Vec::with_capacity(sk.constraints.len());
for mut k in std::mem::take(&mut sk.constraints) {
let before = k.kind.clone();
k.kind.remap(&|x| Some(if x == gone { keep } else { x }), &Some);
let changed = k.kind != before;
moved.push((k, changed));
}
let same = |a: &solidcraft_sketch::Constraint, b: &solidcraft_sketch::Constraint| a.kind == b.kind && a.param == b.param;
let repeats: Vec<bool> = moved
.iter()
.enumerate()
.map(|(i, (k, changed))| *changed && moved.iter().enumerate().any(|(j, (o, o_changed))| j != i && same(k, o) && (j < i || !*o_changed)))
.collect();
let kept: Vec<solidcraft_sketch::Constraint> = moved.into_iter().zip(repeats).filter(|(_, repeat)| !repeat).map(|((k, _), _)| k).collect();
sk.constraints = kept;
sk.remove_points(&[gone]);
Ok(())
}
@@ -2824,7 +2895,11 @@ fn offset_side(sk: &mut Sketch, cmd: &str, order: &[(usize, bool)], sources: &[(
idx.push(pb);
idx.dedup();
if idx.len() >= 2 {
made.push(sk.add_curve(CurveKind::Spline { pts: idx, control: false, degree: 3, periodic: false, nurbs: None }, None)?);
let spline = sk.add_curve(
CurveKind::Spline { pts: idx, control: false, degree: 3, periodic: false, nurbs: None, handles: Vec::new() },
None,
)?;
made.push(spline);
}
}
Piece::Line(..) => {
@@ -3515,10 +3590,25 @@ fn copy_through(
keep: &dyn Fn(Vec2) -> bool,
scale_r: f64,
) -> Result<(Vec<usize>, Vec<(usize, usize)>)> {
if sk.curves.len().saturating_add(cs.len()) > solidcraft_sketch::limits::max_curves() {
let mut sources = cs.to_vec();
let spline_ids: std::collections::BTreeSet<String> = cs
.iter()
.filter_map(|index| sk.curves.get(*index))
.filter(|curve| matches!(curve.kind, CurveKind::Spline { .. }))
.map(|curve| curve.id.clone())
.collect();
sources.extend(
sk.curves
.iter()
.enumerate()
.filter_map(|(index, curve)| curve.spline_handle_of.as_ref().is_some_and(|parent| spline_ids.contains(parent)).then_some(index)),
);
let mut seen = std::collections::BTreeSet::new();
sources.retain(|source| seen.insert(*source));
if sk.curves.len().saturating_add(sources.len()) > solidcraft_sketch::limits::max_curves() {
return Err(solidcraft_sketch::SketchError::TooLarge.into());
}
let pts = involved_points(sk, cs, ps);
let pts = involved_points(sk, &sources, ps);
let mut map: Vec<(usize, usize)> = Vec::new();
for q in &pts {
let pos = sk.point(*q).unwrap_or_default();
@@ -3527,10 +3617,13 @@ fn copy_through(
}
let m = |q: usize| map.iter().find(|x| x.0 == q).map(|x| x.1).unwrap_or(q);
let mut made = Vec::new();
for c in cs {
let mut clones = Vec::with_capacity(sources.len());
let mut curve_map = std::collections::BTreeMap::new();
for c in &sources {
let Some(cu) = sk.curves.get(*c).cloned() else { continue };
let mut kind = cu.kind.clone();
kind.map_points(&m);
let helper_line = cu.spline_handle_of.is_some();
let (kind, prefix) = match cu.kind {
CurveKind::Ellipse { r, .. } => {
if let CurveKind::Ellipse { r: slot, .. } = &mut kind {
@@ -3540,7 +3633,7 @@ fn copy_through(
}
CurveKind::Spline { .. } => (kind, "s"),
CurveKind::Conic { .. } => (kind, "n"),
CurveKind::Line { a, b } => (CurveKind::Line { a: m(a), b: m(b) }, "l"),
CurveKind::Line { a, b } => (CurveKind::Line { a: m(a), b: m(b) }, if helper_line { "h" } else { "l" }),
CurveKind::Circle { c, r } => (CurveKind::Circle { c: m(c), r: r * scale_r }, "c"),
CurveKind::Arc { c, a, b } => {
if flip {
@@ -3553,7 +3646,7 @@ fn copy_through(
let id = sk.fresh(prefix)?;
// A mirrored or patterned infinite line is an infinite line too (#1359).
let infinite = cu.infinite && matches!(kind, CurveKind::Line { .. });
sk.curves.push(Curve {
let clone = Curve {
id,
kind,
construction: cu.construction,
@@ -3562,7 +3655,14 @@ fn copy_through(
centerline: false,
fixed: false,
infinite,
});
spline_handle_of: cu.spline_handle_of.clone(),
};
curve_map.insert(cu.id, clone.id.clone());
clones.push(clone);
}
for clone in &mut clones {
clone.spline_handle_of = clone.spline_handle_of.as_ref().and_then(|parent| curve_map.get(parent).cloned());
sk.curves.push(clone.clone());
made.push(sk.curves.len() - 1);
}
Ok((made, map))
@@ -3620,7 +3720,10 @@ fn mirror(s: &mut Session, p: &Value) -> Result<Value> {
cons.push(add_c(sk, ConstraintKind::Equal { a: *o, b: *n })?);
}
}
Ok((ids_of(sk, &made), cons))
let mut selected = std::collections::BTreeSet::new();
let selected_count = cs.iter().filter(|source| selected.insert(**source)).count();
let reported = made.get(..selected_count).ok_or_else(|| bad(cmd, "copied curves changed while mirroring"))?;
Ok((ids_of(sk, reported), cons))
})?;
reply(cmd, MirrorResult { curves: out.0, constraints: out.1, sketch: info, param_warnings: Vec::new() })
}
+121 -10
View File
@@ -2019,7 +2019,8 @@ fn a_point_added_to_a_spline_keeps_its_constraints_and_undoes() {
run(&mut s, "sketch.line", json!({"points": [format!("{sid}.end"), [40, 6]], "connect": false}));
let sk = sketch(&s);
let si = sk.curve_index(&sid).unwrap();
let old = sk.curves[si].kind.point_ids();
let fit_ids = |k: &CurveKind| if let CurveKind::Spline { pts, .. } = k { pts.clone() } else { Vec::new() };
let old = fit_ids(&sk.curves[si].kind);
let names: Vec<String> = old.iter().map(|i| sk.points[*i].id.clone()).collect();
run(&mut s, "sketch.dimension", json!({"entities": [names[0], names[1]], "value": 12.8}));
let before = sketch(&s);
@@ -2031,7 +2032,7 @@ fn a_point_added_to_a_spline_keeps_its_constraints_and_undoes() {
let out = run(&mut s, "sketch.spline.insert_point", json!({"curve": sid, "at": [15, 6]}));
let after = sketch(&s);
let new = after.point_index(out["point"].as_str().unwrap()).unwrap();
let now = after.curves[si].kind.point_ids();
let now = fit_ids(&after.curves[si].kind);
assert_eq!(now.len(), old.len() + 1);
assert_eq!(now[2], new, "between the second and third fit points: {now:?}");
let q = after.point(new).unwrap();
@@ -2077,7 +2078,9 @@ fn a_point_removed_from_a_spline_goes_with_its_dimension_and_undoes() {
run(&mut s, "sketch.line", json!({"points": [format!("{sid}.end"), [40, 6]], "connect": false}));
let sk = sketch(&s);
let si = sk.curve_index(&sid).unwrap();
let names: Vec<String> = sk.curves[si].kind.point_ids().iter().map(|i| sk.points[*i].id.clone()).collect();
// Its fit points, not their tangent handles (#6502).
let CurveKind::Spline { pts, .. } = &sk.curves[si].kind else { panic!("a spline") };
let names: Vec<String> = pts.iter().map(|i| sk.points[*i].id.clone()).collect();
run(&mut s, "sketch.dimension", json!({"entities": [names[1], names[2]], "value": 12.8}));
let before = sketch(&s);
let at = |sk: &solidcraft_sketch::Sketch, id: &str| sk.point(sk.point_index(id).unwrap()).unwrap();
@@ -2086,7 +2089,8 @@ fn a_point_removed_from_a_spline_goes_with_its_dimension_and_undoes() {
assert_eq!((r["curve"].as_str(), r["point"].as_str(), r["deleted"].as_bool()), (Some(sid.as_str()), Some(names[1].as_str()), Some(true)), "{r}");
let after = sketch(&s);
assert!(after.point_index(&names[1]).is_none(), "the point went");
let left: Vec<String> = after.curves[si].kind.point_ids().iter().map(|i| after.points[*i].id.clone()).collect();
let CurveKind::Spline { pts: left, .. } = &after.curves[si].kind else { panic!("a spline") };
let left: Vec<String> = left.iter().map(|i| after.points[*i].id.clone()).collect();
assert_eq!(left, [names[0].clone(), names[2].clone(), names[3].clone()]);
let segs = after.exact_segs(si).unwrap();
for id in &left {
@@ -2152,10 +2156,18 @@ fn a_reversed_spline_runs_the_other_way_along_the_same_path() {
let r = run(&mut s, "sketch.spline.reverse", json!({"curve": id}));
assert_eq!(r["curve"], json!(id), "{r}");
let after = sketch(&s);
let mut reversed = before.curves[ci].kind.point_ids();
let fit_points = |k: &CurveKind| match k {
CurveKind::Spline { pts, handles, .. } => (pts.clone(), handles.clone()),
_ => (Vec::new(), Vec::new()),
};
let (mut reversed, handles) = fit_points(&before.curves[ci].kind);
reversed.reverse();
assert_eq!(after.curves[ci].kind.point_ids(), reversed, "{id}");
assert_eq!((&after.points, &after.constraints), (&before.points, &before.constraints), "{id}: points and constraints stay");
assert_eq!(fit_points(&after.curves[ci].kind).0, reversed, "{id}");
// Tangent handles (#6502) turn about their fit points; every other point stays.
let others = |sk: &solidcraft_sketch::Sketch| {
sk.points.iter().enumerate().filter(|(i, _)| !handles.contains(i)).map(|(_, p)| p.clone()).collect::<Vec<_>>()
};
assert_eq!((others(&after), &after.constraints), (others(&before), &before.constraints), "{id}: points and constraints stay");
let segs = after.exact_segs(ci).unwrap();
for (a, b) in [(&path, &segs), (&segs, &path)] {
for seg in a {
@@ -2827,7 +2839,8 @@ fn irregular_closed_spline_offset_certificate_edits_undoes_and_reopens() {
let result = run(&mut s, "sketch.offset", json!({"curves":["s1"],"distance":2.0,"chain":true}));
let output = result["curves"][0].as_str().unwrap().to_string();
let parameter = result["param"].as_str().unwrap().to_string();
let point_ids = sketch(&s).curves[1].kind.point_ids();
let output_index = sketch(&s).curve_index(&output).unwrap();
let point_ids = sketch(&s).curves[output_index].kind.point_ids();
let verify = |s: &Session, distance: f64| {
let state = s.world_state().unwrap();
let sk = &state.sketch(1).unwrap().sketch;
@@ -4434,7 +4447,10 @@ fn inside_offset_wall(s: &Session, output: &str) -> Vec2 {
let sk = sketch(s);
let index = sk.curve_index(output).unwrap();
let q = sk.exact_pieces(index)[0].point_at(0.5);
let source: Vec<_> = (0..sk.curves.len()).filter(|i| *i < index && !sk.freeform_offsets.iter().any(|o| o.output == sk.curves[*i].id)).collect();
// The source curves, not a spline's tangent handle lines (#6502) or an offset's output.
let source: Vec<_> = (0..sk.curves.len())
.filter(|i| *i < index && sk.curves[*i].spline_handle_of.is_none() && !sk.freeform_offsets.iter().any(|o| o.output == sk.curves[*i].id))
.collect();
let p = source
.iter()
.flat_map(|i| sk.exact_pieces(*i))
@@ -4468,12 +4484,13 @@ fn freeform_offsets_trim_folds_and_round_chain_corners_into_an_extrudable_wall()
]);
let mut s = new_sketch();
run(&mut s, "sketch.spline.fit_point", json!({"points": cookie, "periodic": true}));
let source_dof = run(&mut s, "sketch.inspect", json!({}))["dof"].as_u64().unwrap();
let (before, params) = (s.doc.to_json(), s.doc.params.len());
let out = run(&mut s, "sketch.offset", json!({"curves": ["s1"], "distance": 1.26}));
let curves = ids(&out["curves"]);
assert!(!curves.is_empty(), "{out}");
assert_eq!(out["param"], Value::Null, "a trimmed offset has no distance to drive: {out}");
assert_eq!(out["sketch"]["dof"], 30, "the offset adds no freedom (only the source's fit points move): {out}");
assert_eq!(out["sketch"]["dof"], source_dof, "the offset adds no freedom: {out}");
assert_eq!(s.doc.params.len(), params, "no orphan distance parameter");
let sk = sketch(&s);
assert!(sk.freeform_offsets.is_empty() && !sk.constraints.iter().any(|c| matches!(c.kind, ConstraintKind::OffsetDistance { .. })));
@@ -5767,6 +5784,100 @@ fn split_failures_name_the_public_command() {
assert!(!text.contains("sketch.modify"), "{text}");
}
/// #11523: two lines whose ends already sit at one place, as points of their own, make a corner
/// to round without any end having to move, however much holds those ends (a fixed position, a
/// length). The fillet was refused as if each end had to move to the meeting point.
#[test]
fn fillet_of_lines_that_already_meet_needs_no_end_to_move() {
for hold in ["fixed", "length", "both"] {
let mut s = new_sketch();
let a = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [40, 0]], "connect": false, "infer": false}))["curves"])[0].clone();
let b = ids(&run(&mut s, "sketch.line", json!({"points": [[40, 0], [40, 50]], "connect": false, "infer": false}))["curves"])[0].clone();
if hold != "length" {
run(&mut s, "sketch.constraint.fix", json!({"entities": [format!("{a}.end")]}));
}
if hold != "fixed" {
run(&mut s, "sketch.dimension", json!({"entities": [a.clone()], "type": "length", "value": "40"}));
run(&mut s, "sketch.dimension", json!({"entities": [b.clone()], "type": "length", "value": "50"}));
}
let r = s.execute("sketch.fillet", &json!({"a": a, "b": b, "radius": 5})).unwrap_or_else(|e| panic!("{hold}: {e}"));
let sk = sketch(&s);
// What held the lines is still there (a length now measures to the kept corner), none merged.
let held = |sk: &solidcraft_sketch::Sketch, f: fn(&ConstraintKind) -> bool| sk.constraints.iter().filter(|k| f(&k.kind)).count();
assert_eq!(
held(&sk, |k| matches!(k, ConstraintKind::Length { .. } | ConstraintKind::Distance { .. })),
if hold == "fixed" { 0 } else { 2 },
"{hold}"
);
assert_eq!(held(&sk, |k| matches!(k, ConstraintKind::Fix { .. })), if hold == "length" { 0 } else { 1 }, "{hold}");
let arcs: Vec<_> = sk.curves.iter().filter(|c| matches!(c.kind, CurveKind::Arc { .. })).collect();
assert_eq!(arcs.len(), 1, "{hold}: {r}");
// The arc's tangent points: 5 mm from the corner (40, 0) along each line.
let CurveKind::Arc { a, b, .. } = arcs[0].kind else { panic!("an arc") };
let ends: Vec<Vec2> = vec![sk.point(a).unwrap(), sk.point(b).unwrap()];
for want in [Vec2::new(35.0, 0.0), Vec2::new(40.0, 5.0)] {
assert!(ends.iter().any(|e| e.dist(want) < 1e-6), "{hold}: {ends:?}");
}
}
}
/// #11523: a line ending at the sketch origin keeps the origin as the corner when another line
/// is brought to meet it there, so a third line still ending at the origin still shares it.
#[test]
fn fillet_meeting_at_the_origin_keeps_the_origin_shared() {
let mut s = new_sketch();
let a = ids(&run(&mut s, "sketch.line", json!({"points": [[20, 0], [5, 0]], "connect": false, "infer": false}))["curves"])[0].clone();
let b = ids(&run(&mut s, "sketch.line", json!({"points": ["origin", [0, 30]], "connect": false, "infer": false}))["curves"])[0].clone();
let c = ids(&run(&mut s, "sketch.line", json!({"points": ["origin", [-20, -20]], "connect": false, "infer": false}))["curves"])[0].clone();
let r = run(&mut s, "sketch.fillet", json!({"a": a, "b": b, "radius": 2}));
let sk = sketch(&s);
let ci = sk.curve_index(&c).expect("the third line");
assert!(matches!(sk.curves[ci].kind, CurveKind::Line { a: 0, .. }), "the third line still starts at the origin: {r}");
assert!(!sk.curves.iter().enumerate().any(|(i, l)| i != ci && matches!(l.kind, CurveKind::Line { a, b } if (a == 0) != (b == 0) && sk.point(if a == 0 { b } else { a }).is_some_and(|q| q.len() < 1e-9))), "no other point sits on the origin");
}
/// #11523: two ends already at one place but each placed by its own expressions can't become one
/// corner point that follows both: the fillet is refused instead of dropping one end's expressions.
#[test]
fn fillet_refuses_meeting_ends_placed_by_different_expressions() {
let mut s = new_sketch();
run(&mut s, "parameters.add", json!({"name": "W", "expression": "40 mm"}));
run(&mut s, "parameters.add", json!({"name": "X", "expression": "40 mm"}));
let a =
ids(&run(&mut s, "sketch.line", json!({"points": [["0 mm", "0 mm"], ["W", "0 mm"]], "connect": false, "infer": false}))["curves"])[0].clone();
let b =
ids(&run(&mut s, "sketch.line", json!({"points": [["X", "0 mm"], ["X", "30 mm"]], "connect": false, "infer": false}))["curves"])[0].clone();
let err = s.execute("sketch.fillet", &json!({"a": a, "b": b, "radius": 5})).expect_err("two placements can't both hold one corner").to_string();
assert!(err.contains("one corner point can't follow both"), "{err}");
// The same expressions on both ends are one placement: that corner rounds.
let mut s = new_sketch();
run(&mut s, "parameters.add", json!({"name": "W", "expression": "40 mm"}));
let a =
ids(&run(&mut s, "sketch.line", json!({"points": [["0 mm", "0 mm"], ["W", "0 mm"]], "connect": false, "infer": false}))["curves"])[0].clone();
let b =
ids(&run(&mut s, "sketch.line", json!({"points": [["W", "0 mm"], ["W", "30 mm"]], "connect": false, "infer": false}))["curves"])[0].clone();
s.execute("sketch.fillet", &json!({"a": a, "b": b, "radius": 5})).expect("one placement on both ends");
}
/// #11523: an end placed by expressions and one linked to a model vertex, at one place, can't
/// become one corner point: the expressions would overwrite where the vertex moves it. Refused.
#[test]
fn fillet_refuses_meeting_ends_placed_by_an_expression_and_a_link() {
let mut s = new_sketch();
run(&mut s, "parameters.add", json!({"name": "W", "expression": "40 mm"}));
let a =
ids(&run(&mut s, "sketch.line", json!({"points": [["0 mm", "0 mm"], ["W", "0 mm"]], "connect": false, "infer": false}))["curves"])[0].clone();
let b = ids(&run(&mut s, "sketch.line", json!({"points": [[40, 0], [40, 30]], "connect": false, "infer": false}))["curves"])[0].clone();
let id = s.active_sketch.unwrap();
let start = sketch(&s).resolve_point(&format!("{b}.start")).expect("b's start");
s.doc_mut().sketch_mut(id).unwrap().points[start].link = Some("model-vertex".into());
let err = s
.execute("sketch.fillet", &json!({"a": a, "b": b, "radius": 5}))
.expect_err("an expression and a link can't both hold one corner")
.to_string();
assert!(err.contains("one corner point can't follow both"), "{err}");
}
/// #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.
+2 -1
View File
@@ -200,6 +200,7 @@ pub(super) fn run(s: &mut Session, p: &Value, circular: bool) -> Result<Value> {
None => sk.fresh("pat")?,
};
let center_id = center.and_then(|i| sk.points.get(i)).map(|p| p.id.clone());
let reported_curve_count = entity_ids.iter().filter(|id| sk.curve_index(id).is_some()).count();
// Removing old copies can retire an earlier pattern seeded by one, so the old index may be past the end.
let index = old_index.unwrap_or(sk.patterns.len()).min(sk.patterns.len());
let pattern = SketchPattern {
@@ -213,7 +214,7 @@ pub(super) fn run(s: &mut Session, p: &Value, circular: bool) -> Result<Value> {
sk.patterns.insert(index, pattern);
sk.update_pattern(index, kind)?;
let pattern = sk.patterns.get(index).ok_or_else(|| bad(cmd, "missing sketch pattern"))?;
let curves: Vec<_> = pattern.instances.iter().flat_map(|i| i.curves.iter().cloned()).collect();
let curves: Vec<_> = pattern.instances.iter().flat_map(|instance| instance.curves.iter().take(reported_curve_count).cloned()).collect();
let constraints: Vec<_> = pattern.instances.iter().flat_map(|i| i.constraints.iter().cloned()).collect();
Ok((id, center_id, curves, constraints))
})?;
@@ -627,6 +627,16 @@ fn patterned_arc_ellipse_spline_and_conic_follow_seed_geometry_exactly() {
let mut s = new_sketch();
let seed = ids(&run(&mut s, cmd, params)["curves"])[0].clone();
let out = run(&mut s, "sketch.pattern.rectangular", json!({"entities":[seed],"count":3,"spacing":40}));
if cmd == "sketch.spline.fit_point" {
let sk = sketch(&s);
let helper_count = sk.curves.iter().filter(|curve| curve.spline_handle_of.as_deref() == Some(seed.as_str())).count();
assert_eq!(helper_count, 0, "a spline has tangent handles only when it is given them");
for copy in ids(&out["curves"]) {
let copy_id = &sk.curves[sk.curve_index(&copy).unwrap()].id;
assert_eq!(sk.curves.iter().filter(|curve| curve.spline_handle_of.as_deref() == Some(copy_id.as_str())).count(), helper_count);
}
assert!(sk.validate_spline_handles().is_ok());
}
run(&mut s, "sketch.scale", json!({"entities":[seed],"base":"origin","factor":1.5}));
let sk = sketch(&s);
if matches!(sk.curves[sk.curve_index(&seed).unwrap()].kind, CurveKind::Conic { .. }) {
+84
View File
@@ -968,6 +968,64 @@ fn construction_on_a_moved_body_moves_with_it() {
assert!(on_line(o, d, Vec3::new(0.0, 0.0, 20.0)) && on_line(o, d, Vec3::new(0.0, 50.0, 20.0)), "{o:?} {d:?}");
}
/// #11469: a tangent plane picked on an inserted STL said only "no face at …", as if the pick
/// had missed. A mesh body has no faces to pick; the error says the pick lies on one and what
/// gives it faces (Convert Mesh), and a pick in empty space keeps the plain message.
#[test]
fn plane_tangent_on_a_mesh_body_says_it_has_no_faces() {
let dir = std::env::temp_dir().join(format!("solidcraft-mesh-tangent-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let stl = dir.join("cube.stl");
let mut cube = Session::default();
run(&mut cube, "solid.box", json!({"length": 20, "width": 20, "height": 20, "corner": [0, 0, 0], "body_name": "Cube"}));
run(&mut cube, "file.export", json!({"path": stl.to_string_lossy()}));
let mut s = Session::default();
run(&mut s, "file.insert_mesh", json!({"path": stl.to_string_lossy()}));
let _ = std::fs::remove_dir_all(&dir);
let mesh = s.world_state().unwrap().bodies.iter().find(|b| b.body.is_mesh()).map(|b| b.name.clone()).expect("a mesh body");
let on = s.execute("construct.plane.tangent", &json!({"face": {"face": [10, 10, 20]}, "name": "T"})).expect_err("a mesh has no face").to_string();
assert!(on.contains(&mesh) && on.contains("mesh") && on.contains("Convert Mesh"), "{on}");
let off = s.execute("construct.plane.tangent", &json!({"face": {"face": [100, 100, 100]}, "name": "T"})).expect_err("nothing there").to_string();
assert!(off.contains("no face at") && !off.contains("Convert Mesh"), "{off}");
}
/// #11469, the converted path the report followed ("convert STL to body"): a finely tessellated
/// cylinder 200 mm tall, inserted as STL and converted, takes a tangent plane on a facet at the
/// picked point, with the facet's normal.
#[test]
fn plane_tangent_on_a_converted_mesh_facet() {
let dir = std::env::temp_dir().join(format!("solidcraft-mesh-converted-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let stl = dir.join("cyl.stl");
let mut src = Session::default();
run(&mut src, "solid.cylinder", json!({"base": [0, 0, 0], "radius": 60, "height": 200, "body_name": "Cyl"}));
run(&mut src, "file.export", json!({"path": stl.to_string_lossy()}));
let mut s = Session::default();
run(&mut s, "file.insert_mesh", json!({"path": stl.to_string_lossy()}));
let _ = std::fs::remove_dir_all(&dir);
let (name, mesh) =
s.world_state().unwrap().bodies.iter().find(|b| b.body.is_mesh()).map(|b| (b.name.clone(), b.body.clone())).expect("a mesh body");
// A side facet's centroid and normal, read from the mesh before it is converted.
let (pos, tris) = mesh.triangle_mesh().unwrap();
let (c, n) = tris
.iter()
.filter_map(|t| {
let [a, b, c] = t.map(|i| pos[i as usize]);
let n = (b - a).cross(c - a).normalized()?;
(n.z.abs() < 1e-6).then(|| ((a + b + c) * (1.0 / 3.0), n))
})
.next()
.expect("a side facet");
run(&mut s, "mesh.convert", json!({"body": name, "tolerance": "0.01 mm"}));
assert!(!s.world_state().unwrap().bodies.iter().any(|b| b.body.is_mesh()), "converted");
run(&mut s, "construct.plane.tangent", json!({"face": {"face": [c.x, c.y, c.z]}, "name": "T"}));
let t = plane(&s, "T");
assert!(parallel(t.normal(), n) && on_plane(&t, c), "{t:?} vs facet at {c:?} normal {n:?}");
}
/// A feature named exactly like a component origin item stays reachable: at every resolver that
/// takes a name the feature wins over the datum key (the #9128 mistake the plan calls out).
/// Without the feature the same pick is the component origin's, saved as its datum.
@@ -1098,3 +1156,29 @@ fn planes_made_of_picks_in_a_moved_component_go_through_their_world_picks() {
}
}
}
/// Snap points (#11228): a construction built from an edge's middle or a hole's centre keeps it
/// as a reference on that edge or face, so it follows the model.
#[test]
fn snapped_centres_and_middles_follow_the_model() {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "h", "expression": "20 mm"}));
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": "h", "body_name": "Box"}));
run(&mut s, "solid.cylinder", json!({"base": [20, 15, -1], "radius": 4, "height": "h + 2 mm", "operation": "cut"}));
let snap = |of: Value, kind: &str, at: [f64; 3]| json!({"type": "snap", "of": of, "kind": kind, "at": at});
let top_front = json!({"type": "edge", "at": [10, 0, 20]});
run(&mut s, "construct.point.vertex", json!({"point": snap(top_front, "middle", [20.0, 0.0, 20.0]), "name": "Mid"}));
// The hole's wall: the centre of its top end, not its bottom one.
let wall = json!({"type": "face", "at": [24, 15, 10]});
run(&mut s, "construct.point.vertex", json!({"point": snap(wall, "centre", [20.0, 15.0, 20.0]), "name": "Top"}));
let side = json!({"type": "edge", "at": [40, 5, 20]});
run(&mut s, "construct.axis.two_points", json!({"a": snap(side, "middle", [40.0, 15.0, 20.0]), "b": {"vertex": [0, 0, 0]}, "name": "Diag"}));
run(&mut s, "parameters.change", json!({"name": "h", "expression": "50 mm"}));
assert!(near(point(&s, "Mid"), Vec3::new(20.0, 0.0, 50.0)), "{:?}", point(&s, "Mid"));
assert!(near(point(&s, "Top"), Vec3::new(20.0, 15.0, 50.0)), "{:?}", point(&s, "Top"));
let (o, d) = axis(&s, "Diag");
assert!(on_line(o, d, Vec3::new(40.0, 15.0, 50.0)) && on_line(o, d, Vec3::ZERO), "{o:?} {d:?}");
// A snap point of something with none of that kind is refused when it is built.
let bad = s.execute("construct.point.vertex", &json!({"point": snap(json!({"type": "origin"}), "middle", [0.0, 0.0, 0.0])}));
assert!(bad.is_err() || construction_points(&s).len() == 2, "{bad:?}");
}

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