Compare commits

...

94 Commits

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

---------

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

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

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

Part of #13790.


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

All 283 units pass.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Opening a design keeps its own Cancel

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

---------

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

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

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

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

Written by Qwen.

---------

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

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

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


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

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

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

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

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

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

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

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

Part of #13790.

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

---------

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

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


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

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

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

* Format note commands

* Drop needless borrow in note command errors

* Add pending format change for notes

---------

Co-authored-by: Dahak Builds <dahakbuilds@users.noreply.github.com>
2026-11-10 20:52:02 +00:00
0rdnungsamt af8e63e111 Clippy-clean under Rust 1.97 as well as the CI's stable (fixes #14547) (#14708)
* claim: clippy-clean under Rust 1.97 (#14547)

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

* Clippy-clean under Rust 1.97: Option::zip and range contains, behaviour kept (#14547)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 20:46:52 +00:00
Alexander Schrab 050ac6d967 Nix flake: evaluating the flake no longer warns that stdenv.isLinux is deprecated (#14713) (#14721)
Evaluating the flake printed "evaluation warning: stdenv.isLinux is
deprecated, use stdenv.hostPlatform.isLinux instead", once for each system
that was evaluated. The flake now reads stdenv.hostPlatform.isLinux in the
eight places that used stdenv.isLinux. Which systems count as Linux does
not change.
2026-11-10 20:36:47 +00:00
Henry 5d7f5507f6 Boolean: a body minus an exact copy of itself is empty (#14711)
Cutting a body by an exact copy of itself failed (SM_ERR) or left
faces. Four kernel defects caused it; each is fixed behind a new
opt-in UpstreamFixes flag, turned on in SolidCraft's fix set and the
rig's fixes.json, with a regression test that fails without it:

- boolean_capture_keeps_operand_vertices_apart: IntersectInsertRelate's
  capture distance, max(0.01, 2000 x intersection tolerance), stays
  below half the smallest distance between two vertices of either
  operand, so no proto vertex captures two distinct vertices of one
  operand (with a STEP file's 0.01 tolerances it was 10 mm).
- radial_sector_step_off_coincidence_by_own_face: when stepping off
  leaves a face, RadialSectorClassify reads a coincident face's side
  against the face it lies on, as its coincidence test does, not
  against the radial partner's.
- split_edge_definition_owns_its_curves: SmEdgeDefinition::Split gives
  the new definition its own curves and trims both before
  AddEdgeDefinition may reparameterize it.
- simple_coincidence_side_across_the_curve: SimpleCoincidenceChecker
  takes the side across the curve, so coincident curves of slightly
  different length are not taken for crossing ones.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 20:33:44 +00:00
Jens 0e69276720 Assemblies: reference a placed component's origin planes, axes and O point (#7313) (#12466)
A sketch, a construction plane or axis, and every feature that takes a plane
or an axis, can now pick the origin geometry of a placed component in the
Browser and the viewport: its O point, the X, Y and Z axes and the XY, XZ and
YZ planes. The reference names the exact placement by its complete occurrence
path and is found again on every evaluation, so it follows the component when
it moves, the way a face attachment re-finds its face. The shared core is the
one DatumRef model the joint sides use (#10056): `PlaneRef::Datum`,
`GeoRef::Datum`, `Document::placement_frame` and the placement digest's datum
paths are already `main`'s, and this change only wires them into the app's
resolvers, the Browser and the viewport.

- The Browser gives every placement its own Origin folder (`o:<ids>/origin`),
  hidden by default and shown per placement
  (`Visibility::shown_component_origins`), drawn like the design Origin's
  quarter squares; the design Origin keeps its own controls. A browser row
  can carry a handle, so a repeated placement's rows keep their automation
  identity next to the design Origin's labels.
- The resolvers take a datum key at every site that takes a name: the
  engine's construct and feature parameters, the dialog chips, the viewport
  previews and Look At, and the visibility command. The one order everywhere:
  a construction feature by name first, then the datum key, then the origin
  aliases.
- A copy within one design rewrites datum paths to the copy's own walk; a
  paste from another design refuses them, since the reference could not
  follow.
- A hidden placement hides its Origin folder and its items too, and a
  placement that is gone prunes its shown folder; undo brings both back.

Changes a default: no

Design-format change: `component-origin-visibility` (pending; the
`component-origin-datums` format change landed with #10056). New message
`dialog.component_origin` ships every complete catalog.

Tests: occurrence tests (picking, resolution, visibility, pruning of gone
placements), clone tests (path rewriting and refusal), frames tests (a datum
key is not frozen into a resolved plane), construct tests, schema tests and
UI scenarios in `occurrence_ui_tests.rs` (a sketch on a component origin
plane follows the component, the XZ square spans the placement's own Z, the
Origin folder stays hidden while a sketch is open).
2026-11-10 20:12:31 +00:00
bherbruck feb681af51 Service worker: claim the open pages first when taking over, and make the offline check wait for the takeover (#14723)
When a new worker took over, it retired the old build's copy and swept before it claimed the pages the old worker had controlled. A page the old worker controlled has no controller in that gap, and a request it makes then goes straight to the network, which serves the newest build (the open v3 page was given v4's shared.js although its record named v3's copy, in run 42883099627). The worker now claims first. The offline test waited for the retire mark, which is written before the claim; it now also waits until the new worker is active and controls the page.

Changes a default: no

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 20:11:53 +00:00
bherbruck 76f6caf2a8 Cancel stops SMLib's full check and tight bounding box of a long feature (#14684)
* solidcore-ops: cancel ops calls with a scoped token (solidcore#3247)

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 20:06:01 +00:00
ancient_astronaut_theorist 1a38a7585e Kernel: keep a failed solidcore-ops conversion in the body's cache (follow-up to #14509) (#14671)
* Kernel: keep a failed solidcore-ops conversion in the body's cache

ops_body kept only a conversion that worked, so a record solidcore-ops
refuses was converted again for every spline face planar_face asked
about. The cache now keeps the failure too, as smlib's refusal is: the
conversion runs unstoppable, so a stopped job cannot make it fail, and
the same record fails the same way again.

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

* Kernel: a refusal of the moment is not kept in the solidcore-ops cache

ops_body filled its OnceLock from inside get_or_init, so a solidcore-ops
failure callback that asked about the same body again would reenter the
cell while it was being filled, and a call refused because it came from
inside a kernel call or the callback (Error::Reentered), or a cancelled
one, was kept as a refusal of the record. The conversion now runs
outside the cell; Reentered and Cancelled are not kept, so a later query
tries again. A refusal of the record is still kept.

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-10 20:03:36 +00:00
rugbymauri 70fdcc6fc7 Extrude: started from an open sketch, its profiles can be picked even when the sketch is already used (#14128) (#14648)
* claim: #14128

Signed-off-by: Maurizio Monticelli <maurizio@whatwedo.ch>

* Extrude: started from an open sketch, its profiles can be picked even when the sketch is already used (#14128)

Extrude and Revolve finish the open sketch before their window opens. A
sketch that an earlier feature uses is hidden once it is finished, so a
rectangle drawn after reopening it could not be picked: the window showed
no profiles and a click inside the rectangle picked nothing.

The dialog now records the sketch it ended on its way, and
sync_feature_edit keeps that sketch shown while the dialog is open, the
way an edited feature's sketch is shown. Closing the dialog hides it again.

Signed-off-by: Maurizio Monticelli <maurizio@whatwedo.ch>

---------

Signed-off-by: Maurizio Monticelli <maurizio@whatwedo.ch>
2026-11-10 20:03:33 +00:00
Brett Lawson a395b4e049 solidcore: the sweep routines describe their failures (SweepFailure::Failed, Missing) (#14670)
The 98 bare `sm_err`/`null_pointer` sites in `sweep/` (topology sweep, its
translational and rotational geometry, screw, cornered, segments, the sweep
API's surfaces) become `SweepFailure::Failed(phrase)` (status SM_ERR) or
`SweepFailure::Missing(phrase)` (SM_ERR_NULL_POINTER), extending the existing
`Reason::Sweep`. Behaviour-neutral: `sm_status`, `kind`, `operation`, `entity`,
`is_port_failure` and `is_status_answer` are those of the `Upstream` each
replaced, and `upstream_status()` covers the new variants
(`SweepFailure::replaces_upstream_status`). `RailPastStationCap` reports as
before (SM_ERR, not_within_tolerance, no upstream status).

A ratchet test keeps bare statuses out of `sweep/`. The trans/rotational
goldens runners read `upstream_status`; SolidCraft's `status()` passes the new
variants on as their status, with a test.

Part of #13790.


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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 20:02:47 +00:00
bherbruck 54ce32cddf Panic-site scan: solidcore-step is in it, its sites fixed or proven (tangency-labs/solidcraft#14026) (#14663)
solidcore-step, SolidCraft's STEP reader (moved in by #13979), joins CRATES in check_panic_sites.py. (The scan's clippy run already copes with solidcore-record, a dependency of step's outside CRATES, through main's `--cap-lints warn` from #14194.)

solidcore-step's 75 sites are listed in plan/panic-sites.tsv, all proven, each with its reason. Fixed first:
- eval.rs `expanded`: the knots are built only for more control points than the degree, with multiplicities adding up to points + degree + 1, checked before anything is built. A file can no longer have the knot vector grow to a size it declares: the knots are at most twice the points, which are already in memory.
- read.rs `implied_knots`: a degree of 0 is refused before the Bezier segments divide by it.
- topology.rs `face_domain`: a natural range that leaves no room for the box (crossed, or not a number) is refused, naming it, before the clamp. A range narrower than the box's span takes its own start exactly, where `n1 - span` could round below `n0` and panic the clamp.
- write/topology.rs `outer_loop`: the ring grows as it is filled, rather than reserving edges x samples.

Main's other rows are left as they are. The scan notices 13 that no longer match a site (main's own drift), and that doesn't fail the check.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 20:02:44 +00:00
Dan Willis 839d523123 Sketch Project: a mesh face's straight side stays one line when single precision moves a corner on it (follow-up to #13482) (#13903)
* Sketch Project: mesh outline tolerances follow single precision and the outline's size (#13464)

A corner part way along a side is dropped when it lies within single
precision of the line through its neighbours (1e-6 of the coordinates'
size), measured as its distance from that line, so a rounded corner at
the loop's start no longer splits a side in two. The circle check for a
mesh outline is never looser than 1e-4 of the outline's own size, so a
small outline far from the origin is not taken for a circle. From
CodeRabbit's second review on #13482.

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

* Sketch Project: a small mesh face far from the origin keeps its corners

A corner part way along a side of a mesh face's outline is dropped when
it lies within single precision of the line through its neighbours,
1e-6 of the coordinates' size. Far from the origin that grew past the
outline itself: a 1 x 1 square at x = 1 000 000 lost all four corners
and projected to nothing. The tolerance is now never more than 1e-4 of
the outline's own size, as the circle check is. From CodeRabbit's last
review on #13482.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 19:54:06 +00:00
Jackson Oursland 1c59721006 Render preview: PBR materials in the palette (#7474)
PBR product triangles carried their linear base colour and optical parameters
in every 48-byte vertex, in a stream of their own built again for any change.
They are now built triangles in the product blocks (TriClass::Pbr, 28 bytes a
vertex) whose palette entry names their material: the palette carries a
material table, uploaded as an Rgba32Float texture beside it and diffed by row,
and vs_pbr_palette reads it for the unchanged fs_pbr. A metallic, roughness or
IOR edit rewrites material rows and uploads no vertex. The design docs follow,
including the product preview's vertex bound (512 MiB on 64-bit desktop).

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-10 19:45:16 +00:00
Dahak Builds e7ac83a074 Sketch: planar loops of 3D sketch curves become profiles (#741) (#6447)
* claim: #741

* Sketch: planar 3D-sketch loops become profiles (#741)

With 3D Sketch on, drawn curves (lines, splines) that close a loop lying
in a plane — possibly together with in-plane lines along the intersection
of that plane with the sketch plane — are now found as profiles in their
own plane. sketch.inspect lists them under "profiles_3d" (plane, area,
centroid, bounding curves), and surface.patch takes them by index: each
becomes a planar face in its own plane, so one sketch builds the sides of
a pyramid.

- crates/sketch: new profiles3d module — lifts the in-plane lines into
  space, adds the wire segments, finds the planes the nodes span, and runs
  the ordinary 2D face tracing on the edges lying in each plane (projected
  into it), so holes nest as they do in the sketch plane. Loops bounded
  only by in-plane curves stay in the 2D list; nothing is listed twice.
- SolvedSketch carries profiles_3d; sketch.inspect lists them.
- surface.patch gains profiles_3d: [indices]; the sketch-plane profiles
  stay untouched unless `profiles` is given as well.
- stable_profiles leaves an empty index selection alone.

* Doc: pending format note for the surface patch's 3D profiles (#741)

---------

Co-authored-by: Dahak Builds <dahakbuilds@users.noreply.github.com>
2026-11-10 19:45:13 +00:00
HiroKenEz 91c10128e1 Agent chat: each message shows its time, the running item how long it has run (#11986) (#12025)
* claim: #11986

* Agent chat: each message shows its time, the running item how long it has run (#11986)

Each message and tool card says when it came in ("3 min ago", the exact
time on hover). While the agent works, a running tool call counts how
long it has run ("running for 30 s") and otherwise the conversation shows
how long since the agent last did something ("Thinking for 4 min"). An
answer ends with how long it took ("Answered in 23 min").

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

* Agent chat: the time-length test installs the English catalog (#11986)

Under nextest each test runs in its own process, so the catalog is not
installed by an earlier test and the span labels read as their keys.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 19:35:59 +00:00
CarelT c9ae5e8072 Engine: open a linked design with its state and reason, one Save As rule (#5449)
`design_link.rs` gives every kind of link to another design file one way to
read it and one set of state words (agreed on #3891, review on #5420):

- `open_linked(file)` reads through vfs, or returns a `LinkProblem`: its
  `LinkState` (missing, unreadable, not_allowed, newer) and the reason;
  `link_presence(file)` says the same without reading. The words also cover
  up_to_date and out_of_date for each link's own comparison.
- `DocError::Newer` carries the format check's "saved by a newer SolidCraft",
  same message, so `newer` doesn't depend on the text.
- `rebase(link, from, to)`: one Save As rule; `rebased` maps over it.
- `same_file`: one self-link rule (same path, or an alias on native hosts).

Insert Design, Update Links and Relink read through `open_linked` and use
`same_file` (only Relink caught an alias before); a design from a newer
SolidCraft says to update. `component.links` adds `state` and `reason` when a
file can't be reached; `found` is unchanged.

Rebased onto main f96b5e067:
- A design from a newer SolidCraft opens with a notice since #12166, so
  the `newer` state and `DocError::Newer` are gone.
- `open_linked` is `read_linked_file` and returns the file's bytes: Update
  Links keeps their digest (#9739) and reads a container's saved geometry.
  It reads up to `limits::max_design_bytes()` (#7565).
- `rebased` keeps every `DesignLink` field.
- `component.links` reports `state` and `reason` in its typed reply
  (`LinkInfo`, #12277), with a schema test for a missing file.
- Tests use `.sc` names (#7325).

Co-authored-by: Carel Theron <careltheron@Carels-MacBook-Pro.local>
Co-authored-by: Claude <noreply@anthropic.com>
2026-11-10 19:28:59 +00:00
Brett Lawson c3dc468507 Sweep rail test: sweep along one arc segment, not a 181-point polyline (#14693)
a_parallel_rail_along_a_curved_path_keeps_the_section swept along a quarter
arc given as 181 points, which the sweep mitres at every vertex (180 corners,
about 895 booleans): 194 s locally, 209 s on CI. It now passes the quarter arc
as one arc path segment, keeps the scale rail, and checks the Pappus volume
within 1e-6 (was 2e-3): 1.1 s.

A 9-point polyline case beside it keeps a rail turning with a mitred path
covered, against the oblique-cylinder volume pi r^2 L cos(d/2) within 1e-6.

Part of #14451


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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 19:26:53 +00:00
bherbruck 7001949c49 Release 0.40.0-rc.3 2026-11-07 15:59:45 -05:00
bherbruck 777f960999 Release 0.40.0-rc.2 2026-11-03 15:36:03 -05:00
bherbruck d4f923a3e6 Release 0.40.0-rc.1 2026-11-03 02:20:23 -05:00
bherbruck bbb98928fb Release 0.40.0-rc.1 2026-11-03 01:00:12 -05:00
bherbruck 8e2b0cd60d Release 0.30.1 2026-11-02 11:11:46 -05:00
bherbruck afd81380ab Release 0.30.0 2026-11-01 21:55:18 -05:00
bherbruck 3a5195b0a0 Release 0.29.0 2026-10-31 19:20:30 -04:00
bherbruck ee6b85605a Release 0.28.0 2026-10-30 19:50:14 -04:00
bherbruck aa1fe28ddf Release 0.27.1 2026-10-29 23:34:31 -04:00
bherbruck 5b0eb08473 Release 0.27.0 2026-10-29 19:53:24 -04:00
bherbruck 9df8837d43 Release 0.26.1 2026-10-29 06:33:09 -04:00
bherbruck 409ab1ed45 Release 0.26.0 (zip 9.1.3) 2026-10-28 19:41:38 -04:00
bherbruck 5e03fe2f4b Release 0.26.0 2026-10-28 19:32:17 -04:00
bherbruck 37a586dd61 Release 0.25.1 2026-10-27 20:58:30 -04:00
bherbruck a79f40a711 Release 0.25.0 2026-10-27 18:05:39 -04:00
bherbruck b5728f284b Release 0.24.1 2026-10-27 08:34:27 -04:00
bherbruck 5965439a15 Release 0.24.0 2026-10-26 19:23:21 -04:00
bherbruck 586a358ed4 Release 0.23.1 2026-10-26 07:07:21 -04:00
bherbruck 7770f8982d Release 0.23.0 2026-10-25 18:18:39 -04:00
bherbruck 72ad6383ef Release 0.22.1 2026-10-25 10:38:39 -04:00
bherbruck 0537623a75 Release 0.22.0 2026-10-24 18:53:43 -04:00
bherbruck 65d288227e Release 0.21.0 (rc.2's code, version only)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 20:51:08 -04:00
bherbruck fedb3bcdb1 Release 0.21.0-rc.2 2026-10-23 17:14:38 -04:00
bherbruck 097b8b7550 Release 0.21.0-rc.1
The tree is main at f120285c9 exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-23 13:48:05 -04:00
bherbruck af7ca3a8a2 Release 0.21.0-rc.1
The tree is main at b31fddb98 exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-23 13:37:43 -04:00
bherbruck 27ddb725c7 Release 0.21.0-rc.1
The tree is main at 2bf3d4e11 exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-23 12:10:02 -04:00
bherbruck a06bdf5cfc Release 0.20.0
The tree is main at 030187452 exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-22 18:34:40 -04:00
bherbruck 33fdcdb3a0 Release 0.19.0
The tree is main at 5899e5b6c exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-21 21:13:33 -04:00
bherbruck c07ab5f8f9 Release 0.18.4
The tree is main at 728f753b8 exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-21 12:26:34 -04:00
bherbruck fea32f8aac Release 0.18.3
The tree is main at 034aa60d4 exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-21 08:46:37 -04:00
bherbruck 4927214976 Release 0.18.2
The tree is main at 57bcc4083 exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-20 22:09:18 -04:00
bherbruck b621819c0c Release 0.18.1
The tree is main at 522ae7755 exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-20 17:39:11 -04:00
bherbruck ec552a5300 Release 0.18.0
The tree is main at c3151fb7f exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-20 16:28:59 -04:00
bherbruck e0cc740cc3 Release 0.17.0
The tree is main at 511faedcf exactly; the 0.16.x release commits are kept as a parent so release only moves forward.
2026-10-20 11:41:48 -04:00
bherbruck 5de48e916d Release 0.16.2 2026-10-19 00:39:30 +00:00
bherbruck 80af7bbe3e Release 0.16.2
Version 0.16.2: 0.16.1 with the rigid orbit fix (#2753). Same design format (27).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:38:53 +00:00
bherbruck 191e983423 Fix orbit about the picked point (regression in 0.16.0) (#2753)
Orbiting about the point under the cursor (or the origin) turned the camera
about its target and then panned to put the pivot back. The pan works at the
target's depth with the parallel-view scale, so in a perspective view the
pivot slid off its pixel and the model swung about the camera.

The camera now turns rigidly about the pivot: Camera::orbit_about keeps the
pivot's coordinates in the camera's frame and rebuilds the target from the
new orientation, so the pivot stays exactly on its pixel in both projections.
The Shift snap spring and view.navigate's pivot orbit use the same.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit e88779218)
2026-10-19 00:38:52 +00:00
bherbruck 2b9445b049 Release 0.16.1 2026-10-18 20:16:21 +00:00
bherbruck 7ddf48ca16 Release 0.16.1
Version 0.16.1: 0.16.0 with the orbit fix (reverts #2142). Same design format (27).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:12:04 +00:00
bherbruck d2f6848377 Revert #2142: orbit about the picked point like 0.15.0
#2142 made orbiting about the point under the cursor turn the camera in place instead of turning the part about that point. Restore the 0.15.0 behaviour.

This reverts commit cac4bccea8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:12:00 +00:00
bherbruck 0b6d6fecd8 Release 0.16.0 2026-10-18 18:16:11 +00:00
bherbruck fa691707c2 Release 0.15.0 2026-10-17 13:54:24 +00:00
bherbruck b9a55c74e2 Release 0.15.0
Version 0.15.0; the pending design-format changes are folded into format 26.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:53:44 +00:00
bherbruck 2bb9c81069 Release 0.14.0 2026-10-16 09:19:14 +00:00
bherbruck cedb51b8ea Release 0.14.0
Version 0.14.0; the pending design-format changes are folded into format 25.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 09:19:10 +00:00
bherbruck 66d13059dd Release 0.13.1 2026-10-15 21:56:43 +00:00
bherbruck 800ea40c0d Release 0.13.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:56:35 +00:00
bherbruck 20442eecd3 Agent chat on Windows: start npm agents through their .cmd shim
npm puts a Unix shell script with the bare name (codex-acp,
claude-code-acp, gemini) beside the .cmd shim. The lookup tried the bare
name first, so every npm-installed agent failed with "not a valid Win32
application (os error 193)". Windows now only considers .exe/.cmd/.bat,
and a command saved with the bare path runs the shim beside it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 60ea8c69d2)
2026-10-15 21:51:01 +00:00
bherbruck 22d1cd7032 Release 0.13.0 2026-10-15 20:34:13 +00:00
bherbruck 6a0dc18655 Fix the Windows 32-bit and macOS release builds
On 32-bit Windows SetWindowLongPtrW is SetWindowLongW, which takes and
returns i32, so the window-procedure hook didn't compile for i686.
The macOS app bundle now signs the bundled solidcraft-cli before the
bundle itself; codesign refuses a bundle with an unsigned helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 94e76cd97e)
2026-10-15 20:34:12 +00:00
bherbruck e006dbf1af Release 0.13.0
The tree is the 0.13.0 version commit (8241a750fa) on main exactly; the previous release is kept as a parent so release only moves forward.
2026-10-15 19:59:50 +00:00
bherbruck 8241a750fa Release 0.13.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:59:42 +00:00
bherbruck 41779cf7bb Release 0.12.0: release branch follows main again after the 0.11.1 patch
The tree is main at c36a6f9f exactly; the 0.11.1 patch commit is kept as a parent so release only moves forward.
2026-10-15 13:32:01 +00:00
bherbruck b01b9689c8 Release 0.11.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:41:41 +00:00
bherbruck 4426da6f5d Windows: start Direct3D 12 first, and Vulkan only without a D3D12 GPU (#1190)
* Windows: start Direct3D 12 first, and Vulkan only without a D3D12 GPU

wgpu started Vulkan, D3D12 and OpenGL together and preferred Vulkan, so
every Windows start loaded all installed Vulkan drivers. An old Intel
Vulkan driver (igvk64.dll) crashed 0.10.1 at startup inside the driver.

The app now probes D3D12, then Vulkan, then OpenGL, one backend at a
time, and starts only the first with a hardware adapter (WARP when none
has one). WGPU_BACKEND still wins; graphics.json's "backend" sets the
order by hand; the software renderer (safe mode, the retry) uses D3D12
alone. A new Windows CI job starts the real app under several settings.

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

* CI: a test filter YAML parses

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

* CI: the startup smoke test writes graphics.json where the app reads it

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

* CI: run the WARP shader tests one at a time

WARP's shader JIT (d3d10warp.dll) crashed twice in 124 runs of the gpu
tests with four test threads, with a null read inside its own optimizer
while several devices compiled compute pipelines at once. Serial runs
did not crash.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 95ea05e7c8)
2026-10-14 20:41:37 +00:00
bherbruck 5ead82e627 Release 0.11.0: release branch follows main again after the 0.10.1 patch
The tree is main at cccbe125 exactly; c29b0d2f (0.10.1) is kept as a parent so release only moves forward.
2026-10-14 20:07:41 +00:00
bherbruck c29b0d2f13 Release 0.10.1 2026-10-14 14:41:19 +00:00
bherbruck 480fd8d517 Fix the startup crash on D3D12 with FXC: CPU fallback for the tessellation pipeline, FXC-friendly shader (#1098) (#1099)
* GPU tessellation: a pipeline the device cannot build falls back to CPU meshing instead of crashing (#1098)

Build the tessellation compute pipeline inside error scopes; TessGpu::new returns an error,
and the viewport logs it and meshes bodies on the CPU. A Windows CI job builds the pipeline
with D3D12's FXC on the WARP adapter.

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

* Tessellation shader: basis functions in one-dimensional local arrays, so D3D12's FXC compiles it (#1098)

FXC has to unroll loops that write an array inside a struct (Basis.n0/n1) or a 2-D array
(ndu) by a counter, and cannot with run-time trip counts (X3511). A2.2 basis functions in a
local 1-D array returned whole; derivatives from the degree p-1 functions; the pole step
offsets computed rather than indexed.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:41:16 +00:00
bherbruck 4c09fa5355 Hovered and selected faces light solidly again over GPU-drawn bodies (#1064)
Since the model's mesh went back to the reference mesher (#940), the GPU viewport drew
opaque bodies from their display plans while hover and selection lit faces with the
reference mesh's triangles. Two triangulations of the same face at equal depth fought, so
a hovered face shimmered in wedges instead of lighting whole.

Faces lit over a body drawn from its plan now use the plan's own triangles
(ModelBody::plan_mesh, evaluated once on the CPU), and the highlight pass gets a small
depth bias toward the eye so f32 rounding between the GPU and CPU evaluations of the same
triangles cannot speckle it either.

Test: every face of a box with a pocket and a round boss, hovered and rendered offscreen
through the viewport's real pipelines on the GPU path, lights the same pixels as on the
CPU path (skipped without a GPU adapter).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:56:47 +00:00
264 changed files with 10996 additions and 1122 deletions
Generated
+33 -33
View File
@@ -4259,7 +4259,7 @@ dependencies = [
[[package]]
name = "solidcraft"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"block2 0.6.2",
"eframe",
@@ -4302,11 +4302,11 @@ dependencies = [
[[package]]
name = "solidcraft-3dx-client"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
[[package]]
name = "solidcraft-cli"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"image",
"log",
@@ -4322,11 +4322,11 @@ dependencies = [
[[package]]
name = "solidcraft-color"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
[[package]]
name = "solidcraft-control"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"getrandom 0.3.4",
"serde",
@@ -4335,7 +4335,7 @@ dependencies = [
[[package]]
name = "solidcraft-control-egui"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"egui",
"image",
@@ -4345,7 +4345,7 @@ dependencies = [
[[package]]
name = "solidcraft-derive"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"proc-macro2",
"quote",
@@ -4354,7 +4354,7 @@ dependencies = [
[[package]]
name = "solidcraft-doc"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"log",
"serde",
@@ -4376,7 +4376,7 @@ dependencies = [
[[package]]
name = "solidcraft-dynamics"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"serde",
"serde_json",
@@ -4387,7 +4387,7 @@ dependencies = [
[[package]]
name = "solidcraft-engine"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"image",
"log",
@@ -4413,7 +4413,7 @@ dependencies = [
[[package]]
name = "solidcraft-geom"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"serde",
"serde_json",
@@ -4426,7 +4426,7 @@ dependencies = [
[[package]]
name = "solidcraft-gpu"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"image",
"log",
@@ -4451,7 +4451,7 @@ dependencies = [
[[package]]
name = "solidcraft-io"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"cadmpeg-core",
"cadmpeg-ir",
@@ -4479,7 +4479,7 @@ dependencies = [
[[package]]
name = "solidcraft-kernel"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"log",
"proptest",
@@ -4500,7 +4500,7 @@ dependencies = [
[[package]]
name = "solidcraft-look"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"egui",
"log",
@@ -4512,7 +4512,7 @@ dependencies = [
[[package]]
name = "solidcraft-mcp"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"image",
"serde_json",
@@ -4526,14 +4526,14 @@ dependencies = [
[[package]]
name = "solidcraft-mcp-server"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"serde_json",
]
[[package]]
name = "solidcraft-profile"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"serde_json",
"web-time",
@@ -4541,7 +4541,7 @@ dependencies = [
[[package]]
name = "solidcraft-render"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"flate2",
"image",
@@ -4564,7 +4564,7 @@ dependencies = [
[[package]]
name = "solidcraft-sketch"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"harfrust",
"serde",
@@ -4578,7 +4578,7 @@ dependencies = [
[[package]]
name = "solidcraft-smlib"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"flate2",
"log",
@@ -4592,18 +4592,18 @@ dependencies = [
[[package]]
name = "solidcraft-space-mouse"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"serde",
]
[[package]]
name = "solidcraft-testkit"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
[[package]]
name = "solidcraft-theme"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"egui",
"serde",
@@ -4613,7 +4613,7 @@ dependencies = [
[[package]]
name = "solidcraft-ui-egui"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"arboard",
"egui",
@@ -4655,7 +4655,7 @@ dependencies = [
[[package]]
name = "solidcraft-ui-kit"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"arboard",
"egui",
@@ -4678,14 +4678,14 @@ dependencies = [
[[package]]
name = "solidcraft-ui-registry"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"egui",
]
[[package]]
name = "solidcraft-ui-text"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"log",
"serde_json",
@@ -4695,7 +4695,7 @@ dependencies = [
[[package]]
name = "solidcraft-viewport"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"image",
"serde",
@@ -4707,7 +4707,7 @@ dependencies = [
[[package]]
name = "solidcraft-viewport-egui"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"egui",
"egui-wgpu",
@@ -4718,7 +4718,7 @@ dependencies = [
[[package]]
name = "solidcraft-viewport-gpu"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"log",
"naga",
@@ -4734,7 +4734,7 @@ dependencies = [
[[package]]
name = "solidcraft-web"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"eframe",
"egui",
@@ -6220,7 +6220,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"flate2",
"proc-macro2",
+1 -1
View File
@@ -6,7 +6,7 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["solidcore", "vendor"]
[workspace.package]
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
+37 -6
View File
@@ -361,23 +361,54 @@ const UNSTABLE: [(&str, &str); 1] = [(
"the halves of each swept wall lie level along every axis, so which is `#1` and which `#2` follows the order the kernel gives the faces in, which changes from one build to the next",
)];
/// The designs that take longest to build, heaviest first, with one build's time (ci profile,
/// CLI `run`, one design at a time, on P-cores, 2026-11-10). The test hands them out first, and
/// each design's two builds go to two workers, so a heavy design is never built twice in a row on
/// one worker while the rest wait. Every other design took under 3.6 s. Only the order of the
/// work depends on this list: a stale entry makes the test slower, never wrong. Re-measure when a
/// design here gets much faster, or one not listed takes over 5 s.
const HEAVIEST: [&str; 6] = [
"oracle/77-fillet-variable-linear", // 35.6 s
"oracle/83-boundary-loop-thicken", // 12.4 s
"regressions/closed_sweep_torus_cut", // 6.8 s
"oracle/41-tube-radial-holes", // 4.6 s
"oracle/42-overlapping-patterns", // 4.2 s
"oracle/35-fillet-curved-curved", // 3.2 s
];
#[test]
fn corpus_designs_name_alike_every_build_and_fail_where_a_reference_is_gone() {
let designs = designs();
// Every design is built twice, as two jobs: (design, which build). The heaviest go first.
for heavy in HEAVIEST {
assert!(designs.iter().any(|(name, _)| name == heavy), "HEAVIEST lists `{heavy}`, which is not in the corpus any more");
}
let rank = |name: &str| HEAVIEST.iter().position(|h| *h == name).unwrap_or(HEAVIEST.len());
let mut jobs: Vec<(usize, usize)> = (0..designs.len()).flat_map(|d| [(d, 0), (d, 1)]).collect();
jobs.sort_by_key(|&(d, build)| (rank(&designs[d].0), d, build));
let next = std::sync::atomic::AtomicUsize::new(0);
let results = std::sync::Mutex::new(Vec::new());
let workers = std::thread::available_parallelism().map_or(4, |n| n.get()).min(designs.len().max(1));
let builds = std::sync::Mutex::new(std::collections::BTreeMap::new());
let workers = std::thread::available_parallelism().map_or(4, |n| n.get()).min(jobs.len().max(1));
std::thread::scope(|scope| {
for _ in 0..workers {
scope.spawn(|| {
while let Some((name, source)) = designs.get(next.fetch_add(1, std::sync::atomic::Ordering::Relaxed)) {
let got = load(source).and_then(|first| Ok((build(&first), build(&load(source)?))));
results.lock().unwrap_or_else(|e| e.into_inner()).push((name.clone(), got));
while let Some(&(d, which)) = jobs.get(next.fetch_add(1, std::sync::atomic::Ordering::Relaxed)) {
let got = load(&designs[d].1).map(|s| build(&s));
builds.lock().unwrap_or_else(|e| e.into_inner()).insert((d, which), got);
}
});
}
});
let mut results = results.into_inner().unwrap_or_else(|e| e.into_inner());
let mut builds = builds.into_inner().unwrap_or_else(|e| e.into_inner());
let mut results: Vec<(String, Result<(Build, Build), String>)> = designs
.iter()
.enumerate()
.map(|(d, (name, _))| {
let first = builds.remove(&(d, 0)).unwrap();
let second = builds.remove(&(d, 1)).unwrap();
(name.clone(), first.and_then(|first| Ok((first, second?))))
})
.collect();
results.sort_by(|a, b| a.0.cmp(&b.0));
let mut problems = Vec::new();
for (name, got) in &results {
+4 -1
View File
@@ -149,9 +149,12 @@ self.addEventListener("activate", (event) => {
return self.registration.unregister();
}
const older = (await caches.keys()).filter((name) => name.startsWith(CACHE_PREFIX) && name !== CACHE);
// Claim first: from the moment this worker is the active one, a page the old worker controlled has
// no controller until it is claimed, and a request it makes then goes straight to the network, which
// serves the newest build, not the page's. Nothing slow goes before this.
await self.clients.claim();
for (const name of older) await (await caches.open(name)).put(url(RETIRED), new Response(""));
await sweep();
await self.clients.claim();
// With no earlier build kept, the pages open when the first worker takes over came from the
// network, and get their files from it. After an update a page with no record is not
// assumed to be anything: its record may only be late, and the network now serves a newer
@@ -0,0 +1,83 @@
{
"en": {
"ui.agent_chat_answered_in": "Answered in {time}",
"ui.agent_chat_ended_after": "Ended after {time}",
"ui.agent_chat_span_h": "{h} h {min} min",
"ui.agent_chat_span_min": "{n} min",
"ui.agent_chat_span_s": "{n} s",
"ui.agent_chat_thinking_for": "Thinking for {time}",
"ui.agent_chat_tool_running_for": "running for {time}"
},
"ar": {
"ui.agent_chat_answered_in": "أُجيب خلال {time}",
"ui.agent_chat_ended_after": "انتهى بعد {time}",
"ui.agent_chat_span_h": "{h} ساعة {min} دقيقة",
"ui.agent_chat_span_min": "{n} دقيقة",
"ui.agent_chat_span_s": "{n} ث",
"ui.agent_chat_thinking_for": "يفكر منذ {time}",
"ui.agent_chat_tool_running_for": "قيد التشغيل منذ {time}"
},
"de": {
"ui.agent_chat_answered_in": "Beantwortet in {time}",
"ui.agent_chat_ended_after": "Beendet nach {time}",
"ui.agent_chat_span_h": "{h} Std. {min} Min.",
"ui.agent_chat_span_min": "{n} Min.",
"ui.agent_chat_span_s": "{n} Sek.",
"ui.agent_chat_thinking_for": "Denkt seit {time} nach",
"ui.agent_chat_tool_running_for": "läuft seit {time}"
},
"fr": {
"ui.agent_chat_answered_in": "Répondu en {time}",
"ui.agent_chat_ended_after": "Arrêté après {time}",
"ui.agent_chat_span_h": "{h} h {min} min",
"ui.agent_chat_span_min": "{n} min",
"ui.agent_chat_span_s": "{n} s",
"ui.agent_chat_thinking_for": "Réfléchit depuis {time}",
"ui.agent_chat_tool_running_for": "en cours depuis {time}"
},
"he": {
"ui.agent_chat_answered_in": "נענה תוך {time}",
"ui.agent_chat_ended_after": "הסתיים אחרי {time}",
"ui.agent_chat_span_h": "{h} שע' {min} דק'",
"ui.agent_chat_span_min": "{n} דק'",
"ui.agent_chat_span_s": "{n} שנ'",
"ui.agent_chat_thinking_for": "חושב כבר {time}",
"ui.agent_chat_tool_running_for": "רץ כבר {time}"
},
"ja": {
"ui.agent_chat_answered_in": "{time}で回答",
"ui.agent_chat_ended_after": "{time}後に終了",
"ui.agent_chat_span_h": "{h} 時間 {min} 分",
"ui.agent_chat_span_min": "{n} 分",
"ui.agent_chat_span_s": "{n} 秒",
"ui.agent_chat_thinking_for": "{time}考え中",
"ui.agent_chat_tool_running_for": "実行中 ({time})"
},
"ko": {
"ui.agent_chat_answered_in": "{time} 만에 답변",
"ui.agent_chat_ended_after": "{time} 후 종료",
"ui.agent_chat_span_h": "{h}시간 {min}분",
"ui.agent_chat_span_min": "{n}분",
"ui.agent_chat_span_s": "{n}초",
"ui.agent_chat_thinking_for": "{time}째 생각 중",
"ui.agent_chat_tool_running_for": "{time}째 실행 중"
},
"vi": {
"ui.agent_chat_answered_in": "Đã trả lời trong {time}",
"ui.agent_chat_ended_after": "Đã dừng sau {time}",
"ui.agent_chat_span_h": "{h} giờ {min} phút",
"ui.agent_chat_span_min": "{n} phút",
"ui.agent_chat_span_s": "{n} giây",
"ui.agent_chat_thinking_for": "Đang suy nghĩ {time}",
"ui.agent_chat_tool_running_for": "đang chạy {time}"
},
"zh": {
"ui.agent_chat_answered_in": "用时 {time} 完成回答",
"ui.agent_chat_ended_after": "{time} 后结束",
"ui.agent_chat_span_h": "{h} 小时 {min} 分钟",
"ui.agent_chat_span_min": "{n} 分钟",
"ui.agent_chat_span_s": "{n} 秒",
"ui.agent_chat_thinking_for": "已思考 {time}",
"ui.agent_chat_tool_running_for": "已运行 {time}"
}
}
@@ -0,0 +1,26 @@
{
"en": {
"cmd.assembly_constraint_create": "Constrain"
},
"de": {
"cmd.assembly_constraint_create": "Abhängigkeit"
},
"ja": {
"cmd.assembly_constraint_create": "拘束"
},
"zh": {
"cmd.assembly_constraint_create": "约束"
},
"ko": {
"cmd.assembly_constraint_create": "구속"
},
"vi": {
"cmd.assembly_constraint_create": "Ràng buộc"
},
"ar": {
"cmd.assembly_constraint_create": "تقييد"
},
"he": {
"cmd.assembly_constraint_create": "אילוץ"
}
}
@@ -0,0 +1,23 @@
{
"en": {
"dialog.component_origin": "{placement} · {item}"
},
"ar": {
"dialog.component_origin": "{placement} · {item}"
},
"de": {
"dialog.component_origin": "{placement} · {item}"
},
"he": {
"dialog.component_origin": "{placement} · {item}"
},
"ja": {
"dialog.component_origin": "{placement} · {item}"
},
"vi": {
"dialog.component_origin": "{placement} · {item}"
},
"zh": {
"dialog.component_origin": "{placement} · {item}"
}
}
@@ -0,0 +1,80 @@
{
"en": {
"cmd.construct.axis": "Axis",
"cmd.construct.plane": "Plane",
"cmd.construct.point": "Point",
"help.construct.axis": "Creates a construction axis with the method your picks call for: along an edge or line, through a hole or round face, where two planes meet, through two points, or square to a face through a point. Use it when you know the references and not the method.",
"help.construct.plane": "Creates a construction plane with the method your picks call for: offset from a plane, through a point, between two planes, through lines or points, tangent to a curved face, or square to a path. Use it when you know the references and not the method.",
"help.construct.point": "Creates a construction point with the method your picks call for: at a vertex, at the centre of a circle or sphere, along an edge, where two lines or three planes meet, or where a line crosses a plane. Use it when you know the references and not the method.",
"ui.construct_auto.chosen": "Chosen from your picks. Pick another reference, or take one out, to change the method.",
"ui.construct_auto.no_fit": "Pick other references: no method builds from these.",
"ui.construct_auto.pick": "Pick what to build from: the method follows from your picks.",
"ui.construct_auto.refs": "References",
"ui.construct_auto.select": "Select references to build from first"
},
"ja": {
"cmd.construct.axis": "軸",
"cmd.construct.plane": "平面",
"cmd.construct.point": "点",
"help.construct.axis": "選択に応じた方法で構築軸を作ります。エッジや線に沿って、穴や丸い面を通って、2つの平面の交線に、2点を通って、または点を通り面に垂直に作ります。参照は決まっているが方法を選びたくないときに使います。",
"help.construct.plane": "選択に応じた方法で構築平面を作ります。平面からのオフセット、点を通る、2つの平面の中間、線や点を通る、曲面に接する、またはパスに垂直に作ります。参照は決まっているが方法を選びたくないときに使います。",
"help.construct.point": "選択に応じた方法で構築点を作ります。頂点、円や球の中心、エッジ上、2本の線や3つの平面の交点、または線が平面と交わる位置に作ります。参照は決まっているが方法を選びたくないときに使います。",
"ui.construct_auto.chosen": "選択から方法を選びました。参照を追加または削除すると方法が変わります。",
"ui.construct_auto.no_fit": "別の参照を選択してください。これらから作れる方法がありません。",
"ui.construct_auto.pick": "作成元を選択してください。方法は選択から決まります。",
"ui.construct_auto.refs": "参照",
"ui.construct_auto.select": "先に作成元の参照を選択してください"
},
"zh": {
"cmd.construct.axis": "轴",
"cmd.construct.plane": "平面",
"cmd.construct.point": "点",
"help.construct.axis": "按所选对象对应的方法创建构造轴:沿边或直线、穿过孔或圆形面、两平面的交线、穿过两点,或过一点垂直于面。在已知参照但不想选择方法时使用。",
"help.construct.plane": "按所选对象对应的方法创建构造平面:偏移自平面、过一点、两平面之间、过直线或点、与曲面相切,或垂直于路径。在已知参照但不想选择方法时使用。",
"help.construct.point": "按所选对象对应的方法创建构造点:在顶点、圆或球的中心、沿边、两直线或三个平面的交点,或直线与平面的交点。在已知参照但不想选择方法时使用。",
"ui.construct_auto.chosen": "已根据所选对象选定方法。添加或移除参照即可更换方法。",
"ui.construct_auto.no_fit": "请选择其他参照:没有方法能由这些对象创建。",
"ui.construct_auto.pick": "选择创建依据:方法由所选对象决定。",
"ui.construct_auto.refs": "参照",
"ui.construct_auto.select": "请先选择创建依据的参照"
},
"ar": {
"cmd.construct.axis": "محور",
"cmd.construct.plane": "مستوى",
"cmd.construct.point": "نقطة",
"help.construct.axis": "ينشئ محورًا إنشائيًا بالطريقة التي تناسب ما تختاره: على امتداد حافة أو خط، أو عبر ثقب أو وجه دائري، أو عند تقاطع مستويين، أو عبر نقطتين، أو عموديًا على وجه عبر نقطة. استخدمه عندما تعرف المراجع ولا تعرف الطريقة.",
"help.construct.plane": "ينشئ مستوى إنشائيًا بالطريقة التي تناسب ما تختاره: إزاحة عن مستوى، أو عبر نقطة، أو بين مستويين، أو عبر خطوط أو نقاط، أو مماسًا لوجه منحنٍ، أو عموديًا على مسار. استخدمه عندما تعرف المراجع ولا تعرف الطريقة.",
"help.construct.point": "ينشئ نقطة إنشائية بالطريقة التي تناسب ما تختاره: عند رأس، أو في مركز دائرة أو كرة، أو على امتداد حافة، أو عند تقاطع خطين أو ثلاثة مستويات، أو حيث يقطع خط مستوى. استخدمه عندما تعرف المراجع ولا تعرف الطريقة.",
"ui.construct_auto.chosen": "اختيرت الطريقة من اختياراتك. اختر مرجعًا آخر أو أزل مرجعًا لتغيير الطريقة.",
"ui.construct_auto.no_fit": "اختر مراجع أخرى: لا توجد طريقة تبني من هذه المراجع.",
"ui.construct_auto.pick": "اختر ما تبني منه: الطريقة تتبع اختياراتك.",
"ui.construct_auto.refs": "المراجع",
"ui.construct_auto.select": "اختر أولًا المراجع التي تبني منها"
},
"he": {
"cmd.construct.axis": "ציר",
"cmd.construct.plane": "מישור",
"cmd.construct.point": "נקודה",
"help.construct.axis": "יוצר ציר עזר בשיטה שמתאימה למה שבחרת: לאורך קצה או קו, דרך חור או פאה עגולה, במפגש של שני מישורים, דרך שתי נקודות, או ניצב לפאה דרך נקודה. השתמש בו כשאתה יודע את ההפניות ולא את השיטה.",
"help.construct.plane": "יוצר מישור עזר בשיטה שמתאימה למה שבחרת: בהיסט ממישור, דרך נקודה, בין שני מישורים, דרך קווים או נקודות, משיק לפאה מעוקלת, או ניצב למסלול. השתמש בו כשאתה יודע את ההפניות ולא את השיטה.",
"help.construct.point": "יוצר נקודת עזר בשיטה שמתאימה למה שבחרת: בקודקוד, במרכז של מעגל או כדור, לאורך קצה, במפגש של שני קווים או שלושה מישורים, או במקום שבו קו חוצה מישור. השתמש בו כשאתה יודע את ההפניות ולא את השיטה.",
"ui.construct_auto.chosen": "השיטה נבחרה לפי הבחירות שלך. בחר הפניה נוספת, או הסר אחת, כדי לשנות את השיטה.",
"ui.construct_auto.no_fit": "בחר הפניות אחרות: אין שיטה שבונה מאלה.",
"ui.construct_auto.pick": "בחר ממה לבנות: השיטה נקבעת לפי הבחירות שלך.",
"ui.construct_auto.refs": "הפניות",
"ui.construct_auto.select": "בחר קודם את ההפניות לבנייה"
},
"vi": {
"cmd.construct.axis": "Trục",
"cmd.construct.plane": "Mặt phẳng",
"cmd.construct.point": "Điểm",
"help.construct.axis": "Tạo trục dựng theo phương pháp phù hợp với những gì bạn chọn: dọc theo cạnh hoặc đường, qua lỗ hoặc mặt tròn, nơi hai mặt phẳng giao nhau, qua hai điểm, hoặc vuông góc với mặt qua một điểm. Dùng khi bạn biết các tham chiếu nhưng không biết phương pháp.",
"help.construct.plane": "Tạo mặt phẳng dựng theo phương pháp phù hợp với những gì bạn chọn: cách một mặt phẳng, qua một điểm, giữa hai mặt phẳng, qua các đường hoặc điểm, tiếp xúc với mặt cong, hoặc vuông góc với đường dẫn. Dùng khi bạn biết các tham chiếu nhưng không biết phương pháp.",
"help.construct.point": "Tạo điểm dựng theo phương pháp phù hợp với những gì bạn chọn: tại đỉnh, tại tâm đường tròn hoặc mặt cầu, dọc theo cạnh, nơi hai đường hoặc ba mặt phẳng giao nhau, hoặc nơi một đường cắt mặt phẳng. Dùng khi bạn biết các tham chiếu nhưng không biết phương pháp.",
"ui.construct_auto.chosen": "Đã chọn phương pháp theo lựa chọn của bạn. Chọn thêm hoặc bỏ một tham chiếu để đổi phương pháp.",
"ui.construct_auto.no_fit": "Hãy chọn tham chiếu khác: không phương pháp nào dựng được từ các tham chiếu này.",
"ui.construct_auto.pick": "Chọn những gì để dựng: phương pháp theo lựa chọn của bạn.",
"ui.construct_auto.refs": "Tham chiếu",
"ui.construct_auto.select": "Hãy chọn tham chiếu để dựng trước"
}
}
@@ -0,0 +1,34 @@
{
"en": {
"prefs.dialog_first_letter_buttons": "Dialog buttons answer their first letter",
"prefs.dialog_first_letter_buttons_help": "With a dialog open, typing the first letter of a button's label presses it: O for OK, C for Cancel. A letter that fits two buttons does nothing, and a focused text field keeps the keystroke. Off: typing does nothing to the buttons, as before."
},
"ar": {
"prefs.dialog_first_letter_buttons": "أزرار نوافذ الحوار تستجيب لحرفها الأول",
"prefs.dialog_first_letter_buttons_help": "عند فتح نافذة حوار، كتابة الحرف الأول من تسمية الزر تضغطه: حرف «ا» يضغط زر «إلغاء» مثلًا. الحرف الذي يناسب زرَّين لا يفعل شيئًا، وحقل النص المركَّز عليه يحتفظ بالتضغطة. عند الإيقاف: الكتابة لا تفعل شيئًا للأزرار، كما كان سابقًا."
},
"de": {
"prefs.dialog_first_letter_buttons": "Dialogschaltflächen reagieren auf ihren Anfangsbuchstaben",
"prefs.dialog_first_letter_buttons_help": "Ist ein Dialog geöffnet, drückt der Anfangsbuchstabe einer Beschriftung die zugehörige Schaltfläche: O für OK, A für Abbrechen. Ein Buchstabe, der zu zwei Schaltflächen passt, tut nichts, und ein fokussiertes Textfeld behält die Tasteneingabe. Aus: Eingaben drücken keine Schaltfläche, wie bisher."
},
"he": {
"prefs.dialog_first_letter_buttons": "כפתורי תיבות דו-שיח נענים לאות הראשונה שלהם",
"prefs.dialog_first_letter_buttons_help": "כשתיבת דו-שיח פתוחה, הקלדת האות הראשונה של שם הכפתור לוחצת עליו: האות «ב» לוחצת על «ביטול», למשל. אות שמתאימה לשני כפתורים אינה עושה דבר, ושדה טקסט שבו נמצא הסמן שומר את ההקלדה. כבוי: הקלדה אינה משפיעה על הכפתורים, כמו קודם."
},
"ja": {
"prefs.dialog_first_letter_buttons": "ダイアログのボタンはラベルの先頭文字で押せる",
"prefs.dialog_first_letter_buttons_help": "ダイアログが開いているとき、ボタンのラベルの先頭文字を入力するとそのボタンが押されます:「キ」で「キャンセル」など。2 つのボタンに当てはまる文字は何もしません。フォーカス中のテキストフィールドはそのまま入力を受け取ります。オフ:従来どおり、入力ではボタンは押されません。"
},
"ko": {
"prefs.dialog_first_letter_buttons": "다이얼로그 단추가 첫 글자에 반응",
"prefs.dialog_first_letter_buttons_help": "다이얼로그가 열려 있을 때 단추 레이블의 첫 글자를 입력하면 그 단추를 누릅니다: 예를 들어 「ㅊ」은 「취소」를 누릅니다. 두 단추에 모두 해당하는 글자는 아무 동작을 하지 않으며, 포커스된 텍스트 필드는 입력을 그대로 받습니다. 끔: 기존처럼 입력이 단추에 영향을 주지 않습니다."
},
"vi": {
"prefs.dialog_first_letter_buttons": "Nút trong hộp thoại trả lời chữ cái đầu của nhãn",
"prefs.dialog_first_letter_buttons_help": "Khi một hộp thoại đang mở, gõ chữ cái đầu của nhãn một nút sẽ bấm nút đó: chữ H bấm nút Hủy bỏ, chẳng hạn. Một chữ khớp với hai nút không thực hiện gì, và trường văn bản đang tiêu điểm giữ lại phím gõ. Tắt: gõ phím không bấm nút, như trước đây."
},
"zh": {
"prefs.dialog_first_letter_buttons": "对话框按钮响应其首字母",
"prefs.dialog_first_letter_buttons_help": "打开对话框时,按下按钮标签的首字母即可触发该按钮:如“取”对应“取消”。一个字母若匹配两个按钮则不起作用,获得焦点的文本框保留按键输入。关闭时:按键不影响按钮,与以前相同。"
}
}
+38
View File
@@ -0,0 +1,38 @@
{
"ar": {
"cmd.note_add": "إضافة ملاحظة",
"cmd.note_delete": "حذف ملاحظة",
"cmd.note_list": "عرض الملاحظات",
"cmd.note_update": "تحرير الملاحظة"
},
"en": {
"cmd.note_add": "Add Note",
"cmd.note_delete": "Delete Note",
"cmd.note_list": "List Notes",
"cmd.note_update": "Edit Note"
},
"he": {
"cmd.note_add": "הוספת הערה",
"cmd.note_delete": "מחיקת הערה",
"cmd.note_list": "הצגת הערות",
"cmd.note_update": "עריכת הערה"
},
"ja": {
"cmd.note_add": "メモを追加",
"cmd.note_delete": "メモを削除",
"cmd.note_list": "メモを一覧表示",
"cmd.note_update": "メモを編集"
},
"vi": {
"cmd.note_add": "Thêm ghi chú",
"cmd.note_delete": "Xóa ghi chú",
"cmd.note_list": "Liệt kê ghi chú",
"cmd.note_update": "Chỉnh sửa ghi chú"
},
"zh": {
"cmd.note_add": "添加备注",
"cmd.note_delete": "删除备注",
"cmd.note_list": "列出备注",
"cmd.note_update": "编辑备注"
}
}
@@ -0,0 +1,240 @@
{
"en": {
"Vertical": "Vertical",
"sketch_text.valign_baseline": "Baseline",
"sketch_text.valign_top": "Top",
"sketch_text.valign_middle": "Middle",
"sketch_text.valign_bottom": "Bottom"
},
"ar": {
"Vertical": "عمودي",
"sketch_text.valign_baseline": "خط الأساس",
"sketch_text.valign_top": "أعلى",
"sketch_text.valign_middle": "وسط",
"sketch_text.valign_bottom": "أسفل"
},
"bg": {
"Vertical": "Вертикално",
"sketch_text.valign_baseline": "Базова линия",
"sketch_text.valign_top": "Горе",
"sketch_text.valign_middle": "Среда",
"sketch_text.valign_bottom": "Долу"
},
"bs": {
"Vertical": "Vertikalno",
"sketch_text.valign_baseline": "Osnovna linija",
"sketch_text.valign_top": "Vrh",
"sketch_text.valign_middle": "Sredina",
"sketch_text.valign_bottom": "Dno"
},
"ca": {
"Vertical": "Vertical",
"sketch_text.valign_baseline": "Línia base",
"sketch_text.valign_top": "Dalt",
"sketch_text.valign_middle": "Mig",
"sketch_text.valign_bottom": "Baix"
},
"cs": {
"Vertical": "Svisle",
"sketch_text.valign_baseline": "Účaří",
"sketch_text.valign_top": "Nahoře",
"sketch_text.valign_middle": "Uprostřed",
"sketch_text.valign_bottom": "Dole"
},
"da": {
"Vertical": "Lodret",
"sketch_text.valign_baseline": "Grundlinje",
"sketch_text.valign_top": "Top",
"sketch_text.valign_middle": "Midte",
"sketch_text.valign_bottom": "Bund"
},
"de": {
"Vertical": "Vertikal",
"sketch_text.valign_baseline": "Grundlinie",
"sketch_text.valign_top": "Oben",
"sketch_text.valign_middle": "Mitte",
"sketch_text.valign_bottom": "Unten"
},
"es": {
"Vertical": "Vertical",
"sketch_text.valign_baseline": "Línea base",
"sketch_text.valign_top": "Arriba",
"sketch_text.valign_middle": "Centro",
"sketch_text.valign_bottom": "Abajo"
},
"fi": {
"Vertical": "Pystysuunta",
"sketch_text.valign_baseline": "Perusviiva",
"sketch_text.valign_top": "Ylä",
"sketch_text.valign_middle": "Keski",
"sketch_text.valign_bottom": "Ala"
},
"fr": {
"Vertical": "Vertical",
"sketch_text.valign_baseline": "Ligne de base",
"sketch_text.valign_top": "Haut",
"sketch_text.valign_middle": "Milieu",
"sketch_text.valign_bottom": "Bas"
},
"gl": {
"Vertical": "Vertical",
"sketch_text.valign_baseline": "Liña base",
"sketch_text.valign_top": "Arriba",
"sketch_text.valign_middle": "Medio",
"sketch_text.valign_bottom": "Abaixo"
},
"he": {
"Vertical": "אנכי",
"sketch_text.valign_baseline": "קו בסיס",
"sketch_text.valign_top": "למעלה",
"sketch_text.valign_middle": "אמצע",
"sketch_text.valign_bottom": "למטה"
},
"hr": {
"Vertical": "Okomito",
"sketch_text.valign_baseline": "Osnovna linija",
"sketch_text.valign_top": "Vrh",
"sketch_text.valign_middle": "Sredina",
"sketch_text.valign_bottom": "Dno"
},
"hu": {
"Vertical": "Függőleges",
"sketch_text.valign_baseline": "Alapvonal",
"sketch_text.valign_top": "Fent",
"sketch_text.valign_middle": "Középen",
"sketch_text.valign_bottom": "Lent"
},
"id": {
"Vertical": "Vertikal",
"sketch_text.valign_baseline": "Garis dasar",
"sketch_text.valign_top": "Atas",
"sketch_text.valign_middle": "Tengah",
"sketch_text.valign_bottom": "Bawah"
},
"it": {
"Vertical": "Verticale",
"sketch_text.valign_baseline": "Linea di base",
"sketch_text.valign_top": "Alto",
"sketch_text.valign_middle": "Centro",
"sketch_text.valign_bottom": "Basso"
},
"ja": {
"Vertical": "垂直",
"sketch_text.valign_baseline": "ベースライン",
"sketch_text.valign_top": "上",
"sketch_text.valign_middle": "中央",
"sketch_text.valign_bottom": "下"
},
"ka": {
"Vertical": "ვერტიკალური",
"sketch_text.valign_baseline": "საბაზისო ხაზი",
"sketch_text.valign_top": "ზემოთ",
"sketch_text.valign_middle": "შუა",
"sketch_text.valign_bottom": "ქვემოთ"
},
"ko": {
"Vertical": "수직",
"sketch_text.valign_baseline": "기준선",
"sketch_text.valign_top": "위",
"sketch_text.valign_middle": "가운데",
"sketch_text.valign_bottom": "아래"
},
"ms": {
"Vertical": "Menegak",
"sketch_text.valign_baseline": "Garis dasar",
"sketch_text.valign_top": "Atas",
"sketch_text.valign_middle": "Tengah",
"sketch_text.valign_bottom": "Bawah"
},
"nb": {
"Vertical": "Loddrett",
"sketch_text.valign_baseline": "Grunnlinje",
"sketch_text.valign_top": "Topp",
"sketch_text.valign_middle": "Midt",
"sketch_text.valign_bottom": "Bunn"
},
"nl": {
"Vertical": "Verticaal",
"sketch_text.valign_baseline": "Basislijn",
"sketch_text.valign_top": "Boven",
"sketch_text.valign_middle": "Midden",
"sketch_text.valign_bottom": "Onder"
},
"pl": {
"Vertical": "Pionowo",
"sketch_text.valign_baseline": "Linia bazowa",
"sketch_text.valign_top": "Góra",
"sketch_text.valign_middle": "Środek",
"sketch_text.valign_bottom": "Dół"
},
"pt-BR": {
"Vertical": "Vertical",
"sketch_text.valign_baseline": "Linha de base",
"sketch_text.valign_top": "Topo",
"sketch_text.valign_middle": "Meio",
"sketch_text.valign_bottom": "Base"
},
"ro": {
"Vertical": "Vertical",
"sketch_text.valign_baseline": "Linie de bază",
"sketch_text.valign_top": "Sus",
"sketch_text.valign_middle": "Mijloc",
"sketch_text.valign_bottom": "Jos"
},
"ru": {
"Vertical": "По вертикали",
"sketch_text.valign_baseline": "Базовая линия",
"sketch_text.valign_top": "Верх",
"sketch_text.valign_middle": "Середина",
"sketch_text.valign_bottom": "Низ"
},
"sr": {
"Vertical": "Вертикално",
"sketch_text.valign_baseline": "Основна линија",
"sketch_text.valign_top": "Врх",
"sketch_text.valign_middle": "Средина",
"sketch_text.valign_bottom": "Дно"
},
"sv": {
"Vertical": "Lodrät",
"sketch_text.valign_baseline": "Baslinje",
"sketch_text.valign_top": "Överkant",
"sketch_text.valign_middle": "Mitten",
"sketch_text.valign_bottom": "Nederkant"
},
"tr": {
"Vertical": "Dikey",
"sketch_text.valign_baseline": "Taban çizgisi",
"sketch_text.valign_top": "Üst",
"sketch_text.valign_middle": "Orta",
"sketch_text.valign_bottom": "Alt"
},
"uk": {
"Vertical": "По вертикалі",
"sketch_text.valign_baseline": "Базова лінія",
"sketch_text.valign_top": "Верх",
"sketch_text.valign_middle": "Середина",
"sketch_text.valign_bottom": "Низ"
},
"vi": {
"Vertical": "Dọc",
"sketch_text.valign_baseline": "Đường cơ sở",
"sketch_text.valign_top": "Trên",
"sketch_text.valign_middle": "Giữa",
"sketch_text.valign_bottom": "Dưới"
},
"zh-TW": {
"Vertical": "垂直",
"sketch_text.valign_baseline": "基線",
"sketch_text.valign_top": "頂端",
"sketch_text.valign_middle": "置中",
"sketch_text.valign_bottom": "底端"
},
"zh": {
"Vertical": "垂直",
"sketch_text.valign_baseline": "基线",
"sketch_text.valign_top": "顶端",
"sketch_text.valign_middle": "居中",
"sketch_text.valign_bottom": "底端"
}
}
@@ -0,0 +1,3 @@
| File | Author | Source | Licence |
|---|---|---|---|
| crates/look/icons/design/svg/model/plane_auto.svg | SolidCraft contributors | original construction icon master (64 grid) | SolidCraft Brand and Interface Licence (LICENSE-BRAND) |
+1 -1
View File
@@ -797,7 +797,7 @@ impl Hub {
}
};
// Subscribed once the window named the design (not when it refused or timed out).
let sent = match joining.and_then(|counted| document_of(&reply).map(|seen| (counted, seen))) {
let sent = match joining.zip(document_of(&reply)) {
Some((counted, seen)) => match Subscription::start(documents, self.protocol.change_event(), counted, &out, seen, &reply) {
Some((subscription, sent)) => {
events = Some(subscription);
+56 -21
View File
@@ -97,7 +97,14 @@ pub fn clone_expression(source: &str, parameters: &BTreeMap<String, String>) ->
result
}
fn remap_geo(g: &mut crate::construct::GeoRef, ids: &BTreeMap<u64, u64>, bodies: &BTreeMap<String, String>, depth: usize) -> crate::Result<()> {
fn remap_geo(
g: &mut crate::construct::GeoRef,
ids: &BTreeMap<u64, u64>,
bodies: &BTreeMap<String, String>,
depth: usize,
occurrences: &BTreeMap<u64, u64>,
doc: &crate::Document,
) -> crate::Result<()> {
use crate::construct::GeoRef;
if depth > 64 {
return Err(crate::DocError::Invalid("clone plane reference depth exceeded".into()));
@@ -114,29 +121,38 @@ fn remap_geo(g: &mut crate::construct::GeoRef, ids: &BTreeMap<u64, u64>, bodies:
*n = remap_topology(n, ids, bodies);
}
}
GeoRef::Plane { plane } => remap_plane(plane, ids, bodies, depth + 1)?,
GeoRef::Plane { plane } => remap_plane(plane, ids, bodies, depth + 1, occurrences, doc)?,
GeoRef::Datum { datum } => remap_datum_path(datum, occurrences, doc)?,
_ => {}
}
Ok(())
}
fn remap_plane(p: &mut PlaneRef, ids: &BTreeMap<u64, u64>, bodies: &BTreeMap<String, String>, depth: usize) -> crate::Result<()> {
fn remap_plane(
p: &mut PlaneRef,
ids: &BTreeMap<u64, u64>,
bodies: &BTreeMap<String, String>,
depth: usize,
occurrences: &BTreeMap<u64, u64>,
doc: &crate::Document,
) -> crate::Result<()> {
if depth > 64 {
return Err(crate::DocError::Invalid("clone plane reference depth exceeded".into()));
}
match p {
PlaneRef::Offset { base, .. } | PlaneRef::Flipped { base, .. } => remap_plane(base, ids, bodies, depth + 1)?,
PlaneRef::Datum { datum } => remap_datum_path(datum, occurrences, doc)?,
PlaneRef::Offset { base, .. } | PlaneRef::Flipped { base, .. } => remap_plane(base, ids, bodies, depth + 1, occurrences, doc)?,
PlaneRef::ThroughPoint { base, point } => {
remap_plane(base, ids, bodies, depth + 1)?;
remap_geo(point, ids, bodies, depth + 1)?;
remap_plane(base, ids, bodies, depth + 1, occurrences, doc)?;
remap_geo(point, ids, bodies, depth + 1, occurrences, doc)?;
}
PlaneRef::AtAngle { base, line, pivot, .. } => {
remap_plane(base, ids, bodies, depth + 1)?;
remap_plane(base, ids, bodies, depth + 1, occurrences, doc)?;
if let Some(g) = line {
remap_geo(g, ids, bodies, depth + 1)?;
remap_geo(g, ids, bodies, depth + 1, occurrences, doc)?;
}
if let Some(g) = pivot {
remap_geo(g, ids, bodies, depth + 1)?;
remap_geo(g, ids, bodies, depth + 1, occurrences, doc)?;
}
}
PlaneRef::Face { name, anchor, .. } => {
@@ -149,13 +165,28 @@ fn remap_plane(p: &mut PlaneRef, ids: &BTreeMap<u64, u64>, bodies: &BTreeMap<Str
}
_ => {
for r in p.refs_mut() {
remap_geo(r, ids, bodies, depth + 1)?;
remap_geo(r, ids, bodies, depth + 1, occurrences, doc)?;
}
}
}
Ok(())
}
/// A datum reference names an exact placement by its complete occurrence path. A copy within one
/// design rewrites the path to the copy's own walk (the copied subtree's source ancestors are not
/// in the map); an empty occurrence map (a paste from another design) or a path that leaves the
/// copied tree is refused - the reference could not follow, and a dangling one would resolve
/// against whatever then sits at those ids.
fn remap_datum_path(d: &mut crate::construct::DatumRef, occurrences: &BTreeMap<u64, u64>, doc: &crate::Document) -> crate::Result<()> {
let last = d.path.last().copied().ok_or_else(|| crate::DocError::Invalid("a copied datum names no placement".into()))?;
let new = occurrences
.get(&last)
.copied()
.ok_or_else(|| crate::DocError::Invalid("the copied reference names a placement outside the copied tree".into()))?;
d.path = doc.occurrence_path(new).ok_or_else(|| crate::DocError::Invalid("the copied placement's path is broken".into()))?;
Ok(())
}
pub(crate) fn remap_link(
source: &mut solidcraft_sketch::LinkSource,
ids: &BTreeMap<u64, u64>,
@@ -204,6 +235,8 @@ impl crate::Feature {
names: &BTreeMap<String, String>,
bodies: &BTreeMap<String, String>,
parameters: &BTreeMap<String, String>,
occurrences: &BTreeMap<u64, u64>,
doc: &crate::Document,
) -> crate::Result<()> {
use FeatureKind as K;
match &self.kind {
@@ -266,7 +299,7 @@ impl crate::Feature {
self.kind.remap_bodies(bodies);
match &mut self.kind {
K::Sketch { plane, sketch } => {
remap_plane(plane, ids, bodies, 0)?;
remap_plane(plane, ids, bodies, 0, occurrences, doc)?;
for c in &mut sketch.constraints {
if let Some(n) = &mut c.param {
mapped_name(n, parameters);
@@ -290,31 +323,33 @@ impl crate::Feature {
}
K::FrameEndCap { ends, .. } => {
for end in ends {
remap_plane(&mut end.plane, ids, bodies, 0)?;
remap_plane(&mut end.plane, ids, bodies, 0, occurrences, doc)?;
}
}
K::ConstructionPlane { plane }
| K::Split { plane, .. }
| K::SplitFace { plane, .. }
| K::Mirror { plane, .. }
| K::SurfaceTrim { plane, .. } => remap_plane(plane, ids, bodies, 0)?,
K::Draft { neutral, .. } => remap_plane(neutral, ids, bodies, 0)?,
K::ReplaceFace { target, .. } => remap_plane(target, ids, bodies, 0)?,
K::SurfaceExtend { up_to: Some(crate::ExtendTo::Plane(plane)), .. } => remap_plane(plane, ids, bodies, 0)?,
K::Thread { up_to: Some(crate::ThreadLimit::Plane(plane)), .. } => remap_plane(plane, ids, bodies, 0)?,
| K::SurfaceTrim { plane, .. } => remap_plane(plane, ids, bodies, 0, occurrences, doc)?,
K::Draft { neutral, .. } => remap_plane(neutral, ids, bodies, 0, occurrences, doc)?,
K::ReplaceFace { target, .. } => remap_plane(target, ids, bodies, 0, occurrences, doc)?,
K::SurfaceExtend { up_to: Some(crate::ExtendTo::Plane(plane)), .. } => remap_plane(plane, ids, bodies, 0, occurrences, doc)?,
K::Thread { up_to: Some(crate::ThreadLimit::Plane(plane)), .. } => remap_plane(plane, ids, bodies, 0, occurrences, doc)?,
K::ConstructionAxis { def } => {
for r in def.refs_mut() {
remap_geo(r, ids, bodies, 0)?;
remap_geo(r, ids, bodies, 0, occurrences, doc)?;
}
}
K::ConstructionPoint { def } => {
for r in def.refs_mut() {
remap_geo(r, ids, bodies, 0)?;
remap_geo(r, ids, bodies, 0, occurrences, doc)?;
}
}
K::Revolve { axis: crate::AxisRef::Model { geo }, .. }
| K::SurfaceRevolve { axis: crate::AxisRef::Model { geo }, .. }
| K::Pattern { pattern: crate::PatternKind::Circular { axis_ref: Some(geo), .. }, .. } => remap_geo(geo, ids, bodies, 0)?,
| K::Pattern { pattern: crate::PatternKind::Circular { axis_ref: Some(geo), .. }, .. } => {
remap_geo(geo, ids, bodies, 0, occurrences, doc)?
}
// An Align frame's joint origin finds its faces and edges by name, as the feature's
// own face names are found.
K::Align { from_frame, to_frame, .. } => {
@@ -417,7 +452,7 @@ mod clone_tests {
value["name"] = json!("Copy");
let mut feature: crate::Feature = serde_json::from_value(value).unwrap();
let before = serde_json::to_string(&feature).unwrap();
feature.remap_clone(&BTreeMap::new(), &names, &BTreeMap::new(), &BTreeMap::new()).unwrap();
feature.remap_clone(&BTreeMap::new(), &names, &BTreeMap::new(), &BTreeMap::new(), &BTreeMap::new(), &crate::Document::default()).unwrap();
assert_eq!(serde_json::to_string(&feature).unwrap(), before.replace("\"Old\"", "\"New\""));
}
}
+1
View File
@@ -988,6 +988,7 @@ mod tests {
sketch,
report,
profiles: Vec::new(),
profiles_3d: Vec::new(),
ends: Default::default(),
}
}
+13
View File
@@ -1704,6 +1704,10 @@ pub enum FeatureKind {
#[serde(default = "all_profiles")]
#[walk(profiles_of = sketch)]
profiles: ProfileSel,
/// Indices into the sketch's planar 3D loops (`sketch.inspect`'s `profiles_3d`): each is
/// patched in the plane it lies in (#741).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
profiles_3d: Vec<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<BodyName>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
@@ -3395,6 +3399,9 @@ pub struct Document {
/// Named views: saved cameras.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub named_views: Vec<NamedView>,
/// Notes: text anchored to points in model space, drawn as screen-facing callouts (#1631).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub notes: Vec<crate::notes::Note>,
/// What was hidden when the design was saved. A session takes it from here when the design
/// opens and keeps the live state itself (`browser.visibility`: saved, and undone like any
/// other change), so a document in a session leaves this empty; the session puts it back when
@@ -3424,6 +3431,9 @@ pub struct Visibility {
/// The browser's Origin folder is hidden (`None`: the app's preference decides).
#[serde(skip_serializing_if = "Option::is_none")]
pub origin_hidden: Option<bool>,
/// Component Origin folders explicitly shown, by complete occurrence path. Hidden by default.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub shown_component_origins: Vec<Vec<u64>>,
/// Finished sketches whose closed profiles are not shaded, by feature id.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub hidden_profiles: Vec<u64>,
@@ -3451,6 +3461,7 @@ impl Visibility {
&& self.shown_sketches.is_empty()
&& self.hidden_joints.is_empty()
&& self.origin_hidden.is_none()
&& self.shown_component_origins.is_empty()
&& self.hidden_profiles.is_empty()
&& self.shown_dimensions.is_empty()
&& self.locked_bodies.is_empty()
@@ -3633,6 +3644,7 @@ impl Document {
.chain(self.timeline_groups.iter().map(|g| g.id))
.chain(self.canvases.iter().map(|c| c.id))
.chain(self.point_clouds.iter().map(|c| c.id))
.chain(self.notes.iter().map(|n| n.id))
.chain(a.joints.iter().map(|j| j.id))
.chain(a.contact_sets.iter().map(|c| c.id))
.chain(a.studies.iter().map(|s| s.id))
@@ -3688,6 +3700,7 @@ impl Document {
timeline_groups: Vec::new(),
browser_order: Default::default(),
named_views: Vec::new(),
notes: Vec::new(),
visibility: Visibility::default(),
}
}
+5 -1
View File
@@ -437,6 +437,8 @@ pub struct SolvedSketch {
pub sketch: solidcraft_sketch::Sketch,
pub report: SolveReport,
pub profiles: Vec<Profile>,
/// Planar closed loops the 3D sketch curves take part in, in planes of their own (#741).
pub profiles_3d: Vec<solidcraft_sketch::Profile3D>,
/// Open and untied ends of its profile curves (where a profile can fail).
pub ends: solidcraft_sketch::EndReport,
}
@@ -2334,6 +2336,7 @@ impl Model {
name: last.name.clone(),
plane: last.plane,
profiles: find_profiles(&sketch),
profiles_3d: solidcraft_sketch::find_planar_3d_profiles(&sketch, last.plane),
ends: solidcraft_sketch::loose_ends(&sketch),
sketch,
report,
@@ -9339,9 +9342,10 @@ fn eval_sketch(
append_warning(warning, profiles_cut_short_note());
}
let profiles = search.profiles;
let profiles_3d = solidcraft_sketch::find_planar_3d_profiles(&sk, plane);
let ends = solidcraft_sketch::loose_ends(&sk);
st.sketches.retain(|s| s.feature != f.id);
st.sketches.push(Arc::new(SolvedSketch { feature: f.id, name: f.name.clone(), plane, sketch: sk, report, profiles, ends }));
st.sketches.push(Arc::new(SolvedSketch { feature: f.id, name: f.name.clone(), plane, sketch: sk, report, profiles, profiles_3d, ends }));
Ok(())
}
+1
View File
@@ -142,6 +142,7 @@ fn joint_face_measurements_are_shared_across_motion_samples_and_snap_kinds() {
suppressed: false,
exprs: Default::default(),
captured: None,
constraint: None,
});
// One body supplies both old nearest-face snaps and persistent named-face snaps.
// A scratch model shares its measurements, even though each sample changes the pose.
+12 -5
View File
@@ -8,13 +8,20 @@ use solidcore_record::Seg2;
pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState, warning: &mut Option<String>) -> Result<()> {
match &f.kind {
FeatureKind::Patch { sketch, profiles, body, edges, crown, continuity, points } => {
FeatureKind::Patch { sketch, profiles, profiles_3d, body, edges, crown, continuity, points } => {
let mut made = Vec::new();
if let Some(sk) = sketch {
let ss = st.sketch(sk.0).ok_or_else(|| DocError::Unknown(format!("sketch {sk} (it must come earlier in the timeline)")))?;
let regions = solidcraft_sketch::merge_regions(&select_profiles(ss, profiles)?);
for r in &regions {
made.push(kernel::patch_region(&ss.plane, r)?);
// An empty index list means the caller asked only for 3D loops (#741).
if !matches!(profiles, ProfileSel::Indices { indices } if indices.is_empty()) {
let regions = solidcraft_sketch::merge_regions(&select_profiles(ss, profiles)?);
for r in &regions {
made.push(kernel::patch_region(&ss.plane, r)?);
}
}
for &i in profiles_3d {
let p = ss.profiles_3d.get(i).ok_or_else(|| DocError::Invalid(format!("sketch {sk} has no 3D profile {i}")))?;
made.push(kernel::patch_region(&p.plane, &p.region)?);
}
}
if !edges.is_empty() {
@@ -33,7 +40,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
made.push(kernel::patch_edges_with(&b.body, &crate::plain(edges), &spec)?);
}
if made.is_empty() {
return Err(DocError::Invalid("a patch needs a sketch region or a loop of edges".into()));
return Err(DocError::Invalid("a patch needs a sketch region, a 3D sketch loop, or a loop of edges".into()));
}
for (k, b) in made.into_iter().enumerate() {
let name = new_name(st, f, k);
@@ -0,0 +1,4 @@
//! Component Origin folders explicitly shown save their complete occurrence paths
//! (`Visibility::shown_component_origins`; they are hidden by default). Older designs have none:
//! nothing new, they round-trip as they are. The last release warns about the newer format
//! number, opens what it can read and keeps the rest.
@@ -0,0 +1,9 @@
//! A joint can say which assembly constraint it was made as (`"constraint": "mate"`, `flush`,
//! `distance`, `concentric`, `insert`, `parallel`, `perpendicular`, `angle`, `tangent` or
//! `point_on_axis`), written only for a joint made with `assembly.constraint.create`. The joint
//! itself is an ordinary joint of the kind the rule needs (a mate is a planar joint), so the solver
//! never reads the field; it names the rule in the browser and the Constrain dialog, and makes the
//! joint start where its parts are when it is changed. Older designs need no transformation: their
//! joints have no constraint and are written as before. The last release opens a design with such
//! a joint with a warning and keeps the field it can't read; there the joint still solves as its
//! kind.
+4
View File
@@ -0,0 +1,4 @@
//! A design saves its notes (`notes`): text anchored to a point in model space, drawn in the
//! viewport as a small screen-facing callout. An older design has none, and opens exactly as
//! before. The last release refuses a design that has notes and asks for an update, instead of
//! opening it and losing them on the next save.
@@ -0,0 +1,5 @@
//! A sketch text's style can say where its block of lines sits up and down against its anchor
//! (`at`, or the curve it follows): `vertical_align` is `top`, `middle` or `bottom`, and is left
//! out for the first line's baseline, where every text sat before. Older designs need no rewrite:
//! without the key a text stays on its baseline. The last release opens a design that carries the
//! key with a warning, keeps what it can't read, and draws such a text on its baseline.
@@ -0,0 +1,5 @@
//! A surface patch can patch planar loops of 3D sketch curves: `profiles_3d` holds indices into
//! the sketch's planar 3D loops (`sketch.inspect`'s `profiles_3d`), each patched in the plane it
//! lies in (#741). Older designs need no transformation: without the field the patch takes its
//! profiles from the sketch's 2D profile list exactly as before. The last release refuses a
//! design saved with this (the newer format number).
+86
View File
@@ -429,6 +429,75 @@ impl JointKind {
}
}
/// The assembly constraint a joint was made as (`assembly.constraint.create`): the rule in the
/// words of the Constrain dialog. The joint itself is an ordinary joint of the kind the rule needs
/// (a mate is planar, a concentric constraint cylindrical); the solver never reads this.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Constraint {
/// Two planes against each other (planar), `offset` apart.
Mate,
/// Two planes facing the same way (planar, flipped), `offset` apart.
Flush,
/// A plane `offset` from a plane (planar) or a point `offset` from a plane (point on plane).
Distance,
/// Two axes on one line (cylindrical): B slides and turns along it.
Concentric,
/// Two round edges at one centre on one axis (revolute): B only turns.
Insert,
/// Two planes or axes parallel.
Parallel,
/// Two planes or axes square to each other.
Perpendicular,
/// Two planes or axes at `angle`.
Angle,
/// A cylinder resting on a plane (line on plane, `offset` its radius).
Tangent,
/// A point on an axis (point on line).
PointOnAxis,
}
impl Constraint {
pub const ALL: [Constraint; 10] = [
Constraint::Mate,
Constraint::Flush,
Constraint::Distance,
Constraint::Concentric,
Constraint::Insert,
Constraint::Parallel,
Constraint::Perpendicular,
Constraint::Angle,
Constraint::Tangent,
Constraint::PointOnAxis,
];
/// The rule's command name (`mate`, `point_on_axis`).
pub fn name(self) -> &'static str {
match self {
Constraint::Mate => "mate",
Constraint::Flush => "flush",
Constraint::Distance => "distance",
Constraint::Concentric => "concentric",
Constraint::Insert => "insert",
Constraint::Parallel => "parallel",
Constraint::Perpendicular => "perpendicular",
Constraint::Angle => "angle",
Constraint::Tangent => "tangent",
Constraint::PointOnAxis => "point_on_axis",
}
}
/// Case, spaces, `_` and `-` are ignored; `coincident` and `axis` are also taken.
pub fn parse(s: &str) -> Option<Constraint> {
let key = s.to_ascii_lowercase().replace([' ', '_', '-'], "");
match key.as_str() {
"coincident" => Some(Constraint::Mate),
"axis" => Some(Constraint::Concentric),
_ => Constraint::ALL.into_iter().find(|c| c.name().replace('_', "") == key),
}
}
}
/// A joint between two occurrences.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Joint {
@@ -462,6 +531,17 @@ pub struct Joint {
/// files from before captured joint values: such a joint keeps its values on Revert Position.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub captured: Option<Vec<f64>>,
/// The assembly constraint it was made as; `None` for a joint made as a joint.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub constraint: Option<Constraint>,
}
impl Joint {
/// Whether its values start where its parts are when it is made or changed, so B moves no
/// further than the joint needs: a relation, or any joint made as an assembly constraint.
pub fn starts_where_it_is(&self) -> bool {
self.kind.is_relation() || self.constraint.is_some()
}
}
/// Expressions a joint's numbers follow (over the design's parameters): the offset (a length),
@@ -2527,6 +2607,11 @@ impl Document {
let t = if o.occurrence == 0 { IDENTITY } else { occurrence_world(self, o.occurrence) };
Ok(mat_mul(&t, &f))
}
/// The radius of the round face a circle origin is on (mm; an error when it is on none).
pub fn origin_radius(&self, st: &ModelState, o: &JointOrigin) -> Result<f64> {
circle_at(self, st, o).map(|c| c.radius)
}
}
#[path = "joints_cam.rs"]
@@ -2756,6 +2841,7 @@ mod tests {
suppressed: false,
exprs: Default::default(),
captured: None,
constraint: None,
})
.collect();
let links =
+1
View File
@@ -945,6 +945,7 @@ mod tests {
suppressed: false,
exprs: Default::default(),
captured: None,
constraint: None,
};
let occ = |id: u64, grounded: bool| crate::Occurrence { id, component: id, parent: 0, name: format!("O{id}"), transform: IDENTITY, grounded };
// 1 frame (grounded), 2 cam, 3-6 a rigid chain to the guide 7, 8 the piston, 9 crank, 10 rod.
+1
View File
@@ -94,6 +94,7 @@ mod tests {
suppressed: false,
exprs: Default::default(),
captured: None,
constraint: None,
}
}
+2
View File
@@ -49,6 +49,8 @@ mod lineage;
pub mod metadata;
pub use metadata::Metadata;
pub mod motion;
pub mod notes;
pub use notes::Note;
mod name_lookup;
mod names;
pub mod naming;
+3 -1
View File
@@ -409,12 +409,14 @@ impl SavedModel {
let mut sketches = Vec::new();
for s in list("sketches")? {
let sketch: solidcraft_sketch::Sketch = serde_json::from_value(parse(s.get("sketch"))?).map_err(|_| bad("saved model: sketch"))?;
let plane: Plane = serde_json::from_value(parse(s.get("plane"))?).map_err(|_| bad("saved model: sketch"))?;
sketches.push(Arc::new(SolvedSketch {
feature: s.get("feature").and_then(Json::as_u64).ok_or_else(|| bad("saved model: sketch"))?,
name: text(s.get("name"))?,
plane: serde_json::from_value(parse(s.get("plane"))?).map_err(|_| bad("saved model: sketch"))?,
plane,
report: serde_json::from_value(parse(s.get("report"))?).map_err(|_| bad("saved model: sketch"))?,
profiles: find_profiles(&sketch),
profiles_3d: solidcraft_sketch::find_planar_3d_profiles(&sketch, plane),
ends: solidcraft_sketch::loose_ends(&sketch),
sketch,
}));
+39
View File
@@ -350,6 +350,45 @@ fn an_edge_named_by_a_piece_of_a_face_that_is_whole_again_is_found() {
assert!(crate::name_lookup::locate_edge(body, &gone, near).is_err(), "{gone}");
}
/// #13464: a mesh's flat patch outline drops a corner part way along a side, even when single
/// precision has moved it off the line (as an STL's corners are), and when the loop starts there.
#[test]
fn a_mesh_outline_drops_a_rounded_corner_part_way_along_a_side() {
let single = |x: f64| f64::from(x as f32);
// a, c and d make a triangle; b lies on its side a–c up to single-precision rounding.
let a = Vec3::new(0.0, 0.0, 0.0);
let b = Vec3::new(single(0.3), single(0.33), 0.0);
let c = Vec3::new(1.0, single(1.1), 0.0);
let d = Vec3::new(0.0, 2.0, 0.0);
let off = (b - a).cross(c - a).len() / (c - a).len();
assert!(off > 0.0 && off < 1e-7, "b is off the line only by rounding: {off}");
// b first, so the outline's loop starts at it.
let mesh =
solidcraft_geom::Mesh { positions: vec![b, c, d, a], triangles: vec![[0, 1, 2], [0, 2, 3]], tri_face: vec![0, 0], ..Default::default() };
let loops = crate::project3d::flat_outline(&mesh, Vec3::new(0.3, 0.8, 0.0));
assert_eq!(loops.len(), 1, "{loops:?}");
let corners = loops.first().unwrap();
assert_eq!(corners.len(), 4, "three corners and the first again: {corners:?}");
assert!(!corners.iter().any(|p| p.dist(b) < 1e-12), "the corner on the side is gone: {corners:?}");
for p in [a, c, d] {
assert!(corners.iter().any(|q| q.dist(p) < 1e-12), "{p:?} is a corner: {corners:?}");
}
}
/// #13464: far from the origin, a small flat patch keeps its corners. A tolerance that grew with
/// the distance from the origin alone took every corner of a 1 × 1 square at x = 1 000 000 for one
/// on a straight side, and the face projected to nothing.
#[test]
fn a_small_mesh_patch_far_from_the_origin_keeps_its_corners() {
let x = 1_000_000.0;
let corners = [Vec3::new(x, 0.0, 0.0), Vec3::new(x + 1.0, 0.0, 0.0), Vec3::new(x + 1.0, 1.0, 0.0), Vec3::new(x, 1.0, 0.0)];
let mesh =
solidcraft_geom::Mesh { positions: corners.to_vec(), triangles: vec![[0, 1, 2], [0, 2, 3]], tri_face: vec![0, 0], ..Default::default() };
let loops = crate::project3d::flat_outline(&mesh, Vec3::new(x + 0.5, 0.5, 0.0));
assert_eq!(loops.len(), 1, "{loops:?}");
assert_eq!(loops.first().map(Vec::len), Some(5), "four corners and the first again: {loops:?}");
}
/// A ring turned from a rectangle on YZ about Z: each side of the rectangle makes one round face.
fn ring() -> Document {
let doc = Document {
+1
View File
@@ -1606,6 +1606,7 @@ mod tests {
sketch,
report,
profiles,
profiles_3d: Vec::new(),
ends: Default::default(),
};
let full_scan = |q: Vec2| {
+76
View File
@@ -0,0 +1,76 @@
//! Notes: text anchored to a point in model space, drawn in the viewport as a small
//! screen-facing callout (#1631). Personal design documentation — reminders, decisions,
//! warnings — saved with the design, not collaboration.
use serde::{Deserialize, Serialize};
use solidcore_record::Vec3;
/// Longest text a note may hold (characters).
pub const MAX_NOTE_CHARS: usize = 4000;
/// A note: an anchor point in model space (mm, design coordinates) and the text shown at
/// its projection. The callout always faces the screen because it is drawn in screen space.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Note {
pub id: u64,
/// The point the note is anchored to (design coordinates, mm).
pub anchor: Vec3,
/// The note's text (may span lines).
pub text: String,
/// Hidden notes are kept but not drawn (`browser.visibility` shows and hides them).
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub hidden: bool,
}
impl Note {
/// True when the text is usable: not empty and within [`MAX_NOTE_CHARS`].
pub fn text_is_valid(text: &str) -> bool {
!text.trim().is_empty() && text.chars().count() <= MAX_NOTE_CHARS
}
}
#[cfg(test)]
mod tests {
use super::*;
fn note(id: u64) -> Note {
Note { id, anchor: Vec3::new(1.0, 2.0, 3.0), text: format!("Check clearance here.\nLine {id}"), hidden: false }
}
#[test]
fn a_design_with_notes_survives_a_round_trip() {
let mut doc = crate::Document::new("Noted");
doc.notes.push(note(1));
doc.notes.push(Note { id: 2, anchor: Vec3::new(-4.0, 0.0, 6.5), text: "Adjust after the test print".into(), hidden: true });
let json = serde_json::to_string(&doc).unwrap();
let back: crate::Document = serde_json::from_str(&json).unwrap();
assert_eq!(back, doc);
assert_eq!(back.notes.len(), 2);
assert!(back.notes[1].hidden);
}
#[test]
fn a_design_saved_before_notes_loads_with_none() {
// An older design file: no `notes` key at all.
let json = serde_json::to_string(&crate::Document::new("Old")).unwrap();
let back: crate::Document = serde_json::from_str(&json).unwrap();
assert!(back.notes.is_empty());
}
#[test]
fn hidden_notes_are_not_written_when_they_are_visible() {
let mut doc = crate::Document::new("Noted");
doc.notes.push(note(1));
let json = serde_json::to_string(&doc).unwrap();
assert!(!json.contains("hidden"));
assert!(serde_json::from_str::<crate::Document>(&json).unwrap().notes[0].text.starts_with("Check"));
}
#[test]
fn note_text_must_be_non_empty_and_bounded() {
assert!(Note::text_is_valid("Extra thickness added for strength."));
assert!(!Note::text_is_valid(" "));
assert!(!Note::text_is_valid(&"x".repeat(MAX_NOTE_CHARS + 1)));
assert!(Note::text_is_valid(&"x".repeat(MAX_NOTE_CHARS)));
}
}
+1
View File
@@ -161,6 +161,7 @@ fn same_geometry_inputs(a: &Document, b: &Document) -> bool {
named_views,
visibility,
point_clouds,
notes: _,
} = a;
(Arc::ptr_eq(render, &b.render) || *render == b.render)
&& *format == b.format
+11 -2
View File
@@ -545,7 +545,16 @@ mod tests {
sketch.add_circle(Vec2::new(0., 0.), 3., None, Some("circle")).unwrap();
let report = solve(&mut sketch);
let profiles = find_profiles(&sketch);
let ss = SolvedSketch { feature: 1, name: "ordinary circle".into(), plane: Plane::XY, sketch, report, profiles, ends: Default::default() };
let ss = SolvedSketch {
feature: 1,
name: "ordinary circle".into(),
plane: Plane::XY,
sketch,
report,
profiles,
profiles_3d: Vec::new(),
ends: Default::default(),
};
assert_eq!(ss.profiles.len(), 1);
let indices = vec![0; 600];
let selection = ProfileSel::Indices { indices: indices.clone() };
@@ -593,7 +602,7 @@ mod tests {
}
let report = solve(&mut sketch);
let profiles = find_profiles(&sketch);
SolvedSketch { feature: 1, name: "S".into(), plane: Plane::XY, sketch, report, profiles, ends: Default::default() }
SolvedSketch { feature: 1, name: "S".into(), plane: Plane::XY, sketch, report, profiles, profiles_3d: Vec::new(), ends: Default::default() }
}
#[test]
+10 -3
View File
@@ -618,7 +618,8 @@ fn circular(samples: &[Vec2]) -> Option<(LinkGeom, f64)> {
sweep += d;
}
let turn = sweep.abs();
if turn > std::f64::consts::TAU + 1e-6 || turn < CIRCLE_MIN_TURN {
// A NaN turn is not refused here, as before.
if !turn.is_nan() && !(CIRCLE_MIN_TURN..=std::f64::consts::TAU + 1e-6).contains(&turn) {
return None;
}
if first.dist(last) <= tol {
@@ -1488,9 +1489,15 @@ const MESH_POLYGON_CORNERS: usize = 12;
/// [`MESH_POLYGON_CORNERS`], all on one circle, is a tessellated circle, an ellipse on a slanted
/// plane; and the sides of a polygon stay lines, never one fitted spline (#13464). A mesh file
/// keeps its corners in single precision, so they lie on the circle within about 1e-7 of their
/// size, not of the circle's.
/// distance from the origin, not of the circle's size; and never more loosely than 1e-4 of the
/// outline's own size, so a small outline far from the origin is not taken for a circle it misses.
fn fit_mesh_outline(plane: &Plane, pts: &[Vec3]) -> Vec<LinkGeom> {
let within = pts.iter().map(|p| p.len()).fold(1.0, f64::max) * 1e-6;
let (lo, hi) = pts.iter().fold(
(Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY), Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY)),
|(lo, hi), p| (lo.min(*p), hi.max(*p)),
);
let size = lo.dist(hi);
let within = (pts.iter().map(|p| p.len()).fold(1.0, f64::max) * 1e-6).min(size * 1e-4);
if pts.len() > MESH_POLYGON_CORNERS + 1
&& let Some(e) = tilted_circle(plane, pts, Some(within))
{
+13 -4
View File
@@ -379,7 +379,13 @@ pub(crate) fn flat_outline(m: &Mesh, at: Vec3) -> Vec<Vec<Vec3>> {
steps += 1;
}
if v == start && pts.len() >= 4 {
loops.extend(turning_corners(&pts, size * 1e-9));
// A corner off the line within the single precision a mesh file keeps is on it, but
// never one farther off than 1e-4 of the loop's own size: far from the origin a small
// patch's real corners stay.
let far = Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
let (lo, hi) = pts.iter().fold((far, -far), |(lo, hi), p| (lo.min(*p), hi.max(*p)));
let precision = (pts.iter().map(|p| p.len()).fold(1.0, f64::max) * 1e-6).min(lo.dist(hi) * 1e-4);
loops.extend(turning_corners(&pts, precision));
}
}
}
@@ -388,15 +394,18 @@ pub(crate) fn flat_outline(m: &Mesh, at: Vec3) -> Vec<Vec<Vec3>> {
/// A closed loop (its first point again at its end) without the corners where it runs straight
/// on: a mesh's outline often has a corner part way along a side, where a triangle of the patch
/// meets it. None when fewer than three corners turn.
/// meets it. A corner within `tol` of the line through its neighbours runs straight on. None when
/// fewer than three corners turn.
fn turning_corners(pts: &[Vec3], tol: f64) -> Option<Vec<Vec3>> {
let ring = pts.get(..pts.len().saturating_sub(1))?;
let n = ring.len();
let turns = |i: usize| {
let (prev, here, next) = (ring.get((i + n - 1) % n), ring.get(i), ring.get((i + 1) % n));
let (Some(prev), Some(here), Some(next)) = (prev, here, next) else { return false };
let (a, b) = (*here - *prev, *next - *here);
a.cross(b).len() > tol * a.len().max(b.len()).max(1.0)
// How far the corner lies off the line from the one before it to the one after.
let span = *next - *prev;
let off = (*here - *prev).cross(span).len() / span.len().max(1e-300);
span.len() <= tol || off > tol
};
let mut out: Vec<Vec3> = (0..n).filter(|i| turns(*i)).filter_map(|i| ring.get(i).copied()).collect();
if out.len() < 3 {
@@ -0,0 +1,188 @@
//! Assembly constraints (`assembly.constraint.create`): a geometric rule between a reference on
//! each of two parts (a mate, a flush, concentric holes…), in the words of the Constrain dialog.
//! Each rule is kept as a joint of the kind it needs, made where the parts are and tagged with the
//! rule (`Joint::constraint`), so the one joint solver places, counts and checks it with the
//! others: several constraints between one pair add up, and b keeps every motion they leave free.
use serde_json::Value;
use solidcraft_doc::joints::{Constraint, JointKind, JointOrigin, Snap};
use super::{CommandSpec, add_joint, new_joint, origin_param, value};
use crate::params::{bad, bool_, str_};
use crate::schema::joints::{CreateConstraintParams, JointAddedResult};
use crate::schema::{TypedContract, output_schema_for};
use crate::{Result, Session};
const CREATE_CONTRACT: TypedContract<CreateConstraintParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] =
&[CommandSpec::new("assembly.constraint.create", "Constrain", crate::checked!("assembly.constraint.create", create))
.icon("joint")
.contract(&CREATE_CONTRACT)
.output(output_schema_for::<JointAddedResult>)
.params(
"type: mate|flush|distance|concentric|insert|parallel|perpendicular|angle|tangent|point_on_axis; a, b: references, as joint.create's \
origins (a face or a datum plane for a plane, a circle or an axis for an axis, a point; b moves); offset?: the gap (mm or an expression; needed for distance); \
angle?: deg or an expression (needed for angle); flip?: turn b over; name? (default: the rule and a number, Mate1); quiet?. \
Kept as a joint of the kind the rule needs (mate and flush planar, distance planar or point on plane, concentric cylindrical, \
insert revolute, tangent line on plane, point on axis point on line), made where the parts are, so b moves no further than the \
rule needs → joint, name, dof, conflicts, fixed as joint.create",
)];
/// What a reference is, for the rules that need one kind.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Shape {
/// A planar face or a frame: its plane and normal.
Plane,
/// A round face's or edge's centre and axis.
Round,
/// A point (a vertex, an edge, a sketch point): its position.
Point,
/// A datum or construction axis: a line, with no rim or radius.
Axis,
}
fn shape(o: &JointOrigin) -> Shape {
match o.snap {
Snap::FaceCenter { .. } | Snap::Frame { .. } | Snap::Plane { .. } => Shape::Plane,
Snap::Axis { .. } => Shape::Axis,
Snap::CircleCenter { .. } => Shape::Round,
Snap::Point { .. } | Snap::Edge { .. } => Shape::Point,
}
}
/// How a rule is kept: the joint's kind, whether b is turned to face along a (`flip`), and whether
/// the two references were given the other way round (the plane or axis goes on side a).
struct Kept {
kind: JointKind,
/// `Some` where the rule fixes the facing; `None` keeps the parts' facing now.
flip: Option<bool>,
swap: bool,
}
fn kept(c: Constraint, a: Shape, b: Shape, cmd: &str) -> Result<Kept> {
use Shape::*;
let fixed = |kind, flip| Ok(Kept { kind, flip: Some(flip), swap: false });
let facing = |kind| Ok(Kept { kind, flip: None, swap: false });
// A line: a round face's or edge's axis, or an axis itself.
let axial = |s: Shape| matches!(s, Round | Axis);
// A rule between one plane or axis and something else: the plane or axis on side a.
let onto = |kind, want: &dyn Fn(Shape) -> bool, other_ok: &dyn Fn(Shape) -> bool| {
if want(a) && other_ok(b) {
Ok(Kept { kind, flip: Some(false), swap: false })
} else if want(b) && other_ok(a) {
Ok(Kept { kind, flip: Some(false), swap: true })
} else {
Err(bad(cmd, format!("{} needs {}", c.name(), needs(c))))
}
};
match (c, a, b) {
(Constraint::Mate, Plane, Plane) => fixed(JointKind::Planar, false),
(Constraint::Flush, Plane, Plane) => fixed(JointKind::Planar, true),
(Constraint::Distance, Plane, Plane) => facing(JointKind::Planar),
(Constraint::Distance, ..) => onto(JointKind::PointOnPlane, &|s| s == Plane, &|s| matches!(s, Round | Point)),
(Constraint::Concentric, ..) if axial(a) && axial(b) => facing(JointKind::Cylindrical),
(Constraint::Insert, Round, Round) => facing(JointKind::Revolute),
(Constraint::Parallel, ..) => facing(JointKind::Parallel),
(Constraint::Perpendicular, ..) => fixed(JointKind::Perpendicular, false),
(Constraint::Angle, ..) => fixed(JointKind::Angle, false),
(Constraint::Tangent, ..) => onto(JointKind::LineOnPlane, &|s| s == Plane, &|s| s == Round),
(Constraint::PointOnAxis, ..) => onto(JointKind::PointOnLine, &axial, &|s| s == Point),
_ => Err(bad(cmd, format!("{} needs {}", c.name(), needs(c)))),
}
}
/// The references a rule takes, for its refusal.
fn needs(c: Constraint) -> &'static str {
match c {
Constraint::Mate | Constraint::Flush => "two planar faces",
Constraint::Distance => "two planar faces, or a planar face and a point",
Constraint::Concentric => "two round faces, round edges or axes",
Constraint::Insert => "two round edges",
Constraint::Parallel | Constraint::Perpendicular | Constraint::Angle => "two faces or axes",
Constraint::Tangent => "a planar face and a round face",
Constraint::PointOnAxis => "a round face or edge (its axis) or an axis, and a point",
}
}
/// The rule's default name: `Mate1`, `Point on axis2`…, the first number no joint's name has.
fn default_name(s: &Session, c: Constraint) -> String {
let word = c.name().replace('_', " ");
let mut label = word.clone();
if let Some(first) = label.get(..1) {
label = first.to_ascii_uppercase() + label.get(1..).unwrap_or("");
}
let names: std::collections::BTreeSet<&str> = s.doc.assembly.joints.iter().map(|j| j.name.as_str()).collect();
let n = (1..=s.doc.assembly.joints.len().saturating_add(1)).find(|n| !names.contains(format!("{label}{n}").as_str())).unwrap_or(1);
format!("{label}{n}")
}
fn create(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "assembly.constraint.create";
let c = str_(p, "type").and_then(Constraint::parse).ok_or_else(|| {
bad(cmd, "`type` must be mate, flush, distance, concentric, insert, parallel, perpendicular, angle, tangent or point_on_axis")
})?;
let a = origin_param(s, p.get("a"), cmd)?;
let b = origin_param(s, p.get("b"), cmd)?;
// The numbers are read before the references are checked, in the order the contract reads them.
for (key, angle) in [("offset", false), ("angle", true)] {
if let Some(v) = p.get(key) {
value(s, v, angle, cmd)?;
}
}
let rule = kept(c, shape(&a), shape(&b), cmd)?;
let (a, b) = if rule.swap { (b, a) } else { (a, b) };
if c == Constraint::Distance && p.get("offset").is_none() {
return Err(bad(cmd, "distance needs `offset`"));
}
if c == Constraint::Angle && p.get("angle").is_none() {
return Err(bad(cmd, "angle needs `angle`"));
}
let flip = {
let st = s.model.state();
// The way b faces a now: along (true) or against. A rule that fixes neither keeps it, so b is
// not turned over.
let along = || -> Result<bool> {
let za = s.doc.origin_world(&st, &a).map_err(|e| bad(cmd, e.to_string()))?;
let zb = s.doc.origin_world(&st, &b).map_err(|e| bad(cmd, e.to_string()))?;
Ok((0..3).map(|i| za[2][i] * zb[2][i]).sum::<f64>() > 0.0)
};
let mut flip = match rule.flip {
Some(f) => f,
None => along()?,
};
if bool_(p, "flip") == Some(true) {
flip = !flip;
}
// A cylinder resting on a plane needs its round face's radius (refused when it has none).
if c == Constraint::Tangent {
s.doc.origin_radius(&st, &b).map_err(|e| bad(cmd, e.to_string()))?;
}
flip
};
// The joint's own params: the kind and facing the rule needs, the rest as given.
let mut q = p.clone();
if let Some(m) = q.as_object_mut() {
m.remove("type");
m.remove("flip");
m.remove("values");
m.remove("value");
m.remove("limits");
m.insert("flip".into(), Value::Bool(flip));
// No name, `null` or blank text: the rule's own name (`Mate1`), not a joint's.
if !m.get("name").and_then(Value::as_str).is_some_and(|n| !n.trim().is_empty()) {
m.insert("name".into(), Value::String(default_name(s, c)));
}
}
let mut j = new_joint(s, &q, rule.kind, a, b, cmd)?;
if c == Constraint::Tangent {
// The gap is kept as the offset's expression; the radius is added to it whenever the joints
// are solved (`reexpress`, which `add_joint`'s solve runs), so the part follows its face.
j.exprs.offset = Some(j.exprs.offset.take().unwrap_or_else(|| format!("{} mm", j.offset)));
}
if j.offset != 0.0 && !j.offset.is_finite() {
return Err(bad(cmd, "`offset` must be finite"));
}
j.constraint = Some(c);
add_joint(s, j, &q)
}
+71 -11
View File
@@ -137,7 +137,7 @@ pub static COMMANDS: &[CommandSpec] = &[
or joints?: [joint id or name] (their glyphs; they still constrain and move parts); \
or features?: [feature id or name] (the bodies they made; path?: [occurrence ids] for only that placement's); \
or folder?: bodies|sketches|construction|origin|joints|all (all of it; showing also shows its browser folders and groups, and `all` takes in the occurrence hides too); \
or tree_folders?: [browser folder or group key: c0/bodies, o:4/sketches, c0/g7, c0/joints, c0/canvases, c0/point_clouds] (the folder or group itself: its items keep their own state); \
or tree_folders?: [browser folder or group key: c0/bodies, o:4/sketches, o:4/origin, c0/g7, c0/joints, c0/canvases, c0/point_clouds] (the folder or group itself: its items keep their own state; component origin items use o:4/origin/XY etc. in items); \
or paths?: [[occurrence ids]] (exact placements and descendants), isolate?: bool (with paths or items of bodies, and visible:true: everything else is hidden, other placements as placements). Saved with the design and undone like any other change (the camera is not)",
),
CommandSpec::new("browser.expand_all", "Expand All", crate::checked!("browser.expand_all", expand_all))
@@ -187,7 +187,10 @@ fn keys(p: &Value, k: &str, cmd: &str) -> Result<Vec<String>> {
_ => None,
};
let v = v.ok_or_else(|| bad(cmd, format!("`{k}` must be a list of item keys")))?;
if v.len() > 10_000 || v.iter().any(|x| x.is_empty() || x.len() > 256) {
// Past the 256-character cap only the datum keys go through, each bounded by its own parser:
// an item's `o:<ids>/origin/<item>`, a folder's `o:<ids>/origin` - deep placements need them.
let past_cap = |x: &String| x.len() > 256 && crate::origins::item(x).is_none() && crate::origins::folder_path(x).is_none();
if v.len() > 10_000 || v.iter().any(|x| x.is_empty() || past_cap(x)) {
return Err(bad(cmd, format!("`{k}` has bad keys")));
}
Ok(v)
@@ -435,8 +438,6 @@ fn order(s: &mut Session, p: &Value) -> Result<Value> {
reply(cmd, BrowserOrdered { param_warnings: Vec::new() })
}
const ORIGIN: [&str; 7] = ["O", "X", "Y", "Z", "XY", "XZ", "YZ"];
/// A browser item whose visibility can change.
enum Shown {
Sketch(u64),
@@ -457,10 +458,13 @@ fn is_construction(k: &FeatureKind) -> bool {
/// The rule, the same for showing and for hiding: an exact name wins over a name that only
/// matches it by case, so the body called "Back Lid" is not the sketch called "Back lid"
/// (#6967). Among the exact matches the order is the one there always was: a feature (by id
/// or by name), then a body, then a joint. With no exact match the name is compared without
/// regard to case, as it always was: a feature first, then an origin item.
/// or by name), then a body, then a joint. With no exact match the key may name a component
/// origin item by its key; else the name is compared without regard to case, as it always was:
/// a feature first, then an origin item.
fn shown_item(s: &Session, world: &solidcraft_doc::ModelState, key: &str) -> Option<Shown> {
shown_item_exact(s, world, key).or_else(|| shown_item_ignore_case(s, world, key))
shown_item_exact(s, world, key)
.or_else(|| s.origin_geometry(key).map(|_| Shown::Origin(key.to_string())))
.or_else(|| shown_item_ignore_case(s, world, key))
}
/// The item a key names exactly: a feature, a body or a joint whose name is the key itself.
@@ -486,7 +490,7 @@ fn shown_item_ignore_case(s: &Session, world: &solidcraft_doc::ModelState, key:
if let Some(shown) = s.doc.find_feature(key).and_then(|f| shown_feature(world, f, key)) {
return Some(shown);
}
ORIGIN.iter().find(|o| o.eq_ignore_ascii_case(key)).map(|o| Shown::Origin(o.to_string()))
crate::origins::ITEMS.iter().find(|o| o.eq_ignore_ascii_case(key)).map(|o| Shown::Origin(o.to_string()))
}
/// The item a feature is: a sketch, or construction geometry by name. A feature that is neither
@@ -509,8 +513,11 @@ fn shown_feature(world: &solidcraft_doc::ModelState, f: &solidcraft_doc::Feature
}
/// A browser folder or group key (`Visibility::hidden_folders`): `c0` or `o:<ids>`, then
/// `/bodies`, `/sketches`, `/construction`, `/joints`, `/canvases` or `/g<group id>`.
/// `/bodies`, `/sketches`, `/construction`, `/joints`, `/canvases`, `/origin` or `/g<group id>`.
pub(crate) fn tree_folder_key(k: &str) -> bool {
if crate::origins::folder_path(k).is_some() {
return true;
}
let Some((scope, last)) = k.rsplit_once('/') else { return false };
let scope_ok =
scope == "c0" || scope.strip_prefix("o:").is_some_and(|ids| ids.split('/').count() <= 64 && ids.split('/').all(|i| i.parse::<u64>().is_ok()));
@@ -627,7 +634,28 @@ fn visibility(s: &mut Session, p: &Value) -> Result<Value> {
})
})
.collect::<Result<_>>()?;
// A component Origin folder's placement has to exist, and showing one is bounded.
for path in keys.iter().filter_map(|key| crate::origins::folder_path(key)) {
s.occurrence_placement(&path).map_err(|e| bad(cmd, e.to_string()))?;
}
if visible {
let mut shown: BTreeSet<_> = s.visibility.shown_component_origins.iter().cloned().collect();
shown.extend(keys.iter().filter_map(|key| crate::origins::folder_path(key)));
if shown.len() > crate::origins::MAX_SHOWN {
return Err(bad(cmd, "at most 10000 component origins can be shown at once"));
}
}
for k in &keys {
if let Some(path) = crate::origins::folder_path(k) {
if visible {
if !s.visibility.shown_component_origins.contains(&path) {
s.visibility.shown_component_origins.push(path);
}
} else {
s.visibility.shown_component_origins.retain(|p| p != &path);
}
continue;
}
s.visibility.hidden_folders.retain(|h| h != k);
if !visible {
s.visibility.hidden_folders.push(k.clone());
@@ -733,7 +761,7 @@ fn visibility(s: &mut Session, p: &Value) -> Result<Value> {
(Some("bodies"), None) => s.world_state()?.bodies.iter().map(|b| Shown::Body(b.name.clone())).collect(),
(Some("sketches"), None) => s.model.state().sketches.iter().map(|x| Shown::Sketch(x.feature)).collect(),
(Some("construction"), None) => s.doc.features.iter().filter(|f| is_construction(&f.kind)).map(|f| Shown::Origin(f.name.clone())).collect(),
(Some("origin"), None) => ORIGIN.iter().map(|o| Shown::Origin(o.to_string())).collect(),
(Some("origin"), None) => crate::origins::ITEMS.iter().map(|o| Shown::Origin(o.to_string())).collect(),
(Some("joints"), None) => s.doc.assembly.joints.iter().map(|j| Shown::Joint(j.id)).collect(),
// Everything at once, for a single "show all" step: every body, sketch, construction
// item, origin item and joint.
@@ -741,7 +769,7 @@ fn visibility(s: &mut Session, p: &Value) -> Result<Value> {
let mut all: Vec<Shown> = s.world_state()?.bodies.iter().map(|b| Shown::Body(b.name.clone())).collect();
all.extend(s.model.state().sketches.iter().map(|x| Shown::Sketch(x.feature)));
all.extend(s.doc.features.iter().filter(|f| is_construction(&f.kind)).map(|f| Shown::Origin(f.name.clone())));
all.extend(ORIGIN.iter().map(|o| Shown::Origin(o.to_string())));
all.extend(crate::origins::ITEMS.iter().map(|o| Shown::Origin(o.to_string())));
all.extend(s.doc.assembly.joints.iter().map(|j| Shown::Joint(j.id)));
all
}
@@ -906,6 +934,9 @@ fn collect_component_keys(s: &Session, prefix: &str, component: u64, root_extras
keys.closed.insert(format!("{prefix}/features"));
}
let occurrence = prefix.starts_with("o:");
if occurrence {
keys.closed.insert(format!("{prefix}/origin"));
}
for f in s.doc.features.iter().filter(|f| f.component == component) {
if let FeatureKind::Sketch { sketch, .. } = &f.kind
&& !sketch.patterns.is_empty()
@@ -2940,6 +2971,35 @@ mod tests {
assert!(plain.visibility.is_empty() && plain.visibility.origin_hidden.is_none());
}
/// A batch with a key that does not name anything (no such placement) fails whole and
/// changes nothing: the atomic contract, for the component origin keys too.
/// A visibility batch that fails validation changes nothing: every key is checked
/// before any of them is applied, and the failed command leaves the saved visibility
/// as it was (#7313).
#[test]
fn invalid_visibility_batches_are_atomic() {
let mut s = Session::default();
// The Origin folder hangs on a placement, so the key takes the placement's occurrence id
// (the create reply also carries the component id, which names no occurrence).
let created = run(&mut s, "component.create", json!({"name": "Part", "grounded": false}));
let occurrence = created["occurrence"].as_u64().unwrap();
let folder = crate::origins::folder(&[occurrence]);
run(&mut s, "browser.visibility", json!({"tree_folders": [&folder], "visible": true}));
run(&mut s, "browser.visibility", json!({"items": [crate::origins::key(&[occurrence], "X")], "visible": false}));
let before = s.visibility.clone();
// The bad key fails the whole batch: the valid folder key changes nothing.
assert!(s.execute("browser.visibility", &json!({"tree_folders": ["c0/bodies", "o:999999/origin"], "visible": true})).is_err());
assert_eq!(s.visibility, before, "invalid batches must be atomic");
// The item form is atomic the same way.
assert!(s.execute("browser.visibility", &json!({"items": ["X", "o:999999/origin/X"], "visible": true})).is_err());
assert_eq!(s.visibility, before);
// And the bounds hold: no `o:` prefix, a zero or non-canonical id, the design's own scope.
for invalid in ["o:/origin", "o:0/origin", "o:01/origin", "c0/origin"] {
assert!(s.execute("browser.visibility", &json!({"tree_folders": [invalid], "visible": true})).is_err(), "{invalid}");
}
assert_eq!(s.visibility, before);
}
#[test]
fn groups_hold_items_and_follow_renames() {
let mut s = Session::default();
+1 -1
View File
@@ -269,7 +269,7 @@ pub(super) fn paste(s: &mut Session, p: &Value) -> Result<Value> {
}
}
}
feature.remap_clone(&ids, &names, &BTreeMap::new(), &mapped)?;
feature.remap_clone(&ids, &names, &BTreeMap::new(), &mapped, &BTreeMap::new(), &copy.doc)?;
if let FeatureKind::Sketch { plane, sketch } = &mut feature.kind {
// 3D wires carry their own coordinate space, independently of the sketch plane.
for wire in &mut sketch.wires {
+1 -1
View File
@@ -486,7 +486,7 @@ pub(super) fn build(
} else if f.body_names.is_empty() {
f.body_names = outputs.get(&old.id).cloned().unwrap_or_default();
}
f.remap_clone(&features, &names, &bodies, &parameters)?;
f.remap_clone(&features, &names, &bodies, &parameters, &occurrences, &scratch.doc)?;
transfer_into_copy(&mut f.kind, &components)?;
if let FeatureKind::Emboss { face: Some(face), .. } = &mut f.kind
&& !face.path.is_empty()
@@ -24,6 +24,7 @@ fn joint(id: u64, a: u64, b: u64) -> Joint {
suppressed: false,
exprs: Default::default(),
captured: None,
constraint: None,
}
}
@@ -749,3 +749,51 @@ fn paste_new_of_a_mesh_import_with_one_label_on_all_objects_moves_the_copy_of_ea
assert_eq!(corners(&s, part), lifted());
assert_eq!(corners(&s, copied), lifted(), "{:?}", body_names(&s));
}
/// A construction axis through a nested placement's origin datum is copied with its datum path
/// rebuilt through the copy (the source's ancestors are not in the occurrence map); an axis
/// whose datum names a placement outside the copied tree refuses the copy - the reference could
/// not follow, and a dangling one would resolve against whatever then sits at those ids.
#[test]
fn cloned_datum_references_follow_the_copy() {
let mut s = Session::default();
let parent = component(&mut s, "Parent");
run(&mut s, "component.create", json!({"name": "Child", "parent": "Parent"}));
run(&mut s, "solid.box", json!({"length": 2, "width": 2, "height": 2, "operation": "new"}));
let parent_occ = s.doc.occurrence_of(parent).unwrap().id;
let child_occ = s.doc.occurrences.iter().find(|o| o.parent == parent).unwrap().id;
run(&mut s, "component.activate", json!({"component": "Parent"}));
run(
&mut s,
"construct.axis.two_points",
json!({"a": format!("o:{parent_occ}/{child_occ}/origin/O"), "b": {"point": [12, 0, 0]}, "name": "DatumAxis"}),
);
let r = copy(&mut s, parent);
let new_comp = r["component"].as_u64().unwrap();
let new_parent_occ = r["occurrence"].as_u64().unwrap();
let new_child_occ = s.doc.occurrences.iter().find(|o| o.parent == new_comp).unwrap().id;
let axis =
s.doc.features.iter().find(|f| f.component == new_comp && matches!(f.kind, solidcraft_doc::FeatureKind::ConstructionAxis { .. })).unwrap();
let solidcraft_doc::FeatureKind::ConstructionAxis { def } = &axis.kind else { panic!("expected a construction axis: {:?}", axis.kind) };
let solidcraft_doc::construct::AxisDef::TwoPoints { a, .. } = def else { panic!("expected a two-points axis: {def:?}") };
match a {
solidcraft_doc::construct::GeoRef::Datum { datum } => {
assert_eq!(datum.path, vec![new_parent_occ, new_child_occ], "the datum names the copy's own nested placement");
}
other => panic!("expected a datum reference, got {other:?}"),
}
// An axis whose datum names a placement outside the copied tree refuses the copy. Outside is
// made at the root: `component.create` places under the active component (the paste left
// Parent active), and a datum key names its placement from the root, so a placement inside
// Parent would be unreachable to the resolver and inside the copy's map at once.
run(&mut s, "component.activate", json!({"component": 0}));
let outside = component(&mut s, "Outside");
run(&mut s, "solid.box", json!({"length": 2, "width": 2, "height": 2, "operation": "new"}));
let outside_occ = s.doc.occurrence_of(outside).unwrap().id;
run(&mut s, "component.activate", json!({"component": "Parent"}));
run(&mut s, "construct.axis.two_points", json!({"a": format!("o:{outside_occ}/origin/O"), "b": {"point": [12, 0, 0]}, "name": "OutsideAxis"}));
assert!(
s.execute("component.paste_new", &json!({"component": parent, "translate": [40, 0, 0]})).is_err(),
"a datum into another tree cannot follow the copy"
);
}
+32 -22
View File
@@ -17,7 +17,7 @@ use serde_json::{Value, json};
use solidcraft_doc::{DesignLink, Document, FeatureKind, LinkedBodyProperties, UserMaterial};
use super::CommandSpec;
use super::design_link::{absolute, resolve, stored};
use super::design_link::{LinkState, absolute, link_presence, read_linked_file, resolve, same_file, stored};
use crate::params::{bad, str_, vec3};
use crate::schema::component_link::{
BreakLinkParams, BreakLinkResult, CopiesParams, CopiesResult, DesignInserted, ExtractParams, ExtractResult, InsertDesignParams,
@@ -112,7 +112,9 @@ pub static COMMANDS: &[CommandSpec] = &[
.params(
"→ linked components: component, name, path, file (resolved), found (null when the file is on a network location not allowed, \
or names a device: it is not looked for), changed (whether the file is no longer what this design last read: Update Links \
reads it again; null when not found or the link is older than this record), and then host (the network location) and reason",
reads it again; null when not found or the link is older than this record), then host (the network location); state (missing, \
unreadable, not_allowed: a network location not allowed or outside the folders this agent may use) and reason when the file \
can't be reached",
),
];
@@ -211,16 +213,12 @@ fn remember_read(file: &Path, bytes: u64, feature: &solidcraft_io::StepFeature,
/// The bytes of the design at `file`, which must not be the design saved at `own`.
fn read_link(file: &Path, own: Option<&str>) -> Result<Vec<u8>> {
let shown = file.display().to_string();
if own.is_some_and(|o| absolute(Path::new(o)) == absolute(file)) {
if own.is_some_and(|o| same_file(Path::new(o), file)) {
return Err(EngineError::Other(format!("{shown}: a design can't be linked into itself")));
}
// A network location only when its server is allowed, a device never: judged from the text
// before anything is read (on Windows, reading `\\host\share\x` connects to the host).
let design = solidcraft_io::limits::Kind::Design;
solidcraft_io::vfs::read_design_path(&shown, own, solidcraft_io::limits::max_design_bytes()).map_err(|e| {
let e = solidcraft_io::limits::explain(design, e).to_string();
EngineError::Other(if e.starts_with(&shown) { e } else { format!("{shown}: {e}") })
})
read_linked_file(file, own).map_err(|e| EngineError::Other(e.reason))
}
/// The solid bodies of the design at `file`, evaluated under `validation`, as an Import feature, with the digest of
@@ -557,7 +555,7 @@ fn insert_design(s: &mut Session, p: &Value) -> Result<Value> {
// A network or device path is judged from its text, as given, before anything reads it.
link_refused(s.path.as_deref(), given).map_err(|e| bad(cmd, e.to_string()))?;
let file = absolute(Path::new(given));
if s.path.as_deref().is_some_and(|own| absolute(Path::new(own)) == file) {
if s.path.as_deref().is_some_and(|own| same_file(Path::new(own), &file)) {
return Err(bad(cmd, "a design can't be linked into itself"));
}
if p.get("reuse").and_then(Value::as_bool).unwrap_or(false) {
@@ -799,11 +797,9 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
}
// A network or device path is judged from its text before the disk is asked anything.
link_refused(s.path.as_deref(), given).map_err(|e| bad(cmd, e.to_string()))?;
// The ordinary lexical check in linked_bodies also works in the browser VFS. On native
// hosts, reject an alias of this assembly too (a symlink or a path with different case).
if let (Ok(candidate), Some(Ok(own))) = (file.canonicalize(), s.path.as_deref().map(std::fs::canonicalize))
&& candidate == own
{
// The same path, or on native hosts an alias of this assembly (a symlink or a path with
// different case): the one self-link rule Insert Design and Update Links use too.
if s.path.as_deref().is_some_and(|own| same_file(Path::new(own), &file)) {
return Err(bad(cmd, "a design can't be linked into itself"));
}
if let Some(occurrence) = lone_placement(s, p, comp, cmd)? {
@@ -959,33 +955,45 @@ fn list_links(s: &mut Session, _p: &Value) -> Result<Value> {
let file = resolve(s.path.as_deref(), &l.path);
let name = s.doc.components.iter().find(|x| x.id == *c).map(|x| x.name.clone());
let path = stored(s.path.as_deref(), &file);
let file = file.to_string_lossy();
let shown = file.to_string_lossy().into_owned();
// A refused path is not looked for at all: on Windows asking whether a file on
// `\\host\share` exists already connects to the host.
match link_refusal(s.path.as_deref(), &l.path) {
None => {
let found = solidcraft_io::vfs::design_path_is_file(&file, s.path.as_deref());
let found = solidcraft_io::vfs::design_path_is_file(&shown, s.path.as_deref());
// Whether the file is no longer what this design last read (#9739): `null`
// when it isn't found, or the link keeps no digest (made before they were).
let changed = l
.file_sha256
.as_ref()
.filter(|_| found == Some(true))
.and_then(|kept| file_sha256(Path::new(file.as_ref()), s.path.as_deref()).map(|now| now != *kept));
LinkInfo::Looked { component: *c, name, path, file: file.into_owned(), found, changed }
.and_then(|kept| file_sha256(Path::new(&shown), s.path.as_deref()).map(|now| now != *kept));
// Why a file that can't be reached can't be: `None` when it can be read.
let (state, reason) = match link_presence(&file, s.path.as_deref()) {
Some(why) => (Some(why.state), Some(why.reason)),
None => (None, None),
};
LinkInfo::Looked { component: *c, name, path, file: shown, found, changed, state, reason }
}
Some(solidcraft_io::vfs::Remote::Network(host)) => LinkInfo::Network {
component: *c,
name,
path,
file: file.into_owned(),
file: shown,
found: None,
state: LinkState::NotAllowed,
reason: format!("on the network location \\\\{host}, which is not allowed"),
host,
},
Some(solidcraft_io::vfs::Remote::Device) => {
LinkInfo::Device { component: *c, name, path, file: file.into_owned(), found: None, reason: "names a device, not a file".into() }
}
Some(solidcraft_io::vfs::Remote::Device) => LinkInfo::Device {
component: *c,
name,
path,
file: shown,
found: None,
state: LinkState::Unreadable,
reason: "names a device, not a file".into(),
},
}
})
.collect();
@@ -1130,6 +1138,8 @@ mod tests {
std::fs::remove_file(&part).unwrap();
let err = b.execute("component.update_links", &json!({})).unwrap_err().to_string();
assert!(err.contains("plate"), "{err}");
let links = run(&mut b, "component.links", json!({}));
assert_eq!((&links["links"][0]["found"], &links["links"][0]["state"]), (&json!(false), &json!("missing")), "{links}");
assert!((volume(&mut b) - 9000.0).abs() < 1e-6);
// Break Link keeps the bodies and forgets the file.
run(&mut b, "component.break_link", json!({"component": "plate"}));
+34 -8
View File
@@ -8,7 +8,7 @@
use serde_json::Value;
use solidcore_record::Vec3;
use solidcraft_doc::construct::{AxisDef, GeoRef, PointDef};
use solidcraft_doc::construct::{AxisDef, DatumItem, GeoRef, PointDef};
use solidcraft_doc::{FeatureKind, PlaneRef};
use super::CommandSpec;
@@ -171,7 +171,7 @@ pub(crate) fn is_model_axis(v: &Value) -> bool {
pub(super) fn model_axis_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<Option<(GeoRef, Vec3, Vec3)>> {
let Some(v) = v.filter(|v| is_model_axis(v)) else { return Ok(None) };
let g = geo(s, Some(v), cmd, "axis")?;
let (o, d) = solidcraft_doc::construct::model_axis(&*s.world_state()?, &g).map_err(|e| bad(cmd, format!("`axis`: {e}")))?;
let (o, d) = solidcraft_doc::construct::model_axis_in(Some(&s.doc), &*s.world_state()?, &g, 0).map_err(|e| bad(cmd, format!("`axis`: {e}")))?;
Ok(Some((g, o, d)))
}
@@ -265,21 +265,47 @@ pub(super) fn geo(s: &Session, v: Option<&Value>, cmd: &str, what: &str) -> Resu
}
}
Ok(GeoForm::Point(p)) => GeoRef::Point { p },
Ok(GeoForm::Plane(plane)) => GeoRef::Plane { plane: plane_param(s, Some(plane), cmd)? },
Ok(GeoForm::Plane(plane)) => {
// A bare component origin datum key (`o:2/origin/O`, `o:2/origin/X`, `o:2/origin/XY`)
// names a point or an axis as often as a plane, and only the plane items reach
// `plane_param`'s own datum branch. A feature of the same name still wins (the one
// resolution order), so the string is left to `plane_param` then; the O, X, Y and Z
// items are saved as their datum, so the reference follows the placement.
let datum = plane
.as_str()
.map(str::trim)
.filter(|k| s.doc.find_feature(k).is_none())
.and_then(crate::origins::datum)
.filter(|d| matches!(d.item, DatumItem::O | DatumItem::X | DatumItem::Y | DatumItem::Z));
match datum {
Some(datum) => GeoRef::Datum { datum },
None => GeoRef::Plane { plane: plane_param(s, Some(plane), cmd)? },
}
}
Ok(GeoForm::Axis(a)) => {
if ["x", "y", "z"].contains(&a.to_ascii_lowercase().as_str()) {
GeoRef::World { axis: a.to_ascii_uppercase() }
} else {
match s.doc.find_feature(a).map(|f| &f.kind) {
Some(FeatureKind::ConstructionAxis { .. }) => GeoRef::Axis { name: a.into() },
_ => return Err(bad(cmd, format!("no axis `{a}`"))),
// A component origin axis: saved as its datum, so it follows the placement.
_ => match crate::origins::datum(a).filter(|d| matches!(d.item, DatumItem::X | DatumItem::Y | DatumItem::Z)) {
Some(datum) => GeoRef::Datum { datum },
None => return Err(bad(cmd, format!("no axis `{a}`"))),
},
}
}
}
Ok(GeoForm::ConstructPoint(n)) => match s.doc.find_feature(n).map(|f| &f.kind) {
Some(FeatureKind::ConstructionPoint { .. }) => GeoRef::ConstructPoint { name: n.into() },
_ => return Err(bad(cmd, format!("no construction point `{n}`"))),
},
Ok(GeoForm::ConstructPoint(n)) => {
match s.doc.find_feature(n).map(|f| &f.kind) {
Some(FeatureKind::ConstructionPoint { .. }) => GeoRef::ConstructPoint { name: n.into() },
// A component origin's O point: its datum, so it follows the placement.
_ => match crate::origins::datum(n).filter(|d| d.item == DatumItem::O) {
Some(datum) => GeoRef::Datum { datum },
None => return Err(bad(cmd, format!("no construction point `{n}`"))),
},
}
}
Err(GeoFormError::Saved(e)) => return Err(bad(cmd, format!("`{what}`: {e}"))),
Err(GeoFormError::Unknown) => return Err(bad(cmd, format!("`{what}` must be {REF}"))),
};
+207
View File
@@ -0,0 +1,207 @@
//! SOLID ▸ CONSTRUCT: Plane, Axis and Point. They have no methods of their own. Given
//! references, they try their family's CONSTRUCT commands (`construct.plane.*`,
//! `construct.axis.*`, `construct.point.*`, in the order the commands are listed) with the
//! references in each command's reference parameters, and build with the first that takes them.
//! A method added to a family is tried as well, with nothing to change here.
use serde_json::{Map, Value, json};
use super::CommandSpec;
use crate::params::bad;
use crate::schema::construct::{ConstructAutoParams, ConstructAutoResult};
use crate::schema::{TypedContract, output_schema_for};
use crate::{Result, Session};
const CONTRACT: TypedContract<ConstructAutoParams> = TypedContract::new();
const PARAMS: &str = "refs: 1-3 refs in any order; with?: {the method's other parameters: offset, angle, t, name…}. Builds with the first of the family's CONSTRUCT commands that takes the refs in its reference parameters (and every key of `with`). Returns method: the command used";
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("construct.plane", "Plane", crate::checked!("construct.plane", plane))
.at("SOLID", "CONSTRUCT")
.icon("plane_auto")
.contract(&CONTRACT)
.output(output_schema_for::<ConstructAutoResult>)
.params(PARAMS),
CommandSpec::new("construct.axis", "Axis", crate::checked!("construct.axis", axis))
.at("SOLID", "CONSTRUCT")
.icon("axis")
.contract(&CONTRACT)
.output(output_schema_for::<ConstructAutoResult>)
.params(PARAMS),
CommandSpec::new("construct.point", "Point", crate::checked!("construct.point", point))
.at("SOLID", "CONSTRUCT")
.icon("point")
.contract(&CONTRACT)
.output(output_schema_for::<ConstructAutoResult>)
.params(PARAMS),
];
/// Whether `id` is one of the tools that choose their method (`construct.plane`…).
pub fn is_construct_tool(id: &str) -> bool {
COMMANDS.iter().any(|c| c.id == id)
}
/// The methods of `tool` (`construct.plane` → `construct.plane.offset`, `construct.plane.midplane`
/// …), in the order the commands are listed: the order they are tried in. Planned commands are
/// left out.
pub fn construct_methods(tool: &str) -> Vec<&'static CommandSpec> {
let prefix = format!("{tool}.");
super::command_specs().into_iter().filter(|c| c.id.starts_with(&prefix) && c.planned.is_none() && c.contract.is_some()).collect()
}
/// How a reference parameter reads a reference (its schema type).
#[derive(Clone, Copy, PartialEq, Eq)]
enum Form {
/// Any `GeoRef` form, as given.
Geo,
/// `PlaneOrFace`: a plane as such (`{plane: p}` given as `p`) or `{face}`.
Plane,
/// `AxisLine`: an axis as such (`{axis: a}` given as `a`), `{edge}` or `{face}`.
Axis,
}
/// A parameter of a method that takes a reference: its key, how it reads one, and whether the
/// method needs it.
struct Slot {
key: String,
form: Form,
required: bool,
}
/// The schema type a property refers to, directly or as one of its `anyOf`/`oneOf` (an
/// optional parameter's).
fn form_of(property: &Value) -> Option<Form> {
let named = |v: &Value| match v.get("$ref").and_then(Value::as_str).and_then(|r| r.rsplit('/').next()) {
Some("GeoRef") => Some(Form::Geo),
Some("PlaneOrFace") => Some(Form::Plane),
Some("AxisLine") => Some(Form::Axis),
_ => None,
};
named(property).or_else(|| ["anyOf", "oneOf"].iter().filter_map(|k| property.get(*k).and_then(Value::as_array)).flatten().find_map(named))
}
/// The reference parameters of `spec`, read from its input schema.
fn slots(spec: &CommandSpec) -> Vec<Slot> {
let Some(contract) = spec.contract else { return Vec::new() };
let schema = contract.input_schema();
let root = schema.as_value();
let required: Vec<&str> =
root.get("required").and_then(Value::as_array).map(|r| r.iter().filter_map(Value::as_str).collect()).unwrap_or_default();
let Some(properties) = root.get("properties").and_then(Value::as_object) else { return Vec::new() };
properties
.iter()
.filter_map(|(key, p)| form_of(p).map(|form| Slot { key: key.clone(), form, required: required.contains(&key.as_str()) }))
.collect()
}
/// `r` as a parameter of `form` reads it.
fn as_form(r: &Value, form: Form) -> Value {
let key = match form {
Form::Geo => return r.clone(),
Form::Plane => "plane",
Form::Axis => "axis",
};
match r.as_object() {
Some(o) if o.len() == 1 => o.get(key).cloned().unwrap_or_else(|| r.clone()),
_ => r.clone(),
}
}
/// Every way to put each of `refs` in its own slot of `slots`, with every required slot filled:
/// the slot index of each reference. The first ways keep the references in the slots' order.
fn placements(refs: usize, slots: &[Slot]) -> Vec<Vec<usize>> {
/// Extend the placement in `at` by one reference, in every free slot, and keep each complete
/// one that fills every required slot.
fn go(refs: usize, slots: &[Slot], at: &mut Vec<usize>, out: &mut Vec<Vec<usize>>) {
if at.len() == refs {
if slots.iter().enumerate().all(|(i, s)| !s.required || at.contains(&i)) {
out.push(at.clone());
}
return;
}
for i in 0..slots.len() {
if !at.contains(&i) {
at.push(i);
go(refs, slots, at, out);
at.pop();
}
}
}
let mut out = Vec::new();
if refs <= slots.len() {
go(refs, slots, &mut Vec::with_capacity(refs), &mut out);
}
out
}
/// The parameters of `spec` with `refs` placed as `at` says, and `with`.
fn params(refs: &[Value], slots: &[Slot], at: &[usize], with: &Map<String, Value>) -> Value {
let mut p = with.clone();
for (r, i) in refs.iter().zip(at) {
if let Some(slot) = slots.get(*i) {
p.insert(slot.key.clone(), as_form(r, slot.form));
}
}
Value::Object(p)
}
/// The first method of `tool` that builds from `refs` with `with`, tried on a scratch copy of
/// `s`: its command and parameters. The error, when none does, is the first a method gave whose
/// parameters fitted, else a list of what can be built from.
pub fn construct_method(s: &Session, tool: &str, refs: &[Value], with: &Map<String, Value>) -> Result<(&'static str, Value)> {
let mut first_error = None;
for spec in construct_methods(tool) {
let Some(contract) = spec.contract else { continue };
let slots = slots(spec);
for at in placements(refs.len(), &slots) {
let p = params(refs, &slots, &at, with);
if !contract.check(&p).is_empty() {
continue;
}
let mut trial = s.scratch();
match spec.run_nested(&mut trial, &p) {
Ok(_) => return Ok((spec.id, p)),
Err(e) => {
if first_error.is_none() {
first_error = Some(e);
}
}
}
}
}
Err(first_error.unwrap_or_else(|| {
let names: Vec<&str> = construct_methods(tool).iter().map(|c| c.label).collect();
bad(tool, format!("no method takes these references; the methods are: {}", names.join(", ")))
}))
}
/// Run `tool` (`construct.plane`…): build with its first method that takes `refs` and `with`, as
/// one undo step, and name the method in the reply.
fn build(s: &mut Session, tool: &str, p: &Value) -> Result<Value> {
// The contract has checked `refs` and `with`; this reads them as given.
let refs = p.get("refs").and_then(Value::as_array).cloned().unwrap_or_default();
let with = p.get("with").and_then(Value::as_object).cloned().unwrap_or_default();
let (method, params) = construct_method(s, tool, &refs, &with)?;
let spec = super::find_command(method).ok_or_else(|| bad(tool, format!("no command `{method}`")))?;
let mut reply = spec.run_nested(s, &params)?;
if let Value::Object(m) = &mut reply {
m.insert("method".into(), json!(method));
}
Ok(reply)
}
/// `construct.plane`.
fn plane(s: &mut Session, p: &Value) -> Result<Value> {
build(s, "construct.plane", p)
}
/// `construct.axis`.
fn axis(s: &mut Session, p: &Value) -> Result<Value> {
build(s, "construct.axis", p)
}
/// `construct.point`.
fn point(s: &mut Session, p: &Value) -> Result<Value> {
build(s, "construct.point", p)
}
+210 -1
View File
@@ -2,9 +2,15 @@
//! link to come): where a link keeps its file and how it finds it again. A link keeps the file
//! relative to the linking design's folder once that design is saved, so a folder of designs can
//! move together; before the design has a file the path is kept whole.
//!
//! Reading a linked design ([`read_linked_file`]) gives its bytes or its [`LinkState`] with the
//! reason, in one set of state words every kind of link reports.
use std::io::ErrorKind;
use std::path::{Component as PathPart, Path, PathBuf};
use schemars::JsonSchema;
use serde::Serialize;
use solidcraft_doc::{DesignLink, Document};
/// The folder relative link paths are kept against: the design's own, once it has a file.
@@ -68,7 +74,120 @@ pub(super) fn resolve(design: Option<&str>, link: &str) -> PathBuf {
/// The links of `doc` stored for a design saved at `to` instead of at `from`.
pub(super) fn rebased(doc: &Document, from: Option<&str>, to: &str) -> std::collections::BTreeMap<u64, DesignLink> {
doc.links.iter().map(|(c, l)| (*c, DesignLink { path: stored(Some(to), &resolve(from, &l.path)), ..l.clone() })).collect()
doc.links.iter().map(|(c, l)| (*c, DesignLink { path: rebase(&l.path, from, to), ..l.clone() })).collect()
}
/// One link's path, kept for a design saved at `from`, as a design saved at `to` keeps it: the
/// one Save As rule every kind of link follows.
pub(super) fn rebase(link: &str, from: Option<&str>, to: &str) -> String {
stored(Some(to), &resolve(from, link))
}
/// Whether two paths name one file: the same path, or (on native hosts) the same file through a
/// symlink or a path with different case. A design can't link itself under either.
pub(super) fn same_file(a: &Path, b: &Path) -> bool {
absolute(a) == absolute(b) || matches!((a.canonicalize(), b.canonicalize()), (Ok(x), Ok(y)) if x == y)
}
/// Where a link stands against its file: the one set of state words every kind of link reports.
/// Reading tells [`LinkState::Missing`], [`LinkState::Unreadable`] and [`LinkState::NotAllowed`]
/// apart; whether a design that was read is [`LinkState::UpToDate`] or [`LinkState::OutOfDate`]
/// is the link's own comparison. A design a newer SolidCraft saved is not a state of its own: it
/// opens, and its `newer_file` says what this build couldn't read (#12166).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum LinkState {
/// The file holds what the link last took from it.
UpToDate,
/// The file changed since the link last took from it.
OutOfDate,
/// There is no file at the link's path.
Missing,
/// The file is there but isn't a design this build can read, or the path names a device.
Unreadable,
/// The file is on a network location that is not allowed, or outside the folders this agent
/// may use.
NotAllowed,
}
impl LinkState {
/// The state word (`up_to_date`, `missing`…) that link listings report.
pub fn word(self) -> &'static str {
match self {
LinkState::UpToDate => "up_to_date",
LinkState::OutOfDate => "out_of_date",
LinkState::Missing => "missing",
LinkState::Unreadable => "unreadable",
LinkState::NotAllowed => "not_allowed",
}
}
}
/// Why a linked design can't be read: its state, and the reason to show.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LinkProblem {
pub state: LinkState,
pub reason: String,
}
impl std::fmt::Display for LinkProblem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.reason)
}
}
fn problem(state: LinkState, reason: String) -> LinkProblem {
LinkProblem { state, reason }
}
/// The state of an I/O error reading `shown`, with the reason naming the file once.
fn io_problem(shown: &str, e: &std::io::Error) -> LinkProblem {
let named = |e: String| if e.starts_with(shown) { e } else { format!("{shown}: {e}") };
match e.kind() {
// The folder scope's refusal says which folders the agent may use.
ErrorKind::PermissionDenied => problem(LinkState::NotAllowed, e.to_string()),
ErrorKind::NotFound => problem(LinkState::Missing, format!("{shown}: not found")),
_ => problem(LinkState::Unreadable, named(e.to_string())),
}
}
/// Why the path a design names may not be reached at all, judged from its text before anything
/// asks the disk or the network (#7372): a network location whose server isn't allowed
/// ([`LinkState::NotAllowed`]), or a device ([`LinkState::Unreadable`]). `design` is the linking
/// design's file, whose own server is allowed.
fn refusal(shown: &str, design: Option<&str>) -> Option<LinkProblem> {
let remote = solidcraft_io::vfs::design_path_refusal(shown, design)?;
let reason = solidcraft_io::vfs::check_design_path(shown, design).err().map(|e| e.to_string()).unwrap_or_else(|| shown.to_string());
Some(match remote {
solidcraft_io::vfs::Remote::Network(_) => problem(LinkState::NotAllowed, reason),
solidcraft_io::vfs::Remote::Device => problem(LinkState::Unreadable, reason),
})
}
/// Where the linked design at `file` stands without reading it: `None` when it can be read.
/// Cheap, for a design being opened, which must not read every file it links. A refused network
/// or device path is judged from its text alone, with nothing asked of the disk or the network.
pub(super) fn link_presence(file: &Path, design: Option<&str>) -> Option<LinkProblem> {
let shown = file.display().to_string();
refusal(&shown, design).or_else(|| solidcraft_io::vfs::len(&shown).err().map(|e| io_problem(&shown, &e)))
}
/// The bytes of the linked design at `file`, linked from the design saved at `design`, read
/// through the folders the caller may use, or why they can't be: a network location not allowed
/// or a device (judged from the path's text before anything is read), not a design file name,
/// missing, larger than the design size limit, or outside an agent's folders. Each with the
/// reason to show. Every kind of link reads its file here.
pub(super) fn read_linked_file(file: &Path, design: Option<&str>) -> Result<Vec<u8>, LinkProblem> {
let shown = file.display().to_string();
if let Some(why) = refusal(&shown, design) {
return Err(why);
}
if !solidcraft_io::is_design_path(&shown) {
return Err(problem(LinkState::Unreadable, format!("{shown}: not a .{} design", solidcraft_io::DESIGN_EXT)));
}
let limit = solidcraft_io::limits::max_design_bytes();
solidcraft_io::vfs::read_design_path(&shown, design, limit)
.map_err(|e| io_problem(&shown, &solidcraft_io::limits::explain(solidcraft_io::limits::Kind::Design, e)))
}
#[cfg(test)]
@@ -99,4 +218,94 @@ mod tests {
Some("plate.solidcraft")
);
}
#[test]
fn one_rule_rebases_every_link_for_save_as() {
let root = absolute(Path::new("projects"));
let from = root.join("asm").join("case.solidcraft").to_string_lossy().into_owned();
let to = root.join("asm").join("v2").join("case.solidcraft").to_string_lossy().into_owned();
assert_eq!(rebase("../parts/plate.solidcraft", Some(&from), &to), "../../parts/plate.solidcraft");
// A link kept whole (the design had no file yet) becomes relative too.
let whole = root.join("parts").join("plate.solidcraft").to_string_lossy().into_owned();
assert_eq!(rebase(&whole, None, &to), "../../parts/plate.solidcraft");
}
/// One folder per test, with the files it needs.
fn folder(test: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("solidcraft-design-link-{test}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn reading_a_linked_design_reports_its_state_and_reason() {
let dir = folder("read");
let good = dir.join("plate.sc");
std::fs::write(&good, solidcraft_io::write_design(&Document::new("plate"))).unwrap();
let read = read_linked_file(&good, None).map(|bytes| solidcraft_io::read_design(&bytes).map(|d| d.name).ok());
assert_eq!(read, Ok(Some("plate".to_string())));
assert_eq!(link_presence(&good, None), None);
let state = |file: &Path| read_linked_file(file, None).err().map(|e| (e.state, e.reason));
// Missing: no file there (and the same without reading it).
let missing = dir.join("gone.sc");
assert!(matches!(state(&missing), Some((LinkState::Missing, r)) if r.contains("gone.sc")));
assert_eq!(link_presence(&missing, None).map(|p| p.state), Some(LinkState::Missing));
// Unreadable: not a design file name, or a folder.
std::fs::write(dir.join("part.step"), b"ISO-10303-21;").unwrap();
assert!(matches!(state(&dir.join("part.step")), Some((LinkState::Unreadable, r)) if r.contains("not a .sc design")));
std::fs::create_dir_all(dir.join("folder.sc")).unwrap();
assert!(matches!(state(&dir.join("folder.sc")), Some((LinkState::Unreadable, _))));
// A design a newer SolidCraft saved is read like any other, and opens saying so (#12166).
let mut newer: serde_json::Value = serde_json::from_slice(&solidcraft_io::write_design(&Document::new("next"))).unwrap();
newer["format"] = serde_json::json!(solidcraft_doc::format::format_string(solidcraft_doc::format::FORMAT + 1));
std::fs::write(dir.join("next.sc"), newer.to_string()).unwrap();
let opened =
read_linked_file(&dir.join("next.sc"), None).map(|bytes| solidcraft_io::read_design(&bytes).map(|d| d.newer_file.is_some()).ok());
assert_eq!(opened, Ok(Some(true)));
// Not allowed: outside the folders an agent may use, without saying whether it exists.
let inside = dir.join("inside");
std::fs::create_dir_all(&inside).unwrap();
solidcraft_io::vfs::scoped(Some(solidcraft_io::vfs::PathScope::new([inside])), || {
assert_eq!(state(&good).map(|s| s.0), Some(LinkState::NotAllowed));
assert_eq!(link_presence(&missing, None).map(|p| p.state), Some(LinkState::NotAllowed));
});
let words: Vec<&str> = [LinkState::UpToDate, LinkState::OutOfDate, LinkState::Missing, LinkState::Unreadable, LinkState::NotAllowed]
.iter()
.map(|s| s.word())
.collect();
assert_eq!(words, ["up_to_date", "out_of_date", "missing", "unreadable", "not_allowed"]);
assert_eq!(serde_json::json!(LinkState::NotAllowed), serde_json::json!("not_allowed"));
let _ = std::fs::remove_dir_all(&dir);
}
/// A network location not allowed, or a device, is refused from the path's text alone:
/// reading it asks nothing of the disk or the network (#7372).
#[test]
fn network_and_device_paths_are_refused_before_anything_is_read() {
let calls = solidcraft_io::vfs::file_calls();
let network = Path::new(r"\\fileserver\share\standard.sc");
let refused = read_linked_file(network, None).err();
assert!(matches!(&refused, Some(p) if p.state == LinkState::NotAllowed && p.reason.contains("fileserver")), "{refused:?}");
assert_eq!(link_presence(network, None).map(|p| p.state), Some(LinkState::NotAllowed));
let device = Path::new(r"\\.\pipe\standard.sc");
assert_eq!(read_linked_file(device, None).err().map(|p| p.state), Some(LinkState::Unreadable));
assert_eq!(solidcraft_io::vfs::file_calls(), calls, "a refused path asked the file system");
}
#[test]
fn a_path_and_its_alias_are_the_same_file() {
let dir = folder("same");
let file = dir.join("a.sc");
std::fs::write(&file, b"{}").unwrap();
assert!(same_file(&file, &dir.join(".").join("a.sc")));
assert!(!same_file(&file, &dir.join("b.sc")));
#[cfg(unix)]
{
let alias = dir.join("alias.sc");
std::os::unix::fs::symlink(&file, &alias).unwrap();
assert!(same_file(&file, &alias));
}
let _ = std::fs::remove_dir_all(&dir);
}
}
+57 -26
View File
@@ -2,6 +2,7 @@
use serde_json::{Value, json};
use solidcore_record::Vec3;
use solidcraft_doc::construct::DatumItem;
use solidcraft_doc::{
AxisRef, Direction, Expr, Extent, FeatureKind, Operation, PointExpr, ProfileSel, RailFit, SweepOrientation, SweepRail, ThinSide, ThinWall,
Touching, expr::Kind, point_expr,
@@ -428,6 +429,11 @@ pub(super) fn stable_profiles(s: &Session, kind: &mut FeatureKind, exact: bool)
let st = s.model.state();
for (sketch, sel) in kind.profile_sels_mut() {
let Some(ss) = st.sketch(sketch) else { continue };
// An empty selection (a patch asked for only the sketch's 3D loops, #741) has nothing to
// capture: it stays empty.
if matches!(sel, ProfileSel::Indices { indices } if indices.is_empty()) {
continue;
}
let indices = match sel {
ProfileSel::Indices { indices } => {
let have = ss.profiles.len();
@@ -1392,7 +1398,8 @@ fn revolve(s: &mut Session, p: &Value) -> Result<Value> {
/// A revolve axis of `sketch` from its parameter: a sketch line id, the sketch's x or y axis, a
/// world axis (also as saved: {type: world, axis: X|Y|Z}), a construction axis by name
/// (followed when it is edited), {origin, dir} (each origin coordinate a number or an expression over
/// (followed when it is edited), a component origin axis (saved as its datum, so it follows the
/// placement), {origin, dir} (each origin coordinate a number or an expression over
/// the parameters, which the axis follows), {sketch, curve} (a line of another sketch, followed in
/// that sketch, #6548; also as saved: {type: sketch_line, …}), or an edge
/// or round face of the model ({edge|face: [x,y,z]}), which the revolve follows as the model
@@ -1423,6 +1430,13 @@ pub(super) fn axis_param(s: &Session, sketch: u64, p: &Value, cmd: &str) -> Resu
Some(a @ ("X" | "Y" | "Z")) => Ok(AxisRef::World { axis: a.into() }),
Some(a @ ("x" | "y")) => Ok(AxisRef::SketchAxis { axis: a.into() }),
Some(name) if !is_sketch_line(s, sketch, name) && is_construction_axis(s, name) => Ok(AxisRef::Construction { name: name.into() }),
// A component origin axis: saved as its datum, so the revolve follows the placement.
Some(name) if !is_sketch_line(s, sketch, name) => {
match crate::origins::datum(name).filter(|d| matches!(d.item, DatumItem::X | DatumItem::Y | DatumItem::Z)) {
Some(datum) => Ok(AxisRef::Model { geo: solidcraft_doc::construct::GeoRef::Datum { datum } }),
None => Ok(AxisRef::SketchLine { curve: name.into(), sketch: None }),
}
}
Some(id) => Ok(AxisRef::SketchLine { curve: id.into(), sketch: None }),
None => {
Err(bad(cmd, "`axis` must be a sketch line id, x, y (sketch axes), X, Y, Z, a construction axis, {origin, dir} or {edge|face: [x,y,z]}"))
@@ -2529,33 +2543,39 @@ fn axis_line(s: &Session, p: &Value, cmd: &str) -> Result<(Vec3, Vec3, Option<so
if let Some((geo, o, d)) = super::construct::model_axis_param(s, p.get("axis"), cmd)? {
return Ok((o, d, Some(geo), None));
}
let (o, d, name) = named_axis_line(s, p, cmd)?;
Ok((o, d, None, name))
named_axis_line(s, p, cmd)
}
/// An axis line from `axis`: X, Y, Z, a construction axis by name or {origin, dir}. A construction
/// axis comes back where it is now, with its name.
fn named_axis_line(s: &Session, p: &Value, cmd: &str) -> Result<(Vec3, Vec3, Option<FeatureName>)> {
match p.get("axis") {
Some(Value::String(a)) => match a.to_ascii_uppercase().as_str() {
"X" => Ok((Vec3::ZERO, Vec3::X, None)),
"Y" => Ok((Vec3::ZERO, Vec3::Y, None)),
"Z" => Ok((Vec3::ZERO, Vec3::Z, None)),
_ if is_construction_axis(s, a) => {
let (_, _, origin, dir) = crate::view::construction_axes(s)
.into_iter()
.find(|(_, name, _, _)| name == a)
.ok_or_else(|| bad(cmd, format!("construction axis `{a}` has no geometry: fix its definition first")))?;
Ok((origin, dir, Some(a.as_str().into())))
}
_ => Err(bad(cmd, "axis must be X, Y, Z, a construction axis, {origin, dir} or {edge|face: [x,y,z]}")),
},
Some(o @ Value::Object(_)) => {
let (origin, dir) = axis_line_object(o).map_err(|why| bad(cmd, why))?;
Ok((origin, dir, None))
}
_ => Err(bad(cmd, "`axis` is required")),
/// An axis line from `axis`: X, Y, Z, a construction axis by name, a component origin axis (its
/// datum, followed) or {origin, dir}. A construction axis comes back where it is now, with its
/// name; a component origin axis with the datum that finds it again on each evaluation.
fn named_axis_line(s: &Session, p: &Value, cmd: &str) -> Result<(Vec3, Vec3, Option<solidcraft_doc::construct::GeoRef>, Option<FeatureName>)> {
if let Some(o @ Value::Object(_)) = p.get("axis") {
let (origin, dir) = axis_line_object(o).map_err(|why| bad(cmd, why))?;
return Ok((origin, dir, None, None));
}
let Some(a) = str_(p, "axis") else { return Err(bad(cmd, "`axis` is required")) };
// The world axes keep the precedence they always had: X, Y and Z name the design's axes
// before a construction axis answers to the same letters (an older design can hold one
// named `x`, and a case-only match must not take them) (#7313 review).
match a.to_ascii_uppercase().as_str() {
"X" => return Ok((Vec3::ZERO, Vec3::X, None, None)),
"Y" => return Ok((Vec3::ZERO, Vec3::Y, None, None)),
"Z" => return Ok((Vec3::ZERO, Vec3::Z, None, None)),
_ => {}
}
if is_construction_axis(s, a) {
let (_, _, origin, dir) = crate::view::construction_axes(s)
.into_iter()
.find(|(_, name, _, _)| name == a)
.ok_or_else(|| bad(cmd, format!("construction axis `{a}` has no geometry: fix its definition first")))?;
return Ok((origin, dir, None, Some(a.into())));
}
if let Some(datum) = crate::origins::datum(a).filter(|d| matches!(d.item, DatumItem::X | DatumItem::Y | DatumItem::Z)) {
let (origin, dir) = s.origin_axis(a).ok_or_else(|| bad(cmd, format!("no axis `{a}`")))?;
return Ok((origin, dir, Some(solidcraft_doc::construct::GeoRef::Datum { datum }), None));
}
Err(bad(cmd, "axis must be X, Y, Z, a construction axis, {origin, dir} or {edge|face: [x,y,z]}"))
}
/// An axis line `{origin?, dir}` as [`named_axis_line`] reads it: the origin (default: the
@@ -2825,7 +2845,18 @@ pub(super) fn plane_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<s
}
Ok(PlaneRef::Origin { name: n.trim().to_ascii_uppercase() })
}
Some(Value::String(n)) => construction(n.trim()),
Some(Value::String(n)) => {
// A construction plane feature by name first, so a name that looks like a datum key
// still reaches it; a component origin plane is saved as its datum, so it follows
// the placement instead of staying where it was picked.
if s.doc.find_feature(n.trim()).is_some() {
return construction(n.trim());
}
match crate::origins::datum(n).filter(|d| matches!(d.item, DatumItem::XY | DatumItem::XZ | DatumItem::YZ)) {
Some(datum) => Ok(PlaneRef::Datum { datum }),
None => construction(n.trim()),
}
}
Some(Value::Number(id)) => match id.as_u64() {
Some(id) => construction(&id.to_string()),
None => Err(bad(cmd, format!("`plane` must be {WANT}"))),
+1
View File
@@ -425,6 +425,7 @@ fn add_rigid_joints(s: &mut Session, pairs: &[(u64, u64, Mat)]) -> Result<Vec<u6
suppressed: false,
exprs: Default::default(),
captured: None,
constraint: None,
})
})
.collect::<Result<_>>()?;
+18
View File
@@ -428,6 +428,23 @@ fn sketch_json(s: &Session, world: &solidcraft_doc::ModelState, id: u64) -> Opti
holes: p.hole_curves.iter().map(|h| solidcraft_sketch::distinct_curves(h)).collect(),
})
.collect();
// Planar loops the 3D sketch curves close in planes of their own (#741): the plane is given
// as an origin and a unit normal, and the centroid is in the same frame as the sketch plane.
let profiles_3d: Vec<Value> = ss
.profiles_3d
.iter()
.enumerate()
.map(|(i, p)| {
json!({
"index": i,
"area_mm2": p.area,
"plane": { "origin": p.plane.origin, "normal": p.plane.normal() },
"centroid": p.centroid,
"curves": p.outer_curves,
"holes": p.hole_curves,
})
})
.collect();
let wires = sk
.wires
.iter()
@@ -458,6 +475,7 @@ fn sketch_json(s: &Session, world: &solidcraft_doc::ModelState, id: u64) -> Opti
points,
constraints,
profiles,
profiles_3d,
links: serde_json::to_value(&sk.links).unwrap_or_default(),
wires,
visible: crate::view::sketch_shown(s, id),
+39 -8
View File
@@ -47,6 +47,9 @@ const CAM_CONTRACT: TypedContract<CamParams> = TypedContract::new();
#[path = "joints_inspect.rs"]
mod inspect;
#[path = "assembly_constraint.rs"]
pub(crate) mod constraint;
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("joint.origin", "Joint Origin", crate::checked!("joint.origin", joint_origin))
.at("SOLID", "CREATE")
@@ -236,9 +239,12 @@ pub fn place(s: &mut Session) -> joints::Solution {
/// Work out the offsets, angles and values joints were given as expressions again (a parameter
/// may have changed). One that can't be worked out (its parameter gone) keeps its numbers; the
/// solve leaves that joint out and says why.
/// solve leaves that joint out and says why. A tangent constraint's offset is its gap plus the
/// radius of its round face as the face is now, so the part keeps resting on the plane when the
/// radius changes; a face that can't be found keeps the numbers it has.
fn reexpress(s: &mut Session) {
let params = joints::Params::new(&s.doc);
let st = s.model.state();
let changed: Vec<(usize, (f64, f64, Vec<f64>))> = s
.doc
.assembly
@@ -247,7 +253,10 @@ fn reexpress(s: &mut Session) {
.enumerate()
.filter(|(_, j)| !j.exprs.is_empty())
.filter_map(|(i, j)| {
let (o, a, mut q) = joints::expressed(&params, j).ok()?;
let (mut o, a, mut q) = joints::expressed(&params, j).ok()?;
if j.constraint == Some(joints::Constraint::Tangent) {
o += s.doc.origin_radius(&st, &j.b).ok()?;
}
joints::clamp(j, &mut q);
((o, a) != (j.offset, j.angle) || q != j.values).then_some((i, (o, a, q)))
})
@@ -1152,14 +1161,14 @@ fn add_joint(s: &mut Session, mut j: Joint, p: &Value) -> Result<Value> {
return Err(EngineError::Other("too many joints".into()));
}
check_sides(&j.a, &j.b, "joint.create")?;
// Relations and loop-closing typed joints start where their parts are. A typed joint in
// Relations, constraints and loop-closing typed joints start where their parts are. A typed joint in
// an open chain keeps its usual initial values; explicitly supplied values also keep theirs.
// A joint on a plane or axis side anchors its part to that geometry: it starts where its
// parts are too, so the part keeps its place on the plane (or along the axis) instead of
// jumping to the geometry's origin.
if p.get("values").is_none()
&& p.get("value").is_none()
&& (j.kind.is_relation() || closes_loop(s, &j) || datum_sided(&j))
&& (j.starts_where_it_is() || closes_loop(s, &j) || datum_sided(&j))
&& let Some(q) = joints::relation_values(&s.doc, &s.model.state(), &j)
{
j.values = q;
@@ -1345,6 +1354,13 @@ fn joint(s: &mut Session, p: &Value) -> Result<Value> {
let kind = kind_param(p, cmd)?;
let a = origin_param(s, p.get("a"), cmd)?;
let b = origin_param(s, p.get("b"), cmd)?;
let j = new_joint(s, p, kind, a, b, cmd)?;
add_joint(s, j, p)
}
/// A joint of `kind` between origins `a` and `b`, with the values, limits, flip, offset, angle and
/// name `p` gives (as joint.create reads them), not yet added.
fn new_joint(s: &mut Session, p: &Value, kind: JointKind, a: JointOrigin, b: JointOrigin, cmd: &str) -> Result<Joint> {
let id = s.doc_mut().allocate_id()?;
let name = new_joint_name(s, p, kind);
let offset = match p.get("offset") {
@@ -1369,13 +1385,14 @@ fn joint(s: &mut Session, p: &Value) -> Result<Value> {
suppressed: false,
exprs: JointExprs { offset: typed(p.get("offset")), angle: typed(p.get("angle")), values: value_exprs(p) },
captured: None,
constraint: None,
};
// No angle given: B keeps its turn about the joint's axis where a half turn would otherwise
// come only from how its origin's x was chosen. (An angle relation's angle is its tilt.)
if angle.is_none() && !j.kind.is_relation() {
j.angle = joints::least_turn_angle(&s.doc, &s.model.state(), &j);
}
add_joint(s, j, p)
Ok(j)
}
fn as_built(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1415,6 +1432,7 @@ fn as_built(s: &mut Session, p: &Value) -> Result<Value> {
suppressed: false,
exprs: Default::default(),
captured: None,
constraint: None,
};
add_joint(s, j, p)
}
@@ -2069,6 +2087,14 @@ fn edit(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(k) = kind {
let was = j.kind;
j.kind = k;
// A constraint changed to another kind of joint is a plain joint now: its tag would keep
// restarting it, and a tangent's gap expression would leave the radius out of its offset.
if k != was {
if j.constraint == Some(joints::Constraint::Tangent) {
j.exprs.offset = None;
}
j.constraint = None;
}
j.values.resize(k.dofs().len(), 0.0);
j.limits.resize(k.dofs().len(), None);
j.exprs.values.truncate(k.dofs().len());
@@ -2083,6 +2109,10 @@ fn edit(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(o) = offset {
j.offset = o;
j.exprs.offset = typed(p.get("offset"));
// A tangent constraint's offset is its gap: the radius is added when it is solved.
if j.constraint == Some(joints::Constraint::Tangent) {
j.exprs.offset.get_or_insert_with(|| format!("{o} mm"));
}
}
if let Some(a) = angle {
j.angle = a;
@@ -2120,11 +2150,11 @@ fn edit(s: &mut Session, p: &Value) -> Result<Value> {
&& kind.is_none_or(|k| k == was_kind)
&& offset.is_none_or(|o| (o - was_offset).abs() <= 1e-9)
&& bool_(p, "flip").is_none_or(|f| f == was_flip);
// Otherwise a relation whose type, offset, tilt or flip changes, or whose sides move, starts
// again from where its parts are.
// Otherwise a relation or a constraint whose type, offset, tilt or flip changes, or whose
// sides move, starts again from where its parts are.
let reseat = !tilt_only && (kind.is_some() || offset.is_some() || angle.is_some() || p.get("flip").is_some() || sides);
if reseat {
let edited = s.doc.assembly.joints.iter().find(|j| j.id == id).filter(|j| j.kind.is_relation()).cloned();
let edited = s.doc.assembly.joints.iter().find(|j| j.id == id).filter(|j| j.starts_where_it_is()).cloned();
if let Some(q) = edited.and_then(|j| joints::relation_values(&s.doc, &s.model.state(), &j))
&& let Some(j) = s.doc_mut().assembly.joints.iter_mut().find(|j| j.id == id)
{
@@ -2218,6 +2248,7 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
frame_a: world(&j.a),
frame_b: world(&j.b),
expressions: (!j.exprs.is_empty()).then(|| j.exprs.clone()),
constraint: j.constraint,
}
})
.collect();
+1
View File
@@ -64,6 +64,7 @@ fn joint_json(s: &Session, st: &ModelState, sol: &joints::Solution, j: &Joint, q
errors: why(&sol.errors),
conflicts: why(&sol.conflicts),
expressions: (!j.exprs.is_empty()).then(|| j.exprs.clone()),
constraint: j.constraint,
}
}
@@ -257,6 +257,7 @@ fn a_panel_full_of_parts_placed_by_relations_stays_solved() {
suppressed: false,
exprs: Default::default(),
captured: None,
constraint: None,
};
if let Some(q) = solidcraft_doc::joints::relation_values(&s.doc, &s.model.state(), &j) {
j.values = q;
@@ -373,3 +374,273 @@ fn an_angle_edit_while_the_joint_is_suppressed_starts_from_where_the_part_is() {
assert!(near(side(&s), moved, 1e-6), "together {together}: {:?}, moved to {moved:?}", side(&s));
}
}
// Assembly constraints (`assembly.constraint.create`): the rules in the Constrain dialog's words,
// kept as joints of the kinds they need and tagged with the rule.
fn constrain(s: &mut Session, kind: &str, a: Value, b: Value, extra: Value) -> Value {
let mut p = json!({"type": kind, "a": a, "b": b});
if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) {
p.extend(extra.clone());
}
run(s, "assembly.constraint.create", p)
}
fn dof_of(r: &Value, s: &Session, component: &str) -> Option<u64> {
let o = occ(s, component);
r["dof"].as_array()?.iter().find(|d| d["occurrence"] == json!(o))?["dof"].as_u64()
}
/// Mate, flush and a distance place the bracket fully, in the panel's words, and it follows the
/// panel as it changes size: its bottom on the top, its front flush with the panel's front, its
/// right end 40 mm in from the panel's.
#[test]
fn mate_flush_and_distance_place_a_part_and_follow_the_panel() {
let mut s = panel();
let r = constrain(&mut s, "mate", json!("Top"), json!("Bottom"), json!({}));
assert_eq!(dof(&r, &s), Some(3), "{r}");
assert_eq!(r["name"], "Mate1");
let r = constrain(&mut s, "flush", json!("Front"), json!("Face"), json!({}));
assert_eq!(dof(&r, &s), Some(1), "{r}");
let r = constrain(&mut s, "distance", json!("Right"), json!("Corner"), json!({"offset": -40}));
assert!(clean(&r), "{r}");
assert_eq!(dof(&r, &s), Some(0), "{r}");
for w in [400.0, 650.0, 250.0] {
run(&mut s, "parameters.change", json!({"name": "W", "expression": format!("{w} mm")}));
let r = run(&mut s, "joint.solve", json!({}));
assert!(clean(&r), "W {w}: {r}");
assert!(near(place(&s, "Bracket", Vec3::new(20.0, 500.0, 0.0)), Vec3::new(w - 40.0, 0.0, 2.0), 1e-6), "W {w}");
assert!(near(place(&s, "Bracket", Vec3::new(0.0, 500.0, 0.0)), Vec3::new(w - 60.0, 0.0, 2.0), 1e-6), "W {w}");
assert!(near(place(&s, "Bracket", Vec3::new(20.0, 510.0, 6.0)), Vec3::new(w - 40.0, 10.0, 8.0), 1e-6), "W {w}");
}
// Each is a joint of the kind its rule needs, tagged with the rule.
let list = run(&mut s, "joint.list", json!({}));
let kinds: Vec<(String, String)> = list["joints"]
.as_array()
.unwrap()
.iter()
.map(|j| (j["type"].as_str().unwrap().to_string(), j["constraint"].as_str().unwrap_or("").to_string()))
.collect();
assert_eq!(kinds, [("planar".into(), "mate".into()), ("planar".into(), "flush".into()), ("point_on_plane".into(), "distance".into())]);
let inspect = run(&mut s, "joint.inspect", json!({"joint": "Flush1"}));
assert_eq!(inspect["joints"][0]["constraint"], "flush", "{inspect}");
// Saved and reopened, the rules stay; a joint made as a joint has none.
let back = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
let rules: Vec<_> = back.assembly.joints.iter().map(|j| j.constraint).collect();
use solidcraft_doc::joints::Constraint;
assert_eq!(rules, [Some(Constraint::Mate), Some(Constraint::Flush), Some(Constraint::Distance)]);
relation(&mut s, "parallel", "Top", "Bottom", json!({}));
assert!(!s.doc.to_json().contains("\"constraint\":null"));
assert_eq!(s.doc.assembly.joints.last().unwrap().constraint, None);
}
/// A mate moves the part only across the gap: a bracket flat above the panel drops straight onto
/// it, not turned and not slid.
#[test]
fn a_mate_moves_the_part_only_across_the_gap() {
let mut s = Session::default();
run(&mut s, "component.create", json!({"name": "Plate"}));
run(&mut s, "solid.box", json!({"length": 100, "width": 100, "height": 10, "corner": [0, 0, 0]}));
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "component.create", json!({"name": "Block"}));
run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 20, "corner": [30, 40, 50]}));
run(&mut s, "component.activate", json!({"component": "root"}));
let plate = occ(&s, "Plate");
run(&mut s, "occurrence.ground", json!({"occurrence": plate, "grounded": true}));
let corners = [Vec3::new(30.0, 40.0, 50.0), Vec3::new(50.0, 40.0, 50.0), Vec3::new(30.0, 60.0, 70.0)];
let r = constrain(
&mut s,
"mate",
json!({"occurrence": "Plate", "face": [5, 5, 10]}),
json!({"occurrence": "Block", "face": [31, 41, 50]}),
json!({}),
);
assert!(clean(&r), "{r}");
assert_eq!(dof_of(&r, &s, "Block"), Some(3), "{r}");
for c in corners {
let got = place(&s, "Block", c);
assert!(near(got, c - Vec3::new(0.0, 0.0, 40.0), 1e-6), "{c:?}: {got:?}");
}
// A gap: 5 mm above the plate.
let r = run(&mut s, "joint.edit", json!({"joint": "Mate1", "offset": 5}));
assert!(clean(&r), "{r}");
assert!(near(place(&s, "Block", corners[0]), Vec3::new(30.0, 40.0, 15.0), 1e-6));
}
/// A part's own datum plane and axis are references too: a block mated onto the plate's XY plane
/// drops onto it, and a point on the plate's Z axis brings its corner onto that axis. Insert and
/// tangent need a rim or a radius, which an axis has not.
#[test]
fn datum_planes_and_axes_take_constraints() {
let mut s = Session::default();
run(&mut s, "component.create", json!({"name": "Plate"}));
run(&mut s, "solid.box", json!({"length": 100, "width": 100, "height": 10, "corner": [0, 0, 0]}));
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "component.create", json!({"name": "Block"}));
run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 20, "corner": [30, 40, 50]}));
run(&mut s, "component.activate", json!({"component": "root"}));
let plate = occ(&s, "Plate");
run(&mut s, "occurrence.ground", json!({"occurrence": plate, "grounded": true}));
let xy = json!({"occurrence": "Plate", "plane": "XY"});
let z = json!({"occurrence": "Plate", "axis": "Z"});
let bottom = json!({"occurrence": "Block", "face": [31, 41, 50]});
let corner = json!({"occurrence": "Block", "point": [30, 40, 50]});
let r = constrain(&mut s, "mate", xy.clone(), bottom.clone(), json!({}));
assert!(clean(&r), "{r}");
assert_eq!(dof_of(&r, &s, "Block"), Some(3), "{r}");
assert!(near(place(&s, "Block", Vec3::new(30.0, 40.0, 50.0)), Vec3::new(30.0, 40.0, 0.0), 1e-6));
assert!(near(place(&s, "Block", Vec3::new(50.0, 60.0, 70.0)), Vec3::new(50.0, 60.0, 20.0), 1e-6));
let r = constrain(&mut s, "point_on_axis", z.clone(), corner.clone(), json!({}));
assert!(clean(&r), "{r}");
assert_eq!(dof_of(&r, &s, "Block"), Some(1), "{r}");
assert!(near(place(&s, "Block", Vec3::new(30.0, 40.0, 50.0)), Vec3::ZERO, 1e-6));
for (kind, a, b, says) in [
("insert", z.clone(), z.clone(), "two round edges"),
("tangent", xy.clone(), z.clone(), "a planar face and a round face"),
("distance", xy.clone(), z.clone(), "a planar face and a point"),
] {
let p = json!({"type": kind, "a": a, "b": b, "offset": 1});
let e = s.execute("assembly.constraint.create", &p).expect_err(&p.to_string()).to_string();
assert!(e.contains(says), "{p}: {e}");
}
assert_eq!(s.doc.assembly.joints.len(), 2);
}
/// A shaft and a collar: two round faces. Concentric leaves a slide and a turn; Insert only the
/// turn, with the collar's end on the shaft's.
#[test]
fn concentric_and_insert_leave_a_slide_and_a_turn_or_a_turn() {
let shaft = || {
let mut s = Session::default();
run(&mut s, "component.create", json!({"name": "Shaft"}));
run(&mut s, "solid.cylinder", json!({"base": [0, 0, 0], "axis": [0, 0, 1], "radius": 5, "height": 20, "operation": "new"}));
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "component.create", json!({"name": "Collar"}));
run(&mut s, "solid.cylinder", json!({"base": [50, 10, 30], "axis": [0, 0, 1], "radius": 5, "height": 10, "operation": "new"}));
run(&mut s, "component.activate", json!({"component": "root"}));
let o = occ(&s, "Shaft");
run(&mut s, "occurrence.ground", json!({"occurrence": o, "grounded": true}));
s
};
let top = json!({"occurrence": "Shaft", "circle": [5, 0, 20]});
let bottom = json!({"occurrence": "Collar", "circle": [55, 10, 30]});
let mut s = shaft();
let r = constrain(&mut s, "concentric", top.clone(), bottom.clone(), json!({}));
assert!(clean(&r), "{r}");
assert_eq!(dof_of(&r, &s, "Collar"), Some(2), "{r}");
let c = place(&s, "Collar", Vec3::new(50.0, 10.0, 30.0));
assert!(c.x.abs() < 1e-6 && c.y.abs() < 1e-6, "on the shaft's axis: {c:?}");
let mut s = shaft();
let r = constrain(&mut s, "insert", top, bottom, json!({}));
assert!(clean(&r), "{r}");
assert_eq!(dof_of(&r, &s, "Collar"), Some(1), "{r}");
assert!(near(place(&s, "Collar", Vec3::new(50.0, 10.0, 30.0)), Vec3::new(0.0, 0.0, 20.0), 1e-6));
assert!(near(place(&s, "Collar", Vec3::new(50.0, 10.0, 40.0)), Vec3::new(0.0, 0.0, 30.0), 1e-6), "standing on the shaft, not through it");
}
/// The rules' remaining freedoms: parallel 4, perpendicular and angle 5, point on axis 4, tangent
/// 4 (a roller on the panel: it rolls, slides and turns), each made where the parts are.
#[test]
fn each_rule_leaves_the_freedom_it_should() {
let roller = |s: &mut Session| {
run(s, "component.create", json!({"name": "Roller"}));
run(s, "solid.cylinder", json!({"base": [0, 600, 50], "axis": [1, 0, 0], "radius": 4, "height": 30, "operation": "new"}));
run(s, "component.activate", json!({"component": "root"}));
};
for (kind, a, b, extra, part, want) in [
("parallel", json!("Top"), json!("Bottom"), json!({}), "Bracket", 4),
("perpendicular", json!("Top"), json!("Face"), json!({}), "Bracket", 5),
("angle", json!("Top"), json!("Bottom"), json!({"angle": 30}), "Bracket", 5),
("distance", json!("Top"), json!("Bottom"), json!({"offset": 12}), "Bracket", 3),
("tangent", json!("Top"), json!({"occurrence": "Roller", "circle": [0, 604, 50]}), json!({}), "Roller", 4),
("point_on_axis", json!({"occurrence": "Roller", "circle": [0, 604, 50]}), json!("Corner"), json!({}), "Bracket", 4),
] {
let mut s = panel();
roller(&mut s);
if kind == "point_on_axis" {
let o = occ(&s, "Roller");
run(&mut s, "occurrence.ground", json!({"occurrence": o, "grounded": true}));
}
let r = constrain(&mut s, kind, a, b, extra);
assert!(clean(&r), "{kind}: {r}");
assert_eq!(dof_of(&r, &s, part), Some(want), "{kind}: {r}");
if kind == "tangent" {
// Its axis a radius above the panel's top.
let p = place(&s, "Roller", Vec3::new(0.0, 600.0, 50.0));
assert!((p.z - 6.0).abs() < 1e-6, "tangent: {p:?}");
}
}
}
/// A rule given the wrong kind of reference, or without the number it needs, is refused with what
/// it needs, and nothing is added.
#[test]
fn a_rule_with_the_wrong_references_is_refused() {
let mut s = panel();
let cases = [
(json!({"type": "mate", "a": "Top", "b": "Corner"}), "two planar faces"),
(json!({"type": "insert", "a": "Top", "b": "Bottom"}), "two round edges"),
(json!({"type": "distance", "a": "Top", "b": "Bottom"}), "offset"),
(json!({"type": "angle", "a": "Top", "b": "Bottom"}), "angle"),
(json!({"type": "weld", "a": "Top", "b": "Bottom"}), "`type` must be"),
(json!({"type": "mate", "a": "Top", "b": "Bottom", "offset": "1e999"}), ""),
];
for (p, says) in cases {
let e = s.execute("assembly.constraint.create", &p).expect_err(&p.to_string()).to_string();
assert!(e.contains(says), "{p}: {e}");
}
assert!(s.doc.assembly.joints.is_empty());
}
/// A roller tangent on the panel keeps resting on it as its radius changes, and a gap edited in as
/// a number stays a gap above the face, not the axis's height.
/// A constraint given a blank or `null` name is named after its rule, as one given no name is. A
/// joint.edit to another kind of joint makes it a plain joint: the tag goes, and a former tangent's
/// offset no longer follows its face's radius.
#[test]
fn a_constraint_edited_to_another_joint_type_is_a_plain_joint() {
let mut s = panel();
run(&mut s, "parameters.add", json!({"name": "R", "expression": "4 mm"}));
run(&mut s, "component.create", json!({"name": "Roller"}));
run(&mut s, "solid.cylinder", json!({"base": [0, 600, 50], "axis": [1, 0, 0], "radius": "R", "height": 30, "operation": "new"}));
run(&mut s, "component.activate", json!({"component": "root"}));
let r = constrain(&mut s, "tangent", json!("Top"), json!({"occurrence": "Roller", "circle": [0, 604, 50]}), json!({"name": null}));
assert!(clean(&r), "{r}");
let r = constrain(&mut s, "parallel", json!("Top"), json!("Bottom"), json!({"name": " "}));
assert!(clean(&r), "{r}");
let names: Vec<&str> = s.doc.assembly.joints.iter().map(|j| j.name.as_str()).collect();
assert_eq!(names, ["Tangent1", "Parallel1"]);
run(&mut s, "joint.edit", json!({"joint": "Tangent1", "type": "revolute"}));
let j = s.doc.assembly.joints.iter().find(|j| j.name == "Tangent1").cloned().unwrap();
assert_eq!((j.kind, j.constraint, j.exprs.offset.clone()), (solidcraft_doc::joints::JointKind::Revolute, None, None));
let list = run(&mut s, "joint.list", json!({}));
let listed = list["joints"].as_array().and_then(|js| js.iter().find(|x| x["name"] == "Tangent1")).cloned().unwrap_or_default();
assert!(listed.get("constraint").is_none(), "{listed}");
// The radius no longer moves it.
run(&mut s, "parameters.change", json!({"name": "R", "expression": "7 mm"}));
run(&mut s, "joint.solve", json!({}));
let after = s.doc.assembly.joints.iter().find(|j| j.name == "Tangent1").map(|j| j.offset);
assert_eq!(after, Some(j.offset));
}
#[test]
fn a_tangent_constraint_follows_its_faces_radius() {
let mut s = panel();
run(&mut s, "parameters.add", json!({"name": "R", "expression": "4 mm"}));
run(&mut s, "component.create", json!({"name": "Roller"}));
run(&mut s, "solid.cylinder", json!({"base": [0, 600, 50], "axis": [1, 0, 0], "radius": "R", "height": 30, "operation": "new"}));
run(&mut s, "component.activate", json!({"component": "root"}));
let r = constrain(&mut s, "tangent", json!("Top"), json!({"occurrence": "Roller", "circle": [0, 604, 50]}), json!({}));
assert!(clean(&r), "{r}");
let axis = |s: &Session| place(s, "Roller", Vec3::new(0.0, 600.0, 50.0)).z;
assert!((axis(&s) - 6.0).abs() < 1e-6, "{}", axis(&s));
for (radius, gap, want) in [(7.0, None, 9.0), (2.5, None, 4.5), (2.5, Some(1.0), 5.5), (6.0, None, 9.0)] {
run(&mut s, "parameters.change", json!({"name": "R", "expression": format!("{radius} mm")}));
if let Some(g) = gap {
run(&mut s, "joint.edit", json!({"joint": "Tangent1", "offset": g}));
}
let r = run(&mut s, "joint.solve", json!({}));
assert!(clean(&r), "R {radius}: {r}");
assert!((axis(&s) - want).abs() < 1e-6, "R {radius}, gap {gap:?}: axis at {}, expected {want}", axis(&s));
}
}
+6 -2
View File
@@ -42,8 +42,12 @@ fn target(s: &Session, p: &Value, cmd: &str) -> Result<String> {
fn convert(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "mesh.convert";
let state = s.model.state();
let body = target(s, p, cmd)?;
let source = state.body(&body).ok_or_else(|| bad(cmd, format!("no body `{body}`")))?;
let given = target(s, p, cmd)?;
// The selection and the Browser name a body of a further placement as the world shows it
// (`Mesh (Scan:2)`): that name means the body it shows, which the conversion edits in its
// component, as `appearance.assign` reads it (#9740).
let body = s.own_body_name(&given).ok_or_else(|| bad(cmd, format!("no body `{given}`")))?;
let source = state.body(&body).ok_or_else(|| bad(cmd, format!("no body `{given}`")))?;
if !source.body.is_mesh() {
return Err(bad(cmd, "select a mesh body"));
}
+7
View File
@@ -33,6 +33,7 @@ mod component;
mod component_clone;
mod component_clone_assembly;
mod construct;
mod construct_auto;
mod delete;
pub(crate) mod diagnostics;
mod edit;
@@ -41,6 +42,8 @@ mod features;
mod features_more;
mod join_pieces;
pub(crate) use construct::{GeoForm, GeoFormError, REF as GEO_REF_FORMS, SKETCH_PART, geo_form, is_model_axis, plane_axis_point_kind};
pub use construct_auto::{construct_method, construct_methods, is_construct_tool};
pub use design_link::{LinkProblem, LinkState};
pub(crate) use face::{FaceRefForm, face_ref_form};
pub(crate) use features::profiles as feature_profiles;
pub(crate) use features::{EdgeRefForm, PLANE_FORMS, axis_dir_form, axis_line_object, edge_ref_form, explicit_plane, plane_name_problem, sketch_key};
@@ -85,6 +88,7 @@ mod measure_shape;
mod mesh;
mod metadata;
pub(crate) mod motion;
mod notes;
mod parts;
mod point_cloud;
mod script;
@@ -347,6 +351,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
v.extend(features::COMMANDS.iter());
v.extend(features_more::COMMANDS.iter());
v.extend(mesh::COMMANDS.iter());
v.extend(construct_auto::COMMANDS.iter());
v.extend(construct::COMMANDS.iter());
v.extend(sheet::COMMANDS.iter());
v.extend(plastic::COMMANDS.iter());
@@ -355,6 +360,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
v.extend(component::COMMANDS.iter());
v.extend(component_link::COMMANDS.iter());
v.extend(joints::COMMANDS.iter());
v.extend(joints::constraint::COMMANDS.iter());
v.extend(clearance::COMMANDS.iter());
v.extend(motion::COMMANDS.iter());
v.extend(panels::COMMANDS.iter());
@@ -363,6 +369,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
v.extend(bom::COMMANDS.iter());
v.extend(mass_properties::COMMANDS.iter());
v.extend(browser::COMMANDS.iter());
v.extend(notes::COMMANDS.iter());
v.extend(delete::COMMANDS.iter());
v.extend(edit::COMMANDS.iter());
v.extend(selection::COMMANDS.iter());
+124
View File
@@ -0,0 +1,124 @@
//! Note commands (#1631): personal notes anchored to points in model space, saved with the
//! design and drawn in the viewport as screen-facing callouts. `note.add` anchors a note,
//! `note.update` edits its text, anchor, or hidden flag, `note.delete` removes it, and
//! `note.list` reads them back. Everything lives in the document, so notes are saved and
//! undone like any other design change.
use serde_json::{Value, json};
use solidcraft_doc::Note;
use super::CommandSpec;
use crate::params::{bad, bool_, req_vec3, str_};
use crate::schema::notes::{NoteAddParams, NoteDeleteParams, NoteDeleted, NoteId, NoteItem, NoteListParams, NoteUpdateParams, NotesList};
use crate::schema::{TypedContract, output_schema_for};
use crate::{Result, Session};
const ADD: TypedContract<NoteAddParams> = TypedContract::new();
const UPDATE: TypedContract<NoteUpdateParams> = TypedContract::new();
const DELETE: TypedContract<NoteDeleteParams> = TypedContract::new();
const LIST: TypedContract<NoteListParams> = TypedContract::new();
/// Notes a design may hold.
const MAX_NOTES: usize = 1000;
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("note.add", "Add Note", crate::checked!("note.add", note_add))
.icon("text")
.contract(&ADD)
.output(output_schema_for::<NoteId>)
.params("anchor: [x,y,z] (the point in model space, mm); text: the note (may span lines); hidden?: bool (saved but not drawn) → note: id"),
CommandSpec::new("note.update", "Edit Note", crate::checked!("note.update", note_update))
.icon("edit")
.contract(&UPDATE)
.output(output_schema_for::<NoteId>)
.params("note: id; text?: the new text; anchor?: [x,y,z] (move the anchor); hidden?: bool (show or hide) → note: id"),
CommandSpec::new("note.delete", "Delete Note", crate::checked!("note.delete", note_delete))
.icon("delete")
.contract(&DELETE)
.output(output_schema_for::<NoteDeleted>)
.params("note: id → deleted: id"),
CommandSpec::new("note.list", "List Notes", crate::checked!("note.list", note_list))
.icon("list")
.contract(&LIST)
.output(output_schema_for::<NotesList>)
.noundo()
.doc_only()
.params("→ notes: [{id, anchor: [x,y,z], text, hidden}] in creation order"),
];
fn note_add(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "note.add";
let anchor = req_vec3(CMD, p, "anchor")?;
let text = str_(p, "text").ok_or_else(|| bad(CMD, "`text` is required"))?;
if !Note::text_is_valid(text) {
return Err(bad(CMD, format!("`text` must not be empty and must be at most {} characters", solidcraft_doc::notes::MAX_NOTE_CHARS)));
}
if s.doc.notes.len() >= MAX_NOTES {
return Err(bad(CMD, "too many notes"));
}
let id = s.doc_mut().allocate_id()?;
let hidden = bool_(p, "hidden").unwrap_or(false);
s.doc_mut().notes.push(Note { id, anchor, text: text.to_string(), hidden });
Ok(json!({ "note": id }))
}
/// The index of a note by id.
fn note_index(s: &Session, p: &Value, cmd: &str) -> Result<usize> {
let id = p.get("note").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "`note` must be a note id"))?;
s.doc.notes.iter().position(|n| n.id == id).ok_or_else(|| bad(cmd, format!("no note {id}")))
}
fn note_update(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "note.update";
let i = note_index(s, p, CMD)?;
let text = match str_(p, "text") {
Some(t) if Note::text_is_valid(t) => Some(t.to_string()),
Some(_) => {
return Err(bad(CMD, format!("`text` must not be empty and must be at most {} characters", solidcraft_doc::notes::MAX_NOTE_CHARS)));
}
None => None,
};
let anchor = match p.get("anchor") {
Some(_) => Some(req_vec3(CMD, p, "anchor")?),
None => None,
};
let hidden = bool_(p, "hidden");
if text.is_none() && anchor.is_none() && hidden.is_none() {
return Err(bad(CMD, "give at least one of `text`, `anchor`, `hidden`"));
}
let id = {
let n = &mut s.doc_mut().notes[i];
if let Some(t) = text {
n.text = t;
}
if let Some(a) = anchor {
n.anchor = a;
}
if let Some(h) = hidden {
n.hidden = h;
}
n.id
};
Ok(json!({ "note": id }))
}
fn note_delete(s: &mut Session, p: &Value) -> Result<Value> {
let i = note_index(s, p, "note.delete")?;
let n = s.doc_mut().notes.remove(i);
Ok(json!({ "deleted": n.id }))
}
fn note_list(s: &mut Session, _p: &Value) -> Result<Value> {
let notes: Vec<NoteItem> = s
.doc
.notes
.iter()
.map(|n| NoteItem { id: n.id, anchor: [n.anchor.x, n.anchor.y, n.anchor.z], text: n.text.clone(), hidden: n.hidden })
.collect();
Ok(json!({ "notes": notes }))
}
#[cfg(test)]
#[path = "notes_tests.rs"]
mod tests;
+87
View File
@@ -0,0 +1,87 @@
//! Note commands (#1631): add, edit, delete, list, undo, and persistence.
use serde_json::{Value, json};
use crate::Session;
fn run(s: &mut Session, id: &str, params: Value) -> Value {
s.execute(id, &params).unwrap_or_else(|e| panic!("{id} {params}: {e}"))
}
#[test]
fn add_update_delete_and_list_round_trip() {
let mut s = Session::default();
let added = run(&mut s, "note.add", json!({"anchor": [10.0, 20.0, 30.0], "text": "Check clearance here."}));
let id = added["note"].as_u64().unwrap();
assert!(id > 0);
let listed = run(&mut s, "note.list", json!({}));
let notes = listed["notes"].as_array().unwrap();
assert_eq!(notes.len(), 1);
assert_eq!(notes[0]["id"], json!(id));
assert_eq!(notes[0]["text"], "Check clearance here.");
assert_eq!(notes[0]["anchor"], json!([10.0, 20.0, 30.0]));
assert_eq!(notes[0]["hidden"], json!(false));
run(&mut s, "note.update", json!({"note": id, "text": "Adjust this diameter after the test print.", "hidden": true}));
let listed = run(&mut s, "note.list", json!({}));
assert_eq!(listed["notes"][0]["text"], "Adjust this diameter after the test print.");
assert_eq!(listed["notes"][0]["hidden"], json!(true));
run(&mut s, "note.update", json!({"note": id, "anchor": [1.0, 2.0, 3.0]}));
assert_eq!(run(&mut s, "note.list", json!({}))["notes"][0]["anchor"], json!([1.0, 2.0, 3.0]));
assert_eq!(run(&mut s, "note.delete", json!({"note": id})), json!({"deleted": id}));
assert!(run(&mut s, "note.list", json!({}))["notes"].as_array().unwrap().is_empty());
}
#[test]
fn notes_survive_a_save_and_reopen() {
let mut s = Session::default();
run(&mut s, "note.add", json!({"anchor": [0.0, 0.0, 5.0], "text": "Extra thickness added for strength."}));
let saved = serde_json::to_string(&*s.doc).unwrap();
let reopened = Session::new(serde_json::from_str(&saved).unwrap());
assert_eq!(reopened.doc.notes.len(), 1);
assert_eq!(reopened.doc.notes[0].text, "Extra thickness added for strength.");
}
#[test]
fn undo_undoes_note_changes() {
let mut s = Session::default();
run(&mut s, "note.add", json!({"anchor": [0.0, 0.0, 0.0], "text": "first"}));
run(&mut s, "note.add", json!({"anchor": [1.0, 0.0, 0.0], "text": "second"}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.notes.len(), 1);
assert_eq!(s.doc.notes[0].text, "first");
let id = s.doc.notes[0].id;
run(&mut s, "note.update", json!({"note": id, "text": "renamed"}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.notes[0].text, "first");
}
#[test]
fn ids_are_never_reused() {
let mut s = Session::default();
let a = run(&mut s, "note.add", json!({"anchor": [0.0, 0.0, 0.0], "text": "a"}))["note"].as_u64().unwrap();
run(&mut s, "note.delete", json!({"note": a}));
let b = run(&mut s, "note.add", json!({"anchor": [0.0, 0.0, 0.0], "text": "b"}))["note"].as_u64().unwrap();
assert_ne!(a, b);
}
#[test]
fn bad_calls_are_refused() {
let mut s = Session::default();
// Empty or missing text.
assert!(s.execute("note.add", &json!({"anchor": [0.0, 0.0, 0.0], "text": " "})).is_err());
assert!(s.execute("note.add", &json!({"anchor": [0.0, 0.0, 0.0]})).is_err());
// A non-finite anchor.
assert!(s.execute("note.add", &json!({"anchor": [f64::NAN, 0.0, 0.0], "text": "x"})).is_err());
// Unknown note id.
assert!(s.execute("note.update", &json!({"note": 999, "text": "x"})).is_err());
assert!(s.execute("note.delete", &json!({"note": 999})).is_err());
// An update that changes nothing.
run(&mut s, "note.add", json!({"anchor": [0.0, 0.0, 0.0], "text": "x"}));
let id = s.doc.notes[0].id;
assert!(s.execute("note.update", &json!({"note": id})).is_err());
assert_eq!(s.doc.notes[0].text, "x");
}
+301
View File
@@ -14,6 +14,203 @@ fn near(value: &Value, expected: f64) {
assert!((value.as_f64().unwrap() - expected).abs() < 1e-6, "{value} != {expected}");
}
/// Nested and repeated placements each keep their own Origin: the same item under different
/// paths resolves at its own placement, folders and items follow the browser's eyes, and the
/// saved state round-trips, undoes and prunes with the placements (#7313).
#[test]
fn component_origins_keep_nested_copies_and_visibility_separate() {
use crate::origins;
use solidcore_record::Vec3;
let mut s = Session::default();
let (parent, first) = created(&mut s, "Assembly");
run(&mut s, "occurrence.move", json!({"occurrence":first,"translate":[100,0,0]}));
let (_, child) = created(&mut s, "Child");
run(&mut s, "occurrence.move", json!({"occurrence":child,"translate":[20,0,0]}));
run(&mut s, "component.activate", json!({"component":0}));
let second = run(&mut s, "occurrence.copy", json!({"component":parent,"translate":[50,0,0]}))["occurrence"].as_u64().unwrap();
let (one, two) = (vec![first, child], vec![second, child]);
assert!(s.origin_plane(&origins::key(&one, "XY")).unwrap().origin.dist(Vec3::new(120., 0., 0.)) < 1e-8);
assert!(s.origin_plane(&origins::key(&two, "XY")).unwrap().origin.dist(Vec3::new(170., 0., 0.)) < 1e-8);
assert!(s.shown_component_origins().is_empty());
run(&mut s, "browser.visibility", json!({"tree_folders":[origins::folder(&two)],"visible":true}));
assert!(s.folder_hidden(&origins::folder(&one)));
assert!(!s.folder_hidden(&origins::folder(&two)));
assert_eq!(s.visibility.origin_hidden, None);
assert_eq!(s.shown_component_origins().len(), 7);
run(&mut s, "browser.visibility", json!({"items":[origins::key(&two,"X")],"visible":false}));
assert_eq!(s.shown_component_origins().len(), 6);
assert!(s.construction_visible("X"));
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.shown_component_origins().len(), 7);
run(&mut s, "edit.redo", json!({}));
let mut saved = (*s.doc).clone();
saved.visibility = s.visibility.clone();
let reopened = Session::new(serde_json::from_str(&serde_json::to_string(&saved).unwrap()).unwrap());
assert_eq!(reopened.visibility, s.visibility);
assert_eq!(reopened.shown_component_origins().len(), 6);
run(&mut s, "browser.visibility", json!({"paths":[[second]],"visible":false}));
assert!(s.shown_component_origins().is_empty(), "a hidden parent hides its origin too");
let before = s.visibility.clone();
assert!(s.execute("browser.visibility", &json!({"tree_folders":[origins::folder(&one),"o:999999/origin"],"visible":true})).is_err());
assert_eq!(s.visibility, before, "invalid batches must be atomic");
for invalid in ["o:/origin", "o:0/origin", "o:01/origin", "c0/origin"] {
assert!(s.execute("browser.visibility", &json!({"tree_folders":[invalid],"visible":true})).is_err(), "{invalid}");
}
let folded = run(&mut s, "browser.expand_all", json!({"path":one}));
assert!(folded["closed_by_default"].as_array().unwrap().contains(&json!(origins::folder(&one))));
run(&mut s, "occurrence.move", json!({"occurrence":child,"translate":[10,0,0],"capture":false}));
assert!(s.origin_plane(&origins::key(&one, "XY")).unwrap().origin.dist(Vec3::new(130., 0., 0.)) < 1e-8);
}
/// A component origin pick lands on the geometry as it is placed: a sketch on the datum
/// follows the component, and axis and plane references resolve at the placement in the
/// reading component's frame, whoever authors them (#7313).
#[test]
fn component_origin_picks_use_world_geometry_and_follow_the_placement() {
use crate::origins;
use solidcore_record::Vec3;
let mut s = Session { param_check: crate::schema::ParamCheck::Strict, ..Session::default() };
let (component, occurrence) = created(&mut s, "Part");
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[20,30,40],"axis":[1,0,0],"angle":"90 deg"}));
let plane = origins::key(&[occurrence], "XY");
let expected = Vec3::new(20., 30., 40.);
assert!(s.origin_plane(&plane).unwrap().origin.dist(expected) < 1e-8);
assert!(s.origin_plane(&plane).unwrap().normal().dist(-Vec3::Y) < 1e-8);
s.selection = vec![crate::Sel::Plane { name: plane.clone() }];
run(&mut s, "view.normal_to", json!({}));
let camera = s.camera.unwrap();
assert!(camera.target.dist(expected) < 1e-8);
assert!(camera.back().dot(Vec3::Y).abs() > 1. - 1e-8);
for author in [0, component] {
run(&mut s, "component.activate", json!({"component":author}));
let sketch = run(&mut s, "sketch.create", json!({"plane":plane}))["sketch"].as_u64().unwrap();
let state = s.world_state().unwrap();
let actual = state.sketch(sketch).unwrap().plane;
assert!(actual.origin.dist(expected) < 1e-8, "author {author}: {actual:?}");
assert!(actual.normal().dist(-Vec3::Y) < 1e-8);
run(&mut s, "sketch.finish", json!({}));
}
run(&mut s, "component.activate", json!({"component":0}));
run(&mut s, "construct.point.vertex", json!({"point":{"construct_point":origins::key(&[occurrence],"O")},"name":"Origin copy"}));
let point = crate::view::construction_points(&s).into_iter().find(|(_, n, _)| n == "Origin copy").unwrap().2;
assert!(point.dist(expected) < 1e-8);
run(&mut s, "construct.plane.at_angle", json!({"base":plane,"axis":origins::key(&[occurrence],"X"),"angle":"90 deg","name":"Turned"}));
let turned = crate::view::construction_planes(&s).into_iter().find(|(_, n, _)| n == "Turned").unwrap().2;
assert!(turned.origin.dist(expected) < 1e-8);
assert!(turned.normal().dot(Vec3::Z).abs() > 1. - 1e-8);
run(&mut s, "component.activate", json!({"component":component}));
run(&mut s, "construct.plane.perpendicular", json!({"plane":plane,"line":{"axis":origins::key(&[occurrence],"X")},"name":"Perpendicular"}));
let perpendicular = crate::view::construction_planes(&s).into_iter().find(|(_, n, _)| n.ends_with("Perpendicular")).unwrap().2;
assert!(perpendicular.origin.dist(expected) < 1e-8);
assert!(perpendicular.normal().dot(Vec3::Z).abs() > 1. - 1e-8);
}
/// The cascade rule (the #9791 rule for construction geometry, applied to datums): a hidden
/// placement hides its Origin folder and its items regardless of their own eyes; showing it
/// again restores the saved per-item state.
#[test]
fn hidden_placement_hides_its_origin_and_restores_it() {
use crate::origins;
let mut s = Session::default();
let (_, occurrence) = created(&mut s, "Part");
// The origin shown, one item hidden by itself: the saved per-item state to keep.
run(&mut s, "browser.visibility", json!({"tree_folders":[origins::folder(&[occurrence])],"visible":true}));
run(&mut s, "browser.visibility", json!({"items":[origins::key(&[occurrence],"X")],"visible":false}));
assert_eq!(s.shown_component_origins().len(), 6);
assert!(s.construction_visible(&origins::key(&[occurrence], "Y")));
assert!(!s.construction_visible(&origins::key(&[occurrence], "X")));
// Hiding the placement hides its origin too, whatever the items' own eyes say.
run(&mut s, "browser.visibility", json!({"paths":[[occurrence]],"visible":false}));
assert!(s.shown_component_origins().is_empty(), "a hidden placement hides its origin");
assert!(!s.construction_visible(&origins::key(&[occurrence], "Y")));
assert!(!s.construction_visible(&origins::key(&[occurrence], "X")));
// Showing it again restores the saved eyes: X stays hidden, the other six are back.
run(&mut s, "browser.visibility", json!({"paths":[[occurrence]],"visible":true}));
assert_eq!(s.shown_component_origins().len(), 6);
assert!(s.construction_visible(&origins::key(&[occurrence], "Y")));
assert!(!s.construction_visible(&origins::key(&[occurrence], "X")));
assert_eq!(s.visibility.shown_component_origins, vec![vec![occurrence]], "the placement stays shown by name");
}
/// A placement that is gone cannot be cleared by hand (browser.visibility refuses a path that
/// no longer resolves), so its shown Origin prunes like hidden_occurrences, and undo brings
/// the placement and its shown folder back (#7313).
#[test]
fn deleting_a_placement_prunes_its_shown_origin_and_undo_restores_it() {
use crate::origins;
let mut s = Session::default();
let (component, occurrence) = created(&mut s, "Part");
run(&mut s, "browser.visibility", json!({"tree_folders":[origins::folder(&[occurrence])],"visible":true}));
assert_eq!(s.shown_component_origins().len(), 7);
// A deleted placement's folder cannot be cleared by hand any more: browser.visibility refuses
// a path that no longer resolves, whichever eye it sets. The stale folder prunes the way
// hidden_occurrences does.
run(&mut s, "component.delete", json!({"component": component}));
assert!(s.shown_component_origins().is_empty(), "the deleted placement's shown origin is pruned");
// Undo brings the placement back with its saved eyes.
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.shown_component_origins().len(), 7, "undo restores the shown origin with the placement");
}
/// A bare component origin datum key in a construct reference names its item: O the point, X an
/// axis, XY a plane (through the plane resolver). Before, only the `{axis}`/`{construct_point}`
/// forms read the datums, and the bare shorthand refused `o:2/4/origin/O` as "no plane" (#7313).
#[test]
fn bare_datum_keys_name_their_item_in_construct_refs() {
use crate::origins;
use solidcraft_doc::construct::{AxisDef, DatumItem, GeoRef};
use solidcraft_doc::{FeatureKind, PlaneRef};
let mut s = Session::default();
let (parent, first) = created(&mut s, "Parent");
run(&mut s, "component.create", json!({"name": "Child", "parent": "Parent"}));
let child = s.doc.occurrences.iter().find(|o| o.parent == parent).unwrap().id;
let path = vec![first, child];
run(&mut s, "component.activate", json!({"component": parent}));
run(&mut s, "construct.axis.two_points", json!({"a": origins::key(&path, "O"), "b": {"point": [12, 0, 0]}, "name": "From O"}));
run(&mut s, "construct.axis.edge", json!({"edge": origins::key(&path, "X"), "name": "On X"}));
run(&mut s, "sketch.create", json!({"plane": origins::key(&path, "XY"), "name": "On the datum"}));
// A closure cannot name the output lifetime here (two reference params), so a nested fn
// ties the returned kind to the session it is read from.
fn saved<'a>(s: &'a Session, name: &str) -> &'a FeatureKind {
s.doc.features.iter().find(|f| f.name == name).map(|f| &f.kind).unwrap()
}
match saved(&s, "From O") {
FeatureKind::ConstructionAxis { def } => match def {
AxisDef::TwoPoints { a, .. } => match a {
GeoRef::Datum { datum } => {
assert_eq!(datum.path, path, "the datum names the nested placement");
assert_eq!(datum.item, DatumItem::O);
}
other => panic!("expected the O datum, got {other:?}"),
},
other => panic!("expected two points, got {other:?}"),
},
other => panic!("expected a construction axis, got {other:?}"),
}
match saved(&s, "On X") {
FeatureKind::ConstructionAxis { def } => match def {
AxisDef::Edge { edge } => match edge {
GeoRef::Datum { datum } => assert_eq!(datum.item, DatumItem::X),
other => panic!("expected the X datum, got {other:?}"),
},
other => panic!("expected an edge line, got {other:?}"),
},
other => panic!("expected a construction axis, got {other:?}"),
}
match saved(&s, "On the datum") {
FeatureKind::Sketch { plane, .. } => match plane {
PlaneRef::Datum { datum } => {
assert_eq!(datum.path, path);
assert_eq!(datum.item, DatumItem::XY);
}
other => panic!("expected the XY datum, got {other:?}"),
},
other => panic!("expected a sketch, got {other:?}"),
}
}
/// A new component holds no bodies of its own: creating one changes nothing in the design and
/// rejects body-taking parameters, so its Origin starts empty like the design's own (#7313).
#[test]
fn ambiguous_component_names_are_refused_without_changing_the_session() {
let mut s = Session::default();
@@ -2026,3 +2223,107 @@ fn absolute_move_refuses_relative_keys_and_keeps_capture_and_grounding() {
let r = run(&mut s, "occurrence.move", json!({"occurrence": occ, "to": {"position": [9,0,0]}, "move_grounded": true}));
near(&r["transform"][3][0], 9.0);
}
/// The one resolution order, pinned at the engine's resolver sites: a feature (exact name)
/// first, then a datum key, then the origin aliases. A construction feature named like a datum
/// key resolves as the feature at every site (its saved reference names the feature, or its
/// geometry is where the feature is now); without the feature, the same key is the datum. The
/// dialog sites (`plane_by_name`, `axis_of`, `named_plane`) pin the same order in the ui tests.
#[test]
fn resolver_order_is_feature_first_at_every_site() {
use crate::origins;
use solidcore_record::Vec3;
use solidcraft_doc::{FeatureKind, PlaneRef};
let mut s = Session::default();
let (_, child) = created(&mut s, "Child");
run(&mut s, "occurrence.move", json!({"occurrence": child, "translate": [100, 0, 0]}));
// The features are created in the active component, and `component.create` made Child active:
// back to the root, so "where the feature is now" is the root origin, not the child placement.
run(&mut s, "component.activate", json!({"component": 0}));
let axis_key = origins::key(&[child], "X");
let plane_key = origins::key(&[child], "XY");
// The features the datum-looking names resolve to at every site below.
run(&mut s, "construct.axis.two_planes", json!({"a": "XY", "b": "XZ", "name": &axis_key}));
run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": "2 mm", "name": &plane_key}));
// construct.geo: a saved reference keeps the feature's name.
run(&mut s, "construct.plane.perpendicular", json!({"plane": "XY", "line": {"axis": &axis_key}, "name": "Geo axis"}));
let f = s.doc.features.iter().find(|f| f.name == "Geo axis").unwrap();
let FeatureKind::ConstructionPlane { plane: PlaneRef::Perpendicular { line, .. } } = &f.kind else { panic!("{:?}", f.kind) };
assert!(matches!(line.as_ref(), solidcraft_doc::construct::GeoRef::Axis { name } if *name == axis_key), "{line:?}");
// named_axis_line: the axis is where the feature is now (the root origin), not the datum's.
run(&mut s, "construct.plane.at_angle", json!({"base": "XY", "axis": &axis_key, "angle": "30 deg", "name": "Axis line"}));
let f = s.doc.features.iter().find(|f| f.name == "Axis line").unwrap();
let FeatureKind::ConstructionPlane { plane: PlaneRef::AtAngle { axis_origin, .. } } = &f.kind else { panic!("{:?}", f.kind) };
assert!(axis_origin.dist(Vec3::ZERO) < 1e-9, "{axis_origin:?}");
// axis_param: a revolve saves the construction axis by name, not the datum.
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Profile"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [5, 5], "p1": [15, 15]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.revolve", json!({"sketch": "Profile", "axis": &axis_key, "angle": "180 deg", "name": "Revolved"}));
let f = s.doc.features.iter().find(|f| f.name == "Revolved").unwrap();
let FeatureKind::Revolve { axis: solidcraft_doc::AxisRef::Construction { name }, .. } = &f.kind else { panic!("{:?}", f.kind) };
assert_eq!(name.to_string(), axis_key);
// plane_param: a sketch on the named plane is attached to the feature, not the datum.
let sid = run(&mut s, "sketch.create", json!({"plane": &plane_key, "name": "On the feature"}))["sketch"].as_u64().unwrap();
let FeatureKind::Sketch { plane: PlaneRef::Construction { feature }, .. } = &s.doc.feature(sid).unwrap().kind else {
panic!("{:?}", s.doc.feature(sid).unwrap().kind)
};
let named = s.doc.features.iter().find(|f| f.name == plane_key).unwrap().id;
assert_eq!(feature.0, named);
// shown_item: the exact name is the feature, and a datum key with no feature is the datum.
run(&mut s, "browser.visibility", json!({"items": [&axis_key], "visible": false}));
assert!(s.visibility.hidden_origin.iter().any(|h| h == &axis_key), "the feature hides by its exact name");
run(&mut s, "browser.visibility", json!({"items": [origins::key(&[child], "Y")], "visible": false}));
assert!(s.visibility.hidden_origin.iter().any(|h| h == &origins::key(&[child], "Y")), "the datum item hides by its key");
}
/// The world axes keep the precedence they always had at the axis resolvers: X, Y and Z name
/// the design's axes before a construction axis answers to the same letters, however it is
/// cased (#7313 review). An angled plane about `x` stands on the world axis, as it did before
/// component origins, not on a construction axis named `x`.
#[test]
fn world_axis_names_keep_their_precedence_at_the_axis_resolvers() {
use solidcore_record::Vec3;
use solidcraft_doc::{FeatureKind, PlaneRef};
let mut s = Session::default();
// An older design can hold a construction axis named like a world axis (case included):
// away from the origin, so the geometry tells which one the command took.
run(&mut s, "construct.axis.two_points", json!({"a":{"point":[7,0,0]},"b":{"point":[12,0,0]},"name":"x"}));
for name in ["x", "X"] {
run(&mut s, "construct.plane.at_angle", json!({"base":"XY","axis":name,"angle":"30 deg","name":format!("Angled {name}")}));
let f = s.doc.features.iter().find(|f| f.name == format!("Angled {name}")).unwrap();
let FeatureKind::ConstructionPlane { plane: PlaneRef::AtAngle { axis_origin, .. } } = &f.kind else { panic!("{:?}", f.kind) };
assert!(axis_origin.dist(Vec3::ZERO) < 1e-9, "{name}: the world axis, not the construction axis at {axis_origin:?}");
}
}
/// Look At keeps the world planes for their names: a picked `XY` looks at the design's XY
/// plane, not at a construction plane of the same name (an older design can hold one), as it
/// did before component origins (#7313 review). A datum key without a feature of its name
/// still follows its placement.
#[test]
fn look_at_keeps_the_world_planes_for_their_names() {
use crate::origins;
use solidcore_record::Vec3;
let mut s = Session::default();
let (_, occurrence) = created(&mut s, "Part");
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[0,0,5]}));
run(&mut s, "component.activate", json!({"component":0}));
run(&mut s, "construct.plane.offset", json!({"base":"XY","offset":"3 mm","name":"Old plane"}));
// The commands refuse a plane named like a world plane today; an older design predates
// that check, so the fixture renames it by hand.
s.doc_mut().features.iter_mut().find(|f| f.name == "Old plane").unwrap().name = "XY".into();
s.selection = vec![crate::Sel::Plane { name: "XY".into() }];
run(&mut s, "view.normal_to", json!({}));
let camera = s.camera.unwrap();
assert!(camera.target.dist(Vec3::ZERO) < 1e-9, "the design's XY plane, not the construction plane of the same name at {:?}", camera.target);
let key = origins::key(&[occurrence], "XY");
s.selection = vec![crate::Sel::Plane { name: key }];
run(&mut s, "view.normal_to", json!({}));
let camera = s.camera.unwrap();
assert!(camera.target.dist(Vec3::new(0., 0., 5.)) < 1e-9, "the datum follows its placement: {:?}", camera.target);
}
+16 -2
View File
@@ -4,12 +4,12 @@
use serde_json::{Value, json};
use solidcore_record::Vec2;
use solidcraft_geom::prelude::*;
use solidcraft_sketch::{ConstraintKind, CurveKind, LinkKind, LinkSource, Shape, Sketch, TextAlign, TextExprs, TextStyle};
use solidcraft_sketch::{ConstraintKind, CurveKind, LinkKind, LinkSource, Shape, Sketch, TextAlign, TextExprs, TextStyle, TextVerticalAlign};
/// The style parameters of `sketch.text` and `sketch.edit_text`.
macro_rules! text_style_doc {
() => {
"bold?, italic?: bool (an installed family's bold or italic face; italic without one is slanted), align?: left|center|right (the lines against `at`, or along the path), letter_spacing?: extra mm between letters, line_spacing?: times the font's line distance (default 1), path?: a curve id of the sketch the baseline follows (\"\": none; `at` and `angle` then do not place the text; the curve becomes construction, a guide the letters stand on), reverse?: bool (the other way along the path)"
"bold?, italic?: bool (an installed family's bold or italic face; italic without one is slanted), align?: left|center|right (the lines against `at`, or along the path), vertical_align?: baseline|top|middle|bottom (the block of lines against `at` or the path: the first line's baseline (default), its capitals' tops, halfway between those and the last line's baseline, or the bottom of the last line's descenders), letter_spacing?: extra mm between letters, line_spacing?: times the font's line distance (default 1), path?: a curve id of the sketch the baseline follows (\"\": none; `at` and `angle` then do not place the text; the curve becomes construction, a guide the letters stand on), reverse?: bool (the other way along the path)"
};
}
@@ -246,6 +246,20 @@ fn text_style(p: &Value, cmd: &str, old: TextStyle) -> Result<TextStyle> {
}
Some(_) => return Err(bad(cmd, "`align` must be left, center or right")),
}
const VERTICAL_MUST: &str = "`vertical_align` must be baseline, top, middle or bottom";
match p.get("vertical_align") {
None | Some(Value::Null) => {}
Some(Value::String(a)) => {
style.vertical_align = match a.trim().to_ascii_lowercase().as_str() {
"baseline" => TextVerticalAlign::Baseline,
"top" => TextVerticalAlign::Top,
"middle" | "center" | "centre" => TextVerticalAlign::Middle,
"bottom" => TextVerticalAlign::Bottom,
_ => return Err(bad(cmd, VERTICAL_MUST)),
}
}
Some(_) => return Err(bad(cmd, VERTICAL_MUST)),
}
if p.get("letter_spacing").is_some_and(|v| !v.is_null()) {
style.letter_spacing =
num(p, "letter_spacing").filter(|v| v.is_finite()).ok_or_else(|| bad(cmd, "`letter_spacing` must be a length in mm"))?;
@@ -226,6 +226,8 @@ fn text_style_parameters_are_checked() {
(json!({"link":link,"bold":true}), "no bold face"),
(json!({"link":link,"bold":"yes"}), "`bold` must be true or false"),
(json!({"link":link,"align":"justify"}), "`align` must be left, center or right"),
(json!({"link":link,"vertical_align":"above"}), "`vertical_align` must be baseline, top, middle or bottom"),
(json!({"link":link,"vertical_align":1}), "`vertical_align` must be baseline, top, middle or bottom"),
(json!({"link":link,"line_spacing":0}), "`line_spacing` must be a positive factor"),
(json!({"link":link,"letter_spacing":"wide"}), "`letter_spacing` must be a length"),
(json!({"link":link,"path":"no-such-curve"}), "not a curve of this sketch"),
@@ -235,14 +237,19 @@ fn text_style_parameters_are_checked() {
assert!(e.contains(error), "{params}: {e}");
assert_eq!(s.doc.to_json(), before);
}
run(&mut s, "sketch.edit_text", json!({"link":link,"italic":true,"align":"right","letter_spacing":1.5,"line_spacing":1.2}));
run(
&mut s,
"sketch.edit_text",
json!({"link":link,"italic":true,"align":"right","vertical_align":"middle","letter_spacing":1.5,"line_spacing":1.2}),
);
let LinkSource::Text { style, text, .. } = &s.doc.sketch(1).unwrap().link(&link).unwrap().source else { panic!() };
assert_eq!(text, "II");
assert!(style.italic && style.align == solidcraft_sketch::TextAlign::Right && style.letter_spacing == 1.5 && style.line_spacing == 1.2);
assert_eq!(style.vertical_align, solidcraft_sketch::TextVerticalAlign::Middle);
// Left out, a style stays.
run(&mut s, "sketch.edit_text", json!({"link":link,"text":"I"}));
let LinkSource::Text { style, .. } = &s.doc.sketch(1).unwrap().link(&link).unwrap().source else { panic!() };
assert!(style.italic && style.letter_spacing == 1.5);
assert!(style.italic && style.letter_spacing == 1.5 && style.vertical_align == solidcraft_sketch::TextVerticalAlign::Middle);
}
/// `base` with the keys of `extra` set (a null `extra` value is sent as null).
+32 -5
View File
@@ -16,14 +16,15 @@ use super::features::{add_feature, axis_param, check_expr, extent_param, feature
use crate::params::{bad, bool_, expr, num, str_, string_list, vec3};
use crate::schema::features::{FeatureResult, SurfaceSweepParams};
use crate::schema::surface::{
FairParams, OffsetParams, ReverseNormalParams, StitchParams, SurfaceExtendParams, SurfaceLoftParams, SurfaceTrimParams, ThickenParams,
UnstitchParams, UntrimParams,
FairParams, OffsetParams, PatchParams, PatchResult, ReverseNormalParams, StitchParams, SurfaceExtendParams, SurfaceLoftParams, SurfaceTrimParams,
ThickenParams, UnstitchParams, UntrimParams,
};
use crate::schema::surface_blend::BlendParams;
use crate::schema::surface_ruled::SurfaceRuledParams;
use crate::schema::{TypedContract, output_schema_for};
use crate::{Result, Session};
const PATCH_CONTRACT: TypedContract<PatchParams> = TypedContract::new();
const STITCH_CONTRACT: TypedContract<StitchParams> = TypedContract::new();
const UNSTITCH_CONTRACT: TypedContract<UnstitchParams> = TypedContract::new();
const REVERSE_NORMAL_CONTRACT: TypedContract<ReverseNormalParams> = TypedContract::new();
@@ -39,10 +40,12 @@ const BLEND_CONTRACT: TypedContract<BlendParams> = TypedContract::new();
const SURFACE_RULED_CONTRACT: TypedContract<SurfaceRuledParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("surface.patch", "Patch", patch)
CommandSpec::new("surface.patch", "Patch", crate::checked!("surface.patch", patch))
.at("SURFACE", "CREATE")
.icon("surface_patch")
.params("sketch?: id|name and profiles? (planar patches of sketch regions) | edges: [[x,y,z]] (points on a closed loop of a body's edges: one face on them, planar when the loop is, else a smooth B-spline surface) and body?; continuity?: g0|g1|g2|g3|g4 (edges only, default g0: how the patch meets the faces beside the loop — g1 their tangent planes, g2 also their curvature across the edges, g3 also its rate of change, g4 the change of that rate) or [{edge: [x,y,z], g: g0|g1|g2|g3|g4}] (those edges so, the rest g0) or {all, edges}; points?: [[x,y,z]] (edges only, up to 64: points the patch passes through); crown?: number −0.9…3 (default 0: only a fitted patch, whose middle becomes fuller by this many times the loop's size — the bump vanishes on the loop, so the face still meets the picked edges and the continuity across them; a loop that is planar and g0 is the exact plane whatever the crown; with points, crown needs a g1 or higher edge); name?, body_name?"),
.contract(&PATCH_CONTRACT)
.output(output_schema_for::<PatchResult>)
.params("sketch?: id|name and profiles? (planar patches of sketch regions) or profiles_3d: [indices] (the sketch's planar 3D-sketch loops, each patched in its own plane, as listed by sketch.inspect) | edges: [[x,y,z]] (points on a closed loop of a body's edges: one face on them, planar when the loop is, else a smooth B-spline surface) and body?; continuity?: g0|g1|g2|g3|g4 (edges only, default g0: how the patch meets the faces beside the loop — g1 their tangent planes, g2 also their curvature across the edges, g3 also its rate of change, g4 the change of that rate) or [{edge: [x,y,z], g: g0|g1|g2|g3|g4}] (those edges so, the rest g0) or {all, edges}; points?: [[x,y,z]] (edges only, up to 64: points the patch passes through); crown?: number −0.9…3 (default 0: only a fitted patch, whose middle becomes fuller by this many times the loop's size — the bump vanishes on the loop, so the face still meets the picked edges and the continuity across them; a loop that is planar and g0 is the exact plane whatever the crown; with points, crown needs a g1 or higher edge); name?, body_name?"),
CommandSpec::new("surface.stitch", "Stitch", crate::checked!("surface.stitch", stitch))
.at("SURFACE", "MODIFY")
.icon("surface_stitch")
@@ -140,9 +143,13 @@ fn patch(s: &mut Session, p: &Value) -> Result<Value> {
let crown = crown_param(p, cmd)?;
let continuity = patch_continuity(p, cmd)?;
let points: Vec<Position> = patch_points(p, cmd)?.into_iter().map(Position).collect();
let profiles_3d = profiles_3d_param(p, cmd)?;
let edges: Vec<solidcore_record::Vec3> =
p.get("edges").and_then(Value::as_array).map(|a| a.iter().filter_map(vec3).collect()).unwrap_or_default();
let mut v = if !edges.is_empty() {
if !profiles_3d.is_empty() {
return Err(bad(cmd, "`profiles_3d` is for a patch on a sketch"));
}
if edges.len() > 10_000 {
return Err(bad(cmd, "too many edges"));
}
@@ -153,6 +160,7 @@ fn patch(s: &mut Session, p: &Value) -> Result<Value> {
FeatureKind::Patch {
sketch: None,
profiles: ProfileSel::All,
profiles_3d: Vec::new(),
body: body.map(BodyName),
edges: edges.into_iter().map(Position).collect(),
crown,
@@ -165,10 +173,13 @@ fn patch(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, "`continuity` and `points` are for a patch on a loop of edges"));
}
let sketch = feature_sketch(s, p, cmd)?;
let profiles = profiles(p, cmd)?;
// With only 3D loops asked for, the sketch's own-plane profiles stay untouched.
let profiles =
if !profiles_3d.is_empty() && p.get("profiles").is_none() { ProfileSel::Indices { indices: Vec::new() } } else { profiles(p, cmd)? };
let kind = FeatureKind::Patch {
sketch: Some(SketchId(sketch)),
profiles,
profiles_3d,
body: None,
edges: Vec::new(),
crown,
@@ -184,6 +195,22 @@ fn patch(s: &mut Session, p: &Value) -> Result<Value> {
Ok(v)
}
/// Indices of the sketch's planar 3D loops (#741): `profiles_3d: [0, 2]`, as listed by
/// `sketch.inspect`.
fn profiles_3d_param(p: &Value, cmd: &str) -> Result<Vec<usize>> {
let Some(v) = p.get("profiles_3d") else { return Ok(Vec::new()) };
let a = v.as_array().ok_or_else(|| bad(cmd, "`profiles_3d` must be a list of indices"))?;
if a.is_empty() || a.len() > 128 {
return Err(bad(cmd, "`profiles_3d` needs 1 to 128 indices"));
}
a.iter()
.map(|i| {
let n = i.as_u64().ok_or_else(|| bad(cmd, "`profiles_3d` indices must be whole numbers"))?;
usize::try_from(n).map_err(|_| bad(cmd, format!("there is no profile {n}")))
})
.collect()
}
/// A continuity word for a patch, within what a patch builds ([`Construction::Patch`]).
fn patch_order(v: Option<&Value>, cmd: &str) -> Result<Continuity> {
crate::params::continuity(cmd, v, "a patch's continuity", Construction::Patch)
+22 -2
View File
@@ -26,7 +26,7 @@ pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new(CMD, "Export Current Vi
.noundo()
.contract(&CONTRACT)
.output(output_schema_for::<ViewDxfResult>)
.params("camera: Camera (current view); hidden?: omit|dashed (default omit); tolerance?: 0.0001..1 mm (curve chord tolerance, default 0.01); path?: .dxf file (else returns dxf text). Visible placed bodies and shown sketches' profile curves (no construction geometry, points, dimensions or constraints); 2D LINE/LWPOLYLINE entities on VISIBLE/HIDDEN layers (sketch curves on SKETCH), mm; finished sketches are hidden behind bodies, the active sketch is drawn on top; perspective projected onto the camera target plane; no viewport crop or overlays.")];
.params("camera: Camera (current view); hidden?: omit|dashed (default omit); tolerance?: 0.0001..1 mm (curve chord tolerance, default 0.01); zero?: target|origin (where the drawing's 0,0 is: the camera target, default, or the model origin as seen from the camera); path?: .dxf file (else returns dxf text). Visible placed bodies and shown sketches' profile curves (no construction geometry, points, dimensions or constraints); 2D LINE/LWPOLYLINE entities on VISIBLE/HIDDEN layers (sketch curves on SKETCH), mm; finished sketches are hidden behind bodies, the active sketch is drawn on top; perspective projected onto the camera target plane; no viewport crop or overlays.")];
fn export(s: &mut Session, p: &Value) -> Result<Value> {
let cam: Camera =
@@ -48,6 +48,12 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
if !sketch::exportable(s) {
return Err(bad(CMD, "no visible bodies or sketches to export"));
}
let at_origin = match p.get("zero") {
None => false,
Some(Value::String(v)) if v == "target" => false,
Some(Value::String(v)) if v == "origin" => true,
_ => return Err(bad(CMD, "zero must be target or origin")),
};
let tolerance = match p.get("tolerance") {
None => 0.01,
Some(v) => v.as_f64().filter(|t| (0.0001..=1.0).contains(t)).ok_or_else(|| bad(CMD, "tolerance must be 0.0001..1 mm"))?,
@@ -64,7 +70,21 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
let meshes = mesh_lines::prepare(s, CMD, tolerance, |b| s.body_visible(&b.name))?;
let prepare_ms = started.elapsed().as_secs_f64() * 1000.;
let sketch = sketch::parts(s, tolerance).map_err(|e| bad(CMD, e))?;
let drawing = mesh_lines::drawing(&meshes, &sketch, &View::from_camera(&cam)).map_err(|e| bad(CMD, e))?;
let view = View::from_camera(&cam);
let mut drawing = mesh_lines::drawing(&meshes, &sketch, &view).map_err(|e| bad(CMD, e))?;
if at_origin {
// The drawing is centred on the camera target; move it so the model origin is 0,0.
let zero = view.world_origin().map_err(|e| bad(CMD, e))?;
for l in &mut drawing.lines {
l.a -= zero;
l.b -= zero;
}
for path in &mut drawing.sketch {
for pt in &mut path.points {
*pt -= zero;
}
}
}
let writing = Stage::new("dxf_write", 1);
writing.check().map_err(|e| bad(CMD, e))?;
let mut visible = 0usize;
+33
View File
@@ -252,3 +252,36 @@ fn view_dxf_hides_finished_sketches_behind_bodies_but_draws_the_active_sketch_on
assert_eq!(dashed["sketch"], 2);
assert!((length(&dashed) - 80.).abs() < 1e-3, "{}", length(&dashed));
}
#[test]
fn view_dxf_zero_origin_puts_the_model_origin_at_the_drawing_zero() {
let mut s = sample();
let bounds = |v: &Value| {
let (mut lo, mut hi) = (Vec2::new(f64::MAX, f64::MAX), Vec2::new(f64::MIN, f64::MIN));
for g in solidcraft_io::read_dxf(v["dxf"].as_str().unwrap()).unwrap() {
let solidcraft_io::Geom2::Line(a, b) = g else { panic!("{g:?}") };
for p in [a, b] {
(lo, hi) = (Vec2::new(lo.x.min(p.x), lo.y.min(p.y)), Vec2::new(hi.x.max(p.x), hi.y.max(p.y)));
}
}
(lo, hi)
};
let near = |a: Vec2, x: f64, y: f64| (a.x - x).abs() < 1e-3 && (a.y - y).abs() < 1e-3;
// By default the drawing is centred on the camera target (20, 15), whatever the model origin.
let (lo, hi) = bounds(&export(&mut s, camera(StandardView::Top), "omit"));
assert!(near(lo, -20., -15.) && near(hi, 20., 15.), "{lo:?} {hi:?}");
// `zero: origin` is the same drawing with the model origin, the plate's corner, at 0,0.
for zero in [None, Some("target")] {
let mut p = json!({"camera": camera(StandardView::Top)});
if let Some(z) = zero {
p["zero"] = json!(z);
}
let (lo, _) = bounds(&run(&mut s, CMD, p));
assert!(near(lo, -20., -15.), "{zero:?}");
}
let v = run(&mut s, CMD, json!({"camera": camera(StandardView::Top), "zero": "origin"}));
let (lo, hi) = bounds(&v);
assert!(near(lo, 0., 0.) && near(hi, 40., 30.), "{lo:?} {hi:?}");
let err = s.execute(CMD, &json!({"camera": camera(StandardView::Top), "zero": "corner"})).unwrap_err().to_string();
assert!(err.contains("zero must be target or origin"), "{err}");
}
+13 -1
View File
@@ -243,7 +243,19 @@ fn normal_target(s: &Session) -> Option<(solidcore_record::Vec3, solidcore_recor
let plane = state.body(body)?.body.planar_face(*index)?;
Some((*point, plane.normal()))
}
Sel::Plane { name } => solidcore_record::Plane::named(name).map(|plane| (plane.origin, plane.normal())),
Sel::Plane { name } => {
// The design's world planes keep the precedence they always had: `XY` looks at the
// design's XY plane before a construction plane of the same name answers (an older
// design can hold one named `XY`) (#7313 review). A component origin key follows
// its placement, with a construction plane of the same name still winning over it.
let feature = || crate::view::construction_planes(s).into_iter().find(|(_, n, _)| n == name).map(|(_, _, plane)| plane);
let plane = if crate::origins::datum(name).is_some() {
feature().or_else(|| s.origin_plane(name))
} else {
solidcore_record::Plane::named(name).or_else(feature)
};
plane.map(|plane| (plane.origin, plane.normal()))
}
_ => None,
})
}
@@ -318,3 +318,35 @@ fn an_editor_accepts_the_placements_rolling_the_marker_solves() {
assert_eq!(session.doc.marker, None);
assert_eq!(occurrence(&session, "B").transform, moved);
}
/// Cancel on a computing OK (solidcore#3247): a dialog's computation of a long Combine Cut (a
/// coil cut from a cylinder, as in #3962) stops within 500 ms of Cancel and says it was
/// cancelled. Nothing it computed is kept in the evaluation cache the session shares: OK again
/// computes the cut anew, and finishes.
#[test]
fn a_cancelled_computation_stops_promptly_and_is_never_cached() {
use solidcraft_doc::progress::{Progress, watch};
use std::time::{Duration, Instant};
let mut session = Session::default();
session.execute("solid.cylinder", &json!({"radius": 20, "height": 120, "body_name": "Stock"})).unwrap();
let coil = json!({"diameter": 51, "pitch": 15.24, "turns": 6, "section": "square", "section_size": 13, "body_name": "Tool", "operation": "new", "base": [0, 0, 12]});
session.execute("solid.coil", &coil).unwrap();
let cut = vec![("solid.combine".to_string(), json!({"operation": "cut", "target": "Stock", "tools": ["Tool"]}))];
let progress = Arc::new(Progress::default());
let (watching, scratch, commands) = (progress.clone(), session.scratch(), cut.clone());
let worker = std::thread::spawn(move || {
let r = scratch.stop_token().within(|| watch(watching, || scratch.compute_commands(&commands, None)));
(r.map(|_| ()).map_err(|e| e.to_string()), Instant::now())
});
std::thread::sleep(Duration::from_millis(300));
let cancelled = Instant::now();
progress.cancel();
let (r, returned) = worker.join().unwrap();
let took = returned.saturating_duration_since(cancelled);
assert!(r.as_ref().is_err_and(|e| e.contains("cancelled")), "{r:?}");
assert!(took < Duration::from_millis(500), "returned {took:?} after Cancel");
let again = session.scratch().compute_commands(&cut, None).unwrap();
// The cut is the one feature computed anew (the session's counter carries over to its copies).
assert_eq!(again.computed.model.recomputed, session.model.recomputed + 1, "nothing of the cancelled run was cached");
assert!(again.computed.model.results.iter().all(|r| r.error.is_none()), "OK again finishes the cut");
}
+110
View File
@@ -967,3 +967,113 @@ fn construction_on_a_moved_body_moves_with_it() {
let (o, d) = axis(&s, "Tether");
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:?}");
}
/// 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.
#[test]
fn feature_named_like_a_datum_key_stays_reachable() {
let mut s = Session::default();
let made = run(&mut s, "component.create", json!({"name": "Child", "grounded": false}));
let child = made["occurrence"].as_u64().unwrap();
run(&mut s, "occurrence.move", json!({"occurrence": child, "translate": [100, 0, 0]}));
let key = crate::origins::key(&[child], "X");
run(&mut s, "construct.axis.two_planes", json!({"a": "XY", "b": "XZ", "name": key}));
// The name resolves the feature, not a frozen line of the origin axis it is spelled like.
run(&mut s, "construct.plane.perpendicular", json!({"plane": "XY", "line": {"axis": key}, "name": "P"}));
let f = s.doc.features.iter().find(|f| f.name == "P").unwrap();
let solidcraft_doc::FeatureKind::ConstructionPlane { plane: solidcraft_doc::PlaneRef::Perpendicular { line, .. } } = &f.kind else {
panic!("{:?}", f.kind)
};
let solidcraft_doc::construct::GeoRef::Axis { name } = line.as_ref() else { panic!("{:?}", line) };
assert_eq!(name.to_string(), key);
// Without the feature the same pick is the component origin axis, saved as its datum: it
// follows the placement instead of staying where it was picked.
run(&mut s, "construct.plane.perpendicular", json!({"plane": "XY", "line": {"axis": crate::origins::key(&[child], "Y")}, "name": "Q"}));
let f = s.doc.features.iter().find(|f| f.name == "Q").unwrap();
let solidcraft_doc::FeatureKind::ConstructionPlane { plane: solidcraft_doc::PlaneRef::Perpendicular { line, .. } } = &f.kind else {
panic!("{:?}", f.kind)
};
let solidcraft_doc::construct::GeoRef::Datum { datum } = line.as_ref() else { panic!("{:?}", line) };
assert_eq!(datum.item, solidcraft_doc::construct::DatumItem::Y);
assert_eq!(datum.path, vec![child]);
}
/// Plane, Axis and Point build with the first method of their family that takes the references:
/// the feature is that method's own, and the reply names it.
#[test]
fn plane_axis_and_point_build_with_the_first_method_that_takes_the_references() {
let mut s = part();
let top = json!({"face": [20, 15, 20]});
let x_edge = json!({"edge": [20, 0, 0]});
let corner = json!({"vertex": [40, 30, 20]});
let cases = [
("construct.plane", json!({"refs": [top], "with": {"offset": 5, "name": "Lid"}}), "construct.plane.offset"),
("construct.plane", json!({"refs": [corner, {"plane": "XY"}], "with": {"name": "Corner"}}), "construct.plane.offset"),
("construct.plane", json!({"refs": [{"plane": "XY"}, top], "with": {"name": "Mid"}}), "construct.plane.midplane"),
(
"construct.plane",
json!({"refs": [{"plane": "XY"}, {"axis": "X"}], "with": {"angle": "90 deg", "name": "Turned"}}),
"construct.plane.at_angle",
),
(
"construct.plane",
json!({"refs": [{"point": [0, 0, 7]}, {"point": [1, 0, 7]}, {"point": [0, 1, 7]}], "with": {"name": "Three"}}),
"construct.plane.three_points",
),
("construct.axis", json!({"refs": [{"face": [65, 0, 5]}], "with": {"name": "Bore"}}), "construct.axis.through_cylinder"),
("construct.axis", json!({"refs": [{"plane": "XZ"}, {"plane": "YZ"}], "with": {"name": "Meet"}}), "construct.axis.two_planes"),
("construct.point", json!({"refs": [corner], "with": {"name": "Tip"}}), "construct.point.vertex"),
(
"construct.point",
json!({"refs": [{"plane": "XY"}, {"plane": "XZ"}, {"plane": "YZ"}], "with": {"name": "Zero"}}),
"construct.point.three_planes",
),
("construct.point", json!({"refs": [x_edge], "with": {"t": 0.5, "name": "Half"}}), "construct.point.along_path"),
];
for (id, p, method) in cases {
let features = s.doc.features.len();
assert_eq!(run(&mut s, id, p.clone())["method"], method, "{id} {p}");
assert_eq!(s.doc.features.len(), features + 1, "{id} {p}: one feature, the method's own");
}
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{:?}", s.model.results);
let lid = plane(&s, "Lid");
assert!(parallel(lid.normal(), Vec3::Z) && on_plane(&lid, Vec3::new(1.0, 1.0, 25.0)), "{lid:?}");
let corner_plane = plane(&s, "Corner");
assert!(on_plane(&corner_plane, Vec3::new(1.0, 1.0, 20.0)), "{corner_plane:?}");
let mid = plane(&s, "Mid");
assert!(on_plane(&mid, Vec3::new(1.0, 1.0, 10.0)), "{mid:?}");
let turned = plane(&s, "Turned");
assert!(parallel(turned.normal(), Vec3::Y), "{turned:?}");
let three = plane(&s, "Three");
assert!(on_plane(&three, Vec3::new(5.0, 5.0, 7.0)), "{three:?}");
let (o, d) = axis(&s, "Bore");
assert!(parallel(d, Vec3::Z) && on_line(o, d, Vec3::new(60.0, 0.0, 3.0)), "{o:?} {d:?}");
let (o, d) = axis(&s, "Meet");
assert!(parallel(d, Vec3::Z) && on_line(o, d, Vec3::ZERO), "{o:?} {d:?}");
assert!(near(point(&s, "Tip"), Vec3::new(40.0, 30.0, 20.0)));
assert!(near(point(&s, "Zero"), Vec3::ZERO));
assert!(near(point(&s, "Half"), Vec3::new(20.0, 0.0, 0.0)), "{:?}", point(&s, "Half"));
// One undo step takes the feature away.
let features = s.doc.features.len();
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.features.len(), features - 1);
}
/// References no method builds from, and a `with` key no method takes, are refused and leave the
/// design as it was.
#[test]
fn plane_axis_and_point_refuse_references_no_method_takes() {
let mut s = part();
let before = (*s.doc).clone();
for (id, p) in [
("construct.plane", json!({"refs": [{"point": [0, 0, 0]}, {"point": [1, 0, 0]}]})),
("construct.plane", json!({"refs": [{"plane": "XY"}, {"plane": "XZ"}], "with": {"bogus": 1}})),
("construct.axis", json!({"refs": [{"vertex": [40, 30, 20]}]})),
("construct.point", json!({"refs": [{"plane": "XY"}]})),
("construct.plane", json!({"refs": []})),
] {
assert!(s.execute(id, &p).is_err(), "{id} {p}");
assert_eq!(*s.doc, before, "{id} {p}: a refusal changes nothing");
}
}
+9
View File
@@ -324,6 +324,10 @@ impl Mapper<'_> {
if v.is_array() {
return self.pick(v, None);
}
// A name, and among them a component origin key (`o:…/origin/XY`) in particular, passes
// through untouched: the command's resolver applies the one order - a feature of the same
// name first, then the datum - and saves the reference that follows what it names (#7313),
// instead of the plane being frozen where it was picked.
let o = v.as_object()?;
let mut m = o.clone();
if let Some(x) = o.get("face").and_then(|x| self.pick(x, None)) {
@@ -721,6 +725,11 @@ fn map_local(s: &Session, world: &solidcraft_doc::ModelState, id: &str, params:
vec3(v).and_then(|p| m.local_point(&frame, p))
}
Axis => {
// A name, and among them a component origin key (`o:…/origin/X`), passes
// through untouched: the command's resolver applies the one order - a feature
// of the same name first, then the datum - and saves the reference that
// follows what it names (#7313), instead of the axis being frozen where it
// was picked.
if let Some(axis) = v.as_object() {
let mut axis = axis.clone();
// An `{origin, dir}` axis without its origin passes through the world's,
+32
View File
@@ -1118,3 +1118,35 @@ fn edge_picks_with_no_placement_to_look_through_are_checked() {
run(&mut s, "solid.chamfer", json!({"edges": [[20, 0, 0]], "distance": 1}));
assert!((body_volume(&s, "Box") - (BLOCK - CUT)).abs() < 1e-3 * CUT, "{}", body_volume(&s, "Box"));
}
/// A sketch (and a construction plane) picked on another component's origin follows that
/// component: the reference is saved as the placement's datum, not as a world plane frozen
/// where the component was when it was picked (#7313).
#[test]
fn cross_component_sketch_refs_follow_the_component() {
use solidcraft_doc::construct::DatumItem;
let mut s = Session::default();
let made = run(&mut s, "component.create", json!({"name": "Assembly", "grounded": false}));
let (parent, first) = (made["component"].as_u64().unwrap(), made["occurrence"].as_u64().unwrap());
run(&mut s, "occurrence.move", json!({"occurrence": first, "translate": [100, 0, 0]}));
let child = run(&mut s, "component.create", json!({"name": "Child", "grounded": false}))["occurrence"].as_u64().unwrap();
run(&mut s, "occurrence.move", json!({"occurrence": child, "translate": [20, 0, 0]}));
run(&mut s, "component.activate", json!({"component": 0}));
run(&mut s, "occurrence.copy", json!({"component": parent, "translate": [50, 0, 0]}));
// A sketch on the child's XY in its first placement, and a plane offset from the same plane.
let plane = crate::origins::key(&[first, child], "XY");
let sid = run(&mut s, "sketch.create", json!({"plane": plane, "name": "On child"}))["sketch"].as_u64().unwrap();
let f = s.doc.feature(sid).unwrap();
let solidcraft_doc::FeatureKind::Sketch { plane: solidcraft_doc::PlaneRef::Datum { datum }, .. } = &f.kind else { panic!("{:?}", f.kind) };
assert_eq!(datum.item, DatumItem::XY);
assert_eq!(datum.path, vec![first, child]);
run(&mut s, "construct.plane.offset", json!({"base": plane, "offset": "5 mm", "name": "Off child"}));
// The sketch is where the child's XY is, and it follows the child when it moves.
let sketch_plane = |s: &Session| s.world_state().unwrap().sketch(sid).unwrap().plane;
assert!(near(sketch_plane(&s).origin, Vec3::new(120.0, 0.0, 0.0)), "{:?}", sketch_plane(&s));
run(&mut s, "occurrence.move", json!({"occurrence": child, "translate": [10, 0, 0]}));
assert!(near(sketch_plane(&s).origin, Vec3::new(130.0, 0.0, 0.0)), "{:?}", sketch_plane(&s));
// The offset plane, built on the datum, follows the move too - offset and all.
let (_, _, off) = crate::view::construction_planes(&s).into_iter().find(|(_, n, _)| n == "Off child").unwrap();
assert!(near(off.origin, Vec3::new(130.0, 0.0, 5.0)), "{off:?}");
}
+1
View File
@@ -86,6 +86,7 @@ fn classify(e: &EngineError) -> Outcome {
| EngineError::BadParams { .. }
| EngineError::Disabled(..)
| EngineError::Refused { .. }
| EngineError::Cancelled(_)
| EngineError::UnknownCommand(_) => Outcome::new("rejected", msg),
// A failed boolean is a failure even when it says "not supported": those are the cases
// to fix. Features the kernel doesn't do yet are limitations.
+18 -2
View File
@@ -22,6 +22,7 @@ pub mod fuzz;
pub mod licences;
mod mesh_lines;
pub mod occurrences;
pub mod origins;
pub mod panels;
pub mod params;
pub mod product_scene;
@@ -51,8 +52,8 @@ use solidcraft_doc::{Document, Model, ModelState};
pub use cmd::settings::{MAX_PROFILE_NAME, profile_name};
pub use cmd::{
Addition, CommandInfo, CommandSpec, OpenSteps, addition, auto_operation, command_specs, dimension_roles, extrude_reach, find_command,
resolve_design_links, shown_scope, standard_view,
Addition, CommandInfo, CommandSpec, LinkProblem, LinkState, OpenSteps, addition, auto_operation, command_specs, dimension_roles, extrude_reach,
find_command, resolve_design_links, shown_scope, standard_view,
};
pub use solidcraft_doc as doc;
pub use solidcraft_doc::Visibility;
@@ -87,6 +88,10 @@ pub enum EngineError {
/// A command panicked; the document was kept as it was (a bug: please report it).
#[error("internal error in `{0}` (the design was kept as it was): {1}")]
Internal(String, String),
/// The work running the command was cancelled ([`solidcraft_doc::progress::Progress::cancel`])
/// while it ran: nothing was applied, whatever was computed before the cancel came in.
#[error("`{0}` was cancelled; nothing was applied")]
Cancelled(String),
}
impl From<solidcraft_doc::DocError> for EngineError {
@@ -1729,6 +1734,15 @@ impl Session {
Err(EngineError::Internal(spec.id.to_string(), msg))
}
};
// Cancelled while it ran: no result, even one finished before the cancel came in, and
// the design goes back as it was (solidcore#3247). Opening, starting or recovering a
// design (which replaces the session) keeps its own Cancel: the features not yet built
// are skipped and what is built so far is shown (#10577).
let r = if solidcraft_doc::progress::cancelled() && self.replacements == replacements {
Err(EngineError::Cancelled(spec.id.to_string()))
} else {
r
};
// The kernel's internal errors come up without the command's id.
let r = r.map_err(|e| match e {
EngineError::Internal(id, msg) if id.is_empty() => {
@@ -2528,6 +2542,7 @@ fn same_but_features(a: &Document, b: &Document) -> bool {
timeline_groups,
browser_order,
named_views,
notes,
visibility,
next_component_color_index,
} = a;
@@ -2564,6 +2579,7 @@ fn same_but_features(a: &Document, b: &Document) -> bool {
&& *timeline_groups == b.timeline_groups
&& *browser_order == b.browser_order
&& *named_views == b.named_views
&& *notes == b.notes
&& *visibility == b.visibility
&& *next_component_color_index == b.next_component_color_index
}
@@ -73,6 +73,13 @@ impl View {
Ok(projected)
}
/// Where the world origin lands in the view's right and up, so a drawing can be shifted to
/// put its zero there.
pub(crate) fn world_origin(&self) -> Result<Vec2, &'static str> {
let p = self.project(Vec3::new(0., 0., 0.))?;
Ok(Vec2::new(p.x, p.y))
}
/// How far behind a surface a line must be to count as covered, in the depth `project` gives.
pub(super) fn depth_eps(&self, eps: f64) -> f64 {
self.perspective.map_or(eps, |distance| 1e-8 / distance)
+20 -3
View File
@@ -801,6 +801,10 @@ impl Session {
/// Is a browser folder or group hidden itself (by tree key, see `Visibility::hidden_folders`)?
pub fn folder_hidden(&self, key: &str) -> bool {
// A component Origin folder: hidden by default, shown only by name (`shown_component_origins`).
if let Some(path) = crate::origins::folder_path(key) {
return !self.visibility.shown_component_origins.contains(&path);
}
if let Some(cache) = self.visibility_index() {
return cache.as_ref().is_none_or(|c| c.folder_keys.contains(key));
}
@@ -862,12 +866,20 @@ impl Session {
if self.visibility.hidden_origin.iter().any(|h| h == name) {
return false;
}
match self.doc.features.iter().find(|f| f.name == name) {
Some(f) => self.feature_placement_visible(f.id) && !self.feature_folder_hidden(f.id, "construction"),
None => true,
// A feature first, so one named like a datum key still reaches it; then a component
// origin item, gated by its placement (a hidden placement hides its origin too) and its
// folder's own eye.
if let Some(f) = self.doc.features.iter().find(|f| f.name == name) {
return self.feature_placement_visible(f.id) && !self.feature_folder_hidden(f.id, "construction");
}
if let Some((path, _)) = crate::origins::item(name) {
return self.visibility.shown_component_origins.contains(&path) && self.occurrence_visible(&path);
}
true
}
/// Whether an occurrence shows in the world: its own eye and every ancestor placement's
/// eye along its path. A hidden placement hides its Origin folder and items too (#7313).
pub fn body_visible(&self, name: &str) -> bool {
if self.body_hidden_itself(name) || self.body_removed_later(name) {
return false;
@@ -1005,5 +1017,10 @@ impl Session {
}
let hidden = std::mem::take(&mut self.visibility.hidden_occurrences);
self.visibility.hidden_occurrences = hidden.into_iter().filter(|p| self.occurrence_placement(p).is_ok()).collect();
// A shown Origin folder whose placement is gone cannot be cleared by hand - browser.visibility
// refuses a path that no longer resolves, whichever eye it sets - so it prunes like
// hidden_occurrences does; undo restores the snapshot with the placement (#7313).
let shown = std::mem::take(&mut self.visibility.shown_component_origins);
self.visibility.shown_component_origins = shown.into_iter().filter(|p| self.occurrence_placement(p).is_ok()).collect();
}
}
+97
View File
@@ -0,0 +1,97 @@
//! The reference geometry of an exact assembly placement, without timeline features.
use solidcore_record::{Plane, Vec3};
use solidcraft_doc::construct::{ConstructGeom, DatumItem, DatumRef};
use crate::Session;
pub const ITEMS: [&str; 7] = ["O", "X", "Y", "Z", "XY", "XZ", "YZ"];
pub const MAX_SHOWN: usize = 10_000;
/// A component Origin folder uses the same scope as its Bodies and Sketches folders.
pub fn folder(path: &[u64]) -> String {
format!("{}/origin", Session::folder_scope(path))
}
/// Parse a bounded, canonical placement folder key. The design's origin has its own controls.
pub fn folder_path(key: &str) -> Option<Vec<u64>> {
if key.len() > 2048 {
return None;
}
let ids = key.strip_prefix("o:")?.strip_suffix("/origin")?;
if ids.is_empty() || ids.split('/').count() > 64 {
return None;
}
ids.split('/').map(|id| id.parse::<u64>().ok().filter(|n| *n != 0 && n.to_string() == id)).collect()
}
/// An item key carries the complete path: a shared child in two parents has two origins. The
/// item reads the way the document's datum grammar parses it — case-insensitively, answered in
/// the canonical spelling (`yz` answers `YZ`) — so a resolver that takes a key in any spelling
/// and one that matches the stored spelling agree about what a key names, and a key no datum
/// grammar accepts is no datum anywhere (#7313).
pub fn item(name: &str) -> Option<(Vec<u64>, &'static str)> {
let (key, item) = name.rsplit_once('/')?;
let item = ITEMS.iter().find(|known| known.eq_ignore_ascii_case(item))?;
Some((folder_path(key)?, *item))
}
/// The Browser key of a placement's origin item: the folder path, then the item
/// (`o:2/origin/XY`). Resolvers and the saved visibility speak these keys.
pub fn key(path: &[u64], item: &str) -> String {
format!("{}/{item}", folder(path))
}
/// The datum an item key names, by the document's shared datum grammar: `o:<ids>/origin/<item>`
/// with its strict bounds. Resolvers save it, so the reference follows the placement.
pub fn datum(name: &str) -> Option<DatumRef> {
DatumRef::parse_key(name)
}
impl Session {
/// Resolve a Browser pick even when its origin is hidden. Includes pending placement moves.
pub fn origin_geometry(&self, name: &str) -> Option<ConstructGeom> {
let d = datum(name)?;
let placement = self.occurrence_placement(&d.path).ok()?;
Some(d.item.geom().placed(&placement.world))
}
/// The plane a component origin key names, placed where its component now stands:
/// `None` for the axes and the point, and for a key whose placement is gone.
pub fn origin_plane(&self, name: &str) -> Option<Plane> {
match self.origin_geometry(name)? {
ConstructGeom::Plane(p) => Some(p),
_ => None,
}
}
/// The axis a component origin key names, placed where its component now stands:
/// `None` for the planes and the point, and for a key whose placement is gone.
pub fn origin_axis(&self, name: &str) -> Option<(Vec3, Vec3)> {
match self.origin_geometry(name)? {
ConstructGeom::Axis { origin, dir } => Some((origin, dir)),
_ => None,
}
}
/// Reference geometry explicitly shown by the user. No assembly expansion on every frame.
pub fn shown_component_origins(&self) -> Vec<(String, ConstructGeom)> {
let mut out = Vec::new();
let mut seen = std::collections::BTreeSet::new();
for path in self.visibility.shown_component_origins.iter().take(MAX_SHOWN) {
if path.is_empty() || path.len() > 64 || !seen.insert(path) || !self.occurrence_visible(path) {
continue;
}
let Ok(placement) = self.occurrence_placement(path) else { continue };
for item in ITEMS {
let name = key(path, item);
if !self.visibility.hidden_origin.contains(&name)
&& let Some(geom) = DatumItem::parse(item).map(|d| d.geom())
{
out.push((name, geom.placed(&placement.world)));
}
}
}
out
}
}
+1 -1
View File
@@ -437,7 +437,7 @@ pub fn default_weight() -> f32 {
}
pub fn validate_weight(weight: f32) -> PanelResult<()> {
if !weight.is_finite() || weight < 0.0 || weight > MAX_LOGICAL {
if !weight.is_finite() || !(0.0..=MAX_LOGICAL).contains(&weight) {
return Err(error(Rule::Bounds, "weight must be finite and in [0, 32768]"));
}
Ok(())
+4
View File
@@ -971,6 +971,9 @@ pub struct VisibilityState {
/// Whether the Origin folder is hidden. Present when set.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub origin_hidden: Option<bool>,
/// Component Origin folders explicitly shown, by occurrence path. Present when there are any.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub shown_component_origins: Vec<Vec<u64>>,
/// Sketches whose closed profiles are not shaded, by feature id. Present when there are any.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub hidden_profiles: Vec<u64>,
@@ -995,6 +998,7 @@ impl From<&solidcraft_doc::Visibility> for VisibilityState {
shown_sketches: v.shown_sketches.clone(),
hidden_joints: v.hidden_joints.clone(),
origin_hidden: v.origin_hidden,
shown_component_origins: v.shown_component_origins.clone(),
hidden_profiles: v.hidden_profiles.clone(),
shown_dimensions: v.shown_dimensions.clone(),
locked_bodies: v.locked_bodies.clone(),
@@ -240,6 +240,7 @@ fn the_visibility_reply_type_matches_the_design_s() {
shown_sketches: vec![5],
hidden_joints: vec![6],
origin_hidden: Some(true),
shown_component_origins: vec![vec![3]],
hidden_profiles: vec![7],
shown_dimensions: vec![8],
locked_bodies: vec!["B".into()],
+15 -2
View File
@@ -20,6 +20,7 @@ use solidcraft_geom::Vec3Geom as _;
use super::check::{located, whole};
use super::types::{not_blank_pattern, present};
use crate::cmd::LinkState;
use crate::params;
/// The most components `component.update_links` and `component.replace_with_link` read from a
@@ -432,8 +433,9 @@ pub struct LinksResult {
pub param_warnings: Vec<String>,
}
/// One linked component. A file that is looked for has `found` and `changed`; a refused one is
/// not looked for and has `found` null and a `reason`.
/// One linked component. A file that is looked for has `found` and `changed`, and a `state` and
/// a `reason` when it can't be reached; a refused one is not looked for and has `found` null, a
/// `state` and a `reason`.
#[derive(Debug, Serialize, JsonSchema)]
#[serde(untagged)]
pub enum LinkInfo {
@@ -451,6 +453,13 @@ pub enum LinkInfo {
/// Whether the file is no longer what this design last read (`component.update_links`
/// reads it again); null when it is not found or the link is older than this record.
changed: Option<bool>,
/// Why the file can't be reached: `missing`, `unreadable` or `not_allowed`. Left out
/// when it can be read.
#[serde(default, skip_serializing_if = "Option::is_none")]
state: Option<LinkState>,
/// What to show about it. Left out with `state`.
#[serde(default, skip_serializing_if = "Option::is_none")]
reason: Option<String>,
},
/// The file is on a network location that is not allowed.
Network {
@@ -462,6 +471,8 @@ pub enum LinkInfo {
found: Option<bool>,
/// The network location.
host: String,
/// Always `not_allowed`.
state: LinkState,
reason: String,
},
/// The path names a device.
@@ -472,6 +483,8 @@ pub enum LinkInfo {
file: String,
/// Always null: it is not looked for.
found: Option<bool>,
/// Always `unreadable`.
state: LinkState,
reason: String,
},
}
@@ -200,6 +200,11 @@ fn link_replies_match_the_output_schema() {
// The design B links changed, and C's file is gone: B updates, C is named in `failed`.
save_box(&b, 25);
std::fs::remove_file(&c).unwrap_or_else(|e| panic!("{e}"));
// A file that is gone says why: its state and reason, in a reply that matches.
let gone = run("component.links", json!({}));
let missing = gone["links"].as_array().and_then(|l| l.iter().find(|e| e["component"] == third["component"])).cloned().unwrap_or_default();
assert!(missing["found"] == false && missing["state"] == "missing" && missing["reason"].is_string(), "{gone}");
matches(output_schema_for::<LinksResult>(), "component.links", &gone);
let update = run("component.update_links", json!({"components": ["B", "C", "A"]}));
assert!(update["updated"] == json!(["B"]) && update["unchanged"] == json!(["A"]), "{update}");
assert!(update["failed"][0]["name"] == "C" && update["failed"][0]["component"] == third["component"], "{update}");
+26 -1
View File
@@ -6,7 +6,7 @@
//! is refused when the feature is evaluated, which fails the command.
use schemars::JsonSchema;
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use super::types::{AngleExpr, ConstructionCommon, GeoRefParam, NumberExpr, PlaneCommon, Point3, feature_common, present};
@@ -254,6 +254,31 @@ pub struct PlanePerpendicularParams {
pub common: PlaneCommon,
}
/// Add a construction plane, axis or point (`construct.plane`, `construct.axis`,
/// `construct.point`) with the first of the family's methods (`construct.plane.*`…, in the order
/// the commands are listed) that takes the references in its reference parameters, with `with`.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct ConstructAutoParams {
/// What to build it from, one to three references in any order.
#[schemars(length(min = 1, max = 3))]
pub refs: Vec<GeoRefParam>,
/// The method's other parameters (`offset`, `angle`, `t`, `name`…): only a method that takes
/// them all is used.
#[serde(default)]
pub with: Option<serde_json::Map<String, serde_json::Value>>,
}
/// What `construct.plane`, `construct.axis` and `construct.point` return: the feature, and the
/// method they chose.
#[derive(Debug, Serialize, JsonSchema)]
pub struct ConstructAutoResult {
/// The id of the CONSTRUCT command whose method was used (`construct.plane.midplane`…).
pub method: String,
#[serde(flatten)]
pub feature: super::features::FeatureResult,
}
#[cfg(test)]
#[path = "construct_tests.rs"]
mod tests;
+4
View File
@@ -962,6 +962,10 @@ pub struct SketchInspected {
pub points: Vec<SketchPoint>,
pub constraints: Vec<SketchConstraint>,
pub profiles: Vec<SketchProfile>,
/// Planar closed loops the 3D sketch curves take part in, in planes of their own (#741):
/// each has `index`, `area_mm2`, `plane` (origin and unit normal), `centroid`, `curves`, `holes`.
#[schemars(with = "Vec<Map<String, Value>>")]
pub profiles_3d: Vec<Value>,
/// Links to geometry outside the sketch (projections, intersections, includes), as saved.
#[schemars(with = "Vec<Map<String, Value>>")]
pub links: Value,
+92 -1
View File
@@ -8,7 +8,7 @@ use serde::{Deserialize, Deserializer, Serialize};
use serde_json::{Map, Value, json};
use solidcore_record::Vec3;
use solidcraft_doc::joints::cam::MAX_PROFILE_EDGES;
use solidcraft_doc::joints::{Coupling, JointExprs, JointKind, Snap};
use solidcraft_doc::joints::{Constraint, Coupling, JointExprs, JointKind, Snap};
use solidcraft_geom::Vec3Geom;
use super::check::whole;
@@ -559,6 +559,88 @@ impl<'de> Deserialize<'de> for CreateJointParams {
}
}
/// An assembly constraint's type, as `assembly.constraint.create` takes it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ConstraintTypeParam(pub Constraint);
const CONSTRAINT_MESSAGE: &str = "`type` must be mate, flush, distance, concentric, insert, parallel, perpendicular, angle, tangent or point_on_axis";
impl ConstraintTypeParam {
fn read(v: &Value) -> Result<Self, String> {
v.as_str().and_then(Constraint::parse).map(ConstraintTypeParam).ok_or_else(|| CONSTRAINT_MESSAGE.to_string())
}
}
impl JsonSchema for ConstraintTypeParam {
fn schema_name() -> Cow<'static, str> {
"ConstraintType".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "The rule: mate (two planes against each other), flush (two planes facing the same way), distance (a plane or a point `offset` from a plane), concentric (two round faces on one axis), insert (two round edges at one centre: only a turn is left), parallel, perpendicular, angle (two planes or axes), tangent (a cylinder resting on a plane) or point_on_axis. Case, spaces, `_` and `-` are ignored; coincident and axis are also taken.",
"type": "string",
"minLength": 1
})
}
}
/// Constrain two parts by a geometric rule between a reference on each: the rule is kept as a
/// joint of the kind it needs (a mate is planar), made where the parts are, so b moves no further
/// than the rule needs and keeps every motion it leaves free. Several constraints between one pair
/// add up. One undo step.
#[derive(Debug, JsonSchema)]
#[schemars(deny_unknown_fields, transform = create_required)]
pub struct CreateConstraintParams {
/// The rule.
#[serde(default, rename = "type", skip_serializing_if = "Option::is_none")]
#[schemars(with = "ConstraintTypeParam")]
pub kind: Option<ConstraintTypeParam>,
/// Side a's reference: a joint origin, as joint.create takes it (a face for a plane, a circle
/// for an axis, a point).
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "JointOriginArg")]
pub a: Option<JointOriginArg>,
/// Side b's reference, as `a`; b is the part that moves.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "JointOriginArg")]
pub b: Option<JointOriginArg>,
/// The gap from a's reference to b's, in mm or an expression (a mate's or flush's gap, a
/// distance; needed for distance). Default: 0.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "JointValue")]
pub offset: Option<JointValue>,
/// An angle constraint's angle, in degrees or an expression (needed for angle).
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "JointValue")]
pub angle: Option<JointValue>,
/// Turn b over: its reference along a's instead of the way the rule puts it. Default: a mate
/// is against, a flush along, the others as the parts face now.
#[serde(default)]
pub flip: Option<bool>,
/// The constraint's name. Default: the rule and the first free number (Mate1).
#[serde(default)]
pub name: Option<String>,
/// Leave out `dof` and `fixed` in the reply. Default: false.
#[serde(default)]
pub quiet: Option<bool>,
}
impl<'de> Deserialize<'de> for CreateConstraintParams {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let m = keys(d)?;
Ok(CreateConstraintParams {
kind: given(&m, "type", ConstraintTypeParam::read)?,
a: given(&m, "a", JointOriginArg::read)?,
b: given(&m, "b", JointOriginArg::read)?,
offset: given(&m, "offset", JointValue::read)?,
angle: given(&m, "angle", JointValue::read)?,
flip: flag(&m, "flip")?,
name: text(&m, "name")?,
quiet: flag(&m, "quiet")?,
})
}
}
/// Add a joint between two occurrences that stay where they are: its frame is a joint origin
/// (`at`, default b's origin), and the joint starts with the origins coinciding as they are. An
/// origin on a or b is kept on its face or hole there, so the joint follows it as the design
@@ -1034,6 +1116,11 @@ pub struct ListedJoint {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "JointExpressions")]
pub expressions: Option<JointExprs>,
/// The assembly constraint the joint was made as (mate, flush…). Left out for a joint made
/// as a joint.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "Option<String>")]
pub constraint: Option<Constraint>,
}
/// A named joint origin.
@@ -1318,6 +1405,10 @@ pub struct InspectedJoint {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "JointExpressions")]
pub expressions: Option<JointExprs>,
/// The assembly constraint it was made as (mate, flush…). Left out for a joint made as a joint.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "Option<String>")]
pub constraint: Option<Constraint>,
}
/// What `joint.inspect` returns.
+39 -4
View File
@@ -3,10 +3,10 @@ use solidcore_record::Vec3;
use solidcraft_doc::joints::JointKind;
use super::{
AsBuiltJointParams, CamParams, CamResult, CouplingResult, CreateJointParams, DeleteJointParams, DeleteJointResult, DriveJointParams,
DriveJointResult, EditJointParams, GearParams, InspectJointParams, InspectJointResult, JointAddedResult, JointKindName, JointLimitsParams,
ListJointsParams, ListJointsResult, MakeJointOriginParams, MakeJointOriginResult, MotionLinkParams, RackPinionParams, ReachParams, ReachResult,
RigidGroupParams, RigidGroupResult, ScrewParams, SolveJointsParams, SolveResult,
AsBuiltJointParams, CamParams, CamResult, CouplingResult, CreateConstraintParams, CreateJointParams, DeleteJointParams, DeleteJointResult,
DriveJointParams, DriveJointResult, EditJointParams, GearParams, InspectJointParams, InspectJointResult, JointAddedResult, JointKindName,
JointLimitsParams, ListJointsParams, ListJointsResult, MakeJointOriginParams, MakeJointOriginResult, MotionLinkParams, RackPinionParams,
ReachParams, ReachResult, RigidGroupParams, RigidGroupResult, ScrewParams, SolveJointsParams, SolveResult,
};
use crate::Session;
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
@@ -19,6 +19,7 @@ fn schemas_and_the_key_check_agree() {
input_schema_for::<CreateJointParams>(),
&["a", "angle", "b", "flip", "limits", "name", "offset", "quiet", "type", "value", "values"],
);
assert_keys(input_schema_for::<CreateConstraintParams>(), &["a", "angle", "b", "flip", "name", "offset", "quiet", "type"]);
assert_keys(input_schema_for::<AsBuiltJointParams>(), &["a", "at", "b", "limits", "name", "quiet", "type", "value", "values"]);
assert_keys(input_schema_for::<DriveJointParams>(), &["continuous", "index", "joint", "quiet", "stop_at_budget", "value", "values"]);
assert_keys(input_schema_for::<JointLimitsParams>(), &["index", "joint", "max", "min", "quiet"]);
@@ -42,6 +43,7 @@ fn schemas_and_the_key_check_agree() {
#[test]
fn the_commands_publish_their_schemas() {
for id in [
"assembly.constraint.create",
"joint.create",
"joint.as_built",
"joint.drive",
@@ -204,6 +206,13 @@ fn strict_keeps_todays_errors() {
("joint.screw", json!({"a": true, "b": 1, "pitch": 1})),
("joint.cam", json!({})),
("joint.cam", json!({"a": false, "b": 1})),
("assembly.constraint.create", json!({})),
("assembly.constraint.create", json!({"type": "weld"})),
("assembly.constraint.create", json!({"type": 5})),
("assembly.constraint.create", json!({"type": "mate"})),
("assembly.constraint.create", json!({"type": "mate", "a": 5, "b": at(0, 1.0)})),
("assembly.constraint.create", json!({"type": "mate", "a": {}, "b": at(0, 1.0)})),
("assembly.constraint.create", json!({"type": "mate", "a": at(0, 0.0), "b": at(0, 1.0), "offset": true})),
]);
}
@@ -510,6 +519,18 @@ fn the_schemas_take_what_the_handlers_read() {
input_schema_for::<InspectJointParams>(),
&[(json!({}), true), (json!({"joint": "J"}), true), (json!({"joint": 4}), true), (json!({"joint": true}), false)],
);
takes(
input_schema_for::<CreateConstraintParams>(),
&[
(json!({"type": "mate", "a": {"face": [0, 0, 0]}, "b": "Origin"}), true),
(json!({"type": "distance", "a": {"face": [0, 0, 0]}, "b": {"point": [1, 2, 3]}, "offset": "gap"}), true),
(json!({"type": "angle", "a": "A", "b": "B", "angle": 30}), true),
(json!({"a": "A", "b": "B"}), false),
(json!({"type": 5, "a": "A", "b": "B"}), false),
(json!({"type": "mate", "a": "A", "b": "B", "flip": 1}), false),
(json!({"type": "mate", "a": "A", "b": "B", "values": [1]}), false),
],
);
takes(input_schema_for::<ListJointsParams>(), &[(json!({}), true), (json!({"quiet": false}), true), (json!({"quiet": 1}), false)]);
takes(input_schema_for::<SolveJointsParams>(), &[(json!({}), true), (json!({"quiet": false}), true), (json!({"quiet": 1}), false)]);
}
@@ -555,6 +576,20 @@ fn replies_match_the_output_schema() {
json!({"type": "rigid", "a": "Top", "b": {"occurrence": "LeafC", "face": [20, 15, 0]}, "name": "Stack", "offset": "0 mm", "angle": 0}),
);
matches(output_schema_for::<JointAddedResult>(), "joint.create", &by_origin);
// assembly.constraint.create answers as joint.create does.
let rule = run(
"assembly.constraint.create",
json!({"type": "parallel", "a": {"occurrence": "LeafA", "face": [20, 15, 3]}, "b": {"occurrence": "LeafE", "face": [20, 15, 3]}, "name": "Rule"}),
);
matches(output_schema_for::<JointAddedResult>(), "assembly.constraint.create", &rule);
// Listed and inspected with the rule it was made as.
let list = run("joint.list", json!({}));
matches(output_schema_for::<ListJointsResult>(), "joint.list", &list);
assert!(list["joints"].as_array().is_some_and(|j| j.iter().any(|j| j["constraint"] == "parallel")), "{list}");
let inspected = run("joint.inspect", json!({"joint": "Rule"}));
matches(output_schema_for::<InspectJointResult>(), "joint.inspect", &inspected);
assert_eq!(inspected["joints"][0]["constraint"], "parallel", "{inspected}");
run("joint.delete", json!({"joint": "Rule"}));
let built = run("joint.as_built", json!({"type": "slider", "a": "LeafB", "b": "LeafC", "name": "Slide", "limits": [[0, 10]], "quiet": false}));
matches(output_schema_for::<JointAddedResult>(), "joint.as_built", &built);
let at = run("joint.as_built", json!({"type": "rigid", "a": "LeafA", "b": "LeafD", "at": {"point": [0, 0, 0]}, "values": [1]}));
+1
View File
@@ -89,6 +89,7 @@ pub mod motion_analysis;
pub mod motion_play;
pub mod motion_trace;
pub mod new_design;
pub mod notes;
pub mod panels;
pub mod parameter_sweep;
pub mod parameters;
+77
View File
@@ -0,0 +1,77 @@
//! Typed contracts for the note commands (#1631).
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use super::types::Point3;
/// Anchor a new note to a point in model space.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct NoteAddParams {
/// The point the note is anchored to: `[x, y, z]` (mm).
pub anchor: Point3,
/// The note's text (may span lines).
pub text: String,
/// Save the note hidden: kept with the design but not drawn.
#[serde(default)]
pub hidden: bool,
}
/// Change a note's text, anchor, or hidden flag.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct NoteUpdateParams {
/// The note's id.
pub note: u64,
/// The new text (may span lines).
#[serde(default)]
pub text: Option<String>,
/// The new anchor point: `[x, y, z]` (mm).
#[serde(default)]
pub anchor: Option<Point3>,
/// Show or hide the note.
#[serde(default)]
pub hidden: Option<bool>,
}
/// Delete a note.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct NoteDeleteParams {
/// The note's id.
pub note: u64,
}
/// List the design's notes (takes nothing).
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct NoteListParams {}
/// The result of `note.add` and `note.update`: the note's id.
#[derive(Debug, Serialize, JsonSchema)]
pub struct NoteId {
pub note: u64,
}
/// The result of `note.delete`: the id that was removed.
#[derive(Debug, Serialize, JsonSchema)]
pub struct NoteDeleted {
pub deleted: u64,
}
/// One note as `note.list` returns it.
#[derive(Debug, Serialize, JsonSchema)]
pub struct NoteItem {
pub id: u64,
/// The anchor point `[x, y, z]` (mm).
pub anchor: [f64; 3],
pub text: String,
pub hidden: bool,
}
/// The result of `note.list`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct NotesList {
pub notes: Vec<NoteItem>,
}
+26
View File
@@ -277,6 +277,28 @@ fn hidden<'de, D: Deserializer<'de>>(d: D) -> Result<Option<HiddenLines>, D::Err
read_as(d, "hidden must be omit or dashed")
}
/// Where the drawing's zero point is.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DxfZero {
/// The point the camera looks at.
Target,
/// The model's origin, seen from the camera.
Origin,
}
fn zero_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "Where the drawing's zero point is: `target` is the point the camera looks at, `origin` is the model's origin as seen from the camera (for machining from a part corner). Default: target.",
"enum": ["target", "origin"],
"default": "target"
})
}
fn zero<'de, D: Deserializer<'de>>(d: D) -> Result<Option<DxfZero>, D::Error> {
read_as(d, "zero must be target or origin")
}
fn tolerance<'de, D: Deserializer<'de>>(d: D) -> Result<Option<f64>, D::Error> {
read_with(d, "tolerance must be 0.0001..1 mm", |v| v.as_f64().filter(|t| (0.0001..=1.0).contains(t)))
}
@@ -329,6 +351,10 @@ pub struct ExportViewDxfParams {
#[serde(default, deserialize_with = "tolerance", skip_serializing_if = "Option::is_none")]
#[schemars(schema_with = "tolerance_schema")]
pub tolerance: Option<f64>,
/// Where the drawing's zero point is: the camera target or the model's origin. Default: target.
#[serde(default, deserialize_with = "zero", skip_serializing_if = "Option::is_none")]
#[schemars(schema_with = "zero_schema")]
pub zero: Option<DxfZero>,
/// The `.dxf` file to write. Default: the reply carries the DXF text instead.
#[serde(default, deserialize_with = "export_path", skip_serializing_if = "Option::is_none")]
#[schemars(schema_with = "path_schema")]
+4 -1
View File
@@ -29,7 +29,7 @@ fn matches(schema: schemars::Schema, id: &str, reply: &Value) {
#[test]
fn schemas_and_the_key_check_agree() {
assert_keys(input_schema_for::<SilhouetteParams>(), &["bodies", "camera", "rows", "view"]);
assert_keys(input_schema_for::<ExportViewDxfParams>(), &["camera", "hidden", "path", "tolerance"]);
assert_keys(input_schema_for::<ExportViewDxfParams>(), &["camera", "hidden", "path", "tolerance", "zero"]);
}
/// Strict gives the handlers' messages for the calls they refuse for a key (a missing `camera`
@@ -209,6 +209,8 @@ fn the_schemas_take_what_the_handlers_read() {
(json!({"camera": camera(), "tolerance": 2}), false),
(json!({"camera": camera(), "tolerance": 0}), false),
(json!({"camera": camera(), "pathh": "a.dxf"}), false),
(json!({"camera": camera(), "zero": "origin"}), true),
(json!({"camera": camera(), "zero": "corner"}), false),
],
);
}
@@ -223,6 +225,7 @@ fn the_check_names_what_is_wrong_with_a_key() {
assert_eq!(issues(SIL, json!({"bodies": [1]})), ["`bodies` must contain names"]);
assert_eq!(issues(DXF, json!({"camera": camera(), "hidden": 1})), ["hidden must be omit or dashed"]);
assert_eq!(issues(DXF, json!({"camera": camera(), "tolerance": 3})), ["tolerance must be 0.0001..1 mm"]);
assert_eq!(issues(DXF, json!({"camera": camera(), "zero": "corner"})), ["zero must be target or origin"]);
assert_eq!(issues(DXF, json!({"camera": camera(), "path": " "})), ["invalid path"]);
assert_eq!(issues(DXF, json!({"camera": 1})), ["invalid camera"]);
// Left out, the handler's own refusal stands in for "is required".

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