Compare commits

..

47 Commits

Author SHA1 Message Date
MarkusRe 9a34227883 solidcore: plane sections on a cylinder get their exact UV trims by default (retire cylinder_plane_section_trims_exact, #13836) (#14744)
* claim: #13836

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

Refs #13739

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

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

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

Refs #13739

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

---------

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

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

---------

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

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

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

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

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

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

---------

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

Part of #14460.


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

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

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

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

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

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

---------

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

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

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

Fixes tangency-labs/solidcraft#13913.

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

---------

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

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

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

Part of #13790.


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

All 283 units pass.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Opening a design keeps its own Cancel

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

---------

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

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

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

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

Written by Qwen.

---------

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

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

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


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

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

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

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

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

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

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

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

Part of #13790.

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

---------

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

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


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

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

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

* Format note commands

* Drop needless borrow in note command errors

* Add pending format change for notes

---------

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

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

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

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

---------

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

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

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

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

Changes a default: no

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

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

Changes a default: no

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 20:03:36 +00:00
rugbymauri 70fdcc6fc7 Extrude: started from an open sketch, its profiles can be picked even when the sketch is already used (#14128) (#14648)
* claim: #14128

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

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

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

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

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

---------

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

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

Part of #13790.


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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

Part of #14451


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

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

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