Compare commits

..

12 Commits

Author SHA1 Message Date
jyviko 43f6ee6a98 Contributing: kernel operations migrate to solidcore; features call its API
AGENTS.md, CONTRIBUTING.md and the contributing skill say how the work is
split since 0.40: SolidCraft is the application and calls the solidcore API;
geometry math (evaluators, intersections, solvers, tessellation, kernel cases)
is added to solidcore first. New features are discussed on Discord, then get
an issue and a claim, after a search of all issues and pull requests, open and
closed, for similar work. Work begun before 0.40 is ported onto today's code
before it lands. The kernel-paused section becomes "Kernel work goes to
solidcore".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 17:23:31 -05:00
Arjan Duijs 0690ee5875 Web app: run as a modelling editor inside another page that keeps the design files (#8517) (#11273)
* claim: #8517

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

* web: editing embed for host pages (?embed=editor, #8517)

A host page that keeps the files itself and frames SolidCraft for modelling needs
the modelling UI without the application chrome. `?embed=editor` runs the web app
as such an embed:

- no application bar (logo, File menu, document tabs, update, help, report a bug)
  and no start page; the layout is docked;
- the ribbon, browser, timeline and properties stay; Ctrl+Z and Ctrl+Y still undo
  and redo;
- Ctrl+N, Ctrl+O, Ctrl+S, Ctrl+W and Ctrl+, are left to the host;
- nothing is kept in the browser (no stored designs, autosave, recovery prompt,
  preferences or leave-page prompt), as with the existing viewer embed.

The existing `?embed&open=` viewer embed is unchanged. SolveApp gets one flag,
`embed_editor`, for desktop and test hosts too.

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

* control: document.export / document.import / document.saved for a host (#8517)

An editing embed's host keeps the design itself. Three control methods
(window.solidcraft.request in the browser; the desktop control channel too; not
the agent chat's, whose method list is unchanged):

- `document.export` -> {name, revision, dirty, format: "json", text}: the active
  design as a `.json` design file holds it. `revision` is the one
  `document.revision` answers, so a host that follows the design compares one number.
- `document.import {text, name?}`: replaces the active design with that text,
  timeline and parameters as saved, opened saved with a new `document_id`. It runs
  as a design opened in a tab of its own, with the old tab closed without asking,
  so whatever was open in the old design leaves with it. A text that does not read
  leaves the design as it was. Waits behind a file loading or a design change, as
  commands do, and evaluates on the window thread.
- `document.saved {revision}`: the host saved that revision; refused when the
  design has changed since.

They are control methods, not commands: they act on the app's open design. The
browser API test gets a round trip through all three.

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

---------

Co-authored-by: aduijs <7501203+aduijs@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 21:23:25 +00:00
Colin Faulkingham 1dd921fc52 Components: stop Capture All Positions from doubling moves (#11259) (#11364)
* Model cache: a body made by rounds keeps them, so a later round blends them the same after opening

A design gave another body when opened from its model cache: the rounds a body was made of
lived only in memory, so a later round at their mitre was built sequentially (or refused, as
too large) after opening and blended with them when evaluated anew. The saved model now
records, per kernel body made by rounds, the body they were made on (a place in the bodies,
stored once though no state holds it) and the edges with their radii; reading it attaches
them back down each chain, and a thinned model keeps those bodies with the bodies made from
them. Model cache format 7.

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

* Tests: a corner patch of rounds 2/3/4 takes no more than the strips leave in its block

three_rounds_at_a_corner bounded the patch's extra cut at 3 mm³ beyond the strips, a figure
with no derivation; SMLib's all-at-once body at radii 2, 3, 4 takes 3.099 (V 7878.579574
against strips 7881.678). That body is the strips' outside the block its patch lies in: its
rounds are the strips' cylinders ending at a's section y = rc, b's x = 20 − rc and c's rails
where they meet a's and b's (the patch's four corners, now asserted), so the patch takes at
most what the strips leave in that block (11.86 at 2/2/3, 32.69 at 2/3/4). The lower bound is
that block; the upper one, at most the strips' volume, stays.

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

* Tests: the io crate's hand-built saved models list their rounds, as format 7 records do

Model cache format 7 (f3cf0c4f3) records a `rounds` list in every saved model, and reading one
without it is refused ("saved model: rounds"), as is a model without any of its other lists:
the states, the preview and the thinned model all came back empty. The io tests' saved_model
and timeline_model wrote format-6 records, so six tests (the round trip, the repeated part
stored once, and the four too-big-for-the-file cases) read nothing. Their bodies are none made
by rounds: the list is empty, and every expectation stays as it was.

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

* SMLib: pin eaa998a (tangency-labs/smlib#68, #69, #70)

Batch 4: drafts and loops, mitre corners, rim chamfers, rulings, tight box, stitch, point
classification, eased corners. Batch 5: a face's tight box divides its cells at the surface's
knots (thread boxes exact); a cylinder, cone, sphere or torus is extended along its own circles.
Batch 6: a revolution's extension closes its turn; a dropped point's foot is the converged one.
The C API is ABI 5 (smlib_blend_dropping and smlib_brep_classify_point are new; nothing existing
changed), so the binding expects 5.

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

* Tests: a strip's tangent and curvature run-offs are the distance long across the surface

SMLib measures an extension's distance across the surface (smlib_extend_sheet corrects
CreateExtendedSurface until the parameter line through the side's middle is that long), so each
run-off of the cylinder strip, swept 20 along z, adds 5 x 20: 10 pi/2 20 + 100 = 414.159 for
both. The parabola-length formula (418.18) was truck's.

Ruling d2 (2026-10-30).

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

* kernel: one smooth angle, and a round too small to change the part is refused

The blend's tangent angle is the shaded view's half degree (smooth_edges::ANGLE), one value
for smooth. A constant round lies r (sec(d/2) - 1) inside the crease it replaces (about
r d^2/8); below the edge's tolerance it changes nothing and is refused before SMLib (which
answered SM_ERR_INVALID_INPUT), naming the smallest radius that rounds,
tolerance / (sec(d/2) - 1). d is the least turn along the edge
(tangent_edge::least_angle_along, at tangent_all_along's stations).

Ruling d3 (2026-10-30). The block's tolerance is 2e-5 mm, not 0.1 um: at 1 deg the smallest
radius is 0.526 mm, so R2 and R20 reach SMLib, which still calls an edge within 2 deg smooth
(SmEdge::GetBlendEdgeuse); that test is ignored until SMLib uses 0.5 deg.

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

* Fillet rig: the knife-edge chamfers name SMLib's cause, a cusp's ball radius of 0

smlib_chamfer turns the distance into a ball radius d sqrt((1 - c)/(1 + c)), c the faces'
binormals' dot (capi chamfer_radius); at #851's cusp the binormals agree, the radius is 0 and
the call is SM_ERR_INVALID_INPUT before the fillet executive runs (gdb: chamfer_edges sees
radius 0; the dump's why is empty). SolidCraft hands every chamfer to SMLib, so the
ball-contact chord (the ball of radius d: 3.822 wide at d = 2, 159.2365 removed) is SMLib's to
build.

Ruling d4 (2026-10-30).

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

* kernel tests: a corner round wider than its faces overflows and takes the corner's other edges

R21 on a 20 mm cube's edge builds (6107.529370, closed form, to 2e-12). With R5 on the
corner's other edges the R21 takes the faces they lie on, so the body is R21's alone; SMLib
fails that blend (status 1001, fillet error 4, Unable to Trim Rail Curve) and the test is
ignored until it builds. It replaces the refusal test that expected TooLarge.

Ruling d8 (2026-10-30).

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

* Tests: a 1 degree crease rounds, and the knife edge's chamfers build, on SMLib smlib-batch-4-rulings

With SMLib's smooth limit at half a degree (422e6f1) the 1 degree crease rounds R2 and R20
(the test is no longer ignored), and with a cusp's chamfer the ball of radius d (d231547) the
The 0.5 one stays, for its own cause (the chamfer face left apart from the body). Two corner
cases the same SMLib (replay/1.0.1 and those commits) builds come off too.

Needs SMLIB_REV at that SMLib once it is pushed.

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

* kernel, doc: an overflowing round goes first; an edge it removes is gone, and the fillet says so

SMLib (smlib-batch-4-rulings 0e83cca, SMLIB_ABI_VERSION 5) rounds the edges whose rounds overflow
their faces first and then what is left of the others; smlib_blend_dropping says which picks'
edges the overflow removed whole. The smlib crate binds it (Brep::blend_dropping), the kernel
returns those picks (fillet_removing), and a fillet notes them as its warning: "the edge at
(0, 0, 10) was removed by the larger round, so it is not rounded" (a 50 x 50 x 20 block, R21 on
its bottom front edge with the front left upright: the block is R21's alone, in closed form).

Ruling d8: [21, 5, 5] on a 20 cube builds as R21 alone (6107.529370); the test is no longer
ignored. R25 with R2 at a corner (blend_corner) builds the same way, R25's alone in closed form,
where it was refused. The rig's 1080 notched block outline at R8 builds now and comes off the
known-failing list.

Needs SMLIB_REV at that SMLib once it is pushed.

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

* Fillet rig: the knife edge's 0.5 chamfer builds

SMLib's trim took a fillet meeting its face at more than 78 degrees for one with no side
(smlib-batch-4-rulings 971e567); the chord of the knife edge's 0.5 chamfer meets the flat face at
81 degrees. The case comes off the known-failing list.

Needs SMLIB_REV at that SMLib once it is pushed.

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

* Tests: a 15° tapered text builds, every letter grown, exact where its outline gives it

Owner ruling d6: the letter t of "tres long" was drafted inward by SMLib (its
loop's normal, summed from the cross products of its chords, pointed down;
fixed in SMLib). With the fix every letter grows, and the join builds at 3°
and 15° by the Round rule and by the default rule (tangent for spline
outlines), where before 15° was expected to be refused.

Exact where the outline derives it: a letter's section at height z is its
outline offset by e = z tan a, by the Round rule of area A + P e + C e² while
the offset trims nothing (Steiner; C half each loop's turning less
tan(t/2) - t/2 at each reflex corner), so the letter is
A h + P k h²/2 + C k² h³/3. Each extruded alone, the t at 3° and s, l and o
at 3° and 15° are checked to 1e-6 (SMLib measures them within 1.3e-9; its
mass properties integrate to 1e-8). The concave joins of r, n, g and e are
tighter than e already at 3° (their swallowtails trimmed: r 1.1e-3 under the
formula), the t's by 15° (9.9e-5 over), and on the block the letters grow
into each other, so the whole word is checked for what holds exactly: one
closed body with the block, and half-way up every point of each straight
letter's outline inside the solid (the t drafted inward failed that).

With SMLib's fix (smlib-batch-4-text-loops 82133b2).

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

* Kernel rig: a sloped basin's mitred rim rounds are checked tangent along their contact lines

1080-fillet-sloped-basin-1-mm-rim-r0-5-4-edges asked G1 at every blend seam. Its front wall
leans 19 over 20 and the others 10 over 20, so at each rim corner the two mitred rounds meet the
sloped corner edge at different heights (0.155625 and 0.276393 below the top): the deeper round
runs on past the corner edge and is cut by the plane of the other's wall, a crease by
construction, the gap below the shallower round filled on that plane. The check now measures
the rounds' contact seams, on the top and on each wall, where the rounds are tangent. The
volume is unchanged.

With SMLib smlib-batch-4-mitre (1c15364): the case passes (V 144530.427946 against
144530.427909, 15 faces, sound); before it, only the four corner creases failed the check.

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

* smlib: Brep::classify_point says exactly where a point lies against a body

Binds the fork's smlib_brep_classify_point (SmBrep::Point3DClassify at the body's own zone
tolerance): inside, outside, or on the boundary within that tolerance of a face, an edge or a
vertex. A box and a cylinder just off its curved wall in measure's tests.

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

* kernel: Body::classify_point decides inside, outside or on the boundary exactly

SMLib's point classification at the body's own tolerance, on the exact faces and edges, in
place of a mesh at a chord of 2e-3 of the body's size (about 0.4 mm on a 200 mm part). A point
within the tolerance of a face, edge or vertex is OnBoundary, never decided by a ray. A mesh
body's triangles are its exact shape: on them within the record's tolerance, else by ray
crossings. The kernel's own probes move to it; Body::contains goes once its last callers have.

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

* doc: a hole decides whether it exits at its drill point's shoulder exactly

Material past the depth needs a body with the point inside, air before it needs every body to
have it outside; a point within a body's tolerance of the exit face is neither.

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

* engine tests: probes ask for the exact point class

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

* ui, cli tests: probes ask for the exact point class

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

* kernel: Body::contains, the mesh test, is gone

Every caller asks Body::classify_point now.

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

* kernel tests: the rim chamfers running off their rims build, on SMLib 737c392

chamfer_wider_than_sloped_basin_rim_builds and a_rim_loop_chamfer_running_off_its_rim_builds
waited for SMLib to build the cliff overflow (CloseRemovalRegion refused it,
SM_FILERR_NO_ROOM). The pinned SMLib builds it (eddf0ede4, bba9914b6): both pass on aarch64
Linux and are no longer ignored.

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

* Fillet rig: the cases SMLib 737c392 builds come off the known-failing list

The gate on aarch64 Linux reports 16 fillet cases FIXED with the pinned SMLib: the sloped and
equal-slope basin rim chamfers and rounds past their rims (1080-*, 5835-*), the pocket rim
chamfer past the floor, and the three pocket rim rounds now mitred at the pocket's corners
(every-pocket-*). No other area's gate reports a case fixed.

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

* kernel: the re-blend's removed picks formatted as rustfmt lays them out

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

* kernel: blend_smlib laid out by the 2024 edition's rustfmt

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

* Skills: the tests skill's unit-test example stays under the 3600-character limit

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

* Fillet: the radius-too-large choice in plain nouns

"If too large: Stop and say so / Largest that fits / Narrow where needed"
becomes "Face limits: Exact radius / Largest radius / Narrowed radius", in
every language of the catalog, with the error and the tip that name them.

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

* kernel: a round that would cut through its wall into a hollow is refused

The owner's rule of 2026-10-30: a round running past a rim of its faces
into open air builds; one whose section would remove material through to
a hollow behind its edge (a slit into the hollow, a cut through the part)
is refused as too large, so the fillet names the largest radius that fits.

Decided exactly in the section of a straight convex edge between planar
faces: the round's centre is at depth r below both planes, and it opens
where its arc uncovers a boundary point strictly inside the wedge with no
material beyond it toward the centre (the body's exact point
classification). Looked at: straight edges parallel to the round's (a
hollow's corner), cylinders about parallel axes (a rounded corner), and a
picked concave parallel edge's own round, which fills the corner.

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

* Fillet rig: 1668's R7 past the inside corner is refused at 4 + 2 sqrt2

The round's centre (32 - r, 42 - r) leaves the hollow's corner (30, 40)
outside its circle, opening the wall, where sqrt2 (r - 2) = r: the case
expects the refusal naming 6.828. 2852's R30, refused naming 25.24, now
passes too: both come off the known-failing list.

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

* xtask: known-failing grades a nextest junit report against tests/KNOWN_FAILING_TESTS

`cargo xtask known-failing --junit PATH` reads nextest's junit (a file, or every .xml under a
directory, so all of a run's junit artifacts at once) and fails on a test that failed and the
list does not name, and on a listed test that passed (FIXED: take its line off, as the kernel
rig's gate does). A listed test that did not run is neither, since a pull request tests only the
packages it touches. The list has the rig's KNOWN_FAILING format (`<test> <cause>`, sorted, the
rig's own parser), with `<test>` nextest's `<binary id>::<test name>`; `cargo xtask lists`
checks it is sorted. The list starts empty.

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

* CI: the test jobs grade their junit against tests/KNOWN_FAILING_TESTS

Native Linux tests and the GPU path test shards no longer fail on any failed test: nextest's
"tests failed" exit (100) goes to `cargo xtask known-failing --junit`, which fails on a failure
the list does not name and on a listed test that passes. Any other nextest exit (a build error,
a bad filter) still fails the step at once. The tests skill says to list a failing test with its
cause, in place of ignoring it.

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

* Tests: the known-failing list takes develop's 10 failing tests

Develop 38fd4a4f6 (CI run 41157648345, after the SMLib eaa998a pin) fails 10 cargo tests; each
is listed with the cause its failure message gives. Seven are SMLib refusals or results off
their derived volumes (fillet, chamfer and shell), the cli action matrix and naming corpus
among them; the two corner_g2 tests are the fitted G2 corner patch bulging past the box, which
the explicit G2 corner fixes.

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

* Reference tests: the eight cases that fail on SMLib eaa998a name their cause as known failures

Develop 38fd4a4f6's Reference tests fail 34, 35, 77, 78 and 81 (STEP export finds fillet edges
4.6e-5 to 6.1e-5 off their surfaces against 2e-5), 37 (the apex blend 0.193 off), 80 (the
taper-to-fit area) and 83 (thicken status 1001). Each expected.json names its cause in
known_failure, which the oracle already reads: a known failure that passes fails the job.

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

* Sweep path: a curve of several pieces is turned round whole

The path chain oriented its first piece, not its first curve: a first curve
of several exact pieces (a fit spline, an ellipse, a projected free-form
edge) was never turned round, so a path whose next curve meets it at its
start was refused as "not connected end to end". The chain now orients
curve by curve: the first runs toward the second where they meet at one
end only (else from the end nearest the profile), each next one starts
where the previous ended, with one joint tolerance for both questions.

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

* SMLib: pin 2fc375f (tangency-labs/smlib#71, #72, #73)

A sheet's side is extended exactly the distance asked, or the call says it cannot (#71);
NVIDIA's -Werror build passes again (#72); a body splits by a sheet of several faces as it
is, smlib_split_body_by_sheet (#73). The C API is ABI 6 (the new entry), so the binding
expects 6.

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

* kernel: Extend grows a surface of several faces face by face

A surface body of more than one face (a fit-point spline's spans extruded, #3830) was refused.
Each face with a picked edge is now extended alone by SMLib's extension of its surface, and the
faces are sewn back together, each the face it was. A side that lengthens onto a face meeting it
tangent takes that face along, past its edge continuing the picked one; across a crease the
neighbour grows only when its edge is picked too, otherwise the extension is refused. Faces whose
lengthened sides no longer meet are refused. Up to a target keeps the pieces every grown face
lies in.

3830's extensions now build; its split still fails (the tool point picks one span, and a face
tool cuts with that face's surface alone), so it stays on the list with that reason.

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

* kernel: Split Body by a face of a smooth sheet cuts with the whole sheet

A face tool cut with that face's surface alone, so a face of a surface body made of spans joined
tangent (a fit-point spline extruded, #3830) cut nothing where its span ends inside the body.
Split Body by a face of a surface body now takes the faces joined to it tangentially (the exact
check Extend uses, within the one angle for smooth) and slices with them as they are, through
SMLib's slice by a sheet (smlib_split_body_by_sheet, tangency-labs/smlib 7a01f1b), its sides those
of the picked face. A sheet that only nicks the body cuts nothing, as before; a lone face, or a
face of a solid, still cuts with its surface extended.

3830 comes off the booleans list. Its answer counted the two halves of the cup and not the surface
that cut it, which stays as a body of its own (4879 removes its surface for that reason): three
bodies, the volume unchanged.

Needs SMLib 7a01f1b pinned (SMLIB_REV, prebuilt; its SMLIB_ABI_VERSION 6, which crates/smlib now expects) to link.

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

* Tests: a cylinder strip's run-offs are 100 pi + 100 to 1e-6, SMLib's exact area

The tangent and curvature run-offs of the quarter cylinder r 10 strip, 5 across the surface,
are its section swept 20 along z: exactly 10 pi/2 20 + 5 x 20 = 414.159265. measure is SMLib's
integration on the exact surface (1e-8), not a mesh, so the run-off check is 1e-6 relative and
the 'measured on meshes' notes go. Red until SMLib's extension is solved to its distance
(smlib-extend-converges 590d8a1): eaa998a's curvature run-off is 4.986 long, area 413.882.

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

* kernel: an extension SMLib cannot make exact says so, naming the length asked

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

* Sketch: a control spline can carry its own knots and weights

A sketch spline over control points was always the clamped uniform B-spline
over them, so a free-form edge whose knots are not evenly spaced (or whose
weights are not 1) could only come into a sketch as one curve per Bezier
span. A control spline now may save `nurbs`: its clamped knots and its
weights. Its points, polyline, exact pieces (one a knot span; conics for a
rational quadratic), the solver's distance and curvature, links, parallel
re-projection and inspect.sketch all read them; a loaded design's are
checked. One pending format file (spline-knots).

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

* Sketch projection: a free-form edge comes in as the one exact spline it is

A projected spline edge of several Bezier spans came into the sketch as one
curve per span (#1508), so a G2 round's bottom edge was 16 curves and a path
naming the corner named one span of it (#3604). The kernel now reads such an
edge (a spline, or an elliptical arc of several spans) as the one clamped
B-spline it is over the edge's interval, its knots and weights its own, and
the sketch takes it as one control spline whose ends sit on the edge's
vertices. Planar regions still read edges span by span. The 3604 case builds
with its original path and leaves KNOWN_FAILING.

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

* Fillet rig: blends past an outer rim of their faces build, on SMLib smlib-blends-past-rims

The owner's ruling: a round or chamfer that runs past an outer edge of its face into open air
builds, tangent where it has room and cut by the face beyond where it runs out. SMLib's branch
smlib-blends-past-rims builds them by the rig's analytic answers. Off the known-failing list:
the disc's rim past its 2 mm wall (190), the hole's top rim R12 past the plate's bottom, the
chamfer past the cylinder's bottom (2893), each closing on the wall continued as itself, and
926's chamfer ending on the boss's wall, which now builds to its answer too.

The sloped basins' R4 rim rounds (1080, mitred rounds built together where one overflows) build
to their analytic volumes (to 6e-9), and stay listed with that reason: their continuity check
(g1 at every blend seam) takes in the mitres' creases, G0 by the mitre ruling.
2893-chamfer-two-distances-elliptical-rim is graded as a solid (the owner: it builds) and stays
listed, waiting on the ruling on unequal chamfers along a rim whose angle varies.
5158-fillet-round-just-past-a-10mm-face stays listed with its cause found: the round passes
5e-6 under the box's top back edge, inside the body's tolerance.

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

* Naming: test that a round of a round's end has no seam with that round's faces

The r1 round of a block's top edge, chained onto the end curve of the r2
round of its arc at a cylinder (rig fillets/every-block-cylinder-crease-
top-edge-after-arc-round-r1), ends where it runs into the cylinder. That
edge is no seam of the r1 round: the cylinder is a face of the r2 round,
not of the r1. blend_seams takes it for one today (the r2 round's name
F3:blend:F1:+z|F2:side holds a | of its own), and the rig reads it as a
contact seam at 60 degrees.

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

* Naming: a blend's seams split its edge's name at the | between its two faces

A blend face's name holds the edge it replaced, <face>|<face>, and a side
of that edge can be a blend face whose own name holds a |. Whether a face
is a contact of the blend was read as the edge's name starting or ending
with the face's name next to a |, so the r2 round's own face F2:side read
as a contact of the r1 round F4:blend:F1:+y|F3:blend:F1:+z|F2:side. The
edge's name is now split into its two faces, a blend side read whole as
the two faces of its own edge.

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

* Fillet rig: five refused blends build, off the known-failing list

926 (the chamfer ending on the boss's wall), the oblique cut's r0.1
round, the every-block r1 round (its seam with the cylinder is no
contact seam, after the blend_seams fix), the up-down squares' concave
chamfers and the shelled box's lip chamfer build with SMLib branch
smlib-blend-refusals (f8650b8 and later). 1207 r0.5 passes SMLib's check
on its rails now and stays listed for the next cause: its end rib on the
boss, which starts where the round and the boss are tangent.

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

* Kernel rig: the trough and the pocket whose floors fall to a drain build

SMLib now offsets a vertex where four faces meet (two walls and two facets at the floor's
corners, four facets at the drain) to two vertices joined by a new edge (tangency-labs/smlib
branch smlib-shells). Both volumes match their closed forms. The pocket's lining, hanging from
the top's rim round the pocket, stands apart from the slab's outer walls with the cavity rising
between them to the open top: the case says two shells in the one body.

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

* Kernel rig: the plate with a hole through its arc side shells open at its far end

SMLib's merge now intersects the offsets of an edge's faces where they could not be mated along
it (the hole's offset poking out through the half-round side's; tangency-labs/solidcore#1795):
one closed valid solid with the one face open, as the owner ruled.

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

* Kernel rig: the ring cut frame and the up-down join shell

SMLib's inset now keeps only the regions that are cavity, not the one the offsets close where
they overlap (between the two faces of #545's 0.5 floor, round the up-down join's two empty
octants). The frame's core under the recess is hollowed to a sealed void, so the case says two
shells and its closed form, 21044 mm³.

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

* Kernel rig: #616's lid shells open at a rib's side, with the 0.15 lip

SMLib keeps the skirt top's offset where its loops wind the other way under the opening, the
cavity's floor (the owner's ruling of 2026-10-31: build it; a lip thinner than the wall is no
break-through).

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

* Known failing tests: the action matrix's shells and chamfers as they fail with SMLib batch 7

The ring cut frame's and the up-down join's shells build now, and so do the up-down join's and
modern-plastic's chamfers (SMLib smlib-blend-refusals). #616's lid in the corpus still fails
SMLib's loop check, the shelled box shelled again at an inner wall is unchanged, and the
oblique cut of a cylinder's fillet still fails SmSurfOfRevolution's Test #7: 3 actions of 7.

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

* SMLib: pin 26cc451 (batch 7: rounds past a rim, blend refusals that build, shells)

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

* Known failing tests: the shell after a cut whose end touches an arc side builds on SMLib batch 7

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

* Surface fair test: the energy falls strictly, the face moves, and the edges are read after the fair

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

* Sketch scaling test: the first dimension sets l1 to 40 and scales both joined rectangles about the origin

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

* Frames tests: a pick on a moved component or an instance removes the exact round and bore volumes

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

* Issue 1080 tests: the rib's rounds and the mitred corner chamfers and rounds have their exact volumes

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

* Engine tests: lip_after_boss checks each lip's and groove's exact band, hole_as_deep_as_the_plate each hole's exact bore

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

* Robustness tests: lip_after_boss and hole_as_deep_as_the_plate live once, in the engine's tests.rs with exact volumes

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

* Engine tests: loft_cut goes; loft_spacing's hollow elbow covers its section counts and more

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

* Kernel loft test: the square-to-circle loft has the oracle's exact ruled volume, without printing

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

* Tessellation lens test: the lens's triangles fill exactly the rhombus through its arcs' midpoints

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

* Pattern test: six chamfered holes take exactly five more holes' bore and chamfer rings

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

* Kernel pocket floor fillet test: the corner rounds and the floor loop round add their exact volumes, by Pappus at the corners

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

* Shell regression: the saddle-corner shell opened on the top box's side has its exact volume

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

* SMLib history test: every operand's order is the identity on its record's faces and edges

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

* Boolean history tests: the SMLib module's box-minus-sphere, box-minus-cylinder and box-union-box copies go; the top-level ones check more

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

* Modify history tests: shell, offset and thicken are one test each through the public API, with the volumes, refusals and history the SMLib copies checked

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

* Chamfer history test: the chamfer after each box cut is exactly one face on the painted block too

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

* Grid test: a pole side's face is the five triangles fanned from its one grid node, none degenerate

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

* Kernel tests: the twelve ignored print-only debug tests and surveys go; coplanar_curved_booleans checks the flush-capped cylinder cuts and unions they printed

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

* Kernel: the #883 board fixture generator is an example, not an ignored test

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

* STEP import test: a seam sphere whose meridian stops short of the pole imports as the whole 2 mm ball

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

* Material tests: the nine copies in material_properties go; the persistence file checks the library copy's card and formats 20 and 21 in one loop

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

* Sample test: one loop checks every sample builds cleanly and without a panic; the robustness copy goes

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

* Known failing: the non-antipodal seam sphere import, refused for a loop of no (u, v) area

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

* Tests: rustfmt the board fixture example and the coplanar asserts

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

* Kernel rig: the two 3871 cone cases were byte-for-byte the 258 cases; the 258 cases carry their sources

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

* Kernel rig: a volume or area can be graded as a hand-derived range where analysis only bounds it

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

* Kernel rig: fillets keeps one case per regime; 17 replications of a case in the same regime go

The sloped-basin chamfers and rounds at 0.5 mm rims repeat the 1 mm rim's regimes; two of the
three knife-edge chamfer tips, the r1 and box r3 curvature corners, the r1 tangent corner, the
1 mm pocket-rim and hole-rim chamfers short of the floor and the r1 together-round each repeat a kept case's regime
and grades. #854 moves to 851-chamfer-arc-top-edge-9-5, and the cases that cited a dropped one
cite the kept one or state its formula.

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

* Kernel rig: five cases outside fillets repeat a kept case's regime and go

A square cut is the slanted plane's ordinary crossing; the cam's half turn is the quarter turn's
contact at a symmetric angle; the r15 slab stands inside the same coincidence tolerance as the r10;
the 90 degree unfold and the 40 degree flange repeat the 72 degree and 20 degree cases' formulas.

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

* Kernel rig: 3243 rim and tooth join graded on its volume (rim plus the trimmed tooth less their 0.01 mm overlap)

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

* Kernel rig: 3782 emboss on a cylinder graded on its volume, area, faces and box (the wrapped 4 x 5 badge raised 1 mm)

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

* Kernel rig: 3604 G2 swept-tool cut graded on a volume range (filleted box minus the swept corner section)

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

* Kernel rig: 4505 coil joined through a cylinder wall graded on its volume, area and box (cylinder plus the wire's screw-swept segment outside r 76)

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

* Kernel rig: 4879 window cut through a thickened spline revolve graded on a volume range and its faces

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

* Kernel rig: 4623 join of crossing pleated loft skins graded on its volume and box (both skins less their overlap)

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

* Kernel rig: 831 tapered glyph extrude graded on a volume range and box (the font's S drafted in by 10 deg)

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

* Kernel rig: 2357 ring of 90 cylinder segments graded on its volume and box (the segments less their 90 joint overlaps)

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

* Kernel rig: 4542 fluted wall joined on a revolved base graded on its volume and box (the base by Pappus plus the fit-spline ring)

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

* Kernel rig: 1985 and 2243 STEP-export enclosures graded on their volume and box (the outline less the cavity, plus gusset and standoffs)

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

* Kernel rig: both 4092 loft, ribs and pad unions graded on their volume and box

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

* Kernel rig: 1525 graded on its volume (the convex body's exact 2 mm inward offset), faces and box

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

* Kernel rig: 1494 graded on its volume (box and cylinder less the 1 mm-offset cavity, sharp to rounded concave edges) and box

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

* Kernel rig: 4492 graded on its box and a volume range (the face's 19 mm sweep added, within the block's sides)

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

* Kernel rig: 745 graded on its volume (the end moved 1 mm plus the spline boss's exact area), faces and box

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

* Kernel rig: 4567 graded on its volume (the revolved spline less its revolved 2 mm offset), faces and box

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

* Kernel rig: the up-down squares shell graded on its volume (the columns less the 0.346 mm-offset cavity, sharp to rounded concave edges) and box

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

* Kernel rig: 545 graded on its volume (the frame less its 0.5 mm-offset cavity, sharp to rounded concave edges), its sealed pillar void and box

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

* Kernel rig: the shell-again case graded on its volume (the shelled box less the 0.5 mm-offset cavity in its walls) and box

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

* Kernel rig: the arc-sided plate shell graded on its volume (sharp to rounded at the hole's bottom), faces and box

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

* Kernel rig: 616 graded on its volume (the lid less its 0.5 mm-offset cavity, sharp to rounded concave edges) and box

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

* Kernel rig: 391 graded on its volume (the shelled housing, sharp to rounded at the flange, less the letters' 0.6 mm deboss) and box

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

* Kernel rig: shell/3576 was the ladder's drafted box with rounds shelled, recipe for recipe; the ladder case grades it and carries #3576 and #3573

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

* Kernel rig: the two 851 chamfers of 2 graded on their volume (the cone's sweep, and its union with the front edge's prism)

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

* Kernel rig: the chamfered corner of the lipped shell graded on its volume (the chamfer's triangle through the wall)

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

* Kernel rig: the concave round into the peg's cylinder graded on a volume range (the torus it must add, the band it can't leave)

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

* Kernel rig: the two folded 72 degree trays graded on their volume (flat area x thickness) and the walls' reach

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

* Kernel rig: the flanged rhombus graded on its volume (flat area x thickness, all four reliefs cut) and its walls' ends

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

* Kernel rig: the base with two adjacent flanges and a hole graded on its volume (base, bends and panels folded)

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

* Kernel rig: the flange beside the ledge graded on a volume range (the flange's two readings of its length)

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

* Kernel rig: the two G0 window patches graded on the box the part they close keeps

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

* Kernel rig: the hub's six swept grooves graded on its volume (the revolved hub less each tube's part inside it)

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

* Kernel rig: the cut mirrored loft graded on the box its sections pin

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

* Kernel rig: the three modelled-thread joins and the fine M48 graded on their volume (the basic profile's groove, Pappus)

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

* Kernel rig: the oblique-cut round graded by its seams (tangent, not curvature-continuous); the flanged rhombus says what it tests (its reliefs stay apart at 60 mm)

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

* Kernel rig: 3841 is 168's rim touching its coin along a circle, and 4092's reduced recipe is its report less a flange clear of the failing join; the kept cases carry their sources

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

* Kernel rig: the oblique-cut round keeps its old grade until the new blend algorithm lands

Its seams grade needs the continuity check to skip the body the round is not on. Fillet cases
wait for the new algorithm.

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

* Kernel rig: 1985's enclosure is 78 x 77 (74 and 73 inside, 2 mm walls); its volume 38063.827 by hand

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

* Kernel rig: the three spline walls' face counts wait for the one-wall decision; their volumes grade them

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

* STEP import: a face bounded by seams alone runs its loop the other way, never turns over

A loop that uses each of its edges once each way (a whole torus within its tube and ring seams)
is the same set of uses run backwards, so it says nothing of the face's side. One written
clockwise in (u, v) turned the face over, against its same_sense: the torus came in inside out
with a warning. Such a loop is now reversed and the face keeps its side.

Test: seam_only_torus_imports_as_a_whole_ring, both orders of the two seams, volume 2π²Rr².

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

* STEP import: a face bounded by a slit alone is the whole of its closed surface

A loop that runs out along a chain of edges and straight back (a meridian from a sphere's pole
stopping short of the other, out and back) encloses no (u, v) area, yet on a closed surface it
bounds all of it less a cut of none. The reader only read such a face when the slit already ran
pole to pole (and moved the sphere's frame onto it only then), so the 2 mm ball whose meridian
stops at 2.5 rad was refused for its empty loop.

The slit is now made the face's seams, so its loop bounds the surface's whole domain: on a
sphere, a revolution whose profile ends on its axis, or a spline closed round u that collapses
at both ends, the slit's meridian is run on to both poles, out and back (a sphere's frame put
on the slit first, whichever two points it joins); on a torus, the circle the slit lies along is
closed and the other circle through its start is the second seam. The seams on a sphere are its
own meridians (angle 0 on the equator), so their pcurves are exact lines on its form.

Tests: non_antipodal_seam_sphere_imports_as_a_whole_ball passes (its known-failing line goes);
seam_only_torus_imports_as_a_whole_ring adds a torus bounded by a slit along its tube.

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

* Rig: the test-peg's concave arc and front top edge rounded together stay within the body's box

A new fillet case, report-fillet-concave-arc-and-front-top-edge-together-solver-residues:
the reported part's bar (with the sketch solver's residues) whose sides run tangent into the
cylinder, its concave arc and front top edge rounded r2 together. The plane of the front face
bounds the whole body, so every blend lies within the unblended body's exact box (SMLib's tight
box); graded to 4e-5, the corner patch's own tolerance. On SMLib 2fc375f the corner patch
stands 0.0374 out of the front face (box y min 0.0205): listed in KNOWN_FAILING.

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

* Rig: the test-peg corner at r5 and r7

Two more fillet cases, the reported part's concave arc and front top edge rounded together at
r5 and r7, with the same exact-box containment check (graded to 4e-5, the corner patch's
tolerance). On SMLib 2fc375f the corner patch stands 0.128 (r5) and 0.230 (r7) out of the
bar's front face (box y min -0.0699 and -0.1723): listed in KNOWN_FAILING.

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

* Rig: blends that end where their face runs tangent into another

Fifteen fillet cases on the test-peg's shape with clean numbers: a bar 40 x 16 x 10 whose far
end is a half disc R8, a disc R8 standing 10 on its top, joined, so the front face runs tangent
into the cylinder along x = 40 and the front top edge (convex) meets the concave arc of the top
tangentially there. Each blend ends on a face tangent to the one it rolls on.

- Rounds: both edges together at r2 and r5; one after the other in either order at r2, and at
  r1.5 and r3; the front edge alone as a curvature round, whose end on the cylinder is a
  semicubical cusp. The containment invariant (the unblended body's exact box, to 4e-5) and
  G1 seams, or the analytic volume where the blend has a closed section.
- Chamfers: the front edge alone and the concave arc alone (analytic volumes: the section along
  x up to the wall, the cone's spandrel ring clipped by the sides), both together, and one after
  the other (1 then 2).
- STEP: the peg written to STEP and read back, its front edge rounded (analytic) and both edges
  rounded together.
- A cone: the peg drafted 5 deg, whose front face runs tangent into a cone along a generator,
  its front edge alone and both edges together.

The quadratures are tanh-sinh, converged to 1e-13; SMLib's own volumes of the passing analytic
cases agree to 4e-9. A sphere is left out: a plane meets a sphere tangentially at a point, not
along a seam, so it is not this ending.

On SMLib 2fc375f eight fail, listed in KNOWN_FAILING: every chamfer that ends tangent (status
1001, SmFilletNx1Corner), the two rounds together (the corner patch 0.0374 at r2 and 0.128 at r5
out of the front face, and the rounds meeting with normals 180 degrees apart where their
contact lines on the top cross), the same on the STEP-read peg (status 1001) and on the drafted
peg (its corner refused as unsupported).

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

* Rig: fillet corners graded curvature-continuous, the default to be

Six fillet cases that pick their edges with no type, so they take the fillet's default, which the
owner has ruled is to be curvature-continuous (G2) at every corner, with circular rounds kept as
an option. Each grades every seam of the rounds and of the corner patch G2, on one valid body:

- an oblique convex corner (a block drafted 15 deg);
- a four-edge and a five-edge apex corner (pyramids lofted to a point);
- a pocket's concave corner;
- a mixed corner (a step: one concave edge, two convex);
- a corner beside a cylinder (the top corner of a half cylinder).

All six fail on SMLib 2fc375f, listed in KNOWN_FAILING: today's default is the circular round,
whose seams jump in curvature by 1/r. On the five-edge apex the corner patch also meets two
rounds at 8.45 degrees (G0).

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

* Rig: the test-peg's front chamfer builds

With the SMLib fork's smlib-tangent-ends (a rail's end on its side edge is the rail/edge
crossing), report-peg-front-chamfer-d2 passes. Its line leaves KNOWN_FAILING, for the pin that
carries that fix.

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

* Rig: the curvature-continuous corner cases pick the curvature fillet

The six corner cases added in 3a854104a took the fillet's default type, on the premise that the
default would become curvature-continuous. The default stays the circular round, so each case
now asks for the curvature fillet itself (type curvature: curvature-continuous sections and
corner patches) and keeps its grading: every seam of the rounds and of the corner patch G2, one
valid body. They are renamed from cov-fillet-g2-default-* to cov-fillet-g2-*.

The oblique three-edge corner (a block drafted 15 deg) and the pocket corner pass. Four fail, as
KNOWN_FAILING now says:

- the corner beside a cylinder: the curvature fillet builds its sections only between planar
  faces;
- the four- and five-edge apex corners: refused because the default setback is shorter than
  where the rounds' contact lines meet (3.90 and 3.51 mm from the apex);
- the mixed corner: SMLib sets back a corner only where its edges are all convex or all
  concave.

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

* Rig: the test-peg's concave chamfer, alone and after the front chamfer, builds

With SMLib's tangent end as an event of the fillet's trace (blend-end-event 6d73e4f), the
concave chamfer's rail on the cylinder ends where it crosses the seam along which the front
face runs tangent into the cylinder, continued past the vertex, and its end rib on the front
face is built from there. report-peg-concave-chamfer-d2 and
report-peg-front-chamfer-d1-then-concave-chamfer-d2 pass and leave KNOWN_FAILING. The two
chamfers together still fail, now at the mixed-convexity corner where they meet: its line says
so. Rig against SMLib 0cc48bd: no area regresses (963/993 pass, 961 on a87c502), ladder 168/168,
kernel lib only its listed failures.

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

* Kernel rig: constant rounds of the exact classes, and a tee's round

A plane cutting a cylinder or a cone at an angle, a sphere or a cone coaxial with the other face, a
cone and a cylinder touching one ball, two pipes touching at a point: their rounds' centre lines
are rational, so the rounds are rational surfaces of degrees fixed by the construction ((4, 2),
(2, 2) revolved, (6, 2), (12, 2)). The tee's centre line is not rational; its round is graded on
its volume. Volumes from the divergence theorem over the exact pieces, at rel 1e-6. The six that
SMLib 0cc48bd traces are listed in KNOWN_FAILING with what they come out as.

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

* Kernel rig: the exact-class rounds pass on SMLib's classified exact rounds

The rounds now come as the rational surfaces their classes give, closed ones as two faces split
at their seams, the touching pipes' as four: one splines entry a face, each of the class's degrees.
Their KNOWN_FAILING lines go.

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

* SMLib: pin 7ac037e (the new blend's tangent-end event, zero-speed G2 ends, and its exact-class rounds)

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

* Kernel rig: the fillets known-failing list is the 22 cases that fail on SMLib 7ac037e

Twelve listed cases build on the new blend's end event and exact classes, or were already
built on develop: they come off the list.

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

* Kernel rig: #316's bottom rounds as large as the wall, or larger, shell with a step wall at the opening, not refused

Where the round meets the open bottom the cavity's wall steps down along the bottom's normal, so the
round meets it at 90 degrees and the opening is exactly the picked face. The cases are renamed and
graded on the exact volumes, and come off the shell known-failing list.

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

* Merge fixes: every crate and test target compiles on the merged tree, but for crates/geom's hlr

world_state, view::bounds, home_camera and scene return Result on develop; new
main code takes that with ? or session_world. New fixtures carry the spline's
nurbs. ForeignCopy::of returns Result. occurrence_placement_with walks the path
with its moves, since the placement index holds pending_moves only.

The hlr port is separate and still to come: this commit does not compile as it
stands in crates/geom/src/hlr.

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

* Drawings hidden lines: read develop's B-rep records

Edges come from the record's edge list, cut to their interval (C3::of_edge), and
faces from its face list; a cylinder, cone, sphere or torus chart takes its bounds
from where its NURBS form lies on its label (S3::of_face). A rigidly placed
revolution has main's revolution chart; other placements stay refused. Moving a body
to its centre goes through Brep::moved. Loops are read against the natural normal of
the face's parameters where a chart wraps.

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

* Hidden-line fixtures: build develop's records from the fixtures' data

Fixtures keep their bounded curves and surfaces and their own edge numbers; a private
builder makes the canonical record, and the local prepare puts the edges back in the
fixture's numbering. Assertions are unchanged.

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

* Hidden lines: tests of the bounded edges, face charts and placed revolutions

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

* drawing-hlr doc: what is drawn and refused on develop's records

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

* hlr tests: a placed revolution is refused when its placement is not rigid

The new model has one record for main's Revolution, which was drawn, and its
PlacedRevolution, which was refused. An identity placement is now the former, so
this test's fixture takes a non-rigid one. The refusal it asserts is unchanged.

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

* Hidden lines: a traced intersection is drawn only where its spline lies on both surfaces

A leader off either surface is refused like a curve with no spline to follow. Tests: a traced rim
drawn on the circle and plane within tolerance, the same leader 1 mm off refused, and kernel bodies
with arcs through the production prepare against closed forms.

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

* Hidden lines: kernel bodies through the production prepare against closed forms

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

* Tests from main: the world state is a Result here

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

* A blend's pick measures close edges on their curves, not their polylines

A polyline can be a chord off its edge, more than two edges are apart near a
tangent point. A pick exactly on a ball's rim took the straight edge the rim is
tangent to, and solidcore built a chamfer of that edge with a clean check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNXLXkZLQ6T7TSi4wswcmk
(cherry picked from commit c786db6bf4)

* Merge fixes from the first SMLib test pass

Model cache key covers the crates develop's kernel adds under doc (profile, smlib, step).
A piece taking a name also takes the feature that made it: SMLib lists a cut's pieces in another
order, and the swap left the name on a piece of the wrong feature.
A replaced analysis keeps its status slot. A command with no world picks does not place the model
first, so assembly.mass_properties answers for a flattening placement. FeatureCommon loses the
touching key the merge added; only the contracts that read it take it.
Two of main's tests listed as failing on SMLib, with why.

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

* Merge fixes from the ui test pass and the gates

Main's thread.direction keys go with develop's thread hand, which replaced them. Vietnamese for the
messages develop added while main made vi a complete catalog. CGA and 1-bit themes set develop's
comb and section hatch colours. A ribbon button with a persistent effect is pressed only while its
tool runs, so persistent_ribbon_pressed alone decides how its effect is shown; other analyses stay
pressed while on. Two of main's ui tests listed as failing on SMLib, with why.
Gates: fixed thread tables become slices, one collapsed if, rustfmt.

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

* A silhouette takes its facing from the surface's normals

A flat triangle beside the outline can tip the other way from the surface it approximates, and its
edges joined the outline. On SMLib's sphere that made 192 segments instead of the equator's 64, 100
curves and 95 profiles from the tight fit of #7989. Each corner's normal is used where it is close
to the triangle's own; a mesh without such normals (none, zero, not numbers, or averaged across
creases) keeps the triangle's normal, as before.

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

* Clippy and rustfmt on the merged tree

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

* Stepwise open checks every feature under the session's mode

OpenSteps::start evaluated under the default check mode, and finish kept those results under the session's mode: an each_step or off session opened in steps recomputed nothing. start now takes the mode (the app passes its session's), and a mode that changed before finish evaluates the design again under the new one.

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

* Clippy without SMLib: the planar-sheet edge reader is built only with it

Its one caller is in the smlib-only sheets module, so without the feature it was dead code and
clippy -D warnings refused the kernel.

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

* SMLib: pin d01d7b0 (the new blend's one Gⁿ section generator: the chord, circle and curvature sections, and the variable rounds as the union of their balls)

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

* Kernel rig: the section orders, chord, circle and curvature section, on planes and against a cylinder

Eight fillet cases for the blend's cross-section by its order of contact with the faces, each
graded on its analytic volume at rel 1e-7 and its exact continuity (the order, and not the next):

- a box's top back edge chamfered 3 (G0), rounded r3 (G1) and curvature-rounded 3 (G2): the
  areas cut off the corner s^2/2, (1 - pi/4) s^2 and 113/756 s^2, the last by the default
  thumbweight's definition;
- a pipe R10 dipping into a plate, its concave edge chamfered 2 (the chord between the contacts of
  the ball SMLib rolls for that setback, radius 1), rounded r2 and curvature-rounded 2, each
  section's added area by Green's theorem along the plate, the section and the pipe's arc; the
  order-2 section is built from the faces' feet of the point at distance d from both, which on
  the pipe are not linear in d;
- a boss root curvature-rounded 2, the square corner's section revolved (Pappus);
- a variable round r1 to r5 along a box edge (rel 1e-5): the balls' envelope is a circular cone,
  and the volume is the cone's sections integrated along the edge.

Three fail today and are listed: the two curvature rounds whose faces are not a square corner of
planes (SMLib's quintic blend curve, not the order-2 section: rel 6.5e-7 and 1.2e-5) and the
variable round (great circles, not the envelope's: rel 4.8e-5). The other five pass on SMLib
7ac037e.

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

* Kernel rig: the curvature fillets' expected bodies from the order-2 section at thumbweight 0

SMLIB_BLEND_CURVATURE's section is now the order-2 G^n section at thumbweight 0. Between two
planes square to each other it cuts A = 0.154192655384275 s^2 off the corner, its first moment
about either face 0.0291753295725360 s^3 (Green's theorem on the section): the curvature cases
built on that corner (862, 2708, every-box-edge, every-hole-top-rim, every-l-bracket,
every-block-cylinder-crease, the three-edges-at-bar-corner reports, 3604's fillet) take their
volumes from it by their own methods, the peg's front blend 23.3237146876 (its section along x
up to the wall). Between planes the section's t^3 term vanishes (their feet are linear in the
distance): the box edge case is exactly G3. The boss root is a certified fit, G3 to the plate and
G2 to the wall at Continuity Analysis's tolerances: at least G2. The boss root and the pipe on
the plate pass on SMLib's section and leave the known-failing list.

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

* Curvature fillets: the section's default thumbweight is equal area, so the round's volumes are the circle's

On the round's rails the order-2 section takes off what the ball's circle does: (1 - pi/4) s^2 at a
square corner, first moment 0.0474559669443 s^3 (w = -0.5252123889). The kernel and engine tests
and the curvature rig cases take their volumes from it by their own methods; the pipe on the plate
from its own wedge (70.52878 degrees, w = -0.8544103493). The G2 corner patch's seam test is listed
until the corner step.

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

* Kernel rig: four G2 corners wait for the corner step: their patch meets the new section's rounds G1 at one face seam

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

* Kernel rig: the turn at a corner of two rounds, Mitre (the default) and Swept, at a pocket's floor and a boss's base, by exact volumes

A round's spandrel A = (1 - pi/4) r^2 and its first moment about its wall M = r^3 (5/6 - pi/4):
Mitre adds L A -+ 2 M per edge (cut at, or run on to, the corners' bisector planes), Swept turns
the section about a convex corner's edge, (pi/2) M a corner, and refuses a concave corner rounded
less than the ball. The pocket's tangent chain round its R3 corners is a torus each. Graded to
1e-7, where Mitre and Swept differ by 6.6e-6. The Swept cases are listed until solid.fillet
takes a turn.

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

* Kernel rig: a junction family: oblique wedges 30 to 150 degrees rounded and chamfered (exact), bosses upright and tilted, pipe tees and a Y junction

Four are listed with their causes: the equal-pipe tee (its exact conic-pair rounds meet at the
ellipses' crossings in a corner the old corner code cannot join), the tilted bosses (1001, before the
blend rework too) and the Y junction (refused for room at its triple points).

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

* Variable rounds: graded by the union of their balls (their envelope), not the balls' great circles

The smlib crate's laws and ladder 005, 006, 007 and 012 take the volumes the balls' envelope
removes; the law with a corner is its own test, listed as failing until the corner step.

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

* Kernel tests: variable rounds graded by the union of their balls, the removed volume to 1e-4 of itself

removed_by_balls integrates, in each plane square to a right-angled edge, the corner the union
of the balls leaves (by rays from the edge, the least entry over the balls), cut to the body's
section. The law with a corner through a middle point is its own test, listed as failing.

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

* Kernel tests: a spline radius whose balls fold over one another at its peak is refused

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

* Kernel rig: the variable round on its cone passes (the round is the balls' envelope)

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

* Tests: three rounds of different radii at a corner listed: the corner's transition kinks with the balls' touch circles

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

* Ladder: the variable rounds graded to 1e-6 by the union of their balls; 012 by its law going on past the edge

At the default 1e-3 the great circles' volumes (4e-6 off) passed too. SMLib builds each within
1.6e-7. The golden takes the new volumes and areas; counts unchanged. The G2 corner test listed
as failing passes on SMLib's step 3 and leaves the list.

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

* Reference tests: the variable rounds 77, 78 and 81 pass on SMLib d01d7b0; their known-failure marks come off

The new section builds their edges on the surfaces, so STEP export no longer finds them 4.6e-5
to 6.1e-5 off. develop's Reference tests (60393a718) report all three as passing while marked.

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

* STEP import: a ball's halves whose seams are off each other's quarter lines each form within one turn

A sphere split into two halves along a circle about its axis, each half covering a pole with no
seam, gets a seam per half from the first vertex of its loop to the pole. The circles between the
halves group their frames so they turn together, and when the two seams are not a whole number of
quarters apart no one turn fits both: the group kept its frames, a half's form ran past one turn,
SMLib's trim (SMLib and solidcore alike) across the form's end crossed the face, and the mesh
folded over the ball (a rotor's ball showed torn; its half had 196 times its area in triangles).

When no one turn fits the group, each spilling face is now turned on its own to start on a quarter
line. On the rotor file this also clears three other faces that were filled leniently and a
0.076 mm edge gap; regression test in step_in/tests.rs with the area and volume of each half.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdcgEu4Lr3moCCisdZShKz
(cherry picked from commit b3188e005d427043b918267449c2336a1777e54f)

* SMLib: pin 5e8c9c1 (the new blend's corners: Ball and End/Join; exact fold and room checks with fit sizes; overflow in place; the round as its definition)

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

* Kernel tests: a law's dip shows its corner ball's sphere, a peak's two rounds cross (the reasons had them swapped)

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

* Reference tests: the variable rounds 77, 78, 80 and 81 expect the union of their balls

A variable round is the envelope of the balls rolled along the edge, which takes less than a
quarter round in each section. The expectations are now that union, the law continued past the
edge's ends along its end slopes, integrated by Gauss-Legendre panels in x and t; 2, 4 and 8
panels agree to 1e-9 of the body's values, and 77's removed volume matches a brute-force union of
balls to 2.5e-10. The nearest-ball search now scans the balls the ray meets around ball x
(|x - s| < r(s) sqrt(sin 2t)) on their own, which near the faces and where 78's radius closes to 0
are narrower than the global scan's step. Regenerating keeps each case's known_failure. 79, a
constant radius, is unchanged.

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

* Known failing: the law corners' kernel and crate tests and rig 1207 pass on SMLib 48ea616

SMLib builds a variable round's law corners as its balls' union (the corner
ball's zone at a dip, the sides' crossing at a peak): the kernel's corner
through a middle point (39.5410) and the crate's 4 -> 2 -> 4 law (39.5421) pass.
Rig 1207 (the bar's top edge rounded after the boss's arc, r0.5) builds and
grades on the same SMLib; it failed on c820346.

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

* Kernel rig: every fillet and chamfer case rounds its tangent chain

The owner's ruling of 2026-11-01: the rig and the ladder round with tangent_chain true, how a
user's default pick behaves. The 38 fillets-area recipes and the ladder's 07-fillets/016 and 018
that passed false now pass true. The shell recipes keep theirs: a shell's tangent_chain is its
open-face chain, a different feature.

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

* Kernel rig: the partial-selection cases kept as -partial-chain copies, tangent chain off

Where a case's point is one edge of a tangent chain rounded alone, a copy keeps tangent_chain
false as its own case (SMLib rounds exactly the picked edges, ending at the smooth vertex); their
expectations stay as they were, to be re-derived:
- fillets/1207-fillet-box-end-edge-into-unblended-round-partial-chain;
- fillets/every-fillet-after-fillet-curvature-front-then-side-r3-partial-chain;
- fillets/1207-fillet-bar-top-edge-after-boss-arc-round-partial-chain, renamed from
  ...-no-tangent-chain: with the chain on it is exactly 1207-fillet-bar-top-edge-after-boss-arc-round,
  which the rig already has;
- ladder/07-fillets/020 and 021, the r5/r3/r7 and r5/r3/r2 corners (016 and 018) with the r3 on
  its edge alone: with the chain on, the r3 runs on along the r5 round's end arc and down the
  vertical edge the third round was to take.

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

* Kernel rig: the concave arc between the block's tangent sides kept with tangent chain off

fillets/every-block-cylinder-tangent-concave-arc-r2-partial-chain: the pick takes one piece of the
concave half circle, which SMLib splits; with the chain off its other pieces are left out, and the
blend is refused at the piece's end (the base gate's outcome). With the chain on the case rounds
the whole half circle. The copy keeps its expectation, to be re-derived.

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

* Kernel rig: two chains that now build graded by their exact volumes

With the tangent chain on, two cases round more than the edge they pick, and their old
expectations were the single edge's:
- 1207-fillet-box-end-edge-into-unblended-round: the r1 chain is the end face's top edge, the r3
  round's end arc and the back upright; 34 A + (pi/2) A (3 - c) by Pappus on the arc:
  V = 7953.139216 (it built 7953.139216, the old 7957.505 passed only inside 1e-3), 10 faces.
- 851-fillet-top-front-then-front-left-upright: the upright's chain runs up the first round's
  end arc on x = 0 and along the left top edge, so the corner becomes the R2 ball's eighth:
  V0 less the first round's spandrel to the wall (35.344727) less 27 (4 - pi) + 2 pi/3:
  V = 9530.777979 (built 9530.777987), 10 faces.

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

* Kernel rig: a curvature chain round a round's end refused for its size; the concave half circle builds

- every-fillet-after-fillet-curvature-front-then-side-r3: the side edge's chain runs round the
  first curvature round's end curve, whose radius of curvature has a weighted mean of 3 (its
  setback) and is unbounded at the ends, so somewhere below 3: the size-3 blend's contact line
  folds back on itself. Refused, "larger than the faces beside its edges allow", as ladder
  07-fillets/016's r7 on the r5 arc. (It was solid or refused for running on tangentially.)
- every-block-cylinder-tangent-concave-arc-r2-no-tangent-chain: the chain takes every piece of
  the half circle and ends at the convex edges it runs on into; it builds the analytic 19091.9620,
  so the outcome is solid (the refusal alternative was the chain-off one's).

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

* Known failing: two 851 chains whose corner piece is a ball of tube-centre radius 0

851-fillet-arc-then-top-front and 851-fillet-top-front-then-upright: with the tangent chain on,
each R2 chain runs along the end curve of an R2 round on a curved face (the arc round's end on the
front face; the first round's end on the wall), where the rolling ball sits on that round's own
centre line, so the piece is the ball about (39.2538, -19, 8): the three-edge rolling-ball corner.
SMLib fails with status 1001; the same piece on a plane end arc builds (front-left upright).
Expectations unchanged.

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

* Ladder: 016 and 018 refused, their r3 chain takes the vertical edge; 020 and 021 recorded

With the tangent chain on, 016's and 018's r3 runs on along the r5 round's end arc and down the
vertical edge, so the third pick (60, 40, 8) is 3 from both of that round's contact lines and
names no edge: refused, asking for a point along one edge. The corners they were made for are
020 and 021 (the r3 alone), whose golden entries are 016's and 018's (021 measured on f12a101).

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

* Kernel tests: three rounds at a box corner end on one rational patch on the ball's sphere, not an analytic sphere

SMLib's Ball corner is always the rational patch exactly on the sphere (owner ruling, blend
design §23), so the corner counts as an 'other' face beside the three cylinders and six planes.
The removed volume still checks the corner against r³ − πr³/6.

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

* Kernel rig: the curvature round on a pipe on a plate is listed: a seam grades G3 where exactly G2 is wanted

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

* Kernel tests: the box-and-fillet wire fixture records the round's cylinder frame as SMLib now keeps it

The round's cylinder keeps the frame it was made on when it is turned (SMLib's SwapUV no longer
re-derives it): its origin sits at x = −20 on the same axis line, (x, 2, 8) along x, radius 2.
Same cylinder; one number in the record moves.

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

* Known failing: two kernel tests' reasons as they fail on SMLib 5e8c9c1 (a partial selection running into an unblended tangent edge)

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

* Kernel rig: two curvature rounds get 600 s, past the 180 s default, while G2 certification is slow (tangency-labs/smlib#96)

On the CI runners the boss root and the hole rim, both size-2/size-1 curvature rounds, build
correctly but take more than 180 s: the order-2 section's span certificate and the round's
definition are evaluated station by station. The per-case limit is the one the thread cases use.

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

* Lid ring sketch test: find the ring under the pick, not by main's kernel's name

On SMLib the lip join's exact history names the lid underside's ring a piece
of the lid's own start (F14:start#[...]), as the naming rule says for a face
that came from the feature's input; main's kernel named it after the case's
top it rests on (F2:top). The test looked the ring up by that name and so
stopped before the attachment it guards. It now takes the underside piece
under the pick point, which the cap certificate attaches on either kernel.

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

* Viewport culling test: each box's triangles from its mesh, not 12

SMLib meshes a 10 mm box in 108 triangles, so the hard-coded 12 a box stopped
the test before it reached the culling. The expected counts are now each
body's span in the scene.

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

* Timeline chip test: a fillet that really reduces

The R20 fillet with fit reduce on one top edge of a 10 mm box builds on SMLib
without a reduced-radius warning, so the fixture had no warned feature. It
now rounds the four vertical edges at R6, reduced to the full round (5), as
the copy-warning test beside it does, and checks the warning says so.

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

* Oracle: 34-chamfer-then-fillet and 35-fillet-curved-curved pass on SMLib 5e8c9c1 (their STEP edges are on their surfaces)

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

* Known-failing: the shell of a fully rounded box opened at one face fails on SMLib 5e8c9c1 (tangency-labs/smlib#102)

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

* Known-failing: the action matrix's oblique cylinder cut fillet passes on SMLib 5e8c9c1; two shells still fail

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

* Kernel rig: the boss root's curvature round gets the rig's 1800 s cap: on the CI runners, 8 cases at a time, its G2 certification passed 600 s (tangency-labs/smlib#96)

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

* Kernel rig: a case's continuity can ask for an order per seam (seams: [{order, edges, exact}])

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

* Kernel rig: the pipe on a plate's curvature round meets the plate G2 and the pipe G3, 40307.8856312 (option B's order-2 jets, tangency-labs/solidcraft#10845)

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

* Edge names: a seam names its one face once; the curve-id test reads every edge kernel-neutrally

A seam's two sides are the same face, and edge names list each face an edge
bounds once, so SMLib's extruded circle names its side's seam F2:side:c1, with
no separator. The name grammar now says one face also means a seam. The #5858
test no longer counts one '|' an edge: every part of every edge name is a
whole face name of the edge, one per face it bounds, two for the rims and one
only for the side's seam (checked to run bottom to top on the circle).

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

* rustfmt on the merged tree

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

* Known failing on SMLib: two of main's new tests assume truck's box mesh and fillet torus

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

* SMLib: pin f916a8d (a round on a definition face is its definition; the G1 setback fill certified convex, setbacks too short refused naming the least; the G2 certificate halves a span no level can certify at once)

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

* Fillet: a setback too short for its corner is refused naming the least that builds (smlib_blend_fit_size)

SMLib's setback corner takes a G1 fill only once certified convex, and refuses a setback too
short for that as SMLIB_ERR_NO_ROOM with SMLIB_FIT_SETBACK_AT_LEAST, the smallest setback that
builds. The kernel reads it (solidcraft-smlib binds smlib_blend_fit_size) and names it; the
Fillet dialog says it in plain words. Other room refusals keep their words. The r5 box corner
set back 5.5 is refused naming 5.962, which builds.

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

* Kernel tests: three rounds of different radii at a corner meet in SMLib's setback corner

Each round runs whole to its section square to its edge at its setback (the owner's rule, at
least 1.5 r, scaled to the least at which the G1 fill is certified convex), and the hole is the
setback split: one patch per round and the triangle in three quadrilaterals, 15 faces, G1.
Outside the box the sections span the body is the three strips', so its volume is theirs within
that box's. A later r3 round at the mitred corner makes the body all three at once make
(7928.142563938, 15 faces), as the engine and the model-cache tests have it.

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

* Kernel tests: SMLib's own corner between curvature rounds is curvature continuous, as the G2 patch is

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

* Kernel rig: the boss root's and the hole rim's curvature rounds take the default timeout again (37 s and 34 s on the box under the rig's parallel load, tangency-labs/smlib#96)

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

* Agents: model decisions use exact geometry; a mesh is for display, export, previews and mesh bodies

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

* xtask: mesh-decisions, model decisions use exact geometry; today's 176 mesh decision sites are listed and the list only shrinks

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

* Sheet metal: cuts and holes pass over sheets and bends by their exact boxes, never a mesh's

A primitive cut skips the sheets kernel::apart (exact bounds grown by the record tolerance)
finds clear of its tool; the bend check leaves the clear case to kernel::boolean, which answers
it from the same exact boxes with no intersection work. The mesh-box helpers are gone, and their
four lines leave xtask/mesh-decisions.txt (172 sites left).

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

* Agents: AGENTS.md loses a stray merge-conflict marker line at its end

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

* Locales: Vietnamese for the setback-too-short refusal, now that vi is a complete catalog

Main made vi a complete catalog; develop's new message (ui.fillet_error_setback_short) had no vi
entry, so every_complete_catalog_carries_every_english_message failed on the merged tree.

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

* Kernel rig: exact volumes for 1213's bent-tube round and 4782's bar round, rel 1e-6

1213: the loft is a cubic skin through the five sections (ellipses between
them), not a torus; its volume 131601.597487935 by quadrature less the exact
rolling-ball removal 233.662461919 gives 131367.935026 (was a Pappus estimate
on a torus, 131360.148, at rel 5e-4).

4782: the section's area in closed form, 25 pi/3 - 5 sqrt(75) + 25, so
V = 630.2934873 (was a grid estimate, 630.293125).

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

* Kernel rig: the boss root's and the hole rim's curvature rounds take 600 s again: under CI's parallel load they pass the 180 s default (develop 820b7b6)

They take 37 s and 34 s alone on the box, but CI runs 8 cases at once on slower runners. Their
G2 certificate cost is the open item (tangency-labs/smlib#106, moved to solidcore#2344).

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

* After the merge: clippy's auto-deref, and the mesh-decisions list for the merged tree

Develop's list was taken on develop alone. Main's code (tap-marking reach, cut and hole box
checks, join pieces, print faces, placed measuring, selection items, imported mesh payloads) and
the branch's own exact_edges and named_vertex use meshes too; three listed functions were renamed
by main (face_outline and plate_recesses into FaceSlice, paste into build).

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

* After the merge: main's new tests on the SMLib kernel

- Cut To the far side of a curved wall (#8634): the tool stops past the first crossing of the
  face ahead of the profile; SMLib's whole bore is one face that a line crosses twice, so the
  single-crossing rule found none and the cut took nothing.
- Clearing view analyses goes through refresh_display, as main's #10687 made the others, so an
  open feature editor keeps OK and Cancel after Zebra.
- vi, a complete catalog on main, gets the setback-too-short message.
- fillets/KNOWN_FAILING: 4782's cause is now its continuity check (SMLib f916a8d builds the
  expected volume).
- Known failing, as main's tests assume truck: the spline wall's spans as one mesh patch, a
  hole touching an edge at a point leaving one body, and a copy of a copy's triangles matching
  a fresh mesh.

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

* Twisted sweep: circles about a straight path are exact cylinders (solidcore#2184)

A twist about a straight path maps a profile circle centred on the path's
line onto itself, so its swept wall is exactly the cylinder. The skin
through the stations stayed within 3.9e-9 of it (6.6e-9 on the outer wall),
and a boolean with another body on that cylinder failed with status 1001
(solidcore#2184; the kernel's handling of a B-spline face lying on a
cylinder stays open there).

sweep_controlled now builds those walls exactly when the path is lines
along the profile's normal and there is no end scale or rails: a profile
whose every loop is such a circle is swept untwisted, and such holes in
another outer loop are cut as exact cylinders from the skinned sweep of
the rest. Other loops, and an axis-centred arc inside a loop of other
curves, are skinned as before; a failed cut falls back to skinning all.

Regression test in smlib_sweeps_tests.rs: #2184's tubes joined at 10, 16,
18, 20 and 30 degrees give one closed body of 11.5625 pi with cylinder
walls; a twisted hexagon with the bore keeps a cylinder bore.

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

* After the merge: a hole that misses is found by exact boxes; a face is a one-face patch; main's mesh-box helpers gone

- `hole_misses` (main's #10701, through brett's branch) decided from the targets' mesh boxes. It now uses their exact boxes (`Body::exact_bounds`). An unknown box still leaves the hole not known to miss.
- With #10303's two shortcuts and `hole_misses` off the mesh, `body_box`, `mesh_box` and `padded_box` have no caller left, and are deleted.
- `project`'s intersection link passes its one face to `meeting_edges` as a one-element patch. This branch picks one face, not #9009's mesh patch.
- xtask/mesh-decisions.txt drops the six sites that no longer use a mesh here: `apply_traced_with`, `cut_end_past_face`, `beyond_cylinder_reach`, `hole_misses`, `body_box`/`mesh_box`/`padded_box`.

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

* After the merge: split_move's Result reaches its callers; to_local's duplicated move check goes

`frames::split_move` returns a Result on this branch: an unplaceable model is an error, not a silent no-split.
- Move/Copy of a component (main's #9914) propagates it, as its other calls do.
- `to_local` had the solid.move check twice after the merge (brett's Option one and ours). Ours stays, after the no-picks/not-placed return.

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

* After the merge: main's two new tests take world_state's Result, as the rest of their files do

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

* Native B-rep: a ball patch is its own surface kind, read from and written to SMLib's flat kind 6

SMLib's flat arrays now carry the corner of three equal rounds as `SURFACE_BALL_PATCH` (kind 6): its net as a NURBS face's block, with its sphere's centre and radius as its definition (ids 36 and 166). The record reads it as `ball_patch {center, radius}`, a label like the analytic kinds, with the net as its `nurbs`. A plain sphere with a form won't do: the net is not a piece of SMLib's sphere NURBS, so SMLib's `IsNurbSurfaceSphere` would refuse it.

Written back, a ball patch is kind 6 with the same definition, which SMLib takes once its net is certified on that sphere. Elsewhere:
- Offsets classify it as its sphere, so its offsets stay exact and collapse at the centre.
- Face kinds count it as a sphere: fillet_three_edges_at_a_corner's corner is now `sphere`, no longer `other`.
- STEP, which has no ball-patch entity, gets the net as the face's B-spline surface, pcurves included.
- Iso curves and SSI of the label are refused; the face's form is what evaluates.

Test: a_rounded_cubes_ball_patches_go_round_with_their_spheres. The cube's eight corners are ball patches about the corners pulled in by r. Points of each patch lie on its sphere to 1e-12. The patches are kind 6 again after import, with the same definitions bit for bit, and the closed-form volume holds.

Format: pending/ball-patch-faces.rs. Docs: docs/native-brep.md and the rig README's `surface_types` (`ball_patch`).

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

* SMLib: pin v1.1.0-alpha (a18bbe5): the generic blend's step-4 corners, the Ball's sphere patch through the flat arrays, all-target CI

Since f916a8d:
- #120: every target again, plus the warnings fix;
- #121: the Ball's patch as flat kind 6 with its sphere, read by the previous commit;
- #122: tests gated on counts;
- #123: the Mixed corner;
- #124: the End at a smooth vertex;
- #125: four or more rounds, and open setbacks;
- #126: the equal-area thumbweight guard;
- #127: the trunk merge.

The SMLIB_PREBUILT checksums are from the release prebuilt-a18bbe5…, written by `scripts/fetch-smlib.sh --pin`.

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

* smlib export tests: rustfmt's line for the rounded cube's edge midpoints

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

* Kernel rig: the fillet and ladder results on SMLib v1.1.0-alpha, the new failures recorded with their reasons

Fixed (off the known-failing list): the Mixed corner's peg cases and the concave arc with the front top edge (smlib #123), and the 40° boss junction (smlib #124). Eight cases in all.

New failures, recorded as they are, each with its reason. They passed on f916a8d.
- **Six cases grade a blend seam G0 where G1 (or G2) is wanted,** at the end of the round's contact seam, after smlib #124's End at a smooth vertex. Two of them also shift in volume.
- **Four cases come back unsupported** after smlib #125, which leaves setbacks to the Setback's corners only.
- **Ladder 07/012** makes 9 faces, 20 edges and 13 vertices, where its golden had 8, 18 and 12. Its golden entry is the new result, with the reason added.

Whether each change is by design or a defect is being established. A defect is fixed in solidcore, where the End and the Setback are being brought over (#2364, #2365), not in the frozen SMLib.

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

* Kernel ladder: 07/021 and 06/018 recorded as they are on SMLib v1.1.0-alpha, each with its reason

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

* Kernel ladder: a golden case marked unstable is gated on its status, and 06/018 is marked

On SMLib v1.1.0-alpha, 06/018 makes 597 or 594 edges from the same input:
the union's post-process keeps pointers to edges it has deleted. A golden
entry's `unstable` reason makes the gate compare that case's status only
and name it in its output; recording keeps the reason, and only an edit of
the golden removes it. v1.1.0-alpha.1 fixes the cause and removes the mark.

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

* After the merge with main: its new code on develop's exact bounds, types and thread fields

- The sheet cut's cull (#10301) and the tapped wall's (#10227) use each
  body's exact box (`exact_box`), not its display mesh.
- model.threads replies with develop's thread fields: form, hand (right,
  left or both) and starts.
- surface.boundary's schema checks a network curve's continuity as the
  handler does, and words a list entry's error as it does.
- Main's new tests take develop's Hole fields and world_state's Result.
- mesh-decisions: contact_sweep's triangle check is now `depth` (main
  split `hits`).

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

* Known failing: the 14 cargo tests SMLib v1.1.0-alpha fails, with what each sees; two cases that pass now

Develop's CI after the a18bbe5 pin (run 41522001173) fails 14 tests that
passed on f916a8d, and passes two listed ones. Each new line says what the
test sees on v1.1.0-alpha; v1.1.0-alpha.1 fixes the setback hole's cause.
shell_tangent_chain_that_opens_every_face_explains_how_to_keep_walls and
oracle 37-pyramid-apex-blend pass and leave their lists.

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

* Cargo.lock: develop's profile, smlib and step crates at main's 0.30.1

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

* Kernel rig: main's six new sketch-only commands are exempt from a kernel case

sketch.construction_line (and its horizontal and vertical forms) and
sketch.spline.insert_point, remove_point and reverse change only the 2D
sketch.

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

* Cuts leave the bodies their tools can't reach, by exact boxes (#10225 on exact geometry)

- The per-target cut loop skips a body whose exact box lies clear of
  every tool's, as main's version did with mesh boxes. The sheet culls
  (#10226, #10301) and this one now leave the far sheets and bodies alone:
  a_cut_leaves_bodies_it_cannot_reach_as_they_were passes and leaves the
  known-failing list.
- a_cut_still_runs_where_its_tool_touches_grazes_or_meets_a_curved_face:
  a tool 0.5 µm clear of the block is now left out (exact boxes; 0.5 µm is
  far past the bodies' tolerances); touching and 0.1 mm in still cut.
- Known failing, with reasons: main's edge lookup by a stored point
  (#11286, on the display mesh) is not taken; SMLib refuses the
  wrong-way chamfer for its tangent chain first.
- EXEMPT: main's eight new sketch-only commands.

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

* Kernel rig: main's new view, rendering, appearance and settings commands are exempt from a case

62 of the commands main added change no geometry: the view and render
panes, rendering lights and results, appearance tools, settings profiles,
point clouds (drawn as points, never a body), the selection, two sketch
edits, component links and History bar visibility, edit.repeat,
timeline.play, inspect.compare_picture (a picture against the outline),
and component.paste_from_design, which pastes the app's cross-window
clipboard that no command fills. Each joins EXEMPT's first section under
its family's reason.

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

* Kernel rig: construct.plane.axis_point gets construction cases

The plane through a box edge and its far corner, graded against its
closed-form normal (0,-2,3)/sqrt(13) through the origin, and the refusal
of a point on the axis.

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

* Kernel rig: assembly cases for mass properties, align, find limits and moving items between parts

- assembly.mass_properties: two boxes (2 g/cm3) in a rigid group: mass
  0.006 kg, centre (18.333,5,5), inertia diag(0.1, 1.0333, 1.0333) kg mm2
  by the parallel-axis theorem.
- occurrence.align: a block's end face onto a base's top face: a quarter
  turn about y, centre (70,20,20), box (65,15,10)-(75,25,30).
- joint.find_limits: a slider between two stops: min -20, max 10 mm.
- component.move_features, browser.reparent and
  component.move_sketches.to_parts: items moved into a turned and moved
  part keep their world place (volumes, centres and boxes by hand).

The mass case assigns a material it creates, so material.create and
material.assign leave EXEMPT. PATHS names the new command modules.

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

* Kernel rig: export and print face cases in the import area

- file.export_step, export_stl, export_3mf, export_obj: a 30x20x10 box
  written and read back (the meshes converted to a solid): V 6000,
  A 2200, 6 faces, box (5,-10,0)-(35,10,10).
- file.export_urdf: a hinged arm on a grounded base: 3 links, 2 joints,
  and the base's mesh, written in metres, reads back as the
  1 x 0.5 x 0.2 box (V 0.1, A 1.6).
- body.print_face: a cylinder's flat end is accepted, its side refused.

PATHS adds the URDF writer and the print face modules.

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

* claim: #11259

* fix: preserve transferred profile frames when capturing positions

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: brett19 <brett19@gmail.com>
Co-authored-by: jyviko <jyviko@gmail.com>
2026-11-04 21:22:49 +00:00
Homero Thompson 1d387d3f21 Measure: the area of a curved face on the exact surface, not the display mesh (#9086) (#11486)
* Measure: the area of a curved face on the exact surface, not the display mesh (#9086)

* Measure: a non-straight border edge leaves the band to the mesh; fix the Vec3 imports (#9086)

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

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 21:22:24 +00:00
github-actions[bot] 51d237829a Regenerate generated files and fold pending files [skip ci] 2026-11-04 21:50:55 +00:00
bherbruck df4f9d3529 Inspect menu: Component Colors is a normal on/off item with a check mark (#11854)
* Inspect menu: Component Colors is a normal on/off item

The Component Colors entry in Inspect > Check was labelled "Enable Component Colors" and
swapped to "Disable Component Colors" with a different icon while on. It is now labelled
"Component Colors" in both states, keeps one icon, and shows a check mark when on, like Draft
Analysis and the other toggles beside it. The command id and parameters are unchanged.

The now-unused "disable" strings and the colored_components_off icon are removed. Other
languages' old "Enable ..." wording is dropped so they fall back to English until retranslated.

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

* Keep the Component Colors label in the fully translated catalogs

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

* Checkable menu rows tell screen readers whether they are on

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

* Georgian: Component Colors loses its Enable/Disable wording

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 20:12:36 +00:00
takuto-NA ef2f485e15 Extend viewport AO to the existing virtual ground (#3413) (#11979)
* claim: #3413

* feat(gpu): extend viewport AO to the virtual ground

* test(gpu): retain measured AO render attachments

* perf: initialize ground AO pipeline with concurrent jobs

* fix(gpu): size ground AO receiver for the current frame radius

* fix(viewport): retain orbit caches when ground AO visibility changes
2026-11-04 19:42:01 +00:00
github-actions[bot] a43cd99e55 Regenerate generated files and fold pending files [skip ci] 2026-11-04 21:11:47 +00:00
github-actions[bot] 8f26457487 Regenerate generated files and fold pending files [skip ci] 2026-11-04 21:01:46 +00:00
sascha2101 07ef94e102 Hole: drill only the shown bodies when no target is picked (#7520) (#7562)
* claim: #7520

* Hole: drill only the shown bodies when no target is picked (#7520)

A Hole that names no bodies drilled every body in its path, hidden ones
too, and its dialog preview replaced the hidden bodies with drilled ones.
Like a cut (#3961), it now takes the shown bodies of the active component
as its participants while some are hidden, stored so showing the others
later changes nothing. With nothing hidden, or everything, it still drills
every body.

Its face points are named on the bodies it drills first, so a hidden
body's face in the same plane no longer takes the picked face's name.

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

* Hole: drill the picked body though it is hidden (#7520)

Review on #7562: with the picked face's body hidden (picked through Select
Other, or hidden while the dialog is open), the shown-bodies default left it
out, so the hole drilled only shown bodies further along, or nothing, without
a message. The body whose face holds the hole's position now joins the shown
ones, unless a shown body has a face there too (a face shared by a shown and
a hidden body is the shown one's, as a pick in the view takes it).

The test also covers editing a hole made with nothing hidden while a body it
drills is hidden: timeline.redefine stores participants only when the edit
passes them, so the hole keeps its empty scope and the hidden body its bore.

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

* Hole: test that a hole made with a body hidden replays the same (#7520)

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

* Hole: follow the face-name lookup now returning a Result (#7520)

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

* Hole: tests follow main's Vec3 and world_state (#7520)

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

* Hole: format (#7520)

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

* Hole: with every body hidden, drill them all as before (#7520)

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

---------

Co-authored-by: sascha2101 <221609842+sascha2101@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-11-04 19:19:18 +00:00
semmelrinde 02f276e296 Report a bug: limit answers by characters and show the room left (#7191) (#7196)
* claim: #7191

* Report a bug: limit answers by characters and show the room left (#7191)

The answers were cut to 2,000 bytes, silently: 666 characters of
Chinese or Japanese. They now keep 2,000 characters, as the text field's
own limit counts them, in the form, in the report and in a crash draft.
Under 200 characters left, an answer says how many there are. The
report's budget grows so three full answers in any script still fit
beside the technical details.

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

* Report a bug: leave the crash draft's description as it was

It is the app's own English text, well under the limit in bytes as in
characters; leaving the line alone keeps it apart from #7189's change.

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

* Locales: Arabic and Hebrew for the new strings

ar and he are complete catalogs on main now (#3458): every new message
needs them. The pending-messages test compares right-to-left messages in
display order once #7532 lands (#7527).

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

* Locales: Vietnamese for the new strings

Vietnamese became a complete catalog on main (#5823), so every new English
message needs it too.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 19:15:23 +00:00
austin62jake 9602587b0d Navigation: a middle or right drag keeps the mode it started with (#9557) (#9592)
* Claim #9557

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

* Navigation: a middle or right drag keeps the mode it started with (#9557)

The mode of a middle or right drag was worked out again from the keys
held on every frame, so letting go of Shift partway through a Fusion
Shift+middle orbit turned the rest of the drag into a pan. It is now taken
when the drag starts and kept until its button is released, as a left
drag with the mapping's keys already was. Preferences > Navigation > "A
drag keeps the navigation it started with" (on) turns it off.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-04 19:14:40 +00:00
119 changed files with 2036 additions and 1698 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# While a new geometry kernel is evaluated, new issues and discussions about kernel work, and new
# pull requests that change the kernel directories, are labelled `kernel-paused` for a maintainer to
# answer (CONTRIBUTING.md, "Kernel work is paused"). The logic is in scripts/kernel-paused/ (tested
# answer (CONTRIBUTING.md, "Kernel work goes to solidcore"). The logic is in scripts/kernel-paused/ (tested
# in ci.yml).
name: Kernel paused
+3 -3
View File
@@ -3,7 +3,7 @@
SolidCraft is a clean-room, pure-Rust parametric 3D CAD app (sketch solver, feature timeline, B-rep kernel, an egui UI that agents drive). This file is a router: the hard rules, then the skill to read for your task. Humans: CONTRIBUTING.md.
## Start here
Work: ROADMAP.md, "Where help is wanted most" in CONTRIBUTING.md, issues labelled `help wanted`, `good first issue`, `needs-triage`; architecture in `docs/architecture.md`. Act when the code, docs and rules settle a question; ask only when it changes what users see. Local `plan/` notes are optional.
Work: ROADMAP.md, "Where help is wanted most" in CONTRIBUTING.md, issues labelled `help wanted`, `good first issue`, `needs-triage`; architecture in `docs/architecture.md`. Act when the code, docs and rules settle a question; ask only when it changes what users see.
## Hard rules (what enforces each)
- Independent: never copy another product's code, icons, text or templates; never drive or take data from commercial CAD. `skills/solidcraft-ground-rules/SKILL.md`.
@@ -11,7 +11,7 @@ Work: ROADMAP.md, "Where help is wanted most" in CONTRIBUTING.md, issues labelle
- Every asset is original or openly licensed with a row in ATTRIBUTION.md; read `assets/AGENTS.md` before icon work: `cargo xtask assets`, `cargo xtask icons`.
- Never crash: no unwrap/expect/panic/unsafe in non-test code (unsafe allowlist in crash-safety), errors are `Result`, input is hostile: clippy `-D warnings`, `hostile_params_never_panic`.
- Model decisions (what an operation does, which bodies it touches, which face or edge a point or name means, depths, nearness) use exact geometry; a mesh is for display, export, previews and mesh bodies (`xtask/mesh-decisions.txt` only shrinks): `cargo xtask mesh-decisions`.
- New kernel cases go to solidcore: `cargo xtask lists`.
- Kernel ops and math go to solidcore; features call its API: `cargo xtask lists`.
- Everything is a command and programmatic calls never open dialogs: `skills/solidcraft-commands/SKILL.md`.
- Layering (geom, sketch/kernel/render, doc, io, engine, ui-egui, apps); only `crates/kernel` uses truck: `cargo xtask layers`.
- One outer UI container, one camera path (`navigation::handle`), colours from theme tokens: `cargo xtask ui-containers`.
@@ -31,7 +31,7 @@ Work: ROADMAP.md, "Where help is wanted most" in CONTRIBUTING.md, issues labelle
## Working on X: read the skill first
| Working on | Read |
|---|---|
| A PR, a claim, review, CI or evidence | `skills/solidcraft-contributing/SKILL.md` |
| A feature, PR, claim, review, CI, evidence | `skills/solidcraft-contributing/SKILL.md` |
| Licences, assets, clean-room, the name | `skills/solidcraft-ground-rules/SKILL.md` |
| Non-test Rust, hostile input, a crash report | `skills/solidcraft-crash-safety/SKILL.md` |
| A command, a crate boundary | `skills/solidcraft-commands/SKILL.md` |
-1
View File
@@ -317,7 +317,6 @@ Editable drawing code, native projects and shipped exports are collected under `
| assets/icons/ui/design/svg/model/circle_ttt.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/coil.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/colored_components.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/colored_components_off.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/combine.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/component.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
+27 -12
View File
@@ -17,15 +17,27 @@ Before choosing a task, check the [command inventory](docs/features.md) and
Questions, ideas or want to say what you're working on? Join the [Discord](https://discord.gg/FtU4NQNVDH).
## Kernel work is paused
## Kernel work goes to solidcore
We're moving to a new geometry kernel. Until that's done, please don't open pull requests that
change `crates/kernel/`, `crates/geom/` or `vendor/truck*`, or issues and discussions about kernel
work: booleans, fillets, chamfers, shell, offsets, sweeps and lofts, threads and holes, STEP/IGES
import and export, or tessellation. The pinned issue on the tracker explains it. New ones get the
`kernel-paused` label (`.github/workflows/kernel-paused.yml`) and a maintainer answers them. Crashes
and data loss are still taken as issues. Pull requests outside those directories carry on as normal,
including the dialogs and tools for these features.
Since 0.40, SolidCraft's geometry kernel is [solidcore](https://github.com/tangency-labs/solidcore),
and the kernel operations still in this repository are migrating there. The split:
- **SolidCraft** is the application: commands, the timeline, sketches, the UI, file handling. A new
feature calls the solidcore API and builds on the operations it offers.
- **solidcore** holds the geometry: evaluators, intersections, booleans, blends, shells, sweeps,
tessellation and the rest of the math. If a feature needs kernel functionality solidcore does not
have yet, add it to solidcore first (its own issue and PR), then use it here.
So pull requests that add geometry math to SolidCraft (`crates/kernel/`, `crates/geom/`, or a new
evaluator or solver anywhere else) are not taken; they get the `kernel-paused` label
(`.github/workflows/kernel-paused.yml`) and a maintainer points them to solidcore. Crashes and data
loss are still taken as issues here, and the dialogs and tools for kernel features carry on as
normal.
Work started before 0.40 was written against the earlier kernel. It is still welcome: port it onto
today's code, calling solidcore for the geometry, before it can land. Expect a substantial rework
for anything that touched the kernel. Designs keep opening and rebuilding on the new kernel; where a
result changes, the PR says so and why.
## Get it running (about 10 minutes on a fast machine)
@@ -277,7 +289,7 @@ mean something else, it needs a format change. **Add one file, and edit nothing
change's file, or any `v<N>/` folder: those are fixed once released.
That's all. The build counts the files: while any are pending it writes the next format number,
which no release reads, so a release warns about a design that has your change and keeps what it can't read of it.
which no release reads, so a release refuses a design that has your change instead of losing it.
A file also lists the pending changes it has (`format_pending`), so a later development build
runs only the steps that file is missing. Two pull requests that each add a change merge without
a conflict. `crates/doc/src/format/pending/README.md` has an example.
@@ -329,8 +341,11 @@ and a fresh CI run:
1. Work is tracked in [GitHub issues](https://github.com/tangency-labs/solidcraft/issues), labelled
by area (`area:kernel`, `area:sketch`, `area:doc`, `area:ui`, `area:infra`). For anything
bigger than a bug fix that has no issue yet, open one first. **Claim the issue with a draft
PR** before you start, so two people don't build the same thing:
bigger than a bug fix that has no issue yet, discuss it on [Discord](https://discord.gg/FtU4NQNVDH)
first, then open an issue. Before you start, search **all** issues and pull requests, open and
closed, for similar work (`gh issue list -R tangency-labs/solidcraft --state all --search "<words>"`,
and the same with `gh pr list`). **Claim the issue with a draft PR** before you start, so two
people don't build the same thing:
- Check nobody has it: `gh pr list -R tangency-labs/solidcraft --state open --search "#N"`.
- Branch from the latest `main`, commit `git commit --allow-empty -m "claim: #N"`, push, and
open a **draft** PR titled `<summary> (#N)`, e.g. `Fillet: variable radius along a chain (#123)`.
@@ -443,7 +458,7 @@ correct them freely and it won't put them back.
## Where help is wanted most
**Kernel speed (slow fillets, booleans, STEP):** please don't rewrite the current kernel code for speed; it is being replaced by [solidcore](https://github.com/bherbruck/solidcore) ([progress](https://tangency-labs.github.io/solidcore/)). Report slow cases with a file under the `kernel-perf` label, and put kernel effort into solidcore instead. See [#1597](https://github.com/tangency-labs/solidcraft/issues/1597).
**Kernel speed (slow fillets, booleans, STEP):** please don't rewrite the current kernel code for speed; the kernel is [solidcore](https://github.com/tangency-labs/solidcore) ([progress](https://tangency-labs.github.io/solidcore/)). Report slow cases with a file under the `kernel-perf` label, and put kernel effort into solidcore instead. See [#1597](https://github.com/tangency-labs/solidcraft/issues/1597).
See the [`help wanted`](https://github.com/tangency-labs/solidcraft/labels/help%20wanted) and
[`good first issue`](https://github.com/tangency-labs/solidcraft/labels/good%20first%20issue) labels.
+1 -18
View File
@@ -318,19 +318,6 @@ fn load_checked(path: &str, check: solidcraft_engine::schema::ParamCheck) -> Res
/// `load_checked`, the features computed under `validation` (`diagnostics.validation`), which
/// the session keeps.
fn load_with(path: &str, check: solidcraft_engine::schema::ParamCheck, validation: solidcraft_engine::kernel::Validation) -> Result<Session, String> {
let s = read_session(path, check, validation)?;
// A newer SolidCraft's file opens all the same; stderr says so, stdout stays the caller's.
for warning in s.doc.newer_file.iter().flat_map(|file| file.warnings()) {
eprintln!("{path}: warning: {warning}");
}
Ok(s)
}
fn read_session(
path: &str,
check: solidcraft_engine::schema::ParamCheck,
validation: solidcraft_engine::kernel::Validation,
) -> Result<Session, String> {
let mut s = Session::default();
s.param_check = check;
s.set_validation(validation);
@@ -592,7 +579,7 @@ fn cmd_eval(args: &[String]) -> Result<(), String> {
let mut s = open(flag(args, "--in"), positional(args), solidcraft_engine::schema::ParamCheck::Off)?;
let m = s.execute("inspect.measure", &json!({})).map_err(|e| e.to_string())?;
let d = s.execute("document.inspect", &json!({})).map_err(|e| e.to_string())?;
let mut out = json!({
let out = json!({
"body_count": m["body_count"],
"bodies": m["bodies"],
"total": m["total"],
@@ -600,10 +587,6 @@ fn cmd_eval(args: &[String]) -> Result<(), String> {
"params": d["params"],
"sketches": d["sketches"],
});
if let Some(file) = &s.doc.newer_file {
out["newer_file"] = file.report();
out["warnings"] = json!(file.warnings());
}
print_json(&out, json_out)
}
+19 -51
View File
@@ -159,7 +159,7 @@ fn cli_loads_both_design_encodings_by_contents() {
}
/// #1369: a pending concept that did not land in the numbered release must not be silently
/// dropped on open/resave, in either encoding: it opens with a warning, and saving keeps it.
/// dropped on open/resave, in either encoding or before replacing an existing destination.
#[test]
fn unknown_pending_concepts_preserve_the_open_design_and_output_files() {
use solidcraft_engine::{Session, doc, io};
@@ -173,29 +173,34 @@ fn unknown_pending_concepts_preserve_the_open_design_and_output_files() {
let source = dir.0.join(format!("experimental-{version}.{extension}"));
std::fs::write(&source, &bytes).unwrap();
let mut session = Session::default();
// It opens, and the reply says what it is.
let reply = session.execute("doc.open", &json!({"path": source})).unwrap();
let notice = reply["newer_file"]["notice"].as_str().unwrap().to_string();
assert!(notice.contains("experimental-card") && notice.starts_with("Saved by a newer SolidCraft"), "{notice}");
assert_eq!(reply["warnings"][0], notice.as_str());
assert_eq!(session.doc.name, "Experimental");
session.execute("solid.box", &json!({"length": 2, "width": 3, "height": 4})).unwrap();
let original = session.doc.clone();
let original_path = session.path.clone();
let undo = session.undo.len();
let dirty = session.is_dirty();
let err = session.execute("doc.open", &json!({"path": source})).unwrap_err().to_string();
assert!(err.contains("experimental-card") && err.contains("update SolidCraft"), "{err}");
assert_eq!(session.doc, original);
assert_eq!(session.path, original_path);
assert_eq!(session.undo.len(), undo);
assert_eq!(session.is_dirty(), dirty);
for existing in [false, true] {
let destination = dir.0.join(format!("output-{version}-{extension}-{existing}.{extension}"));
let backup = io::backup_path(&destination);
if existing {
std::fs::write(&destination, b"existing design").unwrap();
std::fs::write(&backup, b"existing backup").unwrap();
}
let output = cli().args(["run", "--in"]).arg(&source).arg("--save").arg(&destination).output().unwrap();
assert_eq!(output.status.code(), Some(1));
let error = String::from_utf8_lossy(&output.stderr);
assert_eq!(output.status.code(), Some(0), "{error}");
assert!(error.contains("warning: Saved by a newer SolidCraft") && error.contains("experimental-card"), "{error}");
// Saved, it still says it has the change, and opens the same way.
let saved = io::read_design(&std::fs::read(&destination).unwrap()).unwrap();
assert!(saved.format_pending.iter().any(|name| name == "experimental-card"));
assert!(saved.newer_file.is_some());
assert!(error.contains("experimental-card") && error.contains("update SolidCraft"), "{error}");
if existing {
assert_eq!(std::fs::read(&backup).unwrap(), b"existing design");
assert_eq!(std::fs::read(&destination).unwrap(), b"existing design");
assert_eq!(std::fs::read(&backup).unwrap(), b"existing backup");
} else {
assert!(!destination.exists() && !backup.exists());
}
assert_eq!(std::fs::read(&source).unwrap(), bytes);
}
@@ -203,43 +208,6 @@ fn unknown_pending_concepts_preserve_the_open_design_and_output_files() {
}
}
/// `eval` and `run` on a design one format newer, with a feature kind this version doesn't have:
/// the rest builds, the reply names it, and the saved file still holds it, under the file's number.
#[test]
fn the_cli_opens_a_newer_design_and_saving_keeps_what_it_cant_read() {
use solidcraft_engine::{Session, doc, io};
let dir = Scratch::new();
let mut session = Session::default();
session.execute("solid.box", &json!({"length": 2, "width": 3, "height": 4})).unwrap();
let mut file: Value = serde_json::from_slice(&io::write_design(&session.doc)).unwrap();
let newer = doc::format::format_string(doc::format::FORMAT + 1);
file["format"] = json!(newer);
let future = json!({"id": 900, "name": "Lattice1", "type": "gyroid_lattice", "cell": 2.5});
file["features"].as_array_mut().unwrap().push(future.clone());
let source = dir.0.join("newer.json");
std::fs::write(&source, file.to_string()).unwrap();
let output = cli().args(["eval"]).arg(&source).output().unwrap();
let error = String::from_utf8_lossy(&output.stderr);
assert_eq!(output.status.code(), Some(0), "{error}");
assert!(error.contains("warning: Saved by a newer SolidCraft") && error.contains("Lattice1"), "{error}");
let reply: Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(reply["body_count"], 1);
assert!(reply["newer_file"]["not_understood"][0].as_str().unwrap().contains("gyroid_lattice"), "{reply}");
assert!(reply["warnings"][0].as_str().unwrap().starts_with("Saved by a newer SolidCraft"), "{reply}");
for extension in ["json", "sc"] {
let destination = dir.0.join(format!("saved.{extension}"));
let output = cli().args(["run", "--in"]).arg(&source).arg("--save").arg(&destination).output().unwrap();
assert_eq!(output.status.code(), Some(0), "{}", String::from_utf8_lossy(&output.stderr));
let saved = io::read_design(&std::fs::read(&destination).unwrap()).unwrap();
assert_eq!(saved.format, newer, "never a lower number");
let again: Value = serde_json::from_slice(&io::write_design(&saved)).unwrap();
assert_eq!(again["features"].as_array().unwrap().last().unwrap(), &future);
}
}
#[test]
fn known_pending_concepts_and_released_files_round_trip_with_inert_metadata() {
use solidcraft_engine::{doc, io};
+22 -4
View File
@@ -3,7 +3,8 @@
//! Runs the same [`solidcraft_ui_egui::SolveApp`] as the desktop app through eframe's web runner
//! (wgpu: WebGPU where available, WebGL2 otherwise). Build with `trunk build --release` from this
//! directory (output in `dist/web`). URL flags: `?webgl` forces WebGL2 (remembered until
//! `?webgpu`), `?sample` opens the sample part.
//! `?webgpu`), `?sample` opens the sample part, `?embed=editor` runs it as an editing embed in a
//! host page (no application bar or start page; nothing kept in the browser; #8517).
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(target_arch = "wasm32")]
@@ -42,6 +43,8 @@ fn query_flag(query: &str, name: &str) -> bool {
struct Launch {
open: Option<String>,
embed: bool,
/// `?embed=editor` (#8517): an editing embed in a host page that keeps the files itself.
editor: bool,
}
#[cfg(any(target_arch = "wasm32", test))]
@@ -52,16 +55,19 @@ impl Launch {
for (key, value) in url::form_urlencoded::parse(query.trim_start_matches('?').as_bytes()) {
match key.as_ref() {
"open" if launch.open.is_none() => launch.open = Some(value.into_owned()),
"embed" if value == "editor" => launch.editor = true,
"embed" => embed = true,
_ => {}
}
}
launch.embed = embed && launch.open.is_some();
launch.embed = embed && launch.open.is_some() && !launch.editor;
launch
}
/// The browser's own storage (designs, autosave, recovery, preferences) is used: not when
/// embedded, where the host keeps what is kept.
fn persists(&self) -> bool {
!self.embed
!self.embed && !self.editor
}
fn full_app_url(&self, mut url: url::Url) -> String {
@@ -928,7 +934,7 @@ mod web {
}
let (panel, opener, saver, shown) = (files.clone(), files.clone(), files.clone(), files.clone());
let (restorable, restorer) = (files.clone(), files.clone());
let services = if launch.embed { Services::default() } else { Services {
let services = if !launch.persists() { Services::default() } else { Services {
// Open shows the browser's open dialog for a design where it has one
// (the file keeps its handle, so Save writes back to it), else the
// browser's files window. A command that reads another kind of file
@@ -972,6 +978,7 @@ mod web {
let (sender, receiver) = std::sync::mpsc::channel();
let mut app = SolveApp::new(Session::default(), services).with_control(receiver);
app.embedded = launch.embed;
app.embed_editor = launch.editor;
// Listening before reading: settings another tab writes from now on are
// newer than those read here.
let other_tab_prefs = PrefsInbox::default();
@@ -1082,6 +1089,17 @@ mod tests {
assert!(full.fragment().is_none());
}
#[test]
fn embed_editor_needs_no_design_link_and_keeps_nothing_in_the_browser() {
let editor = Launch::parse("?embed=editor");
assert!(editor.editor && !editor.embed && !editor.persists());
let linked = Launch::parse("?embed=editor&open=https://example.org/a.sc");
assert!(linked.editor && !linked.embed, "an editing embed may open a linked design, still editable");
let viewer = Launch::parse("?embed=viewer&open=https://example.org/a.sc");
assert!(viewer.embed && !viewer.editor, "any other value is the viewer embed, as before");
assert!(!Launch::parse("?other=editor").editor);
}
#[test]
fn design_links_refuse_hostile_schemes_credentials_and_sizes() {
assert!(design_url("https://example.org/arm.solidcraft?download=1", "http://solidcraft.io").is_ok());
@@ -60,7 +60,6 @@ circle_ttt false
close false
coil false
colored_components false
colored_components_off false
combine false
compare false
component false
@@ -1,21 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Disable component colors</title>
<path d="M9.235 17.706L15.059 20.882L15.059 48.941L9.235 44.926Z" fill="#328bd3"/>
<path d="M35.176 9.235L15.059 20.882L15.059 48.941L36.765 35.139Z" fill="#236ead"/>
<path d="M29.046 5L35.176 8.176L15.196 19.718L9.066 16.541Z" fill="#b9ddf8"/>
<path d="M29.046 5.529L35.176 8.706L35.176 36.235L15.059 49.471L9.235 45.765L9.066 17.071Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.066 17.6L15.059 20.882L34.647 9.235" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M15.059 20.882L15.059 47.067" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M32.222 18.595L44.949 25.944L44.949 51.122L32.222 43.774Z" fill="#328bd3"/>
<path d="M54.934 20.173L44.949 25.944L44.949 51.122L54.934 45.352Z" fill="#236ead"/>
<path d="M42.059 12.412L54.934 20.173L44.949 25.944L32 17.706Z" fill="#b9ddf8"/>
<path d="M41.159 11.766L54.934 20.173L54.934 45.352L44.949 51.122L31.164 42.715L31.164 17.536Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M32.222 18.595L44.949 25.944L54.934 20.173" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M44.949 25.944L44.949 51.122" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M18.299 39.062L31.015 46.411L31.015 59L18.299 51.652Z" fill="#328bd3"/>
<path d="M41.921 40.121L31.015 46.411L31.015 59L41.921 52.711Z" fill="#236ead"/>
<path d="M29.194 32.773L41.921 40.121L31.015 46.411L18.299 39.062Z" fill="#b9ddf8"/>
<path d="M29.194 32.773L41.921 40.121L41.921 52.711L31.015 59L18.299 51.652L18.299 39.062Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M18.299 39.062L31.015 46.411L41.921 40.121" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M31.015 46.411L31.015 59" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

+5 -2
View File
@@ -964,8 +964,7 @@
"cmd.inspect_analysis": "تحليل العرض",
"cmd.inspect_center_of_mass": "مركز الكتلة",
"cmd.inspect_clearance": "الخلوص",
"cmd.inspect_colored_components": "تفعيل ألوان المكوّنات",
"cmd.inspect_colored_components_disable": "تعطيل ألوان المكوّنات",
"cmd.inspect_colored_components": "ألوان المكوّنات",
"cmd.inspect_compare": "مقارنة الأجسام",
"cmd.inspect_compare_picture": "مقارنة بصورة مرجعية",
"cmd.inspect_continuity": "تحليل الاستمرارية",
@@ -2040,6 +2039,8 @@
"nav.blender.help": "السحب بالزر الأوسط يدوّر العرض، وShift مع الأوسط يُزيحه، وCtrl مع الأوسط يكبّره؛ والسحب بالأيمن لا يحرك الكاميرا",
"nav.creo.help": "السحب بالزر الأوسط يدوّر العرض، وShift مع الأوسط يُزيحه، وCtrl مع السحب العمودي بالأوسط يكبّره أو يصغّره (السحب لأسفل للتكبير)؛ وتدوير العجلة يكبّره أو يصغّره",
"nav.creo.label": "Creo",
"nav.drag_keeps_mode.help": "يستمر السحب بالزر الأوسط أو الأيمن في التدوير أو التحريك أو التكبير كما بدأ حتى تحرير الزر، مهما ضُغطت المفاتيح أو حُرّرت في أثناء ذلك. عند الإيقاف: تقرر المفاتيح المضغوطة في كل لحظة من السحب.",
"nav.drag_keeps_mode.label": "يحتفظ السحب بطريقة التنقل التي بدأ بها",
"nav.free_orbit.help": "الفأرة والفأرة ثلاثية الأبعاد تدوّران العرض عبر القطبين وتحافظان على اللف. يُعطَّل التقاط المحاور بـ Shift.",
"nav.free_orbit.label": "تدوير حر",
"nav.free_orbit.snap_help": "الفأرة والفأرة ثلاثية الأبعاد تدوّران العرض عبر القطبين وتحافظان على اللف. اضغط مطولًا على Shift أثناء سحب التدوير لالتقاط اتجاه العرض واللف.",
@@ -5364,6 +5365,8 @@
"ui.repeat_nothing": "لا يوجد ما يُكرَّر بعد: ابدأ أداة أو مربع حوار أولًا.",
"ui.replace_with_link_done": "أصبحت النسخ الآن مواضع للتصميم المرتبط.",
"ui.report_a_bug": "الإبلاغ عن خطأ",
"ui.report_characters_left_one": "الأحرف المتبقية: {count}",
"ui.report_characters_left_other": "الأحرف المتبقية: {count}",
"ui.reset": "إعادة تعيين",
"ui.reset_all": "إعادة تعيين الكل",
"ui.reset_the_settings_on_this_page": "إعادة تعيين إعدادات هذه الصفحة",
-2
View File
@@ -957,8 +957,6 @@
"cmd.inspect_accessibility": "Analiza dostupnosti",
"cmd.inspect_center_of_mass": "Centar mase",
"cmd.inspect_clearance": "Provjera zazora",
"cmd.inspect_colored_components": "Omogući boje komponenti",
"cmd.inspect_colored_components_disable": "Onemogući boje komponenti",
"cmd.inspect_compare": "Uporedi tijela",
"cmd.inspect_compare_picture": "Uporedi sliku",
"cmd.inspect_continuity": "Analiza kontinuiteta",
-2
View File
@@ -957,8 +957,6 @@
"cmd.inspect_accessibility": "Analýza přístupnosti",
"cmd.inspect_center_of_mass": "Těžiště",
"cmd.inspect_clearance": "Vůle",
"cmd.inspect_colored_components": "Zapnout barvy komponent",
"cmd.inspect_colored_components_disable": "Vypnout barvy komponent",
"cmd.inspect_compare": "Porovnat tělesa",
"cmd.inspect_compare_picture": "Porovnat obrázek",
"cmd.inspect_continuity": "Analýza spojitosti",
-2
View File
@@ -957,8 +957,6 @@
"cmd.inspect_accessibility": "Tilgængelighedsanalyse",
"cmd.inspect_center_of_mass": "Massecentrum",
"cmd.inspect_clearance": "Frigangskontrol",
"cmd.inspect_colored_components": "Aktivér komponentfarver",
"cmd.inspect_colored_components_disable": "Deaktivér komponentfarver",
"cmd.inspect_compare": "Sammenlign emner",
"cmd.inspect_compare_picture": "Sammenlign billede",
"cmd.inspect_continuity": "Kontinuitetsanalyse",
+4 -2
View File
@@ -960,8 +960,6 @@
"cmd.inspect_accessibility": "Zugänglichkeitsanalyse",
"cmd.inspect_center_of_mass": "Massenschwerpunkt",
"cmd.inspect_clearance": "Freiraum",
"cmd.inspect_colored_components": "Komponentenfarben einschalten",
"cmd.inspect_colored_components_disable": "Komponentenfarben ausschalten",
"cmd.inspect_compare": "Körper vergleichen",
"cmd.inspect_continuity": "Kontinuitätsprüfung",
"cmd.inspect_curvature_comb": "Krümmungskamm-Analyse",
@@ -2021,6 +2019,8 @@
"nav.blender.help": "Mittlere Maustaste dreht, Umschalt+Mitte verschiebt, Strg+Mitte zoomt; rechtes Ziehen lässt die Kamera unverändert",
"nav.creo.help": "Ziehen mit der mittleren Maustaste dreht, Umschalt+Mitte verschiebt, Strg+Mitte zoomt beim vertikalen Ziehen (nach unten zum Vergrößern); das Mausrad zoomt",
"nav.creo.label": "Creo",
"nav.drag_keeps_mode.help": "Ein Ziehen mit der mittleren oder rechten Taste dreht, verschiebt oder zoomt weiter wie begonnen, bis die Taste losgelassen wird, egal welche Tasten dabei gedrückt oder losgelassen werden. Aus: Die gehaltenen Tasten entscheiden in jedem Moment des Ziehens.",
"nav.drag_keeps_mode.label": "Ziehen behält die Navigation bei, mit der es begann",
"nav.free_orbit.help": "Maus und 3D-Maus drehen über beide Pole hinweg und behalten die Rollneigung bei. Das Einrasten mit Umschalt ist deaktiviert.",
"nav.free_orbit.label": "Freie Drehung",
"nav.free_orbit.snap_help": "Maus und 3D-Maus drehen über beide Pole hinweg und behalten die Rollausrichtung bei. Halten Sie beim Ziehen zum Drehen die Umschalttaste gedrückt, um Blickrichtung und Rollausrichtung einrasten zu lassen.",
@@ -5305,6 +5305,8 @@
"ui.repeat_nothing": "Noch nichts zu wiederholen: Starten Sie zuerst ein Werkzeug oder einen Dialog.",
"ui.replace_with_link_done": "Die Kopien sind jetzt Platzierungen des verknüpften Designs.",
"ui.report_a_bug": "Fehler melden",
"ui.report_characters_left_one": "{count} Zeichen übrig",
"ui.report_characters_left_other": "{count} Zeichen übrig",
"ui.reset": "Zurücksetzen",
"ui.reset_all": "Alle zurücksetzen",
"ui.reset_the_settings_on_this_page": "Einstellungen auf dieser Seite zurücksetzen",
+1 -2
View File
@@ -22,8 +22,7 @@
"cmd.component_cycle_color": "Cycle Component Colour",
"cmd.construct_point_center": "Point At Centre Of Circle/Sphere/Torus",
"cmd.inspect_center_of_mass": "Centre of Mass",
"cmd.inspect_colored_components": "Enable Component Colours",
"cmd.inspect_colored_components_disable": "Disable Component Colours",
"cmd.inspect_colored_components": "Component Colours",
"cmd.parameters_favorite": "Favourite Parameter",
"cmd.render_aging_describe": "Layered Surface Aging",
"cmd.render_effects_describe": "Lens and Creative Effects",
+5 -2
View File
@@ -964,8 +964,7 @@
"cmd.inspect_analysis": "View Analysis",
"cmd.inspect_center_of_mass": "Center of Mass",
"cmd.inspect_clearance": "Clearance",
"cmd.inspect_colored_components": "Enable Component Colors",
"cmd.inspect_colored_components_disable": "Disable Component Colors",
"cmd.inspect_colored_components": "Component Colors",
"cmd.inspect_compare": "Compare Bodies",
"cmd.inspect_compare_picture": "Compare Picture",
"cmd.inspect_continuity": "Continuity Analysis",
@@ -2040,6 +2039,8 @@
"nav.blender.help": "Middle drag orbits, Shift+middle pans, Ctrl+middle zooms; right drag leaves the camera alone",
"nav.creo.help": "Middle drag orbits, Shift+middle pans, Ctrl+middle drag vertically zooms (down to zoom in); scroll the wheel to zoom",
"nav.creo.label": "Creo",
"nav.drag_keeps_mode.help": "A middle or right drag goes on orbiting, panning or zooming as it began until the button is released, whatever keys are pressed or let go meanwhile. Off: the keys held decide at every moment of the drag.",
"nav.drag_keeps_mode.label": "A drag keeps the navigation it started with",
"nav.free_orbit.help": "Mouse and 3D mouse rotate through both poles and preserve roll. Shift axis snapping is disabled.",
"nav.free_orbit.label": "Free orbit",
"nav.free_orbit.snap_help": "Mouse and 3D mouse rotate through both poles and preserve roll. Hold Shift during an orbit drag to snap the view direction and roll.",
@@ -5364,6 +5365,8 @@
"ui.repeat_nothing": "Nothing to repeat yet: start a tool or dialog first.",
"ui.replace_with_link_done": "The copies are now placements of the linked design.",
"ui.report_a_bug": "Report a bug",
"ui.report_characters_left_one": "{count} character left",
"ui.report_characters_left_other": "{count} characters left",
"ui.reset": "Reset",
"ui.reset_all": "Reset All",
"ui.reset_the_settings_on_this_page": "Reset the settings on this page",
-2
View File
@@ -963,8 +963,6 @@
"cmd.inspect_analysis": "Análisis de vista",
"cmd.inspect_center_of_mass": "Centro de masas",
"cmd.inspect_clearance": "Holgura",
"cmd.inspect_colored_components": "Activar colores de componente",
"cmd.inspect_colored_components_disable": "Desactivar colores de componente",
"cmd.inspect_compare": "Comparar cuerpos",
"cmd.inspect_compare_picture": "Comparar imagen",
"cmd.inspect_continuity": "Análisis de continuidad",
-2
View File
@@ -964,8 +964,6 @@
"cmd.inspect_analysis": "Näytä analyysi",
"cmd.inspect_center_of_mass": "Massakeskipiste",
"cmd.inspect_clearance": "Välys",
"cmd.inspect_colored_components": "Ota komponenttivärit käyttöön",
"cmd.inspect_colored_components_disable": "Poista komponenttivärit käytöstä",
"cmd.inspect_compare": "Vertaa kappaleita",
"cmd.inspect_compare_picture": "Vertaa kuvaa",
"cmd.inspect_continuity": "Jatkuvuusanalyysi",
-2
View File
@@ -956,8 +956,6 @@
"cmd.inspect_accessibility": "Analyse d'accessibilité",
"cmd.inspect_center_of_mass": "Centre de gravité",
"cmd.inspect_clearance": "Jeu",
"cmd.inspect_colored_components": "Activer les couleurs des composants",
"cmd.inspect_colored_components_disable": "Désactiver les couleurs des composants",
"cmd.inspect_compare": "Comparer les corps",
"cmd.inspect_compare_picture": "Comparer les images",
"cmd.inspect_continuity": "Analyse de continuité",
-2
View File
@@ -957,8 +957,6 @@
"cmd.inspect_accessibility": "Análise de accesibilidade",
"cmd.inspect_center_of_mass": "Centro de masas",
"cmd.inspect_clearance": "Folga",
"cmd.inspect_colored_components": "Activar as cores dos compoñentes",
"cmd.inspect_colored_components_disable": "Desactivar as cores dos compoñentes",
"cmd.inspect_compare": "Comparar corpos",
"cmd.inspect_compare_picture": "Comparar a imaxe",
"cmd.inspect_continuity": "Análise de continuidade",
+5 -2
View File
@@ -964,8 +964,7 @@
"cmd.inspect_analysis": "ניתוח תצוגה",
"cmd.inspect_center_of_mass": "מרכז מסה",
"cmd.inspect_clearance": "מרווח",
"cmd.inspect_colored_components": "הפעלת צבעי רכיבים",
"cmd.inspect_colored_components_disable": "ביטול צבעי רכיבים",
"cmd.inspect_colored_components": "צבעי רכיבים",
"cmd.inspect_compare": "השוואת גופים",
"cmd.inspect_compare_picture": "השוואה לתמונת ייחוס",
"cmd.inspect_continuity": "ניתוח רציפות",
@@ -2040,6 +2039,8 @@
"nav.blender.help": "גרירה אמצעית מסובבת, Shift וגרירה אמצעית מזיזים, Ctrl וגרירה אמצעית מבצעים זום; גרירה ימנית לא מזיזה את המצלמה",
"nav.creo.help": "גרירה בלחצן האמצעי מסובבת את התצוגה, Shift והלחצן האמצעי מזיזים אותה, Ctrl וגרירה אנכית בלחצן האמצעי משנים את הזום (גרירה מטה להגדלה); גלילת הגלגלת משנה גם היא את הזום",
"nav.creo.label": "Creo",
"nav.drag_keeps_mode.help": "גרירה בכפתור האמצעי או הימני ממשיכה לסובב, להזיז או לשנות זום כפי שהתחילה עד שמשחררים את הכפתור, בלי קשר למקשים שנלחצים או משתחררים בינתיים. כבוי: המקשים הלחוצים קובעים בכל רגע של הגרירה.",
"nav.drag_keeps_mode.label": "גרירה שומרת על הניווט שבו התחילה",
"nav.free_orbit.help": "העכבר והעכבר התלת־ממדי מסובבים דרך שני הקטבים ושומרים על הגלגול. הצמדת הצירים עם Shift מושבתת.",
"nav.free_orbit.label": "סיבוב חופשי",
"nav.free_orbit.snap_help": "העכבר והעכבר התלת־ממדי מסובבים דרך שני הקטבים ושומרים על הגלגול. יש להחזיק את Shift בזמן גרירת סיבוב כדי להצמיד את כיוון התצוגה ואת הגלגול.",
@@ -5364,6 +5365,8 @@
"ui.repeat_nothing": "אין עדיין מה לחזור עליו: התחל קודם כלי או תיבת דו־שיח.",
"ui.replace_with_link_done": "העותקים הם עכשיו מיקומים של התכן המקושר.",
"ui.report_a_bug": "דיווח על באג",
"ui.report_characters_left_one": "תווים שנותרו: {count}",
"ui.report_characters_left_other": "תווים שנותרו: {count}",
"ui.reset": "איפוס",
"ui.reset_all": "איפוס הכול",
"ui.reset_the_settings_on_this_page": "איפוס ההגדרות בדף זה",
-2
View File
@@ -957,8 +957,6 @@
"cmd.inspect_accessibility": "Analiza dostupnosti",
"cmd.inspect_center_of_mass": "Središte mase",
"cmd.inspect_clearance": "Provjera zazora",
"cmd.inspect_colored_components": "Omogući boje komponenti",
"cmd.inspect_colored_components_disable": "Onemogući boje komponenti",
"cmd.inspect_compare": "Usporedi tijela",
"cmd.inspect_compare_picture": "Usporedi sliku",
"cmd.inspect_continuity": "Analiza kontinuiteta",
-2
View File
@@ -957,8 +957,6 @@
"cmd.inspect_accessibility": "Hozzáférhetőség-elemzés",
"cmd.inspect_center_of_mass": "Tömegközéppont",
"cmd.inspect_clearance": "Hézag",
"cmd.inspect_colored_components": "Komponensszínek bekapcsolása",
"cmd.inspect_colored_components_disable": "Komponensszínek kikapcsolása",
"cmd.inspect_compare": "Testek összehasonlítása",
"cmd.inspect_compare_picture": "Kép összehasonlítása",
"cmd.inspect_continuity": "Folytonosságelemzés",
-2
View File
@@ -957,8 +957,6 @@
"cmd.inspect_accessibility": "Analisis Aksesibilitas",
"cmd.inspect_center_of_mass": "Pusat Massa",
"cmd.inspect_clearance": "Kelonggaran",
"cmd.inspect_colored_components": "Aktifkan Warna Komponen",
"cmd.inspect_colored_components_disable": "Nonaktifkan Warna Komponen",
"cmd.inspect_compare": "Bandingkan Bodi",
"cmd.inspect_compare_picture": "Bandingkan dengan Gambar",
"cmd.inspect_continuity": "Analisis Kontinuitas",
-2
View File
@@ -957,8 +957,6 @@
"cmd.inspect_accessibility": "Analisi di accessibilità",
"cmd.inspect_center_of_mass": "Centro di massa",
"cmd.inspect_clearance": "Distanza libera",
"cmd.inspect_colored_components": "Attiva colori dei componenti",
"cmd.inspect_colored_components_disable": "Disattiva colori dei componenti",
"cmd.inspect_compare": "Confronta corpi",
"cmd.inspect_compare_picture": "Confronta immagine",
"cmd.inspect_continuity": "Analisi di continuità",
+5 -2
View File
@@ -964,8 +964,7 @@
"cmd.inspect_analysis": "表示解析",
"cmd.inspect_center_of_mass": "重心",
"cmd.inspect_clearance": "クリアランス",
"cmd.inspect_colored_components": "コンポーネントの色分けを有効化",
"cmd.inspect_colored_components_disable": "コンポーネントの色分けを無効化",
"cmd.inspect_colored_components": "コンポーネントの色分け",
"cmd.inspect_compare": "ボディを比較",
"cmd.inspect_compare_picture": "参考画像と比較",
"cmd.inspect_continuity": "連続性解析",
@@ -2040,6 +2039,8 @@
"nav.blender.help": "中ドラッグで回転、Shift+中ドラッグでパン、Ctrl+中ドラッグでズーム。右ドラッグではカメラは動きません。",
"nav.creo.help": "マウスの中ボタンドラッグで回転、Shift+中ボタンで平行移動、Ctrl+中ボタンの上下ドラッグでズーム(下方向で拡大)。ホイールの回転でもズーム",
"nav.creo.label": "Creo",
"nav.drag_keeps_mode.help": "中ボタンまたは右ボタンのドラッグは、途中でキーを押したり離したりしても、ボタンを離すまで開始時の回転・パン・ズームを続けます。オフにすると、ドラッグ中の各時点で押しているキーによって決まります。",
"nav.drag_keeps_mode.label": "ドラッグは開始時のナビゲーションを維持",
"nav.free_orbit.help": "マウスと3Dマウスで、極点を越えて回転し、ロールを保ちます。\nShiftによる軸スナップは無効になります。",
"nav.free_orbit.label": "自由回転",
"nav.free_orbit.snap_help": "マウスと3Dマウスで両極を越えて回転でき、ロールも保持されます。オービットのドラッグ中にShiftを押すと、視線方向とロールがスナップします。",
@@ -5364,6 +5365,8 @@
"ui.repeat_nothing": "まだ繰り返すものがありません。先にツールかダイアログを開始してください。",
"ui.replace_with_link_done": "コピーはリンクされたデザインの配置になりました。",
"ui.report_a_bug": "不具合を報告",
"ui.report_characters_left_one": "残り {count} 文字",
"ui.report_characters_left_other": "残り {count} 文字",
"ui.reset": "リセット",
"ui.reset_all": "すべてリセット",
"ui.reset_the_settings_on_this_page": "このページの設定をリセット",
+1 -2
View File
@@ -963,8 +963,7 @@
"cmd.inspect_analysis": "ანალიზის ნახვა",
"cmd.inspect_center_of_mass": "მასის ცენტრი",
"cmd.inspect_clearance": "ღრეჩო",
"cmd.inspect_colored_components": "კომპონენტების ფერების ჩართვა",
"cmd.inspect_colored_components_disable": "კომპონენტების ფერების გამორთვა",
"cmd.inspect_colored_components": "კომპონენტების ფერები",
"cmd.inspect_compare": "სხეულების შედარება",
"cmd.inspect_compare_picture": "სურათთან შედარება",
"cmd.inspect_continuity": "უწყვეტობის ანალიზი",
+2
View File
@@ -1465,6 +1465,8 @@
"nav.blender.help": "가운데 드래그는 회전, Shift+가운데 드래그는 이동, Ctrl+가운데 드래그는 확대/축소입니다. 오른쪽 드래그는 카메라에 영향을 주지 않습니다",
"nav.creo.help": "가운데 버튼을 끌면 회전, Shift+가운데 버튼은 이동, Ctrl+가운데 버튼을 위아래로 끌면 확대/축소합니다(아래로 끌면 확대). 휠을 굴려도 확대/축소합니다",
"nav.creo.label": "Creo",
"nav.drag_keeps_mode.help": "가운데 또는 오른쪽 버튼 드래그는 도중에 키를 누르거나 떼어도 버튼을 놓을 때까지 시작할 때의 회전, 이동 또는 확대/축소를 계속합니다. 끄면 드래그하는 동안 누르고 있는 키가 매 순간 결정합니다.",
"nav.drag_keeps_mode.label": "드래그는 시작할 때의 탐색 방식을 유지",
"nav.free_orbit.help": "마우스와 3D 마우스로 롤을 유지하며 양 극점을 넘어 회전합니다. Shift 축 스냅은 비활성화됩니다.",
"nav.free_orbit.label": "자유 회전",
"nav.free_orbit.snap_help": "마우스와 3D 마우스로 양쪽 극점을 지나 회전할 수 있으며 롤 각도가 유지됩니다. 궤도 회전 드래그 중 Shift를 누르면 보기 방향과 롤 각도가 스냅됩니다.",
-2
View File
@@ -957,8 +957,6 @@
"cmd.inspect_accessibility": "Analisis Kebolehcapaian",
"cmd.inspect_center_of_mass": "Pusat Jisim",
"cmd.inspect_clearance": "Kelegaan",
"cmd.inspect_colored_components": "Aktifkan Warna Komponen",
"cmd.inspect_colored_components_disable": "Nyahaktifkan Warna Komponen",
"cmd.inspect_compare": "Bandingkan Jasad",
"cmd.inspect_compare_picture": "Banding dengan Imej",
"cmd.inspect_continuity": "Analisis Keselanjaran",
-2
View File
@@ -957,8 +957,6 @@
"cmd.inspect_accessibility": "Tilgjengelighetsanalyse",
"cmd.inspect_center_of_mass": "Massesenter",
"cmd.inspect_clearance": "Klaring",
"cmd.inspect_colored_components": "Aktiver komponentfarger",
"cmd.inspect_colored_components_disable": "Deaktiver komponentfarger",
"cmd.inspect_compare": "Sammenlign kropper",
"cmd.inspect_compare_picture": "Sammenlign bilde",
"cmd.inspect_continuity": "Kontinuitetsanalyse",
-2
View File
@@ -957,8 +957,6 @@
"cmd.inspect_accessibility": "Toegankelijkheidsanalyse",
"cmd.inspect_center_of_mass": "Zwaartepunt",
"cmd.inspect_clearance": "Speling",
"cmd.inspect_colored_components": "Componentkleuren inschakelen",
"cmd.inspect_colored_components_disable": "Componentkleuren uitschakelen",
"cmd.inspect_compare": "Lichamen vergelijken",
"cmd.inspect_compare_picture": "Afbeelding vergelijken",
"cmd.inspect_continuity": "Continuïteitsanalyse",
@@ -1,7 +0,0 @@
{
"en": {
"document.newer_file": "Saved by a newer SolidCraft (format {format}, this version reads up to {reads}). If something's missing, update.",
"document.newer_file_change": "Saved by a newer SolidCraft (format {format} with `{change}`, which this version doesn't know). If something's missing, update.",
"document.newer_file_not_read": "Not read: {what}. Saving keeps it."
}
}
-2
View File
@@ -934,8 +934,6 @@
"cmd.inspect_accessibility": "Analiza dostępności",
"cmd.inspect_center_of_mass": "Środek masy",
"cmd.inspect_clearance": "Prześwit",
"cmd.inspect_colored_components": "Włącz kolory komponentów",
"cmd.inspect_colored_components_disable": "Wyłącz kolory komponentów",
"cmd.inspect_compare": "Porównaj bryły",
"cmd.inspect_continuity": "Analiza ciągłości",
"cmd.inspect_curvature_comb": "Analiza grzebienia krzywizny",
-2
View File
@@ -957,8 +957,6 @@
"cmd.inspect_accessibility": "Analiza accesibilității",
"cmd.inspect_center_of_mass": "Centru de masă",
"cmd.inspect_clearance": "Joc",
"cmd.inspect_colored_components": "Activează culorile componentelor",
"cmd.inspect_colored_components_disable": "Dezactivează culorile componentelor",
"cmd.inspect_compare": "Compară corpurile",
"cmd.inspect_compare_picture": "Compară imaginea",
"cmd.inspect_continuity": "Analiza continuității",
-2
View File
@@ -956,8 +956,6 @@
"cmd.inspect_accessibility": "Анализ доступности",
"cmd.inspect_center_of_mass": "Центр масс",
"cmd.inspect_clearance": "Зазор",
"cmd.inspect_colored_components": "Включить цвета компонентов",
"cmd.inspect_colored_components_disable": "Отключить цвета компонентов",
"cmd.inspect_compare": "Сравнить тела",
"cmd.inspect_compare_picture": "Сравнить с изображением",
"cmd.inspect_continuity": "Анализ непрерывности",
-2
View File
@@ -957,8 +957,6 @@
"cmd.inspect_accessibility": "Анализа доступности",
"cmd.inspect_center_of_mass": "Центар масе",
"cmd.inspect_clearance": "Провера зазора",
"cmd.inspect_colored_components": "Омогући боје компоненти",
"cmd.inspect_colored_components_disable": "Онемогући боје компоненти",
"cmd.inspect_compare": "Упореди тела",
"cmd.inspect_compare_picture": "Упореди слику",
"cmd.inspect_continuity": "Анализа континуитета",
-2
View File
@@ -957,8 +957,6 @@
"cmd.inspect_accessibility": "Tillgänglighetsanalys",
"cmd.inspect_center_of_mass": "Masscentrum",
"cmd.inspect_clearance": "Frigång",
"cmd.inspect_colored_components": "Aktivera komponentfärger",
"cmd.inspect_colored_components_disable": "Inaktivera komponentfärger",
"cmd.inspect_compare": "Jämför kroppar",
"cmd.inspect_compare_picture": "Jämför bild",
"cmd.inspect_continuity": "Kontinuitetsanalys",
-2
View File
@@ -957,8 +957,6 @@
"cmd.inspect_accessibility": "Erişilebilirlik analizi",
"cmd.inspect_center_of_mass": "Kütle merkezi",
"cmd.inspect_clearance": "Açıklık",
"cmd.inspect_colored_components": "Bileşen Renklerini Etkinleştir",
"cmd.inspect_colored_components_disable": "Bileşen Renklerini Devre Dışı Bırak",
"cmd.inspect_compare": "Gövdeleri karşılaştır",
"cmd.inspect_compare_picture": "Görüntüyle karşılaştır",
"cmd.inspect_continuity": "Süreklilik kontrolü",
-2
View File
@@ -963,8 +963,6 @@
"cmd.inspect_analysis": "Перегляд аналізу",
"cmd.inspect_center_of_mass": "Центр мас",
"cmd.inspect_clearance": "Зазор",
"cmd.inspect_colored_components": "Увімкнути кольори компонентів",
"cmd.inspect_colored_components_disable": "Вимкнути кольори компонентів",
"cmd.inspect_compare": "Порівняти тіла",
"cmd.inspect_compare_picture": "Порівняти зображення",
"cmd.inspect_continuity": "Аналіз неперервності",
+5 -2
View File
@@ -964,8 +964,7 @@
"cmd.inspect_analysis": "Phân tích khung nhìn",
"cmd.inspect_center_of_mass": "Trọng tâm",
"cmd.inspect_clearance": "Khoảng cách an toàn",
"cmd.inspect_colored_components": "Bật màu cụm chi tiết",
"cmd.inspect_colored_components_disable": "Tắt màu cụm chi tiết",
"cmd.inspect_colored_components": "Màu cụm chi tiết",
"cmd.inspect_compare": "So sánh các thân khối",
"cmd.inspect_compare_picture": "So sánh với ảnh tham chiếu",
"cmd.inspect_continuity": "Phân tích độ liên tục mặt",
@@ -2040,6 +2039,8 @@
"nav.blender.help": "Kéo chuột giữa để xoay, Shift+chuột giữa để trượt, Ctrl+chuột giữa để thu phóng; kéo chuột phải giữ nguyên camera",
"nav.creo.help": "Kéo nút giữa để xoay, Shift+nút giữa để di chuyển, Ctrl+kéo nút giữa theo chiều dọc để thu phóng (kéo xuống để phóng to); lăn con lăn để thu phóng",
"nav.creo.label": "Creo",
"nav.drag_keeps_mode.help": "Kéo bằng nút giữa hoặc nút phải sẽ tiếp tục xoay, di chuyển hoặc thu phóng như lúc bắt đầu cho đến khi nhả nút, dù trong lúc đó có nhấn hay nhả phím nào. Tắt: các phím đang giữ quyết định ở mọi thời điểm của thao tác kéo.",
"nav.drag_keeps_mode.label": "Thao tác kéo giữ nguyên kiểu điều hướng lúc bắt đầu",
"nav.free_orbit.help": "Chuột và chuột 3D xoay tự do qua cả hai cực và giữ nguyên độ lăn nghiêng (Roll). Bắt dính trục bằng phím Shift sẽ bị tắt.",
"nav.free_orbit.label": "Xoay góc nhìn tự do",
"nav.free_orbit.snap_help": "Chuột và chuột 3D xoay qua cả hai cực và giữ nguyên góc nghiêng cuộn. Giữ Shift trong khi kéo xoay quỹ đạo để bắt dính hướng nhìn và góc nghiêng cuộn.",
@@ -5364,6 +5365,8 @@
"ui.repeat_nothing": "Chưa có gì để lặp lại: hãy bắt đầu một công cụ hoặc hộp thoại trước.",
"ui.replace_with_link_done": "Các bản sao hiện đã trở thành các vị trí đặt của thiết kế liên kết.",
"ui.report_a_bug": "Báo cáo lỗi",
"ui.report_characters_left_one": "Còn {count} ký tự",
"ui.report_characters_left_other": "Còn {count} ký tự",
"ui.reset": "Đặt lại",
"ui.reset_all": "Đặt lại tất cả",
"ui.reset_the_settings_on_this_page": "Đặt lại các cài đặt trên trang này",
-2
View File
@@ -957,8 +957,6 @@
"cmd.inspect_accessibility": "可達性分析",
"cmd.inspect_center_of_mass": "質心",
"cmd.inspect_clearance": "間隙",
"cmd.inspect_colored_components": "啟用元件顏色",
"cmd.inspect_colored_components_disable": "停用元件顏色",
"cmd.inspect_compare": "比較實體",
"cmd.inspect_compare_picture": "與圖片比較",
"cmd.inspect_continuity": "連續性分析",
+5 -2
View File
@@ -964,8 +964,7 @@
"cmd.inspect_analysis": "视图分析",
"cmd.inspect_center_of_mass": "质心",
"cmd.inspect_clearance": "间隙",
"cmd.inspect_colored_components": "启用组件颜色",
"cmd.inspect_colored_components_disable": "禁用组件颜色",
"cmd.inspect_colored_components": "组件颜色",
"cmd.inspect_compare": "比较实体",
"cmd.inspect_compare_picture": "比较参考图",
"cmd.inspect_continuity": "连续性分析",
@@ -2040,6 +2039,8 @@
"nav.blender.help": "中键拖动旋转,Shift+中键拖动平移,Ctrl+中键拖动缩放;右键拖动不会移动相机",
"nav.creo.help": "中键拖动旋转,Shift+中键平移,Ctrl+中键上下拖动缩放(向下放大);滚动滚轮也可缩放",
"nav.creo.label": "Creo",
"nav.drag_keeps_mode.help": "中键或右键拖动会保持开始时的旋转、平移或缩放,直到松开按键,期间按下或松开任何键都不会改变。关闭后:拖动的每一刻都由当时按住的键决定。",
"nav.drag_keeps_mode.label": "拖动保持开始时的导航方式",
"nav.free_orbit.help": "鼠标和3D鼠标可保持滚转并越过两个极点旋转。Shift轴吸附将被禁用。",
"nav.free_orbit.label": "自由旋转",
"nav.free_orbit.snap_help": "鼠标和3D鼠标可越过两极旋转,并保留画面旋转角度。在拖动环绕时按住Shift,可吸附视图方向和画面旋转角度。",
@@ -5364,6 +5365,8 @@
"ui.repeat_nothing": "还没有可重复的操作:请先启动一个工具或对话框。",
"ui.replace_with_link_done": "这些副本现在是链接设计的放置。",
"ui.report_a_bug": "报告缺陷",
"ui.report_characters_left_one": "剩余 {count} 个字符",
"ui.report_characters_left_other": "剩余 {count} 个字符",
"ui.reset": "重置",
"ui.reset_all": "全部重置",
"ui.reset_the_settings_on_this_page": "重置此页面上的设置",
+7
View File
@@ -366,6 +366,13 @@ fn fs_ao_depth(i: TOut, @builtin(front_facing) front: bool) -> AoDepth {
return opaque_depth(i, front);
}
// The virtual ground preserves the viewport background; only contact occlusion is blended.
@fragment
fn fs_ground_ao(i: TOut) -> @location(0) vec4<f32> {
if (clipped(i.wp)) { discard; }
return vec4<f32>(0.0, 0.0, 0.0, 1.0 - occlusion(i));
}
/// The AO prepass's depth of an opaque surface's fragment.
fn opaque_depth(i: TOut, front: bool) -> AoDepth {
if (clipped(i.wp) || length(i.n) < 0.5) { discard; }
+40
View File
@@ -2,6 +2,46 @@
use crate::{DocError, Document, Mat, ModelBody, ModelState, Result};
use std::collections::BTreeSet;
/// A profile moved into the component receiving its own body is read through the recorded
/// transfer frame. Reading its live placement would move the source geometry when that
/// component moves, then move the transferred body a second time. All outputs of a constructor
/// must reach the sketch's component through the same frame; independent cross-component
/// references retain their ordinary live placement.
pub(crate) fn profile_frame(doc: &Document, feature: &crate::Feature, sketch: u64) -> Option<Mat> {
if !matches!(
feature.kind,
crate::FeatureKind::Extrude { operation: crate::Operation::NewBody, .. }
| crate::FeatureKind::Revolve { operation: crate::Operation::NewBody, .. }
) || feature.body_names.is_empty()
{
return None;
}
let owner = doc.feature(sketch)?.component;
if owner == feature.component {
return None;
}
let mut frames: std::collections::BTreeMap<&str, (u64, Mat)> =
feature.body_names.iter().map(|name| (name.as_str(), (feature.component, crate::IDENTITY))).collect();
let start = doc.feature_index(feature.id)?.saturating_add(1);
for step in doc.features.iter().take(doc.marker.unwrap_or(usize::MAX)).skip(start).filter(|step| !step.suppressed) {
let crate::FeatureKind::BodyTransfer { bodies, from, to, transform } = &step.kind else { continue };
for name in bodies {
if let Some((component, frame)) = frames.get_mut(name.as_ref()) {
if *component != *from || !valid_transfer_matrix(transform) {
return None;
}
*component = *to;
*frame = crate::mat_mul(transform, frame);
}
}
if frames.values().all(|(component, _)| *component == owner) {
let (_, first) = frames.values().next()?;
return frames.values().all(|(_, frame)| frame == first).then(|| crate::mat_inverse(first)).flatten();
}
}
None
}
pub fn valid_transfer_matrix(m: &Mat) -> bool {
crate::is_placement(m)
}
+1 -1
View File
@@ -185,7 +185,7 @@ fn shell_planar_format13_keeps_v12_documents_and_refuses_future() {
}
assert_eq!(legacy, current, "the migration preserves every prior field");
legacy["format"] = json!(crate::format::format_string(crate::format::FORMAT + 1));
assert!(crate::format::read(&legacy.to_string()).unwrap().newer_file.is_some());
assert!(crate::format::read(&legacy.to_string()).is_err());
}
#[test]
+1 -25
View File
@@ -659,12 +659,6 @@ impl AlignFrame {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum FeatureKind {
/// A feature a newer SolidCraft saved that this build can't read ([`crate::newer_file`]): it
/// holds its place on the timeline, reports an error when evaluated, and goes back into the
/// file as it came.
NewerFileFeature {
raw: crate::newer_file::RawFeature,
},
/// A recorded timeline ownership boundary. Geometry is converted from its
/// current local frame into the destination component frame exactly once.
BodyTransfer {
@@ -2606,7 +2600,6 @@ impl FeatureKind {
/// Short type name (timeline tooltip, `inspect`).
pub fn type_name(&self) -> &'static str {
match self {
FeatureKind::NewerFileFeature { .. } => "NewerFileFeature",
FeatureKind::BodyTransfer { .. } => "BodyTransferFeature",
FeatureKind::Sketch { .. } => "Sketch",
FeatureKind::Extrude { .. } => "ExtrudeFeature",
@@ -2692,7 +2685,6 @@ impl FeatureKind {
/// Design-file variant key (e.g. `"extrude"`, `"box"`, `"sheet_flange"`).
pub fn kind_key(&self) -> &'static str {
match self {
FeatureKind::NewerFileFeature { .. } => "newer_file_feature",
FeatureKind::BodyTransfer { .. } => "body_transfer",
FeatureKind::Sketch { .. } => "sketch",
FeatureKind::Extrude { .. } => "extrude",
@@ -2855,7 +2847,6 @@ impl FeatureKind {
/// Default name prefix (`Extrude` → `Extrude1`).
pub fn base_name(&self) -> &'static str {
match self {
FeatureKind::NewerFileFeature { .. } => "Unknown",
FeatureKind::BodyTransfer { .. } => "BodyTransfer",
FeatureKind::Sketch { .. } => "Sketch",
FeatureKind::Extrude { .. } => "Extrude",
@@ -3128,10 +3119,6 @@ pub fn is_counted_body_name(name: &str) -> bool {
/// The document.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Document {
/// Set when a newer SolidCraft saved the file this came from: what to tell the user, and what
/// this build couldn't read and keeps for the next save ([`crate::newer_file`]).
#[serde(skip)]
pub newer_file: Option<std::sync::Arc<crate::newer_file::NewerFile>>,
/// Named image intent and immutable embedded HDR sources. Active jobs and device state are not saved.
#[serde(default, skip_serializing_if = "crate::render_settings::RenderSettings::is_empty")]
pub render: std::sync::Arc<crate::render_settings::RenderSettings>,
@@ -3493,7 +3480,6 @@ impl Document {
pub fn new(name: &str) -> Self {
Document {
newer_file: None,
render: Default::default(),
format: default_format(),
format_pending: crate::format::pending_names(),
@@ -3915,12 +3901,7 @@ impl Document {
/// Read a design file's JSON (any format this build knows; older ones are upgraded).
pub fn from_json(s: &str) -> Result<Document> {
// A newer build's resources may not read here; the section stays out then (`newer_file`).
if let Err(why) = crate::render_resources::check_json_budget(s)
&& !crate::format::says_newer(s)
{
return Err(why);
}
crate::render_resources::check_json_budget(s)?;
let mut d = crate::format::read(s)?;
if d.features.len() > MAX_FEATURES || d.params.len() > MAX_PARAMS {
return Err(DocError::Invalid("document too large".into()));
@@ -3932,11 +3913,6 @@ impl Document {
Ok(d)
}
pub fn to_json(&self) -> String {
if self.newer_file.is_some() {
let mut value = serde_json::to_value(self).unwrap_or_default();
crate::newer_file::restore(self, &mut value);
return serde_json::to_string_pretty(&value).unwrap_or_default();
}
serde_json::to_string_pretty(self).unwrap_or_default()
}
+3 -125
View File
@@ -84,65 +84,6 @@ pub struct Drawing {
pub language: String,
/// In order, each behind its own `Arc`, so an undo step shares every sheet it didn't change.
pub sheets: Vec<Arc<Sheet>>,
/// Set when a newer SolidCraft saved the file this came from.
#[serde(skip)]
pub newer_file: Option<Arc<NewerDrawing>>,
}
/// The fields a drawing's record has: anything else in a file is a newer build's.
const RECORD_FIELDS: [&str; 7] = ["format", "name", "sources", "properties", "units", "language", "sheets"];
/// Move the elements of the list `key` that don't read as `T` out of a newer build's drawing.
fn take_unread<T: serde::de::DeserializeOwned>(top: &mut serde_json::Map<String, Value>, key: &str, one: &str, file: &mut NewerDrawing) {
let Some(Value::Array(list)) = top.get_mut(key) else { return };
let mut readable = Vec::with_capacity(list.len());
for element in std::mem::take(list) {
match DRAWING.check_numbers(&element, "properties").and_then(|()| serde_json::from_value::<T>(element.clone()).map_err(damaged)) {
Ok(_) => readable.push(element),
Err(why) => {
let name = element.get("name").or(element.get("path")).and_then(Value::as_str).unwrap_or("unnamed");
file.not_understood.push(format!("{one} `{name}` ({why})"));
file.elements.push((key.to_string(), element));
}
}
}
*list = readable;
}
/// What a drawing file says and holds that this build doesn't read ([`Drawing::from_newer`]).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct NewerDrawing {
/// The number the file says.
pub format: u32,
/// The number this build reads up to.
pub reads_up_to: u32,
/// The changes the file names that this build doesn't have.
pub unknown_changes: Vec<String>,
/// What this build couldn't read, by name.
pub not_understood: Vec<String>,
fields: Vec<(String, Value)>,
elements: Vec<(String, Value)>,
}
impl NewerDrawing {
/// What to tell the user.
pub fn notice(&self) -> String {
format!(
"Saved by a newer SolidCraft (format {}, this version reads up to {}). If something's missing, update.",
self.format, self.reads_up_to
)
}
fn restore(&self, value: &mut Value) {
for (key, field) in &self.fields {
value[key] = field.clone();
}
for (key, element) in &self.elements {
if let Some(list) = value.get_mut(key).and_then(Value::as_array_mut) {
list.push(element.clone());
}
}
}
}
/// A design the drawing draws.
@@ -234,7 +175,6 @@ impl Drawing {
units: Units::default(),
language: language.into(),
sheets: vec![Arc::new(first)],
newer_file: None,
};
drawing.validate()?;
Ok(drawing)
@@ -248,65 +188,11 @@ impl Drawing {
}
let mut v: Value = serde_json::from_str(s).map_err(|e| DocError::Invalid(format!("not a SolidCraft drawing: {e}")))?;
let (version, applied) = DRAWING.take_version(&mut v)?;
if let Some(newer) = DRAWING.newer(version, applied.as_deref()) {
return Self::from_newer(v, version, applied, newer);
}
let d = Self::decode(v, version, applied.as_deref())?;
Ok(Drawing { format: DRAWING.format_string(DRAWING.current()), format_pending: DRAWING.pending_names(), ..d })
}
fn decode(mut v: Value, version: u32, applied: Option<&[String]>) -> Result<Drawing> {
DRAWING.check_numbers(&v, "properties")?;
DRAWING.upgrade(&mut v, version, applied)?;
DRAWING.upgrade(&mut v, version, applied.as_deref())?;
let d: Drawing = serde_json::from_value(v).map_err(damaged)?;
d.validate().map_err(damaged)?;
Ok(d)
}
/// A drawing a newer build saved: it opens with what this build can read of it. Everything in a
/// drawing refuses a field it doesn't know, so what it can't read shows as a top-level field,
/// a source or a sheet that doesn't read, which stays out and is named in
/// [`NewerDrawing::not_understood`]; [`Drawing::to_json`] writes it all back, with the file's
/// format. It takes at least one sheet this build can read to open.
fn from_newer(mut v: Value, version: u32, applied: Option<Vec<String>>, newer: crate::format::Newer) -> Result<Drawing> {
let list = applied.as_deref();
let can_read = |v: Value| Self::decode(v, version, list).map_err(|e| e.to_string());
let mut file = NewerDrawing {
format: newer.format,
reads_up_to: newer.reads_up_to,
unknown_changes: newer.unknown_changes.clone(),
..NewerDrawing::default()
};
let mut read = can_read(v.clone());
if read.is_err() {
let top = v.as_object_mut().ok_or_else(|| damaged("expected a JSON object"))?;
let keys: Vec<String> = top.keys().cloned().collect();
for key in keys {
if !RECORD_FIELDS.contains(&key.as_str())
&& let Some(value) = top.remove(&key)
{
file.not_understood.push(format!("field `{key}`"));
file.fields.push((key, value));
}
}
take_unread::<Sheet>(top, "sheets", "sheet", &mut file);
take_unread::<Source>(top, "sources", "source", &mut file);
read = can_read(v);
}
let d = read.map_err(|why| {
DocError::Invalid(format!(
"this drawing was saved by a newer SolidCraft (format {}, this version reads up to {}) and this version can't read it: {why}. Update SolidCraft to open it",
newer.format, newer.reads_up_to
))
})?;
let (format, format_pending) = if newer.format > newer.reads_up_to {
(DRAWING.format_string(newer.format), applied.unwrap_or_default())
} else {
let mut pending = DRAWING.pending_names();
pending.extend(newer.unknown_changes.iter().cloned());
(DRAWING.format_string(DRAWING.current()), pending)
};
Ok(Drawing { format, format_pending, newer_file: Some(Arc::new(file)), ..d })
Ok(Drawing { format: DRAWING.format_string(DRAWING.current()), format_pending: DRAWING.pending_names(), ..d })
}
/// The drawing's JSON: the same bytes for the same drawing. JSON longer than a read takes
@@ -314,15 +200,7 @@ impl Drawing {
/// properties are kept as they came, and indenting them can make a drawing that read in
/// under the limit save over it.
pub fn to_json(&self) -> Result<String> {
let unsaved = |e: serde_json::Error| DocError::Invalid(format!("drawing not saved: {e}"));
let text = match &self.newer_file {
None => serde_json::to_string_pretty(self).map_err(unsaved)?,
Some(file) => {
let mut value = serde_json::to_value(self).map_err(unsaved)?;
file.restore(&mut value);
serde_json::to_string_pretty(&value).map_err(unsaved)?
}
};
let text = serde_json::to_string_pretty(self).map_err(|e| DocError::Invalid(format!("drawing not saved: {e}")))?;
require(text.len() <= MAX_DRAWING_BYTES, || format!("drawing too large to save (at most {} MiB)", MAX_DRAWING_BYTES >> 20))?;
Ok(text)
}
@@ -8,7 +8,7 @@ whole format bump: never edit the `DRAWING` family in `format.rs`, another chang
- Name it after the change, lowercase with dashes: `sheet-views.rs`.
- Start it with a `//!` note: what is saved, what an older drawing gets without it, and what
the last release does with a drawing that has it (it warns about the newer format number, opens what it can read and keeps the rest).
the last release does with a drawing that has it (it refuses the newer format number).
- If older drawings must be rewritten as they open, add the step to the same file:
```rust
+13 -42
View File
@@ -325,28 +325,28 @@ fn damaged_files_are_refused_and_never_patched() {
let why = refused(&d, edit);
assert!(why.starts_with("damaged drawing: ") && why.contains("unknown field"), "{i}: {why}");
}
// A newer build's file isn't held to this build's rules: a field it doesn't know, or a sheet with
// a number out of range, stays out. With no sheet left to show, it says why.
// A newer build's file is refused as one before anything in it is read: a field this build
// doesn't know, or a number out of its range.
let (current, newer) = (DRAWING.current(), DRAWING.current() + 1);
let why = refused(&d, |v| {
v["format"] = json!(DRAWING.format_string(newer));
v["views"] = json!([]);
v["sheets"][0]["dpi"] = json!(1e10);
});
assert_eq!(why, "read");
let why = refused(&d, |v| {
v["format"] = json!(DRAWING.format_string(newer));
v["sheets"] = json!([{"id": "future", "a_future_field": true}]);
});
assert!(
why.starts_with(&format!(
"this drawing was saved by a newer SolidCraft (format {newer}, this version reads up to {current}) and this version can't read it: "
)),
"{why}"
assert_eq!(
why,
format!(
"this drawing was saved by a newer SolidCraft (file format {newer}; this version reads formats up to {current}): update SolidCraft to open it"
)
);
// A list of pending changes means nothing beside a format made of none; beside the pending
// number, a change this build doesn't know is a newer build's.
let why = refused(&d, |v| v["format_pending"] = json!(["a-later-change"]));
assert_eq!(why, "read");
if DRAWING.pending_names().is_empty() {
assert_eq!(why, "read");
} else {
assert!(why.contains("newer SolidCraft"), "{why}");
}
// A design is never read as a drawing, nor a drawing as a design.
let design = crate::Document::new("Plate").to_json();
let design_format = crate::format::format_string(crate::format::FORMAT);
@@ -551,32 +551,3 @@ fn mutate(v: &mut Value, pick: &mut usize, lie: &Value, how: u64) -> bool {
_ => false,
}
}
/// A drawing a newer build saved opens, with what it can read, and saving keeps the rest.
#[test]
fn a_newer_builds_drawing_opens_and_keeps_what_it_cant_read() {
let d = plate();
let mut file: Value = file(&d);
assert!(file.as_object().unwrap().keys().all(|key| RECORD_FIELDS.contains(&key.as_str())), "a new field goes in RECORD_FIELDS");
let newer = DRAWING.format_string(DRAWING.current() + 1);
file["format"] = json!(newer);
// With nothing it can't read: the same drawing.
let opened = Drawing::from_json(&file.to_string()).unwrap();
assert_eq!(opened.sheets, d.sheets);
assert!(opened.newer_file.as_ref().unwrap().notice().starts_with("Saved by a newer SolidCraft"));
assert_eq!(serde_json::from_str::<Value>(&opened.to_json().unwrap()).unwrap()["format"], json!(newer));
// A field and a second sheet it can't read stay out, named, and go back.
file["views"] = json!([{"id": "a"}]);
let mut future_sheet = file["sheets"][0].clone();
future_sheet["name"] = json!("Future");
future_sheet["future_layers"] = json!([1]);
file["sheets"].as_array_mut().unwrap().push(future_sheet);
let opened = Drawing::from_json(&file.to_string()).unwrap();
assert_eq!(opened.sheets, d.sheets);
let said = &opened.newer_file.as_ref().unwrap().not_understood;
assert!(said.iter().any(|what| what.contains("views")) && said.iter().any(|what| what.contains("Future")), "{said:?}");
let again: Value = serde_json::from_str(&opened.to_json().unwrap()).unwrap();
assert_eq!(again["views"], file["views"]);
assert_eq!(again["sheets"].as_array().unwrap().len(), d.sheets.len() + 1);
assert_eq!(again["format"], json!(newer));
}
+25 -8
View File
@@ -1650,6 +1650,13 @@ fn placement_digest(doc: &Document, f: &Feature, st: Option<&ModelState>) -> u64
for (sketch, _) in f.kind.profile_sels() {
if doc.feature(sketch).is_some_and(|s| s.component != f.component) {
reads.sketch(doc, sketch);
// A later transfer can preserve this profile's historical frame. Changes to that
// boundary (including suppression and rollback) must invalidate the constructor.
if let Some(relative) = crate::body_transfer::profile_frame(doc, f, sketch) {
"transferred profile".hash(&mut h);
sketch.hash(&mut h);
hash_placement(&relative, &mut h);
}
}
}
let path = match &f.kind {
@@ -5051,10 +5058,10 @@ fn feature_tools_with_constructor(
let unnamed = |bodies: Vec<Body>| -> Vec<Traced> { bodies.into_iter().map(Traced::unnamed).collect() };
match &f.kind {
FeatureKind::Extrude { sketch, profiles, extent, operation, targets, thin } => {
let ss = sketch_in_feature_frame(
let ss = constructor_profile_in_frame(
doc,
st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?,
f.component,
f,
)?;
let regions = match thin {
Some(thin) => thin_regions(vals, &ss, thin)?,
@@ -5278,10 +5285,10 @@ fn feature_tools_with_constructor(
Ok(tools)
}
FeatureKind::Revolve { sketch, profiles, axis, angle, angle2, symmetric, convention, to, .. } => {
let ss = sketch_in_feature_frame(
let ss = constructor_profile_in_frame(
doc,
st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?,
f.component,
f,
)?;
let regions = solidcraft_sketch::merge_regions(&select_profiles(&ss, profiles)?);
let (o, d) =
@@ -5686,16 +5693,27 @@ fn sketch_in_feature_frame(doc: &Document, sketch: &SolvedSketch, consumer: u64)
profile_in_frame(doc, sketch, consumer)
}
fn constructor_profile_in_frame(doc: &Document, sketch: &SolvedSketch, feature: &Feature) -> Result<SolvedSketch> {
match crate::body_transfer::profile_frame(doc, feature, sketch.feature) {
Some(relative) => profile_with_transform(sketch, &relative),
None => sketch_in_feature_frame(doc, sketch, feature.component),
}
}
fn profile_in_frame(doc: &Document, sketch: &SolvedSketch, consumer: u64) -> Result<SolvedSketch> {
let owner = doc.feature(sketch.feature).map(|feature| feature.component).unwrap_or(0);
let inverse =
crate::mat_inverse(&doc.component_transform(consumer)).ok_or_else(|| DocError::Invalid("profile consumer frame is invalid".into()))?;
let relative = crate::mat_mul(&inverse, &doc.component_transform(owner));
profile_with_transform(sketch, &relative)
}
fn profile_with_transform(sketch: &SolvedSketch, relative: &Mat) -> Result<SolvedSketch> {
let mut mapped = sketch.clone();
mapped.plane = Plane::new(
crate::apply_point(&relative, sketch.plane.origin),
crate::apply_vector(&relative, sketch.plane.x),
crate::apply_vector(&relative, sketch.plane.y),
crate::apply_point(relative, sketch.plane.origin),
crate::apply_vector(relative, sketch.plane.x),
crate::apply_vector(relative, sketch.plane.y),
)
.ok_or_else(|| DocError::Invalid("profile sketch frame is invalid".into()))?;
Ok(mapped)
@@ -7064,7 +7082,6 @@ fn eval_feature(
// Its tools meet bodies in other placements where those are shown (#3383).
let _frames = ToolFrames::install(doc, f, st);
match &f.kind {
FeatureKind::NewerFileFeature { raw } => Err(DocError::Invalid(raw.why())),
FeatureKind::BodyTransfer { bodies, from, to, transform } => {
crate::body_transfer::evaluate(doc, st, &crate::plain(bodies), *from, *to, *transform)
}
@@ -29,7 +29,6 @@ pub(super) fn fixtures() -> Vec<(&'static str, Value)> {
let hole =
|h: Value| json!({"type": "hole", "position": o, "direction": [0, 0, 1], "diameter": "hole.diameter", "depth": "hole.depth", "hole": h});
vec![
("newer_file_feature", json!({"type": "newer_file_feature", "raw": {"type": "gyroid_lattice", "cell": 2}})),
(
"body_transfer",
json!({"type": "body_transfer", "bodies": ["B"], "from": 0, "to": 1, "transform": [[1,0,0,0],[0,1,0,0],[0,0,1,0],[0,0,0,1]]}),
@@ -1,4 +1,3 @@
newer_file_feature
body_transfer
sketch
inputs:
@@ -1,4 +1,3 @@
newer_file_feature
body_transfer
sketch
moves: plane.axis_dir[0], plane.axis_dir[1], plane.axis_origin[0], plane.axis_origin[1], plane.axis_origin[2]
@@ -1,4 +1,3 @@
newer_file_feature
body_transfer
remap_bodies: bodies[0]
sketch
+117 -139
View File
@@ -2,9 +2,8 @@
//!
//! A design file starts with `"format": "solidcraft/N"`. A file of an older format is read and
//! upgraded step by step (N → N+1 → … → current): JSON-level steps first, for changes in the
//! file's shape, then document-level steps. A file a newer build saved opens too, with a warning:
//! what this build reads is read, what it doesn't is reported by name and kept for the next save
//! (`crate::newer_file`).
//! file's shape, then document-level steps. A file of a newer format than this build knows is
//! refused with a message saying so, never guessed at.
//!
//! History:
//! - 1: every design up to 2026-10-08 (format string since M0). Fields added over time all
@@ -265,7 +264,7 @@ const FORMATS: Formats<'static> = Formats { hand: &JSON_STEPS, folded: FOLDED, p
/// A kind of file whose format is versioned here: designs, and drawings (#3890). Each family has
/// its own format tag, its own numbers and its own folder of change files (`build.rs` lists them);
/// reading a file's format, telling a file a newer build saved and the upgrade are the same code
/// reading a file's format, refusing a file a newer build saved and the upgrade are the same code
/// for every family.
pub struct Family<'a> {
/// What a file of the family is called in messages.
@@ -318,8 +317,8 @@ impl Family<'_> {
}
/// Read a file's format and take its `format_pending` out of its JSON, before anything else
/// in it is read: a file that isn't this family's is refused here. One a newer build saved is
/// not: [`Family::newer`] says so.
/// in it is read: a file that isn't this family's, or that a newer build saved, is refused
/// here, so nothing a newer build saved is judged by this build's rules.
pub fn take_version(&self, v: &mut Value) -> Result<(u32, Option<Vec<String>>)> {
let noun = self.noun;
let not_ours = |what: String| DocError::Invalid(format!("not a SolidCraft {noun} ({what})"));
@@ -330,19 +329,10 @@ impl Family<'_> {
Some(_) => return Err(not_ours("format is not a string".into())),
};
let applied = take_names(v, "format_pending", noun)?;
if version == 0 {
return Err(not_ours("format 0".into()));
}
self.formats.check(noun, version, applied.as_deref())?;
Ok((version, applied))
}
/// What says a file of format `version` with the changes `applied` came from a newer build:
/// a number past this build's, or a change this build doesn't have anywhere in its history.
/// `None` for a file this build fully knows.
pub fn newer(&self, version: u32, applied: Option<&[String]>) -> Option<Newer> {
self.formats.newer(version, applied)
}
/// Bring a file of format `version` up to this build's; `applied` is its `format_pending`
/// (both from [`Family::take_version`]).
pub fn upgrade(&self, v: &mut Value, version: u32, applied: Option<&[String]>) -> Result<()> {
@@ -360,17 +350,6 @@ impl Family<'_> {
}
}
/// How a file shows a newer build saved it ([`Family::newer`]).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Newer {
/// The number the file says.
pub format: u32,
/// The number this build reads up to.
pub reads_up_to: u32,
/// The changes the file names that this build doesn't have.
pub unknown_changes: Vec<String>,
}
/// The names of the pending changes this build writes into a design (`format_pending`).
pub fn pending_names() -> Vec<String> {
DESIGN.pending_names()
@@ -415,25 +394,35 @@ impl Formats<'_> {
}
}
/// [`Family::newer`]. Changes are recognized across the entire history: a branch's pending
/// change may have landed at a later number. A list beside a format that isn't made of changes
/// means nothing (a tool or a test set an older number on a newer file).
fn newer(&self, version: u32, applied: Option<&[String]>) -> Option<Newer> {
/// Refuse a `noun` of format `version` that this build can't read: format 0, a newer number,
/// or a change `applied` (its `format_pending`) names that this build doesn't have anywhere in
/// its history, which only a newer build's file can. Changes are recognized across the entire
/// history: a branch's pending change may have landed at a later number. A list beside a format
/// that isn't made of changes means nothing (a tool or a test set an older number on a newer
/// file).
fn check(&self, noun: &str, version: u32, applied: Option<&[String]>) -> Result<()> {
let newer = |what: String| DocError::Invalid(format!("this {noun} was saved by a newer SolidCraft ({what}): update SolidCraft to open it"));
let current = self.current();
let listed = applied.filter(|_| version > current || self.parts(version).is_some()).unwrap_or_default();
let unknown_changes: Vec<String> =
listed.iter().filter(|name| !self.knows_part_name(self.canonical_applied_name(version, name))).cloned().collect();
(version > current || !unknown_changes.is_empty()).then_some(Newer { format: version, reads_up_to: current, unknown_changes })
if version == 0 {
return Err(DocError::Invalid(format!("not a SolidCraft {noun} (format 0)")));
}
if version > current {
return Err(newer(format!("file format {version}; this version reads formats up to {current}")));
}
let applied = applied.filter(|_| self.parts(version).is_some()).unwrap_or_default();
match applied.iter().find(|name| !self.knows_part_name(self.canonical_applied_name(version, name))) {
Some(unknown) => Err(newer(format!("file format {version} with `{unknown}`, which this version doesn't know"))),
None => Ok(()),
}
}
/// Bring a `noun` of format `version` up to [`Formats::current`]. `applied` is the file's
/// `format_pending`: the changes of its format it already has, when it was written while they
/// were pending; the others' steps run first, and already-applied changes never run again at
/// their later number. A file past this build's number has no steps to run.
/// were pending; the others' steps run first. A file [`Formats::check`] refuses is refused,
/// whoever calls, before anything is changed; already-applied changes never run again at
/// their later number.
fn upgrade(&self, noun: &str, v: &mut Value, version: u32, applied: Option<&[String]>) -> Result<()> {
if version == 0 {
return Err(DocError::Invalid(format!("not a SolidCraft {noun} (format 0)")));
}
self.check(noun, version, applied)?;
// A list beside a format that isn't made of changes is ignored, like any field an upgrade
// drops.
let applied = applied.filter(|_| self.parts(version).is_some());
@@ -688,53 +677,18 @@ fn v2_to_v3(v: &mut Value) -> Result<()> {
pub fn read(s: &str) -> Result<Document> {
let mut v: Value = serde_json::from_str(s).map_err(|e| DocError::Invalid(format!("not a SolidCraft design: {e}")))?;
let (version, applied) = DESIGN.take_version(&mut v)?;
match DESIGN.newer(version, applied.as_deref()) {
Some(newer) => crate::newer_file::read(v, version, applied, newer),
None => decode(v, version, applied.as_deref()),
}
}
/// Whether a design's text says a newer build saved it, read before anything else in it is.
pub fn says_newer(text: &str) -> bool {
#[derive(serde::Deserialize)]
struct Says {
format: Option<String>,
format_pending: Option<Vec<String>>,
}
serde_json::from_str::<Says>(text)
.ok()
.and_then(|says| Some((DESIGN.version_of(&says.format?)?, says.format_pending)))
.is_some_and(|(version, applied)| DESIGN.newer(version, applied.as_deref()).is_some())
}
/// The checks of a design's JSON that need nothing outside the part of it they are given.
pub(crate) fn check_raw(v: &Value, version: u32) -> Result<Vec<(usize, usize)>> {
check_design_numbers(v)?;
crate::config::validate_rules_json(v)?;
crate::config::validate_styles_json(v)?;
crate::face_anchor::validate_face_anchors_json(v)?;
validate_versioned_geometry(v)?;
validate_emboss_targets(v)?;
validate_freeform_offsets(v)?;
validate_sketch_sizes(v)?;
crate::format_offsets::validate_raw(v)?;
validate_wires(v, version)
}
/// [`check_raw`], the upgrade and the typed record alone: what a piece of a design must pass by
/// itself ([`crate::newer_file`]), with nothing else in the file for it to refer to.
pub(crate) fn check_piece(mut v: Value, version: u32, applied: Option<&[String]>) -> Result<()> {
check_raw(&v, version)?;
DESIGN.upgrade(&mut v, version, applied)?;
let d: Document = serde_json::from_value(v).map_err(|e| DocError::Invalid(format!("design file: {e}")))?;
check_typed_numbers(&d)
}
/// A design's JSON, its format taken out, as the document it holds.
pub(crate) fn decode(mut v: Value, version: u32, applied: Option<&[String]>) -> Result<Document> {
let legacy_wires = check_raw(&v, version)?;
check_design_numbers(&v)?;
crate::config::validate_rules_json(&v)?;
crate::config::validate_styles_json(&v)?;
crate::face_anchor::validate_face_anchors_json(&v)?;
validate_versioned_geometry(&v)?;
validate_emboss_targets(&v)?;
validate_freeform_offsets(&v)?;
validate_sketch_sizes(&v)?;
crate::format_offsets::validate_raw(&v)?;
let legacy_wires = validate_wires(&v, version)?;
validate_body_transfers(&v)?;
DESIGN.upgrade(&mut v, version, applied)?;
DESIGN.upgrade(&mut v, version, applied.as_deref())?;
let mut d: Document = serde_json::from_value(v).map_err(|e| DocError::Invalid(format!("design file: {e}")))?;
check_typed_numbers(&d)?;
let mut legacy_by_feature = std::collections::BTreeMap::<usize, Vec<usize>>::new();
@@ -1373,9 +1327,9 @@ mod tests {
let mut v = serde_json::json!({});
formats.upgrade("design", &mut v, next, Some(&["a-first".into(), "b-second".into()])).unwrap();
assert_eq!(trace_of(&v), Vec::<&str>::new());
// A change this build doesn't know is a newer build's: said, and kept.
let said = formats.newer(next, Some(&["c-later".into()])).unwrap();
assert_eq!(said, Newer { format: next, reads_up_to: next, unknown_changes: vec!["c-later".into()] });
// A change this build doesn't know is a newer build's: refused, not guessed at.
let err = formats.upgrade("design", &mut serde_json::json!({}), next, Some(&["c-later".into()])).unwrap_err();
assert!(err.to_string().contains("newer SolidCraft"), "{err}");
// A list on a format that isn't made of changes means nothing.
let mut v = serde_json::json!({});
formats.upgrade("design", &mut v, HAND_WRITTEN, Some(&["a-first".into()])).unwrap();
@@ -1418,12 +1372,15 @@ mod tests {
}
#[test]
fn personal_card_alias_without_support_is_a_newer_builds() {
fn personal_card_alias_without_support_refuses_before_mutation() {
let hand: [JsonStep; 27] = [|_| Ok(()); 27];
let first = [traced("a-first")];
let formats = Formats { hand: &hand, folded: &[(29, &first)], pending: &[] };
let said = formats.newer(29, Some(&["physical-material-cards".into()])).unwrap();
assert_eq!(said.unknown_changes, ["physical-material-cards"]);
let mut file = serde_json::json!({"trace": [], "sentinel": 7});
let original = file.clone();
let error = formats.upgrade("design", &mut file, 29, Some(&["physical-material-cards".into()])).unwrap_err();
assert!(error.to_string().contains("physical-material-cards"), "{error}");
assert_eq!(file, original);
}
#[test]
@@ -1434,8 +1391,11 @@ mod tests {
let cards = [traced("material-engineering-cards")];
let formats = Formats { hand: &hand, folded: &[(28, &first), (29, &second), (30, &cards)], pending: &[] };
for version in [28, 30] {
let said = formats.newer(version, Some(&["physical-material-cards".into()])).unwrap();
assert_eq!(said.unknown_changes, ["physical-material-cards"]);
let mut file = serde_json::json!({"trace": []});
let original = file.clone();
let error = formats.upgrade("design", &mut file, version, Some(&["physical-material-cards".into()])).unwrap_err();
assert!(error.to_string().contains("physical-material-cards"), "{error}");
assert_eq!(file, original);
}
}
@@ -1448,7 +1408,11 @@ mod tests {
for names in
[["physical-material-cards".into(), "unknown-card-codec".into()], ["unknown-card-codec".into(), "physical-material-cards".into()]]
{
assert_eq!(formats.newer(29, Some(&names)).unwrap().unknown_changes, ["unknown-card-codec"]);
let mut file = serde_json::json!({"trace": ["b"]});
let original = file.clone();
let error = formats.upgrade("design", &mut file, 29, Some(&names)).unwrap_err();
assert!(error.to_string().contains("unknown-card-codec"), "{error}");
assert_eq!(file, original);
}
}
@@ -1482,14 +1446,18 @@ mod tests {
}
/// An experimental producer can use a number that a release later folds without its
/// change. Its unknown concept is still a newer build's.
/// change. Its unknown concept must still be refused before any migration runs.
#[test]
fn unknown_pending_concepts_are_said_after_folding() {
fn unknown_pending_concepts_are_refused_after_folding() {
let parts = [traced("a-first")];
let later = [traced("b-second")];
for pending in [&[][..], &later[..]] {
let formats = Formats { hand: &[], folded: &[(2, &parts)], pending };
assert_eq!(formats.newer(2, Some(&["experimental-card".into()])).unwrap().unknown_changes, ["experimental-card"]);
let mut file = serde_json::json!({"trace": []});
let original = file.clone();
let err = formats.upgrade("design", &mut file, 2, Some(&["experimental-card".into()])).unwrap_err();
assert!(err.to_string().contains("experimental-card") && err.to_string().contains("update SolidCraft"), "{err}");
assert_eq!(file, original, "refuse before applying even a known migration");
}
}
@@ -1511,20 +1479,23 @@ mod tests {
}
}
/// A design naming a pending change this build doesn't have is a newer build's: what it can't
/// read of it doesn't read as damage.
/// A design naming a pending change this build doesn't have is a newer build's, refused as
/// such before anything else in it is checked (as a newer number is), so a newer build's
/// fields never read as damage.
#[test]
fn a_newer_builds_pending_change_opens_and_its_fields_are_not_damage() {
fn a_newer_builds_pending_change_is_refused_before_the_design_is_checked() {
let mut file: Value = serde_json::from_str(&Document::new("A").to_json()).unwrap();
file["format_pending"] = serde_json::json!(["not-a-change-anyone-made"]);
file["features"] = serde_json::json!([{"type": "emboss", "face": {"body": "Body", "at": [0, 0, 0], "a_newer_key": true}}]);
let opened = read(&file.to_string()).unwrap();
let error = read(&file.to_string()).unwrap_err().to_string();
// With changes pending, FORMAT is made of them; once they are folded it is the folded
// format, which is made of changes too. Either way an unknown change is a newer build's.
let said = opened.newer_file.as_ref().unwrap();
assert_eq!(said.unknown_changes, ["not-a-change-anyone-made"]);
assert!(said.notice().contains(&format!("format {FORMAT} with `not-a-change-anyone-made`")), "{}", said.notice());
assert!(matches!(opened.features[0].kind, crate::FeatureKind::NewerFileFeature { .. }));
assert_eq!(
error,
format!(
"this design was saved by a newer SolidCraft (file format {FORMAT} with `not-a-change-anyone-made`, which this version doesn't know): update SolidCraft to open it"
)
);
}
/// Drawings follow the same rules from their own first format: folded formats follow on from 1
@@ -1604,17 +1575,17 @@ mod tests {
fn tagged(family: &Family<'_>, n: u32) -> Value {
serde_json::json!({"format": family.format_string(n)})
}
// A newer number isn't refused: it is said.
let says = |family: &Family<'_>, mut file: Value| {
let (version, applied) = family.take_version(&mut file).unwrap();
family.newer(version, applied.as_deref())
};
assert_eq!(says(&DESIGN, tagged(&DESIGN, FORMAT + 1)), Some(Newer { format: FORMAT + 1, reads_up_to: FORMAT, unknown_changes: vec![] }));
assert_eq!(read(&tagged(&DESIGN, FORMAT + 1).to_string()).unwrap().newer_file.unwrap().format, FORMAT + 1);
fn newer(noun: &str, n: u32, current: u32) -> String {
format!(
"this {noun} was saved by a newer SolidCraft (file format {n}; this version reads formats up to {current}): update SolidCraft to open it"
)
}
assert_eq!(refusal(&DESIGN, tagged(&DESIGN, FORMAT + 1)), newer("design", FORMAT + 1, FORMAT));
assert_eq!(read(&tagged(&DESIGN, FORMAT + 1).to_string()).unwrap_err().to_string(), newer("design", FORMAT + 1, FORMAT));
let current = DRAWING.current();
assert!(matches!(DRAWING.take_version(&mut tagged(&DRAWING, current)), Ok((v, None)) if v == current));
for n in [current + 1, u32::MAX] {
assert_eq!(says(&DRAWING, tagged(&DRAWING, n)), Some(Newer { format: n, reads_up_to: current, unknown_changes: vec![] }));
assert_eq!(refusal(&DRAWING, tagged(&DRAWING, n)), newer("drawing", n, current));
}
for (family, noun) in [(&DESIGN, "design"), (&DRAWING, "drawing")] {
assert_eq!(refusal(family, tagged(family, 0)), format!("not a SolidCraft {noun} (format 0)"));
@@ -1636,7 +1607,12 @@ mod tests {
let design = Family { formats: Formats { hand: &JSON_STEPS, folded: FOLDED, pending: &pending }, ..DESIGN };
let next = design.current();
let file = json!({"format": format_string(next), "format_pending": ["c-later"]});
assert_eq!(says(&design, file), Some(Newer { format: next, reads_up_to: next, unknown_changes: vec!["c-later".into()] }));
assert_eq!(
refusal(&design, file),
format!(
"this design was saved by a newer SolidCraft (file format {next} with `c-later`, which this version doesn't know): update SolidCraft to open it"
)
);
assert!(matches!(DESIGN.take_version(&mut json!({})), Ok((1, None))));
assert_eq!(refusal(&DRAWING, json!({})), "not a SolidCraft drawing (no format)");
}
@@ -1663,13 +1639,19 @@ mod tests {
assert_eq!(open(json!({"format": "solidcraft-drawing/2", "format_pending": ["a-first"]})).unwrap(), Vec::<String>::new());
// A list beside format 1 means nothing: the step still runs.
assert_eq!(open(json!({"format": "solidcraft-drawing/1", "format_pending": ["c-later"]})).unwrap(), ["a"]);
// Of a newer build: said, and what can run runs.
let list = ["a-first".to_string(), "c-later".to_string()];
assert_eq!(drawing.newer(2, Some(&list)).unwrap().unknown_changes, ["c-later"]);
assert_eq!(open(json!({"format": "solidcraft-drawing/2", "format_pending": list})).unwrap(), Vec::<String>::new());
assert_eq!(drawing.newer(3, None).unwrap(), Newer { format: 3, reads_up_to: 2, unknown_changes: vec![] });
assert_eq!(open(json!({"format": "solidcraft-drawing/3"})).unwrap(), Vec::<String>::new());
// An upgrade called without `take_version` still says format 0 isn't one.
let error = open(json!({"format": "solidcraft-drawing/2", "format_pending": ["a-first", "c-later"]})).unwrap_err().to_string();
assert_eq!(
error,
"this drawing was saved by a newer SolidCraft (file format 2 with `c-later`, which this version doesn't know): update SolidCraft to open it"
);
let error = open(json!({"format": "solidcraft-drawing/3"})).unwrap_err().to_string();
assert_eq!(
error,
"this drawing was saved by a newer SolidCraft (file format 3; this version reads formats up to 2): update SolidCraft to open it"
);
// An upgrade called without `take_version` refuses in the drawing's words too, format 0 included.
let error = drawing.upgrade(&mut json!({}), 3, None).unwrap_err().to_string();
assert!(error.starts_with("this drawing was saved by a newer SolidCraft (file format 3;"), "{error}");
assert_eq!(drawing.upgrade(&mut json!({}), 0, None).unwrap_err().to_string(), "not a SolidCraft drawing (format 0)");
}
@@ -1684,8 +1666,7 @@ mod tests {
assert!(read(&file.to_string()).is_err());
}
file["format_pending"] = serde_json::json!(["not-a-change-anyone-made"]);
let newer = read(&file.to_string()).unwrap();
assert_eq!(newer.newer_file.as_ref().unwrap().unknown_changes, ["not-a-change-anyone-made"], "even after folding");
assert!(read(&file.to_string()).is_err(), "unknown pending concepts are refused even after folding");
file["format_pending"] = Value::from(pending_names());
let opened = read(&file.to_string()).unwrap();
assert_eq!(opened.format_pending, pending_names());
@@ -2157,9 +2138,8 @@ mod tests {
value["features"] = serde_json::json!([{"type":"sketch", "sketch":{"freeform_offsets":records}}]);
let error = read(&value.to_string()).unwrap_err().to_string();
assert!(error.contains("free-form offset metadata"), "{error}");
// A newer build's file isn't held to this: the feature waits for that build.
value["format"] = Value::from(format_string(FORMAT + 1));
assert!(matches!(read(&value.to_string()).unwrap().features[0].kind, crate::FeatureKind::NewerFileFeature { .. }));
assert!(read(&value.to_string()).unwrap_err().to_string().contains("newer SolidCraft"));
}
}
@@ -2183,7 +2163,8 @@ mod tests {
let error = read(&value.to_string()).unwrap_err().to_string();
assert!(error.contains("emboss face"), "{error}");
value["format"] = Value::from(format_string(FORMAT + 1));
assert!(matches!(read(&value.to_string()).unwrap().features[0].kind, crate::FeatureKind::NewerFileFeature { .. }));
let error = read(&value.to_string()).unwrap_err().to_string();
assert!(error.contains("newer SolidCraft"), "{error}");
}
}
@@ -2226,7 +2207,7 @@ mod tests {
}
#[test]
fn version_seven_styles_upgrade_and_a_newer_versions_columns_are_kept() {
fn version_seven_styles_upgrade_and_newer_versions_are_refused_before_columns() {
use crate::config::{Column, ConfigRow};
let mut d = Document::new("configured styles");
d.configs.columns = vec![Column::Material("Body".into()), Column::Appearance("Body".into())];
@@ -2246,11 +2227,9 @@ mod tests {
value["format"] = Value::from(format_string(FORMAT + 1));
value["configs"]["columns"] = serde_json::json!([{"unknown_future_column":"Body"}]);
value["configs"]["rules"] = serde_json::json!({"unknown_future_rule": true});
let opened = read(&value.to_string()).unwrap();
let said = opened.newer_file.as_ref().unwrap();
assert!(said.not_understood.iter().any(|what| what.contains("configs")), "{:?}", said.not_understood);
let saved: Value = serde_json::from_str(&opened.to_json()).unwrap();
assert_eq!(saved["configs"], value["configs"], "kept for the build that reads them");
let error = read(&value.to_string()).unwrap_err().to_string();
assert!(error.contains("newer SolidCraft") && error.contains("update SolidCraft"), "{error}");
assert!(!error.contains("unknown variant"), "format refusal precedes decoding new variants");
}
#[test]
@@ -2305,10 +2284,8 @@ mod tests {
assert_eq!(read(&saved.to_string()).unwrap(), d);
saved["format"] = Value::from(format_string(FORMAT + 1));
saved["features"][0]["sketch"]["constraints"][0]["type"] = Value::from("future_dimension");
let opened = read(&saved.to_string()).unwrap();
assert!(matches!(opened.features[0].kind, crate::FeatureKind::NewerFileFeature { .. }));
let again: Value = serde_json::from_str(&opened.to_json()).unwrap();
assert_eq!(again["features"][0], saved["features"][0]);
let error = read(&saved.to_string()).unwrap_err().to_string();
assert!(error.contains("newer SolidCraft") && !error.contains("unknown variant"), "{error}");
}
#[test]
@@ -2415,14 +2392,15 @@ mod tests {
}
#[test]
fn older_upgraded_newer_opened_others_rejected() {
fn older_upgraded_newer_refused_others_rejected() {
let mut v: Value = serde_json::from_str(&Document::new("A").to_json()).unwrap();
v["format"] = Value::from("solidcraft/1");
assert_eq!(read(&v.to_string()).unwrap().format, format_string(FORMAT));
v.as_object_mut().unwrap().remove("format");
assert!(read(&v.to_string()).is_ok(), "a file without a format is format 1");
v["format"] = Value::from(format_string(FORMAT + 1));
assert_eq!(read(&v.to_string()).unwrap().newer_file.unwrap().format, FORMAT + 1);
let e = read(&v.to_string()).unwrap_err().to_string();
assert!(e.contains("newer SolidCraft"), "{e}");
for bad in ["solidcraft/0", "acad/3", "solidcraft/-1", "solidcraft/99999999999999999999"] {
v["format"] = Value::from(bad);
assert!(read(&v.to_string()).is_err(), "{bad}");
+1 -1
View File
@@ -5,7 +5,7 @@ here is the whole format bump: never edit `FORMAT`, `JSON_STEPS` or another chan
- Name it after the change, lowercase with dashes: `placed-plane-origins.rs`.
- Start it with a `//!` note: what is saved, what an older design gets without it, and what
the last release does with a design that has it (it warns about the newer format number, opens what it can read and keeps the rest).
the last release does with a design that has it (it refuses the newer format number).
- If older designs must be rewritten as they open, add the step to the same file:
```rust
@@ -0,0 +1,6 @@
//! A new-body extrusion or revolve whose outputs transfer into its source sketch's component
//! reads the profile through the recorded transfer frame. Capturing that component's position
//! therefore moves the body once, as the pending preview does. Existing designs use their
//! saved transfer transforms without rewriting fields; independent cross-component references
//! keep their live placement. The last release refuses the newer design-format number rather
//! than reopening the design with the former double-placement behavior.
+1 -1
View File
@@ -399,7 +399,7 @@ mod tests {
damaged["features"][1]["profiles"]["associations"] = associations;
assert!(validate_raw(&damaged).is_err());
damaged["format"] = json!(crate::format::format_string(crate::format::FORMAT + 1));
assert!(crate::format::read(&damaged.to_string()).unwrap().newer_file.is_some());
assert!(crate::format::read(&damaged.to_string()).unwrap_err().to_string().contains("newer SolidCraft"));
}
// Absent metadata preserves the historical one-to-one chain representation.
let mut legacy = fixture();
-1
View File
@@ -11,7 +11,6 @@ mod assembly;
mod body_transfer;
mod body_transfer_sheet;
mod browser_tree;
pub mod newer_file;
pub mod pbr_appearance;
pub use body_transfer::{valid_transfer_matrix, validate_body_transfer};
pub mod canvas;
-530
View File
@@ -1,530 +0,0 @@
//! A design a newer SolidCraft saved: it opens, with a warning, and saving it loses nothing.
//!
//! A newer format number, or a change the file names that this build doesn't have, says a newer
//! build saved the file. It may use nothing this build lacks, so it is read as usual. When part of
//! it can't be read, that part stays out of the way and the rest opens:
//! - a feature this build can't read keeps its place on the timeline as
//! [`FeatureKind::NewerFileFeature`](crate::FeatureKind::NewerFileFeature), which reports an error when evaluated;
//! - a parameter it can't read is left out;
//! - any other top-level section it can't read is left out whole;
//! - a field it doesn't know, which typed reading would drop, is noticed by comparing the file
//! with what was read.
//!
//! All of these are named in [`NewerFile::not_understood`] and go back into the file when it is
//! saved ([`restore`]), as they came. Saving keeps the file's own format number and change list:
//! the file still holds what the newer build wrote, and a lower number would make that build
//! upgrade its own data as if it were old. What can't be put back because the design changed around
//! it ([`not_kept`]) is said in the reply to the save.
use std::collections::BTreeMap;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use crate::format::{self, DESIGN, Newer};
use crate::{DocError, Document, Result};
/// How a [`RawFeature`] is written while it is inside this build; [`restore`] unwraps it.
const FOREIGN_TAG: &str = "newer_file_feature";
/// Sections a design can't open without: filled in when the file's can't be read.
const CORE: [&str; 3] = ["name", "params", "features"];
/// The most sections left out one at a time to find the one a design can't be read with.
const MAX_SECTION_TRIES: usize = 64;
/// Fields a design container adds beside what a design holds; not unknown to this build.
const CONTAINER_ONLY: [&str; 5] = ["step_blob", "mesh_blob", "image_blob", "points_blob", "hdr_blob"];
/// A feature as a newer build wrote it.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct RawFeature(pub Box<Value>);
impl RawFeature {
/// The kind of feature it says it is.
pub fn kind(&self) -> &str {
self.0.get("type").and_then(Value::as_str).unwrap_or("unnamed")
}
/// Why it doesn't build here.
pub fn why(&self) -> String {
format!("saved by a newer SolidCraft: this version can't read a `{}` feature. Update SolidCraft to build it; saving keeps it", self.kind())
}
}
/// What the file says and holds that this build doesn't read.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct NewerFile {
/// The number the file says.
pub format: u32,
/// The number this build reads up to.
pub reads_up_to: u32,
/// The changes the file names that this build doesn't have.
pub unknown_changes: Vec<String>,
/// What this build couldn't read, by name.
pub not_understood: Vec<String>,
/// Fields known records lost in typed reading.
kept: Option<Kept>,
/// Top-level sections left out whole.
sections: Vec<Section>,
/// Elements left out of a top-level list: its key and the element.
elements: Vec<(String, Value)>,
/// The parts of a design container this build has no use for: name and bytes. A blob only one
/// of the kept pieces refers to is one of them.
pub parts: Vec<(String, Arc<[u8]>)>,
/// The file's `container.json`, when this build doesn't know what it says.
pub container: Option<Value>,
}
#[derive(Clone, Debug, PartialEq)]
struct Section {
key: String,
value: Value,
/// This build's record of the section, without the file's: while it is unchanged, the file's
/// goes back.
empty: Option<Value>,
}
/// The fields of a record that typed reading dropped.
#[derive(Clone, Debug, PartialEq)]
enum Kept {
Field(Value),
Fields(BTreeMap<String, Kept>),
Items(Vec<(Anchor, Kept)>),
}
/// Which element of a list some kept fields belong to.
#[derive(Clone, Debug, PartialEq)]
enum Anchor {
Id(u64),
Name(String),
/// Its place, good only while the list keeps its length.
Index {
at: usize,
of: usize,
},
}
impl NewerFile {
/// What to tell the user, whatever else is said.
pub fn notice(&self) -> String {
let reads = self.reads_up_to;
let format = self.format;
match self.unknown_changes.first() {
Some(change) if format <= reads => {
format!(
"Saved by a newer SolidCraft (format {format} with `{change}`, which this version doesn't know). If something's missing, update."
)
}
_ => format!("Saved by a newer SolidCraft (format {format}, this version reads up to {reads}). If something's missing, update."),
}
}
/// Every line to report: the notice, then what couldn't be read, by name.
pub fn warnings(&self) -> Vec<String> {
let unread = self.not_understood.iter().map(|what| format!("Not read: {what}. Saving keeps it."));
std::iter::once(self.notice()).chain(unread).collect()
}
/// What a reply says about it.
pub fn report(&self) -> Value {
json!({
"format": self.format,
"reads_up_to": self.reads_up_to,
"unknown_changes": self.unknown_changes,
"not_understood": self.not_understood,
"notice": self.notice(),
})
}
/// Whether the next save needs a container to keep what the file holds: plain JSON has no room
/// for parts beside the design.
pub fn needs_container(&self) -> bool {
!self.parts.is_empty()
}
/// Whether anything kept back names this text (a blob's name in a container).
pub fn mentions(&self, text: &str) -> bool {
fn value(v: &Value, text: &str) -> bool {
match v {
Value::String(s) => s == text,
Value::Array(a) => a.iter().any(|x| value(x, text)),
Value::Object(o) => o.values().any(|x| value(x, text)),
_ => false,
}
}
fn kept(k: &Kept, text: &str) -> bool {
match k {
Kept::Field(v) => value(v, text),
Kept::Fields(m) => m.values().any(|x| kept(x, text)),
Kept::Items(l) => l.iter().any(|(_, x)| kept(x, text)),
}
}
self.kept.as_ref().is_some_and(|k| kept(k, text))
|| self.sections.iter().any(|s| value(&s.value, text))
|| self.elements.iter().any(|(_, v)| value(v, text))
}
/// Say that a section the container reader couldn't finish reading is left out.
/// `empty` is what the design writes for the section while it holds nothing, if anything.
pub fn keep_section(&mut self, key: &str, value: Value, empty: Option<Value>, why: &str) {
self.not_understood.push(format!("section `{key}` ({why})"));
self.sections.push(Section { key: key.into(), value, empty });
}
}
/// Read a design a newer build saved, or say why it truly can't be read.
pub(crate) fn read(mut raw: Value, version: u32, applied: Option<Vec<String>>, newer: Newer) -> Result<Document> {
let applied_names = applied.as_deref();
let mut file =
NewerFile { format: newer.format, reads_up_to: newer.reads_up_to, unknown_changes: newer.unknown_changes.clone(), ..NewerFile::default() };
let (mut doc, read_as) = match format::decode(raw.clone(), version, applied_names) {
Ok(doc) => (doc, raw),
Err(_) => {
let mut foreign = BTreeMap::new();
let mut doc = salvage(&mut raw, version, applied_names, &mut file, &mut foreign).map_err(|why| {
DocError::Invalid(format!(
"this design was saved by a newer SolidCraft (format {}, this version reads up to {}) and this version can't read it: {why}. Update SolidCraft to open it",
newer.format, newer.reads_up_to
))
})?;
fill_foreign(&mut doc, foreign);
(doc, raw)
}
};
let mut upgraded = read_as;
// Compared with what was read, the file's own shape after the same upgrade.
DESIGN.upgrade(&mut upgraded, version, applied_names)?;
let known = serde_json::to_value(&doc).map_err(|e| DocError::Invalid(format!("design file: {e}")))?;
let mut paths = Vec::new();
file.kept = vanished(&upgraded, &known, "", &mut paths);
file.not_understood.extend(paths);
for section in &mut file.sections {
section.empty = known.get(&section.key).cloned();
}
if newer.format > newer.reads_up_to {
// The file's own number and list: it still holds what that build wrote.
doc.format = DESIGN.format_string(newer.format);
doc.format_pending = applied.unwrap_or_default();
} else {
// Upgraded to this build's format; the changes it doesn't know are still in it.
doc.format_pending.extend(newer.unknown_changes.iter().cloned());
}
doc.newer_file = Some(Arc::new(file));
Ok(doc)
}
/// Read the design around what can't be read, which moves to `file`. `raw` ends as what was read.
fn salvage(
raw: &mut Value,
version: u32,
applied: Option<&[String]>,
file: &mut NewerFile,
foreign: &mut BTreeMap<u64, Value>,
) -> std::result::Result<Document, String> {
let piece = |key: &str, value: &Value| {
let mut stub = json!({"name": "", "params": [], "features": []});
stub[key] = value.clone();
format::check_piece(stub, version, applied)
};
let Some(top) = raw.as_object_mut() else { return Err("expected a JSON object".into()) };
// A feature it can't read keeps its place; a parameter it can't read stays out.
if let Some(Value::Array(features)) = top.get_mut("features") {
let mut free = features.iter().filter_map(|f| f.get("id").and_then(Value::as_u64)).max().unwrap_or(0).saturating_add(1);
for feature in features.iter_mut() {
if let Err(why) = piece("features", &json!([feature])) {
file.not_understood.push(feature_note(feature, &why.to_string()));
*feature = wrapped(std::mem::take(feature), &mut free, foreign);
}
}
}
if let Some(Value::Array(params)) = top.get_mut("params") {
params.retain(|param| match piece("params", &json!([param])) {
Ok(()) => true,
Err(why) => {
let name = param.get("name").and_then(Value::as_str).unwrap_or("unnamed");
file.not_understood.push(format!("parameter `{name}` ({})", short(&why.to_string())));
file.elements.push(("params".into(), param.clone()));
false
}
});
}
// Any other section, by itself.
let keys: Vec<String> = top.keys().cloned().collect();
for key in &keys {
let value = top.get(key).cloned().unwrap_or_default();
if let Err(why) = piece(key, &value) {
move_out(top, key, &why.to_string(), file);
}
}
if !top.get("name").is_some_and(Value::is_string) {
top.insert("name".into(), json!("Untitled"));
}
for key in ["params", "features"] {
top.entry(key).or_insert_with(|| json!([]));
}
let attempt = |top: &Map<String, Value>| format::decode(Value::Object(top.clone()), version, applied);
let first = match attempt(top) {
Ok(doc) => return Ok(doc),
Err(why) => why,
};
// Sections that are fine by themselves but not together with the rest.
let keys: Vec<String> = top.keys().filter(|k| !CORE.contains(&k.as_str())).cloned().collect();
// Each try reads the whole design: a file of many sections goes straight to the last resort.
for key in keys.iter().filter(|_| keys.len() <= MAX_SECTION_TRIES) {
let mut without = top.clone();
without.remove(key);
if let Ok(doc) = attempt(&without) {
move_out(top, key, &first.to_string(), file);
return Ok(doc);
}
}
let mut bare = top.clone();
bare.retain(|key, _| CORE.contains(&key.as_str()));
match attempt(&bare) {
Ok(doc) => {
for key in &keys {
move_out(top, key, &first.to_string(), file);
}
Ok(doc)
}
Err(_) => Err(first.to_string()),
}
}
/// Leave a section out and keep it. A section the design needs is left empty.
fn move_out(top: &mut Map<String, Value>, key: &str, why: &str, file: &mut NewerFile) {
let Some(value) = top.remove(key) else { return };
file.not_understood.push(format!("section `{key}` ({})", short(why)));
file.sections.push(Section { key: key.into(), value, empty: None });
}
/// The first line of a reader's error, without the list of every kind it knows.
fn short(why: &str) -> &str {
let why = why.split(", expected").next().unwrap_or(why);
why.strip_prefix("design file: ").unwrap_or(why)
}
fn feature_note(feature: &Value, why: &str) -> String {
let name = feature.get("name").and_then(Value::as_str).unwrap_or("unnamed");
let kind = feature.get("type").and_then(Value::as_str).unwrap_or("unnamed");
let why = short(why);
if why.starts_with("unknown variant") {
format!("feature `{name}`, a `{kind}` feature this version doesn't know")
} else {
format!("feature `{name}`, a `{kind}` feature ({why})")
}
}
/// A feature this build can't read as this build reads it: the fields every feature has, which stay
/// editable. Its `raw` is filled in after reading ([`fill_foreign`]), so nothing in a feature this
/// build can't read is judged by this build's rules. `free` gives an id to one the file left
/// without.
fn wrapped(raw: Value, free: &mut u64, foreign: &mut BTreeMap<u64, Value>) -> Value {
let id = raw.get("id").and_then(Value::as_u64).unwrap_or_else(|| {
let id = *free;
*free = free.saturating_add(1);
id
});
let name = raw.get("name").and_then(Value::as_str).unwrap_or("Unknown feature").to_string();
let suppressed = raw.get("suppressed").and_then(Value::as_bool).unwrap_or(false);
let component = raw.get("component").and_then(Value::as_u64).unwrap_or(0);
foreign.insert(id, raw);
json!({"id": id, "name": name, "suppressed": suppressed, "component": component, "type": FOREIGN_TAG, "raw": null})
}
/// Give the features [`wrapped`] left without one what the file said.
fn fill_foreign(doc: &mut Document, mut foreign: BTreeMap<u64, Value>) {
for feature in &mut doc.features {
if let crate::FeatureKind::NewerFileFeature { raw } = &mut feature.kind
&& let Some(value) = foreign.remove(&feature.id)
{
raw.0 = Box::new(value);
}
}
}
/// A feature back as the file had it, with the changes this build can make to any feature.
fn unwrapped(feature: &mut Value) {
let Some(wrapper) = feature.as_object_mut() else { return };
let Some(Value::Object(mut raw)) = wrapper.remove("raw") else { return };
for key in ["id", "name", "suppressed", "component"] {
match wrapper.get(key) {
// Left out as a writer leaves it out, unless the file said it.
Some(v) if key != "id" && !raw.contains_key(key) && looks_default(v) => {}
Some(v) => {
raw.insert(key.into(), v.clone());
}
None => {
raw.remove(key);
}
}
}
*feature = Value::Object(raw);
}
/// A value a writer leaves out without losing anything: not worth telling anyone about.
fn looks_default(v: &Value) -> bool {
match v {
Value::Null => true,
Value::Bool(b) => !b,
Value::Number(n) => n.as_f64() == Some(0.0),
Value::String(s) => s.is_empty(),
Value::Array(a) => a.is_empty(),
Value::Object(o) => o.is_empty(),
}
}
/// What `raw` has that `known`, what was read of it, doesn't. `paths` gets those fields' names.
fn vanished(raw: &Value, known: &Value, path: &str, paths: &mut Vec<String>) -> Option<Kept> {
match (raw, known) {
(Value::Object(raw), Value::Object(known)) => {
if known.get("type").and_then(Value::as_str) == Some(FOREIGN_TAG) {
return None;
}
let mut kept = BTreeMap::new();
for (key, value) in raw {
if CONTAINER_ONLY.contains(&key.as_str()) || (path.is_empty() && matches!(key.as_str(), "format" | "format_pending")) {
continue;
}
let inner = if path.is_empty() { key.clone() } else { format!("{path}.{key}") };
match known.get(key) {
None => {
if !looks_default(value) {
paths.push(format!("field `{inner}`"));
}
kept.insert(key.clone(), Kept::Field(value.clone()));
}
Some(known) => {
if let Some(k) = vanished(value, known, &inner, paths) {
kept.insert(key.clone(), k);
}
}
}
}
(!kept.is_empty()).then_some(Kept::Fields(kept))
}
(Value::Array(raw), Value::Array(known)) => {
let mut items = Vec::new();
for (at, element) in raw.iter().enumerate() {
let anchor = anchor_of(element).unwrap_or(Anchor::Index { at, of: raw.len() });
let Some(partner) = find(known, &anchor) else { continue };
let label = match (element.get("name").and_then(Value::as_str), &anchor) {
(Some(name), _) => format!("{path}[{name}]"),
(None, Anchor::Id(id)) => format!("{path}[{id}]"),
_ => format!("{path}[{at}]"),
};
if let Some(k) = vanished(element, partner, &label, paths) {
items.push((anchor, k));
}
}
(!items.is_empty()).then_some(Kept::Items(items))
}
_ => None,
}
}
fn anchor_of(element: &Value) -> Option<Anchor> {
if let Some(id) = element.get("id").and_then(Value::as_u64) {
return Some(Anchor::Id(id));
}
element.get("name").and_then(Value::as_str).map(|name| Anchor::Name(name.to_string()))
}
fn find<'a>(list: &'a [Value], anchor: &Anchor) -> Option<&'a Value> {
match anchor {
Anchor::Id(id) => list.iter().find(|x| x.get("id").and_then(Value::as_u64) == Some(*id)),
Anchor::Name(name) => list.iter().find(|x| x.get("name").and_then(Value::as_str) == Some(name)),
Anchor::Index { at, of } => (list.len() == *of).then(|| list.get(*at)).flatten(),
}
}
fn find_mut<'a>(list: &'a mut [Value], anchor: &Anchor) -> Option<&'a mut Value> {
match anchor {
Anchor::Id(id) => list.iter_mut().find(|x| x.get("id").and_then(Value::as_u64) == Some(*id)),
Anchor::Name(name) => list.iter_mut().find(|x| x.get("name").and_then(Value::as_str) == Some(name)),
Anchor::Index { at, of } => (list.len() == *of).then(|| list.get_mut(*at)).flatten(),
}
}
/// Put the kept fields back. An element that is gone was deleted here, its fields with it; one
/// whose place can't be told any more, and a field whose record is gone, go into `lost`.
fn apply(kept: &Kept, value: &mut Value, path: &str, lost: &mut Vec<String>) {
match kept {
Kept::Field(_) => {}
Kept::Fields(fields) => {
let Some(object) = value.as_object_mut() else {
lost.push(format!("field `{path}`"));
return;
};
for (key, sub) in fields {
let inner = if path.is_empty() { key.clone() } else { format!("{path}.{key}") };
match (sub, object.get_mut(key)) {
(Kept::Field(v), None) => {
object.insert(key.clone(), v.clone());
}
(Kept::Field(_), Some(_)) => {}
(sub, Some(inner_value)) => apply(sub, inner_value, &inner, lost),
// The list it was in is empty and not written: its elements were deleted.
(Kept::Items(_), None) => {}
(_, None) => lost.push(format!("field `{inner}`")),
}
}
}
Kept::Items(items) => {
let Some(list) = value.as_array_mut() else {
lost.push(format!("field `{path}`"));
return;
};
for (anchor, sub) in items {
match (find_mut(list, anchor), anchor) {
(Some(element), _) => apply(sub, element, path, lost),
(None, Anchor::Index { at, .. }) => lost.push(format!("element {at} of `{path}`")),
(None, _) => {}
}
}
}
}
}
/// Write back what the design holds of the file a newer build saved, into `value`, the design's
/// JSON. Returns what couldn't go back ([`not_kept`]).
pub fn restore(doc: &Document, value: &mut Value) -> Vec<String> {
let mut lost = Vec::new();
let Some(file) = &doc.newer_file else { return lost };
if let Some(Value::Array(features)) = value.get_mut("features") {
for feature in features.iter_mut().filter(|f| f.get("type").and_then(Value::as_str) == Some(FOREIGN_TAG)) {
unwrapped(feature);
}
}
if let Some(kept) = &file.kept {
apply(kept, value, "", &mut lost);
}
if let Some(top) = value.as_object_mut() {
for section in &file.sections {
if top.get(&section.key) == section.empty.as_ref() {
top.insert(section.key.clone(), section.value.clone());
} else {
lost.push(format!("section `{}`, replaced by what was edited here", section.key));
}
}
for (key, element) in &file.elements {
let name = element.get("name");
match top.entry(key.clone()).or_insert_with(|| json!([])) {
Value::Array(list) if name.is_none() || !list.iter().any(|x| x.get("name") == name) => list.push(element.clone()),
_ => lost.push(format!("`{key}` element {}, replaced by what was made here", name.and_then(Value::as_str).unwrap_or("unnamed"))),
}
}
}
lost
}
/// What of the file a newer build saved the next save can't keep, because the design was changed
/// around it. Empty almost always.
pub fn not_kept(doc: &Document) -> Vec<String> {
let Ok(mut value) = serde_json::to_value(doc) else { return Vec::new() };
restore(doc, &mut value)
}
#[cfg(test)]
#[path = "newer_file_tests.rs"]
mod tests;
-236
View File
@@ -1,236 +0,0 @@
//! Designs a newer SolidCraft saved: they open, say so, and saving loses nothing.
use serde_json::{Value, json};
use crate::format::{DESIGN, FORMAT, format_string};
use crate::{Document, FeatureKind, Model, PlaneRef};
/// A real design: a sketched disc and its extrusion, and a parameter.
fn design() -> Document {
use solidcraft_sketch::Sketch;
let mut d = Document::new("Plate");
d.set_param("depth", "5 mm", Some("mm"), None).unwrap();
let mut sketch = Sketch::new();
sketch.add_circle(solidcore_record::Vec2::new(0.0, 0.0), 10.0, None, None).unwrap();
let base = d.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch }, Some("Base")).unwrap();
d.add_feature(extrude(base), Some("Pad")).unwrap();
d
}
fn extrude(sketch: u64) -> FeatureKind {
FeatureKind::Extrude {
thin: None,
sketch: crate::SketchId(sketch),
profiles: crate::ProfileSel::All,
extent: serde_json::from_value(json!({"distance": "depth"})).unwrap(),
operation: crate::Operation::NewBody,
targets: vec![],
}
}
fn saved(d: &Document) -> Value {
serde_json::from_str(&d.to_json()).unwrap()
}
/// `(body name, volume)` of everything the design builds, and the error of each feature.
fn built(d: &Document) -> (Vec<(String, f64)>, Vec<Option<String>>) {
let mut model = Model::default();
model.evaluate(d);
let bodies = model.state().bodies.iter().map(|b| (b.name.clone(), b.body.volume_at(0.01).unwrap())).collect();
(bodies, model.results.iter().map(|r| r.error.clone()).collect())
}
fn newer() -> String {
format_string(FORMAT + 1)
}
#[test]
fn one_format_newer_with_nothing_unknown_opens_with_the_same_bodies() {
let original = design();
let mut file = saved(&original);
file["format"] = json!(newer());
let opened = Document::from_json(&file.to_string()).unwrap();
let (bodies, errors) = built(&opened);
assert_eq!((bodies.clone(), errors), built(&original));
assert_eq!(bodies.len(), 1);
assert_eq!(opened.features, original.features);
let notice = opened.newer_file.as_ref().unwrap().notice();
assert_eq!(
notice,
format!("Saved by a newer SolidCraft (format {}, this version reads up to {FORMAT}). If something's missing, update.", FORMAT + 1)
);
assert!(opened.newer_file.as_ref().unwrap().not_understood.is_empty());
// Saved, it still says what it is; read again, the same.
let again = saved(&opened);
assert_eq!(again["format"], json!(newer()));
assert_eq!(Document::from_json(&opened.to_json()).unwrap(), opened);
}
#[test]
fn an_unknown_change_opens_with_a_warning_and_stays_on_the_file() {
let original = design();
let mut file = saved(&original);
file["format_pending"] = json!(["a-later-change"]);
let opened = Document::from_json(&file.to_string()).unwrap();
let report = opened.newer_file.as_ref().unwrap();
assert_eq!(report.unknown_changes, ["a-later-change"]);
assert!(report.notice().contains("a-later-change") && report.notice().starts_with("Saved by a newer SolidCraft"), "{}", report.notice());
assert_eq!(built(&opened), built(&original));
let again = saved(&opened);
assert_eq!(again["format"], json!(format_string(FORMAT)));
assert!(again["format_pending"].as_array().unwrap().contains(&json!("a-later-change")));
assert_eq!(Document::from_json(&opened.to_json()).unwrap(), opened);
}
/// The file, with a feature of a kind this build doesn't have between the sketch and the pad.
fn with_unknown_feature() -> Value {
let mut file = saved(&design());
file["format"] = json!(newer());
file["format_pending"] = json!(["a-later-change"]);
let future = json!({"id": 900, "name": "Lattice1", "type": "gyroid_lattice", "cell": 2.5, "nested": {"big": 1e300}, "suppressed": false});
file["features"].as_array_mut().unwrap().insert(1, future);
file
}
#[test]
fn an_unknown_feature_kind_opens_the_rest_and_reports_that_feature() {
let file = with_unknown_feature();
let opened = Document::from_json(&file.to_string()).unwrap();
let report = opened.newer_file.as_ref().unwrap();
assert!(report.not_understood.iter().any(|what| what.contains("Lattice1") && what.contains("gyroid_lattice")), "{:?}", report.not_understood);
assert_eq!(opened.features.len(), 3);
assert!(matches!(&opened.features[1].kind, FeatureKind::NewerFileFeature { raw } if raw.kind() == "gyroid_lattice"));
let (bodies, errors) = built(&opened);
assert_eq!(bodies.len(), 1, "the pad after it still builds");
assert!(errors[0].is_none() && errors[2].is_none(), "{errors:?}");
let why = errors[1].as_deref().unwrap();
assert!(why.contains("newer SolidCraft") && why.contains("gyroid_lattice"), "{why}");
}
#[test]
fn saving_keeps_an_unknown_feature_as_the_file_had_it_and_the_files_number() {
let file = with_unknown_feature();
let opened = Document::from_json(&file.to_string()).unwrap();
let again = saved(&opened);
assert_eq!(again["format"], file["format"], "never a lower number");
assert_eq!(again["format_pending"], file["format_pending"]);
assert_eq!(again["features"][1], file["features"][1], "the feature as the file had it");
assert_eq!(
again["features"].as_array().unwrap().iter().map(|f| f["type"].clone()).collect::<Vec<_>>(),
[json!("sketch"), json!("gyroid_lattice"), json!("extrude")]
);
assert_eq!(Document::from_json(&opened.to_json()).unwrap(), opened, "saved and read again, the same");
// A rename here goes with it; the rest stays.
let mut renamed = opened.clone();
renamed.features[1].name = "Infill".into();
let again = saved(&renamed);
assert_eq!(again["features"][1]["name"], "Infill");
assert_eq!(again["features"][1]["nested"], json!({"big": 1e300}));
}
#[test]
fn saving_keeps_fields_and_sections_this_version_doesnt_know() {
let mut file = saved(&design());
file["format"] = json!(newer());
file["future_section"] = json!({"anything": [1, 2, 3]});
file["features"][1]["draft_v2"] = json!({"angle": 3.0});
file["params"][0]["future_note"] = json!("keep me");
let opened = Document::from_json(&file.to_string()).unwrap();
assert_eq!(built(&opened).0.len(), 1);
let report = opened.newer_file.as_ref().unwrap();
for field in ["future_section", "draft_v2", "future_note"] {
assert!(report.not_understood.iter().any(|what| what.contains(field)), "{field}: {:?}", report.not_understood);
}
let again = saved(&opened);
assert_eq!(again["future_section"], file["future_section"]);
assert_eq!(again["features"][1]["draft_v2"], file["features"][1]["draft_v2"]);
assert_eq!(again["params"][0]["future_note"], "keep me");
// They follow their records when the design changes around them.
let mut changed = opened.clone();
let id = changed.features[1].id;
changed.features.insert(0, changed.features[0].clone());
changed.features[0].id = 777;
assert_eq!(changed.feature(id).unwrap().name, "Pad");
let again = saved(&changed);
assert_eq!(again["features"][2]["draft_v2"], file["features"][1]["draft_v2"]);
assert!(crate::newer_file::not_kept(&changed).is_empty());
}
#[test]
fn a_section_that_doesnt_read_is_left_out_named_and_kept() {
let mut file = saved(&design());
file["format"] = json!(newer());
file["configs"] = json!({"columns": [{"unknown_future_column": "Body"}], "rules": {"unknown_future_rule": true}});
let opened = Document::from_json(&file.to_string()).unwrap();
assert_eq!(built(&opened).0.len(), 1);
let report = opened.newer_file.as_ref().unwrap();
assert!(report.not_understood.iter().any(|what| what.contains("configs")), "{:?}", report.not_understood);
assert_eq!(saved(&opened)["configs"], file["configs"]);
assert!(crate::newer_file::not_kept(&opened).is_empty());
}
#[test]
fn a_parameter_that_doesnt_read_is_left_out_named_and_kept() {
let mut file = saved(&design());
file["format"] = json!(newer());
file["params"].as_array_mut().unwrap().push(json!({"name": "future", "expression": {"kind": "table"}}));
let opened = Document::from_json(&file.to_string()).unwrap();
assert_eq!(built(&opened).0.len(), 1);
assert!(opened.newer_file.as_ref().unwrap().not_understood.iter().any(|what| what.contains("`future`")));
let names: Vec<_> = saved(&opened)["params"].as_array().unwrap().iter().map(|p| p["name"].clone()).collect();
assert!(names.contains(&json!("future")) && names.contains(&json!("depth")), "{names:?}");
}
#[test]
fn what_cant_go_back_after_an_edit_is_said() {
let mut file = saved(&design());
file["format"] = json!(newer());
file["configs"] = json!({"columns": [{"unknown_future_column": "Body"}]});
let opened = Document::from_json(&file.to_string()).unwrap();
assert!(crate::newer_file::not_kept(&opened).is_empty());
let mut edited = opened.clone();
edited.favorites.insert("depth".into());
assert!(crate::newer_file::not_kept(&edited).is_empty(), "an edit elsewhere costs nothing");
let baseline: Value = saved(&edited);
assert_eq!(baseline["configs"], file["configs"]);
}
#[test]
fn a_feature_this_version_reads_wrongly_is_kept_whole() {
// A kind this build has, in a shape it doesn't: the feature can't read, and is not guessed at.
let mut file = saved(&design());
file["format"] = json!(newer());
file["features"][1]["operation"] = json!("weave");
let opened = Document::from_json(&file.to_string()).unwrap();
assert!(matches!(&opened.features[1].kind, FeatureKind::NewerFileFeature { raw } if raw.kind() == "extrude"));
assert!(built(&opened).1[1].as_deref().unwrap().contains("newer SolidCraft"));
assert_eq!(saved(&opened)["features"][1], file["features"][1]);
}
#[test]
fn what_truly_cant_be_read_says_why() {
for (text, says) in [
("not json", "HDR resource admission"),
("{\"format\":\"solidcraft/0\",\"name\":\"x\"}", "format 0"),
("{\"format\":\"someone-else/9\"}", "not a SolidCraft design (format `someone-else/9`)"),
] {
let why = Document::from_json(text).unwrap_err().to_string();
assert!(why.contains(says), "{text}: {why}");
}
// Two features of one id are damage whatever build saved them.
let mut file = saved(&design());
file["format"] = json!(newer());
let same = file["features"][0].clone();
file["features"].as_array_mut().unwrap().push(same);
let why = Document::from_json(&file.to_string()).unwrap_err().to_string();
assert!(why.contains(&format!("format {}", FORMAT + 1)) && why.contains("can't read it") && why.contains("damaged"), "{why}");
}
#[test]
fn a_file_this_build_fully_knows_is_not_flagged_and_damage_is_still_refused() {
assert!(Document::from_json(&design().to_json()).unwrap().newer_file.is_none());
assert_eq!(DESIGN.newer(FORMAT, None), None);
let mut file = saved(&design());
file["features"][1]["type"] = json!("gyroid_lattice");
assert!(Document::from_json(&file.to_string()).is_err(), "of this build's own format, that is damage");
}
-1
View File
@@ -123,7 +123,6 @@ pub(super) fn local_geometry(f: &Feature, components: &std::collections::HashMap
/// Assembly records do not feed any of the feature kinds accepted by `local_geometry`.
fn same_geometry_inputs(a: &Document, b: &Document) -> bool {
let Document {
newer_file: _,
render,
format,
format_pending,
-1
View File
@@ -42,7 +42,6 @@ impl Walk for i8 {}
impl Walk for f64 {}
impl Walk for usize {}
impl Walk for u32 {}
impl Walk for crate::newer_file::RawFeature {}
impl Walk for ProfileSel {}
impl Walk for std::collections::BTreeMap<String, String> {}
impl Walk for crate::frame::Anchor {}
+1 -1
View File
@@ -60,7 +60,7 @@ mod occurrence_tests;
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("component.cycle_color", "Cycle Component Color", cycle_color)
.icon("colored_components").doc_only()
.params("component: id or name; saves the next identification colour and enables Component Colors; shared by every instance, separate from appearances"),
.params("component: id or name; saves the next identification colour and turns Component Colors on; shared by every instance, separate from appearances"),
CommandSpec::new("component.hide_history", "Hide Component History", hide_history)
.icon("eye").doc_only()
.params("component: id or name; keeps the component's features in the design but leaves them out of the History bar (#10393)"),
@@ -4,6 +4,136 @@ fn run(s: &mut Session, command: &str, params: Value) -> Value {
s.execute(command, &params).unwrap()
}
fn transferred_profile(revolve: bool) -> (Session, u64, u64, solidcraft_kernel::Body) {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane":if revolve { "XZ" } else { "XY" },"name":"Profile"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[20,0],"p1":[30,5]}));
run(&mut s, "sketch.finish", json!({}));
if revolve {
run(&mut s, "solid.revolve", json!({"axis":"y","angle":"360 deg","body_name":"Part"}));
} else {
run(&mut s, "solid.extrude", json!({"distance":10,"body_name":"Part"}));
}
let original = s.world_state().unwrap().body("Part").unwrap().body.clone();
let created = run(&mut s, "component.from_bodies", json!({"bodies":["Part"]}));
let component = created["components"][0]["component"].as_u64().unwrap();
let occurrence = s.doc.occurrences.iter().find(|o| o.component == component).unwrap().id;
run(&mut s, "component.move_sketches", json!({"sketches":["Profile"],"component":component}));
run(&mut s, "occurrence.ground", json!({"occurrence":occurrence,"grounded":false}));
(s, component, occurrence, original)
}
fn check_captured_profile_transfer(revolve: bool) {
let (mut s, _, occurrence, original) = transferred_profile(revolve);
let before = s.doc.to_json();
let matrix = solidcraft_doc::rigid(Vec3::new(40.0, 0.0, 0.0), Vec3::ZERO, Vec3::X, std::f64::consts::FRAC_PI_4);
let expected = solidcraft_kernel::transform_matrix(&original, matrix).unwrap();
let expected_bounds = expected.exact_bounds().unwrap();
let assert_pose = |s: &Session| {
let state = s.world_state().unwrap();
let body = &state.body("Part").unwrap().body;
let bounds = body.exact_bounds().unwrap();
assert!(bounds.min.dist(expected_bounds.min) < 0.002, "minimum: {bounds:?}, expected {expected_bounds:?}");
assert!(bounds.max.dist(expected_bounds.max) < 0.002, "maximum: {bounds:?}, expected {expected_bounds:?}");
let measured = solidcraft_kernel::measure(body).unwrap();
let expected_measure = solidcraft_kernel::measure(&expected).unwrap();
assert!((measured.volume - expected_measure.volume).abs() < 1e-5);
assert!(measured.centroid.dist(expected_measure.centroid) < 1e-5);
};
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[40,0,0],"axis":[1,0,0],"angle":"45 deg","capture":false}));
assert_pose(&s);
let undo = s.undo.len();
run(&mut s, "component.capture_position", json!({}));
assert_eq!(s.undo.len(), undo + 1);
assert!(s.pending_moves.is_empty());
assert_pose(&s);
let mut reopened = Session { doc: solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() };
reopened.refresh();
assert_pose(&reopened);
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.to_json(), before);
run(&mut s, "edit.redo", json!({}));
assert_pose(&s);
}
#[test]
fn capture_transferred_extrusion_with_its_source_sketch_moves_once() {
check_captured_profile_transfer(false);
}
#[test]
fn capture_transferred_revolve_with_its_source_sketch_moves_once() {
check_captured_profile_transfer(true);
}
#[test]
fn transferred_profile_frame_follows_transfer_edits_suppression_and_rollback() {
let (mut s, component, occurrence, _) = transferred_profile(false);
let creator = s.model.state().body("Part").unwrap().feature;
let transfer = s.doc.features.iter().find(|f| matches!(f.kind, FeatureKind::BodyTransfer { .. })).unwrap().id;
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[40,0,0]}));
let assert_x = |s: &Session, local: f64, owner: u64| {
let source = s.model.result(creator).unwrap().state.body("Part").unwrap().body.exact_bounds().unwrap();
assert!((source.min.x - local).abs() < 1e-6, "source frame: {source:?}");
let state = s.world_state().unwrap();
let body = state.body("Part").unwrap();
assert_eq!(body.component, owner);
assert!((body.body.exact_bounds().unwrap().min.x - 60.0).abs() < 1e-6);
};
assert_x(&s, 20.0, component);
s.doc_mut().feature_mut(transfer).unwrap().suppressed = true;
s.refresh();
assert_x(&s, 60.0, 0);
s.doc_mut().feature_mut(transfer).unwrap().suppressed = false;
s.refresh();
assert_x(&s, 20.0, component);
let boundary = s.doc.feature_index(transfer);
s.doc_mut().marker = boundary;
s.refresh();
assert_x(&s, 60.0, 0);
s.doc_mut().marker = None;
s.refresh();
assert_x(&s, 20.0, component);
if let FeatureKind::BodyTransfer { transform, .. } = &mut s.doc_mut().feature_mut(transfer).unwrap().kind {
*transform = solidcraft_doc::rigid(Vec3::new(-7.0, 0.0, 0.0), Vec3::ZERO, Vec3::Z, 0.0);
}
s.refresh();
assert_x(&s, 27.0, component);
let mut reopened = Session { doc: solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() };
reopened.refresh();
assert_x(&reopened, 27.0, component);
}
#[test]
fn capturing_one_transferred_profile_instance_keeps_its_peer_in_place() {
let (mut s, _, occurrence, _) = transferred_profile(false);
run(&mut s, "occurrence.copy", json!({"occurrence":occurrence,"translate":[100,0,0]}));
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[40,0,0],"capture":false}));
run(&mut s, "component.capture_position", json!({}));
let state = s.world_state().unwrap();
let mut xs: Vec<f64> = state.bodies.iter().map(|body| body.body.exact_bounds().unwrap().min.x).collect();
xs.sort_by(f64::total_cmp);
assert_eq!(xs.len(), 2);
assert!((xs[0] - 60.0).abs() < 1e-6 && (xs[1] - 120.0).abs() < 1e-6, "{xs:?}");
}
#[test]
fn a_profile_without_a_body_transfer_keeps_its_live_component_frame() {
let mut s = Session::default();
let created = run(&mut s, "component.create", json!({"name":"Source","grounded":false}));
let occurrence = created["occurrence"].as_u64().unwrap();
run(&mut s, "sketch.create", json!({"plane":"XY","name":"Profile"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[10,20]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "component.activate", json!({"component":0}));
run(&mut s, "solid.extrude", json!({"sketch":"Profile","distance":5,"body_name":"Part"}));
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[40,0,0]}));
let state = s.world_state().unwrap();
let body = state.body("Part").unwrap();
assert_eq!(body.component, 0);
assert!((body.body.exact_bounds().unwrap().min.x - 40.0).abs() < 1e-6);
}
#[test]
fn recorded_assignment_preserves_sample_joined_material_and_timeline() {
let mut s = Session::default();
+57 -5
View File
@@ -744,6 +744,8 @@ fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) ->
names
} else if pts.is_empty() {
Vec::new()
} else if matches!(kind, FeatureKind::Hole { .. }) {
hole_face_names(s, p, &kind)?
} else {
solidcraft_doc::face_point_names(&s.doc, &s.model.state(), &kind, s.active_component)?
}
@@ -1082,6 +1084,22 @@ fn face_body_reached(s: &Session, sketch: u64) -> Result<Option<String>> {
Ok(Some(body.name.clone()))
}
/// A hole's face points, named on the bodies it drills where one of theirs holds the point
/// (#7520): a face of another body in the same plane, a hidden one the hole leaves alone, would
/// otherwise take the name of the face that was picked. Over every body, as before, elsewhere.
fn hole_face_names(s: &Session, p: &Value, kind: &FeatureKind) -> Result<Vec<String>> {
let st = s.model.state();
let all = solidcraft_doc::face_point_names(&s.doc, &st, kind, s.active_component)?;
let drilled = string_list_or_default(p, "participants");
if drilled.is_empty() || drilled.iter().any(|b| b == "*") {
return Ok(all);
}
let mut scoped = solidcraft_doc::ModelState::default();
scoped.bodies = st.bodies.iter().filter(|b| drilled.contains(&b.name)).cloned().collect();
let own = solidcraft_doc::face_point_names(&s.doc, &scoped, kind, s.active_component)?;
Ok(own.into_iter().zip(all).map(|(own, any)| if own.is_empty() { any } else { own }).collect())
}
/// The body whose face `sketch` is attached to (on the face itself, not offset from it), when
/// it is one the active component works on ([`active_scope`]).
fn sketch_face_body(s: &Session, sketch: u64) -> Option<String> {
@@ -2915,6 +2933,20 @@ fn hole(s: &mut Session, p: &Value) -> Result<Value> {
// placed) cuts that body: the pick is already in that body's frame, and the
// component's own bodies alone would leave nothing to cut.
p["participants"] = json!([body]);
} else if let Some(mut shown) = shown_scope(s) {
// Like a cut (#3961), a hole leaves hidden bodies alone (#7520): while some are
// hidden, it drills the shown ones, stored so showing the others later changes
// nothing. The body whose face was picked is drilled though hidden (picked through
// Select Other, or hidden with the dialog open): it is the one the hole is for.
if !shown.is_empty()
&& points.is_none()
&& let Some(body) = hidden_body_picked_at(s, position, &shown)
{
shown.push(body);
}
if !shown.is_empty() {
p["participants"] = json!(shown);
}
}
}
add_feature(
@@ -3000,16 +3032,36 @@ fn hole_reference_params(s: &Session, p: &Value, cmd: &str) -> Result<Vec<solidc
/// component than the active one.
fn body_of_other_component_at(s: &Session, p: Vec3) -> Option<String> {
let st = s.model.state();
let (_, body) = st
.bodies
let body = body_with_face_at(&st, p, |_| true)?;
(s.doc.model_body_component(body) != s.active_component).then(|| body.name.clone())
}
/// The hidden body whose face holds `p`, when no shown body has a face there: a face shared by
/// a shown and a hidden body is the shown one's, as a pick in the view would take it (#7520).
fn hidden_body_picked_at(s: &Session, p: Vec3, shown: &[String]) -> Option<String> {
let st = s.model.state();
if body_with_face_at(&st, p, |b| shown.contains(&b.name)).is_some() {
return None;
}
let scope: Vec<&str> = active_scope(s, &st).into_iter().map(|b| b.name.as_str()).collect();
body_with_face_at(&st, p, |b| scope.contains(&b.name.as_str())).map(|b| b.name.clone())
}
/// The body, among those `keep` takes, with the face nearest `p` within its pick tolerance.
fn body_with_face_at(
st: &solidcraft_doc::ModelState,
p: Vec3,
keep: impl Fn(&solidcraft_doc::ModelBody) -> bool,
) -> Option<&solidcraft_doc::ModelBody> {
st.bodies
.iter()
.filter(|b| !b.body.is_mesh())
.filter(|b| !b.body.is_mesh() && keep(b))
.filter_map(|b| {
let (d, _) = solidcraft_doc::appearance::nearest_face(b, p)?;
(d <= solidcraft_doc::appearance::pick_tolerance(b)).then_some((d, b))
})
.min_by(|a, b| a.0.total_cmp(&b.0))?;
(s.doc.model_body_component(body) != s.active_component).then(|| body.name.clone())
.min_by(|a, b| a.0.total_cmp(&b.0))
.map(|(_, b)| b)
}
/// The points of a sketch a hole drills at when only the sketch is given.
+2 -75
View File
@@ -31,7 +31,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("open")
.key("Ctrl+O")
.noundo()
.params("path: .sc design (or a .solidcraft design from an earlier release), .dxf/.svg drawing (opens as a new, unsaved design with an XY sketch, using sketch.insert_dxf/sketch.insert_svg units and defaults), or a .step/.stp/.igs/.fcstd/.3dm/.3mf/.stl/.obj/.glb/.gltf file (STEP, IGES, FreeCAD, Rhino, 3MF, STL, OBJ or glTF; opens as a new design holding the file as a component named after it, as file.insert_step and file.insert_mesh make), or a .ply/.xyz/.pts point cloud (opens as a new design holding it, as pointcloud.insert makes; its `units` and `max_points` as there); up?: z|y for a STEP, IGES, FreeCAD, Rhino or glTF file (see file.insert_step and file.insert_mesh); names?: auto|solid|product|instance for a STEP, IGES, FreeCAD or Rhino file (see file.insert_step); read_only?: bool (a design: saving to its own file is refused, file.save_as keeps the changes elsewhere) → open_elsewhere: {host, user, app, opened} when another SolidCraft has the design open (its lock file is fresh); newer_file: {format, reads_up_to, unknown_changes, not_understood, notice} and warnings when a newer SolidCraft saved it: it opens all the same, and what this version can't read is named there and kept by saving"),
.params("path: .sc design (or a .solidcraft design from an earlier release), .dxf/.svg drawing (opens as a new, unsaved design with an XY sketch, using sketch.insert_dxf/sketch.insert_svg units and defaults), or a .step/.stp/.igs/.fcstd/.3dm/.3mf/.stl/.obj/.glb/.gltf file (STEP, IGES, FreeCAD, Rhino, 3MF, STL, OBJ or glTF; opens as a new design holding the file as a component named after it, as file.insert_step and file.insert_mesh make), or a .ply/.xyz/.pts point cloud (opens as a new design holding it, as pointcloud.insert makes; its `units` and `max_points` as there); up?: z|y for a STEP, IGES, FreeCAD, Rhino or glTF file (see file.insert_step and file.insert_mesh); names?: auto|solid|product|instance for a STEP, IGES, FreeCAD or Rhino file (see file.insert_step); read_only?: bool (a design: saving to its own file is refused, file.save_as keeps the changes elsewhere) → open_elsewhere: {host, user, app, opened} when another SolidCraft has the design open (its lock file is fresh)"),
CommandSpec::new("file.insert_step", "Insert STEP", insert_step)
.at("SOLID", "INSERT")
.icon("import")
@@ -427,11 +427,6 @@ fn opened_design(
if read_only {
out["read_only"] = json!(true);
}
// A newer SolidCraft saved it: opened all the same, and the reply says so.
if let Some(file) = &s.doc.newer_file {
out["newer_file"] = file.report();
out["warnings"] = json!(file.warnings());
}
let now = solidcraft_io::design_lock::now_secs();
if let Some(lock) = solidcraft_io::design_lock::held_elsewhere(std::path::Path::new(path), now) {
out["open_elsewhere"] = json!({"host": lock.host, "user": lock.user, "app": lock.app, "opened": lock.opened});
@@ -578,21 +573,7 @@ fn write_bytes(
s.disk = solidcraft_io::design_lock::stamp(path);
s.read_only = false;
s.mark_saved();
let mut out = json!({"path": path, "bytes": bytes.len(), "name": s.doc.name});
// A newer SolidCraft's file keeps what this version can't read, and its format number. What
// couldn't go back, because the design was changed around it, is said.
if s.doc.newer_file.is_some() {
let mut lost = solidcraft_doc::newer_file::not_kept(&s.doc);
if format == "json" && s.doc.newer_file.as_ref().is_some_and(|file| file.needs_container()) {
lost.push("the parts of the file beside the design, which plain JSON has no room for: save as .sc to keep them".to_string());
}
out["kept_format"] = json!(s.doc.format);
if !lost.is_empty() {
out["warnings"] = json!(lost.iter().map(|what| format!("Not kept: {what}. The previous file is `{path}.bak`.")).collect::<Vec<_>>());
out["not_kept"] = json!(lost);
}
}
Ok(out)
Ok(json!({"path": path, "bytes": bytes.len(), "name": s.doc.name}))
}
impl Session {
@@ -1411,60 +1392,6 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// A design a newer SolidCraft saved opens through the command agents and the app use: the
/// reply warns, what it can't read shows as that feature's error, work goes on around it, and
/// saving keeps the feature and the file's number in both encodings.
#[test]
fn a_newer_design_opens_and_saving_keeps_what_it_cant_read() {
let dir = std::env::temp_dir().join(format!("sc_newer_design_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let mut s = Session::default();
s.execute("solid.box", &json!({"length": 2, "width": 3, "height": 4})).unwrap();
let mut file: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
let newer = solidcraft_doc::format::format_string(solidcraft_doc::format::FORMAT + 1);
file["format"] = json!(newer);
let future = json!({"id": 900, "name": "Lattice1", "type": "gyroid_lattice", "cell": 2.5});
file["features"].as_array_mut().unwrap().push(future.clone());
file["future_section"] = json!([1, 2]);
for name in ["newer.json", "newer.sc"] {
let path = dir.join(name);
let source = solidcraft_doc::Document::from_json(&file.to_string()).unwrap();
let bytes = if name.ends_with(".sc") { solidcraft_io::write_design_container(&source).unwrap() } else { file.to_string().into_bytes() };
std::fs::write(&path, bytes).unwrap();
let path = path.to_string_lossy().to_string();
let mut opened = Session::default();
let reply = opened.execute("doc.open", &json!({"path": path})).unwrap();
assert_eq!(reply["errors"], 1, "{reply}");
assert_eq!(reply["features"], 2, "{reply}");
assert!(reply["newer_file"]["not_understood"].to_string().contains("gyroid_lattice"), "{reply}");
let first = reply["warnings"][0].as_str().unwrap();
assert_eq!(
first,
format!(
"Saved by a newer SolidCraft (format {}, this version reads up to {}). If something's missing, update.",
solidcraft_doc::format::FORMAT + 1,
solidcraft_doc::format::FORMAT
)
);
assert_eq!(opened.model.state().bodies.len(), 1, "the box still builds");
let lattice = opened.doc.features.iter().position(|f| f.name == "Lattice1").unwrap();
assert!(opened.model.results[lattice].error.as_deref().unwrap().contains("gyroid_lattice"));
opened.execute("solid.box", &json!({"length": 1, "width": 1, "height": 1})).unwrap();
let saved = opened.execute("file.save", &json!({})).unwrap();
assert_eq!(saved["kept_format"], json!(newer), "{saved}");
assert!(saved.get("not_kept").is_none(), "{saved}");
let mut again = Session::default();
again.execute("doc.open", &json!({"path": path})).unwrap();
assert_eq!(again.doc.features.len(), 3);
assert_eq!(again.model.state().bodies.len(), 2);
let written: Value = serde_json::from_str(&again.doc.to_json()).unwrap();
assert_eq!(written["format"], json!(newer));
assert_eq!(written["future_section"], json!([1, 2]));
assert!(written["features"].as_array().unwrap().contains(&future));
}
let _ = std::fs::remove_dir_all(&dir);
}
/// #5467: a script, the CLI or the control channel never gets a prompt. A Save over a file
/// another program or computer changed since it was opened is refused with an error naming
/// `overwrite`, which replaces it; a design opened read-only only saves to another file.
+1 -1
View File
@@ -27,7 +27,7 @@ mod appearance_chain;
pub(super) mod appearance_gestures;
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("inspect.colored_components", "Enable Component Colors", colored_components)
CommandSpec::new("inspect.colored_components", "Component Colors", colored_components)
.at("SOLID", "INSPECT").icon("colored_components").noundo()
.params("enabled?: bool (omitted: toggle); temporary component colours, without changing appearances"),
CommandSpec::new("inspect.measure", "Measure", measure)
+139
View File
@@ -0,0 +1,139 @@
//! What a face measures on the surface behind it (#9086).
//!
//! A curved face's area is the area of the surface it lies on: a cylinder's band is
//! r·θ·h and a whole sphere 4πr². The display mesh's triangles run inside a curve, so
//! their sum falls short of it.
use std::f64::consts::PI;
use solidcore_record::Vec3;
use solidcore_record::brep::Curve;
use solidcraft_geom::prelude::*;
use solidcraft_geom::{Mesh, UnitVec3};
use solidcraft_kernel::{Body, FaceSurface};
/// The area of the patch of faces `patch` of `body`, worked out on the surface behind
/// them: a cylinder's band, or a sphere. `None` where the surface does not give one, and
/// the caller keeps the measure of the mesh.
pub(crate) fn patch_area(body: &Body, mesh: &Mesh, patch: &[usize]) -> Option<f64> {
match solidcraft_kernel::face_surface(body, *patch.first()?)? {
FaceSurface::Cylinder { origin, axis, radius } => cylinder_band(body, mesh, patch, origin, axis, radius),
FaceSurface::Sphere { radius, .. } => sphere(body, patch, radius),
_ => None,
}
}
/// The edges on the border of the patch: the ones a face of the patch shares with a face
/// outside it, and the ones it keeps to itself.
fn border_edges(mesh: &Mesh, patch: &[usize]) -> Vec<usize> {
mesh.edge_faces
.iter()
.enumerate()
.filter(|(_, faces)| {
let inside = faces.iter().filter(|f| patch.contains(&(**f as usize))).count();
inside > 0 && (faces.len() == 1 || inside < faces.len())
})
.map(|(edge, _)| edge)
.collect()
}
/// Whether edge `edge` is a straight line parallel to `axis`.
fn straight_along(body: &Body, edge: usize, axis: UnitVec3) -> bool {
let Some(e) = body.record().edges.get(edge) else { return false };
if !matches!(e.curve, Curve::Line { .. }) {
return false;
}
let Ok(c) = solidcraft_geom::brep::eval::CurveEval::new(e.parameters()) else { return false };
let [t0, t1] = e.interval;
let (Some(a), Some(b)) = (c.point(t0), c.point(t1)) else { return false };
(b - a).normalized().is_some_and(|d| d.dot(axis.vec()).abs() >= 1.0 - 1e-9)
}
/// The area of a cylindrical band: the radius of the surface, the turn the band covers
/// and the distance between its two rims.
fn cylinder_band(body: &Body, mesh: &Mesh, patch: &[usize], origin: Vec3, axis: UnitVec3, radius: f64) -> Option<f64> {
if !(radius.is_finite() && radius > 0.0) {
return None;
}
let tol = radius * 1e-6;
// The whole patch lies on one cylinder.
for f in patch {
match solidcraft_kernel::face_surface(body, *f)? {
FaceSurface::Cylinder { origin: o, axis: a, radius: r } => {
if (r - radius).abs() > tol || o.dist(origin) > tol || a.vec().dot(axis.vec()).abs() < 1.0 - 1e-9 {
return None;
}
}
_ => return None,
}
}
// The rims of the band: the circles that bound it, each across the axis.
let mut rims: Vec<(f64, f64)> = Vec::new();
for edge in border_edges(mesh, patch) {
// An edge that is not a circle but a straight line along the axis (a seam, a straight
// cut along the band) leaves the turn of the band to its rims alone. Any other edge
// (the saddle rim of a bore through the wall) cuts the band: not ours to measure.
let Some(circle) = solidcraft_kernel::edge_circle(body, edge) else {
if straight_along(body, edge, axis) {
continue;
}
return None;
};
if circle.axis.vec().dot(axis.vec()).abs() < 1.0 - 1e-9 || (circle.radius - radius).abs() > tol {
return None;
}
let d = circle.center - origin;
let height = d.dot(axis.vec());
if (d - axis.vec() * height).len() > tol {
return None;
}
rims.push((height, circle.angle));
}
// The two rims: the band runs between them, and both cover the same turn.
let mut levels: Vec<(f64, f64)> = Vec::new();
for (height, angle) in rims {
match levels.iter_mut().find(|(h, _)| (*h - height).abs() <= tol) {
Some((_, a)) => *a += angle,
None => levels.push((height, angle)),
}
}
if levels.len() != 2 {
return None;
}
levels.sort_by(|a, b| a.0.total_cmp(&b.0));
let (low, low_turn) = levels[0];
let (high, high_turn) = levels[1];
let height = high - low;
if !(height > 0.0) || (low_turn - high_turn).abs() > low_turn.max(high_turn) * 1e-6 {
return None;
}
Some(radius * low_turn * height)
}
/// The area of a sphere: 4πr², when the body is the whole of it.
fn sphere(body: &Body, patch: &[usize], radius: f64) -> Option<f64> {
if !(radius.is_finite() && radius > 0.0) {
return None;
}
// The whole patch lies on one sphere.
for f in patch {
match solidcraft_kernel::face_surface(body, *f)? {
FaceSurface::Sphere { radius: r, .. } if (r - radius).abs() <= radius * 1e-6 => {}
_ => return None,
}
}
// The body is the whole sphere: every face of it lies on that sphere and the patch
// holds them all. A body with a face of its own (a sphere that was cut) is a piece of
// the sphere, whose area this does not work out: the measure of the mesh stands in
// for it.
for f in 0..body.record().faces.len() {
match solidcraft_kernel::face_surface(body, f)? {
FaceSurface::Sphere { radius: r, .. } if (r - radius).abs() <= radius * 1e-6 => {}
_ => return None,
}
if !patch.contains(&f) {
return None;
}
}
Some(4.0 * PI * radius * radius)
}
+4 -1
View File
@@ -350,7 +350,10 @@ fn describe(s: &Session, st: &solidcraft_doc::ModelState, sel: &Sel) -> Option<I
let m = b.mesh();
let patch = m.surface_patch(*index);
let tris = patch.iter().flat_map(|f| face_tris(&m, *f)).collect::<Vec<_>>();
let area: f64 = tris.iter().map(|[a, b, c]| (*b - *a).cross(*c - *a).len() * 0.5).sum();
let on_mesh: f64 = tris.iter().map(|[a, b, c]| (*b - *a).cross(*c - *a).len() * 0.5).sum();
// A curved face is measured on the surface it lies on, not on the triangles of
// the display mesh, which run inside the curve.
let area = super::measure_exact::patch_area(&b.body, &m, &patch).unwrap_or(on_mesh);
let normals: Vec<UnitVec3> = tris.iter().filter_map(|[a, b, c]| (*b - *a).cross(*c - *a).unit()).collect();
let planar = normals.first().copied().filter(|n0| normals.iter().all(|n| n.dot(*n0) > 1.0 - 1e-6));
let mut json = json!({"type": "face", "area_mm2": area, "planar": planar.is_some()});
+3 -6
View File
@@ -74,6 +74,7 @@ mod libraries;
pub mod mass_properties;
mod material_properties;
mod materials;
mod measure_exact;
mod measure_sel;
mod measure_shape;
mod mesh;
@@ -261,16 +262,12 @@ impl CommandSpec {
}
pub fn info(&self, s: &Session) -> CommandInfo {
let e = (self.enabled)(s);
let (label, icon) = match self.id {
"inspect.colored_components" if s.colored_components.is_some() => ("Disable Component Colors", "colored_components_off"),
_ => (self.label, self.icon),
};
CommandInfo {
id: self.id,
label,
label: self.label,
tab: self.tab,
panel: self.panel,
icon,
icon: self.icon,
shortcut: self.shortcut,
params: self.params,
enabled: e.is_ok(),
@@ -61,10 +61,7 @@ fn format20_scalar_looks_upgrade_and_format21_setups_keep_their_meaning() {
assert_eq!(d.render.setups["Studio"].width, 320);
for version in [crate::doc::format::FORMAT + 1, crate::doc::format::FORMAT + 2] {
saved["format"] = json!(format!("solidcraft/{version}"));
// A newer build's file opens, says so, and keeps its number.
let newer = crate::doc::Document::from_json(&saved.to_string()).unwrap();
assert_eq!(newer.newer_file.as_ref().unwrap().format, version);
assert_eq!(newer.format, format!("solidcraft/{version}"));
assert!(crate::doc::Document::from_json(&saved.to_string()).is_err());
}
}
+4
View File
@@ -33,6 +33,7 @@ impl Effect {
const READ_METHODS: &[&str] = &[
"control.pending",
"control.subscribe",
"document.export",
"document.inspect",
"document.revision",
"engine.commands",
@@ -119,6 +120,7 @@ mod tests {
for (m, p) in [
("ui.inspect", json!({})),
("ui.screenshot", json!({})),
("document.export", json!({})),
("document.inspect", json!({"measure": true})),
("engine.execute", json!({"command": "sketch.inspect"})),
("engine.execute", json!({"command": "view.status_items", "params": {}})),
@@ -135,6 +137,8 @@ mod tests {
("engine.batch", json!({"commands": []})),
("ui.click", json!({"x": 1, "y": 2})),
("app.quit", json!({})),
("document.import", json!({"text": "{}"})),
("document.saved", json!({"revision": 1})),
] {
assert!(!reads_only(m, &p), "{m} {p}");
assert_eq!(effect(m, &p), Effect::Changes, "{m} {p}");
+2 -1
View File
@@ -2366,6 +2366,8 @@ mod large_assembly_tests;
#[cfg(test)]
mod measure_centre_tests;
#[cfg(test)]
mod measure_surface_tests;
#[cfg(test)]
mod naming_history_tests;
#[cfg(test)]
mod pattern_suppress_tests;
@@ -2429,7 +2431,6 @@ fn image_host_availability(host: Option<&render::job::ImageHost>, expected: &ren
/// to be decided on here.
fn same_but_features(a: &Document, b: &Document) -> bool {
let Document {
newer_file: _,
render,
format,
format_pending,
+147
View File
@@ -0,0 +1,147 @@
//! Measure on a body's curved faces: the area comes from the exact surface, not from the
//! display mesh's triangles, which run inside a curve (#9086).
#![allow(clippy::unwrap_used)]
use std::f64::consts::TAU;
use serde_json::{Value, json};
use solidcore_record::Vec3;
use crate::Session;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
Err(e) => panic!("{id} {p}: {e}"),
}
}
fn close(v: &Value, want: f64, tol: f64) -> bool {
v.as_f64().is_some_and(|x| (x - want).abs() <= tol)
}
/// The first face whose analytic surface satisfies `want`, as a pick.
fn face_of(s: &Session, want: impl Fn(solidcraft_kernel::FaceSurface) -> bool) -> Value {
let st = s.model.state();
let b = st.bodies.first().unwrap();
let m = b.mesh();
let n = m.tri_face.iter().map(|f| *f as usize + 1).max().unwrap();
let f = (0..n).find(|&f| solidcraft_kernel::face_surface(&b.body, f).is_some_and(&want)).expect("face");
let p = m.triangles.iter().zip(&m.tri_face).find(|(_, tf)| **tf as usize == f).and_then(|(t, _)| m.tri(t)).unwrap()[0];
json!({"type": "face", "body": b.name, "index": f, "point": p})
}
fn measure(s: &mut Session, items: Vec<Value>) -> Value {
run(s, "inspect.measure", json!({ "items": items }))
}
/// A cylinder's side face is as big as its exact lateral area, 2πrh, in the same reply that
/// gives its exact radius and diameter: the area came from the display mesh's triangles, which
/// run inside the curve (#9086, the report's own cylinder).
#[test]
fn a_cylinders_side_face_is_as_big_as_its_exact_lateral_area() {
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"radius": 5, "height": 20, "base": [40, 10, 0], "operation": "new"}));
let side = face_of(&s, |f| matches!(f, solidcraft_kernel::FaceSurface::Cylinder { radius, .. } if (radius - 5.0).abs() < 1e-6));
let r = measure(&mut s, vec![side]);
let item = &r["items"][0];
assert!(close(&item["diameter"], 10.0, 1e-9), "the same reply gives the exact diameter: {r}");
assert!(close(&item["area_mm2"], TAU * 5.0 * 20.0, 1e-6), "the side face's area is its exact 2πrh: {r}");
}
/// A hole's wall is as big as its exact lateral area, 2πrh: the area came from the display
/// mesh's triangles, which run inside the curve (#9086).
#[test]
fn a_holes_wall_is_as_big_as_its_exact_lateral_area() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 60, "width": 40, "height": 10}));
run(&mut s, "solid.cylinder", json!({"radius": 3, "height": 12, "base": [15, 20, -1], "operation": "cut"}));
let wall = face_of(&s, |f| matches!(f, solidcraft_kernel::FaceSurface::Cylinder { radius, .. } if (radius - 3.0).abs() < 1e-6));
let r = measure(&mut s, vec![wall]);
let item = &r["items"][0];
assert!(close(&item["diameter"], 6.0, 1e-9), "the same reply gives the exact diameter: {r}");
assert!(close(&item["area_mm2"], TAU * 3.0 * 10.0, 1e-6), "the hole's wall is its exact 2πrh: {r}");
}
/// A sphere's area is its exact 4πr², not the area of the display mesh's triangles (#9086).
#[test]
fn a_sphere_is_as_big_as_its_exact_surface() {
let mut s = Session::default();
run(&mut s, "solid.sphere", json!({"radius": 10, "base": [0, 0, 0], "operation": "new"}));
let ball = face_of(&s, |f| matches!(f, solidcraft_kernel::FaceSurface::Sphere { radius, .. } if (radius - 10.0).abs() < 1e-6));
let r = measure(&mut s, vec![ball]);
let item = &r["items"][0];
assert!(close(&item["diameter"], 20.0, 1e-9), "the same reply gives the exact diameter: {r}");
assert!(close(&item["area_mm2"], 4.0 * std::f64::consts::PI * 100.0, 1e-4), "the sphere's area is its exact 4πr²: {r}");
}
/// A planar face's area is unchanged: it is exact on the mesh already.
///
/// The face picked is the box's side at x = 40 (30 × 10), which the mesh measures exactly.
#[test]
fn a_flat_face_keeps_its_area() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10}));
let st = s.model.state();
let b = st.bodies.first().unwrap();
let m = b.mesh();
let side = m.triangles.iter().zip(&m.tri_face).find(|(_, tf)| **tf as usize == 2).and_then(|(t, _)| m.tri(t)).unwrap()[0];
let pick = json!({"type": "face", "body": b.name, "index": 2, "point": side});
let r = measure(&mut s, vec![pick]);
assert!(close(&r["items"][0]["area_mm2"], 300.0, 1e-6), "{r}");
}
/// The body's own volume is its exact πr²h, not the display mesh's (#9086).
///
/// The volume of a body is still measured on a tessellation of it: the exact volume of a
/// body is measured on its surfaces, which is a follow-up to the faces' own areas.
#[test]
#[ignore = "the body's volume still comes from a tessellation: the exact volume of a body is a follow-up (#9086)"]
fn a_cylinders_volume_is_its_exact_value() {
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"radius": 5, "height": 20, "base": [40, 10, 0], "operation": "new"}));
let name = s.model.state().bodies.first().unwrap().name.clone();
let r = measure(&mut s, vec![json!({"type": "body", "name": name})]);
assert!(close(&r["items"][0]["volume_mm3"], std::f64::consts::PI * 25.0 * 20.0, 1e-3), "{r}");
}
/// The body's bounding box is its exact one: the tessellation's runs inside a curve (#9086).
///
/// The box of a body is still measured on a tessellation of it: the exact box of a body is
/// measured on its surfaces, which is a follow-up to the faces' own areas.
#[test]
#[ignore = "the body's box still comes from a tessellation: the exact box of a body is a follow-up (#9086)"]
fn a_cylinders_bounding_box_is_its_exact_one() {
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"radius": 5, "height": 20, "base": [40, 10, 0], "operation": "new"}));
let r = run(&mut s, "inspect.measure", json!({}));
let bb = &r["bodies"][0]["bbox"];
assert!(close(&bb["min"][0], 35.0, 1e-6) && close(&bb["max"][0], 45.0, 1e-6), "the box's X runs from 35 to 45: {r}");
assert!(close(&bb["min"][1], 5.0, 1e-6) && close(&bb["max"][1], 15.0, 1e-6), "the box's Y runs from 5 to 15: {r}");
}
/// The report's own cylinder, measured as the report measured it (#9086).
#[test]
fn the_reports_cylinder_measures_exactly() {
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"radius": 5, "height": 20, "base": [40, 10, 0], "operation": "new"}));
let side = face_of(&s, |f| matches!(f, solidcraft_kernel::FaceSurface::Cylinder { radius, .. } if (radius - 5.0).abs() < 1e-6));
let r = measure(&mut s, vec![side]);
let area = r["items"][0]["area_mm2"].as_f64().unwrap();
let want = TAU * 5.0 * 20.0;
assert!((area - want).abs() / want < 1e-6, "the side face's area {area} against its exact {want}: {r}");
let _ = Vec3::new(40.0, 10.0, 20.0);
}
/// A bore through the cylinder's wall cuts a rim that is not a circle: the band is no longer
/// the uncut 2πrh, so the exact shortcut gives way to the mesh's measure.
#[test]
fn a_bore_through_the_wall_is_not_measured_as_the_uncut_band() {
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"radius": 5, "height": 20, "base": [0, 0, 0], "operation": "new"}));
run(&mut s, "solid.cylinder", json!({"radius": 1, "height": 14, "base": [-7, 0, 10], "axis": [1, 0, 0], "operation": "cut"}));
let side = face_of(&s, |f| matches!(f, solidcraft_kernel::FaceSurface::Cylinder { radius, .. } if (radius - 5.0).abs() < 1e-6));
let r = measure(&mut s, vec![side]);
let area = r["items"][0]["area_mm2"].as_f64().unwrap();
assert!(area < TAU * 5.0 * 20.0 - 1.0, "the bored wall's area {area} is less than the uncut {}: {r}", TAU * 5.0 * 20.0);
}
+163
View File
@@ -3432,6 +3432,169 @@ fn hole_as_deep_as_the_plate() {
}
}
/// #7520: a hole that names no bodies leaves hidden ones alone, as a cut does (#3961), and keeps
/// doing so once they are shown again. Its face is named on the body it drills, not on a hidden
/// one whose face lies in the same plane; with nothing hidden, it still drills every body.
#[test]
fn a_hole_leaves_hidden_bodies_alone() {
let setup = |hide: bool| {
let mut s = Session::default();
// Two bodies whose bottom faces share the plane z = 0, the hidden one made first.
run(&mut s, "solid.box", json!({"length": 40, "width": 40, "height": 20, "body_name": "Block"}));
run(&mut s, "solid.box", json!({"length": 40, "width": 40, "height": 5, "operation": "new", "body_name": "Plate"}));
if hide {
run(&mut s, "browser.visibility", json!({"items": ["Block"], "visible": false}));
}
// The name of the plate's face that is picked, before the hole takes the point away.
let picked =
solidcraft_doc::naming::face_name_at_body(s.world_state().unwrap().body("Plate").unwrap(), solidcore_record::Vec3::new(20.0, 20.0, 0.0));
let r = s.execute("solid.hole", &json!({"position": [20, 20, 0], "diameter": 5, "depth": 10, "name": "Hole1"})).unwrap();
(s, r, picked)
};
let vol = |s: &Session, name: &str| solidcraft_kernel::measure(&s.world_state().unwrap().body(name).unwrap().body).unwrap().volume;
let (block, plate) = (40.0 * 40.0 * 20.0, 40.0 * 40.0 * 5.0);
let (mut hidden, r, picked) = setup(true);
assert_eq!(r["changed_bodies"], json!(["Plate"]));
assert!((vol(&hidden, "Block") - block).abs() < 1e-6, "the hidden body is not drilled");
assert!(vol(&hidden, "Plate") < plate - 1.0, "the shown one is");
let hole = hidden.doc.find_feature("Hole1").unwrap();
assert!(picked.is_some());
assert_eq!((hole.participants.clone(), hole.face_names.first().cloned()), (vec!["Plate".to_string()], picked));
run(&mut hidden, "browser.visibility", json!({"items": ["Block"], "visible": true}));
assert!((vol(&hidden, "Block") - block).abs() < 1e-6, "shown again, it stays as it was");
let (shown, r, _) = setup(false);
assert_eq!(r["changed_bodies"], json!(["Block", "Plate"]), "with nothing hidden, both are drilled");
assert!(vol(&shown, "Block") < block - 1.0 && vol(&shown, "Plate") < plate - 1.0);
}
/// #7520 (review): the body whose face is picked is drilled even while it is hidden (picked
/// through Select Other, or hidden with the dialog open), and editing a hole made with nothing
/// hidden keeps drilling every body, though one is hidden at the time of the edit.
#[test]
fn a_hole_drills_its_picked_body_and_an_edit_keeps_its_scope() {
let stack = || {
let mut s = Session::default();
// Two plates stacked: Lower z 0..5, Upper z 5..10.
run(&mut s, "solid.box", json!({"length": 40, "width": 40, "height": 5, "body_name": "Lower"}));
run(&mut s, "solid.box", json!({"corner": [0, 0, 5], "length": 40, "width": 40, "height": 5, "operation": "new", "body_name": "Upper"}));
s
};
let vol = |s: &Session, name: &str| solidcraft_kernel::measure(&s.world_state().unwrap().body(name).unwrap().body).unwrap().volume;
let plate = 40.0 * 40.0 * 5.0;
let hole = |d: f64| json!({"position": [20, 20, 10], "direction": [0, 0, -1], "diameter": d, "depth": 20, "name": "Hole1"});
// The picked face is on the hidden Upper: it is drilled, and so is the shown Lower below.
let mut s = stack();
run(&mut s, "browser.visibility", json!({"items": ["Upper"], "visible": false}));
let r = s.execute("solid.hole", &hole(5.0)).unwrap();
assert_eq!(r["changed_bodies"], json!(["Lower", "Upper"]), "the picked body is drilled though hidden");
assert!(vol(&s, "Upper") < plate - 1.0 && vol(&s, "Lower") < plate - 1.0);
// Made with nothing hidden, then edited with Lower hidden: both stay drilled, at the new size.
let mut s = stack();
run(&mut s, "solid.hole", hole(5.0));
let small = vol(&s, "Lower");
assert!(small < plate - 1.0);
run(&mut s, "browser.visibility", json!({"items": ["Lower"], "visible": false}));
run(
&mut s,
"timeline.redefine",
json!({"feature": "Hole1", "command": "solid.hole", "params": {"position": [20, 20, 10], "direction": [0, 0, -1], "diameter": 6, "depth": 20}}),
);
assert!(s.doc.find_feature("Hole1").unwrap().participants.is_empty(), "the edit keeps the hole's scope");
assert!(vol(&s, "Lower") < small - 1.0, "the hidden Lower keeps its hole, now wider");
assert!(vol(&s, "Upper") < small - 1.0);
}
/// #7520 (history): a hole made with a body hidden stores the bodies it drills, so it replays
/// the same way: volumes match exactly after a save and reopen, a rebuild from scratch, undo and
/// redo, showing the hidden body, renaming bodies and editing an earlier feature.
#[test]
fn a_hole_made_with_a_hidden_body_replays_the_same() {
let build = |plate_height: f64| {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 40, "height": 20, "body_name": "Block"}));
run(&mut s, "solid.box", json!({"length": 40, "width": 40, "height": plate_height, "operation": "new", "body_name": "Plate"}));
run(&mut s, "browser.visibility", json!({"items": ["Block"], "visible": false}));
run(&mut s, "solid.hole", json!({"position": [20, 20, 0], "diameter": 5, "depth": 3, "name": "Hole1"}));
s
};
let vol = |s: &Session, name: &str| solidcraft_kernel::measure(&s.world_state().unwrap().body(name).unwrap().body).unwrap().volume;
// A new session evaluated from the saved text alone, as a rebuild from scratch does.
let scratch = |s: &Session| Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
let s = build(5.0);
let (block, plate) = (vol(&s, "Block"), vol(&s, "Plate"));
assert_eq!(block, 40.0 * 40.0 * 20.0, "the hidden body is not drilled");
assert!(plate < 40.0 * 40.0 * 5.0 - 1.0, "the shown one is");
let same = |s: &Session, what: &str| {
assert_eq!(vol(s, "Block"), block, "{what}: Block");
assert_eq!(vol(s, "Plate"), plate, "{what}: Plate");
};
// (1) Save and reopen.
let dir = std::env::temp_dir().join(format!("solidcraft-hole-history-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("hole.solidcraft");
let mut saved = build(5.0);
run(&mut saved, "file.save_as", json!({"path": file.to_string_lossy()}));
let mut reopened = Session::default();
run(&mut reopened, "doc.open", json!({"path": file.to_string_lossy()}));
same(&reopened, "reopened");
let _ = std::fs::remove_dir_all(&dir);
// (2) Rebuild from scratch.
same(&scratch(&s), "rebuilt");
// (3) Undo and redo.
let mut u = build(5.0);
run(&mut u, "edit.undo", json!({}));
assert_eq!(vol(&u, "Plate"), 40.0 * 40.0 * 5.0, "undone: no hole");
run(&mut u, "edit.redo", json!({}));
same(&u, "redone");
same(&scratch(&u), "redone and rebuilt");
// (4) Show the hidden body, then rebuild.
let mut shown = build(5.0);
run(&mut shown, "browser.visibility", json!({"items": ["Block"], "visible": true}));
same(&shown, "shown");
same(&scratch(&shown), "shown and rebuilt");
// (5) Rename the drilled body (and the hidden one), then rebuild.
let mut renamed = build(5.0);
run(&mut renamed, "body.rename", json!({"body": "Plate", "name": "Slab"}));
run(&mut renamed, "body.rename", json!({"body": "Block", "name": "Core"}));
for r in [&renamed, &scratch(&renamed)] {
assert_eq!(vol(r, "Slab"), plate, "renamed: Slab");
assert_eq!(vol(r, "Core"), block, "renamed: Core");
}
// (6) Edit an earlier feature (the plate's own box, then the hidden block's), then rebuild:
// the same as a design made with the new size from the start.
let feature = |s: &Session, body: &str| {
let id = s.world_state().unwrap().body(body).unwrap().feature;
s.doc.features.iter().find(|f| f.id == id).unwrap().name.clone()
};
let mut edited = build(5.0);
let (plate_box, block_box) = (feature(&edited, "Plate"), feature(&edited, "Block"));
run(&mut edited, "timeline.edit", json!({"feature": plate_box, "set": {"height": "8"}}));
let fresh = build(8.0);
for r in [&edited, &scratch(&edited)] {
assert_eq!(vol(r, "Plate"), vol(&fresh, "Plate"), "plate edited");
assert_eq!(vol(r, "Block"), block, "plate edited: Block");
}
run(&mut edited, "timeline.edit", json!({"feature": block_box, "set": {"height": "30"}}));
for r in [&edited, &scratch(&edited)] {
assert_eq!(vol(r, "Block"), 40.0 * 40.0 * 30.0, "block edited stays undrilled");
assert_eq!(vol(r, "Plate"), vol(&fresh, "Plate"), "block edited: Plate");
}
}
/// #7520 (review): with every body hidden, a hole still drills all of them, as before.
#[test]
fn a_hole_with_every_body_hidden_drills_them_all() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 40, "height": 20, "body_name": "Block"}));
run(&mut s, "solid.box", json!({"length": 40, "width": 40, "height": 5, "operation": "new", "body_name": "Plate"}));
run(&mut s, "browser.visibility", json!({"items": ["Block", "Plate"], "visible": false}));
let r = s.execute("solid.hole", &json!({"position": [20, 20, 0], "diameter": 5, "depth": 3, "name": "Hole1"})).unwrap();
assert_eq!(r["changed_bodies"], json!(["Block", "Plate"]));
assert!(s.doc.find_feature("Hole1").unwrap().participants.is_empty());
}
/// Appearances outlive later features: a coloured face keeps its colour after a fillet on a
/// neighbouring edge; an imported STEP's body and face colours survive a hole.
#[test]
+1
View File
@@ -397,6 +397,7 @@ fn frame(scene: &Scene, cam: &Camera, [width, height]: [u32; 2], key: u64, acces
pv_key: 0,
pv_slot: empty(),
pv_hide: false,
preview_bounds: None,
view_proj: cam.view_proj(f64::from(width) / f64::from(height.max(1)), scene.radius.max(1.0)).to_f32(),
back: cam.back().to_f32(),
size_px: [width as f32, height as f32],
+57 -2
View File
@@ -18,6 +18,7 @@ use crate::pbr;
use wgpu::util::DeviceExt;
mod ao;
pub use ao::GroundAo;
mod clouds;
pub use clouds::{CloudFilter, CloudStyle, ColorMode, DOT_SIZE, Dot, FILTERS, GpuCloud, cloud_dot};
#[cfg(not(target_arch = "wasm32"))]
@@ -494,6 +495,8 @@ impl InstancedMesh {
pub struct GpuScene {
/// The colours every vertex, line and instance index into.
pub palette: Arc<Palette>,
/// Non-selectable virtual ground receiving viewport AO, separate from model triangles.
pub ground_ao: Option<GroundAo>,
/// Opaque triangles. A zero normal draws the colour flat (unlit).
pub tris: Vec<u8>,
/// Opaque triangles of open bodies, drawn as `tris` but left out of a section's crossing
@@ -1017,6 +1020,9 @@ pub struct Frame {
pub pv_slot: SceneSlot,
/// Preview reselect: skip drawing the preview while Primary rolls it back.
pub pv_hide: bool,
/// World bounds of the active preview. Ground effects update their small receiver without
/// changing the model scene's key or re-uploading model geometry/colours.
pub preview_bounds: Option<solidcraft_geom::Aabb3>,
/// Column-major view-projection.
pub view_proj: [[f32; 4]; 4],
/// Direction toward the eye (for lighting).
@@ -1700,6 +1706,8 @@ struct Batches {
tris: Option<Batch>,
/// [`GpuScene::open_tris`].
open_tris: Option<Batch>,
ground_ao: Option<Batch>,
ground_input: Option<GroundAo>,
pbr: Option<Batch>,
trans: Option<Batch>,
shadow: Option<Batch>,
@@ -1792,6 +1800,7 @@ impl Batches {
geometry += data.len() as u64;
upload(device, label, data, stride)
};
self.ground_input = sc.ground_ao;
self.tris = own("sc_tris", &sc.tris, TRI_SIZE);
self.open_tris = own("sc_open_tris", &sc.open_tris, TRI_SIZE);
self.pbr = own("sc_pbr", &sc.pbr, PBR_SIZE);
@@ -2077,6 +2086,8 @@ pub struct Renderer {
ss_stats: DrawStats,
/// The AO depth prepass resources (a 1 × 1 stand-in while ambient occlusion is off).
ao: ao::Resources,
ground_ao: wgpu::RenderPipeline,
ground_ao_key: Option<(u64, u64, bool, u32, bool)>,
tri: wgpu::RenderPipeline,
/// `tri` and `tri_cut` for planned bodies (positions and normals from the GPU evaluation,
/// colours per vertex, indexed).
@@ -2737,6 +2748,29 @@ impl Renderer {
bind_group_layouts: &[Some(&bgl), Some(&access_bgl), Some(&ao_layout)],
immediate_size: 0,
});
let ground_ao = || {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("sc_ground_ao"),
layout: Some(&solid_layout),
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("vs_tri"),
buffers: &[Some(tri_layout())],
compilation_options: Default::default(),
},
primitive: wgpu::PrimitiveState { cull_mode: Some(wgpu::Face::Back), ..Default::default() },
depth_stencil: depth(wgpu::CompareFunction::LessEqual, false),
multisample: wgpu::MultisampleState { count: t.samples.max(1), ..Default::default() },
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("fs_ground_ao"),
targets: &[Some(wgpu::ColorTargetState { format: t.format, blend: alpha, write_mask: wgpu::ColorWrites::ALL })],
compilation_options: Default::default(),
}),
multiview_mask: None,
cache: None,
})
};
let built = [Some(tri_layout())];
let evaluated = [Some(planned_layout()), Some(color_layout())];
let placed = [Some(shared_layout()), Some(instance_layout())];
@@ -2982,6 +3016,7 @@ impl Renderer {
let cloud_pipes = jobs.spawn(cloud_pipes);
let image = jobs.spawn(image);
let ao = jobs.spawn(ao);
let ground_ao = jobs.spawn(ground_ao);
let oit = jobs.spawn(oit);
let blits = jobs.spawn(blits);
let resets = jobs.spawn(resets);
@@ -3000,6 +3035,8 @@ impl Renderer {
let oit = oit.join();
Renderer {
ao: ao.join(),
ground_ao: ground_ao.join(),
ground_ao_key: None,
depth_reset,
section_stencil,
#[cfg(test)]
@@ -3484,6 +3521,17 @@ impl Renderer {
res.model.sort_glass(queue, cb.back);
}
res.model.cull(queue, &cb.view_proj, cb.size_px);
let ground_visible = res.model.ground_input.is_some_and(|ground| ground.visible_from(cb.back));
let ground_key = (cb.key, cb.pv_key, cb.pv_hide, cb.ao_radius.to_bits(), ground_visible);
if res.ground_ao_key != Some(ground_key) {
let preview = if cb.pv_hide { None } else { cb.preview_bounds };
res.model.ground_ao = res
.model
.ground_input
.filter(|_| ground_visible)
.and_then(|ground| upload(device, "sc_ground_ao", &ground.vertices_with_radius(preview, cb.ao_radius), TRI_SIZE));
res.ground_ao_key = Some(ground_key);
}
queue.write_buffer(&res.uniform, 0, &uniform_bytes(cb, cb.size_px[0], cb.size_px[1], res.linear_out, res.target.reversed_depth()));
if let Some(a) = &cb.access
&& a.version != res.access_version
@@ -3491,8 +3539,9 @@ impl Renderer {
res.access_bind = access_texture(device, Some(queue), &res.access_bgl, a.size, &a.depth);
res.access_version = a.version;
}
let ao_binds = [scene_bind(&res.model, &res.bind), scene_bind(&res.preview, &res.bind)];
res.ao.prepare(device, encoder, ao_binds, cb.ao_radius, cb.size_px, res.target.reversed_depth(), [&res.model, &res.preview]);
let preview = if cb.pv_hide { &res.empty } else { &res.preview };
let ao_binds = [scene_bind(&res.model, &res.bind), scene_bind(preview, &res.bind)];
res.ao.prepare(device, encoder, ao_binds, cb.ao_radius, cb.size_px, res.target.reversed_depth(), [&res.model, preview]);
// Images: upload new or changed textures, drop removed ones, rebuild the quads.
res.textures.retain(|id, _| cb.images.iter().any(|i| i.id == *id));
res.quads.clear();
@@ -3838,6 +3887,9 @@ impl Renderer {
pass.set_bind_group(0, scene_bind(pv, &res.bind), &[]);
lines(pass, &res.line, &pv.lines);
pass.set_bind_group(0, scene_bind(m, &res.bind), &[]);
if !cb.product && cb.ao_radius > 0.0 {
tris(pass, &res.ground_ao, &m.ground_ao);
}
for (id, q) in res.quads.iter().filter(|_| !cb.product) {
if let Some((_, bind)) = res.textures.get(id) {
pass.set_pipeline(&res.image);
@@ -4514,6 +4566,7 @@ mod tests {
pv_key: 4,
pv_slot: slot(),
pv_hide: false,
preview_bounds: None,
view_proj: [[0.0; 4]; 4],
back: [0.0; 3],
size_px: [800.0, 600.0],
@@ -4973,6 +5026,7 @@ mod glass_tests {
pv_key: 0,
pv_slot: none(),
pv_hide: false,
preview_bounds: None,
view_proj: [[0.1, 0.0, 0.0, 0.0], [0.0, 0.1, 0.0, 0.0], [0.0, 0.0, -0.01, 0.0], [0.0, 0.0, 0.5, 1.0]],
back: [0.0, 0.0, 1.0],
size_px: [SIZE as f32; 2],
@@ -5150,6 +5204,7 @@ mod glass_tests {
pv_key: 0,
pv_slot: none(),
pv_hide: false,
preview_bounds: None,
view_proj: [[0.1, 0.0, 0.0, 0.0], [0.0, 0.1, 0.0, 0.0], [0.0, 0.0, -0.01, 0.0], [0.0, 0.0, 0.5, 1.0]],
back: [0.0, 0.0, 1.0],
size_px: [SIZE as f32; 2],
+109 -3
View File
@@ -1,4 +1,4 @@
//! The optional AO passes. Only opaque model and preview triangles participate: their depth is
//! The optional AO passes. Opaque model, preview and virtual ground triangles participate: their depth is
//! drawn once into a single-sample depth map, and the occlusion term of the surface that depth
//! holds is drawn into a texture of the same size and blurred by depth (#9327), which the lit
//! fragment reads. The maps are independent of the window's MSAA, and the depth runs the same way
@@ -30,6 +30,78 @@ pub(super) struct Maps {
smooth: wgpu::TextureView,
}
/// A virtual receiver in the camera's up-axis frame. Geometry is built independently of the
/// model batches, so moving a preview never re-uploads unchanged model colours or vertices.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct GroundAo {
bounds: solidcraft_geom::Aabb3,
height: f64,
up: solidcraft_render::UpAxis,
radius: f32,
}
impl GroundAo {
pub(super) fn visible_from(&self, back: [f32; 3]) -> bool {
if !back.iter().all(|v| v.is_finite()) {
return false;
}
let cam = solidcraft_render::Camera { up: self.up, ..Default::default() };
cam.to_frame(solidcore_record::Vec3::new(f64::from(back[0]), f64::from(back[1]), f64::from(back[2]))).z > 0.0
}
pub fn new(bounds: solidcraft_geom::Aabb3, height: f64, up: solidcraft_render::UpAxis, radius: f32) -> Option<Self> {
if bounds.is_empty() || !bounds.min.is_finite() || !bounds.max.is_finite() || !height.is_finite() || !radius.is_finite() || radius <= 0.0 {
return None;
}
Some(Self { bounds, height, up, radius })
}
/// Two triangles (168 bytes), extended to cover an optional preview without moving the
/// existing ground height. Invalid preview bounds are ignored.
pub fn vertices(&self, preview: Option<solidcraft_geom::Aabb3>) -> Vec<u8> {
self.vertices_with_radius(preview, self.radius)
}
/// Cover the preview plus the current frame's sampling margin. Invalid radii draw nothing.
pub fn vertices_with_radius(&self, preview: Option<solidcraft_geom::Aabb3>, radius: f32) -> Vec<u8> {
use solidcore_record::Vec3;
if !radius.is_finite() || radius <= 0.0 {
return Vec::new();
}
let cam = solidcraft_render::Camera { up: self.up, ..Default::default() };
let mut coverage = self.bounds;
if let Some(bb) = preview.filter(|b| !b.is_empty() && b.min.is_finite() && b.max.is_finite()) {
for i in 0..8 {
coverage.add(cam.to_frame(Vec3::new(
if i & 1 == 0 { bb.min.x } else { bb.max.x },
if i & 2 == 0 { bb.min.y } else { bb.max.y },
if i & 4 == 0 { bb.min.z } else { bb.max.z },
)));
}
}
let margin = f64::from(radius) * 2.0;
let points = [
Vec3::new(coverage.min.x - margin, coverage.min.y - margin, self.height),
Vec3::new(coverage.max.x + margin, coverage.min.y - margin, self.height),
Vec3::new(coverage.max.x + margin, coverage.max.y + margin, self.height),
Vec3::new(coverage.min.x - margin, coverage.max.y + margin, self.height),
]
.map(|p| cam.from_frame(p).to_f32());
if !points.iter().flatten().all(|v| v.is_finite()) {
return Vec::new();
}
let normal = cam.from_frame(Vec3::Z).to_f32();
let mut bytes = Vec::with_capacity(6 * super::TRI_SIZE);
for i in [0, 1, 2, 0, 2, 3] {
for value in points[i].iter().chain(&normal) {
bytes.extend_from_slice(&value.to_le_bytes());
}
bytes.extend_from_slice(&0u32.to_le_bytes());
}
bytes
}
}
pub(super) struct Resources {
pub layout: wgpu::BindGroupLayout,
pub maps: Maps,
@@ -87,7 +159,7 @@ fn draw_opaque(pass: &mut wgpu::RenderPass<'_>, pipelines: &OpaquePipelines, bin
for (batch, bind) in batches.into_iter().zip(binds) {
pass.set_bind_group(0, bind, &[]);
pass.set_pipeline(&pipelines.built);
for tris in [&batch.tris, &batch.open_tris].into_iter().flatten() {
for tris in [&batch.tris, &batch.open_tris, &batch.ground_ao].into_iter().flatten() {
for (buffer, count) in &tris.parts {
pass.set_vertex_buffer(0, buffer.slice(..));
pass.draw(0..*count, 0..1);
@@ -299,7 +371,41 @@ fn target_size(enabled: bool, pixels: [f32; 2], device_limit: u32) -> [u32; 2] {
#[cfg(test)]
mod tests {
use super::target_size;
use super::{GroundAo, target_size};
#[test]
fn receiver_visibility_follows_the_camera_side_in_both_up_axes() {
use solidcore_record::Vec3;
let bounds = solidcraft_geom::Aabb3 { min: Vec3::ZERO, max: Vec3::new(3.0, 4.0, 12.0) };
for up in [solidcraft_render::UpAxis::Y, solidcraft_render::UpAxis::Z] {
let cam = solidcraft_render::Camera { up, ..Default::default() };
let ground = GroundAo::new(bounds, 0.0, up, 1.0).unwrap();
assert!(ground.visible_from(cam.from_frame(Vec3::Z).to_f32()));
assert!(!ground.visible_from(cam.from_frame(-Vec3::Z).to_f32()));
assert!(!ground.visible_from(cam.from_frame(Vec3::X).to_f32()));
assert!(!ground.visible_from([f32::NAN; 3]));
}
}
#[test]
fn a_growing_preview_keeps_the_current_sampling_margin_at_the_fixed_ground_height() {
use solidcore_record::Vec3;
use solidcraft_geom::Aabb3;
let bounds = Aabb3 { min: Vec3::ZERO, max: Vec3::new(3.0, 4.0, 12.0) };
let ground = GroundAo::new(bounds, -0.014, solidcraft_render::UpAxis::Z, 1.0).unwrap();
let preview = Aabb3 { min: Vec3::new(100.0, 200.0, -40.0), max: Vec3::new(103.0, 204.0, -28.0) };
let mut drawn = Aabb3::EMPTY;
for vertex in ground.vertices_with_radius(Some(preview), 10.0).as_chunks::<{ crate::view::TRI_SIZE }>().0 {
let read = |i| f64::from(f32::from_le_bytes(vertex[i..i + 4].try_into().unwrap()));
drawn.add(Vec3::new(read(0), read(4), read(8)));
}
assert_eq!((drawn.min.x, drawn.min.y, drawn.max.x, drawn.max.y), (-20.0, -20.0, 123.0, 224.0));
assert!((drawn.min.z + 0.014).abs() < 1e-6 && (drawn.max.z + 0.014).abs() < 1e-6);
assert_eq!(ground.vertices(Some(preview)), ground.vertices_with_radius(Some(preview), 1.0));
for radius in [f32::NAN, f32::INFINITY, -1.0, 0.0] {
assert!(ground.vertices_with_radius(Some(preview), radius).is_empty());
}
}
#[test]
fn depth_map_is_bounded_and_released_when_disabled() {
assert_eq!(target_size(true, [6000.0, 3000.0], 8192), [2048, 1024]);
+1
View File
@@ -107,6 +107,7 @@ impl Renderer {
pv_key: 4,
pv_slot: slot(None),
pv_hide: false,
preview_bounds: None,
view_proj: [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.5, 1.0]],
back: [0.0, 0.0, 1.0],
size_px: [SIZE as f32; 2],
+240
View File
@@ -0,0 +1,240 @@
//! Manual timestamp measurement: model-only AO versus the virtual-ground receiver.
//! Run explicitly with --ignored, WGPU_BACKEND and SOLIDCRAFT_GPU=hardware|software.
//! SOLIDCRAFT_BENCH_OUTPUT_DIR is the folder for JSON evidence. Not a CI speed threshold.
#![cfg(not(target_arch = "wasm32"))]
use serde_json::json;
use solidcore_record::Vec3;
use solidcraft_engine::{geom::Aabb3, render::Camera};
use solidcraft_gpu as gpu;
use solidcraft_gpu::view::{Frame, GpuScene, GpuTarget, Renderer};
use std::{
sync::Arc,
time::{Duration, Instant},
};
#[test]
#[ignore = "manual CPU/GPU measurement; requires a timestamp-capable adapter"]
fn measure_ground_ao_renderer_cost() -> Result<(), Box<dyn std::error::Error>> {
ao_comparison(0)?;
ao_comparison(1)
}
fn ao_comparison(ground: u32) -> Result<(), Box<dyn std::error::Error>> {
let (adapter, _) = gpu::select::pick(gpu::select::Policy::from_env().map_err(std::io::Error::other)?).ok_or("no matching adapter")?;
let info = adapter.get_info();
let features = wgpu::Features::TIMESTAMP_QUERY | wgpu::Features::TIMESTAMP_QUERY_INSIDE_ENCODERS;
if !adapter.features().contains(features) {
return Err("adapter has no encoder timestamps".into());
}
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
label: Some("existing AO comparison"),
required_features: features,
required_limits: wgpu::Limits::downlevel_defaults().using_resolution(adapter.limits()),
..Default::default()
}))?;
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let target = GpuTarget {
format: wgpu::TextureFormat::Rgba8Unorm,
depth: Some(wgpu::TextureFormat::Depth24PlusStencil8),
samples: 4,
tess: false,
oit: false,
};
let mut session = solidcraft_engine::Session::default();
for corner in [[-65, -10, 0], [45, -10, 0]] {
session.execute("solid.box", &json!({"length":20,"width":20,"height":20,"corner":corner,"operation":"new"}))?;
}
session.execute("solid.torus", &json!({"major":14,"minor":4,"center":[0,0,9],"operation":"new"}))?;
let meshes: Vec<_> = session.world_state()?.bodies.iter().map(|b| b.mesh()).collect();
let bounds = meshes.iter().fold(Aabb3::EMPTY, |bounds, mesh| bounds.union(&mesh.bounds()));
let radius = (bounds.diagonal() * 0.04).clamp(1e-4, 1e6) as f32;
let cam = Camera {
target: Vec3::new(8.379000844961002, 2.6776676413414724, 7.806862777456871),
distance: 131.41653316382525,
yaw: -0.5,
pitch: 0.65,
..Default::default()
};
let query =
device.create_query_set(&wgpu::QuerySetDescriptor { label: Some("existing AO timestamps"), ty: wgpu::QueryType::Timestamp, count: 4 });
let buffer = |usage| device.create_buffer(&wgpu::BufferDescriptor { label: None, size: 32, usage, mapped_at_creation: false });
let resolve = buffer(wgpu::BufferUsages::QUERY_RESOLVE | wgpu::BufferUsages::COPY_SRC);
let read = buffer(wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ);
let software = info.device_type == wgpu::DeviceType::Cpu;
let samples = if software { 12 } else { 30 };
let warm = 5;
let mut rows = Vec::new();
for (width, height) in [(1670u32, 838u32), (3340, 1676)] {
if software && width > 1670 {
continue;
}
let mut scene = GpuScene::default();
let color = scene.color([190, 195, 205, 255]);
for mesh in &meshes {
for tri in &mesh.triangles {
for index in tri {
let p = mesh.positions.get(*index as usize).ok_or("bad index")?;
let n = mesh.normals.get(*index as usize).copied().unwrap_or(Vec3::Z);
scene.tri(p.to_f32(), n.to_f32(), color);
}
}
}
if ground > 0 {
receiver(&mut scene, bounds_in_frame(&[bounds], &cam), &cam, f64::from(radius));
}
let mut frame = comparison_frame(scene, &cam, width, height, 1);
let mut renderer = Renderer::new(&device, target, None);
let attachment = |format| {
device
.create_texture(&wgpu::TextureDescriptor {
label: Some("AO comparison target"),
size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 4,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
})
.create_view(&Default::default())
};
let color = attachment(target.format);
let depth = attachment(wgpu::TextureFormat::Depth24PlusStencil8);
// Keep each state stable after warm-up: disabling releases the depth image, so alternating
// every frame would benchmark repeated allocation rather than normal steady AO use.
for enabled in [false, true, false, true] {
frame.ao_radius = if enabled { radius } else { 0.0 };
let mut cpu = Vec::new();
let mut prepass = Vec::new();
let mut shade = Vec::new();
let mut total = Vec::new();
for sample in 0..samples + warm {
let mut encoder = device.create_command_encoder(&Default::default());
encoder.write_timestamp(&query, 0);
let started = Instant::now();
renderer.prepare(&device, &queue, &mut encoder, &frame);
let cpu_ms = started.elapsed().as_secs_f64() * 1000.0;
encoder.write_timestamp(&query, 1);
encoder.write_timestamp(&query, 2);
{
let mut pass = encoder
.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("existing AO shaded model"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &color,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::WHITE), store: wgpu::StoreOp::Store },
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &depth,
depth_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Clear(1.0), store: wgpu::StoreOp::Store }),
stencil_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Clear(0), store: wgpu::StoreOp::Store }),
}),
..Default::default()
})
.forget_lifetime();
renderer.paint(&frame, &mut pass, [0.0, 0.0, width as f32, height as f32]);
}
encoder.write_timestamp(&query, 3);
encoder.resolve_query_set(&query, 0..4, &resolve, 0);
encoder.copy_buffer_to_buffer(&resolve, 0, &read, 0, 32);
let submitted = queue.submit([encoder.finish()]);
read.slice(..).map_async(wgpu::MapMode::Read, |_| {});
device.poll(wgpu::PollType::Wait { submission_index: Some(submitted), timeout: Some(Duration::from_secs(30)) })?;
let mapped = read.slice(..).get_mapped_range()?;
let times: Vec<_> =
mapped.as_chunks::<8>().0.iter().map(|b| u64::from_le_bytes(*b) as f64 * f64::from(queue.get_timestamp_period()) / 1e6).collect();
if sample >= warm {
let at = |i| times.get(i).copied().ok_or("missing timestamp");
cpu.push(cpu_ms);
prepass.push((at(1)? - at(0)?).max(0.0));
shade.push((at(3)? - at(2)?).max(0.0));
total.push((at(3)? - at(0)?).max(0.0));
}
drop(mapped);
read.unmap();
}
rows.push(json!({"enabled":enabled,"width":width,"height":height,"samples":samples,
"cpu_cached_prepare_ms":summary(&mut cpu),"gpu_prepare_ms":summary(&mut prepass),
"gpu_model_pass_ms":summary(&mut shade),"gpu_total_ms":summary(&mut total)}));
}
}
if let Some(error) = pollster::block_on(scope.pop()) {
return Err(error.to_string().into());
}
let result = json!({"adapter":info.name,"backend":format!("{:?}",info.backend),"radius":radius,"ground":ground,
"triangles":meshes.iter().map(|m|m.triangles.len()).sum::<usize>(),"rows":rows,
"notes":"Actual Renderer, same cached opaque model, MSAA4, two off/on pairs after warm-up. ground=0: surface AO; 1: virtual floor; Floor composites require AO on. Projection is not part of this measurement. Stored MSAA4 attachments prevent unused-output elimination; presentation, MSAA resolve and final downsample are excluded. 3340x1676 is SSAA2 working resolution. GPU total includes prepare plus model/clear, not the application frame."});
let label = match ground {
1 => "ao-ground",
2 => "ao-ground-projection",
_ => "ao-baseline",
};
let output =
std::env::var_os("SOLIDCRAFT_BENCH_OUTPUT_DIR").map(std::path::PathBuf::from).ok_or("set SOLIDCRAFT_BENCH_OUTPUT_DIR to an output folder")?;
std::fs::create_dir_all(&output)?;
std::fs::write(output.join(format!("{label}-comparison.json")), serde_json::to_vec_pretty(&result)?)?;
println!("{label} comparison completed: {} {:?}", info.name, info.backend);
Ok(())
}
fn summary(values: &mut [f64]) -> serde_json::Value {
values.sort_by(f64::total_cmp);
json!({"median":values.get(values.len()/2),"p95":values.get(values.len().saturating_mul(95)/100),"samples":values.len()})
}
fn bounds_in_frame(bounds: &[Aabb3], cam: &Camera) -> Aabb3 {
let mut combined = Aabb3::EMPTY;
for b in bounds.iter().filter(|b| !b.is_empty()) {
for i in 0..8 {
combined.add(cam.to_frame(Vec3::new(
if i & 1 == 0 { b.min.x } else { b.max.x },
if i & 2 == 0 { b.min.y } else { b.max.y },
if i & 4 == 0 { b.min.z } else { b.max.z },
)));
}
}
combined
}
fn comparison_frame(scene: GpuScene, cam: &Camera, width: u32, height: u32, key: u64) -> Frame {
let empty = || Arc::new(std::sync::Mutex::new(None));
Frame {
product_lights: None,
upload_error: None,
product: false,
key,
slot: Arc::new(std::sync::Mutex::new(Some(scene))),
sketch_key: 0,
sketch_slot: empty(),
hl_key: 0,
hl_slot: empty(),
pv_key: 0,
pv_slot: empty(),
pv_hide: false,
preview_bounds: None,
view_proj: cam.view_proj(f64::from(width) / f64::from(height), 200.0).to_f32(),
back: cam.back().to_f32(),
size_px: [width as f32, height as f32],
clip: [0.0; 4],
cap: [0.0; 4],
images: Vec::new(),
clouds: Vec::new(),
analysis: [0.0; 8],
analysis_colors: [0.0; 36],
access: None,
outline: false,
eye: [0.0; 4],
ao_radius: 0.0,
ao_samples: gpu::view::AO_SAMPLES,
hover_tint: [0.0; 3],
selection_tint: [0.0; 3],
hatch: Default::default(),
sky: [0.0; 4],
ssaa: 1.0,
}
}
fn receiver(sc: &mut GpuScene, b: Aabb3, cam: &Camera, radius: f64) {
sc.ground_ao = solidcraft_gpu::view::GroundAo::new(b, b.min.z - 0.001 * (1.0 + b.diagonal()), cam.up, radius as f32);
}
+6 -181
View File
@@ -343,16 +343,10 @@ fn write_container(
let render = replace(doc.render.as_ref(), fields([("resources", Value::Object(resources))])).map_err(invalid)?;
replacements.insert("render".into(), render);
}
let mut metadata = replace(doc, replacements).map_err(invalid)?;
solidcraft_doc::newer_file::restore(doc, &mut metadata);
let metadata = replace(doc, replacements).map_err(invalid)?;
let metadata = serde_json::to_vec(&metadata).map_err(invalid)?;
writer.part("document.json", &metadata, true)?;
// What a newer build's file holds beside the design goes back as it came.
let container = doc.newer_file.as_ref().and_then(|file| file.container.clone()).unwrap_or_else(|| json!({"format": VERSION}));
writer.part("container.json", &serde_json::to_vec(&container).map_err(invalid)?, false)?;
for (name, bytes) in doc.newer_file.iter().flat_map(|file| &file.parts) {
writer.part(name, bytes, true)?;
}
writer.part("container.json", &serde_json::to_vec(&json!({"format": VERSION})).map_err(invalid)?, false)?;
// The model cache is optional: one that does not fit the container keeps fewer states, or is
// left out, never a failed save.
let cache = geometry.and_then(|g| native_cache::encode_model(g, &metadata, writer.space(2, budget)).ok());
@@ -821,115 +815,16 @@ fn is_container_source(source: &mut FileSource) -> Result<bool> {
Ok(is_design_container(&prefix))
}
/// A newer build's container: a feature, picture or point cloud whose payload doesn't read here
/// is kept as the file has it, and so is every part this build has no use for.
fn hydrate_newer<R: Read + Seek>(doc: &mut Document, metadata: &Value, container: Value, reader: &mut PackageReader<R>) -> Result<()> {
let file = Arc::make_mut(doc.newer_file.as_mut().ok_or_else(|| invalid("not a newer design"))?);
for (index, feature) in doc.features.iter_mut().enumerate() {
let Some(value) = metadata.get("features").and_then(|v| v.get(index)) else { continue };
let readable = match &mut feature.kind {
FeatureKind::Import { .. } => reader.blob(value, "step_blob", "step").map(|_| ()),
FeatureKind::MeshImport { meshes, .. } => {
let mut check = meshes.clone();
check.iter_mut().enumerate().try_for_each(|(i, mesh)| {
let value = value.get("meshes").and_then(|v| v.get(i)).ok_or_else(|| invalid("missing mesh metadata"))?;
decode_mesh(reader.blob(value, "mesh_blob", "mesh")?, mesh)
})
}
_ => Ok(()),
};
if let Err(why) = readable {
file.not_understood.push(format!("feature `{}` ({why})", feature.name));
feature.kind = FeatureKind::NewerFileFeature { raw: solidcraft_doc::newer_file::RawFeature(Box::new(value.clone())) };
}
}
let canvases_read = doc.canvases.iter().enumerate().all(|(i, canvas)| {
canvas.data.is_empty()
&& matches!(canvas.format.as_str(), "png" | "jpeg")
&& metadata.get("canvases").and_then(|v| v.get(i)).is_some_and(|v| reader.blob(v, "image_blob", &canvas.format).is_ok())
});
if !canvases_read {
doc.canvases.clear();
file.keep_section("canvases", metadata.get("canvases").cloned().unwrap_or_default(), Some(json!([])), "pictures this version can't read");
}
let clouds_read = doc.point_clouds.iter().enumerate().all(|(i, cloud)| {
let mut check = cloud.clone();
metadata.get("point_clouds").and_then(|v| v.get(i)).is_some_and(|v| match reader.blob(v, "points_blob", "points") {
Ok(bytes) => decode_cloud(bytes, &mut check).is_ok(),
Err(_) => false,
})
});
if !clouds_read {
doc.point_clouds.clear();
file.keep_section("point_clouds", metadata.get("point_clouds").cloned().unwrap_or_default(), None, "point clouds this version can't read");
}
// The container's own note, when it says something this build doesn't know.
if !is_known_container(&container) {
file.container = Some(container);
}
// A blob a typed record uses comes back from that record; others only for what is kept.
let names: Vec<String> = reader.zip.file_names().filter_map(|name| name.ok().map(|name| name.into_owned())).collect();
for name in names {
let ours = matches!(name.as_str(), "document.json" | "container.json") || name.starts_with("cache/");
let unused_blob = name.starts_with("blobs/") && !file.mentions(&name) && !foreign_mentions(&doc.features, &name);
if !ours && !unused_blob {
file.parts.push((name.clone(), reader.limited_part(&name, part_limit(&name))?.into()));
}
}
// Looking counts against what a design may expand to only once: the reads below are the real ones.
reader.hydrated = 0;
Ok(())
}
/// A `container.json` that says only a format this build writes or wrote.
fn is_known_container(container: &Value) -> bool {
*container == json!({"format": VERSION})
|| *container == json!({"format": LEGACY_VERSION})
|| container.get("content").is_some_and(|c| c == "drawing")
}
/// A container format of ours, this build's or a later one's: `solidcraft-container/N`.
pub(crate) fn is_container_version(format: &str) -> bool {
format == LEGACY_VERSION || format.strip_prefix("solidcraft-container/").is_some_and(|n| n.parse::<u32>().is_ok())
}
/// Whether a feature this build couldn't read refers to this blob.
fn foreign_mentions(features: &[solidcraft_doc::Feature], name: &str) -> bool {
fn names(value: &Value, name: &str) -> bool {
match value {
Value::String(s) => s == name,
Value::Array(a) => a.iter().any(|x| names(x, name)),
Value::Object(o) => o.values().any(|x| names(x, name)),
_ => false,
}
}
features.iter().any(|f| matches!(&f.kind, FeatureKind::NewerFileFeature { raw } if names(&raw.0, name)))
}
fn read_document<R: Read + Seek>(reader: &mut PackageReader<R>) -> Result<Document> {
if drawing::container_kind(reader)? == FileKind::Drawing {
return Err(IoError::Invalid("this file is a SolidCraft drawing, not a design: open it as a drawing".into()));
}
let bytes = reader.document(max_design())?;
check_hdr_manifest(&bytes)?;
let mut metadata: Value = serde_json::from_slice(&bytes).map_err(invalid)?;
// A newer build's file opens whatever it holds: what doesn't read here is kept (`newer_file`).
let container: Value = serde_json::from_slice(&reader.limited_part("container.json", 4096)?).map_err(invalid)?;
let newer_container = !is_known_container(&container);
let newer = newer_container || std::str::from_utf8(&bytes).is_ok_and(solidcraft_doc::format::says_newer);
// HDR's typed JSON representation is deliberately strict: no empty placeholders or
// container-only blob fields may reach the document deserializer. A newer build's section that
// doesn't pass stays as it is, which the document reader then leaves out.
let hydrated_hdr = if newer {
let mut hydrated = metadata.clone();
let done = check_hdr_manifest(&bytes).and_then(|()| hydrate_hdr_resources(&mut hydrated, reader));
if done.is_ok() {
metadata = hydrated;
}
done.unwrap_or(false)
} else {
check_hdr_manifest(&bytes)?;
hydrate_hdr_resources(&mut metadata, reader)?
};
// container-only blob fields may reach the document deserializer.
let hydrated_hdr = hydrate_hdr_resources(&mut metadata, reader)?;
let hydrated_text;
let text = if hydrated_hdr {
hydrated_text = serde_json::to_string(&metadata).map_err(invalid)?;
@@ -940,16 +835,6 @@ fn read_document<R: Read + Seek>(reader: &mut PackageReader<R>) -> Result<Docume
// Migrations and all document validation run on the small parametric metadata.
// Binary arrays have their own equivalent numeric/index validation below.
let mut doc = Document::from_json(text)?;
if newer_container && doc.newer_file.is_none() {
let current = solidcraft_doc::format::FORMAT;
let says = container.get("format").and_then(Value::as_str).unwrap_or("a later container").to_string();
let mut file = solidcraft_doc::newer_file::NewerFile::default();
(file.format, file.reads_up_to, file.unknown_changes) = (current, current, vec![says]);
doc.newer_file = Some(Arc::new(file));
}
if doc.newer_file.is_some() {
hydrate_newer(&mut doc, &metadata, container, reader)?;
}
for (index, feature) in doc.features.iter_mut().enumerate() {
let value = metadata.get("features").and_then(|v| v.get(index)).ok_or_else(|| invalid("missing feature metadata"))?;
match &mut feature.kind {
@@ -1783,60 +1668,6 @@ mod tests {
doc
}
/// A newer build's container: its feature this build can't read, that feature's blob, a part
/// this build has never heard of and a payload in a newer encoding all open and save back.
#[test]
fn a_newer_builds_container_opens_and_saving_keeps_all_it_holds() {
let doc = document();
let mut entries = parts(&write_design_container(&doc).unwrap());
let mut metadata: Value = serde_json::from_slice(&entries["document.json"]).unwrap();
let newer = solidcraft_doc::format::format_string(solidcraft_doc::format::FORMAT + 1);
metadata["format"] = json!(newer);
let payload = b"a future payload".to_vec();
let blob = format!("blobs/{}.lattice", digest(&payload));
let future = json!({"id": 9000, "name": "Lattice1", "type": "gyroid_lattice", "lattice_blob": blob});
metadata["features"].as_array_mut().unwrap().push(future.clone());
metadata["future_section"] = json!({"keep": true});
entries.insert("document.json".into(), serde_json::to_vec(&metadata).unwrap());
entries.insert(blob.clone(), payload.clone());
entries.insert("future/notes.bin".into(), b"more".to_vec());
// This blob nothing names any more: it doesn't stay around.
entries.insert(format!("blobs/{}.lattice", digest(b"orphan")), b"orphan".to_vec());
let opened = crate::read_design(&package(&entries)).unwrap();
assert_eq!(opened.features.len(), doc.features.len() + 1);
assert!(opened.newer_file.as_ref().unwrap().not_understood.iter().any(|what| what.contains("Lattice1")));
assert_eq!(opened.features[0], doc.features[0]);
let resaved = parts(&write_design_container(&opened).unwrap());
let again: Value = serde_json::from_slice(&resaved["document.json"]).unwrap();
assert_eq!(again["format"], json!(newer));
assert_eq!(again["features"].as_array().unwrap().last().unwrap(), &future);
assert_eq!(again["future_section"], json!({"keep": true}));
assert_eq!(resaved[&blob], payload);
assert_eq!(resaved["future/notes.bin"], b"more");
assert!(!resaved.contains_key(&format!("blobs/{}.lattice", digest(b"orphan"))));
// And again: nothing is lost on the way round, the design's own blobs included.
let twice = crate::read_design(&write_design_container(&opened).unwrap()).unwrap();
assert_eq!(twice, opened);
// A payload in an encoding this build doesn't have costs its feature, not the design.
let mut entries = entries.clone();
let mesh = metadata["features"].as_array().unwrap().iter().flat_map(|f| f["meshes"].as_array().into_iter().flatten()).next().unwrap();
let mesh_blob = mesh["mesh_blob"].as_str().unwrap().to_string();
let mut encoded = entries[&mesh_blob].clone();
encoded[0] ^= 0xff;
let renamed = format!("blobs/{}.mesh", digest(&encoded));
entries.remove(&mesh_blob);
entries.insert(renamed.clone(), encoded.clone());
let text = String::from_utf8(entries["document.json"].clone()).unwrap().replace(&mesh_blob, &renamed);
entries.insert("document.json".into(), text.into_bytes());
let opened = crate::read_design(&package(&entries)).unwrap();
let said = &opened.newer_file.as_ref().unwrap().not_understood;
assert!(said.iter().any(|what| what.contains("unsupported mesh blob version")), "{said:?}");
assert_eq!(parts(&write_design_container(&opened).unwrap())[&renamed], encoded);
}
fn parts(bytes: &[u8]) -> BTreeMap<String, Vec<u8>> {
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
(0..zip.len())
@@ -2034,13 +1865,7 @@ mod tests {
entries.insert("../escape".into(), vec![]);
assert!(crate::read_design(&package(&entries)).is_err());
entries.remove("../escape");
// A later container still holds a design: it opens, says so, and saving keeps its note.
entries.insert("container.json".into(), br#"{"format":"solidcraft-container/999","more":1}"#.to_vec());
let later = crate::read_design(&package(&entries)).unwrap();
assert!(later.newer_file.as_ref().unwrap().notice().contains("solidcraft-container/999"));
let resaved = parts(&write_design_container(&later).unwrap());
assert_eq!(resaved["container.json"], entries["container.json"]);
entries.insert("container.json".into(), br#"{"format":"another-tool/1"}"#.to_vec());
entries.insert("container.json".into(), br#"{"format":"solidcraft-container/999"}"#.to_vec());
assert!(crate::read_design(&package(&entries)).unwrap_err().to_string().contains("unsupported container version"));
for length in (0..bytes.len()).step_by(17) {
assert!(crate::read_design(&bytes[..length]).is_err());
+2 -4
View File
@@ -49,15 +49,13 @@ pub fn file_kind(bytes: &[u8]) -> Result<FileKind> {
/// What a container holds, by its `container.json`: no `content` is a design.
pub(super) fn container_kind<R: Read + Seek>(reader: &mut PackageReader<R>) -> Result<FileKind> {
let container: Value = serde_json::from_slice(&reader.limited_part("container.json", 4096)?).map_err(invalid)?;
if !container.get("format").and_then(Value::as_str).is_some_and(super::is_container_version) {
if !matches!(container.get("format").and_then(Value::as_str), Some(super::VERSION | super::LEGACY_VERSION)) {
return Err(invalid("unsupported container version"));
}
match container.get("content") {
None => Ok(FileKind::Design),
Some(Value::String(content)) if content == DRAWING_CONTENT => Ok(FileKind::Drawing),
// A later kind of content that still holds a design opens as one.
Some(_) if reader.has_part("document.json") => Ok(FileKind::Design),
Some(other) => Err(invalid(format!("it holds {other}, which this version doesn't know: update SolidCraft to open it"))),
Some(other) => Err(invalid(format!("this build doesn't know container content {other}"))),
}
}
+7 -8
View File
@@ -106,8 +106,8 @@ fn a_drawing_container_refuses_what_it_cant_take() {
let text = drawing().to_json().unwrap();
let drawing_json = |content: &str| format!(r#"{{"content":{content},"format":"solidcraft-container/1"}}"#);
let cases: [(Vec<u8>, &str); 6] = [
(container(&[("container.json", drawing_json("\"sketch\"").as_bytes()), ("drawing.json", text.as_bytes())]), "it holds \"sketch\""),
(container(&[("container.json", drawing_json("7").as_bytes()), ("drawing.json", text.as_bytes())]), "it holds 7"),
(container(&[("container.json", drawing_json("\"sketch\"").as_bytes()), ("drawing.json", text.as_bytes())]), "container content \"sketch\""),
(container(&[("container.json", drawing_json("7").as_bytes()), ("drawing.json", text.as_bytes())]), "container content 7"),
(
container(&[
("container.json", drawing_json("\"drawing\"").as_bytes()),
@@ -117,7 +117,10 @@ fn a_drawing_container_refuses_what_it_cant_take() {
"also holds a design's document.json",
),
(container(&[("container.json", drawing_json("\"drawing\"").as_bytes())]), "required part `drawing.json`"),
(container(&[("container.json", &br#"{"content":"drawing","format":"another-tool/9"}"#[..]), ("drawing.json", text.as_bytes())]), "version"),
(
container(&[("container.json", &br#"{"content":"drawing","format":"solidcraft-container/9"}"#[..]), ("drawing.json", text.as_bytes())]),
"version",
),
(
container(&[("container.json", drawing_json("\"drawing\"").as_bytes()), ("drawing.json", text.replace("en-GB", "e").as_bytes())]),
"damaged drawing",
@@ -127,11 +130,7 @@ fn a_drawing_container_refuses_what_it_cant_take() {
let e = error(read_drawing(&bytes));
assert!(e.contains(says), "{says}: {e}");
}
assert!(error(read_design(&container(&[("container.json", drawing_json("\"sketch\"").as_bytes())]))).contains("this version doesn't know"));
// A later container of ours still holds a drawing: it opens.
let later =
container(&[("container.json", &br#"{"content":"drawing","format":"solidcraft-container/9"}"#[..]), ("drawing.json", text.as_bytes())]);
assert!(read_drawing(&later).is_ok());
assert!(error(read_design(&container(&[("container.json", drawing_json("\"sketch\"").as_bytes())]))).contains("container content"));
}
/// Drawing files: the extension, and reading one from disk in either form.
+4
View File
@@ -342,6 +342,10 @@ mod tests {
for m in ["app.quit", "ui.click", "ui.prefs", "ui.agent_chat", "engine.script", "ui.key", "ui.file"] {
assert!(matches!(request(m, &json!({}), 0), Verdict::Refuse(_)), "{m}");
}
// How a host page keeps the design (#8517) is not the agent's.
for m in ["document.export", "document.import", "document.saved"] {
assert!(matches!(request(m, &json!({}), 0), Verdict::Refuse(_)), "{m}");
}
}
#[test]
+6
View File
@@ -10,6 +10,8 @@
//! of it first
//! - `engine.commands`: every command with tab, panel, params and enablement
//! - `document.inspect {measure?}`: the design (parameters, timeline, bodies, sketches)
//! - `document.export`, `document.import {text, name?}`, `document.saved {revision}`: the design
//! for a host that keeps the files itself (#8517)
//! - `ui.inspect`: UI state, viewport rect, camera, tool and dialog
//! - `ui.drag {x0, y0, x1, y1, button?, shift?, ctrl?, steps?, hold?}` (box selection, navigation;
//! `hold` keeps the button down that many frames before moving);
@@ -821,6 +823,10 @@ fn dispatch(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value) ->
"engine.commands" => wrap(execute(app, "engine.commands", json!({}))),
"document.inspect" => wrap(app.session.execute("document.inspect", p).map_err(|e| e.to_string())),
"document.revision" => wrap(app.session.execute("document.revision", p).map_err(|e| e.to_string())),
// A host that keeps the files itself (an editing embed, #8517).
"document.export" => wrap(crate::documents::export(app)),
"document.import" => wrap(crate::documents::import(app, p)),
"document.saved" => wrap(crate::documents::saved(app, p)),
// The connection already counts as subscribed (`DesignWatch::join`), and its events start
// from this answer: published first, so no event repeats it and none older follows.
"control.subscribe" => {
+1 -1
View File
@@ -132,7 +132,7 @@ fn reading(req: &ControlRequest) -> Option<(&'static str, Value)> {
/// Requests that change the design: they wait while one computes.
pub(crate) fn changes_the_design(method: &str) -> bool {
matches!(method, "engine.execute" | "command" | "engine.script" | "engine.batch")
matches!(method, "engine.execute" | "command" | "engine.script" | "engine.batch" | "document.import" | "document.saved")
}
/// Whether `doc` is the design `session` shows, as it is or edited since (an undo step away).
+43
View File
@@ -486,6 +486,49 @@ pub fn adopt(app: &mut SolveApp, session: Session) {
}
}
/// The active design as its file holds it (`document.export`): what a host that keeps the files
/// itself writes (#8517). `text` is the design's JSON, the same a `.json` design file holds, and
/// `revision` is the one `document.revision` names this state by.
pub fn export(app: &mut SolveApp) -> Result<Value, String> {
let revision = app.session.design_revision();
let design = app.session.design();
let text = String::from_utf8(solidcraft_engine::io::write_design(&design)).map_err(|e| e.to_string())?;
Ok(json!({"name": design.name, "revision": revision, "dirty": app.session.is_dirty(), "format": "json", "text": text}))
}
/// Replace the active design with one a host kept (`document.import {text, name?}`): the text of
/// a design file (its JSON, as `document.export` gives it). It opens saved, with nothing to undo,
/// in the active design's place: as a design opened in a tab of its own, with that tab closed
/// without asking. A text that does not read changes nothing.
pub fn import(app: &mut SolveApp, p: &Value) -> Result<Value, String> {
if app.file_picker_busy() || app.file_load.busy() || app.docs.closing.is_some() || app.docs.quitting.is_some() || app.docs.batch.is_some() {
return Err("a file is being opened or saved, or designs are being closed: try again".into());
}
let text = p.get("text").and_then(Value::as_str).ok_or("missing `text` (the design's JSON)")?;
let mut doc = solidcraft_engine::io::read_design(text.as_bytes()).map_err(|e| e.to_string())?;
if let Some(name) = p.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()) {
doc.name = name.to_string();
}
let replaced = app.docs.active;
open_tab(app, Session::opened(doc, None))?;
close(app, replaced, true);
let errors = app.session.model.results.iter().filter(|r| r.error.is_some()).count();
let revision = app.session.design_revision();
Ok(json!({"name": app.session.doc.name, "features": app.session.doc.features.len(), "errors": errors, "revision": revision}))
}
/// The host saved the design at `revision` (`document.saved`): it counts as saved from there.
/// Refused when the design has changed since.
pub fn saved(app: &mut SolveApp, p: &Value) -> Result<Value, String> {
let revision = p.get("revision").and_then(Value::as_u64).ok_or("missing `revision`")?;
let now = app.session.design_revision();
if revision != now {
return Err(format!("the design changed since revision {revision} (now {now})"));
}
app.session.mark_saved();
Ok(json!({"revision": revision, "dirty": app.session.is_dirty()}))
}
/// A new, empty design in its own tab. One that is already empty keeps its tab and still
/// takes Units of new designs (#1205): a launch starts on that empty design, so returning
/// here used to leave it in millimetres.
-4
View File
@@ -439,7 +439,6 @@ fn recovered(app: &mut SolveApp, request: &Request) {
let _ = app.session.execute("doc.recovery_discard", &json!({"id": id, "dir": dir}));
}
app.set_status(format!("recovered {}: save it to keep it", request.path), false);
app.say_newer_file();
}
#[cfg(not(target_arch = "wasm32"))]
@@ -1107,9 +1106,6 @@ pub fn tick(app: &mut SolveApp, ctx: &egui::Context) {
},
false,
);
if !request.insert() {
app.say_newer_file();
}
}
request.answer(Ok(made.result));
}
-30
View File
@@ -781,33 +781,3 @@ fn an_opened_design_comes_up_at_the_home_view() {
let _ = std::fs::remove_dir_all(&other);
let _ = std::fs::remove_dir_all(&dir);
}
/// A design a newer SolidCraft saved opens in the window: the status line says so without a
/// dialog, Messages names what wasn't read, and editing that feature says why it can't be edited.
#[test]
fn a_newer_design_opens_with_a_notice_and_no_dialog() {
let mut app = SolveApp::new(Session::default(), Default::default());
let dir = scratch("newer_design");
let mut design = Session::default();
design.execute("solid.box", &json!({"length": 2, "width": 3, "height": 4})).unwrap();
let mut file: serde_json::Value = serde_json::from_str(&design.doc.to_json()).unwrap();
let (reads, newer) = (solidcraft_engine::doc::format::FORMAT, solidcraft_engine::doc::format::FORMAT + 1);
file["format"] = json!(solidcraft_engine::doc::format::format_string(newer));
file["features"].as_array_mut().unwrap().push(json!({"id": 900, "name": "Lattice1", "type": "gyroid_lattice"}));
let path = dir.join("newer.json");
std::fs::write(&path, file.to_string()).unwrap();
app.open_path(&path.to_string_lossy());
let (text, _, error) = app.status.clone().unwrap();
assert_eq!(text, format!("Saved by a newer SolidCraft (format {newer}, this version reads up to {reads}). If something's missing, update."));
assert!(!error);
assert!(app.dialog.is_none());
assert!(app.messages.iter().any(|m| m.text.contains("Lattice1") && m.text.contains("Saving keeps it")));
assert_eq!(app.session.model.state().bodies.len(), 1);
app.edit_feature(900);
let (text, _, error) = app.status.clone().unwrap();
assert!(error && text.contains("gyroid_lattice"), "{text}");
assert!(app.dialog.is_none() && !app.ui.palette_open);
let _ = std::fs::remove_dir_all(&dir);
}
+31
View File
@@ -375,6 +375,36 @@ struct Offscreen<'a> {
ao_samples: u32,
}
#[cfg(all(test, not(target_arch = "wasm32")))]
pub(crate) fn render_ao_offscreen(
scene: GpuScene,
view_proj: [[f32; 4]; 4],
back: [f32; 3],
eye: [f32; 4],
size: u32,
tess: Option<&str>,
ao_radius: f32,
) -> Option<Vec<u8>> {
offscreen(
scene,
None,
view_proj,
back,
eye,
size,
Offscreen {
tess,
product: false,
srgb: false,
analysis: [0.0; 8],
analysis_colors: [0.0; 36],
ssaa: 1.0,
ao_radius,
ao_samples: solidcraft_gpu::view::AO_SAMPLES,
},
)
}
/// [`render_offscreen`] of a scene supersampled `ssaa` times (1: off).
#[cfg(all(test, not(target_arch = "wasm32")))]
pub(crate) fn render_offscreen_ssaa(
@@ -461,6 +491,7 @@ fn offscreen(
pv_key: 0,
pv_slot: none(),
pv_hide: false,
preview_bounds: None,
view_proj,
back,
size_px: [size as f32; 2],
+71 -3
View File
@@ -12,10 +12,14 @@ use crate::{SolveApp, dialogs, help};
mod form;
/// The most an answer keeps, in characters (as `TextEdit::char_limit` counts them, not bytes).
const MAX_ANSWER: usize = 2000;
/// An answer with fewer characters left than this says how many.
const ANSWER_ROOM: usize = 200;
const MAX_BODY: usize = 24_000;
// Room for all three answers and the original bounded diagnostic context.
const MAX_REPORT: usize = 32_000;
// Room for all three answers (up to four bytes a character) and the original bounded diagnostic
// context.
const MAX_REPORT: usize = 3 * 4 * MAX_ANSWER + MAX_BODY + 1000;
const MAX_IMAGE_PIXELS: usize = 16_000_000;
const ISSUE_URL: &str = "https://github.com/tangency-labs/solidcraft/issues/new";
@@ -182,7 +186,7 @@ impl Draft {
}
fn report_body(&self) -> String {
let answer = |text: &str| if text.trim().is_empty() { "Not provided".to_string() } else { limited(text, MAX_ANSWER) };
let answer = |text: &str| if text.trim().is_empty() { "Not provided".to_string() } else { limited_chars(text, MAX_ANSWER) };
let mut report = format!(
"## Actual behavior\n{}\n\n## Reproduction\n{}\n\n## Expected behavior\n{}\n\n",
answer(&self.description),
@@ -299,6 +303,18 @@ fn limited(value: &str, limit: usize) -> String {
value.get(..end).unwrap_or_default().to_string()
}
/// [`truncate`] by characters: an answer's limit counts them, not bytes (#7191).
fn truncate_chars(value: &mut String, limit: usize) {
if let Some((end, _)) = value.char_indices().nth(limit) {
value.truncate(end);
}
}
/// [`limited`] by characters.
fn limited_chars(value: &str, limit: usize) -> String {
value.chars().take(limit).collect()
}
fn block(value: &str) -> String {
value.lines().map(|line| format!(" {line}\n")).collect()
}
@@ -1102,6 +1118,58 @@ mod tests {
assert!(bounded.len() <= MAX_REPORT && bounded.ends_with("[technical details truncated]\n"));
}
/// An answer keeps 2,000 characters in any script (#7191): the limit counted bytes, so one in
/// Chinese kept 666. Wherever it is cut, no character is split.
#[test]
fn issue_report_answers_keep_2000_characters_in_any_script() {
let mut draft = draft_for(&app(), None, None);
draft.body = format!("{}END_CONTEXT", "d".repeat(MAX_BODY - 11));
let mixed = |n: usize| "a現🚀é".chars().cycle().take(n).collect::<String>();
for n in [MAX_ANSWER - 1, MAX_ANSWER, MAX_ANSWER + 1, MAX_ANSWER + 2, MAX_ANSWER + 3, 3000] {
(draft.description, draft.steps, draft.expected) = (mixed(n), "現".repeat(n), "🚀".repeat(n));
let kept = n.min(MAX_ANSWER);
let answers = format!(
"## Actual behavior\n{}\n\n## Reproduction\n{}\n\n## Expected behavior\n{}\n\n",
mixed(kept),
"現".repeat(kept),
"🚀".repeat(kept)
);
let report = draft.report_body();
assert!(report.starts_with(&answers), "{n} characters");
assert!(report.len() <= MAX_REPORT && report.ends_with(&draft.body), "{n} characters: the technical details still fit");
}
}
/// Near the limit, an answer says how many characters are left: a paste past it is visibly
/// cut (#7191).
#[test]
fn issue_report_answer_near_its_limit_says_how_many_characters_are_left() {
let mut h = crate::scenario::Harness::new();
h.app.home.open = false;
h.app.issue_report.draft = Some(draft_for(&h.app, None, None));
h.frames(16);
h.ctx.memory_mut(|m| m.request_focus(egui::Id::new("issue_report_body")));
h.frames(1);
h.frame_with(vec![egui::Event::Paste("故障".repeat(1500))]);
h.frames(4);
let draft = h.app.issue_report.draft.as_mut().unwrap();
assert_eq!(draft.description, "故障".repeat(MAX_ANSWER / 2), "3,000 Chinese characters pasted, 2,000 kept");
// An answer that gets past the limit some other way is cut there too, between characters.
let mixed = |n: usize| "a現🚀é".chars().cycle().take(n).collect::<String>();
(draft.steps, draft.expected) = ("步".repeat(MAX_ANSWER - 150), mixed(MAX_ANSWER + 3));
h.frames(4);
assert_eq!(h.app.issue_report.draft.as_ref().unwrap().expected, mixed(MAX_ANSWER));
for note in ["0 characters left", "150 characters left"] {
assert!(h.widget(note).is_some(), "{note}");
}
h.app.issue_report.draft.as_mut().unwrap().steps = "步".repeat(MAX_ANSWER - 1);
h.frames(4);
assert!(h.widget("1 character left").is_some());
h.app.issue_report.draft.as_mut().unwrap().steps = "步".repeat(MAX_ANSWER - 200);
h.frames(4);
assert!(h.widget("1 character left").is_none() && h.widget("200 characters left").is_none(), "no note far from the limit");
}
#[test]
fn issue_report_requires_an_answer_before_browser_handoff_without_changing_the_design() {
for (size, scale) in [([1600, 1000], 1.0), ([640, 480], 2.0)] {
+7 -1
View File
@@ -17,7 +17,13 @@ fn answer(ui: &mut Ui, label: &str, hint: &str, text: &mut String, id: &str, row
ui_kit::text(ui, tr_label(hint), ui_kit::Text::Note(hint));
let response = ui_kit::text_area(ui, tr_label(label), Id::new(id), text, rows, MAX_ANSWER);
crate::scenario::publish_handle(id, response.rect.center());
super::truncate(text, MAX_ANSWER);
super::truncate_chars(text, MAX_ANSWER);
// What doesn't fit is not added: near the limit, say how much room is left (#7191).
let left = MAX_ANSWER.saturating_sub(text.chars().count());
if left < super::ANSWER_ROOM {
let key = if left == 1 { "ui.report_characters_left_one" } else { "ui.report_characters_left_other" };
ui_kit::text(ui, &crate::i18n::format(key, &[("count", &left.to_string())]), ui_kit::Text::Note(id));
}
response
}
+12 -25
View File
@@ -524,6 +524,11 @@ pub struct SolveApp {
pub(crate) viewport_status_fixture: viewport_status_fixture::Fixture,
/// Compact browser embed: retain model panels and joint controls without application chrome.
pub embedded: bool,
/// Editing embed (#8517): the host page owns the files and the window's chrome. No
/// application bar, no start page, no New/Open/Save/Close or Preferences shortcuts; the
/// ribbon, browser, timeline and properties stay, and the host reads and replaces the
/// design through `document.export` / `document.import`.
pub embed_editor: bool,
pub session: Session,
pub ui: UiState,
pub(crate) shell: shell::State,
@@ -930,6 +935,7 @@ impl SolveApp {
fit_waits: 0,
flat_view: Default::default(),
embedded: false,
embed_editor: false,
home: home::HomeState::default(),
space_mouse: Default::default(),
file_load: file_load::FileLoad::default(),
@@ -2183,12 +2189,6 @@ impl SolveApp {
self.edit_sketch(id);
return;
}
// Nothing here knows what it holds: it says so, and waits for the build that does.
if let FeatureKind::NewerFileFeature { raw } = &f.kind {
let why = raw.why();
self.set_status(why, true);
return;
}
let Some(idx) = self.session.doc.feature_index(id) else { return };
let marker = self.finish_sketch_for_edit();
self.tool = None;
@@ -2493,23 +2493,6 @@ impl SolveApp {
self.status_undo = false;
}
/// Tell the user when the open design came from a newer SolidCraft: the notice on the status
/// line, and what this version couldn't read in the messages.
pub(crate) fn say_newer_file(&mut self) {
let Some(file) = self.session.doc.newer_file.clone() else { return };
for what in &file.not_understood {
let text = i18n::format("document.newer_file_not_read", &[("what", what)]);
self.messages.push(&text, false, now_ms());
}
let text = if file.format <= file.reads_up_to && !file.unknown_changes.is_empty() {
let change = file.unknown_changes.first().map_or("", String::as_str);
i18n::format("document.newer_file_change", &[("format", &file.format.to_string()), ("change", change)])
} else {
i18n::format("document.newer_file", &[("format", &file.format.to_string()), ("reads", &file.reads_up_to.to_string())])
};
self.set_status(text, false);
}
pub fn open_path(&mut self, path: &str) {
match documents::reuse_path(self, path) {
Ok(true) => return,
@@ -2527,7 +2510,6 @@ impl SolveApp {
self.cam.set_view(StandardView::Iso);
self.fit_view();
stand_up::offer(self, path, &[], Some(&r));
self.say_newer_file();
}
}
@@ -3128,6 +3110,11 @@ impl SolveApp {
self.ui.show_browser = true;
self.ui.show_timeline = true;
}
if self.embed_editor {
// The host shows one design and has its own way to start one.
self.ui.layout = shell::LayoutMode::Docked;
self.home.open = false;
}
self.ui.layout.publish(ui.ctx());
self.preferences.dialog_buttons.publish(ui.ctx());
self.preferences.toast_place.publish(ui.ctx());
@@ -3136,7 +3123,7 @@ impl SolveApp {
self.poll_deferred(ui.ctx());
self.poll_delete_check(ui.ctx());
timeline::tick_playback(self, ui.ctx());
if !self.embedded {
if !self.embedded && !self.embed_editor {
toolbar::app_bar(self, ui);
}
issue_report::CadInput::block(self, ui, report_input);
+18 -1
View File
@@ -748,6 +748,23 @@ pub(crate) fn handle(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect, resp: &e
// floating shell card that disables the underlying egui response.
let captured_orbit = app.viewport.orbit_from.is_some() && button.is_some();
let dragging = resp.dragged() || (inside && button.is_some()) || captured_orbit;
// A middle or right drag keeps the mode it started with until its button is released, as a
// left one does (`primary_nav`): letting go of Shift partway through a Shift+middle orbit
// doesn't turn the rest into a pan (#9557). Off in Preferences, the keys held decide on every
// frame, as before.
let button_mode = match button {
Some(b) if dragging && app.viewport.axis_drag.is_none() => {
let kept = app.viewport.button_nav.filter(|(held, _)| *held == b && app.preferences.nav_drag_keeps_mode);
let mode = kept.map_or_else(|| crate::prefs::nav_mode(app, b, mods), |(_, mode)| mode);
app.viewport.button_nav = Some((b, mode));
mode
}
Some(_) => None,
None => {
app.viewport.button_nav = None;
None
}
};
// Without orbit (a flat page), bindings that orbit do nothing.
let allowed = |m: NavMode| caps.orbit || m != NavMode::Orbit;
let motion = modifier_motion(app, ui, inside, hover, mods, picking).filter(|(m, _)| allowed(*m));
@@ -756,7 +773,7 @@ pub(crate) fn handle(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect, resp: &e
.or_else(|| {
if dragging && app.viewport.axis_drag.is_none() {
match button {
Some(b) => crate::prefs::nav_mode(app, b, mods),
Some(_) => button_mode,
// A press held still that opened Select Other stays its own: egui calls it a
// drag after 0.8 s, and moving to a row must not turn the view under the list.
None if primary_down && !crate::select_other::is_open(app) => primary_nav,
+4
View File
@@ -1059,6 +1059,10 @@ preference_settings! {
free_orbit_upright: bool = false, PAGE_NAVIGATION => check_tr("nav.free_orbit_upright.label");
/// Off: a right drag that the mouse mapping would orbit leaves the camera alone (#6492).
right_drag_orbits: bool = true, PAGE_NAVIGATION => check_tr("nav.right_drag_orbits.label");
/// A middle or right drag keeps the mode it started with until its button is released, as a
/// left drag with the mapping's keys does: letting go of Shift partway through a Shift+middle
/// orbit doesn't turn it into a pan (#9557). Off: the keys held decide on every frame, as before.
nav_drag_keeps_mode: bool = true, PAGE_NAVIGATION => check_tr("nav.drag_keeps_mode.label");
/// View orientation widget: original signed axes (default) or compact cube.
navigator_style: NavigatorStyle = NavigatorStyle::default(), PAGE_NAVIGATION;
/// The custom view gizmo's colours: the cube's (themed) or the body's own (modeled).
+2
View File
@@ -110,6 +110,8 @@ fn mouse(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
let response = setting::right_drag_orbits(app, ui).backing_on_hover_text(crate::i18n::tr("nav.right_drag_orbits.help"));
crate::scenario::publish_target("prefs:right_drag_orbits", response.rect);
}
let keeps = setting::nav_drag_keeps_mode(app, ui).backing_on_hover_text(crate::i18n::tr("nav.drag_keeps_mode.help"));
crate::scenario::publish_target("prefs:nav_drag_keeps_mode", keeps.rect);
setting::radial_context_menu(app, ui).backing_on_hover_text(crate::i18n::tr("prefs.navigation.radial_help"));
let label = app.tr("Middle double-click");
let width = ui_kit::ContentWidth::label(ui, label);
+15
View File
@@ -1262,6 +1262,21 @@ mod tests {
}
}
/// #7520: a Hole's preview leaves a hidden body out, drawn nor drilled, as OK does; before,
/// it replaced the hidden body with a drilled one while the dialog was open.
#[test]
fn a_hole_preview_leaves_hidden_bodies_out() {
let mut s = Session::default();
s.execute("solid.box", &json!({"length": 40, "width": 40, "height": 5, "body_name": "Plate"})).unwrap();
s.execute("solid.box", &json!({"corner": [0, 0, 5], "length": 40, "width": 40, "height": 20, "operation": "new", "body_name": "Block"}))
.unwrap();
s.execute("browser.visibility", &json!({"items": ["Block"], "visible": false})).unwrap();
let cmd = ("solid.hole".to_string(), json!({"position": [20, 20, 0], "diameter": 5, "depth": 10}));
let built = compute(&s, &[cmd], colors()).unwrap();
assert!(built.error.is_none(), "{:?}", built.error);
assert_eq!(built.replaced, vec!["Plate".to_string()]);
}
/// A plate in its own component, stood up by its placement (an imported part set upright)
/// and edited with that component active: the extrusion's tool is drawn where the plate is
/// shown, on its top face, not at right angles to it in the component's own frame.
+1
View File
@@ -2241,6 +2241,7 @@ fn paint_product(app: &mut SolveApp, ui: &mut egui::Ui, rect: egui::Rect, camera
pv_key: 0,
pv_slot: slot,
pv_hide: false,
preview_bounds: None,
view_proj: product_projection(camera, radius, rect, image).to_f32(),
back: camera.back().to_f32(),
size_px: [rect.width() * ppp, rect.height() * ppp],
@@ -304,7 +304,6 @@ pub(super) fn same_model_source(app: &SolveApp, document: &std::sync::Weak<solid
}};
}
compare_authoring_fields!(
newer_file,
format,
format_pending,
name,

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