Compare commits

...

84 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
Tianyi 201de37b96 File menu: look up the labels that skipped the translation catalog (#14694)
* File menu: look up the labels that skipped the translation catalog

* Add the French labels for the File menu export items
2026-11-10 19:04:25 +00:00
bherbruck 0cb554058c Service worker: only a first worker records the pages it claims as network pages (#14699)
After an update, a page the new worker claimed before the old worker's record of it was written was recorded as a network page, so its later files came from the network, which serves the newer build (the open v3 page was given v4's shared.js in a queue run). A new worker now records claimed pages as network pages only when no earlier build is kept (a first install, when open pages did come from the network); after an update a page with no record gets the visible failure. The offline test now prints the page records when that check fails.

Changes a default: no

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 19:03:34 +00:00
SS-EN c481b24329 Assemblies: Capture Position also saves joint values, and Revert Position puts them back (#14315)
* Joints: store a rest position and reset one or all joints to it (#14275)

Each joint can store its current values as a rest position (joint.set_rest)
and go back to it (joint.reset), one joint or every joint at once. The
joint's right-click menu has Set Current Pose as Rest and Reset to Rest;
the browser's Joints folder menu sets or resets every joint. joint.list
reports rest. The rest values save with the design (a pending format
change); a joint without one, as in every older file, loads unchanged and
a reset leaves it where it is, listed in skipped.

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

* Joints: rest-position labels in every complete catalog and Korean (#14275)

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

* Joints: captured-value helpers before the test module (clippy)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 18:54:59 +00:00
Brett Lawson 0bad690ed4 solidcore: mass, heal and geom failures carry a typed reason instead of bare statuses (part of #13790) (#14605)
* solidcore: mass, heal and geom failures carry a typed reason instead of bare statuses (part of #13790)

mass (integrator, properties, enclosure), heal (gap, face_props, heal_data,
stitch) and geom (vectors, matrix, transform, boxes, geom_utility, local_solve1d,
global_solver, grid, tol, attribute, a_object, tools, periodic_extent1d) return a
MassFailure, HealFailure or GeomFailure under the new Reason::Mass, Reason::Heal
and Reason::Geom instead of a bare Upstream status, each with a phrase or a
variant saying what failed. The operation path, entity (none), comparison status
and kind are those of the Upstream it replaces, so sm_status(), kind(),
is_port_failure() and is_status_answer() answer as before; no cause is kept.

The integrator's step-control, integrand and skipped-span failures are their own
MassFailure variants, which the integrator and typed_failure match instead of the
operation path; heal's not-yet-healable face is HealFailure::NotYetHealable, which
the heal sequence matches. geom's FALSE (coincident circle points), SM_ERR_WARNING
(nearly singular centre system) and SM_ERR_INVALID_INPUT (tolerance below
SM_EFF_ZERO) keep their statuses as GeomFailure variants. The stitch entry points'
"SmApiStitch..." messages are worded in solidcore terms.

upstream_status() answers for the new reasons as for an Upstream. The goldens
runners that record these statuses (the mass, heal and geom runners, and
blend.fillet_geom_p1, curve composite_curve_p1, curve_p5, curve_p9, hyperbola,
parabola, geom.local_solve_nd, surface volume_p1/p2, which reach geometry
utilities) read it, so every row stays as it was. The fuzz corpus keeps the grid's
voxel refusal under its new message. SolidCraft's status() passes the failures on
as their status, as it did the Upstream.

Held for open PRs: geom extent1d/extent2d (#13605), expansion (#13965),
local_solve_nd (#14102). The singular system of Matrix::solve_linear_system (and
solve_with_lu, which it calls) stays a bare status while local_solve_nd matches it.

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

* solidcore: the bare-status ratchets cut the test module on CRLF checkouts too

The ratchets find each file's `#[cfg(test)]` module by an LF-only string, so on a
Windows checkout with core.autocrlf=true it never matched and the `sm_err` calls
in test modules were counted (reported on #14605). Normalise line endings after
reading, in all six ratchets.

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 18:46:12 +00:00
austin62jake 4c512a243f solidcore: retire five topology UpstreamFixes flags (#14246, batch A16) (#14679)
* solidcore: retire five topology UpstreamFixes flags (#14246, batch A16)

The corrected path becomes the only path, and the flag and the old path are
deleted, for:
- loop_orientation_floor_from_loop_box (WR-561): a loop's orientation floor
  is relative to its own bounding box;
- trim_boundary_seam_wrap (WR-491): a UV gap of whole periods between
  consecutive trim curves is a seam crossing;
- imprint_extension_keeps_analytic_curve (WR-601): a continued line, circle
  or ellipse stays that kind;
- shell_inner_shell_of_statuses (WR-497): IsInnerShellOf passes on the
  statuses upstream ignores, and the Checked::Ignored arms go;
- is_iso_curve_at_propagates_canonical_failure (WR-97): a failing
  GetCanonical is an error. is_iso_curve_at propagates it itself, and
  is_iso_curve_at_with_fixes is removed.

SolidCraft's preset (crates/smlib/src/core.rs) and the kernel rig's
fixes.json had all five on; their lines go, with the goldens' enable_fix arm
for imprint_extension_keeps_analytic_curve. Tests that compared the flag off
with on keep the corrected half.

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

* plan: the five A16 flags' records note that the flags are removed (#14246)

One addendum each for WR-561, WR-491, WR-601, WR-497 and WR-97: the
corrected path is the only path, what was removed, the tests, and the
goldens (no unit changes on Windows against main 87400c80e).

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

---------

Co-authored-by: austin62jake <340374338+austin62jake@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 18:42:54 +00:00
rugbymauri 2b88913228 Extrude: a sketch outline whose arc meets lines tangentially through point-on-curve constraints is one closed profile (#14256) (#14656)
* claim: #14256

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

* Sketch profiles: an outline whose arc meets lines tangentially through point-on-curve constraints closes (#14256)

Where a line meets an arc tangentially and the joint is held by two
point-on-curve constraints instead of one shared point, the solver fixes
the arc's end along the tangent only to about sqrt(2 * radius * 1e-9 mm).
On a 25 mm arc that leaves the ends up to 2.25e-4 mm apart, past the
1e-4 mm the profile search joins ends at, so the outline had no closed
profile and Extrude could not pick it.

The profile search now also joins the ends of a line and an arc, or of
two arcs, up to 1e-3 mm apart when each end lies within 1e-7 mm of the
other curve's line or circle. Two lines, and two arcs of one circle,
never join this way, so ends that are genuinely apart stay apart.

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

---------

Signed-off-by: Maurizio Monticelli <maurizio@whatwedo.ch>
2026-11-10 18:30:18 +00:00
0rdnungsamt 0e5e689f7c Assistant model box: the search hint says any model id can be typed (part of #14369) (#14466)
* claim: model box hint that any model id can be typed (#14369)

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

* Model box: the search hint says any model id can be typed (#14369)

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

* Model box hint: Arabic, Hebrew, Japanese, Korean, Vietnamese and Chinese

The complete catalogs need every English message.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 18:12:42 +00:00
ABDULLAH ed7c33df66 Arabic: denoising terms, excluded-pixel labels, caustics names, texture filtering help; construction line, agent pick and control port notes updated to the new English; tanween, mouse, sketch and Fillet terms in new strings (#14252)
* Arabic: denoising terms, excluded-pixel labels, caustics names, texture filtering and metal reflectance help

* Arabic: construction line typed distance, agent pick pill, control port note, tanween, mouse and sketch terms in new strings

Re-translate five messages whose English changed (construction line help, agent chat pick tip, control channel note) and fix new pending strings: tanween spelling, mouse and sketch terms, Solid Fillet name, quoted button and menu names, design plural.
2026-11-10 18:12:27 +00:00
0rdnungsamt 46bb71b5e1 UI kit: menu items can show a host's icon, not only a built-in one (#13379) (#14012)
* claim: #13379

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

* UI kit: menu items can show a host's icon, not only a built-in one

MenuItem takes the kit's Icon: a kit icon name, a host texture or a
paint function. Item owns its data, so it holds an ItemIcon, which still
reads and reports as the kit name. Named icons keep their danger, ink and
plain painting, so existing menus draw as before.

Closes #13379

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

* Menu test: use the kit's own test context after the move to solidcraft-ui-kit

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 18:03:00 +00:00
SS-EN 18928334c7 Control channel: control.cancel cancels an agent's running or queued request; MCP cancel tool (#1989) (#3754)
* Control channel: control.cancel and the MCP cancel tool (#1989)

A client can cancel its own outstanding request (the one control.pending
reports). Each control request carries a ticket shared with its
connection; whoever moves it first wins:
- still waiting its turn: removed, never runs;
- computing off the window thread (design change, export, agent cursor):
  stopped as Stop stops it, nothing applied, the queue goes on;
- running on the window thread: "cannot be cancelled", it finishes.

The worker is told to stop too: the evaluation skips features it has not
reached, kernel booleans give up at their next try, and work done after
a cancel is never cached. A connection can only cancel its own requests.

MCP: a `cancel` tool sends control.cancel on the bridge's connection;
headless refuses. With #2901's waits: `cancel` goes out at once, not
behind the request still running as other calls are, and once the app
has removed, stopped or answered it, later calls no longer wait for it.
The timeout reply names control.cancel next to control.pending.
With #3654's control.running: a request carries both its `started` flag
and its cancel ticket; one cancelled while it waits is refused before it
is marked started, and stays listed until the window drops it.

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

* Control protocol docs: a cancel's wait is 3600 s for engine.batch and the agent chat

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 17:47:32 +00:00
Umut Acil 709133a27a Viewport: highlight sketches on hover in the Browser and Timeline (#14263) (#14278)
* claim: #14263

* Viewport: highlight sketches on hover in the Browser and Timeline (#14263)

Hovering a sketch or feature row in the Browser tree or a sketch in the
History timeline highlights its geometry over the model in the 3D viewport,
clearing the highlight when the pointer moves away.

* Address CodeRabbit review on semantic_content feature hover ownership

* docs: add docstrings to touched functions for coverage threshold

* style: format touched lines with rustfmt
2026-11-10 17:39:42 +00:00
SS-EN faaa92281a Refuse an ambiguous target name taken by another component's body (#12141) (#12172)
* Refuse a target name another component's body holds when this component's body asked for it (#12141)

A body asked to be called a name a linked component's body already has is
named otherwise (names stay unique), and a later feature that targeted the
name ran its boolean on the linked body, failing with a misleading kernel
error. The target is now refused as ambiguous, naming both bodies.

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

* Match a body to its own stored-name slot; refuse ambiguous targets before sheet machining (#12141)

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

* Find the names bodies were asked for in one pass (#12141)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 17:31:18 +00:00
bherbruck d8ef6dcf15 ops: a loft guided by rails and a blend through cross-sections reach the kernel (#3140) (#14645)
sweep::railed::loft_rails takes sections (a plane point, its normal, the curves) and one or two
rails to the kernel's certified loft (sweep::loft_rails): the caller's tolerance and most wires,
capped into a solid or a sheet, the kernel's refusal past the wires as
Error::RailPastTolerance { asked, reached, wires }. sweep::railed::loft_blend makes the one-face
sheet of the kernel's section blend, which keeps its definition. Ops converts and maps; it
computes no geometry. RailFit (Scale, Stretch) is ops' own here.

The kernel: a section's line curve takes its NURB on a copy; a span is bounded on the surface
both its rail ends lie nearest (a rail along an edge between two faces lies on both); halving
stops where the rail's middle is itself off the surface past the tolerance (it only steers), and
a span past its most pieces is unproved (an infinite bound), not an error.

Measured at ops level on SolidCraft's rail-loft cases (crates/kernel/src/loft_rails_tests.rs and
crates/engine/src/cmd/loft_rails_tests.rs, rebuilt read-only, at SolidCraft's tolerance of 1e-4
of the sections' diagonal and its 33 wires): all sixteen keep their verdict. The twelve that
build match SolidCraft's closed forms within its bars (relative 7e-12 to 7.5e-5 against 1e-4 to
2e-3); the four it refuses are refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 17:30:50 +00:00
bherbruck f56114219b STEP: delete the step-import crate; the goldens and step-export read through the record's Part 21 parser (#14194)
* Move geom's pcurve, label and check into solidcore::eval (pure git mv)

crates/geom/src/brep/{pcurve,pcurve_tests,label,check}.rs move unchanged to
solidcore/crates/solidcore/src/eval/. The next commits retype them to the kernel's evaluation
types and switch their callers; this commit is the move alone so their history follows them.

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

* solidcore::eval: pcurve, label and check on the kernel's evaluation types

The moved files take eval::geometry's Curve, Curve2, Surface and Frame (the record's kinds,
field for field) and the kernel's evaluator, in place of the record's types and
solidcraft-geom's evaluator. What reads a record as a record leaves: the structural checks
(counts, budgets, a use's interval in its curve's natural range), the edge and face readers
(edge_curve_interval, form_turns on a Face) and the trim request types go to solidcore-ops,
which converts. Results are unchanged: the pcurve keeps its own pole test (the evaluator's
speed rule), knot span search and B-spline cut, not the kernel's collapse; the 80 eval tests
pass, the pcurve suite among them.

Lint-driven rewrites that keep each result: checked indexing, no pre-reserving from input counts,
the loop rotation without a remainder, a clamp that cannot panic on a reversed domain. The
hostile-record cases that depend on the record's checks are kept as no-panic checks here and as
refusals in ops.

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

* solidcore-ops: pcurve, label and record_check over the record

The record's side of the moved kernel routines: each checks the record's structure as the
record's validation does (one budget per face, a use's interval in its curve's natural range, a
given pcurve as a 2D B-spline), converts through solidcore-convert, calls solidcore::eval and
converts back. The trim request types (TrimFace, TrimEdge, TrimUse, Trims) hold records, so they
live here. No arithmetic: ops' float-arithmetic and geometry-method lints pass unchanged.

The hostile-record refusals the record checks give (empty and non-finite intervals, a NaN
coordinate, a circle of no radius) are tested here.

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

* Call pcurve, label and the record checks through solidcore-ops

geom, kernel, smlib and step call solidcore_ops::{pcurve, label, record_check} where they called
solidcraft_geom::brep::{pcurve, label, check}, which moved into solidcore::eval. One
implementation of each remains, in the kernel; geom's brep module no longer declares them.
solidcore-ops becomes a workspace dependency of the four crates (the seam check's public list
already allows it).

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

* Move geom's record agreement check into solidcore::eval (pure git mv)

crates/geom/src/brep/geometry.rs moves unchanged to solidcore/crates/solidcore/src/eval/agreement.rs
(the kernel's eval already has a geometry module). The next commit retypes it.

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

* solidcore::eval::agreement on a plain view; ops reads a record into it

The moved check reads a record through agreement::View (edges, vertices, faces and their uses,
in the kernel's evaluation types) in place of the record's Brep, which the kernel never sees.
solidcore-ops' record_check::agreement converts a record's curves, surfaces and pcurves into the
view and calls it; Mismatch, Problem and Record keep their wording. The pole test is the
pcurve's speed rule, as before.

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

* Check record agreement through solidcore-ops

kernel, smlib, step and geom's tests call solidcore_ops::record_check::agreement where they
called solidcraft_geom::brep::geometry::check, which moved into solidcore::eval::agreement.

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

* Leave kernel/src/tess on geom's pcurve path until the tess move

crates/kernel/src/tess is moving into solidcore-tess in its own pull request, so its three
pcurve::eval call sites are not edited here: geom::brep::pcurve re-exports
solidcore_ops::pcurve::eval for them, and goes when the tess move switches them.

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

* solidcore/bench/step: the STEP reader bake-off harness

fetch.sh builds the corpus at run time from pinned sources (SolidCraft's STEP tests,
dan335/step-conformance, solidcore's fixtures, cadmpeg's fixtures, the NIST PMI and STEP Tools
sets through their SHA-256 pinned fetch scripts), folded by SHA-256; nothing downloaded is
committed. run.py runs readers in the solidcraft-cli eval shape with a timeout and writes
per-file.tsv and summary.md: opened, refused, timed out, all bodies closed, reference volumes
within 0.1% (references.tsv, from formulas), and wall time.

On main cc55f59bf2: 319 files, 195 opened, 135 with every body closed, 23 of 24 reference
volumes, median 0.20 s.

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

* Move SolidCraft's STEP reader into solidcore (pure git mv)

crates/step moves unchanged to solidcore/crates/solidcore-step, and its Part 21 parser to
solidcore-record/src/step/p21.rs, where it takes the place of record's earlier port of this same
file (from a5b71ca2). This commit is the move alone, so the files keep their history and
authors; the next commits make them build in their new places and switch SolidCraft to them.

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

* solidcore-record: the Part 21 parser keeps record's hardening

The moved parser takes back what record's port added in review (#2591): typed errors with the
same messages, entities in id order (BTreeMap), capped parameter and record counts, linear-time
decoding of unterminated escapes, a byte order mark skipped. The file's content is record's as
before the move; its history now runs through SolidCraft's parser.

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

* solidcore-step: the moved reader as a solidcore workspace crate

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

* solidcore-step: evaluate and form records through the kernel

The moved reader called solidcraft-geom's record evaluator, exact forms and vector helpers. It
now calls the kernel's: solidcore::eval's CurveEval, SurfaceEval, face_flux and form, through a
private `kernel` module that converts records with solidcore-convert, and the kernel's vector
measures on the record's Vec3 (`vec`). geom's form was a copy of the kernel's, function for
function; the face box (`extent`) is the kernel's with poles taken exactly. The B-spline cut is
the kernel's (eval::pcurve::segment), the pole test the pcurve's speed rule, both made public
for this. Profiling spans, which were SolidCraft's, are left out.

The crate keeps SolidCraft's formatting (its own rustfmt.toml) and lints under solidcore's hard
rules: no unsafe, unwrap, expect, panic or unchecked indexing (the 33 indexing sites are
rewritten with iterators and checked access, results unchanged). The step-conformance test reads
SolidCraft's tests/step-conformance where the tree has it.

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

* SolidCraft reads and writes STEP through solidcore-step

crates/kernel's STEP import (step_in), STEP export (step_out) and IGES export (iges_out) call
solidcore_step where they called solidcraft_step; crates/step is gone. The Part 21 parse error is
the record's typed error, shown with the same message. solidcore-step joins the crates
SolidCraft may use directly (xtask/solidcore-public.txt) until its build is kernel primitives
under an ops recipe (RFC 0003, 1.6).

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

* solidcore-step: bound faces with the pcurves' pole test (form::extent_by)

The bake-off showed the reader on the kernel's form::extent refusing 7 STEP Tools parts
(304-310 boxy_with_*: "loop encloses no (u, v) area") and reading the large parts 2-8x slower
(rear 37.8 s against 18.4 s): extent's exact collapse test classifies the boundary points
differently from the speed rule the reader's faces were always bounded with, and costs a
collapse search per point (the cost in #13655). extent's body now takes the pole test
(form::extent_by; extent keeps the exact one), and the reader passes the pcurves' speed rule.
On those files the result and time are base's again: boxy_with_cylindricity 174839.178,
nist_ftc_06 3323556.516, rear 14.0 s against 16.3 s.

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

* Kernel rig: drop the gone crates/step paths

crates/step moved to solidcore/crates/solidcore-step, which the shared core (PATHS-ALL:
solidcore/**) already covers: the import and crash areas' PATHS and kernel-rig.yml drop it.

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

* solidcore-step: a seam round the tube for a torus face winding around its axis

A torus face bounded by two circles about its axis (a half torus between its rims, the fillet
round a bore) was refused: "a face winding around a torus ... without a seam edge". It now gets
a seam as a band on a cylinder or sphere does, a tube circle at a vertex's angle, running from
the loop the face lies above up the tube to the other loop, so on the face's side whichever half
of the tube that is. On the bake-off 9 bodies that were open shells close: torus_ring (4934.802,
its reference), turbine, nist_ctc_05 (both editions), nist_ftc_06 rd, nist_ftc_11 (both),
nist_stc_06, nist_stc_10; every body closed goes 135 -> 145, reference volumes 23 -> 24 of 24.

bench/step/run.py takes a probe-shaped reader (LABEL=probe:COMMAND, one JSON line) for a
library without SolidCraft's CLI, and runs every reader on a file together so they meet the same
load.

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

* solidcore-step: short entity names, POLY_LOOP, FACETED_BREP and plain FACE

What solidcore's step-import read and this reader refused:
- Part 21 short names (CRTPNT, FCSRF, PLYLP, ...) read as their long names, in the record's
  parser (step-import's table, #2582). STEP Tools' clip and socks are written that way.
- A POLY_LOOP bound is a closed polygon: one vertex per point and one line edge per side, an edge
  shared by every loop with the same two points.
- A FACETED_BREP is a manifold solid of such faces (calo, cylcub).
- A plain FACE, with no surface, lies on the plane of its loops (Newell's normal, refused where
  the loop leaves the plane by more than the tolerance).

Tests: a faceted tetrahedron closes with 6 shared edges; short names parse to long ones; the
torus band seam from the commit before.

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

* STEP: read topology outside representations, lines by their vertices, inner loops by area

Topology-first reading, as solidcore's step-import and step-brep read files (#2583):
- Topology no representation lists (bare geometry files): crates/kernel reads the file's
  unlisted solids, then its unlisted shells and faces (each face a one-face sheet), through
  solidcore_step::topology::roots, in the file's one representation context. A representation's
  own shell and face items stay out (a presentation's are not bodies).
- A LINE edge whose vertices lie off its LINE (a direction or magnitude written wrong) is the
  segment between its vertices, which the faces it bounds share; other curves still refuse a far
  vertex.
- An inner loop written the way its outer loop runs, on a face whose loops close in (u, v), is
  run the other way.
- More Part 21 short names (product structure, representations, units, FACETED_BREP).

Bake-off (319 files, 220 opened by some reader): opened 195 -> 212, every body closed 135 ->
148, reference volumes 24 of 24 (core 22); median time unchanged (0.123 s -> 0.119 s).
kernel-cases/import 35/35; the kernel's step_in, IGES and lump tests pass.

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

* solidcore-step: a sphere face bounded by vertex loops at its poles is the whole sphere

A face of a sphere whose bounds are vertex loops alone, at its poles (cadmpeg's
ap242_vertex_loop), was refused as "a face without an edge loop". It is the whole sphere: its
loop becomes a meridian from the south pole to the north and back, which the slit completion
keeps as its seam, as a ball bounded by its seam alone already is. Any other face without an
edge loop is still refused. Test: a ball bounded by vertex loops at its poles closes with one
seam edge.

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

* STEP import: a face SMLib refuses is named and left out; a face used twice is one piece

Opening a file never fails over faces that cannot be made: the face is left out, the rest come in
as an open body, and the import's warnings name each face left out with why.
- SMLib's refusal of a record face by its index ("flat arrays: face 90: a NURBS form that is not
  its analytic surface") now names the file's face (face #1202), so it is left out like a face
  the reader refuses. NIST CTC 02 (AP203) opens: 480 of its 487 faces as an open body, the 4
  faces 622 mm off their surfaces and the one SMLib refuses named in the warnings.
- A face the solid's shells use more than once (cadmpeg br02: one face as the outer shell and two
  voids) is read once, and its further uses are named as left out.
- One face read alone is the sheet itself (nothing to stitch).

bench/step/run.py counts the files opened with faces left out, from the import warnings.
Test: a face SMLib refuses by its index is named as the file's face.

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

* STEP import: a solid whose only face cannot be made says which face, why and how far

A file whose one face cannot be made (cadmpeg's pc01, pc03 and tp06 non-coplanar fixtures:
edges 1 to 10 mm off the face's surface) has nothing consistent to open. Its error now reads
"the solid's only face, #30, cannot be made: an edge lies 1 mm off the face's surface" in place of
the count of faces whose pcurves failed. Test: a disk whose circle lies 1.5 mm off its plane.

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

* solidcore-step: an unclamped B-spline curve is read clamped to its domain

A closed curve written as an unclamped B-spline (uniform knots past its ends, its first points
repeated at its end: cadmpeg's N04 recipes, periodic writers) had no closed-form pcurve on a
plane, so its faces were left out: "no exact pcurve of a B-spline on a plane". It is now read
as the same curve over [knots[p], knots[n]], clamped there by exact knot insertion
(eval::pcurve::segment), as every evaluator and pcurve takes a spline. recipe N04a and N04b open
as 3 closed solids each (5000 and 6000 mm³, as solidcore's step-brep reads them).

Test: a disk bounded by an unclamped periodic quadratic reads, its edge clamped to [0, 4].

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

* solidcore-step: a seam round the axis for a torus face winding round its tube

A torus face bounded by two circles round its tube (an elbow's wall between its two ends) was
refused: "a loop winding around a surface more than once or in v". Its loops wind in v, so it
gets a seam the other way about: a circle about the axis at a vertex's angle round the tube,
running from the loop that winds up the tube towards −u (the face lies to the left of its loops)
to the other. band_seam and crossing take the parameter the loops wind in. In STEP Tools'
jack_in_the_box 14 bodies that were open shells close (34 -> 48 of 56).

Test: a quarter elbow closes with a quarter-circle seam about its axis.

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

* Move step-brep's export mapping into step-export, its fixtures into solidcore-step (pure git mv)

brep_to_model (a kernel body as step-import's model, for the writer) moves to step-export, which
RFC 0003 item 1.7 measures against SolidCraft's writer; the two STEP fixtures move to
solidcore-step's tests. The move alone, so their history follows them; the next commit deletes
step-brep's import side.

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

* Delete step-brep's STEP import: one STEP reader, solidcore-step

solidcore had two STEP readers; solidcore-step (SolidCraft's, moved in #13979) now reads what
step-brep's topology-first build read, but the faces it would have to build inconsistently
(#14038's gate). step-brep's import side goes: its build, constructor and UV trims (lib.rs,
constructor.rs, pcurves.rs) and their tests. What it kept for the writer, brep_to_model, is
step-export's now (RFC 0003 item 1.7); its round-trip cases that read back through the deleted
importer go with it. The panic-site list drops step-brep's rows and moves brep_to_model's.

Ported, with their names: the Discord fillet operand reads as one closed solid, and a face no
shell lists is a one-face sheet (crates/kernel step_in tests); rod_doubled_edge names the face
of its folded rim (solidcore-step). The fixtures moved with their history. The reading cases of
step-brep's tests, written by Brennen Herbruck (topology_first, issue_2580_winding_faces,
issue_2581_faceted_brep, issue_2583_topology_first, discord_fillet), are covered by
solidcore-step's tests of seams, tori, faceted solids, unlisted topology and the bake-off.

step-import stays for the goldens and step-export until #14123. Core's code for the 7
jack_in_the_box bodies still open (#14130) is at 2a96a046ca.

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

* STEP: port step-brep's import panic fuzz onto solidcore-step

step-brep's tests/panic_fuzz.rs (#2490, by Brennen Herbruck) went with its import. Its cases
come back with their names:
- panic_fuzz_step_import (solidcore-step): the same mutations (truncation, flipped bytes,
  hostile numbers, broken and cyclic references, dropped and repeated lines, swapped entity
  names, deep nesting) of the Discord fillet and tetra fixtures and of primitives this crate's
  writer writes. Each is parsed, read into records, checked as the kernel takes them, made into
  kernel bodies and written again. Also entity_edit_lands_on_an_entity_name, and a check that
  the written bases read back as solids, so the mutations reach the whole path.
- panic_fuzz_step_flat_decode (step-import, whose flat arrays it decodes).

Run: 3 seeds of 20 000 import cases and 5 000 flat cases, 0 findings, 0 hangs.

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

* solidcore: rustfmt the panic fuzz harness's doc

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

* panic fuzz harness: keep solidcore's formatting where solidcore-step includes it

solidcore-step formats with SolidCraft's rustfmt.toml, which reached the harness through its
#[path] include; the include is #[rustfmt::skip] now, and the harness is solidcore's as on main
but its doc naming the fuzzes that share it.

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

* STEP and pcurves: topology short names; a use's interval refused when empty or not finite

From review:
- The record's parser maps the AP203 short names of the topology too (ADVFC, EDGCRV, EDGLP,
  FCBND, MNSLBR, ORNEDG, VRTPNT): a file written with them had its faces refused.
- solidcore::eval::pcurve refuses a use whose interval is empty, reversed or not finite before
  anything else: a given pcurve was checked against the interval, which a NaN end passed, so
  exact_pcurves returned it as a pcurve. Tests: those intervals are Invalid with and without a
  given pcurve; VRTPNT and FCBND read as their long names.

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

* solidcore-record: short names checked against ISO's short-names file

Every pair of the table is as http://standards.iso.org/iso/10303/tech/short_names/short-names.txt
gives it, the source cited beside it. One was wrong, from step-import's table: CMCRSG is
COMPOSITE_CURVE_SEGMENT, not COMPOSITE_CURVE (CMPCRV, added).

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

* solidcore: retire the step-import crate; one STEP parser, the record's p21

step-import's Part 21 parser is gone: the goldens' SMLib comparison, the
--step-corpus run and step-export read through solidcore-record's p21. The
neutral model moves to solidcore-record::step::model (plain data), the mapper
from p21 to that model moves to step-export (it computes frames and angles, so
it cannot live in the record), and the constructor script and flat arrays move
into the goldens crate, their only users.

p21's Exchange now keeps the HEADER's file name, authors and so on (step-export
reads them back). The brep_constructor golden is unchanged (185 of 185 cases).

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

* Cargo.lock: solidcraft-step is now solidcore-step

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

* io: the model cache key no longer names crates/step

The reader moved to solidcore/crates/solidcore-step, which the cache already keys as part of the solidcore tree.

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

* solidcore: a whole turn a rounding short of a quarter line has its form from it (#12760)

Port of #13535's fix in crates/geom's form to the kernel's form, which the STEP reader now uses: a cylinder, cone, sphere or torus face of one whole turn whose seam reads back a rounding short of a quarter line started its form a quarter early, and a tube read back through STEP measured 14 % over.

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

* pcurve docs: a pcurve without a closed form is refused, not fitted

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

* bench/step: a malformed --reader is an error; a reader's bad JSON is a refusal, not a crash

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

* STEP import: SMLib's first refusal names the file's face, on a turned sheet too

A solid SMLib refused by a face's record index ("flat arrays: face 12") had that index named as a file face only on the reads that left faces out, not the first, so where every face read alone nothing was left out and the faces were stitched instead. solid_part names it, with the face order of the record SMLib was given (a sheet's turned faces reorder it).

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

* bench/step: a reader or corpus file that cannot be run is a crashed row, not a stopped run

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

* STEP import: a styled or layered loose shell or face is still read

roots() took any reference to a shell or face as its topology's, so a bare face a STYLED_ITEM or a layer assignment names was skipped and the file read as having no bodies. A presentation's references (styles, layers, invisibility) no longer count.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 13:27:56 -05:00
austin62jake 2215a9a50a Ruled Surface: input and output schemas for surface.ruled (#13134) (#13135)
surface.ruled gets SurfaceRuledParams in a new schema/surface_ruled.rs and replies
with FeatureResult. mode and corners are keyword types in schema/types.rs with the
handler's messages; edges, direction, face and far get types of their own that keep
its messages (far is read in order, so the first bad item gives the same message).
angle and direction are checked whenever given; which mode needs them stays the
handler's.

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-10 17:16:14 +00:00
bherbruck 182d2c6bd8 solidcore-ops: cancel ops calls with a scoped token (solidcore#3247) (#14660)
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>
2026-11-10 16:59:20 +00:00
bherbruck 126e91f95b STEP: one reader: topology-first reading in solidcore-step, step-brep's import deleted (RFC 0003, 1.6) (#14038)
* Move geom's pcurve, label and check into solidcore::eval (pure git mv)

crates/geom/src/brep/{pcurve,pcurve_tests,label,check}.rs move unchanged to
solidcore/crates/solidcore/src/eval/. The next commits retype them to the kernel's evaluation
types and switch their callers; this commit is the move alone so their history follows them.

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

* solidcore::eval: pcurve, label and check on the kernel's evaluation types

The moved files take eval::geometry's Curve, Curve2, Surface and Frame (the record's kinds,
field for field) and the kernel's evaluator, in place of the record's types and
solidcraft-geom's evaluator. What reads a record as a record leaves: the structural checks
(counts, budgets, a use's interval in its curve's natural range), the edge and face readers
(edge_curve_interval, form_turns on a Face) and the trim request types go to solidcore-ops,
which converts. Results are unchanged: the pcurve keeps its own pole test (the evaluator's
speed rule), knot span search and B-spline cut, not the kernel's collapse; the 80 eval tests
pass, the pcurve suite among them.

Lint-driven rewrites that keep each result: checked indexing, no pre-reserving from input counts,
the loop rotation without a remainder, a clamp that cannot panic on a reversed domain. The
hostile-record cases that depend on the record's checks are kept as no-panic checks here and as
refusals in ops.

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

* solidcore-ops: pcurve, label and record_check over the record

The record's side of the moved kernel routines: each checks the record's structure as the
record's validation does (one budget per face, a use's interval in its curve's natural range, a
given pcurve as a 2D B-spline), converts through solidcore-convert, calls solidcore::eval and
converts back. The trim request types (TrimFace, TrimEdge, TrimUse, Trims) hold records, so they
live here. No arithmetic: ops' float-arithmetic and geometry-method lints pass unchanged.

The hostile-record refusals the record checks give (empty and non-finite intervals, a NaN
coordinate, a circle of no radius) are tested here.

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

* Call pcurve, label and the record checks through solidcore-ops

geom, kernel, smlib and step call solidcore_ops::{pcurve, label, record_check} where they called
solidcraft_geom::brep::{pcurve, label, check}, which moved into solidcore::eval. One
implementation of each remains, in the kernel; geom's brep module no longer declares them.
solidcore-ops becomes a workspace dependency of the four crates (the seam check's public list
already allows it).

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

* Move geom's record agreement check into solidcore::eval (pure git mv)

crates/geom/src/brep/geometry.rs moves unchanged to solidcore/crates/solidcore/src/eval/agreement.rs
(the kernel's eval already has a geometry module). The next commit retypes it.

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

* solidcore::eval::agreement on a plain view; ops reads a record into it

The moved check reads a record through agreement::View (edges, vertices, faces and their uses,
in the kernel's evaluation types) in place of the record's Brep, which the kernel never sees.
solidcore-ops' record_check::agreement converts a record's curves, surfaces and pcurves into the
view and calls it; Mismatch, Problem and Record keep their wording. The pole test is the
pcurve's speed rule, as before.

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

* Check record agreement through solidcore-ops

kernel, smlib, step and geom's tests call solidcore_ops::record_check::agreement where they
called solidcraft_geom::brep::geometry::check, which moved into solidcore::eval::agreement.

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

* Leave kernel/src/tess on geom's pcurve path until the tess move

crates/kernel/src/tess is moving into solidcore-tess in its own pull request, so its three
pcurve::eval call sites are not edited here: geom::brep::pcurve re-exports
solidcore_ops::pcurve::eval for them, and goes when the tess move switches them.

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

* solidcore/bench/step: the STEP reader bake-off harness

fetch.sh builds the corpus at run time from pinned sources (SolidCraft's STEP tests,
dan335/step-conformance, solidcore's fixtures, cadmpeg's fixtures, the NIST PMI and STEP Tools
sets through their SHA-256 pinned fetch scripts), folded by SHA-256; nothing downloaded is
committed. run.py runs readers in the solidcraft-cli eval shape with a timeout and writes
per-file.tsv and summary.md: opened, refused, timed out, all bodies closed, reference volumes
within 0.1% (references.tsv, from formulas), and wall time.

On main cc55f59bf2: 319 files, 195 opened, 135 with every body closed, 23 of 24 reference
volumes, median 0.20 s.

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

* Move SolidCraft's STEP reader into solidcore (pure git mv)

crates/step moves unchanged to solidcore/crates/solidcore-step, and its Part 21 parser to
solidcore-record/src/step/p21.rs, where it takes the place of record's earlier port of this same
file (from a5b71ca2). This commit is the move alone, so the files keep their history and
authors; the next commits make them build in their new places and switch SolidCraft to them.

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

* solidcore-record: the Part 21 parser keeps record's hardening

The moved parser takes back what record's port added in review (#2591): typed errors with the
same messages, entities in id order (BTreeMap), capped parameter and record counts, linear-time
decoding of unterminated escapes, a byte order mark skipped. The file's content is record's as
before the move; its history now runs through SolidCraft's parser.

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

* solidcore-step: the moved reader as a solidcore workspace crate

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

* solidcore-step: evaluate and form records through the kernel

The moved reader called solidcraft-geom's record evaluator, exact forms and vector helpers. It
now calls the kernel's: solidcore::eval's CurveEval, SurfaceEval, face_flux and form, through a
private `kernel` module that converts records with solidcore-convert, and the kernel's vector
measures on the record's Vec3 (`vec`). geom's form was a copy of the kernel's, function for
function; the face box (`extent`) is the kernel's with poles taken exactly. The B-spline cut is
the kernel's (eval::pcurve::segment), the pole test the pcurve's speed rule, both made public
for this. Profiling spans, which were SolidCraft's, are left out.

The crate keeps SolidCraft's formatting (its own rustfmt.toml) and lints under solidcore's hard
rules: no unsafe, unwrap, expect, panic or unchecked indexing (the 33 indexing sites are
rewritten with iterators and checked access, results unchanged). The step-conformance test reads
SolidCraft's tests/step-conformance where the tree has it.

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

* SolidCraft reads and writes STEP through solidcore-step

crates/kernel's STEP import (step_in), STEP export (step_out) and IGES export (iges_out) call
solidcore_step where they called solidcraft_step; crates/step is gone. The Part 21 parse error is
the record's typed error, shown with the same message. solidcore-step joins the crates
SolidCraft may use directly (xtask/solidcore-public.txt) until its build is kernel primitives
under an ops recipe (RFC 0003, 1.6).

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

* solidcore-step: bound faces with the pcurves' pole test (form::extent_by)

The bake-off showed the reader on the kernel's form::extent refusing 7 STEP Tools parts
(304-310 boxy_with_*: "loop encloses no (u, v) area") and reading the large parts 2-8x slower
(rear 37.8 s against 18.4 s): extent's exact collapse test classifies the boundary points
differently from the speed rule the reader's faces were always bounded with, and costs a
collapse search per point (the cost in #13655). extent's body now takes the pole test
(form::extent_by; extent keeps the exact one), and the reader passes the pcurves' speed rule.
On those files the result and time are base's again: boxy_with_cylindricity 174839.178,
nist_ftc_06 3323556.516, rear 14.0 s against 16.3 s.

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

* Kernel rig: drop the gone crates/step paths

crates/step moved to solidcore/crates/solidcore-step, which the shared core (PATHS-ALL:
solidcore/**) already covers: the import and crash areas' PATHS and kernel-rig.yml drop it.

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

* solidcore-step: a seam round the tube for a torus face winding around its axis

A torus face bounded by two circles about its axis (a half torus between its rims, the fillet
round a bore) was refused: "a face winding around a torus ... without a seam edge". It now gets
a seam as a band on a cylinder or sphere does, a tube circle at a vertex's angle, running from
the loop the face lies above up the tube to the other loop, so on the face's side whichever half
of the tube that is. On the bake-off 9 bodies that were open shells close: torus_ring (4934.802,
its reference), turbine, nist_ctc_05 (both editions), nist_ftc_06 rd, nist_ftc_11 (both),
nist_stc_06, nist_stc_10; every body closed goes 135 -> 145, reference volumes 23 -> 24 of 24.

bench/step/run.py takes a probe-shaped reader (LABEL=probe:COMMAND, one JSON line) for a
library without SolidCraft's CLI, and runs every reader on a file together so they meet the same
load.

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

* solidcore-step: short entity names, POLY_LOOP, FACETED_BREP and plain FACE

What solidcore's step-import read and this reader refused:
- Part 21 short names (CRTPNT, FCSRF, PLYLP, ...) read as their long names, in the record's
  parser (step-import's table, #2582). STEP Tools' clip and socks are written that way.
- A POLY_LOOP bound is a closed polygon: one vertex per point and one line edge per side, an edge
  shared by every loop with the same two points.
- A FACETED_BREP is a manifold solid of such faces (calo, cylcub).
- A plain FACE, with no surface, lies on the plane of its loops (Newell's normal, refused where
  the loop leaves the plane by more than the tolerance).

Tests: a faceted tetrahedron closes with 6 shared edges; short names parse to long ones; the
torus band seam from the commit before.

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

* STEP: read topology outside representations, lines by their vertices, inner loops by area

Topology-first reading, as solidcore's step-import and step-brep read files (#2583):
- Topology no representation lists (bare geometry files): crates/kernel reads the file's
  unlisted solids, then its unlisted shells and faces (each face a one-face sheet), through
  solidcore_step::topology::roots, in the file's one representation context. A representation's
  own shell and face items stay out (a presentation's are not bodies).
- A LINE edge whose vertices lie off its LINE (a direction or magnitude written wrong) is the
  segment between its vertices, which the faces it bounds share; other curves still refuse a far
  vertex.
- An inner loop written the way its outer loop runs, on a face whose loops close in (u, v), is
  run the other way.
- More Part 21 short names (product structure, representations, units, FACETED_BREP).

Bake-off (319 files, 220 opened by some reader): opened 195 -> 212, every body closed 135 ->
148, reference volumes 24 of 24 (core 22); median time unchanged (0.123 s -> 0.119 s).
kernel-cases/import 35/35; the kernel's step_in, IGES and lump tests pass.

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

* solidcore-step: a sphere face bounded by vertex loops at its poles is the whole sphere

A face of a sphere whose bounds are vertex loops alone, at its poles (cadmpeg's
ap242_vertex_loop), was refused as "a face without an edge loop". It is the whole sphere: its
loop becomes a meridian from the south pole to the north and back, which the slit completion
keeps as its seam, as a ball bounded by its seam alone already is. Any other face without an
edge loop is still refused. Test: a ball bounded by vertex loops at its poles closes with one
seam edge.

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

* STEP import: a face SMLib refuses is named and left out; a face used twice is one piece

Opening a file never fails over faces that cannot be made: the face is left out, the rest come in
as an open body, and the import's warnings name each face left out with why.
- SMLib's refusal of a record face by its index ("flat arrays: face 90: a NURBS form that is not
  its analytic surface") now names the file's face (face #1202), so it is left out like a face
  the reader refuses. NIST CTC 02 (AP203) opens: 480 of its 487 faces as an open body, the 4
  faces 622 mm off their surfaces and the one SMLib refuses named in the warnings.
- A face the solid's shells use more than once (cadmpeg br02: one face as the outer shell and two
  voids) is read once, and its further uses are named as left out.
- One face read alone is the sheet itself (nothing to stitch).

bench/step/run.py counts the files opened with faces left out, from the import warnings.
Test: a face SMLib refuses by its index is named as the file's face.

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

* STEP import: a solid whose only face cannot be made says which face, why and how far

A file whose one face cannot be made (cadmpeg's pc01, pc03 and tp06 non-coplanar fixtures:
edges 1 to 10 mm off the face's surface) has nothing consistent to open. Its error now reads
"the solid's only face, #30, cannot be made: an edge lies 1 mm off the face's surface" in place of
the count of faces whose pcurves failed. Test: a disk whose circle lies 1.5 mm off its plane.

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

* solidcore-step: an unclamped B-spline curve is read clamped to its domain

A closed curve written as an unclamped B-spline (uniform knots past its ends, its first points
repeated at its end: cadmpeg's N04 recipes, periodic writers) had no closed-form pcurve on a
plane, so its faces were left out: "no exact pcurve of a B-spline on a plane". It is now read
as the same curve over [knots[p], knots[n]], clamped there by exact knot insertion
(eval::pcurve::segment), as every evaluator and pcurve takes a spline. recipe N04a and N04b open
as 3 closed solids each (5000 and 6000 mm³, as solidcore's step-brep reads them).

Test: a disk bounded by an unclamped periodic quadratic reads, its edge clamped to [0, 4].

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

* solidcore-step: a seam round the axis for a torus face winding round its tube

A torus face bounded by two circles round its tube (an elbow's wall between its two ends) was
refused: "a loop winding around a surface more than once or in v". Its loops wind in v, so it
gets a seam the other way about: a circle about the axis at a vertex's angle round the tube,
running from the loop that winds up the tube towards −u (the face lies to the left of its loops)
to the other. band_seam and crossing take the parameter the loops wind in. In STEP Tools'
jack_in_the_box 14 bodies that were open shells close (34 -> 48 of 56).

Test: a quarter elbow closes with a quarter-circle seam about its axis.

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

* Move step-brep's export mapping into step-export, its fixtures into solidcore-step (pure git mv)

brep_to_model (a kernel body as step-import's model, for the writer) moves to step-export, which
RFC 0003 item 1.7 measures against SolidCraft's writer; the two STEP fixtures move to
solidcore-step's tests. The move alone, so their history follows them; the next commit deletes
step-brep's import side.

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

* Delete step-brep's STEP import: one STEP reader, solidcore-step

solidcore had two STEP readers; solidcore-step (SolidCraft's, moved in #13979) now reads what
step-brep's topology-first build read, but the faces it would have to build inconsistently
(#14038's gate). step-brep's import side goes: its build, constructor and UV trims (lib.rs,
constructor.rs, pcurves.rs) and their tests. What it kept for the writer, brep_to_model, is
step-export's now (RFC 0003 item 1.7); its round-trip cases that read back through the deleted
importer go with it. The panic-site list drops step-brep's rows and moves brep_to_model's.

Ported, with their names: the Discord fillet operand reads as one closed solid, and a face no
shell lists is a one-face sheet (crates/kernel step_in tests); rod_doubled_edge names the face
of its folded rim (solidcore-step). The fixtures moved with their history. The reading cases of
step-brep's tests, written by Brennen Herbruck (topology_first, issue_2580_winding_faces,
issue_2581_faceted_brep, issue_2583_topology_first, discord_fillet), are covered by
solidcore-step's tests of seams, tori, faceted solids, unlisted topology and the bake-off.

step-import stays for the goldens and step-export until #14123. Core's code for the 7
jack_in_the_box bodies still open (#14130) is at 2a96a046ca.

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

* STEP: port step-brep's import panic fuzz onto solidcore-step

step-brep's tests/panic_fuzz.rs (#2490, by Brennen Herbruck) went with its import. Its cases
come back with their names:
- panic_fuzz_step_import (solidcore-step): the same mutations (truncation, flipped bytes,
  hostile numbers, broken and cyclic references, dropped and repeated lines, swapped entity
  names, deep nesting) of the Discord fillet and tetra fixtures and of primitives this crate's
  writer writes. Each is parsed, read into records, checked as the kernel takes them, made into
  kernel bodies and written again. Also entity_edit_lands_on_an_entity_name, and a check that
  the written bases read back as solids, so the mutations reach the whole path.
- panic_fuzz_step_flat_decode (step-import, whose flat arrays it decodes).

Run: 3 seeds of 20 000 import cases and 5 000 flat cases, 0 findings, 0 hangs.

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

* solidcore: rustfmt the panic fuzz harness's doc

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

* panic fuzz harness: keep solidcore's formatting where solidcore-step includes it

solidcore-step formats with SolidCraft's rustfmt.toml, which reached the harness through its
#[path] include; the include is #[rustfmt::skip] now, and the harness is solidcore's as on main
but its doc naming the fuzzes that share it.

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

* STEP and pcurves: topology short names; a use's interval refused when empty or not finite

From review:
- The record's parser maps the AP203 short names of the topology too (ADVFC, EDGCRV, EDGLP,
  FCBND, MNSLBR, ORNEDG, VRTPNT): a file written with them had its faces refused.
- solidcore::eval::pcurve refuses a use whose interval is empty, reversed or not finite before
  anything else: a given pcurve was checked against the interval, which a NaN end passed, so
  exact_pcurves returned it as a pcurve. Tests: those intervals are Invalid with and without a
  given pcurve; VRTPNT and FCBND read as their long names.

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

* solidcore-record: short names checked against ISO's short-names file

Every pair of the table is as http://standards.iso.org/iso/10303/tech/short_names/short-names.txt
gives it, the source cited beside it. One was wrong, from step-import's table: CMCRSG is
COMPOSITE_CURVE_SEGMENT, not COMPOSITE_CURVE (CMPCRV, added).

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

* Cargo.lock: solidcraft-step is now solidcore-step

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

* io: the model cache key no longer names crates/step

The reader moved to solidcore/crates/solidcore-step, which the cache already keys as part of the solidcore tree.

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

* solidcore: a whole turn a rounding short of a quarter line has its form from it (#12760)

Port of #13535's fix in crates/geom's form to the kernel's form, which the STEP reader now uses: a cylinder, cone, sphere or torus face of one whole turn whose seam reads back a rounding short of a quarter line started its form a quarter early, and a tube read back through STEP measured 14 % over.

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

* pcurve docs: a pcurve without a closed form is refused, not fitted

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

* bench/step: a malformed --reader is an error; a reader's bad JSON is a refusal, not a crash

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

* STEP import: SMLib's first refusal names the file's face, on a turned sheet too

A solid SMLib refused by a face's record index ("flat arrays: face 12") had that index named as a file face only on the reads that left faces out, not the first, so where every face read alone nothing was left out and the faces were stitched instead. solid_part names it, with the face order of the record SMLib was given (a sheet's turned faces reorder it).

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

* bench/step: a reader or corpus file that cannot be run is a crashed row, not a stopped run

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 12:21:45 -05:00
bherbruck 54e5190955 Overlapping cylinders' round builds again: the rail end starts from the round's own section (#13538) (#13898)
* claim: #13537 and #13538, rim cut and overlapping-cylinder fillet

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

* blend: a rail's end at a side edge starts from the round's corner section (#13538)

A round of either crossing edge of two crossing cylinders (SolidCraft's
fillet_the_edges_between_two_overlapping_cylinders, R 13) was refused with
status 1001: CalcFilletIntSideFace found no intersection of the round with
the cap. With the walls' offsets continued as themselves (whole circles),
the faces hold two balls at each cap edge, the round of each crossing.
CalcRailIntEdgeuse's first guess on the small wall's cap edge, upstream's
radius fraction along the edge, solved the rail end onto the other
crossing's ball (contact at (4.02, 20.35) instead of (18.98, 8.37)), so the
cap curve between the two rail ends did not exist.

The first guess is now where the ball's section through the filleted
edge's end touches the side edge: the section is solved from the edge
point on both faces, so it is the ball of this round (the start of a
rolling-ball march; Choi and Ju 1989). The section solve is
ball_fit::edge_section, shared with ball_fits_its_faces. Upstream's other
guesses follow unchanged.

This also covers #3045 (the plate's arc end): its test passes with the
face-domain clamp on the drop removed, and that clamp is what broke this
case at a445bdaac6..c0d3cc0124 (b11c5554b3): it moved the drop's guess to
the face's end, from where the solve also found the other ball. One
construction, so the clamp goes.

Goldens, blend.fillet_executive_p3, 12 cases moved (every other blend
unit unchanged):
- radius-supported-at-limit-accepted: built (volume 2199.999313) ->
  refused 1001 (input kept, 2200). The rail end moves one ulp
  (29.999920000080003 -> 29.99992000008); the case sits at the radius
  limit. The old body was off its closed form (2200 - 4e-8) by 6.9e-4.
- stubs4-coons_01_03_29-e0-r0.5: refused 1001 -> built (8 vertices, 12
  edges, 5 faces; mass status 1007 before and after: not a solid).
- ten others: same status and counts; mass properties move at most
  4e-9 relative; in four (issue_2176 tab side, issue-880 half-arc 1-1 and
  1.4437, stubs4-coons e3) the curves' and surfaces' digests change too
  (the rail ends' parameters converge to other roundings).

New test: solidcore-ops round_between_crossing_cylinders (R 6 at each
crossing, both, and R 13), closed-form volumes; fails without this change
(R 13: Status(1001)).

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

* blend: a variable round too large for a box edge is refused with the radius that fits

a_failed_variable_radius_fillet_keeps_its_error_and_a_current_body (radius
0.5 to 30 along an edge of the 10 x 20 x 30 box) was refused with a bare
status 1001; with the rail end continued from the corner section it is
refused as FilletRefused, same status, naming radius 10 as the largest
that builds (the 10 wide side). The body is kept as before.

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

* Known failing: the overlapping cylinders' round builds again (#13538)

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

* blend: a rail end on an offset continued as itself is solved on its closed turn (#13538)

solidcore's issue_1945_rail_uv_trim top_edge_round_is_valid failed after
the rail end started from the round's corner section: the round of the
bar's top edge, ending on the boss's arc round, was refused ("a round's
rail leaves the face it rolls on into the body's material"). The rail
end's solve found the right point (side-edge parameter 0.99805 as before)
but named it on the arc round's offset one generator turn away: v
-3.3445 instead of 0.6555, which the face's trim reads as off the face.

LoadInitialValues solved the other surface on its base domain grown by
SetupOffsetExtension, several turns of a surface that continues as
itself. The extension names each point once inside its closed turn: round
the axis it wraps past the turn (WR-650), and along a circle generator (a
torus's, a revolved circle's) the closed circle holds every point. Those
directions are now solved there (closed_turn): round the axis periodic on
the turn, along the generator bounded half the gap past either end (its
extension spans are quarter turns, the base's need not be, so it is not
wrapped).

Every blend unit's goldens are unchanged from the previous commit's.

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

* blend: drop the import main's definition change left unused (#13538)

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

* Merge origin/main; goldens: the round at the minimum radius is known failing (tangency-labs/solidcore#3245)

radius-supported-at-limit-accepted depends on rounding: the round and its
end arcs lie r(sqrt 2 - 1) = 3.3e-5 from the corner they replace, inside
the 5e-5 of the corner vertex's and the fillet's tolerances, so whether the
end arcs are imprinted flips with ulp-level changes (it came out
[1001,8,12,6] on the merge with main where it was blessed [1001,12,18,8]).
It is listed in known_failing.json, without a golden line, until #3245.
PASS blend.fillet_executive_p3 cases=798 changed=0 known_failing=5.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 12:05:07 -05:00
bherbruck 6ca950789c Extruding a long outline: its end faces build about 1.5× faster (a face edit no one listens to skips the edge-use walk) (#14329) (#14454)
* solidcore: building a cap face of a long outline stops walking the whole face for each edge (#14329)

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

* solidcore: an unheard face edit skips walking the face's edge uses (#14329)

Each edge added to a face notifies the face (PostEdit), which walked every
edge use and vertex use of the face. An edge use's PostEdit changes nothing
and only a context notify callback can see it, so without one the edge-use
walk is skipped. Building the cap of an outline of N lines was O(N^2) in
that walk; the cap of a 12,000-line outline builds in 10.4 s instead of
15.2 s, with the same face.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 16:40:56 +00:00
Andrzej Błasik 89e3cb13b1 Faster rebuilds: extrusions, revolutions and sweeps name their new faces from exact geometry instead of meshing the body (#14646)
Each new face of an extrusion, revolution, sweep, loft or screw sweep is
named (start cap, end cap, or the wall swept from a sketch segment) so later
features find it again. The kernel meshed every new body to work the names
out; on extrusion-heavy designs that mesh was about a third of the rebuild.

The names now come from the exact body: a cap is a face on the start or end
plane, the start one where a profile point lies on it; a wall comes from each
segment whose middle lies on its edge in the sketch plane, or whose point
half-way along lies on the face (solidcore-ops `Body::face_distances`, the
kernel's trimmed face closest point). Edges and faces SMLib built to its own
tolerance are matched to it. The smlib_sweeps roles line leaves
xtask/mesh-decisions.txt (RFC 0003, item 3.3).
2026-11-10 16:37:51 +00:00
Ben Davis 87400c80ef Threads: select a whole thread flank with one click (#519) (#11737)
* claim: #519 thread flank continuity

* Threads: keep each flank as one curved face

* Threads: preserve saved flank faces when rebuilding old designs

Keep historical thread faces and construction order when a saved feature
has no continuous_flanks field. New threads use continuous flanks by
default, with a preference for the historical layout. Redefine and dialog
reopen retain the feature's saved choice. Generic coils and hole features
keep their existing construction.

Captured pre-change flank names and points remain on the original faces,
including both chamfered ends. All 49 engine thread tests, the UI dialog
regression, and both SMLib guard checks pass. Independent Astra review
found no correctness findings. Required cargo xtask ci passed repository
validators and strict workspace Clippy; its full native test build was
stopped because the available disk cannot hold the remaining binaries.
All 14 selected crates pass cargo xtask wasm. The sweep group retains one
documented baseline cylinder-section failure.

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

* Threads: a new Thread dialog leaves the default flank layout out of the command

The Thread dialog put `continuous_flanks` into every new command, so the
untouched dialog no longer sent only its face and
`dialogs_thread::tests::every_option_reaches_the_command` failed. Continuous
flanks are the command's default, so the dialog now sends the option only when
the `thread_continuous_flanks` preference asks for the historical half-turn
faces. Editing a saved thread still sends its saved layout.

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

* Threads: continuous flanks are not a preference; record the ladder's thread cases

New modeled threads always get continuous flank faces. The
`thread_continuous_flanks` preference, its Design page row, its message
catalog strings and its control-protocol entry are removed. A saved
thread still keeps its own `continuous_flanks` field, so an old design
reopens and rebuilds with its half-turn faces, and editing it keeps them.
The dialog test now checks that a new Thread dialog leaves the field out
and that reopening a saved thread keeps its layout.

Kernel ladder: 29 cases make fewer faces, edges and vertices because each
flank is one face: 06-threads/001 to 017 and 019 to 028, and
11-cumulative-parts/003 and 004. Their volume and area match the old
golden to 4e-9 relative and every check passes. Run with
`continuous_flanks: false`, all 29 make exactly the old counts. Only those
29 entries change; the ladder's order and report stay as on main.

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

---------

Co-authored-by: Benjamin Davis <benjamindavis@Benjamins-Mac-mini.local>
Co-authored-by: OpenAI Codex <noreply@openai.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 16:16:55 +00:00
austin62jake d4e8c0b034 sweep: refuse a pipe whose radius reaches past a bend's centre (part of #13766) (#14640)
* sweep: test that a pipe reaching past a bend's centre is refused (#13766)

A pipe whose radius reaches the centre of a bend in its path folds its wall
through itself on the inside of that bend. The test sweeps pipes along an
ellipse with semi-axes 2 and 4 (tightest bend radius 1) and a circle of
radius 2, and expects radii 1% or more past the bend refused as InvalidInput
and radii 1% within it built as manifold solids.

On main 3e2cc277e it fails at radius 1.01: create_pipe_sweep builds the pipe.

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

* sweep: refuse a pipe whose radius reaches past a bend's centre (#13766)

create_pipe_sweep checked only that the radius is above zero, as upstream
does (SmApiBrep.cpp:1385). Where the path bends with a radius smaller than
the pipe's, the circles of neighbouring sections cross on the inside of the
bend and the swept wall folds through itself; the pipe surface is singular
wherever the radius reaches 1/kappa of the path (Maekawa, Patrikalakis,
Sakkalis and Yu, "Analysis and applications of pipe surfaces", CAGD 15(5),
1998). The panic fuzz's face_ops case 3447330831849305406 built such a
pipe (radius 5.05 along an ellipse whose tightest bend is 1.62), and its
silhouette ran past the hang limit.

path_bends_within measures the path's curvature at 16 samples per knot span,
both span ends included, and create_pipe_sweep refuses the pipe as
InvalidInput when a sample's bend radius is at most the pipe's. A refusal is
proved by the sample; a bend between two samples is missed and that pipe is
built as before. The upstream defect is recorded as WR-774.

general_path_sweep's ledger test swept its 0.75 pipe along the balance
wire's points fitted closed, which bends with radius 0.041 near its start
and 0.045 near its end. Fitted open, as the wire runs, the spline still
bends with radius 0.564 near its far end, so that case's pipe is now 0.5.
Its walls are bare B-spline faces as before.

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

---------

Co-authored-by: austin62jake <340374338+austin62jake@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 16:16:19 +00:00
yoyomaimai 595bc8069c Update zh-TW.json (#14641)
I've updated it:
"Counterbore Ø":
"Ø " was mistakenly typed as "O" by me.
2026-11-10 16:08:35 +00:00
PapaEcho 44fe6a0f24 Navigation: zoom horizontally with Maya Alt-right drag (#10973) (#10992)
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 16:03:43 +00:00
dudedagger42 12a9a7f0f9 Preferences: turn the control channel on and off at once, with a port (#5736) (#13981)
* Preferences: turn the control channel on and off at once, with a port (#5736)

"Let AI agents connect" moves to Preferences > Assistant > Agents through
MCP and gets a Port field (default 27878, as before). Turning it on or
off, or changing the port, now takes effect without a restart: the
listener closes with its connections, token file and discovery file, and
opens again where the setting asks. A port another program holds shows a
message under the field. --control / SOLIDCRAFT_CONTROL_PORT still win for
the run; the setting then waits for the next launch.

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

* Preferences: "Let AI agents connect" is off by default

A new install no longer opens the control channel at launch: the switch
in Preferences > Assistant > Agents through MCP starts off, and turning it
on opens the channel at once, on its port. Preferences saved with it on
keep it on; --control and SOLIDCRAFT_CONTROL_PORT still open it for a run.
The docs and tests follow the new default.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: dudedagger42 <dudedagger42@users.noreply.github.com>
2026-11-10 15:59:46 +00:00
bherbruck 3749c14ad1 sweep: a loft guided by rails, its guided edges certified within the caller's tolerance (tangency-labs/solidcore#3140) (#14204)
Rebased onto main after tangency-labs/solidcraft#13688 merged: its own change from 13688's last head (bdcebb49), three-way per file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 15:36:50 +00:00
rugbymauri 9c4970d2b2 Sketch: constraint glyphs can be made larger and their hover label stays clear of the cursor (#10794) (#11032)
* claim: #10794

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

* Sketch: constraint glyphs can be drawn larger, and their label stays clear of the cursor

Constraint glyphs are a fixed 15-pixel badge with a small letter or
icon, so zooming never made them easier to read, and the label that
names a hovered glyph sat 12 px below and right of the pointer, where
the cursor arrow covers it.

Preferences > Sketch gets "Constraint glyph size" (constraint_glyph_scale,
100% to 300%): badge, letter, icon, spacing and the hover reach grow
together. The default of 100% is the size they always had. The hover
label now goes above the glyph and the pointer, or to their left near
the top of the view, and only below them past the cursor's reach when
neither fits.

Fixes #10794

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

---------

Signed-off-by: Maurizio Monticelli <maurizio@whatwedo.ch>
2026-11-10 15:36:10 +00:00
bherbruck 8d13350c30 issue_3093: require the defeated body's volume to be proved positive (#14623)
volume_of() accepted Positive, Negative and Undecided enclosures alike, so a sign failure would pass. positive() panics unless soundness proves the volume Positive; the overlap and containment asserts are unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 15:20:55 +00:00
bherbruck 995b80a78f sweep: a cornered sweep built piece by piece, meeting at its corners' mitres (#3191) (#14621)
sweep::cornered::cornered_sweep sweeps a closed planar profile along a path
with corners. Each piece is swept on its own (lines as degree-1 skins between
shared sections, arcs by the rotational sweep) and sewn as sweep::segments
sews; a corner's mitre section is asked once and shared by the runs it joins.
Twist, rails, helices, an arc with an end scale or ending at a corner, and a
closed path with an end scale or closing at a corner are refused by name.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 15:20:52 +00:00
ancient_astronaut_theorist 7d271b3252 Move: Ctrl/Shift+click adds more bodies after Move has gone on to From, Point or Axis (#14205) (#14218)
* claim: #14205

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

* Move: Ctrl/Shift+click adds bodies after the dialog goes on (#14205)

In Point to Point, Point to Position and Rotate, a body pick moves the
dialog on to From, Point or Axis (#6170), so each further body needed a
click back into Bodies. A Ctrl or Shift click on a body now adds it to
Bodies (or takes it out if picked already), as Ctrl/Shift+click adds to
the selection in the view, and the dialog stays on the input it was on.
Plain clicks behave as before.

Fixes #14205

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

* Move: add-only Shift keeps a picked body; Bodies hold while OK computes

Review follow-up for #14205. With Shift adding only (Preferences), a
Shift click on a body Move has picked already now keeps it in Bodies;
Ctrl still takes it out. While OK computes, a Ctrl or Shift click leaves
Bodies as it is, as dialog_pick holds the other inputs. The test setup
moves to a helper shared by the three tests.

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 15:19:06 +00:00
rugbymauri a3dcc9ca55 Agent chat: Ctrl+A selects the whole conversation, not one Markdown block (#10676) (#11033)
* claim: #10676

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

* Agent chat: Ctrl+A in the transcript selects the whole conversation

Each Markdown block of a reply is a label of its own, and egui's label
selection cannot be set from code, so Ctrl+A only selected the block the
selection was in. While the chat owns the keyboard and no text box has
focus, Ctrl+A (Cmd+A) now selects every prompt and answer of the retained
conversation: their text is drawn over the selection colour, and Copy
puts them on the clipboard in order, as they read, each under who wrote
it. Thoughts, tool cards, plans, notices and the composer are left out.
Esc or a click ends it; a focused composer keeps its own Select All.
Docked, floating and popped out.

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

* Agent chat: outline the select-all highlight so it shows on a prompt bubble

The selection fill is the same colour as the user bubble, so in the dark
theme a selected prompt looked unselected. Each highlighted block now also
gets a 1 px outline in the selection stroke colour.

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

---------

Signed-off-by: Maurizio Monticelli <maurizio@whatwedo.ch>
2026-11-10 15:16:31 +00:00
jyviko 2874e782fe UI kit: the app bar and built-in themes work under another host's name and tabs (#14612)
* App bar: Home follows the app's name as drawn

Home sat at a fixed 128 pt from the bar's edge, sized for "SolidCraft", so
a host with a longer name drew it under Home. The bar now lays the name
out in the font it is drawn in (semibold at the heading size) and places
Home a 6 pt gap after it, with 128 as the least place.

128 came from SolidCraft's name: it starts at 36 and is 65.1 pt wide in
Plex Sans semibold 14, ending at 101.1; Home's button starts at 108, 6.9
after it. The gap is rounded down to 6 so SolidCraft's bar does not move
(36 + 65.1 + 6 + 20 = 127.1 < 128).

Tests: app_bar::tests::home_follows_a_long_app_name (a long name's text
ends before Home; on the old code it ended at 249 with Home at 108) and
titlebar::tests::home_keeps_its_place_after_solidcrafts_name (SolidCraft's
Home stays 88 pt right of the name's start, as before). No expected
values changed.

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

* Theme files: built-in themes load under any host's tab table

A theme's `tabs` names tabs of the host's tab table, and reading one that
named a tab the table lacked failed the whole file. The look's built-in
themes key Onyx's per-tab surfaces by SolidCraft's tab names, and one
failed built-in empties the list, so under another host's tab table
every built-in theme but Light and Dark was missing.

Design: the strictness now depends on who wrote the file. Built-ins
(read_builtin) still check every `tabs` entry's surfaces and colours, but
leave out an entry for a tab the host lacks. Files a user imports, saves
or loads from a profile (from_value, parse, set_tabs, resolve_for_tab)
are read exactly as before, so SolidCraft's errors for a misspelt or
extra tab are unchanged and saved themes load as before.

Keying the tints by accent ramp instead would let another host's tabs
pick up Onyx's surfaces, but it changes the theme file format and needs
migration of saved and imported files; this change needs neither. Its
cost: a host whose tab names differ gets no per-tab surfaces from Onyx.

Tests: theme_files::tests::a_builtin_keeps_only_the_hosts_tabs_and_a_
users_file_is_still_refused (old code: "theme tinted: `tabs` has too many
entries") and look's every_builtin_theme_loads_under_a_host_with_other_tabs
(old code: read_builtin failed). The look tests' host tab table changes
from SolidCraft's seven tabs, which only let the built-ins load, to three
tabs it does not have (DRAW, SHAPE, PAINT); install_gives_the_kit_...
asserted 25 built-ins against it and failed (0) on the old code.

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

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
2026-11-10 15:07:47 +00:00
bherbruck d8113d096c STEP reader: move SolidCraft's reader into solidcore with its history (RFC 0003, 1.5) (#13979)
* Move geom's pcurve, label and check into solidcore::eval (pure git mv)

crates/geom/src/brep/{pcurve,pcurve_tests,label,check}.rs move unchanged to
solidcore/crates/solidcore/src/eval/. The next commits retype them to the kernel's evaluation
types and switch their callers; this commit is the move alone so their history follows them.

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

* solidcore::eval: pcurve, label and check on the kernel's evaluation types

The moved files take eval::geometry's Curve, Curve2, Surface and Frame (the record's kinds,
field for field) and the kernel's evaluator, in place of the record's types and
solidcraft-geom's evaluator. What reads a record as a record leaves: the structural checks
(counts, budgets, a use's interval in its curve's natural range), the edge and face readers
(edge_curve_interval, form_turns on a Face) and the trim request types go to solidcore-ops,
which converts. Results are unchanged: the pcurve keeps its own pole test (the evaluator's
speed rule), knot span search and B-spline cut, not the kernel's collapse; the 80 eval tests
pass, the pcurve suite among them.

Lint-driven rewrites that keep each result: checked indexing, no pre-reserving from input counts,
the loop rotation without a remainder, a clamp that cannot panic on a reversed domain. The
hostile-record cases that depend on the record's checks are kept as no-panic checks here and as
refusals in ops.

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

* solidcore-ops: pcurve, label and record_check over the record

The record's side of the moved kernel routines: each checks the record's structure as the
record's validation does (one budget per face, a use's interval in its curve's natural range, a
given pcurve as a 2D B-spline), converts through solidcore-convert, calls solidcore::eval and
converts back. The trim request types (TrimFace, TrimEdge, TrimUse, Trims) hold records, so they
live here. No arithmetic: ops' float-arithmetic and geometry-method lints pass unchanged.

The hostile-record refusals the record checks give (empty and non-finite intervals, a NaN
coordinate, a circle of no radius) are tested here.

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

* Call pcurve, label and the record checks through solidcore-ops

geom, kernel, smlib and step call solidcore_ops::{pcurve, label, record_check} where they called
solidcraft_geom::brep::{pcurve, label, check}, which moved into solidcore::eval. One
implementation of each remains, in the kernel; geom's brep module no longer declares them.
solidcore-ops becomes a workspace dependency of the four crates (the seam check's public list
already allows it).

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

* Move geom's record agreement check into solidcore::eval (pure git mv)

crates/geom/src/brep/geometry.rs moves unchanged to solidcore/crates/solidcore/src/eval/agreement.rs
(the kernel's eval already has a geometry module). The next commit retypes it.

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

* solidcore::eval::agreement on a plain view; ops reads a record into it

The moved check reads a record through agreement::View (edges, vertices, faces and their uses,
in the kernel's evaluation types) in place of the record's Brep, which the kernel never sees.
solidcore-ops' record_check::agreement converts a record's curves, surfaces and pcurves into the
view and calls it; Mismatch, Problem and Record keep their wording. The pole test is the
pcurve's speed rule, as before.

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

* Check record agreement through solidcore-ops

kernel, smlib, step and geom's tests call solidcore_ops::record_check::agreement where they
called solidcraft_geom::brep::geometry::check, which moved into solidcore::eval::agreement.

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

* Leave kernel/src/tess on geom's pcurve path until the tess move

crates/kernel/src/tess is moving into solidcore-tess in its own pull request, so its three
pcurve::eval call sites are not edited here: geom::brep::pcurve re-exports
solidcore_ops::pcurve::eval for them, and goes when the tess move switches them.

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

* solidcore/bench/step: the STEP reader bake-off harness

fetch.sh builds the corpus at run time from pinned sources (SolidCraft's STEP tests,
dan335/step-conformance, solidcore's fixtures, cadmpeg's fixtures, the NIST PMI and STEP Tools
sets through their SHA-256 pinned fetch scripts), folded by SHA-256; nothing downloaded is
committed. run.py runs readers in the solidcraft-cli eval shape with a timeout and writes
per-file.tsv and summary.md: opened, refused, timed out, all bodies closed, reference volumes
within 0.1% (references.tsv, from formulas), and wall time.

On main cc55f59bf2: 319 files, 195 opened, 135 with every body closed, 23 of 24 reference
volumes, median 0.20 s.

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

* Move SolidCraft's STEP reader into solidcore (pure git mv)

crates/step moves unchanged to solidcore/crates/solidcore-step, and its Part 21 parser to
solidcore-record/src/step/p21.rs, where it takes the place of record's earlier port of this same
file (from a5b71ca2). This commit is the move alone, so the files keep their history and
authors; the next commits make them build in their new places and switch SolidCraft to them.

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

* solidcore-record: the Part 21 parser keeps record's hardening

The moved parser takes back what record's port added in review (#2591): typed errors with the
same messages, entities in id order (BTreeMap), capped parameter and record counts, linear-time
decoding of unterminated escapes, a byte order mark skipped. The file's content is record's as
before the move; its history now runs through SolidCraft's parser.

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

* solidcore-step: the moved reader as a solidcore workspace crate

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

* solidcore-step: evaluate and form records through the kernel

The moved reader called solidcraft-geom's record evaluator, exact forms and vector helpers. It
now calls the kernel's: solidcore::eval's CurveEval, SurfaceEval, face_flux and form, through a
private `kernel` module that converts records with solidcore-convert, and the kernel's vector
measures on the record's Vec3 (`vec`). geom's form was a copy of the kernel's, function for
function; the face box (`extent`) is the kernel's with poles taken exactly. The B-spline cut is
the kernel's (eval::pcurve::segment), the pole test the pcurve's speed rule, both made public
for this. Profiling spans, which were SolidCraft's, are left out.

The crate keeps SolidCraft's formatting (its own rustfmt.toml) and lints under solidcore's hard
rules: no unsafe, unwrap, expect, panic or unchecked indexing (the 33 indexing sites are
rewritten with iterators and checked access, results unchanged). The step-conformance test reads
SolidCraft's tests/step-conformance where the tree has it.

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

* SolidCraft reads and writes STEP through solidcore-step

crates/kernel's STEP import (step_in), STEP export (step_out) and IGES export (iges_out) call
solidcore_step where they called solidcraft_step; crates/step is gone. The Part 21 parse error is
the record's typed error, shown with the same message. solidcore-step joins the crates
SolidCraft may use directly (xtask/solidcore-public.txt) until its build is kernel primitives
under an ops recipe (RFC 0003, 1.6).

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

* solidcore-step: bound faces with the pcurves' pole test (form::extent_by)

The bake-off showed the reader on the kernel's form::extent refusing 7 STEP Tools parts
(304-310 boxy_with_*: "loop encloses no (u, v) area") and reading the large parts 2-8x slower
(rear 37.8 s against 18.4 s): extent's exact collapse test classifies the boundary points
differently from the speed rule the reader's faces were always bounded with, and costs a
collapse search per point (the cost in #13655). extent's body now takes the pole test
(form::extent_by; extent keeps the exact one), and the reader passes the pcurves' speed rule.
On those files the result and time are base's again: boxy_with_cylindricity 174839.178,
nist_ftc_06 3323556.516, rear 14.0 s against 16.3 s.

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

* Kernel rig: drop the gone crates/step paths

crates/step moved to solidcore/crates/solidcore-step, which the shared core (PATHS-ALL:
solidcore/**) already covers: the import and crash areas' PATHS and kernel-rig.yml drop it.

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

* Cargo.lock: solidcraft-step is now solidcore-step

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

* io: the model cache key no longer names crates/step

The reader moved to solidcore/crates/solidcore-step, which the cache already keys as part of the solidcore tree.

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

* solidcore: a whole turn a rounding short of a quarter line has its form from it (#12760)

Port of #13535's fix in crates/geom's form to the kernel's form, which the STEP reader now uses: a cylinder, cone, sphere or torus face of one whole turn whose seam reads back a rounding short of a quarter line started its form a quarter early, and a tube read back through STEP measured 14 % over.

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

* pcurve docs: a pcurve without a closed form is refused, not fitted

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

* bench/step: a malformed --reader is an error; a reader's bad JSON is a refusal, not a crash

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 15:07:44 +00:00
bherbruck 023b55277f Booleans skip the curve intersection of straight edges whose boxes are apart: same results, joins of prisms about 1.5× faster (#14329) (#14585)
* solidcore: a boolean skips curve intersections of proto edges whose boxes are apart (#14329)

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

* solidcore: a boolean skips curve intersections of proto edges whose boxes are apart (#14329)

A boolean splits its proto edges where they cross by intersecting every pair
of them. Two lines go to IntersectWithLine, whose first test answers "no
solution" when the segments' boxes (one grown by the tolerance) are apart.
That test now runs before the intersection's setup, through the same helper,
so the pairs it rules out cost a box test instead of an intersection. With
two coincident outlines of thousands of lines nearly every pair is such a
pair.

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

* solidcore: clippy for the proto edge box test, and the counts it saves in its test (#14329)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 14:59:48 +00:00
0rdnungsamt 3e2cc277ee About: the licences under Licences are indented so they read as its contents (#14442)
* claim: #14388 (indent the licences under Licences in About)

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

* About: indent the licences under Licences so they read as its contents

NOTICE, MIT, Apache 2.0 and Third-party licences sat at the same indent as
Licences, so after opening it they looked like more sections beside it.
They are now indented by the theme's 14 pt spacing, as the Model
Parameters groups are.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 14:48:01 +00:00
PapaEcho 5f46248771 Construct: group tools by planes, axes and points (#10149) (#10236)
* claim: #10149

* fix: group Construct tools by entity type (#10149)

* fix: make Construct menu grouping opt-in
2026-11-10 14:45:36 +00:00
SS-EN 176ba5464f Engine: selection.get reads the selection, resolved (read-only) (#3760)
An agent driving the app through MCP asked for selection.get and got
"unknown command": the selection was only reachable inside the large
document.inspect output (raw) or the UI-only ui.selection control method.

selection.get returns {count, items}: each item as select.set takes it
(so it can be fed back), with kind and, unless detail: false, what it
resolves to: faces' persistent name, surface (plane normal / cylinder
axis+radius / cone / sphere / torus) and area; edges' persistent name,
curve (line/circle/arc), length, ends and centre/radius; bodies' volume,
creating feature and visibility; component and occurrence path in an
assembly; sketch entities' sketch and entity kind. It changes nothing
(no revision bump, no undo step) and the agent chat gate classes it as
read-only. Documented in docs/control-protocol.md; tests sit with the
other selection commands' in cmd/edit_tests.rs.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 14:38:08 +00:00
671 changed files with 31465 additions and 14787 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"
-2
View File
@@ -30,7 +30,6 @@ on:
- "crates/kernel/**"
- "crates/geom/**"
- "crates/smlib/**"
- "crates/step/**"
- "crates/io/src/**"
- "crates/doc/src/**"
- "crates/engine/src/**"
@@ -51,7 +50,6 @@ on:
- "crates/kernel/**"
- "crates/geom/**"
- "crates/smlib/**"
- "crates/step/**"
- "crates/io/src/**"
- "crates/doc/src/**"
- "crates/engine/src/**"
Generated
+44 -44
View File
@@ -4234,6 +4234,16 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "solidcore-step"
version = "0.1.0"
dependencies = [
"solidcore",
"solidcore-convert",
"solidcore-ops",
"solidcore-record",
]
[[package]]
name = "solidcore-tess"
version = "0.1.0"
@@ -4249,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",
@@ -4292,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",
@@ -4312,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",
@@ -4325,7 +4335,7 @@ dependencies = [
[[package]]
name = "solidcraft-control-egui"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"egui",
"image",
@@ -4335,7 +4345,7 @@ dependencies = [
[[package]]
name = "solidcraft-derive"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"proc-macro2",
"quote",
@@ -4344,7 +4354,7 @@ dependencies = [
[[package]]
name = "solidcraft-doc"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"log",
"serde",
@@ -4366,7 +4376,7 @@ dependencies = [
[[package]]
name = "solidcraft-dynamics"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"serde",
"serde_json",
@@ -4377,7 +4387,7 @@ dependencies = [
[[package]]
name = "solidcraft-engine"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"image",
"log",
@@ -4403,7 +4413,7 @@ dependencies = [
[[package]]
name = "solidcraft-geom"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"serde",
"serde_json",
@@ -4416,7 +4426,7 @@ dependencies = [
[[package]]
name = "solidcraft-gpu"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"image",
"log",
@@ -4441,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",
@@ -4469,7 +4479,7 @@ dependencies = [
[[package]]
name = "solidcraft-kernel"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"log",
"proptest",
@@ -4478,11 +4488,11 @@ dependencies = [
"serde_json",
"solidcore-ops",
"solidcore-record",
"solidcore-step",
"solidcore-tess",
"solidcraft-geom",
"solidcraft-profile",
"solidcraft-smlib",
"solidcraft-step",
"spade",
"thiserror 2.0.21",
"web-time",
@@ -4490,7 +4500,7 @@ dependencies = [
[[package]]
name = "solidcraft-look"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"egui",
"log",
@@ -4502,7 +4512,7 @@ dependencies = [
[[package]]
name = "solidcraft-mcp"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"image",
"serde_json",
@@ -4516,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",
@@ -4531,7 +4541,7 @@ dependencies = [
[[package]]
name = "solidcraft-render"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"flate2",
"image",
@@ -4554,7 +4564,7 @@ dependencies = [
[[package]]
name = "solidcraft-sketch"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"harfrust",
"serde",
@@ -4568,7 +4578,7 @@ dependencies = [
[[package]]
name = "solidcraft-smlib"
version = "0.40.0-rc.3"
version = "0.40.0-rc.4"
dependencies = [
"flate2",
"log",
@@ -4582,28 +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-step"
version = "0.40.0-rc.3"
dependencies = [
"solidcore-ops",
"solidcore-record",
"solidcraft-geom",
"solidcraft-profile",
]
[[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",
+3 -2
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"
@@ -21,7 +21,6 @@ solidcraft-profile = { path = "crates/profile" }
solidcraft-sketch = { path = "crates/sketch" }
solidcraft-kernel = { path = "crates/kernel" }
solidcraft-smlib = { path = "crates/smlib" }
solidcraft-step = { path = "crates/step" }
solidcraft-render = { path = "crates/render" }
solidcraft-dynamics = { path = "crates/dynamics" }
solidcraft-gpu = { path = "crates/gpu" }
@@ -55,6 +54,8 @@ solidcore-record = { path = "solidcore/crates/solidcore-record" }
# The modelling layer (solidcore's layers §1, "Ops"): the record-level calls SolidCraft makes into the
# kernel, converting through solidcore-convert.
solidcore-ops = { path = "solidcore/crates/solidcore-ops" }
# STEP import and export (vendored with solidcore): the Part 21 parser is solidcore-record's.
solidcore-step = { path = "solidcore/crates/solidcore-step" }
# Geometry made drawable (solidcore's layers §3): the one mesher, SolidCraft's, moved into the
# subtree (RFC 0003, item 1.1), with the display mesh and its levels of detail, the types
# SolidCraft's meshes are. Vendored with solidcore, in the same subtree.
+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 {
+14 -7
View File
@@ -148,14 +148,21 @@ self.addEventListener("activate", (event) => {
for (const name of await caches.keys()) if (name.startsWith(CACHE_PREFIX) || name === PAGES) await caches.delete(name);
return self.registration.unregister();
}
for (const name of await caches.keys()) {
if (name.startsWith(CACHE_PREFIX) && name !== CACHE) await (await caches.open(name)).put(url(RETIRED), new Response(""));
}
await sweep();
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();
// Pages open when the worker first takes over came from the network, and get their files from it.
for (const client of await self.clients.matchAll({ includeUncontrolled: true })) {
if (!(await startedBy(client.id).catch(() => null))) await started(client.id, NETWORK).catch(() => {});
for (const name of older) await (await caches.open(name)).put(url(RETIRED), new Response(""));
await sweep();
// 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
// build than the page's.
if (!older.length) {
for (const client of await self.clients.matchAll({ includeUncontrolled: true })) {
if (!(await startedBy(client.id).catch(() => null))) await started(client.id, NETWORK).catch(() => {});
}
}
})(),
);
+63 -35
View File
@@ -3,7 +3,7 @@
//! with what SolidCraft's requests mean ([`SolidCraft`]).
use std::net::TcpListener;
use std::sync::mpsc::{Receiver, Sender, channel};
use std::sync::mpsc::{Sender, channel};
use std::sync::{Arc, OnceLock};
use serde_json::{Value, json};
@@ -12,6 +12,7 @@ use solidcraft_control::{ControlRequest, auth};
use solidcraft_engine::MAX_LINE;
use solidcraft_engine::control_auth::APP;
use solidcraft_engine::control_pending;
use solidcraft_ui_egui::control_switch;
/// The token files this launch wrote (its control port, the agent chat's endpoint) and the
/// token in each, removed when the app ends unless another launch has written the file since.
@@ -85,40 +86,60 @@ pub fn remove_token_file() {
/// The port the app's own channel listens on and its token, for removing the discovery file at exit.
static APP_PORT: std::sync::Mutex<Option<(u16, String)>> = std::sync::Mutex::new(None);
pub fn start(listen_on: auth::Listen, ctx: egui::Context) -> Receiver<ControlRequest> {
/// Open the app's control channel (at launch, or turned on in the settings): its listener, its
/// token file and the discovery file. Stopping it removes both files again.
pub fn start(listen_on: auth::Listen, ctx: egui::Context) -> Result<control_switch::Started, control_switch::StartError> {
use control_switch::StartError;
let (tx, rx) = channel::<ControlRequest>();
// Every connection must show the token before anything runs (docs/control-protocol.md).
match APP.env_token().or_else(server::new_token) {
Some(token) => {
let listener = match listen_on {
auth::Listen::Exact(port) => TcpListener::bind(("127.0.0.1", port)),
auth::Listen::Default => auth::bind_with_fallback(APP.default_port, auth::PORT_TRIES),
};
match listener {
Ok(l) => {
if let Some(port) = listen_on_listener(l, token.clone(), tx, ctx, false) {
if matches!(listen_on, auth::Listen::Default) {
solidcraft_engine::note!(
"solidcraft: control channel on the default port 127.0.0.1:{port} (Preferences > Assistant turns it off)"
);
}
// The discovery file: where `solidcraft-cli mcp` finds this app with no arguments.
match APP.write_current_port(port) {
Ok(_) => {
if let Ok(mut p) = APP_PORT.lock() {
*p = Some((port, token));
}
}
Err(e) => solidcraft_engine::note!("solidcraft: could not write the control discovery file ({e})"),
}
}
}
Err(e) => solidcraft_engine::note!("solidcraft: control server failed to bind 127.0.0.1: {e}"),
let Some(token) = APP.env_token().or_else(server::new_token) else {
solidcraft_engine::note!("solidcraft: control server not started: no random token available");
return Err(StartError::Failed("no random token available".into()));
};
let listener = match listen_on {
auth::Listen::Exact(port) => TcpListener::bind(("127.0.0.1", port)),
auth::Listen::Default => auth::bind_with_fallback(APP.default_port, auth::PORT_TRIES),
};
let listener = listener.map_err(|e| {
solidcraft_engine::note!("solidcraft: control server failed to bind 127.0.0.1: {e}");
match (listen_on, e.kind()) {
// In use; Windows reports a port another program holds exclusively as denied.
(auth::Listen::Exact(port), std::io::ErrorKind::AddrInUse | std::io::ErrorKind::PermissionDenied) => StartError::PortTaken(port),
(auth::Listen::Default, std::io::ErrorKind::AddrInUse | std::io::ErrorKind::PermissionDenied) => StartError::PortTaken(APP.default_port),
_ => StartError::Failed(e.to_string()),
}
})?;
let (port, stop) =
listen_on_listener(listener, token.clone(), tx, ctx, false).ok_or_else(|| StartError::Failed("the listener did not start".into()))?;
if matches!(listen_on, auth::Listen::Default) {
solidcraft_engine::note!("solidcraft: control channel on the default port 127.0.0.1:{port} (Preferences > Assistant turns it off)");
}
// The discovery file: where `solidcraft-cli mcp` finds this app with no arguments.
match APP.write_current_port(port) {
Ok(_) => {
if let Ok(mut p) = APP_PORT.lock() {
*p = Some((port, token.clone()));
}
}
None => solidcraft_engine::note!("solidcraft: control server not started: no random token available"),
Err(e) => solidcraft_engine::note!("solidcraft: could not write the control discovery file ({e})"),
}
rx
let stop = Box::new(move || {
stop.stop();
if let Ok(mut p) = APP_PORT.lock()
&& p.as_ref().is_some_and(|(held, _)| *held == port)
{
*p = None;
}
APP.remove_current_port(port, &token);
if let Some(path) = APP.token_path(port) {
auth::remove_token_file(&path, &token);
if let Ok(mut files) = TOKEN_FILES.lock() {
files.retain(|(p, _)| *p != path);
}
}
solidcraft_engine::note!("solidcraft: control channel on 127.0.0.1:{port} closed");
});
Ok(control_switch::Started { port, requests: rx, stop })
}
/// The agent chat's own endpoint for its agent's MCP server (docs/agent-chat.md): a free loopback
@@ -137,12 +158,19 @@ pub fn listen_agent(tx: Sender<ControlRequest>, ctx: egui::Context) -> Option<u1
return None;
}
};
listen_on_listener(listener, token, tx, ctx, true)
// The chat's endpoint lives as long as the app.
listen_on_listener(listener, token, tx, ctx, true).map(|(port, _)| port)
}
/// Serve `listener` (loopback) with `token`; the port it listens on.
/// Serve `listener` (loopback) with `token`; the port it listens on and how to stop it.
/// `patient`: every request may wait as long as a batch (a person decides on it).
fn listen_on_listener(listener: TcpListener, token: String, tx: Sender<ControlRequest>, ctx: egui::Context, patient: bool) -> Option<u16> {
fn listen_on_listener(
listener: TcpListener,
token: String,
tx: Sender<ControlRequest>,
ctx: egui::Context,
patient: bool,
) -> Option<(u16, server::Stop)> {
let port = listener.local_addr().ok()?.port();
match APP.token_path(port) {
Some(path) => match APP.write_token_file(&path, &token) {
@@ -164,8 +192,8 @@ fn listen_on_listener(listener: TcpListener, token: String, tx: Sender<ControlRe
)
}
}
match hub(ctx).listen(listener, token, tx, patient) {
Ok(()) => Some(port),
match hub(ctx).listen_stoppable(listener, token, tx, patient) {
Ok(stop) => Some((port, stop)),
Err(e) => {
solidcraft_engine::note!("solidcraft: control server not started: {e}");
None
+5 -6
View File
@@ -1320,12 +1320,11 @@ fn main() -> eframe::Result {
));
}
mark("viewport pipelines");
// `--control`/the environment name a port; otherwise the preference (on by default)
// opens the default one (docs/control-protocol.md).
if let Some(listen_on) = solidcraft_control::auth::plan(control_port, app.preferences.agent_control) {
let rx = control_server::start(listen_on, cc.egui_ctx.clone());
app = app.with_control(rx);
}
// `--control`/the environment name a port for this run; otherwise the preference (off
// by default) opens its port, and turning it off or on later takes effect at once
// (docs/control-protocol.md).
let ctx = cc.egui_ctx.clone();
solidcraft_ui_egui::control_switch::launch(&mut app, Box::new(move |listen| control_server::start(listen, ctx.clone())), control_port);
// The agent chat's MCP bridge reaches the app through an endpoint of its own, started
// on a free port the first time the chat connects (docs/agent-chat.md).
let ctx = cc.egui_ctx.clone();
+41 -41
View File
@@ -931,7 +931,7 @@
"ai.http.confirm": "السماح لهذه النقطة الطرفية",
"ai.http.warning": "سيُرسَل مفتاح API والمحادثة دون تشفير. يمكن لأي شخص لديه وصول إلى الشبكة قراءتهما أو تغييرهما. استخدم هذا الخيار فقط على شبكة تثق بها.",
"ai.prefs.allow_control": "السماح لوكلاء الذكاء الاصطناعي بالاتصال",
"ai.prefs.allow_control_note": "قناة محلية (MCP). لا شيء يغادر هذا الحاسوب.\n\nيفتح قناة تحكم على هذا الحاسوب فقط (127.0.0.1، المنفذ 27878 أو أول منفذ متاح بعده). على البرنامج الذي يتصل أن يقدّم أيضًا رمزًا سريًا لا يقرؤه إلا حساب المستخدم الخاص بك، ويُنشأ من جديد عند كل تشغيل. يُطبّق في المرة القادمة التي يبدأ فيها SolidCraft. عند الإيقاف: لا شيء يستمع ما لم تشغّل SolidCraft مع --control.",
"ai.prefs.allow_control_note": "قناة محلية (MCP). لا شيء يغادر هذا الحاسوب.\n\nيفتح قناة تحكم على هذا الحاسوب فقط (127.0.0.1، على المنفذ أدناه؛ وإذا كان 27878 مستخدمًا فيُستخدم المنفذ الحر التالي). على البرنامج الذي يتصل أن يقدّم أيضًا رمزًا سريًا لا يقرؤه إلا حساب المستخدم الخاص بك، ويُنشأ من جديد عند كل تشغيل. يسري فورًا. عند الإيقاف: لا شيء يستمع ما لم تشغّل SolidCraft مع --control.",
"ai.prefs.availability": "التوفر",
"ai.prefs.background_commands": "تشغيل الأوامر البطيئة في الخلفية",
"ai.prefs.connect_apps": "إضافة SolidCraft إلى تطبيقات الذكاء الاصطناعي",
@@ -1890,13 +1890,13 @@
"help.sketch.construction_line": "أنشئ خطًا إنشائيًا لا نهائيًا يمر بنقطتين. استخدمه لتخطيط الرسم دون تقسيم مقاطعه.",
"help.sketch.construction_line.angle": "أنشئ خطًا إنشائيًا لا نهائيًا بزاوية من محور X للرسم. اختر النقطة ثم اكتب الزاوية، أو اختر اتجاهه.",
"help.sketch.construction_line.bisector": "اختر خطين مستقيمين متقاطعين في الرسم، ثم اختر أحد منصفي الزاوية. يتبع الناتج الخطين اللذين أُنشئ منهما.",
"help.sketch.construction_line.horizontal": "أنشئ خطًا إنشائيًا أفقيًا لا نهائيًا يمر بنقطة. يحافظ القيد الأفقي على الاتجاه بعد التعديلات.",
"help.sketch.construction_line.parallel": "اختر خطًا مستقيمًا في الرسم ثم ضع خطًا إنشائيًا لا نهائيًا موازيًا له. انقر بزر الفأرة الأيمن لتغيير المرجع.",
"help.sketch.construction_line.horizontal": "أنشئ خطًا إنشائيًا أفقيًا لا نهائيًا يمر بنقطة، أو أدخل بعده عن المحور X للرسم. يصبح البعد المُدخل بُعدًا قائدًا. يحافظ القيد الأفقي على الاتجاه بعد التعديلات.",
"help.sketch.construction_line.parallel": "اختر خطًا مستقيمًا في الرسم ثم ضع خطًا إنشائيًا لا نهائيًا موازيًا له، أو أدخل إزاحته. تصبح الإزاحة المُدخلة مسافة قائدة. انقر بزر الفأرة الأيمن لتغيير المرجع.",
"help.sketch.construction_line.perpendicular": "اختر خطًا مستقيمًا في الرسم ثم ضع خطًا إنشائيًا لا نهائيًا عموديًا عليه. انقر بزر الفأرة الأيمن لتغيير المرجع.",
"help.sketch.construction_line.tangent_at": "أنشئ مماسًا عند نقطة على دائرة أو قوس دائري، أو عند نهاية منحنى شريحي مفتوح أو قطع مخروطي مفتوح. اختر المنحنى ثم نقطة التماس.",
"help.sketch.construction_line.tangent_external": "اختر دائرة أو قوسًا دائريًا، ثم نقطة خارجية وحل المماس. تعرض المعاينة نقاط التماس الواقعة على القوس المرسوم فقط.",
"help.sketch.construction_line.tangent_two": "اختر دائرتين أو قوسين دائريين ثم اختر مماسًا مشتركًا من المعاينة. يجب أن تقع نقطتا التماس على المنحنيين المرسومين.",
"help.sketch.construction_line.vertical": "أنشئ خطًا إنشائيًا رأسيًا لا نهائيًا يمر بنقطة. يحافظ القيد الرأسي على الاتجاه بعد التعديلات.",
"help.sketch.construction_line.vertical": "أنشئ خطًا إنشائيًا رأسيًا لا نهائيًا يمر بنقطة، أو أدخل بعده عن المحور Y للرسم. يصبح البعد المُدخل بُعدًا قائدًا. يحافظ القيد الرأسي على الاتجاه بعد التعديلات.",
"help.sketch.copy": "ينسخ منحنيات الرسم ونقاطه المحددة مع القيود والأبعاد بينها. الصقها في هذا الرسم أو في رسم آخر.",
"help.sketch.create": "يبدأ رسمًا ثنائي الأبعاد جديدًا على مستوى أو على وجه مسطح من النموذج. يبدأ كل بثق ودوران وكنس برسم.",
"help.sketch.cut": "ينسخ هندسة الرسم المحددة ويحذفها من الرسم بخطوة تراجع واحدة. الصقها في مكان آخر من هذا الرسم أو في رسم آخر.",
@@ -3224,10 +3224,10 @@
"render.category_paint": "طلاء",
"render.category_plastic": "بلاستيك",
"render.category_wood": "خشب",
"render.caustics_area_lights": "تأثيرات الضوء المركّز من الأضواء المساحية",
"render.caustics_area_lights": "تجمّع الضوء من الأضواء المساحية",
"render.caustics_depth": "عمق مسار تجمّع الضوء",
"render.caustics_enable": "تجمّع الضوء عبر الزجاج من مصادر نقطية واتجاهية",
"render.caustics_environment": "تأثيرات الضوء المركّز من البيئة",
"render.caustics_environment": "تجمّع الضوء من البيئة",
"render.caustics_help": "يتحكم عدد الفوتونات في الضجيج وكلفة الحساب. يوازن نصف قطر التجميع بين التفاصيل الدقيقة والتقدير الأنعم. يتطلب CPU إضاءة غير مباشرة.",
"render.caustics_path_required": "يتطلب تجمّع الضوء على CPU إضاءة غير مباشرة.",
"render.caustics_photons": "عدد الفوتونات",
@@ -3235,7 +3235,7 @@
"render.check_glass_cpu": "يدعم CPU صفائح زجاج مستوية وصناديق محاذية للمحاور بعد التحقق منها. تتطلب الشبكات المغلقة العامة والتعشيق والتشتت الداخلي إضاءة غير مباشرة. لا تزال الظلال النافذة عبر الزجاج المغلق غير مدعومة.",
"render.check_glass_gpu": "يدعم GPU أوساط زجاج منفصلة متحققًا منها، أو تعشيقًا متحققًا منه عند تفعيله صراحةً. يتطلب التعشيق أغلفة خارجية محدبة وأجسامًا داخلية محتواة بالكامل وحدودًا غير متلامسة.",
"render.check_media_remaining": "لا تزال شبكات الكثافة والإزاحة والتكامل الطيفي الكامل ونقل حالة الاستقطاب غير متاحة.",
"render.check_media_scope": "يتطلب التشتت الداخلي النشط وسطًا زجاجيًا مغلقًا مع إيقاف نفاذ أشعة الظل المستقيمة وإزالة الضجيج. ويتطلب GPU أيضًا طبقة عينات فرينل واحدة. لا يمكن جمع الغلاف الجوي الخارجي النشط مع الزجاج أو إزالة ضجيج الأسطح.",
"render.check_media_scope": "يتطلب التشتت الداخلي النشط وسطًا زجاجيًا مغلقًا مع إيقاف نفاذ أشعة الظل المستقيمة وإزالة التشويش. ويتطلب GPU أيضًا طبقة عينات فرينل واحدة. لا يمكن جمع الغلاف الجوي الخارجي النشط مع الزجاج أو إزالة تشويش الأسطح.",
"render.check_opaque": "الشفافية: أسطح معتمة فقط؛ ألفا الخلفية مدعومة.",
"render.check_sorted_alpha": "الشفافية: تقريب ألفا مرتّب.",
"render.check_transmission": "الشفافية: نفاذية فيزيائية.",
@@ -3441,8 +3441,8 @@
"render.cpu_studio_enable_apply": "تفعيل الإضاءة غير المباشرة وتطبيقها",
"render.cpu_studio_indirect_help": "يحتاج توزيع الشمس هذا إلى إضاءة غير مباشرة بعمق 8 وظلال متتبعة وعينة كاميرا واحدة لكل مرور. إذا كان التصيير التدريجي معطلًا، يبدأ بـ 16 عينة لكل بكسل وبذرة 0. تُحفظ الإعدادات التدريجية الحالية وملف الموارد. تمنع الإعدادات غير المتوافقة التطبيق دون تغييرها.",
"render.cpu_studio_radiance_limit": "سطوع المصدر المساحي في هذا التوزيع هو {radiance}، ويتجاوز حد CPU البالغ 64. تُحفظ الإضاءة المؤلَّفة؛ اختر توزيعًا آخر أو GPU.",
"render.cpu_subsurface_denoise_reason": "يتطلب التشتت داخل الزجاج على CPU إيقاف إزالة الضجيج. تبقى إعداداتك محفوظة.",
"render.cpu_subsurface_help": "يغيّر التشتت اتجاه الضوء داخل الزجاج المغلق. تتطلب المعدلات الموجبة إضاءة غير مباشرة مع إيقاف إزالة الضجيج. المعدل هو عدد التصادمات لكل مم؛ لا يضيف الصفر أي تشتت. يفضّل انحياز الاتجاه الموجب الأمام، والسالب الخلف.",
"render.cpu_subsurface_denoise_reason": "يتطلب التشتت داخل الزجاج على CPU إيقاف إزالة التشويش. تبقى إعداداتك محفوظة.",
"render.cpu_subsurface_help": "يغيّر التشتت اتجاه الضوء داخل الزجاج المغلق. تتطلب المعدلات الموجبة إضاءة غير مباشرة مع إيقاف إزالة التشويش. المعدل هو عدد التصادمات لكل مم؛ لا يضيف الصفر أي تشتت. يفضّل انحياز الاتجاه الموجب الأمام، والسالب الخلف.",
"render.cpu_summary": "تتبع الأشعة على CPU · انعكاسات · HDR · أضواء اتجاهية ونقطية",
"render.cpu_threads": "خيوط المعالجة",
"render.cpu_threads_auto": "تلقائي",
@@ -3458,40 +3458,40 @@
"render.cpu_volume_method_incompatible": "لا يمكن نقل إعدادات الإضاءة أو أخذ العينات أو الغلاف الجوي هذه إلى هذه الطريقة. تبقى إعداداتك محفوظة.",
"render.cpu_volume_path_required": "فعّل الإضاءة غير المباشرة لاستخدام الغلاف الجوي على CPU.",
"render.cpu_warm_cool": "دافئ وبارد",
"render.denoise_class_background": "متجاوز: خلفية",
"render.denoise_class_coverage": "متجاوز: تغطية جزئية",
"render.denoise_class_degenerate": "متجاوز: مسار متدهور",
"render.denoise_class_depth": "متجاوز: حد عمق المسار",
"render.denoise_class_emitter": "متجاوز: مصدر ضوء",
"render.denoise_class_exit": "متجاوز: خروج غير متوافق",
"render.denoise_class_identity": "متجاوز: حد هوية الدليل",
"render.denoise_class_lens": "متجاوز: عمق المجال",
"render.denoise_class_material": "متجاوز: مادة غير مدعومة",
"render.denoise_class_nested": "متجاوز: وسط متداخل",
"render.denoise_class_optical": "مؤهل: سطح عبر انكسار",
"render.denoise_class_reflection": "متجاوز: نهاية عاكسة",
"render.denoise_class_background": "مستبعد: خلفية",
"render.denoise_class_coverage": "مستبعد: تغطية جزئية",
"render.denoise_class_degenerate": "مستبعد: مسار منحلّ",
"render.denoise_class_depth": "مستبعد: حد عمق المسار",
"render.denoise_class_emitter": "مستبعد: مصدر ضوء",
"render.denoise_class_exit": "مستبعد: خروج غير متوافق",
"render.denoise_class_identity": "مستبعد: حد هوية الدليل",
"render.denoise_class_lens": "مستبعد: عمق المجال",
"render.denoise_class_material": "مستبعد: مادة غير مدعومة",
"render.denoise_class_nested": "مستبعد: وسط متداخل",
"render.denoise_class_optical": "مؤهل: سطح مرئي عبر الانكسار",
"render.denoise_class_reflection": "مستبعد: نهاية عاكسة",
"render.denoise_class_surface": "مؤهل: سطح",
"render.denoise_class_tir": "متجاوز: انعكاس داخلي كلي",
"render.denoise_class_transmission": "متجاوز: نفاذ إضافي",
"render.denoise_class_unavailable": "متجاوز: بلا دليل",
"render.denoise_class_volume": "متجاوز: تشتت حجمي",
"render.denoise_diagnostics_counts": "المؤهلة: {eligible} · المتجاوزة: {skipped}",
"render.denoise_diagnostics_enable": "جمع تشخيصات إزالة الضوضاء",
"render.denoise_class_tir": "مستبعد: انعكاس داخلي كلي",
"render.denoise_class_transmission": "مستبعد: نفاذ إضافي",
"render.denoise_class_unavailable": "مستبعد: بلا دليل",
"render.denoise_class_volume": "مستبعد: تشتت حجمي",
"render.denoise_diagnostics_counts": "المؤهلة: {eligible} · المستبعدة: {skipped}",
"render.denoise_diagnostics_enable": "جمع تشخيصات إزالة التشويش",
"render.denoise_diagnostics_grid": "أعداد البكسلات بالحجم الداخلي {width} × {height}.",
"render.denoise_diagnostics_help": "يجمع أعداد البكسلات المؤهلة والمتجاوزة حتى عند إيقاف إزالة الضوضاء. تعتمد الأعداد على حجم التصيير الداخلي، ويعتمد القناع على حجم الإخراج.",
"render.denoise_diagnostics_title": "تشخيصات إزالة الضوضاء",
"render.denoise_diagnostics_help": "يجمع أعداد البكسلات المؤهلة والمستبعدة حتى عند إيقاف إزالة التشويش. تعتمد الأعداد على حجم التصيير الداخلي، ويعتمد القناع على حجم الإخراج.",
"render.denoise_diagnostics_title": "تشخيصات إزالة التشويش",
"render.denoise_export_mask": "تصدير القناع بصيغة PNG",
"render.denoise_firefly": "تقليل البكسلات الساطعة المعزولة",
"render.denoise_format_mask": "قناع PNG",
"render.denoise_mask_copy_help": "صدّر قناع إزالة الضوضاء بصيغة PNG، أو عُد إلى الصورة المصيّرة لنسخها.",
"render.denoise_mask_unavailable": "فعّل تشخيصات إزالة الضوضاء وصيّر صورة لفحص قناعها.",
"render.denoise_optical_filter": "تصفية المرايا والزجاج",
"render.denoise_optical_filter_help": "تصفية الضوء المنعكس والنافذ المؤهّل. أوقف هذا الخيار لإزالة الضجيج من الأسطح المعتمة فقط.",
"render.denoise_passes": "مرات الترشيح",
"render.denoise_settings_help": "تُنعّم مرات الترشيح الإضافية مساحة أوسع. تحدد القوة مقدار الترشيح المطبّق في كل مرة. تبقى المسارات البصرية غير المدعومة دون ترشيح.",
"render.denoise_strength": "قوة إزالة الضوضاء",
"render.denoise_mask_copy_help": "صدّر قناع إزالة التشويش بصيغة PNG، أو عُد إلى الصورة المصيّرة لنسخها.",
"render.denoise_mask_unavailable": "فعّل تشخيصات إزالة التشويش وصيّر صورة لفحص قناعها.",
"render.denoise_optical_filter": "ترشيح المرايا والزجاج",
"render.denoise_optical_filter_help": "يرشّح الضوء المنعكس والنافذ المؤهّل. أوقف هذا الخيار لإزالة التشويش من الأسطح المعتمة فقط.",
"render.denoise_passes": "تمريرات الترشيح",
"render.denoise_settings_help": "تُنعّم تمريرات الترشيح الإضافية مساحة أوسع. تحدد القوة مقدار الترشيح الذي يطبّقه كل تمرير. تبقى المسارات البصرية غير المدعومة دون ترشيح.",
"render.denoise_strength": "قوة إزالة التشويش",
"render.denoise_view_image": "الصورة المصيّرة",
"render.denoise_view_mask": "قناع إزالة الضوضاء",
"render.denoise_view_mask": "قناع إزالة التشويش",
"render.diag_bodies": "الأجسام المرئية",
"render.diag_job_details": "تفاصيل المهمة الملتقطة",
"render.diag_last_check": "آخر فحص مسبق",
@@ -4047,7 +4047,7 @@
"render.material_v_up": "اتجاه V للنسيج يشير للأعلى في الصورة",
"render.material_weight": "وزن الطبقة (0–1)",
"render.metal_energy_compensation": "استعادة انعكاس المعادن الخشنة",
"render.metal_energy_compensation_help": "عوّض الضوء الذي تفقده الأسطح المعدنية الخشنة بين أوجهها المجهرية. يتطلب معالج CPU إضاءة غير مباشرة.",
"render.metal_energy_compensation_help": "عوّض الضوء المفقود بين الأوجه المجهرية للمعادن الخشنة. يتطلب التصيير على CPU إضاءة غير مباشرة.",
"render.method_cpu": "تتبع الأشعة على CPU",
"render.method_gpu": "تتبع المسارات على GPU",
"render.method_limit_raster": "التنقيط يستخدم معاينة الاستوديو المعتمة الثابتة. اختر طريقة تتبع أشعة صريحة لعناصر التحكم البصرية المدعومة.",
@@ -4507,7 +4507,7 @@
"render.surface_import.source": "المصدر",
"render.surface_import.title": "استيراد صورة",
"render.texture_filtering": "ترشيح تفاصيل الخامات",
"render.texture_filtering_help": "استخدم مستويات التفاصيل والترشيح المتباين لتقليل تعرّج الخامات عند التصغير. يزيد استهلاك الذاكرة؛ يبقى الوضع القديم معطّلاً.",
"render.texture_filtering_help": "استخدم مستويات MIP والترشيح متباين الخواص لتقليل تعرّج الخامات عند التصغير. يستهلك ذاكرة أكبر؛ وعند إيقافه يبقى الترشيح السابق.",
"render.tone_aces_fitted": "ACES مُلاءَم",
"render.tone_reinhard": "راينهارد",
"render.transmission_scope": "يدعم زجاج GPU الأوساط المنفصلة المعتمدة. لا تتوفر الأوساط المتداخلة غير المقيدة ولا مقدّر نفاذ خالٍ من التشويش.",
@@ -4552,7 +4552,7 @@
"render.use_preview": "استخدام المعاينة المدمجة",
"render.volume_albedo": "بياض التشتت · RGB خطي",
"render.volume_anisotropy": "متوسط جيب تمام الطور",
"render.volume_denoise_required": "أوقف تقليل الضوضاء لاستخدام الغلاف الجوي.",
"render.volume_denoise_required": "أوقف تقليل التشويش لاستخدام الغلاف الجوي.",
"render.volume_enable": "تفعيل الوسط المشارك",
"render.volume_extinction": "معامل الإخماد",
"render.volume_glass_required": "لا يمكن الجمع بين الغلاف الجوي والزجاج في هذا الإعداد.",
@@ -5041,7 +5041,7 @@
"ui.agent_chat_permission": "يطلب الوكيل تشغيل: {title}",
"ui.agent_chat_pick": "الوكيل",
"ui.agent_chat_pick_context": "اختيار السياق",
"ui.agent_chat_pick_context_tip": "اختر أوجهًا وحوافًا وميزات وأجسامًا في النموذج ليطّلع عليها الوكيل، ثم اضغط «موافق»",
"ui.agent_chat_pick_context_tip": "اختر أوجهًا وحوافًا وميزات وأجسامًا في النموذج، ثم اضغط «موافق»: تُدرج في الرسالة كشارة في موضع المؤشر (كتابة @ تفعل الشيء نفسه)",
"ui.agent_chat_placeholder": "اطلب من الوكيل تغيير التصميم…",
"ui.agent_chat_placeholder_busy": "ضع رسالة في قائمة الانتظار لحين انتهاء الوكيل (Ctrl+Enter: أرسل الآن)",
"ui.agent_chat_pop_out": "فتح في نافذة مستقلة يمكنك نقلها إلى شاشة أخرى",
+1 -1
View File
@@ -931,7 +931,7 @@
"ai.http.confirm": "Allow for this endpoint",
"ai.http.warning": "The API key and conversation will be sent unencrypted. Anyone with access to the network may read or change them. Use this only on a network you trust.",
"ai.prefs.allow_control": "Let AI agents connect",
"ai.prefs.allow_control_note": "A local channel (MCP). Nothing leaves this computer.\n\nOpens a control channel on this computer only (127.0.0.1, port 27878 or the next free one). A program that connects must also show a secret token that only your user account can read, made fresh at every start. Takes effect the next time SolidCraft starts. Off: nothing listens unless you start SolidCraft with --control.",
"ai.prefs.allow_control_note": "A local channel (MCP). Nothing leaves this computer.\n\nOpens a control channel on this computer only (127.0.0.1, on the port below; 27878 takes the next free one when it is in use). A program that connects must also show a secret token that only your user account can read, made fresh at every start. Takes effect at once. Off: nothing listens unless you start SolidCraft with --control.",
"ai.prefs.availability": "Availability",
"ai.prefs.background_commands": "Run slow commands in the background",
"ai.prefs.connect_apps": "Add SolidCraft to AI apps",
+5
View File
@@ -218,6 +218,7 @@
"Expected result (optional)": "Résultat attendu (facultatif)",
"Explode": "Éclater",
"Explode Text": "Décomposer le texte",
"Export 3MF…": "Exporter 3MF…",
"Export Animation…": "Exporter l'animation…",
"Export Bend Table…": "Exporter la table des plis…",
"Export CSV…": "Exporter CSV…",
@@ -226,6 +227,10 @@
"Export DXF": "Exporter en DXF",
"Export DXF…": "Exporter en DXF…",
"Export JSON…": "Exporter JSON…",
"Export OBJ…": "Exporter OBJ…",
"Export STEP…": "Exporter STEP…",
"Export STL…": "Exporter STL…",
"Export URDF…": "Exporter URDF…",
"Export design library...": "Exporter la bibliothèque de la conception...",
"Export hidden edges as dashed lines": "Exporter les arêtes cachées en tirets",
"Exported by SolidWorks, which usually saves Y up": "Exporté par SolidWorks, qui enregistre généralement en Y vers le haut",
@@ -0,0 +1,10 @@
{
"en": { "ui.agent_chat_transcript_assistant": "Assistant:", "ui.agent_chat_transcript_user": "User:" },
"ar": { "ui.agent_chat_transcript_assistant": "المساعد:", "ui.agent_chat_transcript_user": "المستخدم:" },
"de": { "ui.agent_chat_transcript_assistant": "Assistent:", "ui.agent_chat_transcript_user": "Benutzer:" },
"he": { "ui.agent_chat_transcript_assistant": "עוזר:", "ui.agent_chat_transcript_user": "משתמש:" },
"ja": { "ui.agent_chat_transcript_assistant": "アシスタント:", "ui.agent_chat_transcript_user": "ユーザー:" },
"ko": { "ui.agent_chat_transcript_assistant": "어시스턴트:", "ui.agent_chat_transcript_user": "사용자:" },
"vi": { "ui.agent_chat_transcript_assistant": "Trợ lý:", "ui.agent_chat_transcript_user": "Người dùng:" },
"zh": { "ui.agent_chat_transcript_assistant": "助手:", "ui.agent_chat_transcript_user": "用户:" }
}
@@ -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,10 @@
{
"en": { "ui.agent_chat_model_search_or_id": "Search, or type any model id…" },
"ar": { "ui.agent_chat_model_search_or_id": "ابحث، أو اكتب أي معرّف نموذج…" },
"de": { "ui.agent_chat_model_search_or_id": "Suchen oder eine beliebige Modell-ID eingeben…" },
"he": { "ui.agent_chat_model_search_or_id": "חיפוש, או הקלדת מזהה מודל כלשהו…" },
"ja": { "ui.agent_chat_model_search_or_id": "検索、または任意のモデル ID を入力…" },
"ko": { "ui.agent_chat_model_search_or_id": "검색하거나 모델 ID를 직접 입력…" },
"vi": { "ui.agent_chat_model_search_or_id": "Tìm, hoặc nhập mã mô hình bất kỳ…" },
"zh": { "ui.agent_chat_model_search_or_id": "搜索,或输入任意模型 ID…" }
}
@@ -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": "אילוץ"
}
}
@@ -12,7 +12,7 @@
},
"ar": {
"browser.straighten_angle": "اتجاه الحافة",
"browser.straighten_choose": "اختر ما تصبح عليه الحافة، أفقية أو رأسية أو بزاوية، ثم اضغط تقويم.",
"browser.straighten_choose": "اختر ما تصبح عليه الحافة، أفقية أو رأسية أو بزاوية، ثم اضغط «تقويم».",
"browser.straighten_click_a": "انقر الصورة عند أحد طرفي حافة يجب أن تكون مستقيمة، أو اكتب إحداثياتها.",
"browser.straighten_click_b": "انقر الطرف الآخر للحافة، أو اكتب إحداثياته.",
"browser.straighten_horizontal": "أفقي",
@@ -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,38 @@
{
"en": {
"prefs.constraint_glyph_size": "Constraint glyph size",
"prefs.constraint_glyph_size_note": "How large constraint glyphs and their letters or icons are drawn in a sketch. 100% is the usual size; glyphs keep this screen size at every zoom."
},
"ar": {
"prefs.constraint_glyph_size": "حجم رموز القيود",
"prefs.constraint_glyph_size_note": "حجم رسم رموز القيود وحروفها أو أيقوناتها في الرسم. 100% هو الحجم المعتاد؛ وتحتفظ الرموز بهذا الحجم على الشاشة عند أي مستوى تكبير."
},
"de": {
"prefs.constraint_glyph_size": "Größe der Bindungssymbole",
"prefs.constraint_glyph_size_note": "Wie groß Bindungssymbole samt Buchstaben oder Icons in einer Skizze gezeichnet werden. 100 % ist die übliche Größe; die Symbole behalten diese Bildschirmgröße bei jedem Zoom."
},
"fr": {
"prefs.constraint_glyph_size": "Taille des symboles de contraintes",
"prefs.constraint_glyph_size_note": "Taille à laquelle les symboles de contraintes, avec leurs lettres ou icônes, sont dessinés dans une esquisse. 100 % est la taille habituelle ; les symboles gardent cette taille à l'écran quel que soit le zoom."
},
"he": {
"prefs.constraint_glyph_size": "גודל סמלי האילוצים",
"prefs.constraint_glyph_size_note": "הגודל שבו מצוירים סמלי האילוצים, עם האותיות או הסמלונים שלהם, בשרטוט. 100% הוא הגודל הרגיל; הסמלים שומרים על גודל המסך הזה בכל זום."
},
"ja": {
"prefs.constraint_glyph_size": "拘束グリフのサイズ",
"prefs.constraint_glyph_size_note": "スケッチ内の拘束グリフとその文字やアイコンを描く大きさ。100% が通常のサイズです。グリフはどのズームでもこの画面サイズを保ちます。"
},
"ko": {
"prefs.constraint_glyph_size": "구속 조건 기호 크기",
"prefs.constraint_glyph_size_note": "스케치에서 구속 조건 기호와 그 문자나 아이콘을 그리는 크기입니다. 100%가 기본 크기이며, 기호는 확대/축소와 관계없이 이 화면 크기를 유지합니다."
},
"vi": {
"prefs.constraint_glyph_size": "Kích thước biểu tượng ràng buộc",
"prefs.constraint_glyph_size_note": "Kích thước vẽ biểu tượng ràng buộc cùng chữ cái hoặc biểu tượng bên trong trong bản phác. 100% là kích thước thông thường; biểu tượng giữ kích thước màn hình này ở mọi mức thu phóng."
},
"zh": {
"prefs.constraint_glyph_size": "约束符号大小",
"prefs.constraint_glyph_size_note": "草图中约束符号及其字母或图标的绘制大小。100% 为常规大小;任意缩放比例下符号都保持此屏幕大小。"
}
}
@@ -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,58 @@
{
"en": {
"ui.construct_planes": "Planes",
"ui.construct_axes": "Axes",
"ui.construct_points": "Points",
"prefs.construct_menu_groups": "Group Construct tools by entity type",
"prefs.construct_menu_groups_help": "Show Planes, Axes and Points headings. Off keeps the original menu order."
},
"ar": {
"ui.construct_planes": "المستويات",
"ui.construct_axes": "المحاور",
"ui.construct_points": "النقاط",
"prefs.construct_menu_groups": "تجميع أدوات الإنشاء حسب نوع العنصر",
"prefs.construct_menu_groups_help": "إظهار عناوين المستويات والمحاور والنقاط. يحافظ التعطيل على ترتيب القائمة الأصلي."
},
"he": {
"ui.construct_planes": "מישורים",
"ui.construct_axes": "צירים",
"ui.construct_points": "נקודות",
"prefs.construct_menu_groups": "קיבוץ כלי הבנייה לפי סוג ישות",
"prefs.construct_menu_groups_help": "הצגת כותרות מישורים, צירים ונקודות. כיבוי שומר על סדר התפריט המקורי."
},
"ja": {
"ui.construct_planes": "平面",
"ui.construct_axes": "軸",
"ui.construct_points": "点",
"prefs.construct_menu_groups": "構築ツールを要素の種類でグループ化",
"prefs.construct_menu_groups_help": "平面、軸、点の見出しを表示します。オフにすると元のメニュー順序を維持します。"
},
"zh": {
"ui.construct_planes": "平面",
"ui.construct_axes": "轴",
"ui.construct_points": "点",
"prefs.construct_menu_groups": "按实体类型分组构造工具",
"prefs.construct_menu_groups_help": "显示平面、轴和点标题。关闭时保留原始菜单顺序。"
},
"de": {
"ui.construct_planes": "Ebenen",
"ui.construct_axes": "Achsen",
"ui.construct_points": "Punkte",
"prefs.construct_menu_groups": "Konstruktionswerkzeuge nach Elementtyp gruppieren",
"prefs.construct_menu_groups_help": "Zeigt Überschriften für Ebenen, Achsen und Punkte. Aus behält die ursprüngliche Menüreihenfolge bei."
},
"ko": {
"ui.construct_planes": "평면",
"ui.construct_axes": "축",
"ui.construct_points": "점",
"prefs.construct_menu_groups": "요소 유형별로 구성 도구 그룹화",
"prefs.construct_menu_groups_help": "평면, 축, 점 제목을 표시합니다. 끄면 원래 메뉴 순서를 유지합니다."
},
"vi": {
"ui.construct_planes": "Các mặt phẳng",
"ui.construct_axes": "Các trục",
"ui.construct_points": "Các điểm",
"prefs.construct_menu_groups": "Nhóm công cụ dựng theo loại đối tượng",
"prefs.construct_menu_groups_help": "Hiện tiêu đề Mặt phẳng, Trục và Điểm. Tắt để giữ thứ tự menu ban đầu."
}
}
@@ -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": "打开对话框时,按下按钮标签的首字母即可触发该按钮:如“取”对应“取消”。一个字母若匹配两个按钮则不起作用,获得焦点的文本框保留按键输入。关闭时:按键不影响按钮,与以前相同。"
}
}
@@ -13,9 +13,9 @@
"prefs.export_name": "اسم ملف التصدير",
"prefs.export_name.body": "اسم الجسم المُصدَّر (الأول إن تعددت الأجسام)",
"prefs.export_name.design": "اسم التصميم",
"prefs.export_name_hint": "مثلاً {design}-{body}",
"prefs.export_name_hint": "مثلًا {design}-{body}",
"prefs.export_name_sample": "تصدير هذا التصميم يقترح: {name}",
"prefs.export_name_tip": "اسم الملف الذي يقترحه ملف › تصدير وحفظ كشبكة. تُستبدل هذه العناصر النائبة:\n{placeholders}\nتُحذف خاصية التصميم غير المعيّنة. اترك الحقل فارغاً لاقتراح اسم التصميم، أو اسم الجسم للشبكة."
"prefs.export_name_tip": "اسم الملف الذي يقترحه «ملف › تصدير» و«حفظ كشبكة». تُستبدل هذه العناصر النائبة:\n{placeholders}\nتُحذف خاصية التصميم غير المعيّنة. اترك الحقل فارغًا لاقتراح اسم التصميم، أو اسم الجسم للشبكة."
},
"de": {
"prefs.export": "Export",
@@ -1,7 +1,7 @@
{
"ar": {
"prefs.fillet_legacy_radius": "استخدم نصف قطر Fillet الصلب الافتراضي السابق",
"prefs.fillet_legacy_radius.help": "مطفأ افتراضيًا. عند التشغيل، يفتح Fillet الصلب الجديد عند الرقم 2 مقروءًا بوحدات التصميم (\"2 بوصة\" في تصميم بالبوصة)، كما كان سابقًا؛ وعند الإيقاف، يفتح عند نصف القطر الذي تُسلَّم به الأداة، 2 مم، معروضًا بوحدات التصميم (\"0.07874 بوصة\" في تصميم بالبوصة)."
"prefs.fillet_legacy_radius": "استخدام نصف القطر الافتراضي السابق للتقويس المصمت",
"prefs.fillet_legacy_radius.help": "معطّل افتراضيًا. عند التفعيل، يفتح التقويس المصمت الجديد على الرقم 2 مقروءًا بوحدات التصميم («2 بوصة» في تصميم بالبوصة)، كما كان سابقًا؛ وعند الإيقاف، يفتح على نصف القطر الافتراضي للأداة، 2 مم، معروضًا بوحدات التصميم («0.07874 بوصة» في تصميم بالبوصة)."
},
"de": {
"prefs.fillet_legacy_radius": "Den vorherigen Standardradius für Solid-Fillet verwenden",
@@ -4,8 +4,8 @@
"ui.first_dimension_scales_about_origin_tip": "On: the drawing always grows or shrinks about the sketch origin, so a shape drawn away from the origin moves. Off: a drawing that nothing ties to the origin scales about its own middle and stays where it was drawn."
},
"ar": {
"ui.first_dimension_scales_about_origin": "تغيير المقياس حول أصل الرسم التخطيطي",
"ui.first_dimension_scales_about_origin_tip": "عند التفعيل: يكبر الرسم أو يصغر دائمًا حول أصل الرسم التخطيطي، فيتحرك الشكل المرسوم بعيدًا عن الأصل. عند الإيقاف: الرسم غير المرتبط بالأصل يتغير مقياسه حول منتصفه ويبقى في مكانه."
"ui.first_dimension_scales_about_origin": "تغيير المقياس حول أصل الرسم",
"ui.first_dimension_scales_about_origin_tip": "عند التفعيل: يكبر المرسوم أو يصغر دائمًا حول أصل الرسم، فإذا كان الشكل مرسومًا بعيدًا عن الأصل تحرّك. عند الإيقاف: المرسوم غير المرتبط بالأصل يتغير مقياسه حول منتصفه ويبقى حيث رُسم."
},
"de": {
"ui.first_dimension_scales_about_origin": "Um den Skizzenursprung skalieren",
+2 -2
View File
@@ -21,7 +21,7 @@
"prefs.graphics_api_auto": "تلقائي",
"prefs.graphics_api_help": "اختر واجهة الرسوميات المستخدمة لفتح SolidCraft. إذا تعذّر فتح النافذة عند التشغيل التالي، فستُستعاد السابقة.",
"prefs.graphics_api_override": "يتحكم إعداد عند التشغيل في واجهة الرسوميات. سيُطبّق الاختيار المحفوظ عند فتح SolidCraft بالطريقة العادية.",
"prefs.graphics_api_restart_note": "يُطبّق بعد إعادة التشغيل. تُعالَج التصميمات غير المحفوظة قبل الإغلاق.",
"prefs.graphics_api_restart_note": "يُطبّق بعد إعادة التشغيل. تُعالَج التصاميم غير المحفوظة قبل الإغلاق.",
"prefs.graphics_api_restart_unavailable": "أعد تشغيل SolidCraft لتطبيق واجهة الرسوميات المحفوظة.",
"prefs.graphics_api_running": "الحالية: {current}. المختارة للتشغيل التالي: {selected}.",
"prefs.graphics_api_save_failed": "تعذّر حفظ اختيار واجهة الرسوميات: {error}",
@@ -29,7 +29,7 @@
"prefs.graphics_api_start_reverted": "لم يفتح التشغيل السابق باستخدام {requested} النافذة. استعاد SolidCraft الاختيار {previous}.",
"prefs.graphics_api_unavailable": "اختيار واجهة الرسوميات متاح في تطبيق سطح المكتب.",
"render.graphics_api_restart_vulkan": "إعادة التشغيل باستخدام Vulkan",
"render.graphics_api_restart_warning": "يؤدي تغيير واجهة الرسوميات إلى إعادة تشغيل SolidCraft ويطلب منك حفظ التصميمات غير المحفوظة. إذا تعذّر فتح النافذة بالواجهة الجديدة، فسيُستعاد الاختيار السابق عند التشغيل التالي.",
"render.graphics_api_restart_warning": "يؤدي تغيير واجهة الرسوميات إلى إعادة تشغيل SolidCraft ويطلب منك حفظ التصاميم غير المحفوظة. إذا تعذّر فتح النافذة بالواجهة الجديدة، فسيُستعاد الاختيار السابق عند التشغيل التالي.",
"render.graphics_api_settings": "إعدادات الرسوميات",
"render.graphics_api_vulkan_required": "يتطلب تتبّع المسارات بمعالج الرسوميات Vulkan في هذه الجلسة. أعد التشغيل باستخدام Vulkan، أو واصل استخدام طريقة صور بمعالج CPU."
},
@@ -0,0 +1,34 @@
{
"en": {
"nav.maya_horizontal_zoom.help": "Alt+right drag: right zooms in, left zooms out. Turn off to restore vertical drag zoom.",
"nav.maya_horizontal_zoom.label": "Maya: horizontal drag zoom"
},
"ar": {
"nav.maya_horizontal_zoom.help": "اسحب مع Alt وزر الفأرة الأيمن: إلى اليمين للتكبير وإلى اليسار للتصغير. عطّل هذا الخيار لاستعادة التكبير بالسحب العمودي.",
"nav.maya_horizontal_zoom.label": "Maya: التكبير بالسحب الأفقي"
},
"he": {
"nav.maya_horizontal_zoom.help": "Alt וגרירה עם לחצן העכבר הימני: ימינה לקירוב, שמאלה להרחקה. כבו כדי לחזור לזום בגרירה אנכית.",
"nav.maya_horizontal_zoom.label": "Maya: זום בגרירה אופקית"
},
"ja": {
"nav.maya_horizontal_zoom.help": "Alt+右ドラッグで右に動かすと拡大、左に動かすと縮小します。オフにすると従来の垂直ドラッグに戻ります。",
"nav.maya_horizontal_zoom.label": "Maya: 水平方向のドラッグでズーム"
},
"vi": {
"nav.maya_horizontal_zoom.help": "Alt+kéo chuột phải: kéo sang phải để phóng to, sang trái để thu nhỏ. Tắt để khôi phục thu phóng bằng cách kéo dọc.",
"nav.maya_horizontal_zoom.label": "Maya: thu phóng bằng cách kéo ngang"
},
"zh": {
"nav.maya_horizontal_zoom.help": "Alt+右键拖动:向右放大,向左缩小。关闭此选项可恢复垂直拖动缩放。",
"nav.maya_horizontal_zoom.label": "Maya:水平拖动缩放"
},
"de": {
"nav.maya_horizontal_zoom.help": "Alt+Rechtsziehen: nach rechts vergrößern, nach links verkleinern. Ausschalten, um wieder vertikal zu zoomen.",
"nav.maya_horizontal_zoom.label": "Maya: horizontal ziehen zum Zoomen"
},
"ko": {
"nav.maya_horizontal_zoom.help": "Alt+오른쪽 버튼 드래그: 오른쪽으로 움직이면 확대하고 왼쪽으로 움직이면 축소합니다. 끄면 세로 드래그 확대/축소로 돌아갑니다.",
"nav.maya_horizontal_zoom.label": "Maya: 가로 드래그로 확대/축소"
}
}
@@ -0,0 +1,74 @@
{
"en": {
"ai.prefs.control_port": "Port",
"ai.prefs.control_port_note": "The port AI agents connect to on this computer (127.0.0.1). 27878 is the default: when it is in use, the next free port is taken. Any other port is used exactly. Point your MCP client at the same port, for example solidcraft-cli mcp --connect 127.0.0.1:7878.",
"ai.prefs.control_listening": "Listening on 127.0.0.1:{port}",
"ai.prefs.control_not_listening": "Not listening",
"ai.prefs.control_port_taken": "Port {port} is in use by another program (or another SolidCraft window). Choose another port.",
"ai.prefs.control_failed": "The control channel could not start: {error}",
"ai.prefs.control_from_launch": "Started with --control on port {port} for this run. Changes here take effect the next time SolidCraft starts without --control."
},
"de": {
"ai.prefs.control_port": "Port",
"ai.prefs.control_port_note": "Der Port, über den sich KI-Agenten auf diesem Computer verbinden (127.0.0.1). Standard ist 27878: Ist er belegt, wird der nächste freie Port genommen. Jeder andere Port wird genau so verwendet. Richte deinen MCP-Client auf denselben Port, zum Beispiel solidcraft-cli mcp --connect 127.0.0.1:7878.",
"ai.prefs.control_listening": "Wartet auf 127.0.0.1:{port}",
"ai.prefs.control_not_listening": "Nicht aktiv",
"ai.prefs.control_port_taken": "Port {port} wird von einem anderen Programm (oder einem anderen SolidCraft-Fenster) verwendet. Wähle einen anderen Port.",
"ai.prefs.control_failed": "Der Steuerkanal konnte nicht starten: {error}",
"ai.prefs.control_from_launch": "Für diese Sitzung mit --control auf Port {port} gestartet. Änderungen hier gelten beim nächsten Start von SolidCraft ohne --control."
},
"ja": {
"ai.prefs.control_port": "ポート",
"ai.prefs.control_port_note": "このコンピューター上で AI エージェントが接続するポート (127.0.0.1)。既定は 27878 で、使用中の場合は次の空いているポートを使います。それ以外のポートはそのまま使います。MCP クライアントも同じポートに向けてください (例: solidcraft-cli mcp --connect 127.0.0.1:7878)。",
"ai.prefs.control_listening": "127.0.0.1:{port} で待機中",
"ai.prefs.control_not_listening": "待機していません",
"ai.prefs.control_port_taken": "ポート {port} は別のプログラム (または別の SolidCraft ウィンドウ) が使用しています。別のポートを選んでください。",
"ai.prefs.control_failed": "コントロールチャネルを開始できませんでした: {error}",
"ai.prefs.control_from_launch": "この実行は --control でポート {port} を使って開始されました。ここでの変更は、次に --control なしで SolidCraft を起動したときに反映されます。"
},
"zh": {
"ai.prefs.control_port": "端口",
"ai.prefs.control_port_note": "AI 代理在本机上连接的端口 (127.0.0.1)。默认为 27878:若被占用,则使用下一个空闲端口。其他端口按原样使用。请让 MCP 客户端指向同一端口,例如 solidcraft-cli mcp --connect 127.0.0.1:7878。",
"ai.prefs.control_listening": "正在监听 127.0.0.1:{port}",
"ai.prefs.control_not_listening": "未在监听",
"ai.prefs.control_port_taken": "端口 {port} 已被其他程序(或另一个 SolidCraft 窗口)占用。请选择其他端口。",
"ai.prefs.control_failed": "控制通道无法启动:{error}",
"ai.prefs.control_from_launch": "本次运行通过 --control 在端口 {port} 上启动。此处的更改将在下次不带 --control 启动 SolidCraft 时生效。"
},
"ko": {
"ai.prefs.control_port": "포트",
"ai.prefs.control_port_note": "이 컴퓨터에서 AI 에이전트가 연결하는 포트(127.0.0.1)입니다. 기본값은 27878이며, 사용 중이면 다음 빈 포트를 사용합니다. 다른 포트는 그대로 사용합니다. MCP 클라이언트도 같은 포트를 가리키게 하세요(예: solidcraft-cli mcp --connect 127.0.0.1:7878).",
"ai.prefs.control_listening": "127.0.0.1:{port}에서 대기 중",
"ai.prefs.control_not_listening": "대기하지 않음",
"ai.prefs.control_port_taken": "포트 {port}을(를) 다른 프로그램(또는 다른 SolidCraft 창)이 사용 중입니다. 다른 포트를 선택하세요.",
"ai.prefs.control_failed": "제어 채널을 시작할 수 없습니다: {error}",
"ai.prefs.control_from_launch": "이번 실행은 --control로 포트 {port}에서 시작되었습니다. 여기서 바꾼 내용은 다음에 --control 없이 SolidCraft를 시작할 때 적용됩니다."
},
"vi": {
"ai.prefs.control_port": "Cổng",
"ai.prefs.control_port_note": "Cổng mà các tác tử AI kết nối trên máy tính này (127.0.0.1). Mặc định là 27878: nếu cổng đang được dùng, cổng trống tiếp theo sẽ được dùng. Mọi cổng khác được dùng đúng như vậy. Hãy trỏ ứng dụng MCP của bạn đến cùng cổng, ví dụ solidcraft-cli mcp --connect 127.0.0.1:7878.",
"ai.prefs.control_listening": "Đang lắng nghe trên 127.0.0.1:{port}",
"ai.prefs.control_not_listening": "Không lắng nghe",
"ai.prefs.control_port_taken": "Cổng {port} đang được một chương trình khác (hoặc một cửa sổ SolidCraft khác) sử dụng. Hãy chọn cổng khác.",
"ai.prefs.control_failed": "Không thể khởi động kênh điều khiển: {error}",
"ai.prefs.control_from_launch": "Lần chạy này được khởi động với --control trên cổng {port}. Các thay đổi ở đây có hiệu lực vào lần tới SolidCraft khởi động mà không có --control."
},
"ar": {
"ai.prefs.control_port": "المنفذ",
"ai.prefs.control_port_note": "المنفذ الذي يتصل عبره وكلاء الذكاء الاصطناعي على هذا الحاسوب (127.0.0.1). الافتراضي هو 27878: إذا كان مستخدمًا يُستخدم المنفذ الحر التالي. أي منفذ آخر يُستخدم كما هو. وجّه عميل MCP إلى المنفذ نفسه، مثل solidcraft-cli mcp --connect 127.0.0.1:7878.",
"ai.prefs.control_listening": "يستمع على 127.0.0.1:{port}",
"ai.prefs.control_not_listening": "لا يستمع",
"ai.prefs.control_port_taken": "المنفذ {port} يستخدمه برنامج آخر (أو نافذة SolidCraft أخرى). اختر منفذًا آخر.",
"ai.prefs.control_failed": "تعذّر بدء قناة التحكم: {error}",
"ai.prefs.control_from_launch": "بدأ هذا التشغيل باستخدام --control على المنفذ {port}. تسري التغييرات هنا عند تشغيل SolidCraft في المرة القادمة دون --control."
},
"he": {
"ai.prefs.control_port": "פורט",
"ai.prefs.control_port_note": "הפורט שאליו סוכני AI מתחברים במחשב זה (127.0.0.1). ברירת המחדל היא 27878: אם הוא תפוס, נלקח הפורט הפנוי הבא. כל פורט אחר משמש בדיוק כפי שהוא. כוונו את לקוח ה-MCP לאותו פורט, למשל solidcraft-cli mcp --connect 127.0.0.1:7878.",
"ai.prefs.control_listening": "מאזין ב-127.0.0.1:{port}",
"ai.prefs.control_not_listening": "לא מאזין",
"ai.prefs.control_port_taken": "פורט {port} בשימוש של תוכנה אחרת (או חלון SolidCraft אחר). בחרו פורט אחר.",
"ai.prefs.control_failed": "לא ניתן היה להפעיל את ערוץ הבקרה: {error}",
"ai.prefs.control_from_launch": "הפעלה זו התחילה עם --control בפורט {port}. שינויים כאן ייכנסו לתוקף בפעם הבאה ש-SolidCraft יופעל ללא --control."
}
}
+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": "编辑备注"
}
}
@@ -120,9 +120,9 @@
"ui.right_click_confirms_tip": "對話方塊開啟時,在視圖中按右鍵會轉到下一個空白輸入,在最後一個輸入上執行 OK"
},
"ar": {
"dialog.right_click_pick": "اختر {input} أولاً",
"ui.right_click_confirms": "النقر بزر الماوس الأيمن يؤكد مدخلات مربع الحوار",
"ui.right_click_confirms_tip": "عندما يكون مربع حوار مفتوحاً، ينتقل النقر بزر الماوس الأيمن في العرض إلى المدخل الفارغ التالي، وعند المدخل الأخير ينفّذ موافق"
"dialog.right_click_pick": "حدد {input} أولًا",
"ui.right_click_confirms": "النقر بزر الفأرة الأيمن يؤكد مدخلات مربع الحوار",
"ui.right_click_confirms_tip": "عندما يكون مربع حوار مفتوحًا، ينتقل النقر بزر الفأرة الأيمن في العرض إلى المدخل الفارغ التالي، وعند المدخل الأخير ينفّذ «موافق»"
},
"he": {
"dialog.right_click_pick": "יש לבחור תחילה {input}",
+1 -1
View File
@@ -7,7 +7,7 @@
"document.save_row": "حفظ",
"document.save_selected": "حفظ المحدد",
"prefs.save_prompt_one_at_a_time": "السؤال عن كل تصميم غير محفوظ على حدة عند إغلاق عدة تصاميم",
"prefs.save_prompt_one_at_a_time_help": "عرض نافذة حفظ لكل تصميم غير محفوظ بدلاً من قائمة مجمّعة."
"prefs.save_prompt_one_at_a_time_help": "عرض نافذة حفظ لكل تصميم غير محفوظ بدلًا من قائمة مجمّعة."
},
"de": {
"document.discard_row": "Verwerfen",
@@ -0,0 +1,8 @@
{
"en": { "cmd.selection_get": "Get Selection" },
"ar": { "cmd.selection_get": "الحصول على التحديد" },
"he": { "cmd.selection_get": "קבלת הבחירה" },
"ja": { "cmd.selection_get": "選択を取得" },
"vi": { "cmd.selection_get": "Lấy vùng chọn" },
"zh": { "cmd.selection_get": "获取选择" }
}
@@ -5,7 +5,7 @@
},
"ar": {
"prefs.sketch.cursor_coordinates": "إظهار إحداثيات المؤشر في الرسم",
"prefs.sketch.cursor_coordinates_note": "بجانب المؤشر، قيمتا X وY من أصل الرسم، حتى دون تشغيل أي أداة. إيقاف: فقط أثناء وضع أداة لنقطة."
"prefs.sketch.cursor_coordinates_note": "بجانب المؤشر، قيمتا X وY من أصل الرسم، حتى دون تشغيل أي أداة. عند الإيقاف: فقط عندما تضع أداةٌ نقطة."
},
"de": {
"prefs.sketch.cursor_coordinates": "Cursorkoordinaten in der Skizze anzeigen",
@@ -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})。"
}
}
@@ -5,8 +5,8 @@
"prefs.sketch.max_points_unit": "points"
},
"ar": {
"prefs.sketch.max_points": "أكبر رسم تخطيطي",
"prefs.sketch.max_points_note": "أكبر عدد من النقاط أو المنحنيات في رسم تخطيطي واحد: 20000 افتراضيًا، و200000 كحد أقصى. يرفض إدراج SVG وإدراج DXF رسمًا أكبر. الرسم التخطيطي الكبير يُحل ويُعرض بشكل أبطأ.",
"prefs.sketch.max_points": "أكبر حجم للرسم",
"prefs.sketch.max_points_note": "أكبر عدد من النقاط أو المنحنيات في رسم واحد: 20000 افتراضيًا، و200000 كحد أقصى. يرفض «إدراج SVG» و«إدراج DXF» رسمًا أكبر. الرسم الكبير يُحل ويُعرض بشكل أبطأ.",
"prefs.sketch.max_points_unit": "نقطة"
},
"de": {
@@ -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": "底端"
}
}
+1214 -212
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
| assets/locales/pending/agent-chat-select-all.json | SolidCraft contributors | Original assistant chat select-all strings and translations | MIT OR Apache-2.0 |
@@ -0,0 +1 @@
| assets/locales/pending/constraint-glyph-size.json | SolidCraft contributors | Original constraint glyph size preference label and help text | MIT OR Apache-2.0 |
@@ -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) |
+118
View File
@@ -0,0 +1,118 @@
//! Where a control request is, so the connection that sent it can cancel it (`control.cancel`,
//! #1989): shared between the window, which moves it along, and the connection. Whoever changes
//! it first wins.
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
use std::sync::{Mutex, PoisonError};
/// A request's place in the app, and how to stop the work computing it.
#[derive(Default)]
pub struct Ticket {
state: AtomicU8,
/// It is an export computing on a copy, whose worker writes its file.
writes_file: AtomicBool,
/// Tells its worker to stop: set by the window when it hands the request to one.
stopper: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
}
impl std::fmt::Debug for Ticket {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Ticket").field("state", &self.state).field("writes_file", &self.writes_file).finish_non_exhaustive()
}
}
/// Waiting its turn (sent, or held behind another request).
const WAITING: u8 = 0;
/// Running on the window thread (or handed to something that can't stop it, such as the file
/// loader).
const RUNNING: u8 = 1;
/// Computing off the window thread.
const COMPUTING: u8 = 2;
const CANCELLED: u8 = 3;
/// What [`Ticket::cancel`] did.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Cancel {
/// It was waiting its turn: it will not run.
Removed,
/// It was computing off the window thread: it is answered as stopped and nothing of it is
/// applied. An export's worker may still write its file.
Stopped { writes_file: bool },
/// It runs on the window thread: it will finish.
Running,
/// It was cancelled or stopped before.
Already,
}
impl Ticket {
/// Cancel the request, unless it already runs on the window thread.
pub fn cancel(&self) -> Cancel {
let mut state = self.state.load(Ordering::Acquire);
loop {
let done = match state {
WAITING => Cancel::Removed,
COMPUTING => Cancel::Stopped { writes_file: self.writes_file.load(Ordering::Acquire) },
CANCELLED => return Cancel::Already,
_ => return Cancel::Running,
};
match self.state.compare_exchange(state, CANCELLED, Ordering::AcqRel, Ordering::Acquire) {
Ok(_) => {
self.stop_worker();
return done;
}
Err(now) => state = now,
}
}
}
pub fn is_cancelled(&self) -> bool {
self.state.load(Ordering::Acquire) == CANCELLED
}
/// The window takes the request to run it: false when it was cancelled.
pub fn take(&self) -> bool {
self.moves(WAITING, RUNNING)
}
/// The work computing it stops on `stop` (a worker's own stop flag). Cancelled already, it is
/// stopped at once.
pub fn on_stop(&self, stop: impl Fn() + Send + Sync + 'static) {
*self.stopper.lock().unwrap_or_else(PoisonError::into_inner) = Some(Box::new(stop));
if self.is_cancelled() {
self.stop_worker();
}
}
/// The window hands it to a worker (`writes_file`: an export on a copy).
pub fn computing(&self, writes_file: bool) {
self.writes_file.store(writes_file, Ordering::Release);
let _ = self.moves(RUNNING, COMPUTING) || self.moves(WAITING, COMPUTING);
}
/// Its worker is done and it runs on the window thread now (or it never left): false when it
/// was cancelled meanwhile. Only the window moves it on from running, so running stays running.
pub fn back(&self) -> bool {
self.moves(COMPUTING, RUNNING) || self.state.load(Ordering::Acquire) == RUNNING
}
/// Computed, but it waits its turn again (a file opening meanwhile): false when cancelled.
pub fn wait_again(&self) -> bool {
self.moves(COMPUTING, WAITING)
}
/// The person at the app stopped it (Stop): its worker stops too.
pub fn stop(&self) {
self.state.store(CANCELLED, Ordering::Release);
self.stop_worker();
}
fn stop_worker(&self) {
if let Some(stop) = self.stopper.lock().unwrap_or_else(PoisonError::into_inner).as_ref() {
stop();
}
}
fn moves(&self, from: u8, to: u8) -> bool {
self.state.compare_exchange(from, to, Ordering::AcqRel, Ordering::Acquire).is_ok()
}
}
+2
View File
@@ -8,10 +8,12 @@
#![forbid(unsafe_code)]
pub mod auth;
mod cancel;
mod effect;
mod request;
#[cfg(not(target_arch = "wasm32"))]
pub mod server;
pub use cancel::{Cancel, Ticket};
pub use effect::Effect;
pub use request::{ControlRequest, ControlResponse, DOCUMENT_WATCH, DocumentWatch, Subscriber, cancelled_chat_response, chat_turn_cancelled};
+4 -1
View File
@@ -18,12 +18,15 @@ pub struct ControlRequest {
/// Chat requests already admitted by the gate may outlive a tab switch. This turn's
/// validity follows them through queues, computation and cursor animation.
pub chat_turn: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
/// Where the request is, shared with the connection that sent it so it can cancel it
/// (`control.cancel`, #1989).
pub ticket: std::sync::Arc<crate::Ticket>,
}
impl ControlRequest {
pub fn new(method: impl Into<String>, params: Value) -> (Self, std::sync::mpsc::Receiver<ControlResponse>) {
let (tx, rx) = std::sync::mpsc::channel();
(Self { method: method.into(), params, reply: tx, started: Default::default(), chat_turn: None }, rx)
(Self { method: method.into(), params, reply: tx, started: Default::default(), chat_turn: None, ticket: Default::default() }, rx)
}
/// Answer a request whose agent chat turn has ended, and say so: such a request is not run.
+276 -11
View File
@@ -14,6 +14,7 @@ use serde_json::{Value, json};
use crate::Effect;
use crate::auth::tokens_match;
use crate::cancel::{Cancel, Ticket};
use crate::request::{ControlRequest, DOCUMENT_WATCH, DocumentWatch, Subscriber};
/// What the server needs to know of a request before it sends it to the window: how to name it
@@ -94,28 +95,101 @@ impl Hub {
/// on it). A listener that is not on a loopback address is refused: the channel never goes on
/// the network.
pub fn listen(self: &Arc<Self>, listener: TcpListener, token: String, requests: Sender<ControlRequest>, patient: bool) -> std::io::Result<()> {
self.listen_stoppable(listener, token, requests, patient).map(drop)
}
/// [`Hub::listen`], and a [`Stop`] that closes the listener and the connections it accepted
/// (a channel turned off in the app's settings). Dropping the [`Stop`] leaves it serving.
pub fn listen_stoppable(
self: &Arc<Self>,
listener: TcpListener,
token: String,
requests: Sender<ControlRequest>,
patient: bool,
) -> std::io::Result<Stop> {
let addr = listener.local_addr()?;
if !addr.ip().is_loopback() {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("the control channel listens on loopback only, not {addr}")));
}
let hub = Arc::clone(self);
let token = Arc::new(token);
let shared = Arc::new(Accepted::default());
let (closed_tx, closed) = channel::<()>();
let stop = Stop { addr, shared: Arc::clone(&shared), closed };
std::thread::spawn(move || {
// Dropped when this thread ends, with the listener: `Stop::stop` waits for that.
let _closed = closed_tx;
for stream in listener.incoming().flatten() {
if shared.stopped.load(Ordering::SeqCst) {
break;
}
if hub.open.fetch_add(1, Ordering::Relaxed) >= MAX_CONNECTIONS {
hub.open.fetch_sub(1, Ordering::Relaxed);
let mut refused = stream;
let _ = send(&mut refused, &json!({"ok": false, "error": "too many connections"}));
continue;
}
let (hub, tx, token) = (Arc::clone(&hub), requests.clone(), Arc::clone(&token));
let id = shared.next.fetch_add(1, Ordering::Relaxed);
if let Ok(copy) = stream.try_clone() {
shared.lock().push((id, copy));
}
// Stopped while this one was being accepted: it is closed with the rest.
if shared.stopped.load(Ordering::SeqCst) {
shared.close_all();
}
let (hub, tx, token, shared) = (Arc::clone(&hub), requests.clone(), Arc::clone(&token), Arc::clone(&shared));
std::thread::spawn(move || {
hub.serve(stream, &token, tx, patient);
hub.open.fetch_sub(1, Ordering::Relaxed);
shared.lock().retain(|(i, _)| *i != id);
});
}
});
Ok(())
Ok(stop)
}
}
/// The connections a stoppable listener accepted, so stopping it can close them too.
#[derive(Default)]
struct Accepted {
stopped: AtomicBool,
next: AtomicU64,
streams: Mutex<Vec<(u64, TcpStream)>>,
}
impl Accepted {
fn lock(&self) -> MutexGuard<'_, Vec<(u64, TcpStream)>> {
self.streams.lock().unwrap_or_else(PoisonError::into_inner)
}
fn close_all(&self) {
for (_, stream) in self.lock().drain(..) {
let _ = stream.shutdown(std::net::Shutdown::Both);
}
}
}
/// Stops a listener started with [`Hub::listen_stoppable`].
pub struct Stop {
addr: std::net::SocketAddr,
shared: Arc<Accepted>,
closed: Receiver<()>,
}
impl Stop {
/// The port the listener is on.
pub fn port(&self) -> u16 {
self.addr.port()
}
/// Stop accepting, close the port and end every connection it accepted. Returns once the
/// port is closed (at most a couple of seconds), so the same port can be bound again at once.
pub fn stop(self) {
self.shared.stopped.store(true, Ordering::SeqCst);
// The listener waits in `accept`: a connection of our own wakes it to see the flag.
let _ = TcpStream::connect_timeout(&self.addr, Duration::from_secs(1));
let _ = self.closed.recv_timeout(Duration::from_secs(2));
self.shared.close_all();
}
}
@@ -277,6 +351,62 @@ struct Pending {
/// The request in messages ("`engine.execute` (solid.fillet)").
what: String,
reply: Receiver<Value>,
/// Where it is in the app, to cancel it (`control.cancel`).
job: Arc<Ticket>,
}
/// `control.cancel {id?}`: cancel the request this connection still waits for (the one
/// `control.pending` reports; `id`, when given, must name it). A connection can only cancel its
/// own requests. Waiting its turn, it is removed; computing off the window thread, it is stopped
/// as the app's Stop stops it. Either way nothing of it is applied and the connection is free.
/// One that runs on the window thread can't be cancelled; one that finished is answered.
fn cancel(pending: &mut Option<Pending>, late: Option<Value>, id: Option<&Value>, document: &str) -> Value {
let id = id.filter(|v| !v.is_null());
let mine = pending.as_ref().filter(|p| id.is_none_or(|id| *id == p.id));
let Some(p) = mine else {
return match id {
None => json!({"ok": true, "result": {"cancelled": null, "note": "nothing to cancel: no request of this connection is running"}}),
Some(id) => json!({"ok": false, "error": format!(
"nothing to cancel: request {} is not running on this connection (a connection can only cancel its own requests)",
id.to_string().chars().take(64).collect::<String>()
)}),
};
};
let (what, id) = (format!("request {} ({})", p.id, p.what), p.id.clone());
let finished = |p: Pending, reply: Value| {
json!({"ok": true, "result": {"cancelled": null, "pending": p.id, "method": p.method, "done": true, "reply": reply,
"note": format!("nothing to cancel: {what} had already finished; its reply is here")}})
};
if let Some(reply) = late {
return pending.take().map_or(Value::Null, |p| finished(p, reply));
}
let outcome = p.job.cancel();
let note = match outcome {
Cancel::Removed => format!("cancelled: {what} was still waiting its turn and will not run; nothing was applied"),
Cancel::Stopped { writes_file: false } => format!("stopped: {what} was computing off the window thread; nothing was applied"),
Cancel::Stopped { writes_file: true } => {
format!("stopped: {what} was computing on a copy of the {document}, which is unchanged; its worker may still write the file")
}
Cancel::Already => format!("{what} was already cancelled or stopped; nothing of it was applied"),
Cancel::Running => {
// It may have just finished.
if let Some(reply) = pending.as_ref().and_then(|p| p.reply.try_recv().ok()) {
return pending.take().map_or(Value::Null, |p| finished(p, reply));
}
return json!({"ok": false, "cancellable": false, "error": format!(
"cannot be cancelled: {what} already runs in the app (on its window thread, or as a file it opens) and will \
still finish; ask `control.pending` for its result. Only a request still waiting its turn, or a change or \
export computing off the window thread, can be cancelled"
)});
}
};
let method = pending.take().map(|p| p.method);
let how = match outcome {
Cancel::Removed => "removed",
Cancel::Stopped { .. } => "stopped",
_ => "already",
};
json!({"ok": true, "result": {"cancelled": id, "method": method, "outcome": how, "note": note}})
}
/// Every request the app has been sent and not answered yet, from every connection, for
@@ -311,7 +441,7 @@ impl Running {
/// List `req`, sent on `connection`, until the returned ticket is dropped; `command` and
/// `steps` as the app's [`Protocol`] summarised it.
fn add(self: &Arc<Self>, connection: u64, id: &Value, req: &ControlRequest, command: Option<&'static str>, steps: Option<usize>) -> Ticket {
fn add(self: &Arc<Self>, connection: u64, id: &Value, req: &ControlRequest, command: Option<&'static str>, steps: Option<usize>) -> Listing {
let key = self.keys.fetch_add(1, Ordering::Relaxed);
// Ids and methods come from the client: only short ones are repeated to the others.
let id = match id {
@@ -322,7 +452,7 @@ impl Running {
let method = req.method.chars().take(64).collect();
let started = req.started.clone();
self.lock().push(InFlight { key, connection, id, method, command, steps, since: Instant::now(), started });
Ticket { running: Arc::clone(self), key }
Listing { running: Arc::clone(self), key }
}
/// The `control.running` result, as `asker`'s connection sees it.
@@ -355,12 +485,12 @@ impl Running {
}
/// A listed request; dropped when its reply arrives.
struct Ticket {
struct Listing {
running: Arc<Running>,
key: u64,
}
impl Drop for Ticket {
impl Drop for Listing {
fn drop(&mut self) {
let key = self.key;
self.running.lock().retain(|e| e.key != key);
@@ -369,7 +499,7 @@ impl Drop for Ticket {
/// The reply to a timed-out request, received on a thread of its own so the request stays listed
/// until the app answers, also once its connection has closed (#3654).
fn watch(reply: Receiver<Value>, ticket: Ticket) -> Receiver<Value> {
fn watch(reply: Receiver<Value>, ticket: Listing) -> Receiver<Value> {
let (late_tx, late_rx) = channel();
// A small thread per timed-out request (each connection has at most one). If none can be
// made, the reply is lost and `control.pending` says the app ended the request without one.
@@ -395,11 +525,12 @@ pub fn timeout_message(what: &str, effect: Effect, document: &str) -> String {
}
Effect::WritesFile => format!(
"timeout: {what} is still running in the app and will still write the file when it finishes (it was not cancelled: \
don't send it again; the {document} does not change); ask `control.pending` for its result"
don't send it again; the {document} does not change); ask `control.pending` for its result, or `control.cancel` to \
cancel it"
),
Effect::Changes => format!(
"timeout: {what} is still running in the app and will still apply when it finishes (it was not cancelled: don't send it \
again); ask `control.pending` for its result"
again); ask `control.pending` for its result, or `control.cancel` to cancel it"
),
}
}
@@ -597,6 +728,10 @@ impl Hub {
json!({"ok": true, "result": running.result(connection)})
} else if method == "control.capabilities" {
json!({"ok": true, "result": self.protocol.capabilities()})
} else if method == "control.cancel" {
let r = cancel(&mut pending, late, params.get("id"), self.protocol.document());
(self.wake)();
r
} else if method == "control.pending" {
match (pending.take(), late) {
(None, _) => json!({"ok": true, "result": {"pending": null}}),
@@ -636,6 +771,7 @@ impl Hub {
// answer on.
joining = (method == "control.subscribe").then(|| documents.join());
let (req, rrx) = ControlRequest::new(method.clone(), params);
let job = Arc::clone(&req.ticket);
let ticket = running.add(connection, &id, &req, command, steps);
if tx.send(req).is_err() {
break;
@@ -645,7 +781,7 @@ impl Hub {
Ok(r) => r,
Err(_) => {
let r = json!({"ok": false, "running": true, "error": timeout_message(&what, effect, self.protocol.document())});
pending = Some(Pending { id: id.clone(), method, what, reply: watch(rrx, ticket) });
pending = Some(Pending { id: id.clone(), method, what, reply: watch(rrx, ticket), job });
r
}
}
@@ -661,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);
@@ -807,6 +943,104 @@ mod tests {
assert!(l.contains("unauthorized"), "{l}");
}
/// #1989: `control.cancel` cancels the connection's own outstanding request. One still
/// waiting its turn is removed and never runs, and the connection is free at once; one that
/// already runs in the app can't be, and says so; another connection's request is refused;
/// with nothing outstanding there is nothing to cancel.
#[test]
fn control_cancel_cancels_only_this_connections_request() {
let token = "0123456789abcdef0123456789abcdef";
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = channel::<ControlRequest>();
let (gate_tx, gate_rx) = channel::<()>();
let ran = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
let seen = ran.clone();
// A stand-in for the window thread: `queued` waits behind the others (never taken until
// `slow` is done), `slow` runs until the gate opens.
std::thread::spawn(move || {
let mut held = Vec::new();
for req in rx {
if req.method == "queued" {
held.push(req);
continue;
}
if !req.ticket.take() {
continue;
}
seen.lock().unwrap().push(req.method.clone());
if req.method == "slow" {
let _ = gate_rx.recv();
}
let _ = req.reply.send(json!({"ok": true, "result": req.method}));
if req.method == "slow" {
for req in held.drain(..) {
if req.ticket.take() {
seen.lock().unwrap().push(req.method.clone());
}
}
}
}
});
let waits = Waits { short: Duration::from_millis(100), long: Duration::from_millis(100), token: TOKEN_WAIT };
let hub = hub(waits, &DOCUMENT_WATCH);
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
let (tx, hub) = (tx.clone(), Arc::clone(&hub));
std::thread::spawn(move || hub.serve(stream, token, tx, false));
}
});
let connect = || {
let s = TcpStream::connect(addr).unwrap();
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
let out = s.try_clone().unwrap();
(out, BufReader::new(s))
};
let ask = |(out, lines): &mut (TcpStream, BufReader<TcpStream>), v: Value| -> Value {
writeln!(out, "{v}").unwrap();
let mut l = String::new();
lines.read_line(&mut l).unwrap();
serde_json::from_str(&l).unwrap()
};
let mut a = connect();
let mut b = connect();
let r = ask(&mut b, json!({"id": 1, "method": "control.cancel", "token": token}));
assert!(r["ok"] == true && r["result"]["cancelled"].is_null(), "nothing to cancel: {r}");
// Waiting its turn: removed, never run, and the connection is free.
let r = ask(&mut a, json!({"id": 1, "method": "queued", "token": token}));
assert_eq!(r["running"], true, "{r}");
let r = ask(&mut a, json!({"id": 2, "method": "control.cancel", "params": {"id": 1}}));
assert_eq!((&r["ok"], &r["result"]["cancelled"], &r["result"]["outcome"]), (&json!(true), &json!(1), &json!("removed")), "{r}");
assert!(r["result"]["note"].as_str().is_some_and(|n| n.contains("will not run")), "{r}");
let r = ask(&mut a, json!({"id": 3, "method": "control.pending"}));
assert_eq!(r["result"]["pending"], Value::Null, "{r}");
// Already running in the app: it can't be, and it still answers.
let r = ask(&mut a, json!({"id": 4, "method": "slow"}));
assert_eq!(r["running"], true, "{r}");
let r = ask(&mut b, json!({"id": 2, "method": "control.cancel", "params": {"id": 4}}));
assert!(r["ok"] == false && r["error"].as_str().is_some_and(|e| e.contains("its own requests")), "another connection's: {r}");
let r = ask(&mut a, json!({"id": 5, "method": "control.cancel", "params": {"id": 99}}));
assert!(r["ok"] == false && r["error"].as_str().is_some_and(|e| e.starts_with("nothing to cancel")), "{r}");
let r = ask(&mut a, json!({"id": 6, "method": "control.cancel"}));
assert!(r["ok"] == false && r["cancellable"] == false, "{r}");
assert!(r["error"].as_str().is_some_and(|e| e.starts_with("cannot be cancelled") && e.contains("control.pending")), "{r}");
gate_tx.send(()).unwrap();
let deadline = Instant::now() + Duration::from_secs(10);
let r = loop {
let r = ask(&mut a, json!({"id": 7, "method": "control.pending"}));
if r["result"]["done"] == true || Instant::now() > deadline {
break r;
}
std::thread::sleep(Duration::from_millis(10));
};
assert_eq!(r["result"]["reply"]["result"], "slow", "{r}");
let r = ask(&mut a, json!({"id": 8, "method": "control.cancel"}));
assert!(r["ok"] == true && r["result"]["cancelled"].is_null(), "{r}");
assert_eq!(*ran.lock().unwrap(), ["slow"], "the cancelled request never ran");
}
/// #1618: a request that outlasts its wait says it is still running; the connection keeps its
/// reply for `control.pending`, and refuses other requests as `busy` until it lands, so none
/// is queued to run unseen behind it.
@@ -1154,4 +1388,35 @@ mod tests {
assert_eq!(out.bytes.last(), Some(&b'\n'));
assert_eq!(serde_json::from_slice::<Value>(&out.bytes).unwrap(), reply);
}
/// A stopped listener frees its port at once and ends the connections it had, so a channel
/// turned off in the settings leaves nothing listening and can be turned on again on the
/// same port.
#[test]
fn a_stopped_listener_closes_its_port_and_its_connections() {
use std::io::Read;
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let port = listener.local_addr().unwrap().port();
let (tx, _rx) = channel();
let stop = hub(Waits::default(), &DOCUMENT_WATCH).listen_stoppable(listener, "0123456789abcdef0123456789abcdef".into(), tx, false).unwrap();
assert_eq!(stop.port(), port);
let mut client = TcpStream::connect(("127.0.0.1", port)).unwrap();
client.set_read_timeout(Some(Duration::from_secs(4))).unwrap();
// Wait until the server holds the connection, so stopping has one to close.
let held = Instant::now();
while stop.shared.lock().is_empty() && held.elapsed() < Duration::from_secs(4) {
std::thread::sleep(Duration::from_millis(5));
}
let started = Instant::now();
stop.stop();
let mut byte = [0u8; 1];
match client.read(&mut byte) {
Ok(n) => assert_eq!(n, 0, "the connection ends"),
Err(e) => {
assert!(!matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut), "the connection ends, not waits: {e}")
}
}
assert!(started.elapsed() < Duration::from_secs(3), "before the token wait runs out");
assert!(TcpListener::bind(("127.0.0.1", port)).is_ok(), "the port is free again");
}
}
+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(),
}
}
+30 -1
View File
@@ -1033,6 +1033,10 @@ pub enum FeatureKind {
length: Option<Expr>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
modeled: bool,
/// Continuous flank faces for this feature. Missing in older designs: keep
/// their half-turn faces and groove-before-chamfer order for saved references.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
continuous_flanks: bool,
/// A point near the end of the face the thread starts at; `None`: the face's only free
/// end (no material past it), or the end the pick at `face` lies near.
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -1083,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,
@@ -1700,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")]
@@ -1807,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,
@@ -3391,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
@@ -3420,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>,
@@ -3447,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()
@@ -3629,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))
@@ -3684,6 +3712,7 @@ impl Document {
timeline_groups: Vec::new(),
browser_order: Default::default(),
named_views: Vec::new(),
notes: Vec::new(),
visibility: Visibility::default(),
}
}
+1128 -135
View File
File diff suppressed because it is too large Load Diff
+53
View File
@@ -141,6 +141,8 @@ fn joint_face_measurements_are_shared_across_motion_samples_and_snap_kinds() {
angle: 0.0,
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.
@@ -1412,6 +1414,7 @@ fn marking(p: Vec3) -> FeatureKind {
chamfer_angle: None,
chamfer_depth: None,
steep_flank: Default::default(),
continuous_flanks: false,
old_auto_size: false,
}
}
@@ -2000,6 +2003,56 @@ fn a_hole_classifies_the_faces_it_reads_not_every_face_of_the_design() {
assert!((side - Vec3::new(-1.0, 0.0, 0.0)).len() < 1e-6, "into the side wall: {side:?}");
}
/// A cut whose target name is taken by another component's sheet, while a body of the cut's own
/// component was asked for that name, is refused before the sheet is machined (#12141).
#[test]
fn an_ambiguous_target_is_refused_before_a_sheet_is_machined() {
let mut doc = Document::new("names");
let linked = doc.add_component(Some("Encoder"), 0).unwrap();
let local = doc.add_component(Some("Assembly"), 0).unwrap();
flat_sheet(&mut doc, Vec2::new(0.0, 0.0), 40.0, 40.0);
for f in doc.features.iter_mut() {
f.component = linked;
}
let base = doc.features.last().unwrap().id;
doc.feature_mut(base).unwrap().body_names = vec!["Plate".into()];
let own = doc
.add_feature(
FeatureKind::Box {
corner: point_expr(Vec3::new(100.0, 0.0, 0.0)),
length: "10".into(),
width: "10".into(),
height: "2".into(),
operation: Operation::NewBody,
},
None,
)
.unwrap();
doc.feature_mut(own).unwrap().component = local;
doc.feature_mut(own).unwrap().body_names = vec!["Plate".into()];
let cut = doc
.add_feature(
FeatureKind::Box {
corner: point_expr(Vec3::new(10.0, 10.0, -5.0)),
length: "5".into(),
width: "5".into(),
height: "20".into(),
operation: Operation::Cut,
},
None,
)
.unwrap();
doc.feature_mut(cut).unwrap().component = local;
doc.feature_mut(cut).unwrap().participants = vec!["Plate".into()];
let mut model = Model::new();
model.evaluate(&doc);
let err = model.result(cut).and_then(|r| r.error.clone()).expect("the cut is refused");
assert!(err.contains("ambiguous") && err.contains("`Encoder`"), "{err}");
let st = model.state();
let sheet = st.sheets.iter().find(|s| s.body == "Plate").expect("the linked sheet");
assert!(sheet.holes.is_empty(), "the linked sheet is not machined");
}
/// A hole's look at the material over its entry face (#10695) is best effort: when the kernel
/// cannot build the run-on's column (here a column of no width), the hole keeps its full run-on
/// and cuts nothing out of its tools, as before the check, instead of failing.
+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.
+35 -18
View File
@@ -765,6 +765,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
designation,
length,
modeled,
continuous_flanks,
from,
up_to,
form,
@@ -827,7 +828,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
None => note,
});
}
if let Some(note) = cut(st, on, (*tip, angle, depth))? {
if let Some(note) = cut_with_flanks(st, on, (*tip, angle, depth), *continuous_flanks)? {
*warning = Some(match warning.take() {
Some(w) => format!("{w}; {note}"),
None => note,
@@ -846,11 +847,18 @@ pub(super) fn runout(vals: &BTreeMap<String, Value>, e: &Option<crate::ThreadRun
})
}
/// Cut a modelled thread resolved on its face into its body: the grooves (SMLib's exact screw
/// sweeps, a taper's crests by its envelope) by one B-rep boolean, then the chamfered tip
/// (`tip`, with its angle and depth when given). What the user should be told about the result, if
/// anything, is returned.
/// Cut a modelled thread with its historical half-turn faces and groove-before-chamfer
/// order. Existing hole features use this layout; Thread features keep their saved choice.
pub(super) fn cut(st: &mut ModelState, on: ThreadOn, (tip, angle, depth): (ThreadTip, Option<f64>, Option<f64>)) -> Result<Option<String>> {
cut_with_flanks(st, on, (tip, angle, depth), false)
}
fn cut_with_flanks(
st: &mut ModelState,
on: ThreadOn,
(tip, angle, depth): (ThreadTip, Option<f64>, Option<f64>),
continuous_flanks: bool,
) -> Result<Option<String>> {
let ThreadOn { info: t, cyl, ends, steep, ease, from: from_side, narrow, shoulder_unchecked } = on;
let i = st.bodies.iter().position(|b| b.name == t.body).ok_or_else(|| DocError::Invalid("threaded body".into()))?;
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
@@ -875,7 +883,7 @@ pub(super) fn cut(st: &mut ModelState, on: ThreadOn, (tip, angle, depth): (Threa
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.
@@ -888,11 +896,28 @@ pub(super) fn cut(st: &mut ModelState, on: ThreadOn, (tip, angle, depth): (Threa
}
false => None,
};
// Preserve historical construction order when rebuilding an older feature.
let chamfer = |mut input: Traced| -> Result<Traced> {
if tip != ThreadTip::None {
for cone in tip_cones(&t, radius, ends, (tip, from_side), angle, depth, taper)? {
let faces = cone.face_count();
let cut =
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, recipe: None };
}
}
Ok(input)
};
if continuous_flanks {
input = chamfer(input)?;
}
let cut_grooves = |ends: [End; 2]| -> Result<(Option<Body>, usize)> {
let mut grooves = Vec::new();
for g in thread_grooves(&t, groove_radius, (cyl.start, cyl.end), ends, steep, ease, taper)? {
let section = Region2 { outer: solidcore_record::Loop2::polygon(&g.section), holes: Vec::new() };
grooves.push(kernel::sweep_helix_radial(&g.plane, &section, g.base, g.axis, g.pitch, g.turns, &g.law)?);
grooves.push(kernel::sweep_helix_radial(&g.plane, &section, g.base, g.axis, g.pitch, g.turns, &g.law, continuous_flanks)?);
}
if let Some(taper) = taper
&& let Some(envelope) = taper_envelope(&t, radius, ends, taper)?
@@ -932,16 +957,8 @@ pub(super) fn cut(st: &mut ModelState, on: ThreadOn, (tip, angle, depth): (Threa
// 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 mut nb = Traced { body: nb, lineage };
if tip != ThreadTip::None {
for cone in tip_cones(&t, radius, ends, (tip, from_side), angle, depth, taper)? {
let faces = cone.face_count();
let cut = kernel::boolean(&nb.body, &cone, BoolOp::Cut)?.ok_or_else(|| DocError::Invalid("the chamfer removed everything".into()))?;
let unnamed = crate::lineage::Lineage::unnamed_faces(faces);
let lineage = nb.lineage.and_then(|l| crate::lineage::Lineage::step(&cut, &[&l, &unnamed]));
nb = Traced { body: cut, 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);
}
@@ -951,7 +968,7 @@ pub(super) fn cut(st: &mut ModelState, on: ThreadOn, (tip, angle, depth): (Threa
/// The chamfered tip of an external thread (ISO 4753, chamfered end CH) at the free ends `tip`
/// names (`Start`: the end of the axis `from`, which must be free; `Both`: each free end):
/// the material outside a cone `angle` to the axis (default 45°) that reaches the diameter
/// d_f = d - 2 `depth` at the face's end, as a ring to cut after the groove, so the last teeth
/// d_f = d - 2 `depth` at the face's end, as a ring to cut from the stock, so the last teeth
/// taper into it. ISO 4753 bounds d_f by the external thread's minor diameter; the default is
/// that bound, d3 = d - 1.226869 P (ISO 724, the rounded root of the design profile). It lies
/// below the modelled basic root d1, so the cone crosses the root cylinder and leaves a whole
@@ -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,5 @@
//! Joints save their captured values: `captured`, the values Capture Position
//! (`component.capture_position`) saved and Revert Position (`component.revert_position`) puts
//! the joint back to. An older design has none, so its joints load with their values as before
//! and Revert Position leaves them where they are. The last release warns about the newer format
//! number, opens the design and keeps the `captured` values it can't read.
@@ -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).
@@ -0,0 +1,3 @@
//! Saves each thread feature's continuous-flank layout choice. Older threads omit
//! the field and keep their historical faces and chamfer order, preserving references.
//! The previous release refuses designs with this newer format rather than losing it.
+91
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 {
@@ -458,6 +527,21 @@ pub struct Joint {
/// again whenever the design changes, so a joint follows the parameters it is defined by.
#[serde(default, skip_serializing_if = "JointExprs::is_empty")]
pub exprs: JointExprs,
/// The values Capture Position saved, which Revert Position puts the joint back to. None in
/// 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),
@@ -2523,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"]
@@ -2751,6 +2840,8 @@ mod tests {
angle: 0.0,
suppressed: false,
exprs: Default::default(),
captured: None,
constraint: None,
})
.collect();
let links =
+2
View File
@@ -944,6 +944,8 @@ mod tests {
angle: 0.0,
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.
+2
View File
@@ -93,6 +93,8 @@ mod tests {
angle: 0.6,
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]
+6
View File
@@ -160,6 +160,12 @@ pub fn watch<R>(progress: Arc<Progress>, f: impl FnOnce() -> R) -> R {
stop.within(|| solidcraft_kernel::watch::watching(kernel, f))
}
/// Whether the watch over this thread's evaluations asked them to stop. Work done after that may
/// have been cut short: it is not kept.
pub fn cancelled() -> bool {
CURRENT.with(|c| c.borrow().as_ref().is_some_and(|cur| cur.progress.is_cancelled()))
}
/// Entered at the start of an evaluation; the guard leaves it.
pub(crate) struct Evaluation {
outer: bool,
+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 {
+503 -2
View File
@@ -114,6 +114,112 @@ fn parametric_plate_reevaluates() {
assert!(rel(measure(&m.state().bodies[0].body).unwrap().volume, 50.0 * 30.0 * 10.0) < 1e-9);
}
/// #14256: an end plate whose 25 mm arc meets two lines tangentially, each joint held by two
/// "point on curve" constraints instead of a shared point. The solver leaves the arc's ends on
/// the lines only to its 1e-9 mm residual, so along the tangent they lie up to 2.25e-4 mm from
/// the lines' ends. The outline is still one closed profile, and extruding it gives the plate.
#[test]
fn an_outline_closed_by_point_on_curve_tangent_joints_extrudes() {
let mut doc = Document::new("end plate");
let mut sk = Sketch::new();
let knee = (50.0_f64.powi(2) - 11.125_f64.powi(2)).sqrt();
let mut point = |x: f64, y: f64| sk.add_point(Vec2::new(x, y), None).unwrap();
let corners = [(-14.0, 90.0), (14.0, 90.0), (14.0, 48.0), (knee, 11.125), (90.0, 11.125), (90.0, -11.125), (25.0, -11.125), (25.0, 0.0)];
let [top_left, top_right, shoulder, knee_point, right_top, right_bottom, step_bottom, step_top] = corners.map(|(x, y)| point(x, y));
// Where the solver left the 25 mm arc's ends, and the end of the line along its top.
let arc_start = point(24.999999999949498, 5.025035674136865e-5);
let arc_end = point(0.0002252128472149822, 24.999999999324313);
let ledge_right = point(0.0, 25.0);
let ledge_left = point(-14.0, 25.0);
let centre = point(0.0, 0.0);
let small_centre = point(0.0, 0.0);
let pos = |sk: &Sketch, i: usize| sk.points[i].pos;
let line = |sk: &mut Sketch, a: usize, b: usize| sk.add_line_pts(a, b, None).unwrap();
line(&mut sk, top_left, top_right);
line(&mut sk, top_right, shoulder);
sk.add_arc(Vec2::ZERO, pos(&sk, knee_point), pos(&sk, shoulder), [Some(centre), Some(knee_point), Some(shoulder)], None).unwrap();
line(&mut sk, right_top, knee_point);
line(&mut sk, right_bottom, right_top);
line(&mut sk, step_bottom, right_bottom);
let side = line(&mut sk, step_top, step_bottom);
let arc = sk.add_arc(Vec2::ZERO, pos(&sk, arc_start), pos(&sk, arc_end), [Some(small_centre), Some(arc_start), Some(arc_end)], None).unwrap();
let ledge = line(&mut sk, ledge_left, ledge_right);
line(&mut sk, top_left, ledge_left);
use ConstraintKind::*;
for kind in [
Coincident { p: centre, q: 0 },
Coincident { p: small_centre, q: 0 },
Horizontal { l: ledge },
Vertical { l: side },
Tangent { a: ledge, b: arc },
Tangent { a: side, b: arc },
PointOnCurve { p: ledge_right, c: arc },
PointOnCurve { p: step_top, c: arc },
PointOnCurve { p: arc_start, c: side },
PointOnCurve { p: arc_end, c: ledge },
] {
sk.add_constraint(kind, None).unwrap();
}
let s = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: sk }, None).unwrap();
doc.add_feature(
FeatureKind::Extrude {
thin: None,
sketch: crate::SketchId(s),
profiles: ProfileSel::All,
extent: Extent {
convention: crate::ExtrudeConvention::LegacyPerSide,
distance: "10".into(),
direction: Direction::Positive,
distance2: None,
start_offset: None,
through_all: false,
second_follows_first: false,
taper: None,
corner: Default::default(),
to: None,
to_offset: None,
from: None,
to_body_apart: false,
},
operation: Operation::NewBody,
targets: vec![],
},
None,
)
.unwrap();
let mut m = Model::new();
m.evaluate(&doc);
assert!(m.results.iter().all(|r| r.error.is_none()), "{:?}", m.results);
let st = m.state();
assert_eq!(st.sketches[0].profiles.len(), 1);
assert_eq!(st.bodies.len(), 1);
// The polygon through the corners, plus the segment the 50 mm arc adds, less the segment the
// quarter of the 25 mm circle takes away, 10 mm deep.
let outline = [
(25.0, -11.125),
(90.0, -11.125),
(90.0, 11.125),
(knee, 11.125),
(14.0, 48.0),
(14.0, 90.0),
(-14.0, 90.0),
(-14.0, 25.0),
(0.0, 25.0),
(25.0, 0.0),
];
let polygon: f64 = (0..outline.len())
.map(|i| {
let ((x0, y0), (x1, y1)) = (outline[i], outline[(i + 1) % outline.len()]);
x0 * y1 - x1 * y0
})
.sum::<f64>()
/ 2.0;
let segment = |r: f64, sweep: f64| r * r / 2.0 * (sweep - sweep.sin());
let area = polygon + segment(50.0, 48.0_f64.atan2(14.0) - 11.125_f64.atan2(knee)) - segment(25.0, std::f64::consts::FRAC_PI_2);
let volume = measure(&st.bodies[0].body).unwrap().volume;
assert!(rel(volume, area * 10.0) < 1e-6, "{volume} vs {}", area * 10.0);
}
/// Setting a parameter back takes the earlier results from the cache: nothing is recomputed, but
/// the evaluation reports the features it restored (their geometry did change back).
#[test]
@@ -188,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));
@@ -341,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 {
+11 -5
View File
@@ -279,14 +279,16 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("capture")
.contract(&CAPTURE_POSITION_CONTRACT)
.output(output_schema_for::<CapturePositionResult>)
.params("keeps pending occurrence moves"),
.params("keeps pending occurrence moves and saves every moving joint's values → captured, joints"),
CommandSpec::new("component.revert_position", "Revert Position", crate::checked!("component.revert_position", revert))
.at("SOLID", "POSITION")
.icon("revert")
.noundo()
.contract(&REVERT_POSITION_CONTRACT)
.output(output_schema_for::<RevertPositionResult>)
.params("occurrences?: [ids] (default all); drops selected pending occurrence moves"),
.params(
"occurrences?: [ids] (default all); drops selected pending occurrence moves; with no occurrences the joints also go back to \
the values Capture Position saved (clamped to limits) → reverted, joints",
),
];
const ALIGN_CONTRACT: TypedContract<OccurrenceAlignParams> = TypedContract::new();
@@ -1005,8 +1007,9 @@ fn capture(s: &mut Session, _p: &Value) -> Result<Value> {
o.transform = m;
}
}
let joints = super::joints::capture_values(s);
s.revision += 1;
reply(CapturePositionResult { captured: n, param_warnings: Vec::new() })
reply(CapturePositionResult { captured: n, joints, param_warnings: Vec::new() })
}
fn revert(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1027,8 +1030,11 @@ fn revert(s: &mut Session, p: &Value) -> Result<Value> {
s.pending_moves.clear();
}
let n = before - s.pending_moves.len();
// Every occurrence going back takes the joints back too; a revert of some occurrences
// leaves the joints alone.
let joints = if p.get("occurrences").is_none() { super::joints::revert_values(s) } else { 0 };
s.revision += 1;
reply(RevertPositionResult { reverted: n, param_warnings: Vec::new() })
reply(RevertPositionResult { reverted: n, joints, param_warnings: Vec::new() })
}
/// The active component's frame from world coordinates (for new features' geometry).
+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()
@@ -23,6 +23,8 @@ fn joint(id: u64, a: u64, b: u64) -> Joint {
angle: 0.1,
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);
}
}
+6
View File
@@ -2109,6 +2109,12 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
{
parameters.entry("convention").or_insert_with(|| json!(convention));
}
if let FeatureKind::Thread { continuous_flanks, .. } = &old.kind
&& cid == "solid.thread"
&& let Some(parameters) = params.as_object_mut()
{
parameters.entry("continuous_flanks").or_insert_with(|| json!(continuous_flanks));
}
// A thread saved before holes were sized by their nominal diameter keeps the size it had
// when it is edited without naming one.
if let FeatureKind::Thread { old_auto_size: true, .. } = &old.kind

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