Compare commits

...

78 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
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
155 changed files with 6033 additions and 625 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 {
@@ -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,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) |
+5
View File
@@ -3399,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
@@ -3641,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))
@@ -3696,6 +3700,7 @@ impl Document {
timeline_groups: Vec::new(),
browser_order: Default::default(),
named_views: Vec::new(),
notes: Vec::new(),
visibility: Visibility::default(),
}
}
+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.
@@ -0,0 +1,9 @@
//! A joint can say which assembly constraint it was made as (`"constraint": "mate"`, `flush`,
//! `distance`, `concentric`, `insert`, `parallel`, `perpendicular`, `angle`, `tangent` or
//! `point_on_axis`), written only for a joint made with `assembly.constraint.create`. The joint
//! itself is an ordinary joint of the kind the rule needs (a mate is a planar joint), so the solver
//! never reads the field; it names the rule in the browser and the Constrain dialog, and makes the
//! joint start where its parts are when it is changed. Older designs need no transformation: their
//! joints have no constraint and are written as before. The last release opens a design with such
//! a joint with a warning and keeps the field it can't read; there the joint still solves as its
//! kind.
+4
View File
@@ -0,0 +1,4 @@
//! A design saves its notes (`notes`): text anchored to a point in model space, drawn in the
//! viewport as a small screen-facing callout. An older design has none, and opens exactly as
//! before. The last release refuses a design that has notes and asks for an update, instead of
//! opening it and losing them on the next save.
@@ -0,0 +1,5 @@
//! A sketch text's style can say where its block of lines sits up and down against its anchor
//! (`at`, or the curve it follows): `vertical_align` is `top`, `middle` or `bottom`, and is left
//! out for the first line's baseline, where every text sat before. Older designs need no rewrite:
//! without the key a text stays on its baseline. The last release opens a design that carries the
//! key with a warning, keeps what it can't read, and draws such a text on its baseline.
+86
View File
@@ -429,6 +429,75 @@ impl JointKind {
}
}
/// The assembly constraint a joint was made as (`assembly.constraint.create`): the rule in the
/// words of the Constrain dialog. The joint itself is an ordinary joint of the kind the rule needs
/// (a mate is planar, a concentric constraint cylindrical); the solver never reads this.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Constraint {
/// Two planes against each other (planar), `offset` apart.
Mate,
/// Two planes facing the same way (planar, flipped), `offset` apart.
Flush,
/// A plane `offset` from a plane (planar) or a point `offset` from a plane (point on plane).
Distance,
/// Two axes on one line (cylindrical): B slides and turns along it.
Concentric,
/// Two round edges at one centre on one axis (revolute): B only turns.
Insert,
/// Two planes or axes parallel.
Parallel,
/// Two planes or axes square to each other.
Perpendicular,
/// Two planes or axes at `angle`.
Angle,
/// A cylinder resting on a plane (line on plane, `offset` its radius).
Tangent,
/// A point on an axis (point on line).
PointOnAxis,
}
impl Constraint {
pub const ALL: [Constraint; 10] = [
Constraint::Mate,
Constraint::Flush,
Constraint::Distance,
Constraint::Concentric,
Constraint::Insert,
Constraint::Parallel,
Constraint::Perpendicular,
Constraint::Angle,
Constraint::Tangent,
Constraint::PointOnAxis,
];
/// The rule's command name (`mate`, `point_on_axis`).
pub fn name(self) -> &'static str {
match self {
Constraint::Mate => "mate",
Constraint::Flush => "flush",
Constraint::Distance => "distance",
Constraint::Concentric => "concentric",
Constraint::Insert => "insert",
Constraint::Parallel => "parallel",
Constraint::Perpendicular => "perpendicular",
Constraint::Angle => "angle",
Constraint::Tangent => "tangent",
Constraint::PointOnAxis => "point_on_axis",
}
}
/// Case, spaces, `_` and `-` are ignored; `coincident` and `axis` are also taken.
pub fn parse(s: &str) -> Option<Constraint> {
let key = s.to_ascii_lowercase().replace([' ', '_', '-'], "");
match key.as_str() {
"coincident" => Some(Constraint::Mate),
"axis" => Some(Constraint::Concentric),
_ => Constraint::ALL.into_iter().find(|c| c.name().replace('_', "") == key),
}
}
}
/// A joint between two occurrences.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Joint {
@@ -462,6 +531,17 @@ pub struct Joint {
/// files from before captured joint values: such a joint keeps its values on Revert Position.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub captured: Option<Vec<f64>>,
/// The assembly constraint it was made as; `None` for a joint made as a joint.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub constraint: Option<Constraint>,
}
impl Joint {
/// Whether its values start where its parts are when it is made or changed, so B moves no
/// further than the joint needs: a relation, or any joint made as an assembly constraint.
pub fn starts_where_it_is(&self) -> bool {
self.kind.is_relation() || self.constraint.is_some()
}
}
/// Expressions a joint's numbers follow (over the design's parameters): the offset (a length),
@@ -2527,6 +2607,11 @@ impl Document {
let t = if o.occurrence == 0 { IDENTITY } else { occurrence_world(self, o.occurrence) };
Ok(mat_mul(&t, &f))
}
/// The radius of the round face a circle origin is on (mm; an error when it is on none).
pub fn origin_radius(&self, st: &ModelState, o: &JointOrigin) -> Result<f64> {
circle_at(self, st, o).map(|c| c.radius)
}
}
#[path = "joints_cam.rs"]
@@ -2756,6 +2841,7 @@ mod tests {
suppressed: false,
exprs: Default::default(),
captured: None,
constraint: None,
})
.collect();
let links =
+1
View File
@@ -945,6 +945,7 @@ mod tests {
suppressed: false,
exprs: Default::default(),
captured: None,
constraint: None,
};
let occ = |id: u64, grounded: bool| crate::Occurrence { id, component: id, parent: 0, name: format!("O{id}"), transform: IDENTITY, grounded };
// 1 frame (grounded), 2 cam, 3-6 a rigid chain to the guide 7, 8 the piston, 9 crank, 10 rod.
+1
View File
@@ -94,6 +94,7 @@ mod tests {
suppressed: false,
exprs: Default::default(),
captured: None,
constraint: None,
}
}
+2
View File
@@ -49,6 +49,8 @@ mod lineage;
pub mod metadata;
pub use metadata::Metadata;
pub mod motion;
pub mod notes;
pub use notes::Note;
mod name_lookup;
mod names;
pub mod naming;
+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
@@ -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)
}
@@ -24,6 +24,7 @@ fn joint(id: u64, a: u64, b: u64) -> Joint {
suppressed: false,
exprs: Default::default(),
captured: None,
constraint: None,
}
}
+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)
}
+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<_>>()?;
+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"));
}
+6
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,7 @@ 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;
@@ -86,6 +88,7 @@ mod measure_shape;
mod mesh;
mod metadata;
pub(crate) mod motion;
mod notes;
mod parts;
mod point_cloud;
mod script;
@@ -348,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());
@@ -356,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());
@@ -364,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");
}
+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).
+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}");
}
@@ -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");
}
+79
View File
@@ -998,3 +998,82 @@ fn feature_named_like_a_datum_key_stays_reachable() {
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");
}
}
+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.
+15
View File
@@ -88,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 {
@@ -1730,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() => {
@@ -2529,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;
@@ -2565,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)
+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;
+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".
+37
View File
@@ -640,3 +640,40 @@ fn conversion_from_another_branch_names_the_component_to_activate() {
assert_eq!(s.doc.to_json(), before);
assert_eq!((s.active_component, s.active_occurrence_path.clone()), context);
}
/// A mesh body of a component placed twice converts under its second placement's name, the one
/// the selection and the Browser hold: the conversion edits the component, so both placements
/// then show the solid. Given as `body` and taken from the selection.
#[test]
fn a_second_placements_mesh_body_converts_by_the_name_the_world_shows() {
let dir = std::env::temp_dir().join(format!("solidcraft-mesh-convert-placed-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let stl = dir.join("cube.stl").to_string_lossy().to_string();
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
run(&mut s, "file.save_mesh", json!({"path": stl}));
for by_selection in [false, true] {
let mut s = Session::default();
run(&mut s, "file.insert_mesh", json!({"path": stl}));
run(&mut s, "occurrence.copy", json!({"component": "cube", "translate": [0, 40, 0]}));
let definition = s.model.state().bodies[0].name.clone();
let world = s.world_state().unwrap();
let second = world.bodies.iter().map(|b| b.name.clone()).find(|n| *n != definition).expect("a second placement");
assert!(world.bodies.len() == 2 && world.bodies.iter().all(|b| b.body.is_mesh()), "two placed mesh bodies");
if by_selection {
s.selection = vec![Sel::Body { name: second.clone() }];
run(&mut s, "mesh.convert", json!({"tolerance": "0.05 mm"}));
} else {
run(&mut s, "mesh.convert", json!({"body": second, "tolerance": "0.05 mm"}));
}
let world = s.world_state().unwrap();
assert_eq!(world.bodies.len(), 2);
assert!(world.bodies.iter().all(|b| !b.body.is_mesh()), "by selection {by_selection}: both placements show the solid");
match &s.doc.features.last().unwrap().kind {
FeatureKind::MeshConvert { body, .. } => assert_eq!(body.to_string(), definition, "the feature names the component's body"),
other => panic!("{other:?}"),
}
}
let _ = std::fs::remove_dir_all(&dir);
}
@@ -184,6 +184,7 @@ pipe false
plane false
plane_along_path false
plane_angle false
plane_auto false
plane_axis_point false
plane_midplane false
plane_perpendicular false
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Plane from picks</title>
<path d="M32 18L56 32L32 46L8 32Z" fill="#b9ddf8" stroke="#236ead" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round" stroke-dasharray="0.1 5"/>
</svg>

After

Width:  |  Height:  |  Size: 265 B

+1 -1
View File
@@ -81,7 +81,7 @@ pub use solver::{
flips_from_stored, measure_dimension, move_line_towards, move_point_towards, point_along, solve, solve_after_edit, solve_after_link_move,
solve_after_value_edit, solve_holding, solve_with,
};
pub use text::{MAX_TEXT_CHARS, TextAlign, TextMetrics, TextStyle, text_geometry, text_geometry_in, text_geometry_styled};
pub use text::{MAX_TEXT_CHARS, TextAlign, TextMetrics, TextStyle, TextVerticalAlign, text_geometry, text_geometry_in, text_geometry_styled};
pub use thin::{MAX_THIN_SEGMENTS, thin_wall_regions};
pub use wire::{MAX_FIT_POINTS, MAX_WIRE_POINTS, Wire, WireArc, WireCurve, WireSpace, arc_from_3_points, arc_polyline, arc_sweep, fit_polyline};
+54 -2
View File
@@ -105,6 +105,40 @@ impl TextAlign {
}
}
/// Where a text's block of lines sits up and down against its anchor (`at`, or its path).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TextVerticalAlign {
/// The first line's baseline runs through the anchor.
#[default]
Baseline,
/// The tops of the first line's capitals are at the anchor.
Top,
/// The block is centred on it: halfway between the first line's capital tops and the last
/// line's baseline.
Middle,
/// The bottom of the last line's descenders (the tails of g, p and y) is at the anchor.
Bottom,
}
impl TextVerticalAlign {
fn is_baseline(&self) -> bool {
*self == TextVerticalAlign::Baseline
}
/// How far up (mm) every baseline moves for a block of `lines` lines at capital height
/// `height`, `pitch` apart, whose descenders reach `descent` below a baseline.
fn shift(self, lines: usize, height: f64, pitch: f64, descent: f64) -> f64 {
let below = lines.saturating_sub(1) as f64 * pitch;
match self {
TextVerticalAlign::Baseline => 0.0,
TextVerticalAlign::Top => -height,
TextVerticalAlign::Middle => (below - height) / 2.0,
TextVerticalAlign::Bottom => below + descent,
}
}
}
/// How a text is set: the style of its font, where its lines sit, the space between its letters
/// and lines, and the sketch curve its baseline follows (instead of the straight line from `at`).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -115,6 +149,8 @@ pub struct TextStyle {
pub italic: bool,
#[serde(default, skip_serializing_if = "TextAlign::is_left")]
pub align: TextAlign,
#[serde(default, skip_serializing_if = "TextVerticalAlign::is_baseline")]
pub vertical_align: TextVerticalAlign,
/// Extra space between letters (mm; negative brings them closer).
#[serde(default, skip_serializing_if = "is_zero")]
pub letter_spacing: f64,
@@ -132,7 +168,16 @@ pub struct TextStyle {
impl Default for TextStyle {
fn default() -> Self {
TextStyle { bold: false, italic: false, align: TextAlign::Left, letter_spacing: 0.0, line_spacing: 1.0, path: None, reverse: false }
TextStyle {
bold: false,
italic: false,
align: TextAlign::Left,
vertical_align: TextVerticalAlign::Baseline,
letter_spacing: 0.0,
line_spacing: 1.0,
path: None,
reverse: false,
}
}
}
@@ -249,6 +294,12 @@ impl<'a> TextMetrics<'a> {
Ok(own * self.line_spacing)
}
/// How far (mm) the font's descenders reach below a baseline at a capital height of `height` mm (none when the
/// font gives no descender below it).
pub fn descent(&self, height: f64) -> Result<f64> {
Ok((-f64::from(self.face.descender())).max(0.0) * self.scale(height)?)
}
/// The width (mm) of one line at a capital height of `height` mm, as it is drawn: from its first glyph's origin to
/// the end of its last glyph's advance. A line break in it is a character like any other.
pub fn line_width(&self, line: &str, height: f64) -> Result<f64> {
@@ -380,9 +431,10 @@ pub fn text_geometry_styled(
let along = path.map(|pts| Path::new(pts, style.reverse)).transpose()?;
let anchor = along.as_ref().map(|p| p.anchor(style.align)).unwrap_or(0.0);
let (sin, cos) = angle.sin_cos();
let lift = style.vertical_align.shift(text.split('\n').count(), height, pitch, metrics.descent(height)?);
let mut out = Vec::new();
for (row, line) in text.split('\n').enumerate() {
let base_y = -(row as f64) * pitch;
let base_y = lift - (row as f64) * pitch;
let start = match style.align {
TextAlign::Left => 0.0,
TextAlign::Center => -metrics.line_width(line, height)? / 2.0,
+32 -1
View File
@@ -1,4 +1,4 @@
//! Text styles: alignment, letter and line spacing, a made-up italic, a bold the font lacks, and
//! Text styles: alignment along and up or down, letter and line spacing, a made-up italic, a bold the font lacks, and
//! text that follows a curve of its sketch; and text measured (`TextMetrics`) by the rule that draws it.
use solidcore_record::Vec2;
@@ -58,6 +58,35 @@ fn lines_align_left_center_or_right_on_the_anchor() {
assert!((left.0.y - center.0.y).abs() < 1e-12, "alignment moves lines along, not up");
}
#[test]
fn the_block_of_lines_sits_on_its_baseline_top_middle_or_bottom() {
let at = |text: &str, vertical_align: TextVerticalAlign| extent(&styled(text, &TextStyle { vertical_align, ..TextStyle::default() }, None));
let near = |a: f64, b: f64| (a - b).abs() < 0.05;
// An H stands on the baseline and reaches the capital height, 10 mm.
let plain = at("H", TextVerticalAlign::Baseline);
assert!(near(plain.0.y, 0.0) && near(plain.1.y, 10.0), "{plain:?}");
let top = at("H", TextVerticalAlign::Top);
assert!(near(top.1.y, 0.0) && near(top.0.y, -10.0), "the capitals hang from the anchor: {top:?}");
// Two lines: the block runs from the first line's capital tops to the last line's baseline.
let two = at("H\nH", TextVerticalAlign::Baseline);
let middle = at("H\nH", TextVerticalAlign::Middle);
assert!(near(middle.0.y + middle.1.y, 0.0), "centred on the anchor: {middle:?}");
assert!(near(middle.1.y - middle.0.y, two.1.y - two.0.y), "the block keeps its size: {middle:?}");
// Bottom leaves room for the tails of g, p and y: the last line's g reaches down to the anchor
// (the font's descender is a little deeper than its g), and its H stands well above it.
let tail = at("H\nHg", TextVerticalAlign::Bottom);
assert!(tail.0.y > -1e-9 && tail.0.y < 0.3, "the tail ends at the anchor: {tail:?}");
let bottom = at("H\nH", TextVerticalAlign::Bottom);
assert!(bottom.0.y > 1.0 && bottom.0.y > tail.0.y, "the last line stands above the anchor: {bottom:?}");
assert!(near(bottom.1.y - bottom.0.y, two.1.y - two.0.y), "the block keeps its size: {bottom:?}");
assert!((plain.0.x - top.0.x).abs() < 1e-12, "vertical alignment moves lines up or down, not along");
// On a path the lines move off the curve the same way: centred, an H straddles it.
let line = [v(-20.0, 0.0), v(20.0, 0.0)];
let on =
extent(&styled("H", &TextStyle { path: Some("l".into()), vertical_align: TextVerticalAlign::Middle, ..TextStyle::default() }, Some(&line)));
assert!(near(on.0.y, -5.0) && near(on.1.y, 5.0), "{on:?}");
}
#[test]
fn letter_spacing_adds_between_letters_and_line_spacing_scales_the_lines() {
let plain = extent(&styled("HHH", &TextStyle::default(), None));
@@ -189,6 +218,7 @@ fn a_style_is_saved_only_when_it_is_not_plain_and_reads_back() {
bold: true,
italic: true,
align: TextAlign::Right,
vertical_align: TextVerticalAlign::Bottom,
letter_spacing: -0.5,
line_spacing: 1.5,
path: Some("l3".into()),
@@ -198,6 +228,7 @@ fn a_style_is_saved_only_when_it_is_not_plain_and_reads_back() {
};
let json = serde_json::to_value(&styled).unwrap();
assert_eq!(json["style"]["align"], "right");
assert_eq!(json["style"]["vertical_align"], "bottom");
assert_eq!(serde_json::from_value::<LinkSource>(json).unwrap(), styled);
// Saved without a style (before styles): plain.
let mut old = serde_json::to_value(&plain).unwrap();
+18
View File
@@ -81,6 +81,7 @@ pub(super) fn status(e: SolidError) -> Error {
e if is_primitive_failure(&e) => Error::Status(e.sm_status().unwrap_or(solidcore::error::SM_ERR)),
e if is_mass_heal_or_geom_failure(&e) => Error::Status(e.sm_status().unwrap_or(solidcore::error::SM_ERR)),
e if is_sweep_failure(&e) => Error::Status(e.sm_status().unwrap_or(solidcore::error::SM_ERR)),
e if is_query_failure(&e) => Error::Status(e.sm_status().unwrap_or(solidcore::error::SM_ERR)),
SolidError::InvalidInput { .. } => Error::Status(INVALID_INPUT),
SolidError::Unsupported { reason, .. } if reason == MIXED_CORNER_RAILS_CROSS => Error::Unported(MIXED_CORNER_UNSUPPORTED),
e if is_boolean_failure(&e) => boolean_status(e),
@@ -142,6 +143,12 @@ fn is_sweep_failure(e: &SolidError) -> bool {
e.sweep_failure().is_some_and(|w| w.replaces_upstream_status())
}
/// A query's failure (solidcore's `QueryFailure`): it stands for the status the query returned
/// (`Upstream` before solidcore described it), and passes on as that status.
fn is_query_failure(e: &SolidError) -> bool {
e.query_failure().is_some()
}
/// `size_ok`.
fn size_ok(x: f64) -> bool {
x.is_finite() && x > 0.0 && x <= 1e6
@@ -1499,4 +1506,15 @@ mod tests {
// The same entity twice is picked once, by its first point.
assert_eq!(pick(&all, &[[3.1, 0.0, 0.0], [2.9, 0.0, 0.0]], 0.5, 1e-6, d).unwrap(), Ok((vec![3.0], vec![0])));
}
/// A query's failure passes on as the status it replaced, as the `Upstream` it replaced did.
#[test]
fn query_failures_pass_on_as_their_status() {
use solidcore::error::Failure;
use solidcore::error::query::QueryFailure;
let query = |q| Failure::new("query::edge", q).into_error();
assert!(matches!(status(query(QueryFailure::Failed("the region has no shells"))), Error::Status(1001)));
assert!(matches!(status(query(QueryFailure::Missing("the face's surface"))), Error::Status(1006)));
assert!(matches!(status(query(QueryFailure::InvalidValue("a vector of zero length has no direction"))), Error::Status(1007)));
}
}
+8 -1
View File
@@ -1538,7 +1538,7 @@ fn browser_header(app: &mut SolveApp, ui: &mut egui::Ui, collapsible: bool) {
crate::ui_kit::disclosure(ui.painter(), pos2(right - t.form_section_icon_size / 2.0, rect.center().y), !collapsed, t.text_dim);
right -= 18.0;
}
let bodies = app.session.model.state().bodies.len();
let bodies = listed_bodies(&app.session);
let key = if bodies == 1 { "ui.browser_bodies_one" } else { "ui.browser_bodies_other" };
let count = solidcraft_ui_text::format(key, &[("count", &bodies.to_string())]);
let galley = ui.painter().layout_no_wrap(count, FontId::proportional(t.font_body), t.text_dim);
@@ -1557,6 +1557,13 @@ fn browser_header(app: &mut SolveApp, ui: &mut egui::Ui, collapsible: bool) {
}
}
/// How many bodies the Browser lists for its header: one row per placement of each body, so a
/// component's body placed twice counts twice, as the world shows it. The model's own count when
/// the design cannot be placed (the rows then still list the model's bodies).
fn listed_bodies(session: &solidcraft_engine::Session) -> usize {
session.world_state().map_or_else(|_| session.model.state().bodies.len(), |world| world.bodies.len())
}
fn prepare_model(app: &mut SolveApp) {
if app.tree.model.as_ref().is_none_or(|model| !model.matches(&app.session)) {
app.tree.model = Some(model::Model::new(&app.session));
+18
View File
@@ -2219,3 +2219,21 @@ fn parked_reveals_are_pruned_against_their_own_document_when_the_target_is_remov
semantic_frame(&mut h, &[], Vec::new(), egui::Modifiers::NONE);
assert_no_pending_reveals(&h);
}
/// A component's body placed twice is two body rows in the Browser; the header's count agrees
/// with the rows it lists instead of counting the model's bodies ("1 body" over two rows).
#[test]
fn the_browser_header_counts_one_body_per_placement() {
let (mut h, _, _) = crate::test_support::component_box();
h.app.preferences.browser_components_open = true;
h.settle();
assert_eq!(listed_bodies(&h.app.session), 1);
h.app.run("occurrence.copy", json!({"component": "Block", "translate": [0, 40, 0]})).unwrap();
h.settle();
assert_eq!(crate::open_meshing::session_world(&h.app.session).bodies.len(), 2);
// One body row under each placement's row, both labelled with the body's own name.
let rows = drawn_rows();
let body_rows = rows.iter().filter(|(label, ..)| label == "Body1").count();
assert_eq!(body_rows, 2, "a body row per placement: {rows:?}");
assert_eq!(listed_bodies(&h.app.session), body_rows, "the header counts the rows it lists");
}
+3
View File
@@ -26,11 +26,13 @@ pub const DOCUMENTED: &[&str] = &[
"config.preview",
"config.rules",
"config.table",
"construct.axis",
"construct.axis.edge",
"construct.axis.normal_to_face",
"construct.axis.through_cylinder",
"construct.axis.two_planes",
"construct.axis.two_points",
"construct.plane",
"construct.plane.along_path",
"construct.plane.at_angle",
"construct.plane.axis_point",
@@ -40,6 +42,7 @@ pub const DOCUMENTED: &[&str] = &[
"construct.plane.tangent",
"construct.plane.three_points",
"construct.plane.two_edges",
"construct.point",
"construct.point.along_path",
"construct.point.center",
"construct.point.edge_plane",
+33
View File
@@ -25,6 +25,8 @@ use crate::tool_modifiers::{Audit, Scope, ToolModifiers};
use crate::viewport::Hit;
use solidcraft_theme::Tokens;
#[path = "dialogs_auto.rs"]
mod auto;
#[path = "dialogs_dense.rs"]
mod dense;
#[cfg(test)]
@@ -264,6 +266,9 @@ crate::dialog_descriptor::dialog_descriptors! {
keys: Vec<&'static str>,
t: Option<String>,
},
/// Plane, Axis or Point (`construct.plane|axis|point`) while no method takes its picks;
/// once one does, the dialog is that method's own (`dialogs_auto`).
ConstructAuto { command: &'static str },
/// A plane perpendicular to one selected path at a fractional distance.
PathPlane {
t: String,
@@ -569,6 +574,9 @@ pub struct Dialog {
pub press_pull: bool,
/// The operation the window chooses by itself (#6901).
pub op_auto: crate::op_auto::OpAuto,
/// Plane, Axis or Point standing in for the method its picks call for: this dialog is that
/// method's, and picks go to the tool, which chooses the method again (dialogs_auto).
pub(crate) auto: Option<auto::Auto>,
/// Where the face that reopened this fillet or chamfer was clicked (Press Pull): its arrow
/// sits on the edge nearest it. UI state only, never saved.
pub(crate) anchor_hint: Option<Vec3>,
@@ -812,6 +820,8 @@ impl Dialog {
self.error = Some(error);
}
/// A dialog of `kind` with these inputs and every other field at its starting value: no picks
/// taken yet beyond the inputs' own, no error, and not standing in for Plane, Axis or Point.
pub(crate) fn new(kind: Kind, inputs: Vec<SelInput>) -> Dialog {
let chain_applied = matches!(kind, Kind::Fillet { chain: true, .. });
Dialog {
@@ -845,6 +855,7 @@ impl Dialog {
op_auto: Default::default(),
anchor_hint: None,
started_from_sketch: None,
auto: None,
}
}
@@ -889,6 +900,9 @@ impl Dialog {
}
}
/// The dialog a toolbar click on command `id` opens, filled from the current selection where
/// its inputs take it; Plane, Axis and Point open as the method their picks call for
/// (`dialogs_auto`). `None` for a command that has no dialog.
pub fn for_command(app: &SolveApp, id: &str) -> Option<Dialog> {
let s = &app.session;
let has_bodies = !crate::open_meshing::session_world(s).bodies.is_empty();
@@ -978,6 +992,8 @@ impl Dialog {
Kind::MeshConvert { tolerance: crate::length_input::model(&s.doc, 0.05) },
vec![SelInput::new("Mesh body", BODIES, false)],
),
// Plane, Axis and Point open as the method their picks call for (`dialogs_auto`).
_ if solidcraft_engine::cmd::is_construct_tool(id) => return auto::start(app, id),
"construct.plane.midplane" => Dialog::new(
Kind::Midplane,
vec![SelInput::new("First plane", PLANES | PLANAR_FACES, false), SelInput::new("Second plane", PLANES | PLANAR_FACES, false)],
@@ -1627,6 +1643,10 @@ impl Dialog {
if matches!(self.kind, Kind::SketchModify(_)) {
return crate::sketch_modify_ui::pick_candidate(self, s, h);
}
// Plane, Axis and Point take whatever a method of theirs builds from.
if self.auto.is_some() {
return auto::candidate(s, h);
}
if let Some(target) = self.extrude_target(s, h) {
return Some(target);
}
@@ -1809,6 +1829,13 @@ impl Dialog {
if self.active_input().is_some_and(|i| i.rejects(&sel)) {
return;
}
// Plane, Axis and Point: the pick is the tool's, which chooses the method again.
if let Some(a) = &mut self.auto {
a.toggle(sel);
self.error = None;
self.focus = true;
return;
}
if self.take_extrude_target(s, &sel) {
return;
}
@@ -2622,6 +2649,7 @@ fn publish_check(ui: &egui::Ui, response: &egui::Response, selected: bool) {
response.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, ui.is_enabled(), selected, tr(crate::ui_kit::row_label(ui))));
}
/// The dialog's heading for `k`: a message id or label the header translates.
fn title(k: &Kind) -> &'static str {
match k {
Kind::Sketch => "CREATE SKETCH",
@@ -2637,6 +2665,9 @@ fn title(k: &Kind) -> &'static str {
Kind::Midplane => "MIDPLANE",
Kind::MeshConvert { .. } => "CONVERT MESH",
Kind::Construct { title, .. } => title,
Kind::ConstructAuto { command: "construct.plane", .. } => "cmd.construct.plane",
Kind::ConstructAuto { command: "construct.axis", .. } => "cmd.construct.axis",
Kind::ConstructAuto { .. } => "cmd.construct.point",
Kind::PathPlane { .. } => "PLANE ALONG PATH",
Kind::Rib { web: false, .. } => "RIB",
Kind::Rib { web: true, .. } => "WEB",
@@ -3878,6 +3909,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
return;
}
crate::sketch_text::sync_path(&mut d);
auto::sync(app, &mut d);
let _language = solidcraft_ui_text::scope(&app.preferences.language);
if let Kind::TextMove(k) = &d.kind
&& !crate::sketch_text_move::valid(app, k)
@@ -5799,6 +5831,7 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
}
(*command, Value::Object(p))
}
Kind::ConstructAuto { .. } => return Err(auto::refusal(d)),
Kind::PathPlane { t, sketch } => {
need(0, "a path")?;
let path = match sels(d, 0).first() {
+166
View File
@@ -0,0 +1,166 @@
//! Plane, Axis and Point (`construct.plane`, `construct.axis`, `construct.point`) have no
//! dialog of their own. With references picked, the window is the dialog of the first method of
//! the tool's family (`construct_methods`, in the engine's order) that takes every pick in its
//! inputs and builds from them, with that method's rows, preview and canvas handles. A pick goes
//! to the tool, which chooses the method again. A method added to a family is chosen as well,
//! with its own dialog, and nothing here changes.
use super::{
AXES, AXIS_PICKS, Accept, CURVES, Dialog, EDGES, FACES, Kind, PLANES, SelInput, apply_commands, fits, planar_face, selection, takes_as_axis,
};
use crate::SolveApp;
use crate::viewport::Hit;
use solidcraft_engine::{Sel, Session};
/// Whatever a method builds from.
const PICKS: Accept = PLANES | FACES | EDGES | AXES | CURVES | selection::VERTICES | selection::POINTS | selection::CONSTRUCTION_POINTS;
/// The most references a method takes.
const MAX: usize = 3;
const REFERENCES: &str = "ui.construct_auto.refs";
/// The tool a dialog stands in for, and what was picked for it.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Auto {
tool: &'static str,
/// Every reference picked, in pick order.
picks: Vec<Sel>,
/// What the dialog's inputs held when it was made: a change there (a pick taken out in its
/// row, a box selection) is a change of picks.
placed: Vec<Sel>,
/// Picked since the method was chosen.
dirty: bool,
}
impl Auto {
/// A click: a new reference is added (the oldest goes past [`MAX`]); one picked already is
/// taken out.
pub(super) fn toggle(&mut self, sel: Sel) {
if let Some(i) = self.picks.iter().position(|p| selection::same_item(p, &sel)) {
self.picks.remove(i);
} else {
self.picks.push(sel);
if self.picks.len() > MAX {
self.picks.remove(0);
}
}
self.dirty = true;
}
}
/// The line over the form: that the method was chosen from the picks, or what to pick.
pub(super) fn hint(a: &Auto) -> String {
solidcraft_ui_text::tr(if a.picks.is_empty() { "ui.construct_auto.pick" } else { "ui.construct_auto.chosen" }).to_owned()
}
/// Why OK does nothing while no method takes the picks.
pub(super) fn refusal(d: &Dialog) -> String {
let none = d.auto.as_ref().is_none_or(|a| a.picks.is_empty());
solidcraft_ui_text::tr(if none { "ui.construct_auto.select" } else { "ui.construct_auto.no_fit" }).to_owned()
}
/// What a hit picks for the tool.
pub(super) fn candidate(s: &Session, h: &Hit) -> Option<Sel> {
SelInput::new(REFERENCES, PICKS, true).accepts(h, s.active_sketch, |body, face| planar_face(s, body, face).is_some())
}
/// The tool's window, with the selection as its first picks.
pub(super) fn start(app: &SolveApp, tool: &str) -> Option<Dialog> {
let tool = solidcraft_engine::find_command(tool)?.id;
let picks: Vec<Sel> = app.session.selection.iter().filter(|sel| fits(PICKS, sel)).take(MAX).cloned().collect();
Some(choose(app, tool, picks, None))
}
/// Choose again once the picks changed: a click, or the inputs edited in the form.
pub(super) fn sync(app: &SolveApp, d: &mut Dialog) {
let Some(a) = &d.auto else { return };
let items = d.items();
if !a.dirty && items == a.placed {
return;
}
let mut picks = a.picks.clone();
if !a.dirty {
picks.retain(|p| items.iter().any(|x| selection::same_item(x, p)));
for x in items {
if !picks.iter().any(|p| selection::same_item(p, &x)) {
picks.push(x);
}
}
}
let tool = a.tool;
let was = std::mem::replace(d, Dialog::new(Kind::ConstructAuto { command: tool }, Vec::new()));
*d = choose(app, tool, picks, Some(&was));
}
/// Whether `inp` can hold `sel` too, as a pre-selection fills it.
fn takes(s: &Session, inp: &SelInput, sel: &Sel) -> bool {
let planar_ok = match sel {
Sel::Face { body, index, .. } => inp.accept & FACES != 0 || planar_face(s, body, *index).is_some(),
_ => true,
};
(inp.multi || inp.items.is_empty()) && fits(inp.accept, sel) && planar_ok && (inp.accept != AXIS_PICKS || takes_as_axis(s, sel))
}
/// Put every one of `picks` in an input of `d` that takes it, the first way `good` accepts.
fn place(s: &Session, d: &mut Dialog, picks: &[Sel], good: &dyn Fn(&Dialog) -> bool) -> bool {
let Some((sel, rest)) = picks.split_first() else { return good(d) };
for k in 0..d.inputs.len() {
if !d.inputs.get(k).is_some_and(|inp| takes(s, inp, sel)) {
continue;
}
if let Some(inp) = d.inputs.get_mut(k) {
inp.items.push(sel.clone());
}
if place(s, d, rest, good) {
return true;
}
if let Some(inp) = d.inputs.get_mut(k) {
inp.items.pop();
}
}
false
}
/// The first method's dialog, in the family's order, whose inputs take `picks` and that builds
/// from them; else the first whose inputs take them (its error says why it doesn't build); else
/// the tool's own window, waiting for picks. A method chosen again keeps its values.
fn choose(app: &SolveApp, tool: &'static str, picks: Vec<Sel>, was: Option<&Dialog>) -> Dialog {
let s = &app.session;
let mut chosen = None;
if !picks.is_empty() {
let complete = |d: &Dialog| apply_commands(app, d).is_ok();
let builds = |d: &Dialog| apply_commands(app, d).is_ok_and(|cmds| s.preview(&cmds).is_ok());
let methods = solidcraft_engine::cmd::construct_methods(tool);
'tries: for good in [&builds as &dyn Fn(&Dialog) -> bool, &complete] {
for spec in &methods {
let Some(mut d) = Dialog::for_command(app, spec.id) else { continue };
if let Some(w) = was
&& w.auto.is_some()
&& w.command() == Some(spec.id)
{
d.kind = w.kind.clone();
d.extra = w.extra.clone();
}
for inp in &mut d.inputs {
inp.items.clear();
}
if place(s, &mut d, &picks, good) {
chosen = Some(d);
break 'tries;
}
}
}
}
let mut d = chosen.unwrap_or_else(|| {
let mut refs = SelInput::new(REFERENCES, PICKS, true);
refs.items = picks.clone();
Dialog::new(Kind::ConstructAuto { command: tool }, vec![refs])
});
if let Some(w) = was {
d.collapsed = w.collapsed;
d.preview_enabled = w.preview_enabled;
}
d.advance();
d.focus = true;
d.auto = Some(Auto { tool, placed: d.items(), picks, dirty: false });
d
}
+7
View File
@@ -5,6 +5,7 @@ use crate::ui_kit;
use crate::ui_kit::{choice_row, expr_row, hint_row};
use solidcraft_engine::kernel::Continuity;
/// The command whose icon and label head a dialog of `kind`, when it has one.
pub(super) fn command(kind: &Kind) -> Option<&'static str> {
match kind {
Kind::Material { .. } => Some("material.assign"),
@@ -44,6 +45,7 @@ pub(super) fn command(kind: &Kind) -> Option<&'static str> {
Kind::Midplane => Some("construct.plane.midplane"),
Kind::MeshConvert { .. } => Some("mesh.convert"),
Kind::Construct { command, .. } => Some(command),
Kind::ConstructAuto { command, .. } => Some(command),
Kind::PathPlane { .. } => Some("construct.plane.along_path"),
Kind::Split => Some("solid.split_body"),
Kind::DeleteFaces => Some("face.delete"),
@@ -365,6 +367,7 @@ fn choose_fillet_type(extra: &mut serde_json::Map<String, Value>, index: usize,
}
}
/// The dialog's form: its hints, inputs and value rows. True when Enter was pressed in a field.
pub(super) fn rows(app: &mut SolveApp, ctx: &egui::Context, d: &mut Dialog, ui: &mut egui::Ui) -> bool {
let t = Tokens::get();
let mut enter = false;
@@ -404,6 +407,10 @@ pub(super) fn rows(app: &mut SolveApp, ctx: &egui::Context, d: &mut Dialog, ui:
if let Some(key) = manage_hint {
ui_kit::text(ui, tr(key), ui_kit::Text::Hint("manage"));
}
// Plane, Axis or Point: which method its picks chose, and that more picks choose again.
if let Some(hint) = d.auto.as_ref().map(super::auto::hint) {
ui_kit::text(ui, &hint, ui_kit::Text::Hint("auto"));
}
placement_row(d, ui, app);
if crate::hole_refs::takes(d) {
// Each reference edge with its distance (#37, #6380).
+1 -1
View File
@@ -112,7 +112,7 @@ mod tests {
}
let construction: Vec<_> = specs.iter().filter(|c| c.panel == "CONSTRUCT").collect();
let distinct: std::collections::HashSet<_> = construction.iter().map(|c| c.icon).collect();
assert_eq!(construction.len(), 20);
assert_eq!(construction.len(), 23);
assert_eq!(construction.iter().find(|c| c.id == "construct.plane.axis_point").unwrap().icon, "plane_axis_point");
assert_eq!(distinct.len(), construction.len(), "construction methods need distinct visual identities");
}
+3
View File
@@ -3487,6 +3487,9 @@ impl SolveApp {
let _blur_scope = self.blur.begin(ui.ctx(), blur_source);
value_step::set_enabled(ui.ctx(), self.preferences.step_values);
canvas::set_boxes_shown(ui.ctx(), self.preferences.value_boxes_in_view);
// #9841: dialog buttons answer their first letter only when the preference says so;
// off is the old behaviour, so the kit default and this preference default agree.
ui_kit::set_dialog_mnemonics(self.preferences.dialog_first_letter_buttons);
recent_colors::share(ui.ctx(), &mut self.ui.recent_colors);
let t0 = now_ms();
// The first frames draw with the bundled faces while the system's CJK, Hangul and Thai
+24
View File
@@ -1433,6 +1433,30 @@ fn toolbar_pins_removes_reorders_and_keeps_its_layout() {
assert_eq!(ids(&app, "CREATE"), create);
}
/// The Construct panel's default buttons are Plane, Axis and Point (#11340). The old layout, Offset
/// Plane alone, stays one pin and three removals away, and is kept with the `toolbar` preference.
#[test]
fn the_old_construct_layout_is_kept_with_the_toolbar_preference() {
use crate::toolbar_custom as tc;
let mut app = sample_app();
app.ui.tab = "MODEL".into();
let ids = |app: &SolveApp| -> Vec<&'static str> {
let lists = tc::panel_lists("MODEL");
let (_, cmds, n) = lists.iter().find(|(p, ..)| *p == "CONSTRUCT").unwrap();
tc::buttons(app, "MODEL", "CONSTRUCT", cmds, *n).iter().map(|c| c.id).collect()
};
assert_eq!(ids(&app), vec!["construct.plane", "construct.axis", "construct.point"]);
act(&mut app, &Target::ToolbarCommand { id: "construct.plane.offset".into() }, "Pin to Toolbar");
for id in ["construct.plane", "construct.axis", "construct.point"] {
act(&mut app, &Target::ToolbarCommand { id: id.into() }, "Remove from Toolbar");
}
assert_eq!(ids(&app), vec!["construct.plane.offset"]);
let mut reopened = sample_app();
reopened.ui.tab = "MODEL".into();
reopened.load_prefs(&app.prefs());
assert_eq!(ids(&reopened), vec!["construct.plane.offset"], "the old layout comes back with the preferences");
}
#[test]
fn the_workspace_menu_lists_design_and_render_and_greys_future_workspaces() {
let app = sample_app();
+3 -4
View File
@@ -120,16 +120,15 @@ fn box_size(app: &SolveApp, d: &Dialog) -> Vec3 {
}
/// A stored or typed point of expressions (numbers, or strings in the design's terms); an empty
/// field is 0, as OK sends it.
/// field is 0, as OK sends it. Text is read as the model reads it (`length_input::eval`): a bare
/// number or a unitless expression such as `2 * 1` is in the design's unit.
fn expr_point(app: &SolveApp, v: &Value) -> Option<Vec3> {
let c = |i: usize| -> Option<f64> {
let x = v.as_array()?.get(i)?;
if x.as_str().is_some_and(|t| t.trim().is_empty()) {
return Some(0.0);
}
x.as_f64()
.or_else(|| app.session.doc.eval(&app.session.doc.with_design_unit(x.as_str()?, ValueKind::Length), ValueKind::Length).ok())
.filter(|v| v.is_finite())
x.as_f64().or_else(|| crate::length_input::eval(&app.session.doc, x.as_str()?, ValueKind::Length).ok()).filter(|v| v.is_finite())
};
Some(Vec3::new(c(0)?, c(1)?, c(2)?))
}
@@ -219,6 +219,31 @@ fn an_empty_placement_field_counts_as_zero_for_the_marker() {
assert!(base.dist(Vec3::new(300.0 + size.x / 2.0, size.y / 2.0, 400.0)) < 1e-6, "corner with y empty: {base:?}");
}
/// A typed corner is read as the size fields read it, by the marker and by OK alike: in an inch
/// design a decimal comma, a unitless expression, a value with its unit and a bare number are all
/// inches, and the marker stands over the corner of the box OK makes.
#[test]
fn a_typed_corner_is_in_the_design_unit_for_the_marker_and_for_ok() {
for (text, inches) in [("1,5", 1.5), ("2 * 1", 2.0), ("1,5 in", 1.5), ("3", 3.0)] {
let mut h = Harness::new();
h.app.preferences.placement_handles = true;
h.app.run("document.units", json!({"units": "in"})).unwrap();
h.settle();
start(&mut h, "solid.box");
let size = h.app.dialog.as_ref().map(|d| super::box_size(&h.app, d)).unwrap();
h.app.dialog.as_mut().unwrap().extra.insert("corner".into(), json!([text, text, text]));
let (base, _) = place(&h);
let marked = base - Vec3::new(size.x / 2.0, size.y / 2.0, 0.0);
ok(&mut h);
let r = h.app.session.execute("inspect.measure", &json!({})).unwrap();
let min = &r["bodies"][0]["bbox"]["min"];
let made = Vec3::new(min[0].as_f64().unwrap(), min[1].as_f64().unwrap(), min[2].as_f64().unwrap());
let want = Vec3::new(inches, inches, inches) * 25.4;
assert!(marked.dist(want) < 1e-6, "{text}: the marker's corner {marked:?}, want {want:?}");
assert!(made.dist(want) < 1e-6, "{text}: OK's corner {made:?}, want {want:?}");
}
}
/// A size typed as a bare expression ("2 * 1") is in the design's unit, as OK reads it: in an
/// inch design the corner a drop writes is 1 in back from the point, not 1 mm.
#[test]
+4
View File
@@ -964,6 +964,10 @@ preference_settings! {
/// A right-click in the view with a dialog open confirms its input: on to the next one, then OK.
/// Off: it does nothing there, as before (#6159).
right_click_confirms: bool = false, PAGE_GENERAL => check("Right-click confirms dialog inputs", "With a dialog open, a right-click in the view goes on to its next empty input, and on the last one runs OK");
/// With a dialog open, typing the first letter of a button's label presses it: O for OK,
/// C for Cancel (#9841). Off by default: typing does nothing to the buttons, as before.
/// A letter that fits two buttons does nothing, and a focused text field keeps the keystroke.
dialog_first_letter_buttons: bool = false, PAGE_GENERAL => check_id("prefs.dialog_first_letter_buttons");
/// View orientation widget: original signed axes (default) or compact cube.
navigator_style: NavigatorStyle = NavigatorStyle::default(), PAGE_NAVIGATION;
/// The custom view gizmo's colours: the cube's (themed) or the body's own (modeled).
+2
View File
@@ -147,6 +147,8 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
ui_kit::settings_section(ui, "dialogs", app.tr("prefs.dialogs"), |ui| {
let right_click = setting::right_click_confirms(app, ui);
crate::scenario::publish_target("prefs:right_click_confirms", right_click.rect);
let first_letter = setting::dialog_first_letter_buttons(app, ui).backing_on_hover_text(app.tr("prefs.dialog_first_letter_buttons_help"));
crate::scenario::publish_target("prefs:dialog_first_letter_buttons", first_letter.rect);
});
ui_kit::settings_section(ui, "updates", app.tr("prefs.updates"), |ui| {
crate::update::prefs_rows(app, ui);
+40
View File
@@ -3171,6 +3171,46 @@ fn hole_does_not_freeze_or_preselect_a_drawn_3d_wire_point() {
assert!(d.candidate(&app.session, &node).is_none());
}
/// The ribbon's Plane opens as the method its picks call for: a plane alone opens Offset Plane,
/// with its Distance, and a second plane turns the window into Midplane. OK adds that method's
/// own feature.
#[test]
fn plane_tool_opens_as_the_method_its_picks_call_for() {
let mut h = crate::scenario::Harness::new();
h.app.home.open = false;
h.app.run("construct.plane.offset", json!({"base":"XY","offset":"20 mm","name":"Shelf"})).unwrap();
h.app.session.selection = vec![Sel::Plane { name: "XY".into() }];
h.app.start("construct.plane");
h.frames(2);
let d = h.app.dialog.as_ref().unwrap();
assert!(matches!(d.kind, Kind::OffsetPlane { .. }) && d.auto.is_some(), "{d:?}");
assert_eq!(d.items(), vec![Sel::Plane { name: "XY".into() }], "the selection is the first pick");
assert!(h.app.dialog.as_mut().unwrap().primary().is_some_and(|(label, ..)| label == "Distance"), "Offset Plane's own value and handle");
h.app.dialog.as_mut().unwrap().pick(&h.app.session, Sel::Plane { name: "Shelf".into() });
h.frames(2);
let d = h.app.dialog.as_ref().unwrap();
assert!(matches!(d.kind, Kind::Midplane), "a second plane chooses Midplane: {d:?}");
assert_eq!(d.items(), vec![Sel::Plane { name: "XY".into() }, Sel::Plane { name: "Shelf".into() }]);
// Clicking Shelf again takes it out: one plane chooses Offset Plane again.
h.app.dialog.as_mut().unwrap().pick(&h.app.session, Sel::Plane { name: "Shelf".into() });
h.frames(2);
let d = h.app.dialog.as_ref().unwrap();
assert!(matches!(d.kind, Kind::OffsetPlane { .. }), "taking the second plane out chooses Offset Plane again: {d:?}");
assert_eq!(d.items(), vec![Sel::Plane { name: "XY".into() }]);
h.app.dialog.as_mut().unwrap().pick(&h.app.session, Sel::Plane { name: "Shelf".into() });
h.frames(2);
let d = h.app.dialog.as_ref().unwrap();
assert!(matches!(d.kind, Kind::Midplane), "{d:?}");
let commands = apply_commands(&h.app, d).unwrap();
assert_eq!(commands[0].0, "construct.plane.midplane", "OK runs the method's own command");
h.step(&json!({"key":"Enter"})).unwrap();
assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog.as_ref().and_then(|d| d.error.clone()));
h.frames(2);
let (_, _, pl) =
solidcraft_engine::view::construction_planes(&h.app.session).into_iter().find(|(_, name, _)| name != "Shelf").expect("the midplane");
assert!(pl.normal().cross(Vec3::Z).len() < 1e-6 && (pl.origin.z - 10.0).abs() < 1e-6, "{pl:?}");
}
/// Plane at Angle draws the tilted plane while its dialog is open, when created and when edited,
/// and redraws it when the angle changes (it used to show only the picked plane and the label).
#[test]
+4 -3
View File
@@ -184,8 +184,9 @@ pub(crate) fn dropped(s: &Session, extra: &mut Map<String, Value>, p: Vec3, n: V
Some(json!(coords))
}
/// The typed placement into the command's parameters: a bare number typed into a field is in the
/// design's units (as in the size fields), an empty field is 0, and a stored number is kept.
/// The typed placement into the command's parameters, read as the size fields read it
/// (`length_input::expression`): a bare number, a decimal comma or a unitless expression typed
/// into a field is in the design's units, an empty field is 0, and a stored number is kept.
pub(crate) fn params(s: &Session, cmd: &str, extra: &Map<String, Value>, p: &mut Map<String, Value>) {
for key in keys(cmd) {
let Some(Value::Array(point)) = extra.get(*key) else { continue };
@@ -193,7 +194,7 @@ pub(crate) fn params(s: &Session, cmd: &str, extra: &Map<String, Value>, p: &mut
.iter()
.map(|c| match c {
Value::String(t) if t.trim().is_empty() => json!(0),
Value::String(t) => json!(s.doc.with_design_unit(t, ValueKind::Length)),
Value::String(t) => json!(crate::length_input::expression(&s.doc, t, ValueKind::Length)),
other => other.clone(),
})
.collect();
@@ -515,6 +515,7 @@ pub(super) fn same_model_source(app: &SolveApp, document: &std::sync::Weak<solid
timeline_groups,
browser_order,
named_views,
notes,
visibility,
)
}
+69 -2
View File
@@ -10,7 +10,7 @@ use serde_json::{Value, json};
use solidcraft_engine::{
Sel,
doc::expr::Kind as ValueKind,
sketch::{LinkKind, LinkSource, TextAlign, TextStyle},
sketch::{LinkKind, LinkSource, TextAlign, TextStyle, TextVerticalAlign},
};
use crate::SolveApp;
@@ -538,7 +538,7 @@ pub(crate) fn rows(ui: &mut egui::Ui, k: &mut Tx, inputs: &mut [SelInput], activ
TextAlign::Right => 2,
};
let labels = ["sketch_text.align_left", "sketch_text.align_center", "sketch_text.align_right"].map(solidcraft_ui_text::tr);
ui_kit::segmented(ui, &mut selected, &labels, ui_kit::Segments { compact: true, ..Default::default() });
ui_kit::segmented(ui, &mut selected, &labels, ui_kit::Segments { compact: true, glyphs: Some(&paragraph_glyph), ..Default::default() });
k.style.align = match selected {
1 => TextAlign::Center,
2 => TextAlign::Right,
@@ -550,6 +550,26 @@ pub(crate) fn rows(ui: &mut egui::Ui, k: &mut Tx, inputs: &mut [SelInput], activ
}
}
});
ui_kit::row(ui, "Vertical", |ui| {
const ORDER: [TextVerticalAlign; 4] =
[TextVerticalAlign::Baseline, TextVerticalAlign::Top, TextVerticalAlign::Middle, TextVerticalAlign::Bottom];
let mut selected = ORDER.iter().position(|v| *v == k.style.vertical_align).unwrap_or(0);
let labels = ["sketch_text.valign_baseline", "sketch_text.valign_top", "sketch_text.valign_middle", "sketch_text.valign_bottom"]
.map(solidcraft_ui_text::tr);
// Four worded segments do not fit the dialog's width: each shows "Ag" with a line where the anchor is.
let glyph = |p: &egui::Painter, r: egui::Rect, i: usize, ink: egui::Color32| {
vertical_glyph(p, r, ORDER.get(i).copied().unwrap_or_default(), ink);
};
ui_kit::segmented(ui, &mut selected, &labels, ui_kit::Segments { compact: true, glyphs: Some(&glyph), ..Default::default() });
k.style.vertical_align = ORDER.get(selected).copied().unwrap_or_default();
for (label, handle) in
labels.iter().zip(["dialog:Vertical baseline", "dialog:Vertical top", "dialog:Vertical middle", "dialog:Vertical bottom"])
{
if let Some(at) = crate::scenario::handle_at(&format!("dialog:Vertical:{label}")) {
crate::scenario::publish_handle(handle, at);
}
}
});
// On a path the curve places the text: X, Y and the angle do not.
let on_path = k.style.path.is_some();
let (xy, rest) = k.values.split_at_mut(2);
@@ -642,6 +662,44 @@ fn is_font_file(s: &str) -> bool {
}
/// The application supplies families/file recognition; the kit owns search and list widgets.
/// The symbol of a horizontal alignment (0 left, 1 centre, 2 right): three lines of a paragraph set that way.
fn paragraph_glyph(p: &egui::Painter, r: egui::Rect, align: usize, ink: egui::Color32) {
let t = solidcraft_theme::Tokens::get();
let width = r.width().min(t.space_16 + t.space_2);
let stroke = egui::Stroke::new(1.5, ink);
for (row, share) in [1.0_f32, 0.6, 0.8].into_iter().enumerate() {
let length = width * share;
let left = match align {
0 => r.center().x - width / 2.0,
1 => r.center().x - length / 2.0,
_ => r.center().x + width / 2.0 - length,
};
p.hline(left..=left + length, r.center().y + (row as f32 - 1.0) * t.space_4, stroke);
}
}
/// The symbol of a vertical alignment: "Ag" with an accent line where the text's anchor is, at the
/// capitals' tops, halfway down them, on the baseline or under the g's tail.
fn vertical_glyph(p: &egui::Painter, r: egui::Rect, align: TextVerticalAlign, ink: egui::Color32) {
let t = solidcraft_theme::Tokens::get();
let size = t.font_label;
let galley = p.layout_no_wrap("Ag".to_owned(), crate::ui_kit::type_face::semibold(size), ink);
let at = r.center() - galley.size() / 2.0;
// The baseline: where the first row's glyphs stand, else an estimate from the font size.
let baseline = galley.rows.first().and_then(|row| row.row.glyphs.first().map(|g| row.pos.y + g.pos.y)).unwrap_or(galley.size().y * 0.8);
let base = at.y + baseline;
let cap = size * 0.72;
let y = match align {
TextVerticalAlign::Top => base - cap,
TextVerticalAlign::Middle => base - cap / 2.0,
TextVerticalAlign::Baseline => base,
TextVerticalAlign::Bottom => base + size * 0.24,
};
let half = (galley.size().x / 2.0 + t.space_3).min(r.width() / 2.0);
p.galley(at, galley, ink);
p.hline(r.center().x - half..=r.center().x + half, y, egui::Stroke::new(1.5, t.accent));
}
fn font_row(ui: &mut egui::Ui, k: &mut Tx) {
let builtin = solidcraft_ui_text::format("sketch_text.builtin_font", &[("family", solidcraft_engine::sketch::DEFAULT_FONT_FAMILY)]);
let current = if k.font.trim().is_empty() { builtin.clone() } else { k.font.clone() };
@@ -711,10 +769,17 @@ fn edit_params(app: &SolveApp, k: &Tx) -> Result<Value, String> {
TextAlign::Center => "center",
TextAlign::Right => "right",
};
let vertical_align = match k.style.vertical_align {
TextVerticalAlign::Baseline => "baseline",
TextVerticalAlign::Top => "top",
TextVerticalAlign::Middle => "middle",
TextVerticalAlign::Bottom => "bottom",
};
let bold = k.style.bold && font_has_bold_face(&k.font);
let mut params = json!({
"link": k.link, "at": [sent_x, sent_y], "height": sent_height,
"bold": bold, "italic": k.style.italic, "align": align,
"vertical_align": vertical_align,
"letter_spacing": letter_spacing, "line_spacing": line_spacing,
"path": k.style.path.clone().unwrap_or_default(), "reverse": k.style.reverse,
});
@@ -1527,12 +1592,14 @@ mod tests {
h.frames(3);
h.step(&json!({"click":{"handle":"dialog:Italic"}})).unwrap();
h.step(&json!({"click":{"handle":"dialog:Align center"}})).unwrap();
h.step(&json!({"click":{"handle":"dialog:Vertical middle"}})).unwrap();
replace(&mut h, "dialog:Letter Spacing", "1.5");
replace(&mut h, "dialog:Line Spacing", "1.25");
h.frames(2);
// On the canvas before OK.
let live = style_of(&h, &link);
assert!(live.italic && !live.bold && live.align == TextAlign::Center, "{live:?}");
assert_eq!(live.vertical_align, TextVerticalAlign::Middle, "{live:?}");
assert_eq!((live.letter_spacing, live.line_spacing), (1.5, 1.25));
ok(&mut h);
assert!(!editing(&h.app));
+114 -14
View File
@@ -3356,7 +3356,9 @@ fn first_triangle_point(m: &solidcraft_engine::geom::Mesh, index: usize) -> Opti
/// The faces a feature changed, for the states either side of it, on the bodies as they are now:
/// a face whose name is new, or whose area, centroid or plane moved. A face a later feature
/// renamed is found again by its plane and nearest centre.
/// renamed is found again by its plane and nearest centre, in the body's own coordinates. Each
/// face is lit on every copy of the body `now` shows: a component placed twice or turned shows
/// its body once per placement, and the copies share the body's face names.
fn changed_faces(app: &SolveApp, id: u64, now: &solidcraft_engine::doc::ModelState, shown_only: bool) -> Vec<Sel> {
let Some(index) = app.session.doc.features.iter().position(|f| f.id == id) else { return Vec::new() };
let after = match app.session.doc.features.get(index + 1) {
@@ -3364,12 +3366,22 @@ fn changed_faces(app: &SolveApp, id: u64, now: &solidcraft_engine::doc::ModelSta
None => app.session.model.state(),
};
let before = app.session.model.state_before(id);
let current = app.session.model.state();
let replaced = app.preview.effective_replaced();
let mut out = Vec::new();
for b in &after.bodies {
if shown_only && (!app.session.body_visible(&b.name) || app.preview.effective_replaced().contains(&b.name)) {
// The body as `now` shows it, once per placement (the body itself outside a component).
let copies: Vec<_> = now
.bodies
.iter()
.filter(|c| c.definition_name() == b.name)
.filter(|c| !shown_only || (app.body_visible(&c.name) && !replaced.contains(&c.name)))
.map(|c| (c, solidcraft_engine::doc::naming::face_names(c)))
.collect();
if copies.is_empty() {
continue;
}
let (Some(now_body), Some(old)) = (now.body(&b.name), before.body(&b.name)) else { continue };
let (Some(now_body), Some(old)) = (current.body(&b.name), before.body(&b.name)) else { continue };
let names = solidcraft_engine::doc::naming::face_names(b);
let old_names = solidcraft_engine::doc::naming::face_names(old);
// A body the feature left as it was (same record, same face names) has no face it
@@ -3380,6 +3392,7 @@ fn changed_faces(app: &SolveApp, id: u64, now: &solidcraft_engine::doc::ModelSta
}
let tol = (b.body.size() * 1e-3).max(1e-4);
let (Ok(new_faces), Ok(old_faces)) = (b.body.faces(tol), old.body.faces(tol)) else { continue };
let now_names = solidcraft_engine::doc::naming::face_names(now_body);
for (i, name) in names.iter().enumerate() {
let Some(new) = new_faces.get(i) else { continue };
// A face the feature moved takes a new name (Offset Face); a face it only trimmed
@@ -3396,27 +3409,31 @@ fn changed_faces(app: &SolveApp, id: u64, now: &solidcraft_engine::doc::ModelSta
if !moved {
continue;
}
let Some((index, point)) = current_face(now_body, name, new, tol) else { continue };
if !out.iter().any(|s| matches!(s, Sel::Face { body, index: i, .. } if body == &b.name && *i == index)) {
out.push(Sel::Face { body: b.name.clone(), index, point });
// The face as the body has it now, by the name it carries there on every copy.
let Some(current_name) = current_face(now_body, &now_names, name, new, tol).and_then(|k| now_names.get(k)) else { continue };
for (copy, copy_names) in &copies {
let Some(index) = copy_names.iter().position(|n| n == current_name) else { continue };
let Some(point) = first_triangle_point(&copy.mesh(), index) else { continue };
if !out.iter().any(|s| matches!(s, Sel::Face { body, index: i, .. } if body == &copy.name && *i == index)) {
out.push(Sel::Face { body: copy.name.clone(), index, point });
}
}
}
}
out
}
/// Face `name` of `now` (or the nearest face on its plane when a later feature renamed it),
/// with a point on it.
/// Face `name` of `now`, whose faces are `names`, or the nearest face on its plane when a later
/// feature renamed it.
fn current_face(
now: &solidcraft_engine::doc::ModelBody,
names: &[String],
name: &str,
sample: &solidcraft_engine::kernel::FaceInfo,
tol: f64,
) -> Option<(usize, solidcore_record::Vec3)> {
let m = now.mesh();
let names = solidcraft_engine::doc::naming::face_names(now);
) -> Option<usize> {
if let Some(index) = names.iter().position(|n| n == name) {
return first_triangle_point(&m, index).map(|point| (index, point));
return Some(index);
}
let normal = sample.plane_normal?;
let faces = now.body.faces(tol).ok()?;
@@ -3429,8 +3446,7 @@ fn current_face(
best = Some((off, d, f.index));
}
}
let index = best?.2;
first_triangle_point(&m, index).map(|point| (index, point))
Some(best?.2)
}
/// The sketches picked among `items` (a sketch row in the Browser or the timeline) that the
@@ -5823,6 +5839,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
crate::joint_handles::show(app, ui, &painter, &proj);
overlays(app, &painter, &proj);
paint_device_pivot(app, &painter, &proj);
draw_notes(app, &painter, &proj);
crate::dim_view::show(app, ui, &painter, &proj);
crate::sketch_text_handles::show(app, ui, &painter, &proj, hover);
crate::sketch3d::show(app, &painter, &proj);
@@ -7368,6 +7385,59 @@ impl Conflict {
}
}
/// Notes (#1631): text anchored to points in model space, drawn as small screen-facing
/// callouts. The callout is laid out in screen space at the anchor's projection, so its text
/// always faces the camera and stays readable at any rotation, while the anchor follows the
/// model. Hidden notes are kept with the design but not drawn. Notes stay out of the way
/// while a sketch is being edited.
///
/// A note as it is drawn this frame: the anchor on screen and the text box beside it.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct DrawnNote {
/// The projected anchor point (screen space).
pub anchor: Pos2,
/// The text box (screen space), offset up-right from the anchor.
pub text_rect: Rect,
/// The note's text.
pub text: String,
}
/// Where the visible notes are drawn this frame (screen space), for painting and hit tests.
pub(crate) fn note_callouts(app: &SolveApp, painter: &egui::Painter, proj: &Proj) -> Vec<DrawnNote> {
if app.session.active_sketch.is_some() {
return Vec::new();
}
let t = Tokens::get();
app.session
.doc
.notes
.iter()
.filter(|n| !n.hidden)
.filter_map(|n| {
let anchor = proj.to_screen(n.anchor)?;
let galley = painter.layout(n.text.clone(), FontId::proportional(t.font_small), t.text, 240.0);
let text_rect = Rect::from_min_size(anchor + vec2(14.0, -14.0) - vec2(0.0, galley.size().y + 8.0), galley.size() + vec2(12.0, 8.0));
Some(DrawnNote { anchor, text_rect, text: n.text.clone() })
})
.collect()
}
fn draw_notes(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
if app.session.doc.notes.is_empty() {
return;
}
let t = Tokens::get();
for note in note_callouts(app, painter, proj) {
// A leader from the anchor to the text box, the dot, then the callout itself:
// a rounded panel with the text, always parallel to the screen.
painter.line_segment([note.anchor, note.text_rect.left_bottom() + vec2(6.0, -4.0)], Stroke::new(1.0, t.accent));
painter.circle_filled(note.anchor, 3.0, t.accent);
painter.rect_filled(note.text_rect, 4.0, t.panel);
painter.rect_stroke(note.text_rect, 4.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside);
painter.text(note.text_rect.min + vec2(6.0, 4.0), Align2::LEFT_TOP, &note.text, FontId::proportional(t.font_small), t.text);
}
}
/// Where a profile can fail: a ring round each open end (it touches no other curve) and a
/// diamond round each untied one (it touches another curve only by position), told apart by
/// shape. Both are warnings, not errors: an open sketch (a sweep path, an axis) is valid.
@@ -14493,4 +14563,34 @@ mod feature_highlight_tests {
// The plate's walls bulge past y = ±8; nothing of the tab does.
assert!(faces.iter().all(|p| p.y.abs() <= 8.0 + 1e-6), "{parts:?}");
}
/// A component placed twice, the first placement turned: a feature whose faces a later Offset
/// Face renamed lights the faces it changed on both placements, where each is shown.
#[test]
fn a_renamed_face_lights_on_every_placement_of_its_component() {
let mut s = solidcraft_engine::Session::default();
s.execute("component.create", &json!({"name": "Part", "grounded": false})).unwrap();
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20, "body_name": "Base"})).unwrap();
let first =
s.execute("solid.offset_face", &json!({"faces": [[20, 15, 20]], "distance": 5, "name": "Raise"})).unwrap()["feature"].as_u64().unwrap();
s.execute("solid.offset_face", &json!({"faces": [[20, 15, 25]], "distance": 2, "name": "RaiseAgain"})).unwrap();
s.execute("component.activate", &json!({"component": "root"})).unwrap();
s.execute("occurrence.copy", &json!({"component": "Part", "translate": [0, 40, 0]})).unwrap();
s.execute("occurrence.move", &json!({"occurrence": "Part:1", "axis": [0, 0, 1], "angle": "90 deg", "origin": [0, 0, 0]})).unwrap();
let app = SolveApp::new(s, crate::Services::default());
let parts = feature_parts(&app, first, true);
let mut bodies: Vec<&str> = parts.iter().filter_map(|p| if let Sel::Face { body, .. } = p { Some(body.as_str()) } else { None }).collect();
bodies.sort_unstable();
bodies.dedup();
assert_eq!(bodies.len(), 2, "faces lit on both placements: {parts:?}");
for body in bodies {
// The raised top face, at z = 27 on the turned placement as on the copy.
let top = parts.iter().any(|p| matches!(p, Sel::Face { body: b, point, .. } if b == body && (point.z - 27.0).abs() < 1e-6));
assert!(top, "{body} lights its raised top: {parts:?}");
}
// The copy stands 40 mm along Y; the turned first placement has its faces at x <= 0.
let copy = parts.iter().any(|p| matches!(p, Sel::Face { point, .. } if point.y > 40.0));
let turned = parts.iter().any(|p| matches!(p, Sel::Face { point, .. } if point.x < 0.0));
assert!(copy && turned, "{parts:?}");
}
}
@@ -2800,3 +2800,36 @@ fn a_section_card_shows_every_row_and_reads_like_its_pill() {
}
}
}
/// Notes (#1631): the callout is laid out in screen space at the projected anchor, so it
/// faces the camera at any rotation, follows the anchor, and hidden notes are not drawn.
#[test]
fn note_callouts_are_screen_facing_and_follow_the_anchor() {
fn shot(h: &Harness) -> Vec<DrawnNote> {
let proj = display_proj(&h.app);
let mut out = Vec::new();
draw(&h.ctx, |ui| out = note_callouts(&h.app, ui.painter(), &proj));
out
}
let mut h = Harness::new();
h.app.run("note.add", json!({"anchor": [10.0, 5.0, 20.0], "text": "Check clearance here."})).unwrap();
h.settle();
let first = shot(&h);
assert_eq!(first.len(), 1);
assert_eq!(first[0].text, "Check clearance here.");
assert!(first[0].text_rect.width() > 0.0 && first[0].text_rect.height() > 0.0);
// Another rotation: the same text, drawn at a new place — the callout faces the screen.
h.call("ui.view", json!({"view": "front"}));
h.settle();
let second = shot(&h);
assert_eq!(second.len(), 1);
assert_eq!(second[0].text, first[0].text);
assert_ne!(second[0].anchor, first[0].anchor, "the anchor follows the model as it turns");
// A hidden note is kept but not drawn.
let id = h.app.session.doc.notes[0].id;
h.app.run("note.update", json!({"note": id, "hidden": true})).unwrap();
h.settle();
assert!(shot(&h).is_empty());
}
+22 -8
View File
@@ -113,7 +113,9 @@ pub fn groups(tab: &str, panel: &str) -> &'static [CommandGroup] {
("MODEL", "CONSTRUCT") => &[
CommandGroup {
label: "ui.construct_planes",
// Plane, Axis and Point (method from the picks) lead their own heading.
ids: &[
"construct.plane",
"construct.plane.offset",
"construct.plane.at_angle",
"construct.plane.midplane",
@@ -128,6 +130,7 @@ pub fn groups(tab: &str, panel: &str) -> &'static [CommandGroup] {
CommandGroup {
label: "ui.construct_axes",
ids: &[
"construct.axis",
"construct.axis.through_cylinder",
"construct.axis.normal_to_face",
"construct.axis.two_planes",
@@ -138,6 +141,7 @@ pub fn groups(tab: &str, panel: &str) -> &'static [CommandGroup] {
CommandGroup {
label: "ui.construct_points",
ids: &[
"construct.point",
"construct.point.vertex",
"construct.point.two_edges",
"construct.point.three_planes",
@@ -221,7 +225,13 @@ pub const SECTIONS: &[Section] = &[
ids: &["solid.move", "solid.mirror", "solid.pattern.rectangular", "solid.align", "solid.pattern.circular", "solid.pattern.path"],
},
Section { tab: "MODEL", panel: "CONFIGURE", promoted: 2, ids: CONFIGURE },
Section { tab: "MODEL", panel: "CONSTRUCT", promoted: 1, ids: &["construct.plane.offset"] },
// Plane, Axis and Point work out the method from the picks; the methods follow in the menu.
Section {
tab: "MODEL",
panel: "CONSTRUCT",
promoted: 3,
ids: &["construct.plane", "construct.axis", "construct.point", "construct.plane.offset"],
},
// Only while a linked design is edited in place, pinned at the right as FINISH SKETCH is.
Section { tab: "MODEL", panel: "FINISH EDITING", promoted: 1, ids: &["component.finish_edit"] },
// Sketches drive surfaces too: New Sketch leads the tab, as on MODEL and SHEET METAL.
@@ -920,11 +930,13 @@ mod tests {
let mut previous = 0;
for (_, c) in &grouped {
assert!(seen.insert(c.id), "duplicate {}", c.id);
let rank = if c.id.starts_with("construct.plane.") {
// The tool that picks its method from the selection leads its entity's heading.
let entity = |e: &str| c.id == format!("construct.{e}") || c.id.starts_with(&format!("construct.{e}."));
let rank = if entity("plane") {
0
} else if c.id.starts_with("construct.axis.") {
} else if entity("axis") {
1
} else if c.id.starts_with("construct.point.") {
} else if entity("point") {
2
} else {
panic!("ungrouped {}", c.id)
@@ -932,11 +944,13 @@ mod tests {
assert!(rank >= previous, "out of order: {}", c.id);
previous = rank;
}
assert_eq!(grouped[0].1.id, "construct.plane.offset");
assert_eq!(grouped[1].1.id, "construct.plane.at_angle");
assert_eq!(grouped[0].1.id, "construct.plane");
assert_eq!(grouped[1].1.id, "construct.plane.offset");
assert_eq!(grouped[2].1.id, "construct.plane.at_angle");
assert!(ungrouped("MODEL", "CONSTRUCT", &commands).is_empty());
// Removing pinned tools must not leave an empty heading or lose other commands.
let without_planes: Vec<_> = commands.iter().copied().filter(|c| !c.id.starts_with("construct.plane.")).collect();
let without_planes: Vec<_> =
commands.iter().copied().filter(|c| c.id != "construct.plane" && !c.id.starts_with("construct.plane.")).collect();
let grouped = grouped_commands("MODEL", "CONSTRUCT", &without_planes);
assert_eq!(grouped.iter().filter_map(|(h, _)| *h).collect::<Vec<_>>(), ["ui.construct_axes", "ui.construct_points"]);
assert_eq!(grouped.len(), without_planes.len());
@@ -1071,7 +1085,7 @@ mod tests {
("CREATE", vec!["solid.extrude", "solid.revolve", "solid.hole"]),
("MODIFY", vec!["solid.fillet", "solid.chamfer", "solid.shell", "solid.press_pull"]),
("ARRANGE", vec!["solid.move", "solid.mirror", "solid.pattern.rectangular"]),
("CONSTRUCT", vec!["construct.plane.offset"]),
("CONSTRUCT", vec!["construct.plane", "construct.axis", "construct.point"]),
] {
let (cmds, n) = layout("MODEL", panel, &specs).unwrap();
assert_eq!(cmds.iter().take(n).map(|c| c.id).collect::<Vec<_>>(), expected);
+7 -6
View File
@@ -195,17 +195,18 @@ fn narrow_toolbar_reaches_collapsed_commands() {
use serde_json::json;
let mut h = Harness::new();
h.run(&json!([
// The compact ribbon still fits every button at 640 wide.
// At 640 wide the ribbon still needs no » menu: Construct's three buttons (Plane, Axis
// and Point) fold into the panel's own button.
{"call":"ui.resize","params":{"width":640,"height":800}},
{"expect":{"shown_handles":["button:construct.plane.offset"],"hidden_handles":["toolbar:overflow"]}},
{"expect":{"shown_handles":["collapsed:MODEL:CONSTRUCT"],"hidden_handles":["toolbar:overflow","button:construct.plane"]}},
// Too narrow for every button: the panels go into the » menu.
{"call":"ui.resize","params":{"width":440,"height":800}},
{"expect":{"shown_handles":["toolbar:overflow"],"hidden_handles":["button:construct.plane.offset"]}},
{"expect":{"shown_handles":["toolbar:overflow"],"hidden_handles":["button:construct.plane","collapsed:MODEL:CONSTRUCT"]}},
{"click":{"handle":"toolbar:overflow"}},
{"widget":"CONSTRUCT"},
{"expect":{"shown_handles":["toolbar:construct.plane.offset"]}},
{"click":{"handle":"toolbar:construct.plane.offset"}},
{"expect":{"dialog":"OffsetPlane"}}
{"expect":{"shown_handles":["toolbar:construct.plane"]}},
{"click":{"handle":"toolbar:construct.plane"}},
{"expect":{"dialog":"ConstructAuto"}}
]))
.unwrap();
}
+37
View File
@@ -198,6 +198,43 @@ pub fn keyboard(ctx: &egui::Context, ok_enabled: bool) -> Action {
}
}
/// First-letter accelerators (#9841): a typed character answers the visible button whose
/// translated label starts with it. The match is on the typed glyph (`Event::Text`), not the
/// physical key, so it works on any keyboard layout, and on the label after translation, so a
/// German dialog answers N, A and S. Ambiguous letters do nothing; a matched but disabled
/// button still swallows the key so it cannot reach the app behind the dialog. A focused text
/// editor keeps the keystroke, as with Enter in [`keyboard`]. The host's preference gates the
/// whole accelerator ([`super::set_dialog_mnemonics`]); this runs only when it is on.
pub(super) fn first_letter_action(ctx: &egui::Context, candidates: &[(Action, &str, bool)]) -> Action {
if ctx.memory(|m| m.focused().is_some()) {
return Action::None;
}
ctx.input_mut(|i| {
let hit = i.events.iter().enumerate().find_map(|(index, event)| match event {
egui::Event::Text(text) => {
let mut letters = text.chars();
let (Some(ch), None) = (letters.next(), letters.next()) else {
return None;
};
let mut matches = candidates.iter().filter(|(_, label, _)| {
solidcraft_ui_text::tr_label(label).trim().chars().next().is_some_and(|first| first.to_lowercase().eq(ch.to_lowercase()))
});
let (action, live) = match (matches.next(), matches.next()) {
(Some((action, _, live)), None) => (*action, *live),
_ => return None,
};
Some((index, action, live))
}
_ => None,
});
if let Some((index, action, live)) = hit {
i.events.remove(index);
return if live { action } else { Action::None };
}
Action::None
})
}
/// Pinned actions with optional Apply, compute status and keyboard selection mark.
#[derive(Default)]
pub struct Footer<'a> {
+19 -18
View File
@@ -33,6 +33,9 @@ pub struct Segments<'a> {
pub primary: bool,
pub intrinsic: bool,
pub proportional: bool,
/// Draws each segment's symbol (painter, segment rect, index, ink) in place of its label. The
/// labels stay the hover text, the accessible names and the handles.
pub glyphs: Option<&'a dyn Fn(&egui::Painter, egui::Rect, usize, egui::Color32)>,
}
pub fn segmented(ui: &mut Ui, selected: &mut usize, options: &[&str], config: Segments<'_>) -> bool {
@@ -58,6 +61,7 @@ pub fn segmented(ui: &mut Ui, selected: &mut usize, options: &[&str], config: Se
primary,
intrinsic,
proportional,
glyphs,
} = config;
let accent = solidcraft_theme::TabAccent::current(ui.ctx());
let t = Tokens::get();
@@ -174,24 +178,21 @@ pub fn segmented(ui: &mut Ui, selected: &mut usize, options: &[&str], config: Se
crate::icons::paint_ink(ui.painter(), icon_rect, icon, if on && workspace_accent { accent.ink } else { t.form_ink });
}
let text_rect = if compact { text_rect.intersect(segment.shrink2(vec2(t.space_4, 0.0))) } else { text_rect };
paint_text(
ui,
text_rect,
text,
font,
if !segment_enabled {
t.form_dim
} else if on && primary {
t.on_accent
} else if on && workspace_accent {
accent.ink
} else if on {
t.selected_ink
} else {
t.form_ink
},
false,
);
let ink = if !segment_enabled {
t.form_dim
} else if on && primary {
t.on_accent
} else if on && workspace_accent {
accent.ink
} else if on {
t.selected_ink
} else {
t.form_ink
};
match glyphs {
Some(glyph) => glyph(ui.painter(), segment.shrink2(vec2(t.space_4, t.space_2)), index, ink),
None => paint_text(ui, text_rect, text, font, ink, false),
}
t.button_bevel_with_radius(ui, &response, on, rounding);
response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, segment_enabled, on, *text));
solidcraft_ui_registry::publish_target(&format!("dialog:{}:{option}", label(ui)), segment);
+49 -1
View File
@@ -1,8 +1,30 @@
//! Modal confirmations share the Panel/Window frame and the single kit footer.
use super::{Action, Footer, Window, actions::footer_with_left, text::paint_text};
use super::{
Action, Footer, Window,
actions::{first_letter_action, footer_with_left},
text::paint_text,
};
use egui::{Ui, vec2};
use solidcraft_theme::Tokens;
thread_local! {
/// Does the host let dialog buttons answer the first letter of their labels (#9841)?
/// Off unless the host turns it on, so the old behaviour — typing does nothing to the
/// buttons — is the default. See [`set_dialog_mnemonics`].
static DIALOG_MNEMONICS: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
/// Do dialog buttons answer the first letter of their labels? See [`set_dialog_mnemonics`].
pub fn dialog_mnemonics() -> bool {
DIALOG_MNEMONICS.with(std::cell::Cell::get)
}
/// The host turns the first-letter button accelerators of [`Confirm`] on or off (#9841).
/// Off is the old behaviour: a typed letter does nothing to a dialog's buttons.
pub fn set_dialog_mnemonics(on: bool) {
DIALOG_MNEMONICS.with(|cell| cell.set(on));
}
#[derive(Clone, Copy)]
pub enum Tone {
Warning,
@@ -65,6 +87,22 @@ impl<'a> Confirm<'a> {
self
}
/// The footer's visible buttons with their live states, for the first-letter accelerator:
/// OK needs valid inputs, Cancel always answers, and a confirmation's left action is live.
fn accelerators(&self, valid: bool) -> Vec<(Action, &'a str, bool)> {
let mut out = Vec::new();
if !self.ok.is_empty() {
out.push((Action::Ok, self.ok, valid));
}
if let Some(left) = self.left {
out.push((Action::Apply, left, true));
}
if !self.cancel.is_empty() {
out.push((Action::Cancel, self.cancel, true));
}
out
}
pub fn show(self, ctx: &egui::Context, valid: bool, message: impl FnOnce(&mut Ui)) -> (bool, Action) {
// Reviewed reports and native pickers temporarily own input. Keep the pending
// answer untouched and resume the modal when that boundary closes.
@@ -80,6 +118,10 @@ impl<'a> Confirm<'a> {
ui.spacing_mut().item_spacing.y = 0.0;
// Consume before native button focus can interpret Enter as a different answer.
let enter = ui.is_enabled() && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter));
// #9841: a typed letter answers the button whose translated label starts with it.
// Read here so the keystroke is consumed even when Escape or a click wins the frame.
// The host's preference gates the accelerator; off, the keystroke is left alone.
let accel = if ui.is_enabled() && dialog_mnemonics() { first_letter_action(ui.ctx(), &self.accelerators(valid)) } else { Action::None };
let mut closed = false;
egui::Frame::NONE
.inner_margin(egui::Margin {
@@ -148,6 +190,8 @@ impl<'a> Confirm<'a> {
action
} else if enter && valid {
self.selected.unwrap_or(Action::Ok)
} else if action == Action::None && accel != Action::None {
accel
} else {
action
};
@@ -155,3 +199,7 @@ impl<'a> Confirm<'a> {
})
}
}
#[cfg(test)]
#[path = "confirm_tests.rs"]
mod tests;
+130
View File
@@ -0,0 +1,130 @@
use crate::test_support::*;
use crate::*;
use egui::Ui;
/// A pressed letter as the platform delivers it: `Event::Text` carries the typed glyph, which is
/// what makes the accelerator layout-independent (#9841).
fn letter(ch: &str) -> Vec<egui::Event> {
vec![egui::Event::Text(ch.to_owned())]
}
/// Draws the prompt for two frames with the accelerator switched on, as the host does when its
/// preference is on. An egui Area spends its first frame on an invisible sizing pass, so the
/// modal only answers keys from its second frame on — the same warm-up the panel tests use.
fn prompt_frame(ctx: &egui::Context, events: Vec<egui::Event>, valid: bool, labels: (&str, &str, Option<&str>)) -> Action {
set_dialog_mnemonics(true);
prompt_draw(ctx, events, valid, labels)
}
fn prompt_draw(ctx: &egui::Context, events: Vec<egui::Event>, valid: bool, labels: (&str, &str, Option<&str>)) -> Action {
let mut action = Action::None;
let mut draw = |ui: &mut Ui| {
let (ok, cancel, left) = labels;
action = Confirm::new(ui.scope_id().with("prompt"), "status_warning", "Save changes?")
.tone(Tone::Warning)
.actions(ok, cancel, left)
.secondary_left()
.show(ui.ctx(), valid, |ui| {
text(ui, "Your changes will be lost.", Text::Report("message", false));
})
.1;
};
frame(ctx, vec![], &mut draw);
frame(ctx, events, &mut draw);
action
}
/// The changed-default rule: with the host's preference left at its default — off — the letters
/// answer nothing, the old behaviour. Turning it on makes each button answer its first letter.
#[test]
fn first_letters_are_inert_until_the_host_turns_them_on() {
let ctx = crate::test_support::context();
let labels = ("Save", "Cancel", Some("Don't Save"));
assert!(!dialog_mnemonics(), "off unless the host turns it on");
assert_eq!(prompt_draw(&ctx, letter("s"), true, labels), Action::None, "off: Save does not answer S");
assert_eq!(prompt_draw(&ctx, letter("c"), true, labels), Action::None, "off: Cancel does not answer C");
assert_eq!(prompt_frame(&ctx, letter("s"), true, labels), Action::Ok, "on: Save answers S");
}
/// Each button answers its own first letter, in either case; an unmatched letter answers nothing.
#[test]
fn confirm_answers_the_first_letter_of_each_button_label() {
let ctx = crate::test_support::context();
let labels = ("Save", "Cancel", Some("Don't Save"));
assert_eq!(prompt_frame(&ctx, letter("s"), true, labels), Action::Ok, "Save");
assert_eq!(prompt_frame(&ctx, letter("c"), true, labels), Action::Cancel, "Cancel");
assert_eq!(prompt_frame(&ctx, letter("d"), true, labels), Action::Apply, "Don't Save");
assert_eq!(prompt_frame(&ctx, letter("S"), true, labels), Action::Ok, "the letter matches whatever its case");
assert_eq!(prompt_frame(&ctx, letter("x"), true, labels), Action::None, "no label starts with x");
}
/// The accelerator follows the translated label: German shows „Speichern/Abbrechen/Nicht
/// speichern", so the keys are S, A and N — and the English D answers nothing.
#[test]
fn confirm_first_letter_follows_the_translated_label() {
let ctx = crate::test_support::context();
let _language = solidcraft_ui_text::scope("de");
let labels = (solidcraft_ui_text::tr("ui.save"), solidcraft_ui_text::tr("ui.cancel"), Some(solidcraft_ui_text::tr("ui.don_t_save")));
assert_eq!(labels, ("Speichern", "Abbrechen", Some("Nicht speichern")), "the catalog answers in German");
assert_eq!(prompt_frame(&ctx, letter("s"), true, labels), Action::Ok, "Speichern");
assert_eq!(prompt_frame(&ctx, letter("a"), true, labels), Action::Cancel, "Abbrechen");
assert_eq!(prompt_frame(&ctx, letter("n"), true, labels), Action::Apply, "Nicht speichern");
assert_eq!(prompt_frame(&ctx, letter("d"), true, labels), Action::None, "no German label starts with d");
}
/// Two visible buttons sharing a first letter make the key ambiguous: it answers nothing, while
/// a letter that still distinguishes one button keeps working.
#[test]
fn ambiguous_first_letters_do_nothing() {
let ctx = crate::test_support::context();
let labels = ("Delete", "Discard", Some("Keep"));
assert_eq!(prompt_frame(&ctx, letter("d"), true, labels), Action::None, "Delete and Discard both start with d");
assert_eq!(prompt_frame(&ctx, letter("k"), true, labels), Action::Apply, "Keep is still unambiguous");
}
/// The matched keystroke is consumed: the next frame sees no event and does not answer twice,
/// and Enter and Escape keep their existing meanings even alongside a letter.
#[test]
fn first_letter_is_consumed_and_never_overrides_enter_or_escape() {
let ctx = crate::test_support::context();
let labels = ("Save", "Cancel", Some("Don't Save"));
assert_eq!(prompt_frame(&ctx, letter("s"), true, labels), Action::Ok);
assert_eq!(prompt_frame(&ctx, vec![], true, labels), Action::None, "the consumed letter does not answer again");
let mut events = key(egui::Key::Enter);
events.extend(letter("c"));
assert_eq!(prompt_frame(&ctx, events, true, labels), Action::Ok, "Enter still picks the selected button");
let mut events = key(egui::Key::Escape);
events.extend(letter("s"));
assert_eq!(prompt_frame(&ctx, events, true, labels), Action::Cancel, "Escape still cancels");
}
/// A text field inside the dialog (preset and rule name prompts) owns the keystroke while focused.
#[test]
fn first_letter_yields_to_a_text_field_inside_the_dialog() {
set_dialog_mnemonics(true);
let ctx = crate::test_support::context();
let mut name = String::new();
let mut action = Action::None;
let mut draw = |ui: &mut Ui| {
action = Confirm::new(ui.scope_id().with("name"), "save", "Save preset?")
.actions("Save", "Cancel", None)
.show(ui.ctx(), true, |ui| {
ui.text_edit_singleline(&mut name).request_focus();
})
.1;
};
frame(&ctx, vec![], &mut draw);
frame(&ctx, letter("s"), &mut draw);
assert_eq!(action, Action::None, "the focused text field keeps the keystroke");
}
/// A disabled button swallows its letter instead of letting it reach the app behind the dialog.
#[test]
fn first_letter_respects_disabled_buttons() {
let ctx = crate::test_support::context();
let labels = ("Save", "Cancel", Some("Don't Save"));
assert_eq!(prompt_frame(&ctx, letter("s"), false, labels), Action::None, "Save is disabled while inputs are invalid");
assert_eq!(prompt_frame(&ctx, vec![], false, labels), Action::None, "the swallowed letter did not linger");
assert_eq!(prompt_frame(&ctx, letter("c"), false, labels), Action::Cancel, "Cancel is always live");
}
+1 -1
View File
@@ -93,7 +93,7 @@ pub use checks::*;
pub use choice::*;
pub use color::*;
pub use compare::*;
pub use confirm::{Confirm, Tone};
pub use confirm::{Confirm, Tone, dialog_mnemonics, set_dialog_mnemonics};
pub use container::{BackingTooltipExt, Container, DockEdges, Edge, LayoutMode, Surface};
pub use container::{SHELL_GAP, input_blocked, set_input_blocked};
pub use dash::dashed_line;
@@ -0,0 +1,5 @@
{
"ui.save": "Speichern",
"ui.cancel": "Abbrechen",
"ui.don_t_save": "Nicht speichern"
}
+4 -1
View File
@@ -1,5 +1,8 @@
{
"ui.expand": "Expand",
"ui.view_compass_tip": "View from {axis}. Click again to reverse. Drag to orbit.",
"ui.view_compass_tip_snap": "View from {axis}. Click again to reverse. Drag to orbit; hold Shift to snap."
"ui.view_compass_tip_snap": "View from {axis}. Click again to reverse. Drag to orbit; hold Shift to snap.",
"ui.save": "Save",
"ui.cancel": "Cancel",
"ui.don_t_save": "Don't Save"
}
+5 -3
View File
@@ -6,10 +6,12 @@ use solidcraft_theme::Tokens;
use solidcraft_ui_registry as registry;
use std::collections::HashMap;
/// The kit's own English test catalog: the messages its tests read, with the same English a host
/// gives them. Catalogs are host data; the kit's tests never rely on SolidCraft's.
/// The kit's own test catalogs: the messages its tests read, with the same English a host
/// gives them, plus the German the first-letter test needs. Catalogs are host data; the kit's
/// tests never rely on SolidCraft's.
static ENGLISH: solidcraft_ui_text::Catalog = solidcraft_ui_text::Catalog::new("en", "English", include_str!("test_support/catalog.json"));
static CATALOGS: [&solidcraft_ui_text::Catalog; 1] = [&ENGLISH];
static GERMAN: solidcraft_ui_text::Catalog = solidcraft_ui_text::Catalog::new("de", "Deutsch", include_str!("test_support/catalog.de.json"));
static CATALOGS: [&solidcraft_ui_text::Catalog; 2] = [&ENGLISH, &GERMAN];
/// Install the kit's test catalog (once per process) and read the system language as English.
pub fn install_catalog() {

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